diff --git a/DEVLOG.md b/DEVLOG.md index 2f1e55a..f17efdb 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -172,6 +172,68 @@ PMS_UNAVAILABLE 三个值,而 `pms_advisor._today_exit_verdict` 第 102 行明 --- +## 2026-08-06 · 收盘后四条只读诊断,外加一个由它们暴露出来的窗口末日追高 + +**四条诊断的结论** + +1. **买入信号 67 条,落在候选池里的只有 1 只**(`300570.SZ`)。 + `intraday_signals:2026-08-06` 共 73 条,动作分布 BUY 67 / SELL 6。 + 说明**决策系统的盘中转多判断与上游选股计划的排序是两套几乎不相干的视角**—— + 互补而不是互相验证。所以「必须同时在候选池里」这条口径在 `PMS_PLAN_TOP_N=30` + 的当前参数下,插队排序的增量只有 1/67,基本无效。 + 顺带日志说破另一件事:`主榜正好吃满 top=300 (打分池 440)`,上游还有 140 只没拿到。 + 两条改法(调大 `PMS_PLAN_TOP_N`,或让只被 top_n 纯排序截断切掉的信号票凭信号捞回来) + 记在下面「还欠着什么」里,等明天有真实留痕再定。 + +2. **`trading_buy_plan` 最新一行是 07-30,七天没动。** 四个状态全是旧数据 → + 老的建仓链路是死腿,**不存在两套系统同时买的风险**,ENTRY_GATE 关不关都行。 + +3. **悬项四定案:不是口径差。** 差额 564(08-05)→ 51(08-06 盘中)→ **4.00 元**(收盘后), + 递减到接近零,就是取数时点差加收盘价精度差(4 元对 177,561 的持仓市值是 0.002%)。 + **但「`cash_avail` 里 `+ sell_return_today` 双算」这个假设今天没被验证,只是没被触发** + ——今天零卖出成交,那个加法加的是 0。真正的验证要等有卖出成交的那天。 + +4. **悬项八有结论:`trade_no` 是随机串**(`T-SHADOW-420911c4b8`),不是确定式, + S3 收尾函那个阻断项**不闭环**。后果是幂等:同一笔成交若被重推,随机串识别不出来。 + 得确认回放那侧的去重键用的是 `trade_no` 还是单调的 `seq`。 + +**由诊断暴露出来的问题:窗口末日会强制追高,而自主买入不该这样** + +`ws_smoke inbox` 里 `000063.SZ` 那五笔成交是今天 **14:46**(时间戳换算),1700 股, +成交价 34.71 到 34.76。而交接信写的是「现价高于买入区间上沿,**不追**,窗口今天到期」。 +判了不追,最后还是买了——走的是 `exec_timing.hard_gate` 买入分支的窗口末日强制完成, +而 `hard_gate` 在 `exec_advisor.decide` 里是**先于**咨询决策系统跑的, +14:45 之后根本不问择时,直接按 现价×1.002 追进去。 + +那一笔是命令驱动的(`INS_20260804_000063SZ_OPEN_003`),**强制完成是对的**: +用户下过「投这么多」的命令,到期必须完成。但同一段代码等新建仓上线就会作用在自主提议上, +那时它与「可以接受买不上」直接冲突,而且是无人值守。 + +更要紧的是 `window_verdict` 早就把口径写死了:`"PARTIAL" if is_command else "EXPIRED"` +——**自主类窗口耗尽直接作废**。既然到期就作废,就不该在到期当天先被强制完成一遍。 +两处本来是矛盾的,这次是把它们对齐,不是新增策略。 + +**改法** +- `exec_timing.hard_gate` 新增 `is_command`,**必填、故意不给默认值**——漏传立刻 + `TypeError`,不许静默按某一侧走。买入的窗口末日强制完成只对命令生效,自主返回等待 + 并写明「到期作废」。 +- **卖出侧一个字没改**,两种来源都照常兜底。这不是漏改:买入的强制完成是「多背一份风险」, + 卖出的是「少背一份风险」;自主减仓若也到期作废,那是把该降的风险留在账上。 + 宁可买不上,但不能卖不掉。 +- `exec_timing.decide` 与 `exec_advisor.decide` 的 `is_command` 默认 `True` + (命令口径 = 改动前的行为),`executor.run_tick` 从指令的 `progress.is_command` 显式传下来。 +- 影响面说破:**已有的 FILL / ADD / DCA 自主买单也跟着改**——从前也会在末日强制完成, + 现在到期作废。「回踩补足」在末日追高买本身就自相矛盾,所以对它们同样是修正, + 但确实是既有行为的改变。 + +**动了哪些文件** +`app/core/exec_timing.py`、`app/services/exec_advisor.py`、`app/services/executor.py`; +测试 `scripts/test_batch12_units.py` 加 6 例(含一条「executor 真的把 is_command 传下去了」 +的签名与源码检查,防这道分岔形同虚设),`scripts/test_batch11_units.py` 的夹具补一个参数。 +全量单测 **ALL SUITES PASS,464 例**。 + +--- + ## 2026-08-06 · 决策系统的买入信号一直被 PMS 丢在门口,已接上 **做了什么** diff --git a/app/core/exec_timing.py b/app/core/exec_timing.py index dd44d89..bf7483f 100644 --- a/app/core/exec_timing.py +++ b/app/core/exec_timing.py @@ -101,7 +101,7 @@ def slice_qty(quota: int, slices: int = 1, lot: int = LOT) -> list: def hard_gate(*, side: str, now, day: dict, params: dict, is_last_day: bool, - fired_today: int = 0, quota: int = 0): + is_command: bool, fired_today: int = 0, quota: int = 0): """事实性检查 (实现A/B 共用的前置)。命中返回决策 dict, 未命中返回 None。 包含: 无价/停牌/配额尽/非时段/一字板/买入不追高(当日涨幅)/14:45 兜底与「兜底后 @@ -110,6 +110,9 @@ def hard_gate(*, side: str, now, day: dict, params: dict, is_last_day: bool, 与拆分前 decide() 的唯一语义差别: 卖出的 14:45 兜底现在排在「避开开盘 30 分钟」 之前判。两者只在 eod_force_time 被改到 10:00 之前这种病态配置下才会同时成立, 且真到那时也该是兜底赢 —— 强制完成这件事永远归 PMS 自己管。 + + **is_command 是必填的, 故意不给默认值** (2026-08-06): 漏传立刻 TypeError, + 而不是悄悄按某一侧的口径走。它决定窗口末日要不要强制完成 —— 见下面买入分支的说明。 """ now_min = hm_to_min(now) price = float(day.get("price") or 0) @@ -131,6 +134,10 @@ def hard_gate(*, side: str, now, day: dict, params: dict, is_last_day: bool, return out(ACT_WAIT, "非交易时段") if side == "sell": + # **卖出侧不按命令/自主分岔, 一律兜底。** 这不是漏改, 是方向不同: + # 买入的强制完成是"多背一份风险", 卖出的强制完成是"少背一份风险"。 + # 自主减仓 (保垫减仓、信号转来的清仓) 若也到期作废, 那是把该降的风险留在账上 —— + # 宁可买不上, 但不能卖不掉。减持方向不设门槛这条口径, 在这里同样成立。 if day.get("limit_down") and not is_last_day: return out(ACT_SKIP, "跌停一字板, 当日跳过顺延") if now_min >= eod_min: @@ -147,9 +154,28 @@ def hard_gate(*, side: str, now, day: dict, params: dict, is_last_day: bool, return out(ACT_STOP, f"当日涨幅 {float(dayup):.2%} > {cap:.0%}, 停止买入 (不追高)") premium = round(2 - disc, 4) # 买入兜底与卖出对称: 0.998 → 1.002 if now_min >= eod_min: - if is_last_day: + # 窗口末日的强制完成**只对命令驱动生效** (2026-08-06)。 + # + # 由来: 2026-08-06 实机, 000063.SZ 那条建仓指令连日判「现价高于买入区间上沿, + # 不追」, 到窗口末日 14:46 照样按 现价×1.002 追进去 1700 股。那一笔是命令驱动的 + # (用户下过「投这么多」的命令, 到期必须完成, 强制是对的), 但同一段代码等新建仓 + # 上线就会作用在自主提议上 —— 系统自己挑的票, 连着三天判"不追", 第三天下午 + # 无人值守地追进去, 与「可以接受买不上」那条原则直接冲突。 + # + # 更要紧的是: window_verdict 早就把口径写死了 —— + # "PARTIAL" if is_command else "EXPIRED" (自主类窗口耗尽直接作废) + # 既然自主的到期就作废, 就不该在到期当天先被强制完成一遍。这两处本来是矛盾的, + # 这次是把它们对齐, 不是新增策略。 + # + # 影响面要说破: 已有的 FILL / ADD / DCA 自主买单也跟着改 —— 它们从前也会在 + # 末日强制完成, 现在到期作废。「回踩补足」在末日追高买本身就自相矛盾, 所以 + # 这对它们同样是修正, 但确实是既有行为的改变。 + if is_last_day and is_command: return out(ACT_FIRE, f"窗口末日 {_fmt(eod_min)} 强制完成: 限价 = 现价×{premium}", limit=round(price * premium, 2), forced=True) + if is_last_day: + return out(ACT_WAIT, f"窗口末日 {_fmt(eod_min)} 之后不追买 —— " + f"自主买入不做强制完成, 本条到期作废 (可以接受买不上)") return out(ACT_WAIT, f"{_fmt(eod_min)} 后不新开买单, 顺延次日") return None @@ -157,15 +183,19 @@ def hard_gate(*, side: str, now, day: dict, params: dict, is_last_day: bool, def decide(*, side: str, now, day: dict, params: dict, is_last_day: bool, - fired_today: int = 0, quota: int = 0) -> dict: + is_command: bool = True, fired_today: int = 0, quota: int = 0) -> dict: """实现B: 单条指令在「此刻」该不该出手 (= 硬闸 + 内置保守看法)。 day: {price, vwap, halted, limit_up, limit_down, day_chg_from_open, support} params: {sell_avoid_open_min, buy_halt_dayup, eod_force_time, eod_force_discount} 返回 {"action", "qty_hint", "limit_price", "reason", "forced"} + + is_command 默认 True (命令口径 = 2026-08-06 之前的行为), 只影响买入的窗口末日强制完成, + 见 hard_gate 里那段说明。生产路径由 exec_advisor 从指令的 progress.is_command 显式传下来, + 这里给默认值只是为了让既有单测与临时试算不必逐个改口径。 """ h = hard_gate(side=side, now=now, day=day, params=params, is_last_day=is_last_day, - fired_today=fired_today, quota=quota) + is_command=is_command, fired_today=fired_today, quota=quota) if h is not None: return h diff --git a/app/services/exec_advisor.py b/app/services/exec_advisor.py index 4f6756a..87667a4 100644 --- a/app/services/exec_advisor.py +++ b/app/services/exec_advisor.py @@ -73,7 +73,7 @@ def _post(url: str, payload: dict, timeout: int) -> dict: def decide(*, side: str, action: str, ts_code: str, now, day: dict, params: dict, - is_last_day: bool, fired_today: int = 0, quota: int = 0, + is_last_day: bool, is_command: bool = True, fired_today: int = 0, quota: int = 0, pos: dict = None, tdays_left=None, prog: dict = None) -> dict: """择时判定统一入口 (executor.run_tick 的唯一调用点)。 @@ -87,13 +87,13 @@ def decide(*, side: str, action: str, ts_code: str, now, day: dict, params: dict """ if not available(): d = et.decide(side=side, now=now, day=day, params=params, is_last_day=is_last_day, - fired_today=fired_today, quota=quota) + is_command=is_command, fired_today=fired_today, quota=quota) d["source"] = IMPL_B return d left = max(0, int(quota) - int(fired_today)) h = et.hard_gate(side=side, now=now, day=day, params=params, is_last_day=is_last_day, - fired_today=fired_today, quota=quota) + is_command=is_command, fired_today=fired_today, quota=quota) if h is not None: h["source"] = "guard" # 本地事实性检查 (配额/兜底等), 与实现无关 return h @@ -116,7 +116,7 @@ def decide(*, side: str, action: str, ts_code: str, now, day: dict, params: dict note = f"研判动作无法识别: {advice.get('verdict')!r}" d = et.decide(side=side, now=now, day=day, params=params, is_last_day=is_last_day, - fired_today=fired_today, quota=quota) + is_command=is_command, fired_today=fired_today, quota=quota) d["source"] = f"B(实现A不可用: {note})" return d diff --git a/app/services/executor.py b/app/services/executor.py index aaba1a5..9cd377c 100644 --- a/app/services/executor.py +++ b/app/services/executor.py @@ -162,9 +162,13 @@ def run_tick(*, now=None, dry_run: bool = False) -> dict: # =A 时先过硬闸 (配额/兜底等 PMS 自留地), 再委托决策系统, 不可用退实现B。 # 咨询结论会写进 prog["exec_advice"], 随下面既有的 update_instruction 落表; # dry_run 传 None —— 只算不落库, 试算不该占用/刷新研判缓存。 + # is_command 必须一路传到 hard_gate: 窗口末日的强制完成只对命令驱动生效, + # 自主买入到期作废 (与 window_verdict 的口径对齐, 见 exec_timing 里那段说明)。 d = exec_advisor.decide(side=side, action=ins.get("action"), ts_code=code, now=now, day=day_ctx, params=exec_prm, - is_last_day=is_last, fired_today=fired_today, + is_last_day=is_last, + is_command=bool(prog.get("is_command")), + fired_today=fired_today, quota=quota, pos=pos, tdays_left=tdays_left, prog=(None if dry_run else prog)) diff --git a/scripts/run_tests.py b/scripts/run_tests.py index 8d4a1a7..ad13787 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -17,9 +17,9 @@ test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 (48 例) test_batch11_units.py 择时实现A: 本地检查等价/应答折算/缓存冷却/退B (24 例) test_batch12_units.py 自主新建仓: 选票与滚动扣减/硬数字裁剪/漂移/预算/ - 转多信号插队与留痕 (33 例) + 转多信号插队与留痕/窗口末日只对命令强制完成 (39 例) test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (58 例) - 共 458 例 + 共 464 例 任一子集失败即整体失败 (退出码 1)。 """ import os diff --git a/scripts/test_batch11_units.py b/scripts/test_batch11_units.py index 6275b9a..269d4aa 100644 --- a/scripts/test_batch11_units.py +++ b/scripts/test_batch11_units.py @@ -48,9 +48,13 @@ def day(**kw): return d -def _dec(side="buy", now="10:30", d=None, quota=1000, fired=0, last=False): +def _dec(side="buy", now="10:30", d=None, quota=1000, fired=0, last=False, cmd=True): + # cmd 默认 True = 命令口径 = 2026-08-06 之前的行为, 所以下面这批等价性用例的判据没变。 + # 那天给窗口末日的强制完成分了岔 (命令照旧强制完成, 自主到期作废), hard_gate 的 + # is_command 是**必填**的 —— 漏传立刻 TypeError, 不许静默按某一侧走, 所以这里补上。 + # 分岔本身的行为在 test_batch12_units.py 的「窗口末日」那组里锁着。 return dict(side=side, now=now, day=d or day(), params=PRM, - is_last_day=last, fired_today=fired, quota=quota) + is_last_day=last, is_command=cmd, fired_today=fired, quota=quota) # ================================================================ diff --git a/scripts/test_batch12_units.py b/scripts/test_batch12_units.py index 0271ca3..c2c184e 100644 --- a/scripts/test_batch12_units.py +++ b/scripts/test_batch12_units.py @@ -436,6 +436,80 @@ def _(): assert "ref_lock" not in prog, "没有位就不该锁一份空的进去" +# ================================================================ 窗口末日的强制完成 +@case("窗口末日·命令驱动照旧强制完成 (用户下过命令, 到期必须完成)") +def _(): + from app.core import exec_timing as et + prm = {"buy_halt_dayup": 0.05, "eod_force_time": "14:45", "eod_force_discount": 0.998} + d = {"price": 10.0, "day_chg_from_open": 0.01, "bars": 60} + r = et.hard_gate(side="buy", now="14:46", day=d, params=prm, + is_last_day=True, is_command=True, quota=1000) + assert r["action"] == et.ACT_FIRE and r["forced"] is True, r + assert r["limit_price"] == 10.02, r # 10 × 1.002 + + +@case("窗口末日·自主买入不强制追买, 到期作废 (与 window_verdict 的口径对齐)") +def _(): + from app.core import exec_timing as et + prm = {"buy_halt_dayup": 0.05, "eod_force_time": "14:45", "eod_force_discount": 0.998} + d = {"price": 10.0, "day_chg_from_open": 0.01, "bars": 60} + r = et.hard_gate(side="buy", now="14:46", day=d, params=prm, + is_last_day=True, is_command=False, quota=1000) + assert r["action"] == et.ACT_WAIT and not r["forced"], r + assert "不追买" in r["reason"] and "到期作废" in r["reason"], r["reason"] + # 与窗口收口那侧说的是同一件事: 自主类到期作废 + assert et.window_verdict(remaining_qty=500, tdays_left=0, + is_command=False)["verdict"] == "EXPIRED" + + +@case("窗口末日·非末日两侧行为一致 (都是顺延次日)") +def _(): + from app.core import exec_timing as et + prm = {"buy_halt_dayup": 0.05, "eod_force_time": "14:45", "eod_force_discount": 0.998} + d = {"price": 10.0, "day_chg_from_open": 0.01, "bars": 60} + for is_cmd in (True, False): + r = et.hard_gate(side="buy", now="14:46", day=d, params=prm, + is_last_day=False, is_command=is_cmd, quota=1000) + assert r["action"] == et.ACT_WAIT and "顺延次日" in r["reason"], (is_cmd, r) + + +@case("窗口末日·卖出侧一个字没改: 两种来源都照常兜底 (宁可买不上, 不能卖不掉)") +def _(): + from app.core import exec_timing as et + prm = {"sell_avoid_open_min": 30, "eod_force_time": "14:45", "eod_force_discount": 0.998} + d = {"price": 10.0, "day_chg_from_open": -0.01, "bars": 60} + for is_cmd in (True, False): + r = et.hard_gate(side="sell", now="14:46", day=d, params=prm, + is_last_day=True, is_command=is_cmd, quota=1000) + assert r["action"] == et.ACT_FIRE and r["forced"] is True, (is_cmd, r) + assert r["limit_price"] == 9.98, r # 10 × 0.998 + + +@case("窗口末日·hard_gate 的 is_command 是必填的 (漏传立刻炸, 不许静默按某一侧走)") +def _(): + from app.core import exec_timing as et + try: + et.hard_gate(side="buy", now="14:46", day={"price": 10.0}, params={}, + is_last_day=True, quota=1000) + except TypeError: + pass + else: + raise AssertionError("漏传 is_command 必须抛 TypeError") + + +@case("窗口末日·executor 真的把 is_command 传下去了 (签名对得上)") +def _(): + import inspect + from app.services import exec_advisor as ea + from app.core import exec_timing as et + assert "is_command" in inspect.signature(ea.decide).parameters + assert "is_command" in inspect.signature(et.hard_gate).parameters + assert "is_command" in inspect.signature(et.decide).parameters + src = inspect.getsource(__import__("app.services.executor", fromlist=["x"]).run_tick) + assert "is_command=bool(prog.get(\"is_command\"))" in src, \ + "executor.run_tick 必须显式把指令的 is_command 传给择时, 否则这道分岔形同虚设" + + # ================================================================ 研判的单轮时间预算 @case("研判预算·没设预算就一律放行") def _():