diff --git a/Makefile b/Makefile index 0d2bed0..a3fe82a 100644 --- a/Makefile +++ b/Makefile @@ -21,7 +21,7 @@ DC := docker compose $(PROFILES) # 一次性命令一律 --no-deps: 不然跑个单测都会把 beat/worker 拉起来 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 \ t-plan t-pre t-cmd t-plans t-mat t-dry t-tick t-ins t-book t-gate \ reset-ledger shell @@ -54,9 +54,32 @@ logs: ## 跟日志 (make logs S=pms-ws 只看一个服务) $(DC) logs -f $(S) # ---------------------------------------------------------------- 自检 -test: ## 全部单测 (零外部依赖, 不连库; 应输出 ALL SUITES PASS) +test: ## 全部单测 (先查镜像新旧再跑; 应输出 ALL SUITES PASS) + @$(MAKE) --no-print-directory stale $(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 只演练打印) $(RUN) python scripts/init_db.py $(if $(DRY),,--yes) diff --git a/README.md b/README.md index 8d901d9..2c561c1 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ scripts/ test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 32 例 test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 60 例 test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 47 例 - test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 33 例 + test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 36 例 test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) 58 例 init_db.py 建表 (应用 ddl_pms_v1.sql, 幂等, 默认演练; 含 DDL 体检) check_db.py 实机连通性与表结构自检 (需真实 .env) @@ -78,6 +78,8 @@ scripts/ / 榜单变化 (只读; --snapshot 才落库, 那是它唯一的写操作) rebuild_ledger.py 账本重建: 预检 → 执行 → 判收 (默认只预检; --yes 才改账) 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,八条已修) @@ -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] 四条单测钉住。 -**另一头是「没事长得像出事」,同一种病的反面**:`make t-mat` 三个列表全空时读起来像「一条都没转成」,实际多半是「早就转完了」——它只吃 `PENDING` 状态的方案,已转指令的是 `EXECUTING`、等解锁的批是 `GATED`。现在输出多了 `scanned` 与 `note`,把当前方案分布直接写出来。 +**另一头是「没事长得像出事」,同一种病的反面**,两处: + +`make t-mat` 三个列表全空时读起来像「一条都没转成」,实际多半是「早就转完了」——它只吃 `PENDING` 状态的方案,已转指令的是 `EXECUTING`、等解锁的批是 `GATED`。现在输出多了 `scanned` 与 `note`,把当前方案分布直接写出来。 + +`make rebuild` 在「下游一只持仓都没有」时印 `[FAIL]` 并退出码 2,make 报 `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` 的票。 diff --git a/app/services/ledger_service.py b/app/services/ledger_service.py index 02f8327..6aab679 100644 --- a/app/services/ledger_service.py +++ b/app/services/ledger_service.py @@ -732,7 +732,16 @@ def rebuild_preflight() -> dict: 再加一段开关现状。清账期间关掉的东西, 重建完必须记得打开 —— 尤其 `PMS_SIGNAL_ENABLED`: 账本空时信号消化判 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] out["book_held"] = len(book) out["first_build"] = not book @@ -748,12 +757,25 @@ def rebuild_preflight() -> dict: out["hint"] = "拿不到事实源, 谈不上重建 (读不到 ≠ 清仓)" return out 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: - 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 + out["steps"].append({"step": "事实源", "ok": True, + "why": f"{src['source']} 给出 {len(rows)} 只持仓"}) prices = market.get_prices([r["ts_code"] for r in rows]) chk = rbc.check_costs(rows, prices) @@ -780,6 +802,7 @@ def rebuild_preflight() -> dict: "跑一次 ledger_service.rebuild_accept() 看安全垫分布与行业集中度", ] out["ready"] = not chk["blocking"] + out["verdict"] = "READY" if out["ready"] else "NOT_READY" out["hint"] = (chk["hint"] if chk["blocking"] else f"可以重建: {len(rows)} 只持仓、成本价可用。" + ("" if cov["enough"] else "注意 " + cov["hint"])) diff --git a/scripts/code_fingerprint.py b/scripts/code_fingerprint.py new file mode 100644 index 0000000..b3216c8 --- /dev/null +++ b/scripts/code_fingerprint.py @@ -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/)。改 Makefile、compose、DDL 不影响它 —— +那几样不进 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") diff --git a/scripts/rebuild_ledger.py b/scripts/rebuild_ledger.py index 6f21114..b69e296 100644 --- a/scripts/rebuild_ledger.py +++ b/scripts/rebuild_ledger.py @@ -117,6 +117,16 @@ def main(): pf = ls.rebuild_preflight() 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: print("\n(只预检, 没有改任何东西。要执行加 --yes)") return 0 if ready else 2 diff --git a/scripts/run_tests.py b/scripts/run_tests.py index a16cc4d..0f78021 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 静默失败专项: 关键路径不许丢返回值 + 八条实例 (33 例) + test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 (36 例) test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (58 例) - 共 384 例 + 共 387 例 任一子集失败即整体失败 (退出码 1)。 """ import os @@ -32,20 +32,38 @@ SUITES = ["test_core_units.py", "test_batch2_units.py", "test_batch3_units.py", 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 = [] + missing = [] for s in SUITES: path = os.path.join(HERE, s) if not os.path.exists(path): - print(f"== 跳过 {s} (文件不存在)") + # 少一个测试文件绝不能只印一行"跳过"就当没事 —— 镜像是旧的时候, + # 新增的那一批测试正好全部"不存在", 于是一条没跑却报 ALL SUITES PASS。 + print(f"== 缺失 {s} —— **这一批一条都没跑**") + missing.append(s) continue print(f"\n=== {s} " + "=" * (52 - len(s))) r = subprocess.run([sys.executable, path], cwd=ROOT) if r.returncode != 0: failed.append(s) print("\n" + "=" * 62) + if missing: + print(f"SUITE MISSING: {', '.join(missing)} —— 这些测试文件不在当前代码里, " + f"一条都没跑。镜像旧了就 make deploy; 真删了就把 SUITES 清单一起改") if failed: print(f"SUITE FAILED: {', '.join(failed)}") sys.exit(1) + if missing: + sys.exit(1) # 「没跑」不许算「通过」 print("ALL SUITES PASS") diff --git a/scripts/test_batch10_units.py b/scripts/test_batch10_units.py index 1e76634..8ed26df 100644 --- a/scripts/test_batch10_units.py +++ b/scripts/test_batch10_units.py @@ -712,6 +712,54 @@ def _(): 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(): ok = fail = 0