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]
290 lines
12 KiB
Python
290 lines
12 KiB
Python
"""组合策略实盘入口(VPS Windows / miniQMT 直连)——bullet_trade 0.9.2 LiveEngine。
|
|
|
|
supervisor(``sanguo_live.runner.run_supervisor``) 对 strategy_type='portfolio' 的
|
|
live_accounts 行以**子进程**方式拉起本模块,env 传参:
|
|
|
|
SANGUO_QMT_ACCOUNT / SANGUO_QMT_PATH miniQMT 交易账号 / userdata_mini 路径
|
|
SANGUO_LIVE_STRATEGY / _MAX_POOL / _BENCHMARK 组合策略配置
|
|
SANGUO_LIVE_CASH 初始资金(engine NAV 基准)
|
|
SANGUO_LIVE_DB / SANGUO_LIVE_ACCOUNT_ID 快照落库目标(缺省不落)
|
|
|
|
手动用法(交易日 + miniQMT 连接下):
|
|
set SANGUO_QMT_ACCOUNT=66639661
|
|
python -m sanguo_portfolio.runner_live
|
|
|
|
不在 Mac 跑(Mac 无 xtquant/miniQMT 客户端)。
|
|
|
|
历史注记:0.2 之前的 bullet_trade LiveEngine 接受 ``initialize=/broker=`` 直传,
|
|
0.9.x 改为 strategy_file + broker_factory——本模块即按新 API 装配,策略逻辑在
|
|
``sanguo_portfolio/live_strategy.py``(适配文件)。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
# ENV GUARD 必须早于任何 bullet_trade import
|
|
import os
|
|
os.environ.setdefault("DEFAULT_DATA_PROVIDER", "miniqmt")
|
|
|
|
import logging
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any, Dict
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
ADAPTER_FILE = Path(__file__).resolve().parent / "live_strategy.py"
|
|
|
|
|
|
def build_provider(provider_config: Dict[str, Any] | None = None) -> Any:
|
|
"""构造 live 模式的 SanguoMiniQmtProvider。"""
|
|
from .providers import SanguoMiniQmtProvider
|
|
|
|
cfg = dict(provider_config or {})
|
|
cfg.setdefault("mode", "live")
|
|
cfg.setdefault("auto_download", True)
|
|
return SanguoMiniQmtProvider(cfg)
|
|
|
|
|
|
def live_env() -> Dict[str, str]:
|
|
"""解析 env 实盘配置(带默认值)。独立出来便于单测。"""
|
|
return {
|
|
"strategy": os.environ.get("SANGUO_LIVE_STRATEGY", "all_weather"),
|
|
"max_pool": os.environ.get("SANGUO_LIVE_MAX_POOL", "0"),
|
|
"benchmark": os.environ.get("SANGUO_LIVE_BENCHMARK", "000300.XSHG"),
|
|
"cash": os.environ.get("SANGUO_LIVE_CASH", "1000000"),
|
|
"account": os.environ.get("SANGUO_QMT_ACCOUNT", ""),
|
|
"mini_path": (os.environ.get("SANGUO_QMT_PATH")
|
|
or r"C:\国金QMT交易端模拟\userdata_mini"),
|
|
"db": os.environ.get("SANGUO_LIVE_DB", ""),
|
|
"account_id": os.environ.get("SANGUO_LIVE_ACCOUNT_ID", ""),
|
|
}
|
|
|
|
|
|
def _effective_trade_time(trade: Any) -> Any:
|
|
"""成交时间守卫(2026-08-20 事故):QMT 原始成交时间经引擎 pd.to_datetime 的
|
|
失败形态会落成 1970-01-01 00:00:01 的 datetime——当日 9 笔 traded_at=1970
|
|
落库,前端"今日成交"按日期过滤全空 + 账本 trade_date 失真(T+1 视图错)。
|
|
年份<2000 一律视为无效,回退当前时刻:归因轮询间隔 ≤60s,日期误差只剩
|
|
跨日 60s 窗口,可忽略。"""
|
|
from datetime import datetime
|
|
|
|
v = getattr(trade, "time", None)
|
|
if isinstance(v, datetime) and v.year >= 2000:
|
|
return v
|
|
return datetime.now()
|
|
|
|
|
|
def _sync_instance_trades(
|
|
engine: Any, ledger: Any, db: str, account_id: int, strategy_name: str,
|
|
) -> None:
|
|
"""归因成交:engine.get_trades() 只留 order_id ∈ engine.get_orders() 的部分。
|
|
|
|
共享 QMT 账户下 broker 成交是全账户的(8 路策略+手动);引擎 _orders 只登记
|
|
本进程提交的订单,且 Trade.order_id 已被引擎映射回本实例 id 空间
|
|
(_broker_order_index)——「本实例订单」的判定天然成立。归因后的成交:
|
|
①驱动实例虚拟账本 ②落 live_trades(account_id=本实例,方向取自订单)。
|
|
跨日:QMT 只查当日成交,历史靠 DB 已存行(重启时 restore_from_trades 重放)。
|
|
"""
|
|
from sanguo_live.persistence import save_trade
|
|
|
|
try:
|
|
orders = engine.get_orders() or {}
|
|
own_buy: Dict[str, bool] = {
|
|
str(oid): bool(getattr(o, "is_buy", True))
|
|
for oid, o in orders.items()
|
|
}
|
|
trades = engine.get_trades() or {}
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning("[live-trades] 查订单/成交失败 (account=%s): %s", account_id, e)
|
|
return
|
|
for tid, t in trades.items():
|
|
oid = str(getattr(t, "order_id", ""))
|
|
if oid not in own_buy:
|
|
continue # 别家实例/手动单,不归因给本实例
|
|
is_buy = own_buy[oid]
|
|
t_time = _effective_trade_time(t)
|
|
date_str = t_time.strftime("%Y-%m-%d")
|
|
applied = ledger.apply_trade(
|
|
is_buy=is_buy,
|
|
symbol=str(getattr(t, "security", "")),
|
|
price=float(getattr(t, "price", 0) or 0),
|
|
volume=int(getattr(t, "amount", 0) or 0),
|
|
trade_id=str(tid),
|
|
trade_date=date_str[:10],
|
|
fee=(float(getattr(t, "commission", 0) or 0)
|
|
+ float(getattr(t, "tax", 0) or 0)) or None,
|
|
)
|
|
if not applied:
|
|
continue
|
|
save_trade(db, account_id, {
|
|
"strategy_name": strategy_name,
|
|
"symbol": str(getattr(t, "security", "")),
|
|
"direction": "buy" if is_buy else "sell",
|
|
"offset": "open" if is_buy else "close",
|
|
"price": float(getattr(t, "price", 0) or 0),
|
|
"volume": int(getattr(t, "amount", 0) or 0),
|
|
"traded_at": (t_time.strftime("%Y-%m-%d %H:%M:%S")
|
|
if hasattr(t_time, "strftime") else str(t_time or "")),
|
|
"vt_tradeid": str(tid),
|
|
})
|
|
logger.info("[live-trades] 本实例成交落库 (account=%s %s %s x%s@%s)",
|
|
account_id, "买入" if is_buy else "卖出",
|
|
getattr(t, "security", ""), getattr(t, "amount", 0),
|
|
getattr(t, "price", 0))
|
|
|
|
|
|
def _snapshot_once(engine: Any, db: str, account_id: int, ledger: Any) -> None:
|
|
"""单次快照:**实例虚拟账本** → live_positions/live_balance。
|
|
|
|
2026-08-19 前落的是 context.portfolio(全账户)——8 实例同写一份全账户持仓、
|
|
收益率=全账户/初始资金(共享 QMT 账号下毫无意义)。改落实例视图:
|
|
- positions = 账本持仓(T+1 冻结=当日买入);
|
|
- balance = 账本现金 + 持仓市值(价格取全账户快照的现价,取不到用加权成本)。
|
|
全账户真实数字由 QMT 客户端随时可查,不再经本表透传。
|
|
"""
|
|
from datetime import datetime
|
|
|
|
from sanguo_live.persistence import save_balance, save_positions
|
|
|
|
now_date = datetime.now().strftime("%Y-%m-%d")
|
|
view = ledger.positions_view(now_date)
|
|
positions: Dict[str, Dict[str, Any]] = {
|
|
sym: {
|
|
"volume": float(p["amount"]),
|
|
"frozen": float(p["amount"] - p["closeable_amount"]),
|
|
"avg_price": float(p["avg_cost"]),
|
|
}
|
|
for sym, p in view.items()
|
|
}
|
|
save_positions(db, account_id, positions)
|
|
|
|
# 现价:全账户快照里有(本实例持仓必是其子集);取不到退加权成本
|
|
prices: Dict[str, float] = {}
|
|
for sym, pos in (getattr(engine.context.portfolio, "positions", None)
|
|
or {}).items():
|
|
price = float(getattr(pos, "price", 0) or 0) \
|
|
or float(getattr(pos, "current_price", 0) or 0)
|
|
if price > 0:
|
|
prices[str(sym)] = price
|
|
cash, mv, total = ledger.equity(prices)
|
|
save_balance(
|
|
db, account_id, datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
cash, market_value=mv, total=total,
|
|
)
|
|
|
|
|
|
def _snapshot_loop(engine: Any, db: str, account_id: int, ledger: Any,
|
|
interval_sec: float = 60.0,
|
|
snap_min_interval: float = 300.0) -> None:
|
|
"""后台线程:归因成交→实例账本→快照落库(供 API 读)。
|
|
|
|
归因轮询每 60s;快照(positions+balance)只在**有新成交或 ≥5 分钟**时写——
|
|
旧版 60s 无脑写 balance(1440 行/天/实例,2026-08-19 上午 8 账户 4848 行)
|
|
量偏大遗留一并治。任何异常只 warning 不中断(engine 主循环不受影响)。
|
|
"""
|
|
strategy_name = os.environ.get("SANGUO_LIVE_STRATEGY", "")
|
|
last_snap = 0.0
|
|
while True:
|
|
time.sleep(interval_sec)
|
|
try:
|
|
_sync_instance_trades(engine, ledger, db, account_id, strategy_name)
|
|
now = time.time()
|
|
if ledger.dirty or now - last_snap >= snap_min_interval:
|
|
_snapshot_once(engine, db, account_id, ledger)
|
|
ledger.dirty = False
|
|
last_snap = now
|
|
except Exception as e: # noqa: BLE001
|
|
logger.warning("[live-snapshot] 落库失败 (account=%s): %s", account_id, e)
|
|
|
|
|
|
def _instance_adapter(account_id: str) -> Path:
|
|
"""按账户复制一份策略适配文件。
|
|
|
|
bullet_trade 实例锁判重键=主机+strategy_path+broker_type+account_identity;
|
|
多实盘共用同一 QMT 账号(合法场景:同账号跑多策略)时 strategy_path 相同会被
|
|
误判"重复实例"拒启 → 每账户一份副本(内容同、路径异)即视为不同逻辑实例。
|
|
"""
|
|
if not account_id:
|
|
return ADAPTER_FILE
|
|
dst = (Path(__file__).resolve().parent.parent / "runtime"
|
|
/ f"live_{account_id}" / ADAPTER_FILE.name)
|
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
if not dst.exists() or dst.read_text(encoding="utf-8") != \
|
|
ADAPTER_FILE.read_text(encoding="utf-8"):
|
|
import shutil
|
|
shutil.copyfile(ADAPTER_FILE, dst)
|
|
return dst
|
|
|
|
|
|
def run_live(provider_config: Dict[str, Any] | None = None) -> None:
|
|
"""装配 LiveEngine(strategy_file=适配文件 + QmtBroker)并 run(阻塞)。"""
|
|
from bullet_trade.core.live_engine import LiveEngine # type: ignore
|
|
from bullet_trade.data.api import set_data_provider # type: ignore
|
|
from bullet_trade.broker.qmt import QmtBroker # type: ignore
|
|
|
|
cfg = live_env()
|
|
if not cfg["account"]:
|
|
raise RuntimeError(
|
|
"缺 SANGUO_QMT_ACCOUNT(miniQMT 交易账号),实盘无法启动。"
|
|
"设 set SANGUO_QMT_ACCOUNT=66639661 后重试。"
|
|
)
|
|
|
|
provider = build_provider(provider_config)
|
|
set_data_provider(provider)
|
|
|
|
broker = QmtBroker(account_id=cfg["account"], data_path=cfg["mini_path"])
|
|
logger.info("QmtBroker 装配 account=%s data_path=%s", cfg["account"], cfg["mini_path"])
|
|
|
|
# 实例虚拟账本(共享 QMT 账户的切片视图,2026-08-19 互卖/对账/收益率三问题同根):
|
|
# 先建+恢复再起 engine——适配层 _setup 经 get_active() 注入 facade 通道
|
|
from .live_instance_ledger import LiveInstanceLedger, set_active
|
|
ledger = LiveInstanceLedger(initial_cash=float(cfg["cash"] or 1_000_000))
|
|
if cfg["db"] and cfg["account_id"]:
|
|
try:
|
|
from sanguo_live.persistence import list_trades
|
|
n = ledger.restore_from_trades(
|
|
list_trades(cfg["db"], int(cfg["account_id"])))
|
|
logger.info("[instance-ledger] 恢复 %d 笔: cash=%.2f 持仓 %d 只",
|
|
n, ledger.cash, len(ledger.positions))
|
|
except Exception as e: # noqa: BLE001 - 无库/表未建不阻断启动(空账本起跑)
|
|
logger.warning("[instance-ledger] 恢复失败,空账本起跑: %s", e)
|
|
set_active(ledger)
|
|
|
|
engine = LiveEngine(
|
|
_instance_adapter(cfg["account_id"]),
|
|
broker_factory=lambda: broker,
|
|
# bullet_trade 每实例锁 runtime 目录(单实例设计);多实盘并行须各用独立目录
|
|
live_config={"runtime_dir": str(
|
|
Path(__file__).resolve().parent.parent / "runtime"
|
|
/ f"live_{cfg['account_id'] or 'solo'}")},
|
|
)
|
|
logger.info(
|
|
"组合 live engine 启动: strategy=%s max_pool=%s benchmark=%s cash=%s",
|
|
cfg["strategy"], cfg["max_pool"], cfg["benchmark"], cfg["cash"],
|
|
)
|
|
|
|
# 快照落库(supervisor 注入 db+account_id 时才开)
|
|
if cfg["db"] and cfg["account_id"]:
|
|
# B 修法(2026-08-25 卖后买现金窗口):下单返回后即时归因——台账 cash
|
|
# 秒级新鲜,同轮「全卖→马上全买」的调仓立刻见到卖出回款;60s 归因
|
|
# 轮询(_snapshot_loop)保留兜底(部分成交后续/异步路径)。
|
|
ledger.on_order_done = lambda: _sync_instance_trades(
|
|
engine, ledger, cfg["db"], int(cfg["account_id"]), cfg["strategy"])
|
|
t = threading.Thread(
|
|
target=_snapshot_loop,
|
|
args=(engine, cfg["db"], int(cfg["account_id"]), ledger),
|
|
daemon=True, name="live-snapshot",
|
|
)
|
|
t.start()
|
|
|
|
engine.run()
|
|
|
|
|
|
def main() -> None:
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
|
run_live()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|