账本重建

This commit is contained in:
zlt 2026-07-31 13:46:15 +08:00
parent 582925732d
commit bab6d0ec27
9 changed files with 831 additions and 6 deletions

View File

@ -22,7 +22,7 @@ DC := docker compose $(PROFILES)
RUN := docker compose run --rm --no-deps pms-web
.PHONY: help deploy deploy-local build up down ps logs test initdb check health \
probe changes industry ws-status reset-ledger shell
probe changes industry ws-status rebuild rebuild-accept reset-ledger shell
help: ## 列出所有目标
@grep -hE '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
@ -78,6 +78,13 @@ industry: ## 行业源实探 (ready=false 就是硬拦截失效, 别当它绿着
ws-status: ## ws 通道状态 (连接态 / seq 水位 / 出口队列)
$(RUN) python scripts/ws_smoke.py status
# ---------------------------------------------------------------- 账本重建 (待办 #4)
rebuild: ## 账本重建预检 (只读)。加 GO=1 才真的建账, FORCE=1 连预检不过也建
$(RUN) python scripts/rebuild_ledger.py $(if $(GO),--yes,) $(if $(FORCE),--force,)
rebuild-accept: ## 只看判收: 安全垫分布 / 批次账 / 行业集中度
$(RUN) python scripts/rebuild_ledger.py --accept
# ---------------------------------------------------------------- 危险操作 (要确认)
reset-ledger: ## 清空账本重来 (影子期专用; 必须 CONFIRM=1)
@if [ "$(CONFIRM)" != "1" ]; then \

View File

@ -33,6 +33,7 @@ app/
action_engine.py 动作引擎: FILL 回踩补足 / ADD 盈利加仓 / DCA 补仓 / TRIM 保垫减仓
signal_rules.py 决策系统两条信号流的解析与消化口径 (含置信度尺度归一)
plan_diff.py 上游榜单的版本比对: 名册指纹 / 新进掉榜 / 档位升降 / 榜尾噪音闸
rebuild_check.py 接管既有持仓前的成本价体检 (阻断判据 + §5.2 情形覆盖)
tradedays.py 交易日历: 调度守卫与执行窗口计算
ws_codec.py QMT 协议编解码: 规范化串 / Ed25519 签名验签 / 信封 / seq 水位推进
db/session.py 三库连接 + **严格单表访问守卫** (JOIN/逗号连表/跨表子查询一律拒绝)
@ -65,6 +66,7 @@ scripts/
test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/DDL/逐笔入账 65 例
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 32 例
test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 60 例
test_batch9_units.py 接管持仓前的成本价体检: 阻断判据/情形覆盖 23 例
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) 58 例
init_db.py 建表 (应用 ddl_pms_v1.sql, 幂等, 默认演练; 含 DDL 体检)
check_db.py 实机连通性与表结构自检 (需真实 .env)
@ -72,6 +74,7 @@ scripts/
reset_ledger.py 清空账本并把回放游标对齐到当前 (影子运行期重来一次; 不碰下游表)
probe_plan_api.py 上游计划实机探活: 通不通 / 字段口径 / 候选筛选结果 / 有没有价
/ 榜单变化 (只读; --snapshot 才落库, 那是它唯一的写操作)
rebuild_ledger.py 账本重建: 预检 → 执行 → 判收 (默认只预检; --yes 才改账)
ws_smoke.py ws 联调工具: status/watch/place/cancel/inbox (绕开 dispatch_mode)
```
@ -196,6 +199,22 @@ QMT ──trade/order_update──▶ pms-ws ──落 pms_qmt_inbox──▶
这条不是细节:拿现价当成本,摊薄成本就等于当天价、**安全垫齐刷刷是 0**——而安全垫是盈利加仓≥3%、保垫减仓峰值≥6%、补仓评估档8%/15%)共同的判断依据。一只真实成本 20、现价 10 的票(实亏 50%)会被记成「不赚不亏」,该评估的补仓不评估;页面和日报上的浮动盈亏也全是 0。2026-07-29 首次接管 22 只持仓时踩到,已修并有单测守着。
**但「优先取成本价」还不够——兜底是静默的。** 下游那一列填 0、留空、或干脆等于现价时对账照样建账只在 note 里留一句「现价兜底」。结果是账本建起来了、页面一切正常、每个数都长得像真的,只有安全垫齐刷刷是 0而没人会盯着一个「看起来就该是 0」的字段。所以 2026-07-31 加了一道**首次建账的成本价硬闸**`app/core/rebuild_check.py`
- **只在账本为空时生效**——那一刻是不可逆的,这批 RECON 批次的开仓价会定死每只票的摊薄成本。日常漂移不走这道闸,否则每天拦一次对账才是真的坏事。
- 拦两类:**任何一只成本价缺失/为 0/与现价差两个数量级/可用量大于总量**;以及**整组成本≈现价**(默认 ≥80%)——单只可能是当日买入,整组都这样就是有人拿现价填的。
- 拦下来只是不建账,不改任何东西。确认这份数据就是对的,可以 `--force` 放行,那等于声明你接受安全垫从 0 起算。
配套一条命令,默认只预检不改账:
```bash
make rebuild # 预检(只读):事实源 → 成本价体检 → 情形覆盖 → 开关现状
make rebuild GO=1 # 预检过了才执行,执行完自动判收
make rebuild-accept # 只看判收:安全垫分布 / 批次账 / 行业集中度
```
判收里最硬的一条是**安全垫分布**:如果重建后每一只票的安全垫都是 0那就是踩了这个坑——页面上每个数都合理只有这一处露馅。行业集中度那一项顺带回答 `PMS_SECTOR_MAX_RATIO=40%` 在三级粒度下偏不偏松(分母与 `sizer.check_caps` 一致,是**组合持仓市值**不是总规模,两边用不同分母会得出两个都自称「行业集中度」的数)。
## 自主提议的分流(设计 §6 / §7
动作引擎每分钟扫一遍持仓,产出 FILL / ADD / DCA / TRIM 四类候选,然后依次过三道:
@ -216,7 +235,7 @@ QMT ──trade/order_update──▶ pms-ws ──落 pms_qmt_inbox──▶
## 已实现 / 待开发
**已实现**:建表 DDL 与建表脚本配置与运行参数中心仓位规划器与安全垫账命令系统27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);规则闸终检;择时执行器实现 B分日配额、分笔、VWAP/回踩/不追高、14:45 兜底、停牌一字板顺延、窗口耗尽收口、挂单有效期);动作引擎四类自主动作 + 研判闸客户端 + 提议分流;决策系统信号消化(两条流独立消费组订阅、置信度分档转清仓指令或提议);管理页面四块 + 运维/日报抽屉;调度器九个调度位;**上游选股计划接口接入**`/plan` 取候选池、交易日龄硬校验、`theme` 灌行业映射表、页面预览抽屉与不可用横幅);**榜单变化提示**(名册快照 + 新进/掉榜/档位升降/覆盖翻转/名次跳变,持仓票单列,榜尾截断噪音闸);**ws 直连通道的连接层**(常驻进程 + 出口队列 + 签名 + seq 水位与累积确认,见下);**单测 304 例**。
**已实现**:建表 DDL 与建表脚本配置与运行参数中心仓位规划器与安全垫账命令系统27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);规则闸终检;择时执行器实现 B分日配额、分笔、VWAP/回踩/不追高、14:45 兜底、停牌一字板顺延、窗口耗尽收口、挂单有效期);动作引擎四类自主动作 + 研判闸客户端 + 提议分流;决策系统信号消化(两条流独立消费组订阅、置信度分档转清仓指令或提议);管理页面四块 + 运维/日报抽屉;调度器九个调度位;**上游选股计划接口接入**`/plan` 取候选池、交易日龄硬校验、`theme` 灌行业映射表、页面预览抽屉与不可用横幅);**榜单变化提示**(名册快照 + 新进/掉榜/档位升降/覆盖翻转/名次跳变,持仓票单列,榜尾截断噪音闸);**ws 直连通道的连接层**(常驻进程 + 出口队列 + 签名 + seq 水位与累积确认,见下);**单测 327 例**。
### 下一步(按可动工顺序)
@ -225,7 +244,7 @@ QMT ──trade/order_update──▶ pms-ws ──落 pms_qmt_inbox──▶
| 1 | ~~ws 通道的账本侧改造(清单 4~6~~ | ✅ 2026-07-29 |
| 2 | ~~上游选股计划接入(`/plan`~~ | ✅ 2026-07-31候选池独占来源、交易日龄硬校验、盘前昨收兜底、ST 剔除、PMS 侧主题限额、页面抽屉与探活脚本。口径与实测记录见 `UPSTREAM_PLAN_API.md` |
| 3 | ~~行业硬拦截开闸~~ | ✅ 2026-07-31`PMS_SECTOR_SOURCE=gp_hybk`199 库,三级 884*,每票只认 bk_code 最小的主行业),实测 `ready=true`、代码写法 `prefix`。**这是 PMS 第一次有可用的行业源。**待账本重建后回看 `PMS_SECTOR_MAX_RATIO=40%` 对三级粒度是否偏松(参考项目三级用 20%)。详见 `UPSTREAM_PLAN_API.md` §8 |
| 4 | **账本重建**清账后账本为空,需对端持仓就绪后 `POST /api/ops/reconcile?apply_fix=true` 以下游为准补回 | 阻塞:等 QMT 侧按真实成本价重建模拟持仓(`QMT_SIDE_S3_CLOSEOUT.md` §5 |
| 4 | **账本重建**预检/执行/判收已就绪(`make rebuild`**首次建账加了成本价硬闸** | 阻塞:等 QMT 侧按真实成本价重建模拟持仓(`QMT_SIDE_S3_CLOSEOUT.md` §5。对端一装好就 `make rebuild` 看预检,过了再 `make rebuild GO=1` |
| 5 | **ws 通道联调收尾**(协议 §9 S3 | 阻塞:`trade_no` 格式不合 §5.5`QMT_SIDE_S3_CLOSEOUT.md` §1这条挡住切 ws |
| 6 | 上游计划的剩余待确认口径 | 等上游:`UPSTREAM_PLAN_API.md` §4 剩 5 条 + §7.3 新增两条(同一 `date` 计划不幂等、档位规模与文档不符) |
| 7 | ~~榜单变化做成页面提示(新进传导链 / 掉榜)~~ | ✅ 2026-07-31上游 `changes` 恒为 `null`,所以**改成 PMS 自己算**——每次拉计划落一份名册快照(第 15 张表),差异由纯逻辑比。顺带补上「同一 `date` 多版本、下游无从分辨手上是哪一版」那个洞。详见 `UPSTREAM_PLAN_API.md` §9 |

View File

@ -648,7 +648,7 @@ PMS_PLAN_DIFF_TAIL_GUARD 0.5 榜尾进出的噪音闸, 见 9.4
```bash
# 【服务器 factorevaluation · ~/project/tradingSystem】
make test # 应输出 ALL SUITES PASS (304 例)
make test # 应输出 ALL SUITES PASS (327 例)
make initdb # 幂等, 建第 15 张表
make check # [2] 段应能看到 pms_plan_snapshot
make probe SNAP=1 # 落第一份快照; [7] 段此时应报"库里还没有快照"

184
app/core/rebuild_check.py Normal file
View File

@ -0,0 +1,184 @@
# -*- coding: utf-8 -*-
"""
接管既有持仓前的成本价体检 (纯逻辑, 零外部依赖)
================================================
账本清空之后要按以下游为准把真实持仓认领回来 (README 待办 #4)。这一步只发生一次,
但它定死了每一只票的**开仓价**, 而开仓价一错, 后面所有纪律都错在一个看不出来的地方
链条是这样的:
下游 cost_price RECON 批次开仓价 摊薄成本 安全垫
盈利加仓(3%) · 保垫减仓(峰值6%) · 补仓评估档(8%/15%)
`recon.build_recon_fixes` 已经做了对的事: 开仓价优先取下游成本价, 现价只兜底**但兜底是
静默的** 下游那一列填 0填空或干脆等于现价时, 它照样建账, 只在 note 里留一句"现价
兜底"。结果是摊薄成本等于当天价、**安全垫齐刷刷是 0**: 一只真实成本 20、现价 10 的票
(实亏 50%) 被记成"不赚不亏", 该评估的补仓不评估, 页面和日报的浮动盈亏也全是 0
2026-07-29 首次接管 22 只持仓时踩过一次
**漏账看得见, 错账看不见** 拒绝建账的代价是等对端把数据填对 (一句话的事); 建错账的代价
是一本从头到尾都合理但每个数都错的账 而且它跟真账长得一模一样所以这里的默认行为
**拿不准就不建**, 与本仓库拿不到 通过是同一条纪律
本模块只做判断不碰库, 也不决定要不要放行 判据交给调用方 (`ledger_service.reconcile`
的建账闸 / `scripts/rebuild_ledger.py` 的预检段)
"""
from __future__ import annotations
# 单只票的体检结论
OK = "OK" # 成本价可用
MISSING = "MISSING" # 缺失 / <=0 —— 会走现价兜底, 安全垫必为 0
EQ_PRICE = "EQ_PRICE" # 与现价几乎相等 —— 安全垫≈0, 单只可能是真的, 全组合则不可能
ABSURD = "ABSURD" # 与现价差了两个数量级 —— 多半是单位或字段错了
AVAIL_BAD = "AVAIL_BAD" # 可用量 > 总量, 或为负 —— T+1 口径错
NO_PRICE = "NO_PRICE" # 本端取不到现价, 判不了 EQ_PRICE / ABSURD (成本本身仍可用)
# 判为"与现价几乎相等"的相对阈值。0.5% 已经远小于一个交易日的正常波动 ——
# 真实成本恰好落进这个带里是可能的, 但整个组合都落进来就不是巧合了。
EQ_EPS = 0.005
# 与现价的比值超出这个区间即判 ABSURD。取 100 倍是因为 A 股不存在这种成本差,
# 出现只可能是单位错 (分/元)、字段串位或脏数据。
ABSURD_LO, ABSURD_HI = 0.01, 100.0
def check_row(row: dict, price=None) -> dict:
"""一只票的成本价体检。row: {ts_code, qty, avail_qty, cost, ...}; price: 本端现价。"""
code = row.get("ts_code")
qty = _int(row.get("qty"))
avail = row.get("avail_qty")
cost = _float(row.get("cost"))
px = _float(price if price is not None else row.get("price"))
out = {"ts_code": code, "qty": qty, "avail_qty": avail, "cost": cost, "price": px,
"verdict": OK, "why": "", "cushion_pct": None}
if avail is not None:
a = _int(avail)
if a < 0 or a > qty:
out.update({"verdict": AVAIL_BAD,
"why": f"可用量 {a} 不合理 (总量 {qty}) —— T+1 可卖口径会跟着错"})
return out
if cost is None or cost <= 0:
out.update({"verdict": MISSING,
"why": "下游没给成本价 —— 建账会退回现价, 摊薄成本=当天价、安全垫恒为 0"})
return out
if px is None or px <= 0:
out.update({"verdict": NO_PRICE,
"why": "本端取不到现价, 无法核对成本是否合理 (成本值本身可用)"})
return out
ratio = cost / px
if ratio <= ABSURD_LO or ratio >= ABSURD_HI: # 边界含在内: 正好 100 倍也不是真成本
out.update({"verdict": ABSURD,
"why": f"成本 {cost:.4g} 与现价 {px:.4g} 差了 {ratio:.3g} 倍 —— "
f"多半是单位(分/元)或字段串位, 不是真成本"})
return out
out["cushion_pct"] = (px - cost) / cost
if abs(px - cost) / px <= EQ_EPS:
out.update({"verdict": EQ_PRICE,
"why": f"成本 {cost:.4g} ≈ 现价 {px:.4g} —— 安全垫≈0。"
f"单只可能是当日买入, 整组都这样则是拿现价充的成本"})
return out
def check_costs(rows, price_map=None, *, eq_ratio_block: float = 0.8) -> dict:
"""整批持仓的成本价体检。
`blocking` 为真表示**不该照这份数据建账**两条阻断判据:
1. **任何一只 MISSING / ABSURD / AVAIL_BAD** 这些是数据本身不可用, 不是判断问题
一只错就够呛 账本是一本账, 里面混一只错成本的票, 之后每次看这本账都得先想起
"那只是估的"宁可等对端填对
2. **EQ_PRICE 占比过高** (默认 80%)单只票成本现价完全可能 (当日买入); 整个组合
都这样, 那就是有人拿现价当成本填进去了 而这正是 07-29 踩的那个坑的模样,
区别只在于上次是我方拿现价兜底, 这次是对端拿现价填结果一模一样: 安全垫全 0
NO_PRICE 不阻断: 现价取不到是本端行情的事, 成本值本身没毛病, 建账照常, 只是没法核
"""
rows = [r for r in (rows or []) if _int(r.get("qty")) > 0]
price_map = price_map or {}
checked = [check_row(r, price_map.get(r.get("ts_code"))) for r in rows]
by = {}
for c in checked:
by.setdefault(c["verdict"], []).append(c)
n = len(checked)
hard = by.get(MISSING, []) + by.get(ABSURD, []) + by.get(AVAIL_BAD, [])
n_eq = len(by.get(EQ_PRICE, []))
# 占比只在能判的那部分里算 —— 取不到现价的票不该稀释这个比例
judgeable = n - len(by.get(NO_PRICE, []))
eq_ratio = (n_eq / judgeable) if judgeable else 0.0
reasons = []
if hard:
reasons.append(f"{len(hard)} 只成本价不可用 ("
+ ", ".join(f"{c['ts_code']}:{c['verdict']}" for c in hard[:6])
+ ("" if len(hard) > 6 else "") + ")")
eq_block = bool(judgeable >= 2 and eq_ratio >= eq_ratio_block)
if eq_block:
reasons.append(f"{n_eq}/{judgeable} 只的成本≈现价 ({eq_ratio:.0%}) —— "
f"整组安全垫会是 0, 补仓/加仓/保垫减仓全都判不出来")
return {"n": n, "counts": {k: len(v) for k, v in sorted(by.items())},
"rows": checked, "eq_ratio": round(eq_ratio, 4),
"blocking": bool(hard or eq_block), "reasons": reasons,
"hint": _hint(n, by, reasons)}
def coverage(rows_checked) -> dict:
"""这批持仓能不能把纪律验到 (QMT_SIDE_S3_CLOSEOUT §5.2 的四种情形)。
**不阻断建账** 数据是真的就该建只是提醒: 全是"不赚不亏"的话, 盈利加仓保垫减仓
补仓评估档这些分支一条都跑不到, 重建完等于没验
"""
ok = [c for c in (rows_checked or []) if c.get("cushion_pct") is not None]
gain = [c for c in ok if c["cushion_pct"] >= 0.05]
loss = [c for c in ok if c["cushion_pct"] <= -0.10]
intraday = [c for c in (rows_checked or [])
if c.get("avail_qty") is not None and _int(c["avail_qty"]) < _int(c["qty"])]
want = {"明显浮盈(≥+5%) 验盈利加仓与保垫减仓": len(gain),
"明显浮亏(≤10%) 验补仓评估档": len(loss),
"当日买入(可用<总量) 验 T+1 可卖口径": len(intraday),
"持仓只数(≥3 只才验得到组合上限与行业集中度)": len(rows_checked or [])}
missing = [k for k, v in want.items() if v == 0]
if len(rows_checked or []) < 3:
missing.append("持仓不足 3 只")
return {"counts": want, "missing": missing,
"enough": not missing,
"hint": ("这批持仓覆盖了四种情形, 重建后各条纪律都验得到" if not missing else
"缺这几种情形, 对应的纪律分支这轮验不到: " + " / ".join(missing))}
def _hint(n, by, reasons) -> str:
if not n:
return "下游一只持仓都没有 —— 无账可建 (等对端装持仓)"
if reasons:
return ("**不建议照这份数据建账**: " + "; ".join(reasons)
+ "。开仓价一错, 摊薄成本与安全垫跟着错, 而错账与真账在页面上长得一模一样 —— "
"请对端把 trading_position 的 cost_price / available_quantity 填成真实值再来")
extra = ""
if by.get(EQ_PRICE):
extra = f" (其中 {len(by[EQ_PRICE])} 只成本≈现价, 若确为当日买入则正常)"
if by.get(NO_PRICE):
extra += f" ({len(by[NO_PRICE])} 只取不到现价, 成本没核但可用)"
return f"{n} 只持仓的成本价可用, 可以建账{extra}"
def _int(v):
try:
return int(float(v or 0))
except (TypeError, ValueError):
return 0
def _float(v):
try:
if v is None or (isinstance(v, str) and not v.strip()):
return None
return float(v)
except (TypeError, ValueError):
return None

View File

@ -20,6 +20,7 @@ from datetime import datetime, timedelta
from app.core import command_spec as cs
from app.core import cushion as cu
from app.core import rebuild_check as rbc
from app.core import recon as rc
from app.core import tradedays as td
from app.core import ws_codec as wsc
@ -639,6 +640,39 @@ def reconcile(*, apply_fix: bool = True, force: bool = False) -> dict:
codes = [d["ts_code"] for d in diffs]
prices = market.get_prices(codes)
# 首次建账的成本价闸 (2026-07-31 加; QMT_SIDE_S3_CLOSEOUT.md §5)
# ---------------------------------------------------------------
# 只在**账本为空**时生效 —— 那一刻是不可逆的: 这一批 RECON 批次的开仓价会定死每只票
# 的摊薄成本, 而摊薄成本是安全垫的分母, 安全垫又是补仓/加仓/保垫减仓的共同判据。
# 日常漂移不走这道闸: 那时已有批次各自带着自己的成本, 补几百股用什么价影响有限,
# 而每天拦一次对账才是真的坏事。
#
# 为什么非要有这道闸: `rc.build_recon_fixes` 在下游给不出成本价时**静默退回现价**,
# 只在 note 里留一句"现价兜底"。于是账本建起来了、页面一切正常、每个数都长得像真的,
# 只有安全垫齐刷刷是 0 —— 而没人会盯着一个"看起来就该是 0"的字段。
# 漏账看得见 (有行、有 ERROR、有数字), 错账看不见。所以拿不准就不建。
if not [b for b in book if int(b.get("total_qty") or 0) > 0] and \
param_store.get_bool("PMS_RECON_REQUIRE_COST", True):
chk = rbc.check_costs(ds.get("rows") or [], prices)
out["cost_check"] = {k: chk[k] for k in ("n", "counts", "eq_ratio", "blocking",
"reasons", "hint")}
out["coverage"] = rbc.coverage(chk["rows"])
if chk["blocking"] and not force:
out.update({"severity": rc.SEV_ERROR, "fixes": [],
"blocked": {"why": "首次建账被成本价闸拦下: " + "; ".join(chk["reasons"]),
"hint": chk["hint"],
"how": "请对端把 trading_position 的 cost_price 填成真实"
"成本价再重试; 确认这份数据就是对的可用 force=true "
"放行 (那意味着你接受安全垫从 0 起算)"}})
logger.error("[对账] 首次建账被成本价闸拦下: %s%s", chk["reasons"], chk["hint"])
return out
if chk["blocking"]:
logger.warning("[对账] 成本价闸本应拦下 (%s), 但 force=true 放行 —— "
"安全垫将从 0 起算, 补仓/加仓/保垫减仓这一轮判不准", chk["reasons"])
if not out["coverage"]["enough"]:
logger.warning("[对账] 建账数据的情形覆盖不全: %s", out["coverage"]["hint"])
# 下游的成本价 —— 补仓位时的开仓价优先取它, 现价只兜底 (见 rc.build_recon_fixes 注释:
# 拿现价当成本会让安全垫齐刷刷归零, 整条纪律链跟着失灵)
costs = {r["ts_code"]: r.get("cost") for r in (ds.get("rows") or [])}
@ -660,6 +694,158 @@ def reconcile(*, apply_fix: bool = True, force: bool = False) -> dict:
return out
def rebuild_preflight() -> dict:
"""账本重建的**只读**预检 (README 待办 #4)。一个字都不写, 随时可跑。
回答三个问题, 顺序就是它们该被回答的顺序:
1. 事实源给不给得出持仓? (给不出就没什么可谈的)
2. 这份持仓的成本价能不能用? (`rebuild_check` 的成本价闸, 这是重建的成败所在)
3. 建完之后验不验得到纪律? (§5.2 的四种情形; 不阻断, 但缺了就是白建一轮)
再加一段开关现状清账期间关掉的东西, 重建完必须记得打开 尤其 `PMS_SIGNAL_ENABLED`:
账本空时信号消化判 IGNORE 也照样 ACK, 卖出信号会被消费组静默吃掉且跨日拿不回来
"""
out = {"ok": True, "ready": False, "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
src = positions_source()
out["source"] = {"source": src["source"], "mode": src["mode"], "age_sec": src.get("age_sec"),
"rows": len(src["rows"]), "columns": src["columns"],
"alerts": src["alerts"]}
if src["source"] == SRC_NONE:
out["steps"].append({"step": "事实源", "ok": False,
"why": "ws 快照与 trading_position 都没有应答 —— 不是空集, 是没读到。"
"先确认 pms-ws 在跑且对端实现了 query_positions"})
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"] = "下游一只持仓都没有 —— 等对端装好再来"
return out
prices = market.get_prices([r["ts_code"] for r in rows])
chk = rbc.check_costs(rows, prices)
out["cost_check"] = chk
out["steps"].append({"step": "成本价体检", "ok": not chk["blocking"], "why": chk["hint"]})
cov = rbc.coverage(chk["rows"])
out["coverage"] = cov
out["steps"].append({"step": "情形覆盖", "ok": cov["enough"], "why": cov["hint"],
"blocking": False})
out["switches"] = {
"PMS_DISPATCH_MODE": param_store.get("PMS_DISPATCH_MODE", "shadow"),
"PMS_AUTONOMY": param_store.get("PMS_AUTONOMY", "propose_only"),
"PMS_SIGNAL_ENABLED": param_store.get_bool("PMS_SIGNAL_ENABLED", True),
"PMS_EXEC_HALT": param_store.get_bool("PMS_EXEC_HALT", False),
"PMS_SECTOR_SOURCE": param_store.get("PMS_SECTOR_SOURCE", ""),
}
out["after"] = [
"重建完立刻把 PMS_SIGNAL_ENABLED 打开 —— 账本空时信号消化判 IGNORE 也照样 ACK, "
"卖出信号被消费组静默吃掉且跨日拿不回来",
"确认账本无误后再重开 pms-beat (15:10 日终结算会自动认领 trading_position, "
"对端装到一半时跨过 15:10 会拿半成品建账)",
"跑一次 ledger_service.rebuild_accept() 看安全垫分布与行业集中度",
]
out["ready"] = not chk["blocking"]
out["hint"] = (chk["hint"] if chk["blocking"] else
f"可以重建: {len(rows)} 只持仓、成本价可用。" +
("" if cov["enough"] else "注意 " + cov["hint"]))
return out
def rebuild_accept() -> dict:
"""重建之后的**只读**判收。回答"这本账建对了没有"
最硬的一条判据是**安全垫分布**: 如果重建后每一只票的安全垫都是 0, 那就是踩了
拿现价当成本那个坑 页面上每个数都合理, 只有这一处露馅所以专门数它
顺带回答 README 待办 #3 留的那个问题: `PMS_SECTOR_MAX_RATIO=40%` 是当初按"二级或更粗"
的粒度定的, 换到 gp_hybk 三级 (884*) 之后偏不偏松 有了真实持仓分布才算得出来
"""
out = {"ok": True, "checks": [], "hint": ""}
view = portfolio.positions_view()
held = view.get("positions") or []
t = view.get("totals") or {}
out["held"] = len(held)
if not held:
out.update({"ok": False, "hint": "账本还是空的 —— 重建没跑, 或跑了但被闸拦下了"})
return out
cush = [x.get("cushion_pct") for x in held if x.get("cushion_pct") is not None]
zero = [x for x in held if abs(float(x.get("cushion_pct") or 0)) < 1e-9]
out["cushion"] = {"n": len(cush), "zero": len(zero),
"min": round(min(cush), 4) if cush else None,
"max": round(max(cush), 4) if cush else None,
"solid": t.get("solid_names"), "neg": t.get("neg_names")}
all_zero = bool(held and len(zero) == len(held))
out["checks"].append({
"check": "安全垫分布", "ok": not all_zero,
"why": ("**每一只的安全垫都是 0** —— 这正是拿现价当成本的样子。摊薄成本等于当天价, "
"补仓/加仓/保垫减仓这一整条纪律链会全程判不出来。请核对下游 cost_price"
if all_zero else
f"{len(cush)} 只有安全垫, 区间 {out['cushion']['min']:+.1%} ~ "
f"{out['cushion']['max']:+.1%}, 其中 {len(zero)} 只为 0")})
lots = {}
for x in held:
for l in pms_repo.list_lots(x["ts_code"], status="OPEN"):
lots[l["lot_type"]] = lots.get(l["lot_type"], 0) + 1
out["lots"] = lots
out["checks"].append({"check": "批次账", "ok": bool(lots),
"why": f"批次分布 {lots or '(空)'}" +
("" if lots else " —— 持仓有行但没有批次, 账本结构不完整")})
ready = bool(t.get("sector_ready", view.get("sector_ready")))
names, mv = t.get("sector_names") or {}, t.get("sector_mv") or {}
# **分母必须与规则闸一致**: sizer.check_caps 的行业判据是
# (sector_mv + add) / port_after > sector_max_ratio
# 即「占组合持仓市值」, 不是占总规模 PMS_SCALE。两边用不同分母的话, 这里报"没超"而
# 规则闸拦人 (或反过来), 而两个数字都自称是"行业集中度"——同名不同物最难查。
# (settings.py 里那句注释写的是"占总仓", 容易被读成占 PMS_SCALE, 已在此写明。)
port_mv = float(t.get("portfolio_mv") or 0.0) or sum(
float(x.get("market_value") or 0.0) for x in held)
max_ratio = param_store.get_float("PMS_SECTOR_MAX_RATIO", 0.40)
max_names = param_store.get_int("PMS_SECTOR_MAX_NAMES", 4)
top = sorted(((s, (mv[s] / port_mv if port_mv else 0.0), names.get(s, 0)) for s in mv),
key=lambda x: -x[1])[:5]
over_ratio = [x for x in top if x[1] > max_ratio + 1e-9]
over_names = [s for s, n in names.items() if n > max_names]
out["sector"] = {"ready": ready, "max_ratio": max_ratio, "max_names": max_names,
"denominator": "组合持仓市值 (与 sizer.check_caps 一致)",
"portfolio_mv": round(port_mv, 2),
"over_ratio": [s for s, _, _ in over_ratio], "over_names": over_names,
"top": [{"sector": s, "ratio": round(r, 4), "names": n} for s, r, n in top]}
worst = top[0][1] if top else 0.0
if not ready:
why = "行业源没就绪 —— 集中度约束此刻等同未配置且会静默失效"
elif over_ratio or over_names:
# 接管进来的持仓超限**不算重建失败** —— 账本忠实反映了下游的真实状态, 那才是它的职责。
# 但必须说出来: 规则闸从此会挡住这些行业的加仓, 不说的话下次加不进去会以为是 bug。
why = (f"接管进来的持仓**已经超限**: "
+ (f"占比超上限 {max_ratio:.0%} 的有 {[s for s, _, _ in over_ratio]}"
f" (最大 {worst:.1%})" if over_ratio else "")
+ (f" 只数超上限 {max_names} 的有 {over_names}" if over_names else "")
+ "。这不是重建出错 —— 账本忠实反映了下游真实持仓; 但规则闸从此会挡住这几个"
"行业的加仓, 心里要有数 (减持方向不受影响)")
else:
why = f"最大行业占组合 {worst:.1%} (上限 {max_ratio:.0%}), 只数上限 {max_names}, 未超限"
if worst < max_ratio * 0.5:
why += ("。阈值是当初按二级或更粗的粒度定的, 现在是三级 884* —— "
"拿这份真实分布回看它偏不偏松 (参考项目三级用 20%)")
out["checks"].append({"check": "行业集中度", "ok": ready, "why": why})
out["ok"] = all(c["ok"] for c in out["checks"])
bad = [c["check"] for c in out["checks"] if not c["ok"]]
out["hint"] = "账本重建判收通过" if out["ok"] else "这几项没过: " + " / ".join(bad)
return out
def _apply_fix(f: dict):
code = f["ts_code"]
pms_repo.ensure_position(code)

View File

@ -287,6 +287,27 @@ def api_replay(limit: int = Query(500)):
return ok(ledger_service.replay_fills, limit=limit)
@app.get("/api/ops/rebuild-preflight")
def api_rebuild_preflight():
"""账本重建的只读预检 (README 待办 #4)。不写任何东西, 随时可点。
成本价能不能用是这一步的成败所在 详见 app/core/rebuild_check.py 的模块说明
"""
def _pf():
from app.services import ledger_service
return ledger_service.rebuild_preflight()
return ok(_pf)
@app.get("/api/ops/rebuild-accept")
def api_rebuild_accept():
"""重建之后的只读判收: 安全垫分布 / 批次账 / 行业集中度。"""
def _ac():
from app.services import ledger_service
return ledger_service.rebuild_accept()
return ok(_ac)
@app.post("/api/ops/reconcile")
def api_reconcile(apply_fix: bool = Query(True), force: bool = Query(False)):
"""force=true 绕过「差异面过大不自动改账」的限制, 仅在人工确认下游读数无误后使用。"""

156
scripts/rebuild_ledger.py Normal file
View File

@ -0,0 +1,156 @@
# -*- coding: utf-8 -*-
"""
账本重建: 预检 执行 判收 (README 待办 #4)
==============================================
docker compose run --rm --no-deps pms-web python scripts/rebuild_ledger.py # 只预检
docker compose run --rm --no-deps pms-web python scripts/rebuild_ledger.py --yes # 预检通过就执行
docker compose run --rm --no-deps pms-web python scripts/rebuild_ledger.py --accept # 只看判收
( make rebuild / make rebuild GO=1 / make rebuild-accept)
清账之后账本是空的, 真实持仓要按以下游为准认领回来这一步**只发生一次**, 但它定死了
每一只票的开仓价, 而开仓价一错, 后面每一条纪律都错在一个看不出来的地方:
下游 cost_price RECON 批次开仓价 摊薄成本 安全垫
盈利加仓(3%) · 保垫减仓(峰值6%) · 补仓评估档(8%/15%)
所以本脚本**默认只预检不执行** `--yes` 才真的改账, 且预检不过照样不执行 (要强行放行得
再加 `--force`, 那等于声明"我确认这份数据就是对的, 接受安全垫从 0 起算")
三段各自的意思:
[1] 预检 事实源给不给得出持仓 / 成本价能不能用 / 建完验不验得到纪律 (只读)
[2] 执行 reconcile(apply_fix=True), 走的是生产同一条路, 不是另写一套
[3] 判收 安全垫分布是最硬的判据: 0 就是踩了"拿现价当成本"那个坑
"""
from __future__ import annotations
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
OK, BAD, WARN = " OK ", " FAIL ", " WARN "
def _line(tag, msg):
print(f"[{tag}] {msg}")
def _steps(steps):
for s in steps:
tag = OK if s.get("ok") else (WARN if s.get("blocking") is False else BAD)
print(f" [{tag}] {s['step']}: {s['why']}")
def show_preflight(pf) -> bool:
print("=" * 72)
print("[1] 预检 (只读, 一个字都不写)")
print("=" * 72)
_line("账本", f"当前持仓 {pf['book_held']}"
+ (" —— 空账本, 这是首次建账, 成本价闸生效" if pf["first_build"]
else " —— 非空, 走的是日常对账口径, 成本价闸不生效"))
src = pf.get("source") or {}
_line("事实源", f"{src.get('source')} (mode={src.get('mode')}, "
f"age={src.get('age_sec')}s, 列={src.get('columns')})")
for a in (src.get("alerts") or []):
_line(a.get("level", "WARN"), a.get("message"))
_steps(pf.get("steps") or [])
chk = pf.get("cost_check") or {}
if chk.get("rows"):
print(f"\n 成本价明细 ({chk['n']} 只, 判定分布 {chk['counts']}):")
for r in chk["rows"]:
cu = "" if r.get("cushion_pct") is None else f" 安全垫 {r['cushion_pct']:+.1%}"
mark = " " if r["verdict"] == "OK" else "! "
print(f" {mark}{r['ts_code']:<12} {str(r['qty']):>7}"
f"成本 {_g(r['cost'])} 现价 {_g(r['price'])} "
f"[{r['verdict']}]{cu}")
if r.get("why"):
print(f" {r['why']}")
cov = pf.get("coverage") or {}
if cov:
print("\n 情形覆盖 (不阻断建账, 只影响这轮验不验得到纪律):")
for k, v in (cov.get("counts") or {}).items():
print(f" [{OK if v else WARN}] {k}: {v}")
sw = pf.get("switches") or {}
if sw:
print("\n 开关现状: " + " · ".join(f"{k}={v}" for k, v in sw.items()))
print(f"\n 结论: {pf.get('hint')}")
return bool(pf.get("ready"))
def show_accept(ac):
print("=" * 72)
print("[3] 判收 (只读)")
print("=" * 72)
_line("持仓", f"{ac.get('held')}")
for c in ac.get("checks") or []:
print(f" [{OK if c['ok'] else BAD}] {c['check']}: {c['why']}")
if ac.get("sector", {}).get("top"):
print("\n 行业分布 (占规模):")
for s in ac["sector"]["top"]:
print(f" {s['sector'] or '(无)':<20} {s['ratio']:>7.1%} {s['names']}")
print(f"\n 结论: {ac.get('hint')}")
return bool(ac.get("ok"))
def _g(v):
return "-" if v in (None, "") else f"{float(v):.4g}"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--yes", action="store_true", help="预检通过就执行重建 (缺省只预检)")
ap.add_argument("--force", action="store_true",
help="预检不过也执行。**这等于声明你接受安全垫从 0 起算**")
ap.add_argument("--accept", action="store_true", help="只跑判收段 (重建已经做过了)")
args = ap.parse_args()
from app.services import ledger_service as ls
if args.accept:
return 0 if show_accept(ls.rebuild_accept()) else 1
pf = ls.rebuild_preflight()
ready = show_preflight(pf)
if not args.yes:
print("\n(只预检, 没有改任何东西。要执行加 --yes)")
return 0 if ready else 2
if not ready and not args.force:
print("\n预检没过, 不执行。请对端把 trading_position 的 cost_price / "
"available_quantity 填成真实值后重跑;\n"
"确认这份数据就是对的可以加 --force 放行 (那意味着安全垫从 0 起算, "
"这一轮的补仓/加仓/保垫减仓都判不准)。")
return 2
print("\n" + "=" * 72)
print("[2] 执行 reconcile(apply_fix=True" + (", force=True" if args.force else "") + ")")
print("=" * 72)
res = ls.reconcile(apply_fix=True, force=args.force)
_line("结果", f"ok={res.get('ok')} 事实源={res.get('source')} "
f"差异 {len(res.get('diffs') or [])} 项 修正 {len(res.get('fixes') or [])}")
if res.get("blocked"):
_line("BLOCKED", res["blocked"].get("why"))
if res["blocked"].get("how"):
_line("怎么办", res["blocked"]["how"])
return 1
for f in res.get("fixes") or []:
print(f" {f['ts_code']:<12} {f['op']:<14} {f['qty']:>7}"
f"@ {_g(f.get('price'))} {f.get('price_source') or ''}")
for e in res.get("errors") or []:
_line("ERROR", e)
print()
ok = show_accept(ls.rebuild_accept())
print("\n收尾 (脚本不替你做, 因为这几件事该由人确认):")
for s in pf.get("after") or []:
print(f" - {s}")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())

View File

@ -13,8 +13,9 @@
test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/DDL/逐笔入账 (65 )
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 (32 )
test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 (60 )
test_batch9_units.py 接管持仓前的成本价体检: 阻断判据/情形覆盖 (23 )
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) (58 )
304
327
任一子集失败即整体失败 (退出码 1)
"""
import os
@ -25,7 +26,8 @@ HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
SUITES = ["test_core_units.py", "test_batch2_units.py", "test_batch3_units.py",
"test_batch4_units.py", "test_batch5_units.py", "test_batch6_units.py",
"test_batch7_units.py", "test_batch8_units.py", "test_wiring.py"]
"test_batch7_units.py", "test_batch8_units.py", "test_batch9_units.py",
"test_wiring.py"]
def main():

View File

@ -0,0 +1,250 @@
# -*- coding: utf-8 -*-
"""
第九批模块单测 (零外部依赖, 不连库不触网)
==========================================
运行: tradingSystem 仓库根目录执行 python scripts/test_batch9_units.py
覆盖: 接管既有持仓前的成本价体检 (app/core/rebuild_check.py)
这一批守的是一个**只发生一次且不可逆**的决定: 账本清空后按以下游为准认领真实持仓,
那一刻定死每只票的开仓价 摊薄成本 安全垫 补仓/加仓/保垫减仓的共同判据
2026-07-29 首次接管 22 只持仓时踩过一次 (全按现价开仓, 安全垫齐刷刷是 0)
所以用例的重点不是"算得对不对", 而是**该拦的拦不拦得住**, 以及**不该拦的会不会误伤**
误伤的代价是多等一天, 漏拦的代价是一本每个数都错却和真账长得一模一样的账
"""
import os
import sys
import traceback
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.core import rebuild_check as rb # noqa: E402
RESULTS = []
def case(name):
def deco(fn):
RESULTS.append((name, fn))
return fn
return deco
def _p(code, qty=1000, cost=10.0, avail=None, price=None):
"""一行下游持仓 (downstream_repo.fetch_positions 的行形态)。"""
return {"ts_code": code, "qty": qty, "cost": cost,
"avail_qty": qty if avail is None else avail, "price": price}
# ================================================================ [A] 单只票的判定
@case("[A1] 成本价正常 → OK, 并算出安全垫")
def t_a1():
r = rb.check_row(_p("600000.SH", cost=20.0), price=10.0)
assert r["verdict"] == rb.OK
assert abs(r["cushion_pct"] - (-0.5)) < 1e-9, "真实成本 20、现价 10 就是实亏 50%"
@case("[A2] 成本价缺失 / 为 0 / 为负 → MISSING")
def t_a2():
for bad in (None, 0, 0.0, -1.0, ""):
r = rb.check_row(_p("600000.SH", cost=bad), price=10.0)
assert r["verdict"] == rb.MISSING, f"cost={bad!r} 应判 MISSING"
assert "安全垫恒为 0" in rb.check_row(_p("600000.SH", cost=0), price=10.0)["why"]
@case("[A3] 成本 ≈ 现价 → EQ_PRICE (安全垫≈0)")
def t_a3():
r = rb.check_row(_p("600000.SH", cost=10.02), price=10.0) # 差 0.2% < 0.5%
assert r["verdict"] == rb.EQ_PRICE
r2 = rb.check_row(_p("600000.SH", cost=10.30), price=10.0) # 差 3% > 0.5%
assert r2["verdict"] == rb.OK, "正常的小幅浮盈不该被当成'拿现价充数'"
@case("[A4] 成本与现价差两个数量级 → ABSURD (多半是单位错)")
def t_a4():
assert rb.check_row(_p("600000.SH", cost=1000.0), price=10.0)["verdict"] == rb.ABSURD
assert rb.check_row(_p("600000.SH", cost=0.05), price=10.0)["verdict"] == rb.ABSURD
# 分/元 混用是最典型的一种: 成本记成 1002 分而现价是 10.02 元
r = rb.check_row(_p("600000.SH", cost=1002.0), price=10.02)
assert r["verdict"] == rb.ABSURD and "单位" in r["why"]
@case("[A5] 可用量 > 总量 或为负 → AVAIL_BAD")
def t_a5():
assert rb.check_row(_p("600000.SH", qty=1000, avail=1500), price=10.0)["verdict"] \
== rb.AVAIL_BAD
assert rb.check_row(_p("600000.SH", qty=1000, avail=-1), price=10.0)["verdict"] \
== rb.AVAIL_BAD
# 当日买入是合法的: 可用 < 总量 (成本给个与现价不同的值, 免得撞上 EQ_PRICE)
assert rb.check_row(_p("600000.SH", qty=1000, avail=0, cost=20.0),
price=10.0)["verdict"] == rb.OK
@case("[A6] 取不到现价 → NO_PRICE, 但成本本身仍算可用")
def t_a6():
r = rb.check_row(_p("600000.SH", cost=20.0), price=None)
assert r["verdict"] == rb.NO_PRICE and r["cushion_pct"] is None
assert "成本值本身可用" in r["why"]
@case("[A7] 可用量的判定排在成本之前 —— 两个都坏时先报可用量")
def t_a7():
r = rb.check_row(_p("600000.SH", qty=100, avail=999, cost=0), price=10.0)
assert r["verdict"] == rb.AVAIL_BAD
# ================================================================ [B] 整批的阻断判据
@case("[B1] 全部正常 → 不阻断")
def t_b1():
rows = [_p("600000.SH", cost=20.0), _p("600519.SH", cost=1500.0)]
out = rb.check_costs(rows, {"600000.SH": 10.0, "600519.SH": 1800.0})
assert out["blocking"] is False and out["reasons"] == []
assert "可以建账" in out["hint"]
@case("[B2] 只要有一只成本不可用就阻断 —— 一本账里混一只错的也不行")
def t_b2():
rows = [_p("600000.SH", cost=20.0), _p("600519.SH", cost=0)]
out = rb.check_costs(rows, {"600000.SH": 10.0, "600519.SH": 1800.0})
assert out["blocking"] is True
assert "600519.SH:MISSING" in out["reasons"][0]
@case("[B3] 整组成本≈现价 → 阻断 (这就是 07-29 那个坑的模样)")
def t_b3():
rows = [_p(f"60000{i}.SH", cost=10.0) for i in range(1, 6)]
out = rb.check_costs(rows, {f"60000{i}.SH": 10.0 for i in range(1, 6)})
assert out["blocking"] is True and out["eq_ratio"] == 1.0
assert "安全垫会是 0" in out["reasons"][0]
@case("[B4] 少数几只成本≈现价 → 不阻断 (当日买入是正常的)")
def t_b4():
rows = [_p("600000.SH", cost=20.0), _p("600519.SH", cost=1800.0),
_p("600036.SH", cost=30.0), _p("601318.SH", cost=10.0)]
px = {"600000.SH": 10.0, "600519.SH": 1500.0, "600036.SH": 40.0, "601318.SH": 10.0}
out = rb.check_costs(rows, px)
assert out["counts"].get(rb.EQ_PRICE) == 1
assert out["blocking"] is False, "四只里一只当日买入很正常, 不该拦"
@case("[B5] 只有一只票时不按占比阻断 —— 样本太小, 它可能真是当日买的")
def t_b5():
out = rb.check_costs([_p("600000.SH", cost=10.0)], {"600000.SH": 10.0})
assert out["counts"].get(rb.EQ_PRICE) == 1
assert out["blocking"] is False
@case("[B6] 取不到现价的票不稀释 EQ_PRICE 占比")
def t_b6():
# 两只成本≈现价 + 八只取不到现价。若拿 10 做分母, 占比 20% 不阻断 —— 那是错的:
# 能判的两只全是 EQ_PRICE, 该阻断。
rows = [_p("600001.SH", cost=10.0), _p("600002.SH", cost=10.0)] + \
[_p(f"6001{i:02d}.SH", cost=5.0) for i in range(1, 9)]
px = {"600001.SH": 10.0, "600002.SH": 10.0} # 其余取不到价
out = rb.check_costs(rows, px)
assert out["counts"].get(rb.NO_PRICE) == 8
assert out["eq_ratio"] == 1.0 and out["blocking"] is True
@case("[B7] 零持仓行不参与体检")
def t_b7():
rows = [_p("600000.SH", qty=0, cost=0), _p("600519.SH", cost=1800.0)]
out = rb.check_costs(rows, {"600519.SH": 1500.0})
assert out["n"] == 1 and out["blocking"] is False, "qty=0 的行是历史残留, 不该拖累建账"
@case("[B8] 下游一只持仓都没有 → 不阻断但说清是没账可建")
def t_b8():
out = rb.check_costs([], {})
assert out["n"] == 0 and out["blocking"] is False
assert "无账可建" in out["hint"]
@case("[B9] 阻断时的 hint 要给出可执行的下一步")
def t_b9():
out = rb.check_costs([_p("600000.SH", cost=0)], {"600000.SH": 10.0})
assert "cost_price" in out["hint"], "得说清要对端改哪一列, 不是只说'数据有问题'"
assert "长得一模一样" in out["hint"], "得说清为什么不能将就着建"
# ================================================================ [C] 情形覆盖 (不阻断)
@case("[C1] 四种情形齐全 → enough")
def t_c1():
rows = [_p("600000.SH", cost=10.0), # 浮盈 +50%
_p("600519.SH", cost=20.0), # 浮亏 25%
_p("600036.SH", cost=10.0, qty=1000, avail=0), # 当日买入
_p("601318.SH", cost=10.0)]
px = {"600000.SH": 15.0, "600519.SH": 15.0, "600036.SH": 11.0, "601318.SH": 11.0}
cov = rb.coverage(rb.check_costs(rows, px)["rows"])
assert cov["enough"] is True and cov["missing"] == []
@case("[C2] 全是不赚不亏 → 报出这轮验不到哪些纪律")
def t_c2():
rows = [_p(f"60000{i}.SH", cost=10.0) for i in range(1, 4)]
cov = rb.coverage(rb.check_costs(rows, {f"60000{i}.SH": 10.05 for i in range(1, 4)})["rows"])
assert cov["enough"] is False
assert any("浮盈" in m for m in cov["missing"])
assert any("浮亏" in m for m in cov["missing"])
@case("[C3] 只有一只持仓 → 报持仓不足 3 只")
def t_c3():
cov = rb.coverage(rb.check_costs([_p("600000.SH", cost=20.0)], {"600000.SH": 10.0})["rows"])
assert cov["enough"] is False and any("不足 3 只" in m for m in cov["missing"])
@case("[C4] 覆盖不全绝不阻断建账 —— 数据是真的就该建")
def t_c4():
rows = [_p("600000.SH", cost=20.0), _p("600519.SH", cost=3000.0)]
out = rb.check_costs(rows, {"600000.SH": 10.0, "600519.SH": 1500.0})
cov = rb.coverage(out["rows"])
assert out["blocking"] is False and cov["enough"] is False
# ================================================================ [D] 健壮性
@case("[D1] 脏数据不炸")
def t_d1():
for bad in ({"ts_code": "600000.SH"}, {"ts_code": None, "qty": "x", "cost": "y"},
{"ts_code": "600000.SH", "qty": "1000", "cost": "20.0", "avail_qty": "1000"}):
rb.check_row(bad, price=10.0)
out = rb.check_costs([{"ts_code": "600000.SH", "qty": "1000", "cost": "20.0"}],
{"600000.SH": "10.0"})
assert out["n"] == 1, "字符串数字要能吃进去 —— 库里 DECIMAL 列取出来常是字符串"
@case("[D2] rows 为 None 不炸")
def t_d2():
assert rb.check_costs(None, None)["n"] == 0
assert rb.coverage(None)["enough"] is False
@case("[D3] 判定常量互不相同 —— 别把两种故障混成一个码")
def t_d3():
vs = [rb.OK, rb.MISSING, rb.EQ_PRICE, rb.ABSURD, rb.AVAIL_BAD, rb.NO_PRICE]
assert len(set(vs)) == len(vs)
def main():
import logging
logging.disable(logging.CRITICAL)
passed, failed = 0, 0
for name, fn in RESULTS:
try:
fn()
print(f" PASS {name}")
passed += 1
except Exception:
print(f" FAIL {name}")
traceback.print_exc()
failed += 1
print("-" * 60)
if failed:
print(f"FAILED: {failed} / {passed + failed}")
sys.exit(1)
print(f"ALL PASS ({passed} cases)")
if __name__ == "__main__":
main()