tradingSystem/app/core/recon.py

322 lines
14 KiB
Python
Raw Normal View History

# -*- 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)))