处理现有问题

This commit is contained in:
zlt 2026-08-03 09:10:56 +08:00
parent 889e2ef5b8
commit d1e0cb9298
7 changed files with 206 additions and 13 deletions

View File

@ -21,7 +21,7 @@ DC := docker compose $(PROFILES)
# 一次性命令一律 --no-deps: 不然跑个单测都会把 beat/worker 拉起来 # 一次性命令一律 --no-deps: 不然跑个单测都会把 beat/worker 拉起来
RUN := docker compose run --rm --no-deps pms-web RUN := docker compose run --rm --no-deps pms-web
.PHONY: help deploy deploy-local build up down ps logs test initdb check health \ .PHONY: help deploy deploy-local build up down ps logs test stale initdb check health \
probe changes industry ws-status rebuild rebuild-accept \ probe changes industry ws-status rebuild rebuild-accept \
t-plan t-pre t-cmd t-plans t-mat t-dry t-tick t-ins t-book t-gate \ t-plan t-pre t-cmd t-plans t-mat t-dry t-tick t-ins t-book t-gate \
reset-ledger shell reset-ledger shell
@ -54,9 +54,32 @@ logs: ## 跟日志 (make logs S=pms-ws 只看一个服务)
$(DC) logs -f $(S) $(DC) logs -f $(S)
# ---------------------------------------------------------------- 自检 # ---------------------------------------------------------------- 自检
test: ## 全部单测 (零外部依赖, 不连库; 应输出 ALL SUITES PASS) test: ## 全部单测 (先查镜像新旧再跑; 应输出 ALL SUITES PASS)
@$(MAKE) --no-print-directory stale
$(RUN) python scripts/run_tests.py $(RUN) python scripts/run_tests.py
stale: ## 容器里的代码跟工作树对不对得上 (git pull 之后没 build 的话这里会喊)
@host=$$(python3 scripts/code_fingerprint.py 2>/dev/null || echo UNKNOWN); \
img=$$($(RUN) python scripts/code_fingerprint.py 2>/dev/null | tr -d '\r' | tail -1); \
if [ -z "$$img" ]; then img=UNKNOWN; fi; \
if [ "$$host" = UNKNOWN ] || [ "$$img" = UNKNOWN ]; then \
echo " ?? 指纹取不到 (工作树=$$host 镜像=$$img), 跳过新旧判断"; \
elif [ "$$host" = "$$img" ]; then \
echo " OK 镜像与工作树一致 ($$host)"; \
else \
echo ""; \
echo " ##################################################################"; \
echo " # 镜像里的代码不是你工作树里的代码"; \
echo " # 工作树 $$host"; \
echo " # 镜像 $$img"; \
echo " #"; \
echo " # 源码是打进镜像的, git pull 不更新容器。此刻跑任何 make 目标"; \
echo " # (test / rebuild / t-*) 都是在跑旧代码 —— 而它照样会 PASS。"; \
echo " # 先跑: make deploy (本地已改好则 make deploy-local)"; \
echo " ##################################################################"; \
echo ""; \
fi
initdb: ## 建表/补表 (幂等; 加 DRY=1 只演练打印) initdb: ## 建表/补表 (幂等; 加 DRY=1 只演练打印)
$(RUN) python scripts/init_db.py $(if $(DRY),,--yes) $(RUN) python scripts/init_db.py $(if $(DRY),,--yes)

View File

@ -67,7 +67,7 @@ scripts/
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 32 例 test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 32 例
test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 60 例 test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 60 例
test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 47 例 test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 47 例
test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 33 test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 36
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) 58 例 test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) 58 例
init_db.py 建表 (应用 ddl_pms_v1.sql, 幂等, 默认演练; 含 DDL 体检) init_db.py 建表 (应用 ddl_pms_v1.sql, 幂等, 默认演练; 含 DDL 体检)
check_db.py 实机连通性与表结构自检 (需真实 .env) check_db.py 实机连通性与表结构自检 (需真实 .env)
@ -78,6 +78,8 @@ scripts/
/ 榜单变化 (只读; --snapshot 才落库, 那是它唯一的写操作) / 榜单变化 (只读; --snapshot 才落库, 那是它唯一的写操作)
rebuild_ledger.py 账本重建: 预检 → 执行 → 判收 (默认只预检; --yes 才改账) rebuild_ledger.py 账本重建: 预检 → 执行 → 判收 (默认只预检; --yes 才改账)
ws_smoke.py ws 联调工具: status/watch/place/cancel/inbox (绕开 dispatch_mode) ws_smoke.py ws 联调工具: status/watch/place/cancel/inbox (绕开 dispatch_mode)
code_fingerprint.py 当前 .py 源码的短哈希 —— `make test` 拿它比对容器与工作树,
防「git pull 了没 build, 跑的还是旧代码而且照样 PASS」
``` ```
## 三条铁律 ## 三条铁律
@ -300,7 +302,7 @@ make t-gate # 随时: 规则闸/研判闸拒了什么、为什么
## 已实现 / 待开发 ## 已实现 / 待开发
**已实现**:建表 DDL 与建表脚本配置与运行参数中心仓位规划器与安全垫账命令系统27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);规则闸终检;择时执行器实现 B分日配额、分笔、VWAP/回踩/不追高、14:45 兜底、停牌一字板顺延、窗口耗尽收口、挂单有效期);动作引擎四类自主动作 + 研判闸客户端 + 提议分流;决策系统信号消化(两条流独立消费组订阅、置信度分档转清仓指令或提议);管理页面四块 + 运维/日报抽屉;调度器九个调度位;**上游选股计划接口接入**`/plan` 取候选池、交易日龄硬校验、`theme` 灌行业映射表、页面预览抽屉与不可用横幅);**榜单变化提示**(名册快照 + 新进/掉榜/档位升降/覆盖翻转/名次跳变,持仓票单列,榜尾截断噪音闸);**ws 直连通道的连接层**(常驻进程 + 出口队列 + 签名 + seq 水位与累积确认,见下);**单测 384 例**。 **已实现**:建表 DDL 与建表脚本配置与运行参数中心仓位规划器与安全垫账命令系统27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);规则闸终检;择时执行器实现 B分日配额、分笔、VWAP/回踩/不追高、14:45 兜底、停牌一字板顺延、窗口耗尽收口、挂单有效期);动作引擎四类自主动作 + 研判闸客户端 + 提议分流;决策系统信号消化(两条流独立消费组订阅、置信度分档转清仓指令或提议);管理页面四块 + 运维/日报抽屉;调度器九个调度位;**上游选股计划接口接入**`/plan` 取候选池、交易日龄硬校验、`theme` 灌行业映射表、页面预览抽屉与不可用横幅);**榜单变化提示**(名册快照 + 新进/掉榜/档位升降/覆盖翻转/名次跳变,持仓票单列,榜尾截断噪音闸);**ws 直连通道的连接层**(常驻进程 + 出口队列 + 签名 + seq 水位与累积确认,见下);**单测 387 例**。
### 静默失败专项2026-07-31八条已修 ### 静默失败专项2026-07-31八条已修
@ -333,7 +335,21 @@ make t-gate # 随时: 规则闸/研判闸拒了什么、为什么
实机踩到这条的路径是清账:账本里躺过 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] 四条单测钉住。 实机踩到这条的路径是清账:账本里躺过 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`,把当前方案分布直接写出来。 **另一头是「没事长得像出事」,同一种病的反面**,两处:
`make t-mat` 三个列表全空时读起来像「一条都没转成」,实际多半是「早就转完了」——它只吃 `PENDING` 状态的方案,已转指令的是 `EXECUTING`、等解锁的批是 `GATED`。现在输出多了 `scanned``note`,把当前方案分布直接写出来。
`make rebuild` 在「下游一只持仓都没有」时印 `[FAIL]` 并退出码 2make 报 `Error 2`——可账户空 + 账本空 = 两边一致,**这是终态不是故障**,压根不需要重建这一步。预检现在给出 `verdict``READY` / `EMPTY` / `NOT_READY` / `NO_SOURCE``EMPTY` 退 0 并明说「没有需要接管的持仓,重建这一步跳过」。只有一种 `EMPTY` 仍然要拦:**账户空而账本非空**,那是顺序反了,得先 `reset_ledger` 清账本,别让对账拿空集去核销已有持仓。
**第三种变体最阴,是「没跑长得像通过」。** 源码是打进镜像的,`docker compose run pms-web python scripts/run_tests.py` 跑的是**镜像里那份**。于是有这么一条路径:
```
git pull → make test → ALL SUITES PASS
```
这个 PASS 是**旧代码的 PASS**。新增的那批测试文件根本不在镜像里,`run_tests.py` 只会印一行「跳过 xxx文件不存在」然后照样 ALL SUITES PASS——一条没跑结论却是全过。2026-07-31 实机就撞上了:`make t-pre` 的返回里少了新加的字段,才反推出容器跑的是旧镜像。
两道锁:`run_tests.py` 现在把缺失的测试文件单列成 `SUITE MISSING` 并**退非零码**(「没跑」不许算「通过」),同时打印当前代码指纹;`make test` 跑之前先执行 `make stale`,把容器里的指纹和工作树的指纹对一遍,对不上就横幅提示先 `make deploy`。指纹逻辑在 `scripts/code_fingerprint.py`,只覆盖 `app/` `scripts/` `config/` 下的 `.py`
顺带修出来的第九条,是**本轮改动自己引入的**`positions_view` 改成「取不到现价时拿摊薄成本顶住 `price`」之后,`planner._usable` 光看 `price > 0` 就漏了——拿成本价算出来的市值会被当成真市值去凑「释放 20 万」,凑够了报 DONE 而实际卖出金额对不上。现在 `_usable` 一并排掉 `price_ok is False` 的票。 顺带修出来的第九条,是**本轮改动自己引入的**`positions_view` 改成「取不到现价时拿摊薄成本顶住 `price`」之后,`planner._usable` 光看 `price > 0` 就漏了——拿成本价算出来的市值会被当成真市值去凑「释放 20 万」,凑够了报 DONE 而实际卖出金额对不上。现在 `_usable` 一并排掉 `price_ok is False` 的票。

View File

@ -732,7 +732,16 @@ def rebuild_preflight() -> dict:
再加一段开关现状清账期间关掉的东西, 重建完必须记得打开 尤其 `PMS_SIGNAL_ENABLED`: 再加一段开关现状清账期间关掉的东西, 重建完必须记得打开 尤其 `PMS_SIGNAL_ENABLED`:
账本空时信号消化判 IGNORE 也照样 ACK, 卖出信号会被消费组静默吃掉且跨日拿不回来 账本空时信号消化判 IGNORE 也照样 ACK, 卖出信号会被消费组静默吃掉且跨日拿不回来
""" """
out = {"ok": True, "ready": False, "steps": [], "switches": {}, "hint": ""} # verdict 把三种结局分开, 别再挤成 ready 那一个布尔 (2026-07-31):
# READY 有持仓、成本价过关 → 可以建账
# EMPTY 事实源明确回"一只都没有" → **没活干, 不是出事**。账户本来就是空的,
# 账本也空, 两边一致, 直接开始交易即可, 根本不需要重建这一步
# NOT_READY 有持仓但成本价体检不过 → 真的要人去处理
# NO_SOURCE 两个源都没应答 → 读不到, 更要人去处理 (读不到 ≠ 清仓)
# 原来 EMPTY 和另外两种一样落到 ready=False, 脚本印 [FAIL] 退出码 2, make 报
# Error 2 —— 一个完全正常的终态被报成故障, 让人以为还有事没做完。
out = {"ok": True, "ready": False, "verdict": "NO_SOURCE", "steps": [],
"switches": {}, "hint": ""}
book = [p for p in pms_repo.list_positions() if int(p.get("total_qty") or 0) > 0] book = [p for p in pms_repo.list_positions() if int(p.get("total_qty") or 0) > 0]
out["book_held"] = len(book) out["book_held"] = len(book)
out["first_build"] = not book out["first_build"] = not book
@ -748,12 +757,25 @@ def rebuild_preflight() -> dict:
out["hint"] = "拿不到事实源, 谈不上重建 (读不到 ≠ 清仓)" out["hint"] = "拿不到事实源, 谈不上重建 (读不到 ≠ 清仓)"
return out return out
rows = [r for r in src["rows"] if cs.is_stock_code(r.get("ts_code"))] rows = [r for r in src["rows"] if cs.is_stock_code(r.get("ts_code"))]
out["steps"].append({"step": "事实源", "ok": bool(rows),
"why": f"{src['source']} 给出 {len(rows)} 只持仓"
+ ("" if rows else " —— 对端还没装持仓, 等它")})
if not rows: if not rows:
out["hint"] = "下游一只持仓都没有 —— 等对端装好再来" out["verdict"] = "EMPTY"
out["steps"].append({
"step": "事实源", "ok": True, "blocking": False,
"why": f"{src['source']} 明确回「一只持仓都没有」(空集是数据, 不是缺数据)"})
out["steps"].append({
"step": "结论", "ok": True, "blocking": False,
"why": (f"账户空 + 账本空 ({out['book_held']} 只), 两边一致 —— "
f"**没有需要接管的持仓, 重建这一步可以跳过**")
if not book else
(f"账户空但账本还有 {out['book_held']} 只 —— 这是**反的**: "
f"先清账本 (reset_ledger), 别让对账拿空集去核销")})
out["hint"] = ("账户与账本都是空的, 没什么可重建 —— 直接下命令开始交易即可"
if not book else
f"账户空而账本有 {out['book_held']} 只, 顺序反了: 先 reset_ledger 清账本")
out["ok"] = not book
return out return out
out["steps"].append({"step": "事实源", "ok": True,
"why": f"{src['source']} 给出 {len(rows)} 只持仓"})
prices = market.get_prices([r["ts_code"] for r in rows]) prices = market.get_prices([r["ts_code"] for r in rows])
chk = rbc.check_costs(rows, prices) chk = rbc.check_costs(rows, prices)
@ -780,6 +802,7 @@ def rebuild_preflight() -> dict:
"跑一次 ledger_service.rebuild_accept() 看安全垫分布与行业集中度", "跑一次 ledger_service.rebuild_accept() 看安全垫分布与行业集中度",
] ]
out["ready"] = not chk["blocking"] out["ready"] = not chk["blocking"]
out["verdict"] = "READY" if out["ready"] else "NOT_READY"
out["hint"] = (chk["hint"] if chk["blocking"] else out["hint"] = (chk["hint"] if chk["blocking"] else
f"可以重建: {len(rows)} 只持仓、成本价可用。" + f"可以重建: {len(rows)} 只持仓、成本价可用。" +
("" if cov["enough"] else "注意 " + cov["hint"])) ("" if cov["enough"] else "注意 " + cov["hint"]))

View File

@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
"""
代码指纹 (零依赖, 不连库, import 业务模块)
================================================
运行: python scripts/code_fingerprint.py 只打印一行 12 位短哈希
为什么需要它
------------
**源码是打进镜像的**, `docker compose run --rm pms-web python scripts/xxx.py` 跑的是
**镜像里的那份**, 不是工作树里刚 `git pull` 下来的那份于是有一条特别难发现的路径:
git pull make test ALL SUITES PASS
这个 PASS **旧代码的 PASS**它读起来像"新改的代码验过了", 实际上新代码一行都没跑过
连单测清单都还是旧的 (新增的一批测试文件根本不在镜像里, `run_tests.py` 只会打印
"跳过 xxx (文件不存在)" 然后照样 ALL SUITES PASS)2026-07-31 实机就是这样:
`make t-pre` 的返回里少了新加的字段, 才反推出容器跑的是旧镜像
这跟本项目一直在治的是同一种病 **一个不成立的结论长得像成立**所以给它一个指纹:
`make test` 会把容器里的指纹和工作树的指纹对一遍, 对不上就喊, 而不是让那个 PASS 蒙人
指纹只覆盖 .py 源码 (app/ scripts/ config/) MakefilecomposeDDL 不影响它
那几样不进 Python 运行时, 另有各自的生效路径
"""
import hashlib
import os
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DIRS = ("app", "scripts", "config")
def fingerprint(root: str = ROOT) -> str:
h = hashlib.sha256()
files = []
for d in DIRS:
base = os.path.join(root, d)
for dirpath, dirnames, filenames in os.walk(base):
# __pycache__ 里是编译产物, 跟着源码走, 不该进指纹
dirnames[:] = [x for x in dirnames if x != "__pycache__"]
for fn in filenames:
if fn.endswith(".py"):
files.append(os.path.join(dirpath, fn))
for p in sorted(files, key=lambda x: os.path.relpath(x, root)):
rel = os.path.relpath(p, root).replace(os.sep, "/")
h.update(rel.encode("utf-8"))
h.update(b"\0")
with open(p, "rb") as f:
h.update(hashlib.sha256(f.read()).digest())
return h.hexdigest()[:12]
if __name__ == "__main__":
# 只打印指纹本身, 一行, 不带任何前后缀 —— 调用方要拿它做字符串比较
sys.stdout.write(fingerprint() + "\n")

View File

@ -117,6 +117,16 @@ def main():
pf = ls.rebuild_preflight() pf = ls.rebuild_preflight()
ready = show_preflight(pf) ready = show_preflight(pf)
# EMPTY 是**终态不是故障**: 账户空 + 账本空 = 两边一致, 没有需要接管的持仓,
# 重建这一步本来就该跳过。原来它和「读不到事实源」「成本价不过关」一起退 2,
# make 报 Error 2 —— 一个完全正常的状态被报成失败, 让人以为还有事没做完。
if pf.get("verdict") == "EMPTY":
print("\n(没有需要接管的持仓, 重建这一步跳过 —— **这不是错误**。)"
if pf.get("ok") else
"\n(顺序反了: 先 make reset-ledger CONFIRM=1 清账本, "
"别让对账拿空集去核销已有持仓。)")
return 0 if pf.get("ok") else 2
if not args.yes: if not args.yes:
print("\n(只预检, 没有改任何东西。要执行加 --yes)") print("\n(只预检, 没有改任何东西。要执行加 --yes)")
return 0 if ready else 2 return 0 if ready else 2

View File

@ -14,9 +14,9 @@
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 (32 ) test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 (32 )
test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 (60 ) test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 (60 )
test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 (47 ) test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 (47 )
test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 (33 ) test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 (36 )
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) (58 ) test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) (58 )
384 387
任一子集失败即整体失败 (退出码 1) 任一子集失败即整体失败 (退出码 1)
""" """
import os import os
@ -32,20 +32,38 @@ SUITES = ["test_core_units.py", "test_batch2_units.py", "test_batch3_units.py",
def main(): def main():
# 先把「我到底在跑哪份代码」打出来。源码是打进镜像的, git pull 之后不 build 的话
# 这里跑的还是旧代码, 而它照样会 ALL SUITES PASS —— `make test` 会拿这个指纹跟
# 工作树对一遍, 对不上就喊。详见 scripts/code_fingerprint.py。
try:
from code_fingerprint import fingerprint
print(f"代码指纹 {fingerprint()} (与工作树对不上 = 容器跑的是旧镜像, 先 make deploy)")
except Exception as e:
print(f"代码指纹取不到: {type(e).__name__}: {e}")
failed = [] failed = []
missing = []
for s in SUITES: for s in SUITES:
path = os.path.join(HERE, s) path = os.path.join(HERE, s)
if not os.path.exists(path): if not os.path.exists(path):
print(f"== 跳过 {s} (文件不存在)") # 少一个测试文件绝不能只印一行"跳过"就当没事 —— 镜像是旧的时候,
# 新增的那一批测试正好全部"不存在", 于是一条没跑却报 ALL SUITES PASS。
print(f"== 缺失 {s} —— **这一批一条都没跑**")
missing.append(s)
continue continue
print(f"\n=== {s} " + "=" * (52 - len(s))) print(f"\n=== {s} " + "=" * (52 - len(s)))
r = subprocess.run([sys.executable, path], cwd=ROOT) r = subprocess.run([sys.executable, path], cwd=ROOT)
if r.returncode != 0: if r.returncode != 0:
failed.append(s) failed.append(s)
print("\n" + "=" * 62) print("\n" + "=" * 62)
if missing:
print(f"SUITE MISSING: {', '.join(missing)} —— 这些测试文件不在当前代码里, "
f"一条都没跑。镜像旧了就 make deploy; 真删了就把 SUITES 清单一起改")
if failed: if failed:
print(f"SUITE FAILED: {', '.join(failed)}") print(f"SUITE FAILED: {', '.join(failed)}")
sys.exit(1) sys.exit(1)
if missing:
sys.exit(1) # 「没跑」不许算「通过」
print("ALL SUITES PASS") print("ALL SUITES PASS")

View File

@ -712,6 +712,54 @@ def _():
assert r["scanned"] == 0 and "一条方案都没有" in r["note"], r assert r["scanned"] == 0 and "一条方案都没有" in r["note"], r
@case("[L3] 账户空 + 账本空 → 重建预检判 EMPTY 而不是失败 (没活干 ≠ 出事)")
def _():
from test_wiring import install_fakes
from app.services import ledger_service as ls
install_fakes(prices={})
orig = ls.positions_source
try:
ls.positions_source = lambda: {
"source": "ws", "mode": "ws_first", "rows": [], "age_sec": 191.4,
"columns": {"qty": None, "avail": None, "cost": None}, "alerts": []}
pf = ls.rebuild_preflight()
assert pf["verdict"] == "EMPTY", pf
assert pf["ok"] is True, pf # 两边都空 = 一致, 不是故障
assert all(s["ok"] for s in pf["steps"]), pf["steps"]
assert "可以跳过" in pf["steps"][-1]["why"], pf["steps"]
finally:
ls.positions_source = orig
@case("[L4] 账户空但账本非空 → 顺序反了, 这个才该拦 (别拿空集去核销持仓)")
def _():
from test_wiring import install_fakes
from app.services import ledger_service as ls
fake = install_fakes(prices={"600000.SH": 9.5})
fake.update_position("600000.SH", total_qty=1100, avail_qty=1100, avg_cost=9.273)
orig = ls.positions_source
try:
ls.positions_source = lambda: {
"source": "ws", "mode": "ws_first", "rows": [], "age_sec": 10.0,
"columns": {"qty": None, "avail": None, "cost": None}, "alerts": []}
pf = ls.rebuild_preflight()
assert pf["verdict"] == "EMPTY" and pf["ok"] is False, pf
assert "先清账本" in pf["steps"][-1]["why"] or "reset_ledger" in pf["hint"], pf
finally:
ls.positions_source = orig
@case("[L5] 单测清单里的文件缺失不许算通过 (镜像旧了正好整批不存在)")
def _():
import re
src = open(os.path.join(ROOT, "scripts", "run_tests.py"), encoding="utf-8").read()
# 原来缺文件只印一行"跳过"就继续, 于是镜像旧的时候新增那批测试一条没跑、
# 照样 ALL SUITES PASS。这是"没跑"被当成"通过"。
assert "missing" in src and "SUITE MISSING" in src, "run_tests 缺失分支没兜住"
assert re.search(r"if missing:\s*\n\s*sys\.exit\(1\)", src), "缺失时没有退非零码"
assert "code_fingerprint" in src, "run_tests 要打印代码指纹, 好识别跑的是哪份代码"
# ================================================================ # ================================================================
def main(): def main():
ok = fail = 0 ok = fail = 0