# -*- coding: utf-8 -*- """ 成交回放与对账 (纯逻辑, 无外部依赖, 可单测) ============================================ 对应设计 POSITION_MGMT_DESIGN.md §4「成交回放与对账(生命线)」与 §13 降级表。 四件事: 1. 回放: 下游 trading_order 的已成交单 → 认领到 PMS 指令 → 转成批次入账动作; 认领不上的即「外部成交」, 买入并入 BASE 并告警留痕 (设计原话)。 2. 对账: 账本 total_qty vs 下游 trading_position 数量, **以下游为准**修正, 修正走 RECON 批次留痕。 3. 除权检测: 数量与价格反比突变 → 按比例调整批次; 比例不吻合 → ERROR 待人工。 4. T+1 可用量: 日初重置 = 全部持仓可卖; 当日买入不增可卖, 当日卖出扣减可卖。 认领口径说明 (重要): trading_order 目前**没有** PMS 指令来源列 —— 该列已列入 QMT_INTERFACE_REQUIREMENTS A2 («来源标识», 待下游答复)。在拿到来源标识前, 本模块按 (代码, 方向, 时间, 数量) 做 FIFO 贪心认领: 同股同向、下发时间早于成交时间的在途指令按下发顺序吃单。 下游一旦提供来源标识, 只需给 fills 带上 instruction_id, 认领即退化为精确匹配 (见 claim_fills)。 """ from __future__ import annotations from app.core.cushion import sell_allocation # 指令动作 → 批次类型 (设计 §4: 批次 BASE/FILL/ADD/DCA/T0, RECON 为对账调整专用) ACTION_TO_LOT = { "OPEN": "BASE", "FILL": "FILL", "ADD": "ADD", "DCA": "DCA", "T0_ROUND": "T0", "BASE": "BASE", "T0": "T0", } ALERT_EXTERNAL = "EXTERNAL_FILL" # 对不上指令的外部成交 ALERT_OVERFILL = "OVER_FILL" # 成交量超过指令数量 ALERT_SELL_NO_LOT = "SELL_WITHOUT_LOT" # 卖出但账本无对应批次 SEV_OK, SEV_WARN, SEV_ERROR = "OK", "WARN", "ERROR" def _side(v) -> str: s = str(v or "").strip().lower() if s in ("buy", "b", "1", "买入", "买"): return "buy" if s in ("sell", "s", "2", "卖出", "卖"): return "sell" return s def fill_qty_price(f: dict) -> tuple: """从一条 trading_order 记录取 (成交数量, 成交价)。 下游 filled_amount 不可靠 (bionic entry_gate 既有结论), 故价格口径: filled_price/exec_avg_price > order_price; 数量口径: filled_qty > order_quantity。 """ qty = f.get("filled_qty") if qty in (None, 0, "0"): qty = f.get("order_quantity") price = f.get("filled_price") or f.get("exec_avg_price") or f.get("order_price") try: qty = int(float(qty or 0)) except (TypeError, ValueError): qty = 0 try: price = float(price or 0) except (TypeError, ValueError): price = 0.0 return qty, price # ================================================================ # 1. 回放: 认领 + 转账本动作 # ================================================================ def claim_fills(fills: list, open_instructions: list) -> list: """把成交单认领到 PMS 在途指令上 (FIFO 贪心)。 fills: [{order_id, ts_code, side, qty, price, done_time, instruction_id(可选)}] 时间升序 open_instructions: [{instruction_id, ts_code, side, qty, exec_qty, action, dispatched_at}] 返回 [{fill, claims:[{instruction_id, qty, action}], unclaimed_qty}] """ remain = {} for i in open_instructions or []: remain[i["instruction_id"]] = max(0, int(i.get("qty") or 0) - int(i.get("exec_qty") or 0)) idx = {i["instruction_id"]: i for i in (open_instructions or [])} out = [] for f in fills or []: qty = int(f.get("qty") or 0) claims, left = [], qty # (a) 下游已提供来源标识 → 精确认领 iid = f.get("instruction_id") if iid and iid in remain: take = min(left, remain[iid]) if take > 0: claims.append({"instruction_id": iid, "qty": take, "action": idx[iid].get("action")}) remain[iid] -= take left -= take # (b) 无来源标识 → 同股同向、下发早于成交、按下发时间 FIFO if left > 0: cands = [i for i in (open_instructions or []) if i.get("ts_code") == f.get("ts_code") and _side(i.get("side")) == _side(f.get("side")) and remain.get(i["instruction_id"], 0) > 0 and _not_after(i.get("dispatched_at"), f.get("done_time"))] cands.sort(key=lambda i: (str(i.get("dispatched_at") or ""), i["instruction_id"])) for i in cands: if left <= 0: break take = min(left, remain[i["instruction_id"]]) if take <= 0: continue claims.append({"instruction_id": i["instruction_id"], "qty": take, "action": i.get("action")}) remain[i["instruction_id"]] -= take left -= take out.append({"fill": f, "claims": claims, "unclaimed_qty": left}) return out def _not_after(dispatched_at, done_time) -> bool: """下发时间 ≤ 成交时间 (任一为空则不做时间约束, 交给数量口径兜底)。""" if not dispatched_at or not done_time: return True try: return str(dispatched_at) <= str(done_time) except Exception: return True def map_fills_to_book(fills: list, open_instructions: list, known_order_ids=()) -> dict: """成交单 → 账本动作。幂等: known_order_ids 里的单直接跳过 (回放可重跑)。 返回 {"actions":[...], "alerts":[...], "skipped":int} action = {kind: BUY|SELL, ts_code, qty, price, lot_type(买入), instruction_id, order_id, alerts} """ known = set(known_order_ids or ()) fresh = [f for f in (fills or []) if f.get("order_id") not in known] claimed = claim_fills(fresh, open_instructions) actions, alerts = [], [] for c in claimed: f, left = c["fill"], c["unclaimed_qty"] side = _side(f.get("side")) price = float(f.get("price") or 0) for cl in c["claims"]: lot_type = ACTION_TO_LOT.get(str(cl.get("action") or "").upper(), "BASE") actions.append({"kind": "BUY" if side == "buy" else "SELL", "ts_code": f.get("ts_code"), "qty": cl["qty"], "price": price, "lot_type": lot_type if side == "buy" else None, "instruction_id": cl["instruction_id"], "order_id": f.get("order_id"), "alerts": []}) if left > 0: a = [ALERT_EXTERNAL] msg = (f"外部成交: {f.get('ts_code')} {side} {left} 股 @ {price} " f"(order_id={f.get('order_id')}) 认领不到 PMS 指令") if c["claims"]: a.append(ALERT_OVERFILL) msg += " —— 且成交量超过在途指令数量" alerts.append({"level": "WARN", "code": ALERT_EXTERNAL, "ts_code": f.get("ts_code"), "message": msg, "order_id": f.get("order_id")}) actions.append({"kind": "BUY" if side == "buy" else "SELL", "ts_code": f.get("ts_code"), "qty": left, "price": price, "lot_type": "BASE" if side == "buy" else None, # 外部买入并入 BASE "instruction_id": None, "order_id": f.get("order_id"), "alerts": a}) return {"actions": actions, "alerts": alerts, "skipped": len(fills or []) - len(fresh)} def apply_sell_to_lots(lots: list, qty: int) -> dict: """卖出核销预演: 按 T0→ADD(新→旧)→DCA→FILL→BASE 分配。 账本批次不足时按可核销量分配并告警 (以下游为准, 差额留给对账修正)。""" total = sum(int(l.get("qty") or 0) for l in (lots or [])) if qty > total: alloc = sell_allocation(lots, total) if total > 0 else [] return {"alloc": alloc, "short": qty - total, "alerts": [{"level": "WARN", "code": ALERT_SELL_NO_LOT, "message": f"卖出 {qty} 股 > 账本批次 {total} 股, 差额 {qty - total} " f"股待对账修正 (以下游为准)"}]} return {"alloc": sell_allocation(lots, qty), "short": 0, "alerts": []} def next_cursor(fills: list, cursor=None, key: str = "order_id"): """回放游标推进 (取本批最大 key, 空批保持原值)。""" vals = [f.get(key) for f in (fills or []) if f.get(key) is not None] if not vals: return cursor try: mx = max(int(v) for v in vals) return mx if cursor is None else max(int(cursor), mx) except (TypeError, ValueError): mx = max(str(v) for v in vals) return mx if cursor is None else max(str(cursor), mx) # ================================================================ # 2. 对账: 账本 vs 下游 (以下游为准) # ================================================================ def diff_positions(book_rows: list, ds_rows: list) -> list: """账本与下游持仓比对。返回差异列表 (无差异 = 空表)。 book_rows: [{ts_code, total_qty}] ; ds_rows: [{ts_code, qty}] (下游 trading_position) kind: MISSING_IN_BOOK(下游有账本无) / EXTRA_IN_BOOK(账本有下游无) / QTY_MISMATCH delta = 下游 − 账本 (正数 = 账本要补, 负数 = 账本要减) """ book = {r["ts_code"]: int(r.get("total_qty") or 0) for r in (book_rows or [])} ds = {r["ts_code"]: int(r.get("qty") or 0) for r in (ds_rows or [])} out = [] for code in sorted(set(book) | set(ds)): b, d = book.get(code, 0), ds.get(code, 0) if b == d: continue if b == 0: kind = "MISSING_IN_BOOK" elif d == 0: kind = "EXTRA_IN_BOOK" else: kind = "QTY_MISMATCH" out.append({"ts_code": code, "book_qty": b, "ds_qty": d, "delta": d - b, "kind": kind}) return out def build_recon_fixes(diffs: list, price_map=None, lots_map=None, cost_map=None) -> list: """按差异生成修正动作 (以下游为准, 全部留痕)。 delta > 0 → 新增 RECON 批次补足 delta < 0 → 按核销次序冲销 |delta| 股 (用现价算已实现盈亏) **补仓位时开仓价必须优先取下游的成本价, 不能拿现价充数。** 这条踩过: 2026-07-29 首次把下游 22 只已有持仓接进账本时, RECON 批次全按现价开仓, 于是每只票的 摊薄成本都等于当天现价、安全垫齐刷刷是 0 —— 而安全垫是盈利加仓(≥3%)、保垫减仓(峰值≥6%)、 补仓评估档(−8%/−15%) 共同的判断依据。一只真实成本 20、现价 10 的票 (实亏 50%) 会被记成 "不赚不亏", 该评估的补仓不评估; 页面与日报上的浮动盈亏也全是 0。 下游 `trading_position.cost_price` 本来就有这个数, 没有理由不用。 现价只在下游给不出成本价时兜底, 并在 note 里注明来源 —— 账本上要看得出这笔成本是估的。 """ price_map = price_map or {} lots_map = lots_map or {} cost_map = cost_map or {} fixes = [] for d in diffs or []: code, delta = d["ts_code"], d["delta"] px = float(price_map.get(code) or 0) if delta > 0: cost = float(cost_map.get(code) or 0) open_px, src = (cost, "下游成本价") if cost > 0 else (px, "现价兜底(下游无成本价)") fixes.append({"ts_code": code, "op": "ADD_RECON_LOT", "qty": delta, "price": open_px, "need_price": open_px <= 0, "price_source": src, "note": f"对账修正(+{delta}股): 以下游为准补入 RECON 批次, " f"开仓价取{src}"}) else: lots = lots_map.get(code) or [] res = apply_sell_to_lots(lots, -delta) fixes.append({"ts_code": code, "op": "REDUCE_LOTS", "qty": -delta, "alloc": res["alloc"], "short": res["short"], "price": px, "note": f"对账修正({delta}股): 以下游为准冲销批次"}) return fixes def recon_severity(consecutive_days: int, alarm_days: int = 3) -> str: """连续不一致天数 → 告警级别 (设计 §13: 连续 3 日 → ERROR 待人工)。""" n = int(consecutive_days or 0) if n <= 0: return SEV_OK return SEV_ERROR if n >= int(alarm_days) else SEV_WARN def summarize_recon(diffs: list, consecutive_days: int = 0, alarm_days: int = 3) -> dict: kinds = {} for d in diffs or []: kinds[d["kind"]] = kinds.get(d["kind"], 0) + 1 return {"diff_count": len(diffs or []), "by_kind": kinds, "consecutive_days": int(consecutive_days or 0), "severity": recon_severity(consecutive_days if diffs else 0, alarm_days)} # ================================================================ # 3. 除权检测与批次调整 # ================================================================ def detect_ex_right(prev_qty: int, now_qty: int, prev_price: float, now_price: float, tol: float = 0.02) -> dict: """数量与价格反比突变 = 除权 (送转股)。 送股/转增: 数量 ×k, 价格 ÷k ⇒ 数量比 ≈ 价格反比。 返回 None(无异动) / {"kind":"EX_RIGHT","ratio":k} / {"kind":"MISMATCH",...}(待人工) """ prev_qty, now_qty = int(prev_qty or 0), int(now_qty or 0) prev_price, now_price = float(prev_price or 0), float(now_price or 0) if prev_qty <= 0 or now_qty <= prev_qty: return None qr = now_qty / prev_qty if prev_price <= 0 or now_price <= 0: return {"kind": "MISMATCH", "qty_ratio": round(qr, 4), "price_ratio": None, "reason": "缺少价格, 无法判定除权 (待人工)"} pr = prev_price / now_price rel = abs(qr - pr) / qr if rel <= float(tol): return {"kind": "EX_RIGHT", "ratio": round(qr, 6), "qty_ratio": round(qr, 4), "price_ratio": round(pr, 4), "rel_err": round(rel, 4)} return {"kind": "MISMATCH", "ratio": round(qr, 6), "qty_ratio": round(qr, 4), "price_ratio": round(pr, 4), "rel_err": round(rel, 4), "reason": f"数量比 {qr:.3f} 与价格反比 {pr:.3f} 不吻合 (偏差 {rel:.1%}), " f"疑似非除权变动 → ERROR 待人工"} def apply_ex_right(lots: list, ratio: float) -> list: """按除权比例调整批次: 数量 ×ratio, 成本价 ÷ratio (总成本不变)。""" r = float(ratio) out = [] for l in lots or []: q = int(round(int(l.get("qty") or 0) * r)) px = float(l.get("open_price") or 0) / r if r else 0.0 n = dict(l) n["qty"], n["open_price"] = q, round(px, 3) n["note"] = f"除权调整 ×{r:g}" out.append(n) return out # ================================================================ # 4. T+1 可用量 # ================================================================ def daily_avail_reset(total_qty: int) -> int: """日初重置: 昨日及以前的持仓全部可卖。""" return max(0, int(total_qty or 0)) def avail_after_fill(avail_qty: int, side: str, qty: int) -> int: """当日成交对可卖量的影响: 买入不增可卖 (T+1), 卖出扣减可卖。""" a, q = int(avail_qty or 0), int(qty or 0) return max(0, a - q) if _side(side) == "sell" else a def sellable_today(avail_qty: int, want_qty: int) -> int: """本次可卖数量 = min(想卖, 可卖); 部分不可卖时由执行器顺延次日。""" return max(0, min(int(avail_qty or 0), int(want_qty or 0)))