tradingSystem/app/core/rebuild_check.py

185 lines
9.3 KiB
Python
Raw Normal View History

2026-07-31 13:46:15 +08:00
# -*- 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