Files
sanguo_vnpy_v2/sanguo_portfolio/live_instance_ledger.py
T
claude_dev dae56e29aa
CI/CD / test (push) Successful in 11s
CI/CD / nas-deploy (push) Successful in 34s
CI/CD / nas-verify (push) Successful in 11s
fix(live): instance虚拟账本+成交归因+实盘日志黑洞——共享QMT账户三害根治第一步 [vps]
2026-08-19盘后QMT实锤:①互卖当日真实发生(518880两个实例各卖183700、
600036两实例各卖42900)②买入sizing被全账户污染(channel_test实盘买
183700股@9.12≈168万=全账户995万/6,影子才16.8万=100万/6)③QMT委托带
remark=bt:live_strateg:<hash>实例指纹(归因可交叉验证)。

①live_instance_ledger(新模块):每live实例一份虚拟子账本(现金=初始−Σ买
−Σ费+Σ卖,持仓+移动加权成本+T+1当日买入锁定,线程安全锁),由**本实例
真实成交**驱动——engine.get_trades()按order_id∈engine.get_orders()归因
(引擎_broker_order_index已映射回本实例id空间),别家实例/手动单不进账;
卖超账面/无账面卖出如实留痕不崩;restore_from_trades重启恢复。
②runner_live:_sync_trades旧轮询(写不进live_trades的坏件)替换为归因
落库(方向取自订单is_buy);快照落库从context.portfolio全账户改**实例视图**
(positions=账本持仓T+1冻结;balance=虚拟现金+市值,现价取全账户快照/退
加权成本)——治8实例同写一份全账户持仓+收益率=全账户/初始资金无意义;
balance节流:有成交立即写否则≥5分钟(治1440行/天/实例量偏大遗留)。
③通道注入:BrokerFacade.get_instance_positions字段+live_strategy._setup
读get_active()注入positions_view(策略session消费,getattr兜底回退
context.portfolio;回测/单测无账本=保持None)。
④实盘引擎日志黑洞根治:supervisor子进程stdout/stderr落logs/live_{aid}.log
(>5MB截断+spawn标记,对齐影子#88同款修法;8-17起实盘委托/成交零留存)。
+15测试(算术/归因过滤/幂等/快照实例视图/通道注入/日志重定向);
portfolio+live+shadow 400绿。
2026-08-19 18:39:13 +08:00

191 lines
8.2 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, 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
# ------------------ 成交驱动 ------------------
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 时按费率估算;快照带实际佣金/印花税则传实际值。
"""
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
with self._lock:
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