From bafecb7feab30939e83861418e8b42ca25042776 Mon Sep 17 00:00:00 2001 From: baobao Date: Fri, 16 Oct 2026 11:27:19 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BD=91=E6=A0=BC=E7=AD=96=E7=95=A5=EF=BC=9A?= =?UTF-8?q?=E5=8D=96=E5=87=BA=E6=8C=89=E6=A1=A3=E4=BD=8D=E4=BD=99=E9=A2=9D?= =?UTF-8?q?=E4=B8=8E=E5=AE=9E=E9=99=85=E7=BD=91=E6=A0=BC=E6=8C=81=E4=BB=93?= =?UTF-8?q?=E5=B0=81=E9=A1=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一、自然成交反例 威胜信息原网格档位1000股,首卖700后恢复300股。第二次穿越仍按配置卖700股,实际侵入400股底仓。原代码只以配置档量和总可卖量封顶,遗漏档位余额和实际ADD持仓。 二、修复与验证 增加两项数量上限,保持原穿越条件、参数、价格、资金上限和最小申报要求。四组新增回归原实现均失败,修复后45个策略用例及44批配置隔离禁网回归全部通过。未成交和部分成交仍按原流程恢复余额。 三、实际成交边界 真实七笔成交合计1400股、32933.49元,批次和指令已逐笔核验。保留实际成交,不回填底仓或反向交易,已取消策略不恢复。仅部署155模拟仓,188不动。关联桥台账070,规格与部署判收另同步。 --- DEVLOG.md | 7 ++++ app/services/strategy_runner.py | 7 +++- scripts/run_tests.py | 2 +- scripts/test_batch17_units.py | 74 +++++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 3 deletions(-) diff --git a/DEVLOG.md b/DEVLOG.md index fdd9e4d..fe85517 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -1969,3 +1969,10 @@ PMS 页面需要重新登录;基座页面版本与研究字段已实机核对 15072f7 的 18 个专项用例、44 批本地和候选镜像禁网回归全部通过。10:44:28 在 155 重建四容器,镜像 7003b81c79000cb61633d94a6529253a412de9c2cbc5522e296aea71a0e2f337,代码指纹 cf0a57a03a76。15 项参数与三票修复账本不变,七条暂停指令仍 CANCELLED,审计 122+1。健康正常、QMT ONLINE、resync=0、对账差异为空。188 未操作。 14 笔昨日修复后自然成交已逐笔判收:数量、父指令进度与批次毛盈亏未双记。既有逐笔舍入一分钱差异单列。修复后的自然确认、开盘后新评析传播时延仍待事件,不能以本地通过替代。详细证据、备份与绑定挂载下的回退步骤见四仓同步交接 10:50 节。 + + +## 2026-10-16 · 网格卖出按实际余额封顶 + +11:15 自然事件核验发现威胜信息第二次网格卖单越界。原档位 1,000 股,首卖 700 后正确恢复 300;第二次仍按配置卖 700,连同 400 股底仓卖出。七笔成交据实入账,快照追平后对账为空。原策略 11:16:01 已被清场取消。实际成交保留,不回填或反向交易。 + +修复 strategy_runner._eval_grid,将数量同时限在配置档量、档位余额、实际 ADD 与可卖量内。保持穿越信号、价格、档距、资金上限和最小申报规则。四组新增反例在原实现均失败,批次 17 增至 45 例;完整禁网与候选镜像回归通过后仅部署 155。关联桥台账 070。部署与成本核验结果待追加。 diff --git a/app/services/strategy_runner.py b/app/services/strategy_runner.py index feeec87..cd29dfd 100644 --- a/app/services/strategy_runner.py +++ b/app/services/strategy_runner.py @@ -437,10 +437,13 @@ def _eval_grid(st, pos, day, now, ctx): if cur > last: k = last if k in filled and avail >= LOT and actual_add >= LOT: - q = min(per_lot, _round_lot(avail)) + info = filled[k] + # 部分成交恢复的档位可能小于配置档量;不能借底仓补齐一档。 + # 同时守住实际网格股和可卖量,最小申报口径继续沿用下面的检查。 + q = min(per_lot, _round_lot(info.get("qty")), + _round_lot(actual_add), _round_lot(avail)) if q >= mlot: # 科创板部分卖低于 200 股不合法, 量不足先不推进 state["last_band"] = last + 1 - info = filled[k] return {"side": "sell", "action": A_SELL, "qty": q, "leg": f"grid_sell:{k}", "grid_sell_idx": k, "reason": f"网格卖: 现价 {price} 涨破档{k}(买价 {info.get('price')}), 卖 {q} 股"} diff --git a/scripts/run_tests.py b/scripts/run_tests.py index 1a089bb..d7850cf 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -43,7 +43,7 @@ 五阶段判定与逐票阶段返回/note 约定与冷却推导/ 接力判定/clear_buypause/编排冒烟(dry_run 滴水不写/ 名额/暂停买入/接力全链)/真跑写一跳摘要而预演不写/ - 判分脚本聚合与对照分组 (41 例) + 判分脚本聚合与对照分组、网格余额封顶与恢复 (45 例) test_batch18_units.py 登录与权限: 角色判定/会话票签验/bshop 返回解析/ 接口鉴权(维护类归管理员) (16 例) test_batch19_units.py 2026-08-28 审查修复回归: 科创板最小申报统一口径/ diff --git a/scripts/test_batch17_units.py b/scripts/test_batch17_units.py index 30bb3db..a9d4e41 100644 --- a/scripts/test_batch17_units.py +++ b/scripts/test_batch17_units.py @@ -836,6 +836,80 @@ def _(): assert rep._fmt_pct(None) == "—" and rep._fmt_pct(0.1) == "+10.00%" +def _grid_sell_fixture(qty=300, avail=700, add=300, per_lot=700, code="688100.SH"): + from app.services import strategy_runner as srun + prm = {"lower": 9.0, "upper": 11.0, "center": 10.0, "step_pct": 0.02, + "per_lot": per_lot, "max_capital": 50000} + band = srun._band(srun._grid_levels(prm), 10.5) - 1 + st = {"strategy_id": "grid-test", "type": "GRID", "ts_code": code, "params": prm} + state = {"last_band": band, "filled_levels": {str(band): {"price": 9.8, "qty": qty}}, + "invested": 9.8 * qty} + pos = {"avail_qty": avail, "add_qty": add} + ctx = {"state": state, "notes": [], "buy_paused": False} + return srun, st, state, pos, ctx, band + + +@case("[网格余额] 剩余300股、配置700股时只卖300股,完整档保持") +def _(): + for qty, avail, add, expected in [(300, 700, 300, 300), (700, 1100, 700, 700)]: + sr, st, state, pos, ctx, band = _grid_sell_fixture(qty, avail, add) + d = sr._eval_grid(st, pos, {"price": 10.5}, None, ctx) + assert d and d["qty"] == expected, d + assert state["last_band"] == band + 1 + + +@case("[网格余额] 原1000股首卖700后恢复300,再触发不卖底仓") +def _(): + from unittest.mock import patch + sr, st, state, pos, ctx, band = _grid_sell_fixture(1000, 1400, 1000) + first = sr._eval_grid(st, pos, {"price": 10.5}, None, ctx) + assert first["qty"] == 700 + sr._apply_grid_state(state, first) + state["pending"] = {"iid": "i1", "leg": first["leg"], "grid_sell_idx": band, + "grid_restore": first["_grid_restore"]} + with patch.object(sr, "_pending_terminal", return_value=(True, {"exec_qty": 700})): + assert sr._reconcile(st, state, pos) + assert state["filled_levels"][str(band)]["qty"] == 300 + assert abs(state["invested"] - 2940) < 1e-6 + state["last_band"] = band # 正常再次穿越同档。 + second = sr._eval_grid(st, {"avail_qty": 700, "add_qty": 300}, {"price": 10.5}, None, ctx) + assert second and second["qty"] == 300, second + assert first["qty"] + second["qty"] == 1000 + assert 1400 - first["qty"] - second["qty"] == 400 # 底仓完整保留。 + + +@case("[网格余额] 可卖不足、档位零股与科创板小余额不突破持仓") +def _(): + for qty, avail, add, code, expected in [ + (300, 200, 300, "688100.SH", 200), + (350, 1000, 500, "688100.SH", 300), + (100, 700, 100, "688100.SH", None), + (100, 700, 100, "600000.SH", 100), + (0, 700, 500, "688100.SH", None), + (300, 700, 0, "688100.SH", None)]: + sr, st, state, pos, ctx, band = _grid_sell_fixture(qty, avail, add, code=code) + d = sr._eval_grid(st, pos, {"price": 10.5}, None, ctx) + assert (d["qty"] if d else None) == expected, (qty, avail, add, d) + if qty in (100, 0) and code == "688100.SH": + assert state["last_band"] == band + + +@case("[网格余额] 未成交和部分成交继续按实际剩余恢复档位") +def _(): + from unittest.mock import patch + for executed, remaining in [(0, 300), (200, 100), (300, 0)]: + sr, st, state, pos, ctx, band = _grid_sell_fixture() + d = sr._eval_grid(st, pos, {"price": 10.5}, None, ctx) + assert d["qty"] == 300, d + sr._apply_grid_state(state, d) + state["pending"] = {"iid": "i1", "leg": d["leg"], "grid_sell_idx": band, + "grid_restore": d["_grid_restore"]} + with patch.object(sr, "_pending_terminal", return_value=(True, {"exec_qty": executed})): + assert sr._reconcile(st, state, pos) + assert state["filled_levels"].get(str(band), {}).get("qty", 0) == remaining + assert abs(state["invested"] - remaining * 9.8) < 1e-6 + + def main(): from test_wiring import install_fakes install_fakes() # 其余外部依赖也必须有默认内存桩。