账本重建

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 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 initdb check health \
probe changes industry ws-status reset-ledger shell probe changes industry ws-status rebuild rebuild-accept reset-ledger shell
help: ## 列出所有目标 help: ## 列出所有目标
@grep -hE '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \ @grep -hE '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
@ -78,6 +78,13 @@ industry: ## 行业源实探 (ready=false 就是硬拦截失效, 别当它绿着
ws-status: ## ws 通道状态 (连接态 / seq 水位 / 出口队列) ws-status: ## ws 通道状态 (连接态 / seq 水位 / 出口队列)
$(RUN) python scripts/ws_smoke.py status $(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) reset-ledger: ## 清空账本重来 (影子期专用; 必须 CONFIRM=1)
@if [ "$(CONFIRM)" != "1" ]; then \ @if [ "$(CONFIRM)" != "1" ]; then \

View File

@ -33,6 +33,7 @@ app/
action_engine.py 动作引擎: FILL 回踩补足 / ADD 盈利加仓 / DCA 补仓 / TRIM 保垫减仓 action_engine.py 动作引擎: FILL 回踩补足 / ADD 盈利加仓 / DCA 补仓 / TRIM 保垫减仓
signal_rules.py 决策系统两条信号流的解析与消化口径 (含置信度尺度归一) signal_rules.py 决策系统两条信号流的解析与消化口径 (含置信度尺度归一)
plan_diff.py 上游榜单的版本比对: 名册指纹 / 新进掉榜 / 档位升降 / 榜尾噪音闸 plan_diff.py 上游榜单的版本比对: 名册指纹 / 新进掉榜 / 档位升降 / 榜尾噪音闸
rebuild_check.py 接管既有持仓前的成本价体检 (阻断判据 + §5.2 情形覆盖)
tradedays.py 交易日历: 调度守卫与执行窗口计算 tradedays.py 交易日历: 调度守卫与执行窗口计算
ws_codec.py QMT 协议编解码: 规范化串 / Ed25519 签名验签 / 信封 / seq 水位推进 ws_codec.py QMT 协议编解码: 规范化串 / Ed25519 签名验签 / 信封 / seq 水位推进
db/session.py 三库连接 + **严格单表访问守卫** (JOIN/逗号连表/跨表子查询一律拒绝) db/session.py 三库连接 + **严格单表访问守卫** (JOIN/逗号连表/跨表子查询一律拒绝)
@ -65,6 +66,7 @@ scripts/
test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/DDL/逐笔入账 65 例 test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/DDL/逐笔入账 65 例
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 32 例 test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 32 例
test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 60 例 test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 60 例
test_batch9_units.py 接管持仓前的成本价体检: 阻断判据/情形覆盖 23 例
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)
@ -72,6 +74,7 @@ scripts/
reset_ledger.py 清空账本并把回放游标对齐到当前 (影子运行期重来一次; 不碰下游表) reset_ledger.py 清空账本并把回放游标对齐到当前 (影子运行期重来一次; 不碰下游表)
probe_plan_api.py 上游计划实机探活: 通不通 / 字段口径 / 候选筛选结果 / 有没有价 probe_plan_api.py 上游计划实机探活: 通不通 / 字段口径 / 候选筛选结果 / 有没有价
/ 榜单变化 (只读; --snapshot 才落库, 那是它唯一的写操作) / 榜单变化 (只读; --snapshot 才落库, 那是它唯一的写操作)
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)
``` ```
@ -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**——而安全垫是盈利加仓≥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 ## 自主提议的分流(设计 §6 / §7
动作引擎每分钟扫一遍持仓,产出 FILL / ADD / DCA / TRIM 四类候选,然后依次过三道: 动作引擎每分钟扫一遍持仓,产出 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 | | 1 | ~~ws 通道的账本侧改造(清单 4~6~~ | ✅ 2026-07-29 |
| 2 | ~~上游选股计划接入(`/plan`~~ | ✅ 2026-07-31候选池独占来源、交易日龄硬校验、盘前昨收兜底、ST 剔除、PMS 侧主题限额、页面抽屉与探活脚本。口径与实测记录见 `UPSTREAM_PLAN_API.md` | | 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 | | 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 | | 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` 计划不幂等、档位规模与文档不符) | | 6 | 上游计划的剩余待确认口径 | 等上游:`UPSTREAM_PLAN_API.md` §4 剩 5 条 + §7.3 新增两条(同一 `date` 计划不幂等、档位规模与文档不符) |
| 7 | ~~榜单变化做成页面提示(新进传导链 / 掉榜)~~ | ✅ 2026-07-31上游 `changes` 恒为 `null`,所以**改成 PMS 自己算**——每次拉计划落一份名册快照(第 15 张表),差异由纯逻辑比。顺带补上「同一 `date` 多版本、下游无从分辨手上是哪一版」那个洞。详见 `UPSTREAM_PLAN_API.md` §9 | | 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 ```bash
# 【服务器 factorevaluation · ~/project/tradingSystem】 # 【服务器 factorevaluation · ~/project/tradingSystem】
make test # 应输出 ALL SUITES PASS (304 例) make test # 应输出 ALL SUITES PASS (327 例)
make initdb # 幂等, 建第 15 张表 make initdb # 幂等, 建第 15 张表
make check # [2] 段应能看到 pms_plan_snapshot make check # [2] 段应能看到 pms_plan_snapshot
make probe SNAP=1 # 落第一份快照; [7] 段此时应报"库里还没有快照" 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 command_spec as cs
from app.core import cushion as cu 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 recon as rc
from app.core import tradedays as td from app.core import tradedays as td
from app.core import ws_codec as wsc 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] codes = [d["ts_code"] for d in diffs]
prices = market.get_prices(codes) 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 注释: # 下游的成本价 —— 补仓位时的开仓价优先取它, 现价只兜底 (见 rc.build_recon_fixes 注释:
# 拿现价当成本会让安全垫齐刷刷归零, 整条纪律链跟着失灵) # 拿现价当成本会让安全垫齐刷刷归零, 整条纪律链跟着失灵)
costs = {r["ts_code"]: r.get("cost") for r in (ds.get("rows") or [])} 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 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): def _apply_fix(f: dict):
code = f["ts_code"] code = f["ts_code"]
pms_repo.ensure_position(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) 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") @app.post("/api/ops/reconcile")
def api_reconcile(apply_fix: bool = Query(True), force: bool = Query(False)): def api_reconcile(apply_fix: bool = Query(True), force: bool = Query(False)):
"""force=true 绕过「差异面过大不自动改账」的限制, 仅在人工确认下游读数无误后使用。""" """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_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/DDL/逐笔入账 (65 )
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 (32 ) test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 (32 )
test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 (60 ) test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 (60 )
test_batch9_units.py 接管持仓前的成本价体检: 阻断判据/情形覆盖 (23 )
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) (58 ) test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) (58 )
304 327
任一子集失败即整体失败 (退出码 1) 任一子集失败即整体失败 (退出码 1)
""" """
import os import os
@ -25,7 +26,8 @@ HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE) ROOT = os.path.dirname(HERE)
SUITES = ["test_core_units.py", "test_batch2_units.py", "test_batch3_units.py", 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_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(): 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()