"""共享 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