网格策略:卖出按档位余额与实际网格持仓封顶

一、自然成交反例
威胜信息原网格档位1000股,首卖700后恢复300股。第二次穿越仍按配置卖700股,实际侵入400股底仓。原代码只以配置档量和总可卖量封顶,遗漏档位余额和实际ADD持仓。

二、修复与验证
增加两项数量上限,保持原穿越条件、参数、价格、资金上限和最小申报要求。四组新增回归原实现均失败,修复后45个策略用例及44批配置隔离禁网回归全部通过。未成交和部分成交仍按原流程恢复余额。

三、实际成交边界
真实七笔成交合计1400股、32933.49元,批次和指令已逐笔核验。保留实际成交,不回填底仓或反向交易,已取消策略不恢复。仅部署155模拟仓,188不动。关联桥台账070,规格与部署判收另同步。
This commit is contained in:
baobao 2026-10-16 11:27:19 +08:00
parent 8491381d4a
commit bafecb7fea
4 changed files with 87 additions and 3 deletions

View File

@ -1969,3 +1969,10 @@ PMS 页面需要重新登录;基座页面版本与研究字段已实机核对
15072f7 的 18 个专项用例、44 批本地和候选镜像禁网回归全部通过。10:44:28 在 155 重建四容器,镜像 7003b81c79000cb61633d94a6529253a412de9c2cbc5522e296aea71a0e2f337,代码指纹 cf0a57a03a76。15 项参数与三票修复账本不变,七条暂停指令仍 CANCELLED,审计 122+1。健康正常、QMT ONLINE、resync=0、对账差异为空。188 未操作。 15072f7 的 18 个专项用例、44 批本地和候选镜像禁网回归全部通过。10:44:28 在 155 重建四容器,镜像 7003b81c79000cb61633d94a6529253a412de9c2cbc5522e296aea71a0e2f337,代码指纹 cf0a57a03a76。15 项参数与三票修复账本不变,七条暂停指令仍 CANCELLED,审计 122+1。健康正常、QMT ONLINE、resync=0、对账差异为空。188 未操作。
14 笔昨日修复后自然成交已逐笔判收:数量、父指令进度与批次毛盈亏未双记。既有逐笔舍入一分钱差异单列。修复后的自然确认、开盘后新评析传播时延仍待事件,不能以本地通过替代。详细证据、备份与绑定挂载下的回退步骤见四仓同步交接 10:50 节。 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。部署与成本核验结果待追加。

View File

@ -437,10 +437,13 @@ def _eval_grid(st, pos, day, now, ctx):
if cur > last: if cur > last:
k = last k = last
if k in filled and avail >= LOT and actual_add >= LOT: 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 股不合法, 量不足先不推进 if q >= mlot: # 科创板部分卖低于 200 股不合法, 量不足先不推进
state["last_band"] = last + 1 state["last_band"] = last + 1
info = filled[k]
return {"side": "sell", "action": A_SELL, "qty": q, "leg": f"grid_sell:{k}", return {"side": "sell", "action": A_SELL, "qty": q, "leg": f"grid_sell:{k}",
"grid_sell_idx": k, "grid_sell_idx": k,
"reason": f"网格卖: 现价 {price} 涨破档{k}(买价 {info.get('price')}), 卖 {q} 股"} "reason": f"网格卖: 现价 {price} 涨破档{k}(买价 {info.get('price')}), 卖 {q} 股"}

View File

@ -43,7 +43,7 @@
五阶段判定与逐票阶段返回/note 约定与冷却推导/ 五阶段判定与逐票阶段返回/note 约定与冷却推导/
接力判定/clear_buypause/编排冒烟(dry_run 滴水不写/ 接力判定/clear_buypause/编排冒烟(dry_run 滴水不写/
名额/暂停买入/接力全链)/真跑写一跳摘要而预演不写/ 名额/暂停买入/接力全链)/真跑写一跳摘要而预演不写/
判分脚本聚合与对照分组 (41 例) 判分脚本聚合与对照分组、网格余额封顶与恢复 (45 例)
test_batch18_units.py 登录与权限: 角色判定/会话票签验/bshop 返回解析/ test_batch18_units.py 登录与权限: 角色判定/会话票签验/bshop 返回解析/
接口鉴权(维护类归管理员) (16 例) 接口鉴权(维护类归管理员) (16 例)
test_batch19_units.py 2026-08-28 审查修复回归: 科创板最小申报统一口径/ test_batch19_units.py 2026-08-28 审查修复回归: 科创板最小申报统一口径/

View File

@ -836,6 +836,80 @@ def _():
assert rep._fmt_pct(None) == "—" and rep._fmt_pct(0.1) == "+10.00%" 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(): def main():
from test_wiring import install_fakes from test_wiring import install_fakes
install_fakes() # 其余外部依赖也必须有默认内存桩。 install_fakes() # 其余外部依赖也必须有默认内存桩。