# -*- coding: utf-8 -*- """ 账本服务: 成交回放 · 对账 · 除权 · 日终结算 (设计 §4「生命线」) ================================================================ 纯逻辑在 app/core/recon.py, 本模块只负责取数、落库与状态推进。 摊薄成本口径 (与 core/cushion.PositionCost 等价, 但数据来自批次表): cum_buy = Σ (剩余数量 + 已核销数量) × 开仓价 cum_sell = Σ 已核销数量 × 核销均价 avg_cost = max(0, (cum_buy − cum_sell) / 当前持股数) 做T利润通过 T0 批次的买卖流水自然摊入上式, 故 **不再另行扣减** realized_t_profit (该列仅作展示统计, 重复扣减会把成本做低两次)。 铁律: 对账以下游为准; 修正一律走 RECON 批次留痕; 连续 N 日不一致升级 ERROR。 """ from __future__ import annotations import logging from datetime import datetime, timedelta from app.core import cushion as cu from app.core import recon as rc from app.core import tradedays as td from app.repo import downstream_repo, pms_repo from app.services import market, param_store, portfolio logger = logging.getLogger("pms.ledger") CURSOR_KEY = "PMS_REPLAY_CURSOR" STREAK_KEY = "PMS_RECON_STREAK" LIVE_INSTR = ("DISPATCHED", "JUDGE_PASSED", "RULE_PASSED") # ================================================================ 回放 def replay_fills(*, limit: int = 500) -> dict: """增量回放 trading_order 已成交单 → 批次入账 (每 5 分钟一跳, 幂等)。""" out = {"ok": True, "fills": 0, "actions": 0, "alerts": [], "errors": [], "cursor": None} cursor = pms_repo.get_param(CURSOR_KEY) or None try: fills = downstream_repo.fetch_filled_orders(since_id=cursor, limit=limit) except Exception as e: out.update({"ok": False, "errors": [f"读 trading_order 失败: {type(e).__name__}: {e}"]}) return out out["fills"] = len(fills) if not fills: out["cursor"] = cursor return out try: instrs = _open_instructions() except Exception as e: instrs = [] out["errors"].append(f"读在途指令失败(按外部成交处理): {e}") mapped = rc.map_fills_to_book(fills, instrs) for act in mapped["actions"]: try: _apply_action(act) out["actions"] += 1 except Exception as e: logger.exception("入账失败 %s", act) out["errors"].append(f"{act.get('ts_code')} 入账失败: {type(e).__name__}: {e}") out["alerts"] = mapped["alerts"] for code in {a["ts_code"] for a in mapped["actions"]}: try: recompute_position(code) except Exception as e: out["errors"].append(f"{code} 成本重算失败: {e}") try: nc = rc.next_cursor(fills, cursor) pms_repo.set_param(CURSOR_KEY, nc, "system") out["cursor"] = nc except Exception as e: out["errors"].append(f"游标推进失败: {e}") out["ok"] = not out["errors"] for a in out["alerts"]: logger.warning("[回放告警] %s", a.get("message")) return out def _open_instructions() -> list: rows = pms_repo.list_instructions(statuses=list(LIVE_INSTR), limit=500) return [{"instruction_id": r["instruction_id"], "ts_code": r["ts_code"], "side": r.get("side"), "qty": int(r.get("qty") or 0), "exec_qty": int(r.get("exec_qty") or 0), "action": r.get("action"), "dispatched_at": str(r.get("updated_at") or r.get("created_at") or "")} for r in rows] def _apply_action(act: dict): code, qty, px = act["ts_code"], int(act["qty"]), float(act["price"] or 0) if qty <= 0: return pms_repo.ensure_position(code) if act["kind"] == "BUY": pms_repo.insert_lot(ts_code=code, lot_type=act.get("lot_type") or "BASE", qty=qty, open_price=px, open_date=datetime.now().date(), instruction_id=act.get("instruction_id"), note="外部成交并入 BASE" if not act.get("instruction_id") else None) pms_repo.bump_position_qty(code, total_delta=qty, avail_delta=0) # T+1: 当日买入不可卖 else: lots = pms_repo.list_lots(code, status="OPEN") res = rc.apply_sell_to_lots( [{"lot_id": l["id"], "lot_type": l["lot_type"], "qty": int(l["qty"]), "open_date": _date_key(l["open_date"])} for l in lots], qty) by_id = {l["id"]: l for l in lots} t_profit = 0.0 for a in res["alloc"]: lot = by_id[a["lot_id"]] pnl = (px - float(lot["open_price"] or 0)) * a["qty"] pms_repo.close_lot_qty(a["lot_id"], qty=a["qty"], close_price=px, realized_pnl=pnl) if lot["lot_type"] == "T0": t_profit += pnl pms_repo.bump_position_qty(code, total_delta=-qty, avail_delta=-qty) if t_profit: pos = pms_repo.get_position(code) or {} pms_repo.update_position( code, realized_t_profit=float(pos.get("realized_t_profit") or 0) + t_profit) for al in res["alerts"]: logger.warning("[卖出核销] %s %s", code, al.get("message")) if act.get("instruction_id"): try: pms_repo.add_instruction_exec(act["instruction_id"], qty) _settle_instruction(act["instruction_id"]) except Exception as e: logger.warning("指令进度更新失败 %s: %s", act["instruction_id"], e) def _settle_instruction(instruction_id: str): """成交量达到指令数量 → 置 CONFIRMED (部分成交保持在途, 由窗口/过期规则收口)。""" ins = pms_repo.get_instruction(instruction_id) if ins and int(ins.get("exec_qty") or 0) >= int(ins.get("qty") or 0) > 0: pms_repo.update_instruction(instruction_id, status="CONFIRMED") def _date_key(d): try: return int(str(d).replace("-", "")[:8]) except (TypeError, ValueError): return 0 def recompute_position(ts_code: str) -> dict: """由批次表重算持仓数量/摊薄成本/安全垫/垫子峰值。""" lots = pms_repo.list_lots(ts_code, status=None, limit=2000) qty = sum(int(l["qty"] or 0) for l in lots) cum_buy = sum((int(l["qty"] or 0) + int(l["closed_qty"] or 0)) * float(l["open_price"] or 0) for l in lots) cum_sell = sum(int(l["closed_qty"] or 0) * float(l["close_avg_price"] or 0) for l in lots) by_type = {} for l in lots: if int(l["qty"] or 0) > 0: by_type[l["lot_type"]] = by_type.get(l["lot_type"], 0) + int(l["qty"]) avg_cost = max(0.0, (cum_buy - cum_sell) / qty) if qty > 0 else None px = market.get_price(ts_code) or avg_cost or 0 cp = (px / avg_cost - 1.0) if (avg_cost and avg_cost > 0 and px) else None solid = param_store.get_float("PMS_CUSHION_SOLID", 0.03) pos = pms_repo.get_position(ts_code) or {} peak = max(float(pos.get("cushion_peak") or 0), cp or 0) scale = param_store.get_float("PMS_TOTAL_SCALE", 0) fields = { "total_qty": qty, "base_qty": by_type.get("BASE", 0) + by_type.get("RECON", 0), "fill_qty": by_type.get("FILL", 0), "add_qty": by_type.get("ADD", 0), "dca_qty": by_type.get("DCA", 0), "t0_qty": by_type.get("T0", 0), "avg_cost": round(avg_cost, 3) if avg_cost else None, "cushion_pct": round(cp, 4) if cp is not None else None, "cushion_state": cu.cushion_state(cp, solid), "cushion_peak": round(peak, 4), "pct_of_scale": round(qty * px / scale, 4) if scale > 0 else None, "status": "CLOSED" if qty <= 0 else (pos.get("status") or "HOLDING"), } if qty > 0 and (pos.get("status") in (None, "PLANNED", "CLOSED")): fields["status"] = "HOLDING" if qty > 0 and not pos.get("opened_date"): fields["opened_date"] = datetime.now().date() pms_repo.update_position(ts_code, **fields) return fields # ================================================================ 对账 def reconcile(*, apply_fix: bool = True) -> dict: """账本 vs 下游持仓, 以下游为准修正并留痕。""" out = {"ok": True, "diffs": [], "fixes": [], "errors": [], "columns": {}, "severity": rc.SEV_OK} try: ds = downstream_repo.fetch_positions() except Exception as e: out.update({"ok": False, "errors": [f"读 trading_position 失败: {type(e).__name__}: {e}"]}) return out out["columns"] = ds["columns"] if ds["rows"] and ds["columns"].get("qty") is None: out.update({"ok": False, "errors": [ "下游持仓表未识别出数量列 —— 请按 QMT_INTERFACE_REQUIREMENTS A1/D1 取得 DDL 后, " "把列名补进 downstream_repo.QTY_CANDIDATES"]}) return out book = [{"ts_code": r["ts_code"], "total_qty": int(r.get("total_qty") or 0)} for r in pms_repo.list_positions()] diffs = rc.diff_positions(book, [{"ts_code": r["ts_code"], "qty": r["qty"]} for r in ds["rows"]]) out["diffs"] = diffs streak = param_store.get_int(STREAK_KEY, 0) streak = streak + 1 if diffs else 0 # 必须走 ParamStore 写入: 直接写库不会失效缓存, 会导致连续天数一直读到旧值 param_store.set_param(STREAK_KEY, streak, "system") out["severity"] = rc.recon_severity(streak if diffs else 0, param_store.get_int("PMS_RECON_ALARM_DAYS", 3)) if not diffs or not apply_fix: return out codes = [d["ts_code"] for d in diffs] prices = market.get_prices(codes) lots_map = {c: [{"lot_id": l["id"], "lot_type": l["lot_type"], "qty": int(l["qty"]), "open_date": _date_key(l["open_date"])} for l in pms_repo.list_lots(c, status="OPEN")] for c in codes} fixes = rc.build_recon_fixes(diffs, price_map=prices, lots_map=lots_map) for f in fixes: try: _apply_fix(f) recompute_position(f["ts_code"]) except Exception as e: logger.exception("对账修正失败 %s", f) out["errors"].append(f"{f['ts_code']} 修正失败: {type(e).__name__}: {e}") out["fixes"] = fixes out["ok"] = not out["errors"] if out["severity"] == rc.SEV_ERROR: logger.error("[对账] 连续 %s 日不一致, 升级 ERROR 待人工: %s 项差异", streak, len(diffs)) return out def _apply_fix(f: dict): code = f["ts_code"] pms_repo.ensure_position(code) if f["op"] == "ADD_RECON_LOT": px = float(f.get("price") or 0) pms_repo.insert_lot(ts_code=code, lot_type="RECON", qty=int(f["qty"]), open_price=px, open_date=datetime.now().date(), note=f["note"] + ("" if px > 0 else " [缺现价, 成本待人工核]")) else: lots = {l["id"]: l for l in pms_repo.list_lots(code, status="OPEN")} px = float(f.get("price") or 0) for a in f.get("alloc") or []: lot = lots.get(a["lot_id"]) pnl = (px - float(lot["open_price"] or 0)) * a["qty"] if (lot and px) else 0.0 pms_repo.close_lot_qty(a["lot_id"], qty=a["qty"], close_price=px or float(lot["open_price"] or 0), realized_pnl=pnl) pms_repo.insert_ledger(ts_code=code, action="RECON", arbiter="rule", verdict="PASS", price_at=float(f.get("price") or 0), hard_numbers={"op": f["op"], "qty": f["qty"]}, reason=f["note"]) # ================================================================ 除权 def detect_and_apply_ex_right() -> dict: """用昨日结算快照与今日持仓/价格比对, 识别送转股并按比例调整批次。""" out = {"checked": 0, "ex_rights": [], "mismatches": [], "errors": []} prev = _prev_snapshot() if not prev: out["errors"].append("无昨日结算快照, 本次跳过除权检测 (次日起生效)") return out for pos in pms_repo.list_positions(only_open=True): code = pos["ts_code"] old = prev.get(code) if not old: continue out["checked"] += 1 px = market.get_price(code) r = rc.detect_ex_right(int(old.get("qty") or 0), int(pos.get("total_qty") or 0), float(old.get("price") or 0), float(px or 0)) if not r: continue if r["kind"] == "EX_RIGHT": try: lots = pms_repo.list_lots(code, status="OPEN") for l in rc.apply_ex_right(lots, r["ratio"]): pms_repo.update_lot(l["id"], qty=l["qty"], open_price=l["open_price"], note=l["note"]) recompute_position(code) pms_repo.insert_ledger(ts_code=code, action="RECON", arbiter="rule", verdict="PASS", price_at=px or 0, hard_numbers=r, reason=f"除权调整 ×{r['ratio']}") out["ex_rights"].append({"ts_code": code, **r}) except Exception as e: out["errors"].append(f"{code} 除权调整失败: {e}") else: out["mismatches"].append({"ts_code": code, **r}) logger.error("[除权] %s 比例不吻合, 待人工: %s", code, r.get("reason")) return out def _prev_snapshot() -> dict: """取最近一份日终快照 {code: {qty, price}} (存在 pms_daily_report 里, 不新增表)。""" r = pms_repo.latest_report() if not r: return {} snap = (r.get("report") or {}).get("snapshot") or {} return snap if isinstance(snap, dict) else {} # ================================================================ 盘前 / 日终 def premarket() -> dict: """盘前准备 (08:50): T+1 可卖重置、参考位取数、刹车结算。""" out = {"ok": True, "avail_reset": 0, "refs": 0, "brake": None, "errors": []} try: out["avail_reset"] = pms_repo.reset_avail_all() except Exception as e: out["errors"].append(f"可卖量重置失败: {e}") for pos in pms_repo.list_positions(only_open=True): code = pos["ts_code"] try: refs = market.get_refs(code, base_cost=pos.get("avg_cost")) pms_repo.update_position(code, support_ref=refs.get("support"), pressure_ref=refs.get("pressure"), stop_ref=refs.get("stop"), ref_source=refs.get("source")) out["refs"] += 1 except Exception as e: out["errors"].append(f"{code} 参考位取数失败: {e}") try: out["brake"] = _settle_brake() except Exception as e: out["errors"].append(f"刹车结算失败: {e}") out["ok"] = not out["errors"] return out def _settle_brake() -> dict: """组合刹车: 自高水位回撤 ≥ 阈值 → 自主增持停 N 个交易日 (命令类不受限)。""" v = portfolio.positions_view() mv = v["totals"]["portfolio_mv"] hw = param_store.get_float("PMS_HIGH_WATER", 0.0) dd_limit = param_store.get_float("PMS_BRAKE_DRAWDOWN", 0.05) days = param_store.get_int("PMS_BRAKE_DAYS", 3) until = param_store.get_int("PMS_BRAKE_UNTIL", 0) today = td.ymd() if mv > hw: param_store.set_param("PMS_HIGH_WATER", mv, "system") hw = mv drawdown = (1 - mv / hw) if hw > 0 else 0.0 if hw > 0 and drawdown >= dd_limit and today >= until: until = td.ymd(td.next_trade_day(datetime.now().date(), days)) param_store.set_param("PMS_BRAKE_UNTIL", until, "system") logger.warning("[刹车] 自高水位回撤 %.1f%% ≥ %.0f%%, 自主增持暂停至 %s", drawdown * 100, dd_limit * 100, until) return {"high_water": hw, "portfolio_mv": mv, "drawdown": round(drawdown, 4), "brake_until": until, "active": today < until} def daily_settle() -> dict: """日终结算 (15:10): 除权检测 → 全量对账 → 垫子峰值/连负天数 → 命令进度日结 → 快照留存。""" from app.services import command_service out = {"ok": True, "steps": {}, "errors": []} try: out["steps"]["ex_right"] = detect_and_apply_ex_right() except Exception as e: out["errors"].append(f"除权检测失败: {e}") try: out["steps"]["recon"] = reconcile() except Exception as e: out["errors"].append(f"对账失败: {e}") try: out["steps"]["cushion"] = _settle_cushion() except Exception as e: out["errors"].append(f"安全垫结算失败: {e}") try: out["steps"]["commands"] = command_service.refresh_progress() except Exception as e: out["errors"].append(f"命令进度结算失败: {e}") try: pms_repo.expire_proposals() except Exception as e: out["errors"].append(f"提议过期处理失败: {e}") out["ok"] = not out["errors"] return out def _settle_cushion() -> dict: """更新垫子峰值与「安全垫连续为负天数」(清弱票判定所需)。""" v = portfolio.positions_view() streak = portfolio.neg_streak_map() updated = 0 for x in v["held"]: code, cp = x["ts_code"], x["cushion_pct"] streak[code] = (int(streak.get(code, 0)) + 1) if (cp is not None and cp < 0) else 0 peak = max(float(x["cushion_peak"] or 0), cp or 0) pms_repo.update_position(code, cushion_pct=cp, cushion_peak=round(peak, 4), cushion_state=x["cushion_state"]) updated += 1 held = {x["ts_code"] for x in v["held"]} portfolio.save_neg_streak({k: v2 for k, v2 in streak.items() if k in held}) return {"updated": updated, "neg_streak": {k: v2 for k, v2 in streak.items() if v2 > 0 and k in held}} # ================================================================ 日报 def build_daily_report(ymd: int = None) -> dict: """运营日报 (15:30): 关注区 + 全量统计 + 当日快照 (快照供次日除权检测)。""" from app.services import command_service ymd = int(ymd or td.ymd()) v = portfolio.positions_view() t = v["totals"] try: recon_state = {"streak": param_store.get_int(STREAK_KEY, 0)} except Exception: recon_state = {} cmds = pms_repo.list_commands(statuses=["EXECUTING", "PARTIAL", "PENDING", "PLANNING"], limit=100) proposals = pms_repo.list_proposals(statuses=("WAIT_USER",), limit=100) live_ins = pms_repo.list_instructions(statuses=list(LIVE_INSTR), limit=200) attention = [] for c in cmds: prog = c.get("progress") or {} attention.append({"type": "命令进度", "command_id": c["command_id"], "cmd_type": c["cmd_type"], "status": c["status"], "done": prog.get("done_amount"), "target": prog.get("target_amount"), "deadline": prog.get("deadline")}) if proposals: attention.append({"type": "待确认提议", "count": len(proposals)}) if recon_state.get("streak"): attention.append({"type": "对账差异", "streak": recon_state["streak"], "severity": rc.recon_severity( recon_state["streak"], param_store.get_int("PMS_RECON_ALARM_DAYS", 3))}) brake_until = param_store.get_int("PMS_BRAKE_UNTIL", 0) if brake_until > ymd: attention.append({"type": "组合刹车", "until": brake_until}) if v["price_missing"]: attention.append({"type": "行情缺失", "codes": v["price_missing"]}) if not v["sector_ready"]: attention.append({"type": "行业约束停用", "hint": "PMS_SECTOR_SOURCE 未配置"}) if td.calendar_degraded(): attention.append({"type": "交易日历降级", "hint": "未安装 chinesecalendar, 节假日不可辨"}) report = { "ymd": ymd, "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "totals": t, "attention": attention, "commands": [{"command_id": c["command_id"], "cmd_type": c["cmd_type"], "status": c["status"], "progress": c.get("progress")} for c in cmds], "proposals": len(proposals), "live_instructions": len(live_ins), "positions": [{"ts_code": x["ts_code"], "qty": x["total_qty"], "price": x["price"], "avg_cost": x["avg_cost"], "cushion_pct": x["cushion_pct"], "cushion_state": x["cushion_state"], "mv": x["market_value"], "pct_of_scale": x["pct_of_scale"]} for x in v["held"]], # 次日除权检测用的快照 (数量 + 收盘价) "snapshot": {x["ts_code"]: {"qty": x["total_qty"], "price": x["price"]} for x in v["held"]}, "recon": recon_state, } try: pms_repo.upsert_report(ymd, report) except Exception as e: logger.error("日报落表失败: %s", e) report["save_error"] = str(e) return report def expire_stale_instructions() -> int: """下发后长时间未被接受的指令置过期 (不自动重发 —— 设计 §13)。""" mins = param_store.get_int("PMS_DISPATCH_EXPIRE_MIN", 30) cut = datetime.now() - timedelta(minutes=mins) n = 0 for r in pms_repo.list_instructions(statuses=["DISPATCHED"], limit=500): try: if str(r.get("updated_at") or "") and str(r["updated_at"]) < str(cut): pms_repo.update_instruction(r["instruction_id"], status="EXPIRED") n += 1 except Exception: continue return n