# -*- 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. **任何一只 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 不阻断: 现价取不到是本端行情的事, 成本值本身没毛病, 建账照常, 只是没法核。
    """
    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)
    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 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%}) —— "
                       f"整组安全垫会是 0, 补仓/加仓/保垫减仓全都判不出来")

    return {"n": n, "counts": {k: len(v) for k, v in sorted(by.items())},
            "rows": checked, "eq_ratio": round(eq_ratio, 4),
            "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:
    """这批持仓能不能把纪律验到 (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, estimated=()) -> str:
    if not n:
        return "下游一只持仓都没有 —— 无账可建 (等对端装持仓)"
    if reasons:
        return ("**不建议照这份数据建账**: " + "; ".join(reasons)
                + "。开仓价一错, 摊薄成本与安全垫跟着错, 而错账与真账在页面上长得一模一样 —— "
                  "请对端把 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])} 只成本≈现价, 若确为当日买入则正常)"
    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
