处理没成本价的问题

This commit is contained in:
zlt 2026-07-31 14:19:36 +08:00
parent ea542103bb
commit 72d35e7fc9
6 changed files with 93 additions and 28 deletions

View File

@ -91,6 +91,17 @@ rebuild-accept: ## 只看判收: 安全垫分布 / 批次账 / 行业集中度
# 一步一停, 每步都能看清楚 —— 别开 beat, 那会让几个调度位同时动, 出了岔子分不清是谁干的。
# 全程 PMS_DISPATCH_MODE=shadow: 指令照常生成、照常过规则闸、照常记账, **只是不写下游**。
# 顺序就是生产里那几个调度位的顺序, 只是改成手动触发。
#
# **A3 之前必须先下一条「任务命令」。** 参数命令 (SET_SCALE / SET_PORTFOLIO_CAP /
# SET_STOCK_CAP / SET_MAX_NAMES …) 只是设约束, **本来就不产生方案** —— 页面上它们显示
# 「0 条方案」是对的, 不是坏了。要走通链路得下 B/C 组的任务命令:
# INCREASE_EXPOSURE 升仓 pct% (全局: 垫厚票补到目标 + 候选池新票建仓)
# OPEN_TARGET 建仓某股至 x% (个股: 分批 50/25/25, 含一手合并)
#
# **这里的「择时」是 PMS 内置的实现 B, 不经决策系统。** 设计里择时有两个实现:
# 实现 A = 委托决策系统盘中择时 —— 待办 #9, 等 bionic 侧接口, **还没接**
# 实现 B = PMS 自己算 (分日配额 / 分笔 / VWAP / 回踩 / 不追高 / 14:45 兜底) —— 已实现
# 所以跑 A 段不需要动决策系统的任何逻辑, 它这一段本来就不参与。
A ?= http://127.0.0.1:38100
J := python3 -m json.tool
@ -100,7 +111,7 @@ t-plan: ## A1 拉候选池 (= 08:40 调度位)
t-pre: ## A2 盘前准备: T+1 可卖重置 / 参考位 / 刹车结算 (= 08:50 调度位)
@curl -s -X POST '$(A)/api/ops/premarket' | $(J)
t-cmd: ## A3 命令轮询: 新命令 → 方案生成 → 状态机推进 (= 每分钟调度位)
t-cmd: ## A3 命令轮询: 新命令 → 方案生成 (先下任务命令! 参数命令不产生方案)
@curl -s -X POST '$(A)/api/ops/plan-pending' | $(J)
t-plans: ## A4 看方案 (方案是"买什么、多少股、分几批", 还没成指令)
@ -109,10 +120,10 @@ t-plans: ## A4 看方案 (方案是"买什么、多少股、分几批", 还没
t-mat: ## A5 方案 → 指令 (先记账后动作: 指令先落表)
@curl -s -X POST '$(A)/api/ops/materialize' | $(J)
t-dry: ## A6 择时试算 (dry_run: 规则闸判什么、今天该出多少, 一股都不发)
t-dry: ## A6 择时试算 (实现B, 不经决策系统; dry_run 只算不发)
@curl -s -X POST '$(A)/api/ops/exec-tick?dry_run=true' | $(J)
t-tick: ## A7 真出手 (shadow 下 = 置 DISPATCHED 但不写下游, 你照着在 QMT 手工执行)
t-tick: ## A7 真出手 (shadow 下 = 置 DISPATCHED 但不写下游, 你照着在 QMT 手工)
@curl -s -X POST '$(A)/api/ops/exec-tick' | $(J)
t-ins: ## A8 看指令与子单状态

View File

@ -66,7 +66,7 @@ scripts/
test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/DDL/逐笔入账 65 例
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 32 例
test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 60 例
test_batch9_units.py 成本价体检 + 连续不一致按日推进 29
test_batch9_units.py 成本价体检 + 连续不一致按日推进 31
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) 58 例
init_db.py 建表 (应用 ddl_pms_v1.sql, 幂等, 默认演练; 含 DDL 体检)
check_db.py 实机连通性与表结构自检 (需真实 .env)
@ -225,12 +225,18 @@ make rebuild-accept # 只看判收:安全垫分布 / 批次账 / 行业集
`shadow` 正好是这一段的天然测试环境,不是妥协:指令照常生成、照常过规则闸、照常记账,**只是不写下游**。所以这一段不用碰任何开关,也不需要 ws 通道就绪。
两件容易踩的事,先说在前面。
**A3 之前必须先下一条「任务命令」。** 参数命令(`SET_SCALE` / `SET_PORTFOLIO_CAP` / `SET_STOCK_CAP` / `SET_MAX_NAMES` 这些)只是设约束,**本来就不产生方案**——页面上它们显示「0 条方案」是对的,不是坏了。要走通链路得下 B/C 组的任务命令:`INCREASE_EXPOSURE`(升仓 pct%,全局:垫厚票补到目标 + 候选池新票建仓)或 `OPEN_TARGET`(建仓某股至 x%,个股:分批 50/25/25 含一手合并)。
**这里的「择时」是 PMS 内置的实现 B不经决策系统。** 设计里择时有两个实现:实现 A 是委托决策系统盘中择时(待办 #9,等 bionic 侧接口,**还没接**);实现 B 是 PMS 自己算分日配额、分笔、VWAP/回踩/不追高、14:45 兜底),已实现。所以跑 A 段不需要动决策系统的任何逻辑——它这一段本来就不参与。
**别开 beat**——那会让几个调度位同时动,出了岔子分不清是谁干的。`Makefile` 里有一组 `t-*` 目标,就是把那几个调度位改成手动逐跳触发,顺序与生产一致:
```bash
make t-plan # A1 拉候选池 (= 08:40 调度位)
make t-pre # A2 盘前准备 (= 08:50 调度位)
# —— 这里在页面「命令台」下一条建仓或升仓命令 ——
# —— 页面「命令台」下一条**任务命令**: INCREASE_EXPOSURE 或 OPEN_TARGET ——
make t-cmd # A3 命令轮询: 新命令 → 方案生成
make t-plans # A4 看方案: 买什么、多少股、分几批 (还没成指令)
make t-mat # A5 方案 → 指令 (先记账后动作)
@ -263,7 +269,7 @@ make t-gate # 随时: 规则闸/研判闸拒了什么、为什么
## 已实现 / 待开发
**已实现**:建表 DDL 与建表脚本配置与运行参数中心仓位规划器与安全垫账命令系统27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);规则闸终检;择时执行器实现 B分日配额、分笔、VWAP/回踩/不追高、14:45 兜底、停牌一字板顺延、窗口耗尽收口、挂单有效期);动作引擎四类自主动作 + 研判闸客户端 + 提议分流;决策系统信号消化(两条流独立消费组订阅、置信度分档转清仓指令或提议);管理页面四块 + 运维/日报抽屉;调度器九个调度位;**上游选股计划接口接入**`/plan` 取候选池、交易日龄硬校验、`theme` 灌行业映射表、页面预览抽屉与不可用横幅);**榜单变化提示**(名册快照 + 新进/掉榜/档位升降/覆盖翻转/名次跳变,持仓票单列,榜尾截断噪音闸);**ws 直连通道的连接层**(常驻进程 + 出口队列 + 签名 + seq 水位与累积确认,见下);**单测 333 例**。
**已实现**:建表 DDL 与建表脚本配置与运行参数中心仓位规划器与安全垫账命令系统27 类命令全目录 + 双状态机 + 冲突识别);方案生成器(降仓凑额四档、升仓、建仓分批、清仓/减至、行业清仓与限额、暂停买入撤单);账本回放与对账引擎(成交认领、外部成交并入 BASE 告警、以下游为准修正、除权检测、T+1 可用量、连续不一致升级);规则闸终检;择时执行器实现 B分日配额、分笔、VWAP/回踩/不追高、14:45 兜底、停牌一字板顺延、窗口耗尽收口、挂单有效期);动作引擎四类自主动作 + 研判闸客户端 + 提议分流;决策系统信号消化(两条流独立消费组订阅、置信度分档转清仓指令或提议);管理页面四块 + 运维/日报抽屉;调度器九个调度位;**上游选股计划接口接入**`/plan` 取候选池、交易日龄硬校验、`theme` 灌行业映射表、页面预览抽屉与不可用横幅);**榜单变化提示**(名册快照 + 新进/掉榜/档位升降/覆盖翻转/名次跳变,持仓票单列,榜尾截断噪音闸);**ws 直连通道的连接层**(常驻进程 + 出口队列 + 签名 + seq 水位与累积确认,见下);**单测 335 例**。
### 下一步(按可动工顺序)

View File

@ -88,14 +88,23 @@ def check_row(row: dict, price=None) -> dict:
def check_costs(rows, price_map=None, *, eq_ratio_block: float = 0.8) -> dict:
"""整批持仓的成本价体检。
`blocking` 为真表示**不该照这份数据建账**两条阻断判据:
`blocking` 为真表示**不该照这份数据建账**三条阻断判据, 共同点是"**系统性**不可用":
1. **任何一只 MISSING / ABSURD / AVAIL_BAD** 这些是数据本身不可用, 不是判断问题
一只错就够呛 账本是一本账, 里面混一只错成本的票, 之后每次看这本账都得先想起
"那只是估的"宁可等对端填对
2. **EQ_PRICE 占比过高** (默认 80%)单只票成本现价完全可能 (当日买入); 整个组合
都这样, 那就是有人拿现价当成本填进去了 而这正是 07-29 踩的那个坑的模样,
区别只在于上次是我方拿现价兜底, 这次是对端拿现价填结果一模一样: 安全垫全 0
1. **任何一只 ABSURD / AVAIL_BAD** 这不是"", "" 成本与现价差两个数量级
多半是单位(/)或字段串位, 可用量大于总量是 T+1 口径本身写错了一只出现就说明
这一批数据的生产方式有问题, 不该只怀疑那一只
2. **全部 MISSING** 对端那一列整个没填 这时建账等于全组拿现价当成本, 就是
2026-07-29 那个坑
3. **EQ_PRICE 占比过高** (默认 80%)单只成本现价完全可能 (当日买入); 整组都这样
就是有人拿现价填的 与上一条同样的结果 (安全垫全 0), 只是这次是对端填的
**少数几只 MISSING 不阻断** `recon.build_recon_fixes` 对这种情况本来就有逐只回退现价
的路径, 且在 `price_source` / `note` 里注明是估的 那是设计里定好的行为, 不该被这道
闸推翻闸的职责是拦下"一本从头就错的账", 不是替既有设计做二次判断
但要知道**估出来的成本是改不回来的**: 数量对得上就没有对账差异, 后续每天的对账都不会
再碰它所以这几只会单独列进 `estimated`, 由调用方大声报出来 它是一条已知的
带标签的坏账, 不是一条被忽略的
NO_PRICE 不阻断: 现价取不到是本端行情的事, 成本值本身没毛病, 建账照常, 只是没法核
"""
@ -107,17 +116,21 @@ def check_costs(rows, price_map=None, *, eq_ratio_block: float = 0.8) -> dict:
by.setdefault(c["verdict"], []).append(c)
n = len(checked)
hard = by.get(MISSING, []) + by.get(ABSURD, []) + by.get(AVAIL_BAD, [])
corrupt = by.get(ABSURD, []) + by.get(AVAIL_BAD, [])
missing = by.get(MISSING, [])
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 "") + ")")
if corrupt:
reasons.append(f"{len(corrupt)} 只成本价/可用量是**错的**不是缺的 ("
+ ", ".join(f"{c['ts_code']}:{c['verdict']}" for c in corrupt[:6])
+ ("" if len(corrupt) > 6 else "") + ")")
all_missing = bool(n and len(missing) == n)
if all_missing:
reasons.append(f"{n} 只**全部**没有成本价 —— 建账等于全组拿现价当成本, 安全垫会齐刷刷是 0")
eq_block = bool(judgeable >= 2 and eq_ratio >= eq_ratio_block)
if eq_block:
reasons.append(f"{n_eq}/{judgeable} 只的成本≈现价 ({eq_ratio:.0%}) —— "
@ -125,8 +138,9 @@ def check_costs(rows, price_map=None, *, eq_ratio_block: float = 0.8) -> dict:
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)}
"estimated": [c["ts_code"] for c in missing] if not all_missing else [],
"blocking": bool(corrupt or all_missing or eq_block), "reasons": reasons,
"hint": _hint(n, by, reasons, missing if not all_missing else [])}
def coverage(rows_checked) -> dict:
@ -153,7 +167,7 @@ def coverage(rows_checked) -> dict:
"缺这几种情形, 对应的纪律分支这轮验不到: " + " / ".join(missing))}
def _hint(n, by, reasons) -> str:
def _hint(n, by, reasons, estimated=()) -> str:
if not n:
return "下游一只持仓都没有 —— 无账可建 (等对端装持仓)"
if reasons:
@ -161,8 +175,14 @@ def _hint(n, by, reasons) -> str:
+ "。开仓价一错, 摊薄成本与安全垫跟着错, 而错账与真账在页面上长得一模一样 —— "
"请对端把 trading_position 的 cost_price / available_quantity 填成真实值再来")
extra = ""
if estimated:
codes = ", ".join(str(c["ts_code"]) for c in estimated[:6])
extra += (f"。**{len(estimated)} 只会拿现价当成本建账** ({codes}"
+ ("" if len(estimated) > 6 else "") + ") —— 它们的安全垫从 0 起算, "
"而且**改不回来**: 数量对得上就没有对账差异, 后续对账不会再碰它。"
"能等的话, 请对端把这几只的 cost_price 填上再建")
if by.get(EQ_PRICE):
extra = f" (其中 {len(by[EQ_PRICE])} 只成本≈现价, 若确为当日买入则正常)"
extra += f" ({len(by[EQ_PRICE])} 只成本≈现价, 若确为当日买入则正常)"
if by.get(NO_PRICE):
extra += f" ({len(by[NO_PRICE])} 只取不到现价, 成本没核但可用)"
return f"{n} 只持仓的成本价可用, 可以建账{extra}"

View File

@ -681,6 +681,13 @@ def reconcile(*, apply_fix: bool = True, force: bool = False) -> dict:
if chk["blocking"]:
logger.warning("[对账] 成本价闸本应拦下 (%s), 但 force=true 放行 —— "
"安全垫将从 0 起算, 补仓/加仓/保垫减仓这一轮判不准", chk["reasons"])
if chk.get("estimated"):
# 少数几只没成本价 —— 走 build_recon_fixes 的逐只兜底 (设计定好的行为), 不拦。
# 但必须吼一声: 估出来的成本**改不回来** (数量对得上就没有对账差异, 后续对账
# 不会再碰它), 它是一条已知的坏账, 得让人知道是哪几只。
logger.error("[对账] %s 只没有下游成本价, 将拿现价建账且**此后不会被对账修正**: %s"
"能等的话请对端补上 cost_price 再建",
len(chk["estimated"]), chk["estimated"])
if not out["coverage"]["enough"]:
logger.warning("[对账] 建账数据的情形覆盖不全: %s", out["coverage"]["hint"])

View File

@ -13,9 +13,9 @@
test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/DDL/逐笔入账 (65 )
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 (32 )
test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 (60 )
test_batch9_units.py 成本价体检 + 连续不一致按日推进 (29 )
test_batch9_units.py 成本价体检 + 连续不一致按日推进 (31 )
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) (58 )
333
335
任一子集失败即整体失败 (退出码 1)
"""
import os

View File

@ -102,12 +102,33 @@ def t_b1():
assert "可以建账" in out["hint"]
@case("[B2] 只要有一只成本不可用就阻断 —— 一本账里混一只错的也不行")
@case("[B2] 少数几只没成本价不阻断, 但要单独列出来")
def t_b2():
# 既有设计对"某一只没成本价"有逐只回退现价的路径 (build_recon_fixes, 带 price_source
# 留痕)。闸不该推翻它 —— 闸管的是"一本从头就错的账", 不是替既有设计做二次判断。
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]
assert out["blocking"] is False
assert out["estimated"] == ["600519.SH"]
assert "改不回来" in out["hint"], "估出来的成本后续对账不会再碰, 这句必须说出来"
@case("[B2b] 全部没成本价 → 阻断 (对端那一列整个没填)")
def t_b2b():
rows = [_p("600000.SH", cost=0), _p("600519.SH", cost=None)]
out = rb.check_costs(rows, {"600000.SH": 10.0, "600519.SH": 1800.0})
assert out["blocking"] is True and out["estimated"] == []
assert "全部" in out["reasons"][0]
@case("[B2c] 数据是**错的**而不是缺的 → 一只就阻断")
def t_b2c():
# ABSURD/AVAIL_BAD 说明这批数据的生产方式有问题, 不该只怀疑那一只
for bad in ({"cost": 1000.0}, {"avail": 9999}):
rows = [_p("600000.SH", cost=20.0), _p("600519.SH", **bad)]
out = rb.check_costs(rows, {"600000.SH": 10.0, "600519.SH": 10.0})
assert out["blocking"] is True, bad
assert "错的" in out["reasons"][0]
@case("[B3] 整组成本≈现价 → 阻断 (这就是 07-29 那个坑的模样)")
@ -163,7 +184,7 @@ def t_b8():
@case("[B9] 阻断时的 hint 要给出可执行的下一步")
def t_b9():
out = rb.check_costs([_p("600000.SH", cost=0)], {"600000.SH": 10.0})
out = rb.check_costs([_p("600000.SH", cost=0)], {"600000.SH": 10.0}) # 唯一一只且缺失
assert "cost_price" in out["hint"], "得说清要对端改哪一列, 不是只说'数据有问题'"
assert "长得一模一样" in out["hint"], "得说清为什么不能将就着建"