tradingSystem/app/core/recon.py

322 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- 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) -> list:
"""按差异生成修正动作 (以下游为准, 全部留痕)。
delta > 0 → 新增 RECON 批次补足 (价格取现价/成本代理, 无价则标 NEED_PRICE)
delta < 0 → 按核销次序冲销 |delta| 股
"""
price_map = price_map or {}
lots_map = lots_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:
fixes.append({"ts_code": code, "op": "ADD_RECON_LOT", "qty": delta,
"price": px, "need_price": px <= 0,
"note": f"对账修正(+{delta}股): 以下游为准补入 RECON 批次"})
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)))