加一个监测脚本
This commit is contained in:
parent
d376a55fbd
commit
deecd40ffc
10
Makefile
10
Makefile
|
|
@ -126,9 +126,13 @@ rebuild-accept: ## 只看判收: 安全垫分布 / 批次账 / 行业集中度
|
|||
# 实现 B = PMS 自己算 (分日配额 / 分笔 / VWAP / 回踩 / 不追高 / 14:45 兜底) —— 已实现
|
||||
# 所以跑 A 段不需要动决策系统的任何逻辑, 它这一段本来就不参与。
|
||||
A ?= http://127.0.0.1:38100
|
||||
# --no-ensure-ascii 是必须的: 不加的话中文全被转义成 \uXXXX, 每条 reason 都得自己解码,
|
||||
# 而这些接口的 reason 恰恰是最该看的东西 (Python 3.9+)
|
||||
J := python3 -m json.tool --no-ensure-ascii
|
||||
# 中文必须原样出来: `json.tool` 默认把中文转义成 \uXXXX, 而这些接口的 reason 恰恰是最该
|
||||
# 看的东西。**不能用 `json.tool --no-ensure-ascii`** —— 这条管道跑在**宿主机**上不是容器里,
|
||||
# 宿主机的 python3 可能低于 3.9 (2026-08-03 实机: unrecognized arguments)。下面这行任何
|
||||
# python3 都认。解析失败时原样透传, 免得把错误信息也一起吞了。
|
||||
J := python3 -c 'import sys,json;\
|
||||
d=sys.stdin.read();\
|
||||
sys.stdout.write(json.dumps(json.loads(d),ensure_ascii=False,indent=2)+"\n" if d.strip().startswith(("{","[")) else d)'
|
||||
|
||||
# t-pool 与 t-plans 是**两个完全不同的东西**, 别被名字带偏 (原名 t-plan/t-plans 只差一个 s,
|
||||
# 实测把人绕进去过):
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ scripts/
|
|||
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 32 例
|
||||
test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 60 例
|
||||
test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 49 例
|
||||
test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 40 例
|
||||
test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 46 例
|
||||
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) 58 例
|
||||
init_db.py 建表 (应用 ddl_pms_v1.sql, 幂等, 默认演练; 含 DDL 体检)
|
||||
check_db.py 实机连通性与表结构自检 (需真实 .env)
|
||||
|
|
@ -304,7 +304,7 @@ make t-gate # 随时: 规则闸/研判闸拒了什么、为什么
|
|||
|
||||
## 已实现 / 待开发
|
||||
|
||||
**已实现**:建表 DDL 与建表脚本;配置与运行参数中心;仓位规划器与安全垫账;命令系统(27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);规则闸终检;择时执行器实现 B(分日配额、分笔、VWAP/回踩/不追高、14:45 兜底、停牌一字板顺延、窗口耗尽收口、挂单有效期);动作引擎四类自主动作 + 研判闸客户端 + 提议分流;决策系统信号消化(两条流独立消费组订阅、置信度分档转清仓指令或提议);管理页面四块 + 运维/日报抽屉;调度器九个调度位;**上游选股计划接口接入**(`/plan` 取候选池、交易日龄硬校验、`theme` 灌行业映射表、页面预览抽屉与不可用横幅);**榜单变化提示**(名册快照 + 新进/掉榜/档位升降/覆盖翻转/名次跳变,持仓票单列,榜尾截断噪音闸);**ws 直连通道的连接层**(常驻进程 + 出口队列 + 签名 + seq 水位与累积确认,见下);**单测 393 例**。
|
||||
**已实现**:建表 DDL 与建表脚本;配置与运行参数中心;仓位规划器与安全垫账;命令系统(27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);规则闸终检;择时执行器实现 B(分日配额、分笔、VWAP/回踩/不追高、14:45 兜底、停牌一字板顺延、窗口耗尽收口、挂单有效期);动作引擎四类自主动作 + 研判闸客户端 + 提议分流;决策系统信号消化(两条流独立消费组订阅、置信度分档转清仓指令或提议);管理页面四块 + 运维/日报抽屉;调度器九个调度位;**上游选股计划接口接入**(`/plan` 取候选池、交易日龄硬校验、`theme` 灌行业映射表、页面预览抽屉与不可用横幅);**榜单变化提示**(名册快照 + 新进/掉榜/档位升降/覆盖翻转/名次跳变,持仓票单列,榜尾截断噪音闸);**ws 直连通道的连接层**(常驻进程 + 出口队列 + 签名 + seq 水位与累积确认,见下);**单测 399 例**。
|
||||
|
||||
### 静默失败专项(2026-07-31,八条已修)
|
||||
|
||||
|
|
@ -346,6 +346,10 @@ make t-gate # 随时: 规则闸/研判闸拒了什么、为什么
|
|||
|
||||
`make rebuild` 在「下游一只持仓都没有」时印 `[FAIL]` 并退出码 2,make 报 `Error 2`——可账户空 + 账本空 = 两边一致,**这是终态不是故障**,压根不需要重建这一步。预检现在给出 `verdict`:`READY` / `EMPTY` / `NOT_READY` / `NO_SOURCE`,`EMPTY` 退 0 并明说「没有需要接管的持仓,重建这一步跳过」。只有一种 `EMPTY` 仍然要拦:**账户空而账本非空**,那是顺序反了,得先 `reset_ledger` 清账本,别让对账拿空集去核销已有持仓。
|
||||
|
||||
**第四种变体:测试桩比真依赖宽松,于是单测替真依赖打掩护。** 2026-08-03 切 ws 当天撞到:`executor.run_tick` 给 `pms_repo.update_instruction` 多传了一个 `limit_price=None`,真 repo 没这个形参 → `TypeError` → 被外层 `except Exception` 吞成 `out["errors"]` 里的一条。后果是**单子已经发到 QMT 了,而本端一个字没记**——父指令停在 `PROPOSED`、`children` 空、`dispatch_ref` 空,当日配额恒按 0 算,下一跳会拿同一个子单号 `_D01` 再发一次(出口表唯一索引拦住之后就彻底卡死)。
|
||||
|
||||
单测全程没看见,因为 `FakeRepo.update_instruction` 写的是 `def update_instruction(self, iid, **kw)`——什么关键字都收。**桩比真依赖宽松,等于单测在替真依赖打掩护。** `test_batch10_units.py` 的 **[M] 组**守这一条:从**源码**(AST)读 `pms_repo` 的真实签名,逐个比对 `FakeRepo` 的同名方法,桩收 `**kw` 而真 repo 是固定签名就判失败。注意必须从源码读——`install_fakes` 已经把 `pms_repo` 的函数替换成桩的绑定方法了,`getattr(pms_repo, name)` 拿到的是桩自己,那样这条守卫会永远绿(第一版就是这么写错的,守卫本身也会静默失效)。`make watch` 里另有一段常驻交叉检查:出口表有委托而父指令还停在 `PROPOSED`、或 `children` 条数跟出口表对不上,直接顶在屏幕上。
|
||||
|
||||
**第三种变体最阴,是「没跑长得像通过」。** 源码是打进镜像的,`docker compose run pms-web python scripts/run_tests.py` 跑的是**镜像里那份**。于是有这么一条路径:
|
||||
|
||||
```
|
||||
|
|
|
|||
|
|
@ -236,9 +236,23 @@ def run_tick(*, now=None, dry_run: bool = False) -> dict:
|
|||
prog.setdefault("dispatched_at", now.strftime("%Y-%m-%d %H:%M:%S"))
|
||||
prog["last_decision"] = {"at": now.strftime("%H:%M"), "action": et.ACT_FIRE,
|
||||
"reason": d["reason"]}
|
||||
pms_repo.update_instruction(ins["instruction_id"], status=ST_DISPATCHED,
|
||||
limit_price=None, dispatch_ref=res["ref"],
|
||||
progress=prog)
|
||||
# 这里曾经多传了一个 limit_price=None (2026-08-03 实机): 真 repo 的
|
||||
# update_instruction 没有这个形参 → TypeError → 被本函数外层的
|
||||
# `except Exception` 吞成 out["errors"] 里的一条。后果是**单子已经发到 QMT
|
||||
# 了, 而本端一个字没记**: 父指令停在 PROPOSED、children 空、dispatch_ref 空,
|
||||
# 于是当日配额恒按 0 算, 下一跳会拿同一个子单号 _D01 再发一次 —— 出口表唯一
|
||||
# 索引拦住之后就彻底卡死。单测全程没看见, 因为 FakeRepo.update_instruction
|
||||
# 是 `**kw` 全收, 比真依赖宽松 (见 test_batch10 的 [M] 组)。
|
||||
r = pms_repo.update_instruction(ins["instruction_id"], status=ST_DISPATCHED,
|
||||
dispatch_ref=res["ref"], progress=prog)
|
||||
if not r:
|
||||
# 单子已经出去了, 这里回滚不了; 但绝不能让它悄悄过去
|
||||
logger.error("[出手] %s 已下发到通道, 但父指令没更新到任何行 —— "
|
||||
"本端与出口表将不一致, 请核对 pms_instruction",
|
||||
ins["instruction_id"])
|
||||
out["errors"].append(
|
||||
f"{ins['instruction_id']} 已下发但父指令未更新 (影响 0 行) —— "
|
||||
f"出口表与本端不一致")
|
||||
pms_repo.insert_ledger(ts_code=code, action=ins.get("action"), arbiter="rule",
|
||||
verdict="PASS", price_at=day_ctx.get("price") or 0,
|
||||
hard_numbers={**gate["hard_numbers"], "limit": d["limit_price"],
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@
|
|||
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 (32 例)
|
||||
test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 (60 例)
|
||||
test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 (49 例)
|
||||
test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 (40 例)
|
||||
test_batch10_units.py 静默失败专项: 关键路径不许丢返回值 + 八条实例 (46 例)
|
||||
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (58 例)
|
||||
共 393 例
|
||||
共 399 例
|
||||
任一子集失败即整体失败 (退出码 1)。
|
||||
"""
|
||||
import os
|
||||
|
|
|
|||
|
|
@ -830,6 +830,86 @@ def _():
|
|||
assert "code_fingerprint" in src, "run_tests 要打印代码指纹, 好识别跑的是哪份代码"
|
||||
|
||||
|
||||
# ================================================================
|
||||
# [M] 测试桩不许比真依赖宽松
|
||||
# ================================================================
|
||||
@case("[M1] FakeRepo 的方法签名不许比 pms_repo 宽松 (`**kw` 全收会替真依赖打掩护)")
|
||||
def _():
|
||||
import ast
|
||||
import inspect
|
||||
from test_wiring import FakeRepo
|
||||
|
||||
# 2026-08-03 实机: executor 给 update_instruction 多传了一个 limit_price=None,
|
||||
# 真 repo 没这个形参 → TypeError → 被外层 except 吞掉 → **单子已发到 QMT 而本端
|
||||
# 一个字没记**。单测全程没看见, 因为桩是 `def update_instruction(self, iid, **kw)`,
|
||||
# 什么关键字都收。桩比真依赖宽松, 等于单测在替真依赖打掩护 —— 这一条守住它。
|
||||
#
|
||||
# 真 repo 的签名**必须从源码读**, 不能 getattr(pms_repo, name): install_fakes 已经
|
||||
# 把 pms_repo 的函数替换成桩的绑定方法了, 那样等于拿桩跟自己比, 这条守卫会永远绿。
|
||||
# (第一版就是这么写错的 —— 守卫本身也会静默失效。)
|
||||
real = {}
|
||||
path = os.path.join(ROOT, "app", "repo", "pms_repo.py")
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for node in ast.parse(f.read(), path).body:
|
||||
if isinstance(node, ast.FunctionDef) and not node.name.startswith("_"):
|
||||
a = node.args
|
||||
real[node.name] = {
|
||||
"kw": [x.arg for x in (a.args + a.kwonlyargs)],
|
||||
"var_kw": a.kwarg is not None}
|
||||
assert "update_instruction" in real, "没能从源码解析出 pms_repo 的签名"
|
||||
|
||||
bad = []
|
||||
for name, r in sorted(real.items()):
|
||||
fake = getattr(FakeRepo, name, None)
|
||||
if not callable(fake):
|
||||
continue
|
||||
fp = inspect.signature(fake).parameters
|
||||
fake_var_kw = any(p.kind is inspect.Parameter.VAR_KEYWORD for p in fp.values())
|
||||
if fake_var_kw and not r["var_kw"]:
|
||||
bad.append(f"{name}: 桩收 **kw 而真 repo 是固定签名 "
|
||||
f"({', '.join(r['kw'])}) —— 调用方多传一个参数, "
|
||||
f"真机抛 TypeError 而单测照样绿")
|
||||
assert not bad, ("测试桩比真 repo 宽松, 单测会盖住真机才暴露的错:\n "
|
||||
+ "\n ".join(bad))
|
||||
|
||||
|
||||
@case("[M2] 扫描器本身有效: 造一个宽松桩, 必须被 M1 那套判据抓出来")
|
||||
def _():
|
||||
import inspect
|
||||
class _Real:
|
||||
def f(self, a, *, b=None):
|
||||
return 1
|
||||
class _Fake:
|
||||
def f(self, a, **kw):
|
||||
return 1
|
||||
rp = inspect.signature(_Real.f).parameters
|
||||
fp = inspect.signature(_Fake.f).parameters
|
||||
assert any(p.kind is inspect.Parameter.VAR_KEYWORD for p in fp.values())
|
||||
assert not any(p.kind is inspect.Parameter.VAR_KEYWORD for p in rp.values())
|
||||
|
||||
|
||||
@case("[M3] 下发成功但父指令没更新到任何行 → 必须冒进 errors, 不许静默")
|
||||
def _():
|
||||
import ast
|
||||
path = os.path.join(ROOT, "app", "services", "executor.py")
|
||||
with open(path, encoding="utf-8") as f:
|
||||
src = f.read()
|
||||
# 只看真实调用的实参, 不看注释 —— 注释里提这个词是在解释这个坑, 不该被自己抓
|
||||
bad = []
|
||||
for node in ast.walk(ast.parse(src, path)):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
fn = node.func
|
||||
if isinstance(fn, ast.Attribute) and fn.attr == "update_instruction":
|
||||
names = {k.arg for k in node.keywords if k.arg}
|
||||
extra = names - {"status", "exec_qty", "exec_avg_price", "dispatch_ref",
|
||||
"progress"}
|
||||
if extra:
|
||||
bad.append(f"第 {node.lineno} 行传了 pms_repo 不认的参数 {sorted(extra)}")
|
||||
assert not bad, "update_instruction 调用点参数不合真 repo 签名:\n " + "\n ".join(bad)
|
||||
assert "已下发但父指令未更新" in src, "下发成功但更新影响 0 行时没有留出口"
|
||||
|
||||
|
||||
# ================================================================
|
||||
def main():
|
||||
ok = fail = 0
|
||||
|
|
|
|||
|
|
@ -52,7 +52,11 @@ class FakeRepo:
|
|||
def next_command_seq(self, ymd):
|
||||
return len(self.commands) + 1
|
||||
|
||||
def insert_command(self, **kw):
|
||||
def insert_command(self, *, command_id, cmd_class, cmd_type, ts_code, params, status,
|
||||
issued_by=None, note=None, progress=None):
|
||||
kw = {"command_id": command_id, "cmd_class": cmd_class, "cmd_type": cmd_type,
|
||||
"ts_code": ts_code, "params": params, "status": status,
|
||||
"issued_by": issued_by, "note": note, "progress": progress}
|
||||
self.commands[kw["command_id"]] = {
|
||||
"id": len(self.commands) + 1, "command_id": kw["command_id"],
|
||||
"cmd_class": kw["cmd_class"], "cmd_type": kw["cmd_type"], "ts_code": kw["ts_code"],
|
||||
|
|
@ -221,7 +225,13 @@ class FakeRepo:
|
|||
"note": note})
|
||||
return 1
|
||||
|
||||
def insert_instruction(self, **kw):
|
||||
def insert_instruction(self, *, instruction_id, origin_type, origin_id, ts_code, action,
|
||||
side, qty, limit_price=None, window_tdays=3, status="PROPOSED",
|
||||
progress=None):
|
||||
kw = {"instruction_id": instruction_id, "origin_type": origin_type,
|
||||
"origin_id": origin_id, "ts_code": ts_code, "action": action, "side": side,
|
||||
"qty": qty, "limit_price": limit_price, "window_tdays": window_tdays,
|
||||
"status": status, "progress": progress or {}}
|
||||
kw.setdefault("exec_qty", 0)
|
||||
kw["created_at"] = kw["updated_at"] = datetime.now()
|
||||
self.instructions[kw["instruction_id"]] = kw
|
||||
|
|
@ -237,11 +247,18 @@ class FakeRepo:
|
|||
def get_instruction(self, iid):
|
||||
return self.instructions.get(iid)
|
||||
|
||||
def update_instruction(self, iid, **kw):
|
||||
# **签名必须跟真 repo 一模一样**, 不许写成 `**kw`。2026-08-03 实机: executor 多传了
|
||||
# 一个 limit_price=None, 真 repo 抛 TypeError、单子已发出而本端没记账, 而这里 `**kw`
|
||||
# 全收所以单测一路绿。桩比真依赖宽松 = 单测在替真依赖打掩护。
|
||||
# test_batch10 的 [M] 组会扫这一条, 别改回 **kw。
|
||||
def update_instruction(self, iid, *, status=None, exec_qty=None, exec_avg_price=None,
|
||||
dispatch_ref=None, progress=None):
|
||||
i = self.instructions.get(iid)
|
||||
if not i:
|
||||
return 0
|
||||
for k, v in kw.items():
|
||||
for k, v in (("status", status), ("exec_qty", exec_qty),
|
||||
("exec_avg_price", exec_avg_price), ("dispatch_ref", dispatch_ref),
|
||||
("progress", progress)):
|
||||
if v is not None:
|
||||
i[k] = v
|
||||
i["updated_at"] = datetime.now()
|
||||
|
|
@ -253,7 +270,11 @@ class FakeRepo:
|
|||
i["exec_qty"] = int(i.get("exec_qty") or 0) + int(qty)
|
||||
return 1
|
||||
|
||||
def insert_proposal(self, **kw):
|
||||
def insert_proposal(self, *, proposal_id, ts_code, action, qty, hard_numbers, expire_at,
|
||||
judge_verdict=None, judge_reason=None, status="WAIT_USER"):
|
||||
kw = {"proposal_id": proposal_id, "ts_code": ts_code, "action": action, "qty": qty,
|
||||
"hard_numbers": hard_numbers, "expire_at": expire_at,
|
||||
"judge_verdict": judge_verdict, "judge_reason": judge_reason, "status": status}
|
||||
kw["status"] = kw.get("status", "WAIT_USER")
|
||||
kw["hard_numbers"] = kw.get("hard_numbers") or {}
|
||||
self.proposals[kw["proposal_id"]] = kw
|
||||
|
|
@ -275,7 +296,12 @@ class FakeRepo:
|
|||
def expire_proposals(self, now=None):
|
||||
return 0
|
||||
|
||||
def insert_ledger(self, **kw):
|
||||
def insert_ledger(self, *, ts_code, action, arbiter, verdict, price_at, hard_numbers=None,
|
||||
failed_checks=None, reason=None, ref_id=None):
|
||||
kw = {"ts_code": ts_code, "action": action, "arbiter": arbiter, "verdict": verdict,
|
||||
"price_at": price_at, "hard_numbers": hard_numbers or {},
|
||||
"failed_checks": failed_checks or [], "reason": reason, "ref_id": ref_id,
|
||||
"decided_at": datetime.now()}
|
||||
self.ledger.append(kw)
|
||||
return 1
|
||||
|
||||
|
|
@ -1152,17 +1178,20 @@ def _():
|
|||
"deadline": "2026-07-27", "status": "EXECUTING", "reason": "收利润"}])
|
||||
fake.insert_instruction(instruction_id="INS_F", origin_type="plan", origin_id="P_F",
|
||||
ts_code="600000.SH", action="TRIM", side="sell", qty=2000,
|
||||
status="DISPATCHED", exec_qty=2000,
|
||||
status="DISPATCHED",
|
||||
progress={"deadline": "2026-07-27", "is_command": True,
|
||||
"children": []})
|
||||
# exec_qty 不是 insert_instruction 的形参 (真 repo 没有), 造数据直接改桩里的字段
|
||||
fake.instructions["INS_F"]["exec_qty"] = 2000
|
||||
fake.insert_instruction(instruction_id="INS_P", origin_type="plan", origin_id="P_F",
|
||||
ts_code="600000.SH", action="TRIM", side="sell", qty=2000,
|
||||
status="DISPATCHED", exec_qty=500,
|
||||
status="DISPATCHED",
|
||||
progress={"deadline": "2026-07-20", "is_command": True,
|
||||
"children": []})
|
||||
fake.instructions["INS_P"]["exec_qty"] = 500
|
||||
fake.insert_instruction(instruction_id="INS_A2", origin_type="proposal", origin_id="PR1",
|
||||
ts_code="600000.SH", action="ADD", side="buy", qty=1000,
|
||||
status="DISPATCHED", exec_qty=0,
|
||||
status="DISPATCHED",
|
||||
progress={"deadline": "2026-07-20", "is_command": False,
|
||||
"children": []})
|
||||
r = executor.sweep_windows(now=_dt(2026, 7, 27, 15, 10))
|
||||
|
|
@ -1317,7 +1346,7 @@ def _():
|
|||
fake = install_fakes(prices={"600000.SH": 10.0})
|
||||
fake.insert_instruction(instruction_id="INS_R", origin_type="plan", origin_id="P1",
|
||||
ts_code="600000.SH", action="OPEN", side="buy", qty=100,
|
||||
price_hint=10.0, status="DISPATCHED")
|
||||
status="DISPATCHED")
|
||||
# 真单: 出口表有行、父指令不带 SMOKE_
|
||||
fake.qmt.enqueue_order(instruction_id="INS_R-1", parent_id="INS_R", ts_code="600000.SH",
|
||||
side="buy", qty=100, limit_price=10.0, valid_until=0)
|
||||
|
|
|
|||
Loading…
Reference in New Issue