资金快照不可信时整轮跳过刹车结算

This commit is contained in:
zlt 2026-07-31 16:43:43 +08:00
parent 2ae59df497
commit 889e2ef5b8
6 changed files with 210 additions and 27 deletions

View File

@ -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` 的票。
### 下一步(按可动工顺序)

View File

@ -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()

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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