191270c884
2026-08-25 事故:small_cap同轮「全卖19只→马上全买20只」在两轮归因轮询间隙 读台账现金,卖出回款不可见→20笔买入全部目标0、全天空仓;momentum同型撞运 只入账首笔79k/6=13.2k缩水44%仓;value无卖后买序列满额(反证)。QMT无责 (0.5s filled/券商现金即时/下单线程同步见filled),gap=runner_live.py归因 poller 60s一轮才调ledger.apply_trade(唯一cash更新入口,DB落库时间戳恰差60s 铁证)。 修法(B,治本现金新鲜度): - LiveInstanceLedger.on_order_done钩子+notify_order_done(未注入/抛错静默, 绝不阻断下单;漏单由轮询兜底);apply_trade幂等判定整体移入锁内——钩子 (策略线程)与轮询(poller线程)并发同步同一笔成交时恰好一笔入账,防双计 - live_strategy._instance_order_wrappers:所有真实委托(bt_order/透传)返回后 _done()触发即时归因;决策层不下单的路径不触发 - runner_live:engine装配后注入on_order_done=_sync_instance_trades闭包; 60s轮询保留兜底(部分成交后续/异步路径) 测试+8:钩子三态(nop/触发/吞异常)+8线程同trade_id并发恰入账一次(竞态回归) +wrapper卖出/买入/透传触发+不下单不触发;portfolio 459绿+api 170绿 [vps]
213 lines
9.5 KiB
Python
213 lines
9.5 KiB
Python
"""共享 QMT 账户下的 per-instance 虚拟子账本(实盘/影子组合引擎通用通道)。
|
|
|
|
背景(2026-08-19 三日体检):8 路组合实盘全打同一 miniQMT 账号,LiveEngine 的
|
|
``context.portfolio`` 是券商同步的**全账户**视图(8 路策略+手动持仓并集)——
|
|
channel_test 轮换会卖掉别家持仓、对账 8 对全 FAIL、前端收益率=全账户/初始资金
|
|
毫无意义。策略 session 拍板:卖出范围应限**本实例持仓**,前后端 session 出通道。
|
|
|
|
本模块即该通道:
|
|
- ``LiveInstanceLedger`` 由**本实例的真实成交**(engine.get_trades() 中 order_id
|
|
∈ engine.get_orders() 的部分)驱动的虚拟账本——现金=初始−Σ买−Σ费+Σ卖,
|
|
持仓=成交聚合+移动加权成本,与 ShadowBroker.restore_from_trades 同一套算术。
|
|
- 进程内通道 ``set_active()/get_active()``:runner_live 建好账本后 set,适配层
|
|
``live_strategy._setup`` 读到即注入 facade.get_instance_positions——策略侧
|
|
``getattr(broker, "get_instance_positions", None)`` 消费,回测/无台账时回退
|
|
context.portfolio(策略 session 接入,前后端只出通道)。
|
|
|
|
费用口径:QMT 成交快照常无佣金字段,按 live_strategy 的 OrderCost 估算
|
|
(佣金 max(成交额×0.0003, 5)+卖出印花税 0.001);快照带实际费用则用实际。
|
|
虚拟现金与真实账户费用有细微漂移,仅供实例视图/风控,不做资金对账依据。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import threading
|
|
from typing import Any, Callable, Dict, Iterable, Optional, Tuple
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
COMMISSION_RATE = 0.0003
|
|
MIN_COMMISSION = 5.0
|
|
STAMP_TAX = 0.001
|
|
|
|
|
|
def estimate_fee(is_buy: bool, price: float, volume: int) -> float:
|
|
"""按 live_strategy OrderCost 估算一笔成交的费用。"""
|
|
value = price * volume
|
|
fee = max(value * COMMISSION_RATE, MIN_COMMISSION)
|
|
if not is_buy:
|
|
fee += value * STAMP_TAX
|
|
return fee
|
|
|
|
|
|
class LiveInstanceLedger:
|
|
"""一个 live 实例的虚拟子账本(共享账户的切片视图)。
|
|
|
|
只记本实例自己的成交;别家策略/手动持仓不在账内 → 台账空仓时策略
|
|
不卖任何东西(正是互卖事故要的行为)。
|
|
"""
|
|
|
|
def __init__(self, initial_cash: float = 1_000_000.0):
|
|
self.initial_cash = float(initial_cash)
|
|
self.cash: float = float(initial_cash)
|
|
# symbol -> {"volume": int, "avg_cost": float}
|
|
self.positions: Dict[str, Dict[str, float]] = {}
|
|
# symbol -> (买入日期 str, 当日买入量) —— T+1 可卖视图
|
|
self._today_bought: Dict[str, Tuple[str, int]] = {}
|
|
self._seen_trade_ids: set[str] = set()
|
|
# poller 线程写 / 策略线程(handle_data)读 —— 实盘视图一致性
|
|
self._lock = threading.Lock()
|
|
# 有新成交未落 balance 快照 → 下个快照周期必写(节流档位见 runner_live)
|
|
self.dirty = True
|
|
# B 修法(2026-08-25 卖后买现金窗口):下单返回后即时归因钩子,
|
|
# runner_live 注入 _sync_instance_trades 闭包;未注入(回测/影子/单测)=无操作
|
|
self.on_order_done: Optional[Callable[[], None]] = None
|
|
|
|
# ------------------ 即时归因入口(B修法) ------------------
|
|
def notify_order_done(self) -> None:
|
|
"""下单返回后立刻归因——台账 cash 秒级新鲜,不等 60s 归因轮询。
|
|
|
|
2026-08-25 事故:small_cap 同轮「全卖→马上全买」在两轮轮询间隙读现金,
|
|
19 笔卖出回款不可见 → 20 笔买入全部目标0、全天空仓(momentum 同型缩水
|
|
44% 仓)。钩子把引擎已见的成交即时喂进账本;未注入或抛错均静默——
|
|
漏掉的成交由归因轮询兜底,绝不阻断下单主流程。
|
|
"""
|
|
hook = self.on_order_done
|
|
if hook is None:
|
|
return
|
|
try:
|
|
hook()
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning("[instance-ledger] 即时归因失败,等60s轮询兜底: %s", e)
|
|
|
|
# ------------------ 成交驱动 ------------------
|
|
def apply_trade(
|
|
self,
|
|
is_buy: bool,
|
|
symbol: str,
|
|
price: float,
|
|
volume: int,
|
|
trade_id: str,
|
|
trade_date: str,
|
|
fee: Optional[float] = None,
|
|
) -> bool:
|
|
"""应用一笔本实例成交;trade_id 重复返回 False(幂等)。
|
|
|
|
fee=None 时按费率估算;快照带实际佣金/印花税则传实际值。
|
|
幂等判定整体在锁内:即时归因钩子(策略线程)与归因轮询(poller 线程)
|
|
并发同步同一笔成交时,恰好一笔入账(判定在锁外会双计现金)。
|
|
"""
|
|
with self._lock:
|
|
if not trade_id or trade_id in self._seen_trade_ids:
|
|
return False
|
|
if price <= 0 or volume <= 0:
|
|
logger.warning("[instance-ledger] 非法成交跳过 %s %s x%s@%s",
|
|
trade_id, symbol, volume, price)
|
|
return False
|
|
self._seen_trade_ids.add(trade_id)
|
|
actual_fee = fee if (fee is not None and fee > 0) else \
|
|
estimate_fee(is_buy, price, volume)
|
|
value = price * volume
|
|
if is_buy:
|
|
self.cash -= value + actual_fee
|
|
pos = self.positions.setdefault(
|
|
symbol, {"volume": 0, "avg_cost": 0.0})
|
|
total_cost = pos["avg_cost"] * pos["volume"] + value
|
|
pos["volume"] += volume
|
|
pos["avg_cost"] = total_cost / pos["volume"] if pos["volume"] else 0.0
|
|
date, bought = self._today_bought.get(symbol, ("", 0))
|
|
self._today_bought[symbol] = (
|
|
trade_date, bought + volume if date == trade_date else volume)
|
|
else:
|
|
self.cash += value - actual_fee
|
|
pos = self.positions.get(symbol)
|
|
if pos is None:
|
|
# 账上无此标的的卖出(如 bootstrap 缺口前的旧仓):现金照收,
|
|
# 持仓无账可扣——如实留痕,不崩
|
|
logger.warning(
|
|
"[instance-ledger] 卖出无账面持仓 %s x%s@%s(只入现金)",
|
|
symbol, volume, price)
|
|
else:
|
|
if pos["volume"] < volume:
|
|
logger.warning(
|
|
"[instance-ledger] 卖出超账面 %s: want %s have %s(按账面扣)",
|
|
symbol, volume, int(pos["volume"]))
|
|
volume = int(pos["volume"])
|
|
pos["volume"] -= volume
|
|
if pos["volume"] == 0:
|
|
pos["avg_cost"] = 0.0
|
|
del self.positions[symbol]
|
|
self.dirty = True
|
|
return True
|
|
|
|
def restore_from_trades(self, rows: Iterable[Dict[str, Any]]) -> int:
|
|
"""重启恢复:重放 DB 已归因成交(live_trades 行),返回重放笔数。
|
|
|
|
行格式 = sanguo_live.persistence.list_trades 的返回:
|
|
direction(buy/sell)/symbol/price/volume/traded_at/vt_tradeid。
|
|
"""
|
|
count = 0
|
|
for r in rows:
|
|
applied = self.apply_trade(
|
|
is_buy=str(r.get("direction", "")) == "buy",
|
|
symbol=str(r.get("symbol", "")),
|
|
price=float(r.get("price") or 0),
|
|
volume=int(float(r.get("volume") or 0)),
|
|
trade_id=str(r.get("vt_tradeid") or ""),
|
|
trade_date=str(r.get("traded_at", ""))[:10],
|
|
)
|
|
if applied:
|
|
count += 1
|
|
if count:
|
|
logger.info("[instance-ledger] 重启恢复 %d 笔成交: cash=%.2f 持仓 %d 只",
|
|
count, self.cash, len(self.positions))
|
|
return count
|
|
|
|
# ------------------ 视图 ------------------
|
|
def positions_view(self, now_date: str = "") -> Dict[str, Dict[str, Any]]:
|
|
"""实例持仓视图(引擎快照同构,供策略/落库):
|
|
{symbol: {amount, closeable_amount(T+1), avg_cost}}。
|
|
"""
|
|
view: Dict[str, Dict[str, Any]] = {}
|
|
with self._lock:
|
|
items = list(self.positions.items())
|
|
for sym, pos in items:
|
|
vol = int(pos["volume"])
|
|
if vol <= 0:
|
|
continue
|
|
date, bought = self._today_bought.get(sym, ("", 0))
|
|
locked = bought if date and date == now_date else 0
|
|
view[sym] = {
|
|
"amount": vol,
|
|
"closeable_amount": max(vol - locked, 0),
|
|
"avg_cost": float(pos["avg_cost"]),
|
|
}
|
|
return view
|
|
|
|
def equity(self, prices: Dict[str, float]) -> Tuple[float, float, float]:
|
|
"""(现金, 市值, 总资产)。prices 缺失/<=0 的标的最加权成本兜底。"""
|
|
with self._lock:
|
|
cash = self.cash
|
|
mv = 0.0
|
|
for sym, pos in list(self.positions.items()):
|
|
price = prices.get(sym) or 0.0
|
|
if price <= 0:
|
|
price = float(pos["avg_cost"])
|
|
mv += price * pos["volume"]
|
|
return cash, mv, cash + mv
|
|
|
|
|
|
# ------------------ 进程内通道(runner ↔ 适配层) ------------------
|
|
_ACTIVE: Optional[LiveInstanceLedger] = None
|
|
|
|
|
|
def set_active(ledger: Optional[LiveInstanceLedger]) -> None:
|
|
"""runner_live 装配账本后调用;None 清除(测试隔离)。"""
|
|
global _ACTIVE
|
|
_ACTIVE = ledger
|
|
|
|
|
|
def get_active() -> Optional[LiveInstanceLedger]:
|
|
"""适配层/策略侧取当前实例账本;未装配(回测/单测)返回 None。"""
|
|
return _ACTIVE
|