322 lines
14 KiB
Python
322 lines
14 KiB
Python
|
|
# -*- 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)))
|