185 lines
9.3 KiB
Python
185 lines
9.3 KiB
Python
|
|
# -*- 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
|