diff --git a/README.md b/README.md index 6236e69..8d901d9 100644 --- a/README.md +++ b/README.md @@ -67,11 +67,13 @@ scripts/ test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 32 例 test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 60 例 test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 47 例 + test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 33 例 test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) 58 例 init_db.py 建表 (应用 ddl_pms_v1.sql, 幂等, 默认演练; 含 DDL 体检) check_db.py 实机连通性与表结构自检 (需真实 .env) gen_keys.py ws 通道密钥: 生成 / 只取公钥(--pubkey) / PEM(--pem) / 自检(--check) - reset_ledger.py 清空账本并把回放游标对齐到当前 (影子运行期重来一次; 不碰下游表) + reset_ledger.py 清空账本 + 回放游标对齐 + 归零四个账本派生参数 (连续不一致天数、 + 高水位、刹车解除日); 影子运行期重来一次, 绝不碰下游表 probe_plan_api.py 上游计划实机探活: 通不通 / 字段口径 / 候选筛选结果 / 有没有价 / 榜单变化 (只读; --snapshot 才落库, 那是它唯一的写操作) rebuild_ledger.py 账本重建: 预检 → 执行 → 判收 (默认只预检; --yes 才改账) @@ -298,7 +300,7 @@ make t-gate # 随时: 规则闸/研判闸拒了什么、为什么 ## 已实现 / 待开发 -**已实现**:建表 DDL 与建表脚本;配置与运行参数中心;仓位规划器与安全垫账;命令系统(27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);规则闸终检;择时执行器实现 B(分日配额、分笔、VWAP/回踩/不追高、14:45 兜底、停牌一字板顺延、窗口耗尽收口、挂单有效期);动作引擎四类自主动作 + 研判闸客户端 + 提议分流;决策系统信号消化(两条流独立消费组订阅、置信度分档转清仓指令或提议);管理页面四块 + 运维/日报抽屉;调度器九个调度位;**上游选股计划接口接入**(`/plan` 取候选池、交易日龄硬校验、`theme` 灌行业映射表、页面预览抽屉与不可用横幅);**榜单变化提示**(名册快照 + 新进/掉榜/档位升降/覆盖翻转/名次跳变,持仓票单列,榜尾截断噪音闸);**ws 直连通道的连接层**(常驻进程 + 出口队列 + 签名 + seq 水位与累积确认,见下);**单测 378 例**。 +**已实现**:建表 DDL 与建表脚本;配置与运行参数中心;仓位规划器与安全垫账;命令系统(27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);规则闸终检;择时执行器实现 B(分日配额、分笔、VWAP/回踩/不追高、14:45 兜底、停牌一字板顺延、窗口耗尽收口、挂单有效期);动作引擎四类自主动作 + 研判闸客户端 + 提议分流;决策系统信号消化(两条流独立消费组订阅、置信度分档转清仓指令或提议);管理页面四块 + 运维/日报抽屉;调度器九个调度位;**上游选股计划接口接入**(`/plan` 取候选池、交易日龄硬校验、`theme` 灌行业映射表、页面预览抽屉与不可用横幅);**榜单变化提示**(名册快照 + 新进/掉榜/档位升降/覆盖翻转/名次跳变,持仓票单列,榜尾截断噪音闸);**ws 直连通道的连接层**(常驻进程 + 出口队列 + 签名 + seq 水位与累积确认,见下);**单测 384 例**。 ### 静默失败专项(2026-07-31,八条已修) @@ -325,6 +327,14 @@ make t-gate # 随时: 规则闸/研判闸拒了什么、为什么 这条纪律由 `test_batch10_units.py` 的 **[A] 组**守着——它是全套单测里唯一一条**静态**用例:AST 扫 `app/` 全库,凡是「整条语句就是一次这类调用、返回值没被任何人接住」的写法直接判失败(`await _db(qmt_repo.update_order, ...)` 这种线程池间接调用也认)。名单在 `SOFT_FAIL`,确实可以丢的写进 `ALLOWED` 并**必须带理由**。[A2] 反过来守着扫描器本身:造一个丢返回值的调用,扫不出来就算失败——守卫写错会让 [A1] 永远绿,那比没有守卫更糟。 +**组合刹车的高水位改成按总资产算(2026-07-31)。** 原来拿 `portfolio_mv` 当基准,那量的是「仓位有多大」而不是「我值多少钱」,于是**每一次主动减仓都会被当成回撤**:一条降仓 40% 的命令执行完,回撤算出 40% ≥ 5%,自主增持立刻被刹停 3 个交易日——用户按纪律减了仓,系统当他亏了钱。清仓、止盈卖出、清账重来三条路全中。「自高水位回撤」在任何交易语境里都是**权益回撤**,减仓时钱从市值挪到现金、总资产不动,这才是它该有的样子。 + +配套两条:资金快照不可信时**整轮跳过刹车结算,不拿 `cash_est` 硬判**(那是 `scale − 市值` 的虚数,拿它当权益等于换个地方犯同一个错),但跳过这件事挂在 warnings 上说出来;口径切换不需要迁移,旧的 `PMS_HIGH_WATER` 是市值量级必然小于总资产,第一次跑就被抬上来,只会往上走不会凭空造出一段回撤。 + +实机踩到这条的路径是清账:账本里躺过 1100 股(市值 10461)→ 高水位记成 10461 → 清空账本 → 市值归 0 → 回撤 100%。改按总资产之后钱还在账户里,回撤是 0。另外 `PMS_HIGH_WATER` 本来就是账本的派生量,`reset_ledger` 现在把 `PMS_RECON_STREAK` / `PMS_RECON_STREAK_YMD` / `PMS_HIGH_WATER` / `PMS_BRAKE_UNTIL` 四个一起归零(`RESET_PARAMS`),[D3]~[D4] 四条单测钉住。 + +**另一头是「没事长得像出事」,同一种病的反面**:`make t-mat` 三个列表全空时读起来像「一条都没转成」,实际多半是「早就转完了」——它只吃 `PENDING` 状态的方案,已转指令的是 `EXECUTING`、等解锁的批是 `GATED`。现在输出多了 `scanned` 与 `note`,把当前方案分布直接写出来。 + 顺带修出来的第九条,是**本轮改动自己引入的**:`positions_view` 改成「取不到现价时拿摊薄成本顶住 `price`」之后,`planner._usable` 光看 `price > 0` 就漏了——拿成本价算出来的市值会被当成真市值去凑「释放 20 万」,凑够了报 DONE 而实际卖出金额对不上。现在 `_usable` 一并排掉 `price_ok is False` 的票。 ### 下一步(按可动工顺序) diff --git a/app/services/executor.py b/app/services/executor.py index dc9c4eb..ce4c108 100644 --- a/app/services/executor.py +++ b/app/services/executor.py @@ -43,10 +43,29 @@ BUY_ACTIONS = {"OPEN", "FILL", "ADD", "DCA"} # ================================================================ 方案 → 指令 def materialize_plans(limit: int = 100) -> dict: - """把在途命令的可执行方案条目转成指令。幂等: 已生成过指令的方案置 EXECUTING 不再重复。""" - out = {"created": [], "skipped": [], "errors": []} + """把在途命令的可执行方案条目转成指令。幂等: 已生成过指令的方案置 EXECUTING 不再重复。 + + **三个列表全空必须能区分出是哪一种全空** (2026-07-31): 本函数只吃 PENDING 的方案, + 而已转过指令的方案是 EXECUTING、等解锁的批是 GATED。三种情况输出一模一样都是 + `{created: [], skipped: [], errors: []}`, 读起来像"一条都没转成", 实际多半是 + "早就转完了"。加 `scanned` 与 `note` 把它说破 —— 这是同一类"失败长得像成功"的毛病, + 只不过这里是"没事长得像出事", 一样会让人白排查半天。 + """ + out = {"created": [], "skipped": [], "errors": [], "scanned": 0, "note": ""} plans = pms_repo.list_plans(statuses=[PLAN_PENDING], limit=limit) + out["scanned"] = len(plans) if not plans: + other = {} + for st in (PLAN_GATED, PLAN_EXEC, PLAN_DONE): + try: + other[st] = len(pms_repo.list_plans(statuses=[st], limit=limit)) + except Exception: + pass + out["plans_by_status"] = other + out["note"] = ( + "没有 PENDING 方案可转 —— 这**不是**失败。当前方案分布: " + + (", ".join(f"{k} {v} 条" for k, v in other.items() if v) or "一条方案都没有") + + "。EXECUTING = 已经转过指令了 (看 make t-ins); GATED = 等动作引擎解锁的批") return out cmd_cache = {} ymd = td.ymd() diff --git a/app/services/ledger_service.py b/app/services/ledger_service.py index dc242b8..02f8327 100644 --- a/app/services/ledger_service.py +++ b/app/services/ledger_service.py @@ -975,6 +975,19 @@ def premarket() -> dict: def _settle_brake() -> dict: """组合刹车: 自高水位回撤 ≥ 阈值 → 自主增持停 N 个交易日 (命令类不受限)。 + 高水位的基准是**总资产 (现金 + 持仓市值), 不是持仓市值** (2026-07-31 改) + ---------------------------------------------------------------------- + 原来拿 `portfolio_mv` 当基准, 那量的是"仓位有多大"而不是"我值多少钱", 于是**每一次 + 主动减仓都会被当成回撤**: 一条降仓 40% 的命令执行完, 回撤算出 40% ≥ 5%, 自主增持立刻 + 被刹停 3 个交易日 —— 用户按纪律减了仓, 系统当他亏了钱。清仓、止盈卖出、清账重来 + 三条路全中 (清账那条 07-31 实机踩到: 高水位 10461, 清完账市值归 0, 回撤 100%)。 + 「自高水位回撤」在任何交易语境里都是**权益回撤**, 减仓时钱从市值挪到现金、总资产不动, + 这才是它该有的样子。 + + 资金快照不可信时**整轮跳过, 不拿估算值硬判**。`cash_est = scale − 市值` 是从参数算出来 + 的虚数 (见 portfolio._cash_snapshot), 拿它当权益, 减仓照样会让"权益"跳水, 等于换个地方 + 犯同一个错。宁可这一轮不判, 也不误判 —— 但**跳过这件事必须说出来**, 挂在 warnings 上。 + 两处 set_param 的返回值都必须接 (2026-07-31 静默失败专项): * PMS_BRAKE_UNTIL 写不进去 = 刹车根本没踩。proposal_service 每一跳重新去 param_store 读这个键判 brake_active, 读不到就是 False, 自主增持照跑。而本函数 @@ -982,23 +995,40 @@ def _settle_brake() -> dict: 日报、页面、日志三处都说停了, 实际一路买穿整个回撤。 * PMS_HIGH_WATER 写不进去 = 高水位永远停在旧值, 回撤按陈年高点算, 阈值再也够不着。 风控是慢性失效, 没有任何一处会报错。 + + 口径切换不需要迁移: 旧的 PMS_HIGH_WATER 是市值量级, 必然小于总资产, 第一次跑就会被 + 抬到新基准上 —— 只会往上走, 不会凭空造出一段回撤。 """ v = portfolio.positions_view() - mv = v["totals"]["portfolio_mv"] + t = v["totals"] + mv = t["portfolio_mv"] hw = param_store.get_float("PMS_HIGH_WATER", 0.0) dd_limit = param_store.get_float("PMS_BRAKE_DRAWDOWN", 0.05) days = param_store.get_int("PMS_BRAKE_DAYS", 3) until = param_store.get_int("PMS_BRAKE_UNTIL", 0) today = td.ymd() warnings = [] - if mv > hw: - w = param_store.set_param("PMS_HIGH_WATER", mv, "system") or {} + + equity = t.get("total_asset") + if t.get("cash_source") != portfolio.CASH_WS or not equity or float(equity) <= 0: + why = (t.get("cash_why") + or f"资金来源是 {t.get('cash_source')!r}, 拿不到账户总资产") + logger.warning("[刹车] 本轮跳过刹车结算 —— %s。**这一轮没有回撤保护**", why) + return {"high_water": hw, "portfolio_mv": mv, "equity": None, "drawdown": None, + "brake_until": until, "active": today < until, "engaged": False, + "skipped": True, + "warnings": [f"刹车结算已跳过, 本轮无回撤保护: {why} —— " + f"高水位要按总资产算, 而总资产只有 ws 资金快照给得出"]} + equity = float(equity) + + if equity > hw: + w = param_store.set_param("PMS_HIGH_WATER", equity, "system") or {} if w.get("ok"): - hw = mv + hw = equity else: logger.error("[刹车] 高水位写入失败 (仍按旧值 %s 算回撤): %s", hw, w.get("error")) warnings.append(f"高水位未能落库, 回撤按旧高点 {hw:,.0f} 计算: {w.get('error')}") - drawdown = (1 - mv / hw) if hw > 0 else 0.0 + drawdown = (1 - equity / hw) if hw > 0 else 0.0 engaged = False if hw > 0 and drawdown >= dd_limit and today >= until: nxt = td.ymd(td.next_trade_day(datetime.now().date(), days)) @@ -1013,8 +1043,9 @@ def _settle_brake() -> dict: drawdown * 100, dd_limit * 100, w.get("error")) warnings.append(f"**刹车未生效**: 回撤 {drawdown:.1%} 已达阈值但 " f"PMS_BRAKE_UNTIL 写不进去 ({w.get('error')}), 自主增持仍在放行") - out = {"high_water": hw, "portfolio_mv": mv, "drawdown": round(drawdown, 4), - "brake_until": until, "active": today < until, "engaged": engaged} + out = {"high_water": hw, "portfolio_mv": mv, "equity": equity, + "drawdown": round(drawdown, 4), "brake_until": until, + "active": today < until, "engaged": engaged, "skipped": False} if warnings: out["warnings"] = warnings return out diff --git a/scripts/reset_ledger.py b/scripts/reset_ledger.py index da1ed31..fe9f03a 100644 --- a/scripts/reset_ledger.py +++ b/scripts/reset_ledger.py @@ -175,21 +175,32 @@ def main(): print(f" FAIL 游标重置: {type(e).__name__}: {e}") failed.append(CURSOR_KEY) - # 对账连续不一致天数归零 —— 账本刚清空, 上一轮攒下的 streak 会让下一次对账直接判 ERROR。 + # 跟着账本一起归零的四个运行参数 —— 它们都是**账本的派生量**, 账本清了它们不清, + # 留下的就是「记着一段不存在的历史」的状态。 # set_param 写不进去是**返回 ok=False 而不抛异常**, 只 try/except 的话归零失败照样 - # 打印 "OK ... 归零", 而下一次对账带着旧 streak 直接升 ERROR。 + # 打印 "OK ... 归零"。 + # + # PMS_RECON_STREAK / _YMD 上一轮攒下的连续不一致天数, 不清则下次对账直接判 ERROR + # PMS_HIGH_WATER 组合刹车的高水位。**这条是 2026-07-31 实机踩到的**: + # 账本里躺着 1100 股 (市值 10461) 时高水位记成 10461, 清账后 + # 组合市值归 0 → 回撤算出 100% ≥ 5% → 盘前准备一跑就把自主 + # 增持刹停 3 个交易日。清完账的第一件事就被刹车拦住, 而日志 + # 只会说"自高水位回撤 100%", 看着像出了大事 + # PMS_BRAKE_UNTIL 同上, 上一轮踩下的刹车不该跨过清账继续生效 + RESET_PARAMS = ("PMS_RECON_STREAK", "PMS_RECON_STREAK_YMD", + "PMS_HIGH_WATER", "PMS_BRAKE_UNTIL") try: from app.services import param_store - for _k in ("PMS_RECON_STREAK", "PMS_RECON_STREAK_YMD"): + for _k in RESET_PARAMS: + old = param_store.get(_k, 0) w = param_store.set_param(_k, 0, "reset_ledger") or {} if w.get("ok"): - print(f" OK {_k} 归零") + print(f" OK {_k} 归零 (原值 {old})") else: - print(f" FAIL {_k} 归零失败: {w.get('error')} —— " - f"下次对账会带着旧的连续天数直接升 ERROR") + print(f" FAIL {_k} 归零失败: {w.get('error')}") failed.append(_k) except Exception as e: - print(f" FAIL 对账连续天数归零失败: {type(e).__name__}: {e}") + print(f" FAIL 账本派生参数归零失败: {type(e).__name__}: {e}") failed.append("PMS_RECON_STREAK") if args.reset_ws: diff --git a/scripts/run_tests.py b/scripts/run_tests.py index c129fea..a16cc4d 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -14,9 +14,9 @@ test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 (32 例) test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 (60 例) test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 (47 例) - test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 (27 例) + test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 (33 例) test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (58 例) - 共 378 例 + 共 384 例 任一子集失败即整体失败 (退出码 1)。 """ import os diff --git a/scripts/test_batch10_units.py b/scripts/test_batch10_units.py index a2bf4f7..1e76634 100644 --- a/scripts/test_batch10_units.py +++ b/scripts/test_batch10_units.py @@ -243,6 +243,13 @@ def _(): # ================================================================ # [D] 组合刹车: 该踩没踩上必须报错 # ================================================================ +def _fake_totals(mv, equity, source="ws", why=""): + """positions_view 的最小桩: 只喂刹车结算要看的那几个字段。""" + return lambda **kw: {"held": [], "positions": [], "params": {}, + "totals": {"portfolio_mv": mv, "total_asset": equity, + "cash_source": source, "cash_why": why}} + + @case("[D1] PMS_BRAKE_UNTIL 写入失败 → 盘前准备报 errors, 不许 ok=True") def _(): from test_wiring import install_fakes @@ -250,10 +257,8 @@ def _(): install_fakes(prices={}) orig_set, orig_view = param_store.set_param, portfolio.positions_view try: - # 高水位 100 万, 现值 80 万 → 回撤 20%, 远超默认 5%, 必须踩刹车 - portfolio.positions_view = lambda **kw: { - "held": [], "positions": [], "params": {}, - "totals": {"portfolio_mv": 800000.0}} + # 高水位 100 万, 总资产 80 万 → 回撤 20%, 远超默认 5%, 必须踩刹车 + portfolio.positions_view = _fake_totals(mv=700000.0, equity=800000.0) param_store.set_param = lambda k, v, by="user": ( {"ok": True} if k == "PMS_HIGH_WATER" else {"ok": False, "error": "库挂了"}) orig_get = param_store.get_float @@ -276,9 +281,7 @@ def _(): install_fakes(prices={}) orig_set, orig_view = param_store.set_param, portfolio.positions_view try: - portfolio.positions_view = lambda **kw: { - "held": [], "positions": [], "params": {}, - "totals": {"portfolio_mv": 500000.0}} + portfolio.positions_view = _fake_totals(mv=100000.0, equity=500000.0) param_store.set_param = lambda k, v, by="user": {"ok": False, "error": "库挂了"} b = ls._settle_brake() assert b["high_water"] == 0.0, b # 没写进去就不许当成写进去了 @@ -287,6 +290,85 @@ def _(): param_store.set_param, portfolio.positions_view = orig_set, orig_view +@case("[D3] 高水位按总资产算: **主动减仓不该被当成回撤**") +def _(): + from test_wiring import install_fakes + from app.services import ledger_service as ls, param_store, portfolio + install_fakes(prices={}) + orig_view = portfolio.positions_view + try: + # 100 万全在持仓, 总资产 100 万 → 高水位 100 万 + portfolio.positions_view = _fake_totals(mv=1000000.0, equity=1000000.0) + ls._settle_brake() + assert param_store.get_float("PMS_HIGH_WATER") == 1000000.0 + + # 一条降仓 40% 的命令执行完: 市值 60 万、现金 40 万, 总资产没变。 + # 按市值算会判成"回撤 40%"直接刹停 3 天 —— 用户按纪律减了仓, 系统当他亏了钱。 + portfolio.positions_view = _fake_totals(mv=600000.0, equity=1000000.0) + b = ls._settle_brake() + assert b["drawdown"] == 0.0 and b["engaged"] is False, b + + # 真亏钱才该刹: 总资产掉到 92 万 → 回撤 8% ≥ 5% + portfolio.positions_view = _fake_totals(mv=520000.0, equity=920000.0) + b = ls._settle_brake() + assert abs(b["drawdown"] - 0.08) < 1e-9 and b["engaged"] is True, b + finally: + portfolio.positions_view = orig_view + + +@case("[D3b] 清空账本后不许误刹车 (账本清了但高水位还在的那一幕)") +def _(): + from test_wiring import install_fakes + from app.services import ledger_service as ls, param_store, portfolio + install_fakes(prices={}) + orig_view = portfolio.positions_view + try: + # 2026-07-31 实机那一幕: 账本里躺过 1100 股 → 高水位被顶起来, 然后账本被清空。 + # 按市值算的话回撤 100%, 清个账就把自主增持刹停 3 天。按总资产算, 钱还在账户里, + # 回撤是 0 —— 这才对。 + param_store.set_param("PMS_HIGH_WATER", 10461.0, "test") + param_store.set_param("PMS_BRAKE_UNTIL", 0, "test") + portfolio.positions_view = _fake_totals(mv=0.0, equity=2000000.0) + b = ls._settle_brake() + assert b["drawdown"] == 0.0 and b["engaged"] is False, b + # 旧口径的高水位是市值量级, 必然小于总资产 → 第一次跑就被抬上来, 不需要迁移 + assert b["high_water"] == 2000000.0, b + finally: + portfolio.positions_view = orig_view + + +@case("[D3c] 资金快照不可信 → 整轮跳过刹车结算, 且**必须说出来**") +def _(): + from test_wiring import install_fakes + from app.services import ledger_service as ls, portfolio + install_fakes(prices={}) + orig_view = portfolio.positions_view + try: + # cash_est 是 scale−市值 的虚数, 拿它当权益, 减仓照样会让"权益"跳水 —— + # 换个地方犯同一个错。宁可这一轮不判, 但不判这件事不能没人知道。 + portfolio.positions_view = _fake_totals( + mv=600000.0, equity=None, source="estimate", + why="ws 从未回过 funds 快照 (对端未实现 query_funds)") + b = ls._settle_brake() + assert b["skipped"] is True and b["engaged"] is False, b + assert b["drawdown"] is None, b + assert any("本轮无回撤保护" in w and "query_funds" in w + for w in b.get("warnings", [])), b + finally: + portfolio.positions_view = orig_view + + +@case("[D4] reset_ledger 的归零清单必须盖住这四个账本派生参数") +def _(): + import re + src = open(os.path.join(ROOT, "scripts", "reset_ledger.py"), encoding="utf-8").read() + m = re.search(r"RESET_PARAMS\s*=\s*\(([^)]*)\)", src, re.S) + assert m, "reset_ledger 里找不到 RESET_PARAMS" + got = set(re.findall(r'"([A-Z_]+)"', m.group(1))) + need = {"PMS_RECON_STREAK", "PMS_RECON_STREAK_YMD", "PMS_HIGH_WATER", "PMS_BRAKE_UNTIL"} + assert need <= got, f"清账时漏了这些派生参数: {need - got}" + + # ================================================================ # [E] 安全垫连负天数: 写不上要让日终结算整体报失败 # ================================================================ @@ -600,6 +682,36 @@ def _(): assert param_store.get("PMS_GLOBAL_BUY_HALT") is not True, "误伤: 没设过被当成读不到" +# ================================================================ +# [L] 「没事」不许长得像「出事」: 全空输出要说清是哪一种空 +# ================================================================ +@case("[L1] 没有 PENDING 方案时, t-mat 要说破是「早就转完了」而不是「一条都没转成」") +def _(): + from test_wiring import install_fakes + from app.services import executor + fake = install_fakes(prices={}) + fake.plans.extend([ + {"id": 1, "plan_id": "P1", "command_id": "C1", "ts_code": "600000.SH", + "action": "OPEN", "qty": 100, "status": "EXECUTING", "filled_qty": 0}, + {"id": 2, "plan_id": "P2", "command_id": "C1", "ts_code": "600000.SH", + "action": "FILL", "qty": 100, "status": "GATED", "filled_qty": 0}]) + r = executor.materialize_plans() + assert r["created"] == [] and r["skipped"] == [] and r["errors"] == [], r + assert r["scanned"] == 0, r + assert "不是**失败" in r["note"], r["note"] + assert r["plans_by_status"].get("EXECUTING") == 1, r + assert r["plans_by_status"].get("GATED") == 1, r + + +@case("[L2] 一条方案都没有时也说得清 (与「转完了」是两回事)") +def _(): + from test_wiring import install_fakes + from app.services import executor + install_fakes(prices={}) + r = executor.materialize_plans() + assert r["scanned"] == 0 and "一条方案都没有" in r["note"], r + + # ================================================================ def main(): ok = fail = 0