335 lines
16 KiB
Python
335 lines
16 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, 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)))
|