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