tradingSystem/app/core/rebuild_check.py

185 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- 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