Files
sanguo_vnpy_v2/sanguo_trader/live_orchestrator.py
T
claude_dev 992f53d4db feat(trader): Phase2—vnpy_qmt 进程内执行客户端,替 HTTP bridge
- qmt_gateway_client.py: QmtGatewayClient(同 BridgeClient 接口 place_order/
  get_account/get_positions/get_orders/cancel_order),底层进程内 QmtGateway 直连
  miniQMT(单例 _QmtExec: EventEngine+QmtGateway+账本缓存,懒连接复用)。
  实证:连真实 miniQMT 读账户(9997081)+持仓(600000/000001),place_order 落单 QMT.xxxxx#1。
- live_orchestrator.py: _make_exec_client 工厂,SANGUO_USE_QMT_GATEWAY=1 → QmtGatewayClient
  (bridge 已退),否则 HTTP bridge 兼容。shadow + reconcile 两处替换。

同机部署后 HTTP 跳无必要,brain→vnpy_qmt→xtquant→miniQMT 零跳直链。
bridge+sanguo-caddy schtasks 可 disable(vnpy_qmt 路径已实证)。
2026-07-15 18:57:20 +08:00

337 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""C-S3 实走编排(task 2):每日 live_step 单根推进。
架构(简化,避开 APScheduler per-account 闭包注入):
- 容器 APScheduler 每日 20:30 调 live_runner.run_live_step(全局 job,遍历 live accounts
- live_step(account_id) 自包含:恢复状态 → 读当日 raw bar → engine.step → 存状态
- 当日 raw 由 Mac launchd 增量推 NASrun_daily_update.sh 加 raw 增量)
状态恢复:cash=最后余额 / positions=paper_positions / pending=paper_pending_orders
TODO(分期项):prev_close 从昨日 raw close 读(首版用当日 open 兜底);
listing_days 从 IPO 日算(首版 stub 0);realized_pnl 未恢复(归因次日重置)。
"""
import json
import logging
import os
import sqlite3
from datetime import datetime, timedelta
from .account import Account
from .cta_adapter import PaperCtaEngine
from .engine import PaperEngine
from .limit import lot_size_for
from .models import AccountConfig, MatchSession, OrderSide, PaperOrder
from .position_ledger import PositionLedger
from .persistence import (
load_last_balance, load_positions, save_positions,
save_daily_balance,
load_pending_orders, save_pending_orders,
save_shadow_order, is_trade_shadowed,
)
from .strategy_runner import StrategyRunner
logger = logging.getLogger(__name__)
def _make_exec_client(cfg):
"""构造执行客户端(Phase 2)。
SANGUO_USE_QMT_GATEWAY=1 → 进程内 vnpy_qmt 直连 miniQMTbridge 已退);
否则 → HTTP bridge(需 bridge_url + token,缺则 warning 返回 None)。
返回 None 表示跳过本次影子/reconcile(不阻断 live_step)。
"""
if os.environ.get("SANGUO_USE_QMT_GATEWAY", "").strip().lower() in ("1", "true", "yes", "on"):
from .qmt_gateway_client import QmtGatewayClient
return QmtGatewayClient()
live_cfg = getattr(cfg, "live", None) or {}
token = live_cfg.get("bridge_token") or os.environ.get("BRIDGE_TOKEN")
url = live_cfg.get("bridge_url")
if not token or not url:
logger.warning("影子下单:bridge 模式但 bridge_url/token 未配(或设 SANGUO_USE_QMT_GATEWAY=1 走 vnpy_qmt),跳过")
return None
from .bridge_client import BridgeClient
return BridgeClient(url, token)
def _get_account(db_path: str, account_id: int) -> dict:
with sqlite3.connect(db_path) as conn:
conn.row_factory = sqlite3.Row
return dict(conn.execute(
"SELECT * FROM paper_accounts WHERE id=?", (account_id,)
).fetchone())
def _restore_ledger(positions: dict) -> dict:
"""{symbol:{volume,frozen,avg_price}} → {symbol: PositionLedger}。"""
return {sym: PositionLedger(sym, p["volume"], p.get("frozen", 0), p["avg_price"])
for sym, p in positions.items()}
def live_step(db_path: str, account_id: int, data_source, cfg, today: str | None = None) -> None:
"""实走单日 stepscheduler 每日调)。data_source=_DataSourceWrapper, cfg=data 配置。
today: 默认 datetime.now();验证可指定历史日期(有 raw)。
"""
from sanguo_data.datareader import guess_exchange
from sanguo_api.strategy_registry import get_strategy_class
from vnpy.trader.utility import ArrayManager
acc = _get_account(db_path, account_id)
symbols = json.loads(acc["symbols"] or "[]")
strategies = json.loads(acc["strategies"] or "[]")
interval = acc.get("interval") or "d"
initial = acc["initial_capital"]
# 1. 恢复 accountcash + positions
account = Account(initial)
last_bal = load_last_balance(db_path, account_id)
if last_bal:
account.cash = last_bal["cash"]
account.positions = _restore_ledger(load_positions(db_path, account_id, "account"))
# 2. 构造 runners(恢复 positions
runners: list = []
for s in strategies:
cls = get_strategy_class(s["name"])
if cls is None:
continue
cta = PaperCtaEngine(s["name"], match_session=s.get("match_session", "next_open"),
listing_days=s.get("listing_days", 0),
size=lot_size_for(s["symbol"]))
vt = f"{s['symbol']}.{guess_exchange(s['symbol']).value}"
strat = cls(cta, s["name"], vt, s.get("params", {}))
strat.trading = True
if not hasattr(strat, "am"):
strat.am = ArrayManager(20)
cta.set_strategy(strat)
max_a = s.get("max_allocation")
runner = StrategyRunner(s["name"], strategy=strat, paper_cta_engine=cta,
symbol=s["symbol"],
max_allocation=max_a if max_a is not None else initial)
runner.positions = _restore_ledger(
load_positions(db_path, account_id, f"strategy:{s['name']}"))
runners.append(runner)
if not runners:
logger.warning("live_step %s: 无可用策略(容器缺 vnpy_ctastrategy?),跳过", account_id)
return
# 3. 日期 + warmup(重放 start~昨日到策略 am 使其 inited;丢弃 warmup 单)
# 实走每日单根 barArrayManager 需 warmup 才 inited,否则策略无信号。
today = today or datetime.now().strftime("%Y-%m-%d")
yesterday = (datetime.strptime(today, "%Y-%m-%d") - timedelta(days=1)).strftime("%Y-%m-%d")
start_date = acc.get("start_date") or today
if start_date < yesterday:
for _wd, wbars in data_source.iter_bars(
symbols, start_date, yesterday, interval, adjust="qfq", cfg=cfg
):
for runner in runners:
if runner.symbol in wbars:
runner.paper_cta_engine.on_bar(wbars[runner.symbol])
runner.paper_cta_engine.pop_orders() # warmup 单丢弃(不撮合)
# 4. 当日 raw bar(撮合)+ qfq bar(策略 on_bar 信号)
bars = {}
qfq_bars = {}
for sym in symbols:
rbar = data_source.fetch_day(sym, today, interval, adjust="raw", cfg=cfg)
if rbar is not None:
bars[sym] = rbar
qbar = data_source.fetch_day(sym, today, interval, adjust="qfq", cfg=cfg)
if qbar is not None:
qfq_bars[sym] = qbar
if not bars:
logger.info("live_step %s: 当日无 raw bar%s 非交易日或未推?),跳过", account_id, today)
return
# 5. 恢复 pending + prev_close
pending = []
for o in load_pending_orders(db_path, account_id):
runner = next((r for r in runners if r.strategy_id == o["strategy_id"]), None)
if runner is None:
continue
pending.append((PaperOrder(
o["strategy_id"], o["symbol"], OrderSide(o["side"]), o["price"], o["volume"],
o["is_market"], MatchSession(o["match_session"]), o["listing_days"],
), runner))
# prev_close:昨日 raw closefetch_day 昨日);失败兜底用当日 open
prev_close = {}
for sym in symbols:
ybar = data_source.fetch_day(sym, yesterday, interval, adjust="raw", cfg=cfg)
prev_close[sym] = ybar.close_price if ybar else bars[sym].open_price
# 5. engine.step(单根当日)
acc_cfg = AccountConfig(
initial_capital=initial, rate=acc["rate"], slippage=acc["slippage"],
pricetick=acc["pricetick"], stamp_duty_rate=acc["stamp_duty_rate"],
transfer_fee_rate=acc["transfer_fee_rate"], min_commission=acc["min_commission"],
)
pe = PaperEngine(account, runners, data_source, acc_cfg, db_path, account_id,
symbols, acc.get("start_date") or today, today, interval,
risk_free_rate=getattr(cfg, "risk_free_rate", 0.0))
pending_new, _closes = pe.step(today, bars, qfq_bars, prev_close, pending)
# 6. 存状态(pending + positions
save_pending_orders(db_path, account_id, [
{"strategy_id": o.strategy_id, "symbol": o.symbol, "side": o.side.value,
"price": o.price, "volume": o.volume, "is_market": o.is_market,
"match_session": o.match_session.value, "listing_days": o.listing_days}
for o, _r in pending_new
])
save_positions(db_path, account_id, "account", {
sym: {"volume": p.volume, "frozen": p.frozen, "avg_price": p.avg_price}
for sym, p in account.positions.items()}, today)
for r in runners:
save_positions(db_path, account_id, f"strategy:{r.strategy_id}", {
sym: {"volume": p.volume, "frozen": p.frozen, "avg_price": p.avg_price}
for sym, p in r.positions.items()}, today)
logger.info("live_step %s 完成 @%spending=%d positions=%d",
account_id, today, len(pending_new), len(account.positions))
# 7. 影子下单(D-3,spec §5 模式 A):当日成交 POST bridge,默认关闭
# enabled=false 时 _shadow_trades_to_bridge 立即 returnlive_step 行为完全不变
_shadow_trades_to_bridge(db_path, account_id, today, cfg)
# 8. 模式 B reconcileD-4cspec §5.3 模式 B):bridge 回报校正 account 账本
# mode_b=false 时 reconcile_from_bridge 立即 returnlive_step 行为不变
reconcile_from_bridge(db_path, account_id, today, account, cfg)
def _shadow_trades_to_bridge(db_path: str, account_id: int, today: str, cfg) -> None:
"""当日成交影子下单到 bridge(D-3,spec §5 模式 A)。
PaperEngine 模拟撮合为准,当日成交信号同步 POST 到 bridge 影子下单到 miniQMT。
默认关闭(cfg.live.enabled=false);任何失败仅记日志,不阻断 live_step。
幂等:paper_shadow_orders UNIQUE(account_id, trade_id) 保证 scheduler 重跑不重复下单。
"""
try:
live_cfg = getattr(cfg, "live", None) or {}
if not live_cfg.get("enabled"):
return
from .bridge_client import to_bridge_code
client = _make_exec_client(cfg)
if client is None:
return
with sqlite3.connect(db_path) as conn:
conn.row_factory = sqlite3.Row
cur = conn.execute(
"SELECT id, strategy_id, symbol, direction, price, volume "
"FROM paper_trades WHERE account_id=? AND bar_date=? AND rejected=0",
(account_id, today),
)
trades = [dict(r) for r in cur.fetchall()]
shadowed = 0
for t in trades:
if is_trade_shadowed(db_path, account_id, t["id"]):
continue
code = to_bridge_code(t["symbol"])
resp = client.place_order(
code, t["direction"], t["price"], t["volume"],
reason=f"shadow:strategy:{t['strategy_id']}",
)
ok = bool(resp and resp.get("ok"))
status = "ok" if ok else "failed"
bridge_order_id = resp.get("order_id") if resp else None
save_shadow_order(db_path, account_id, t["id"], bridge_order_id, status)
if ok:
shadowed += 1
else:
logger.warning("live_step %s: 影子下单失败 trade=%s code=%s err=%s",
account_id, t["id"], code,
resp.get("error") if resp else "no_response")
if trades:
logger.info("live_step %s 影子下单: %d/%d ok", account_id, shadowed, len(trades))
except Exception as e: # noqa: BLE001 影子下单绝不阻断 live_step
logger.warning("live_step %s: 影子下单异常(不阻断): %s", account_id, e)
def reconcile_from_bridge(db_path: str, account_id: int, today: str,
account: Account, cfg) -> None:
"""模式 B: bridge 真实回报校正 account 账本(spec §5.2/§5.3 模式 B)。
bridge /account + /positions 为准,覆盖 account.cash/market_value/positions
纠模拟撮合漂移(实盘成交价/分红/拆股等导致的账本偏差)。
默认关闭(cfg.live.mode_b != True);任何失败仅记日志,不阻断 live_step。
bridge 失败 → warning return,降级用模拟账本(live_step step 6 已存的 simulation 状态)。
"""
try:
live_cfg = getattr(cfg, "live", None) or {}
if not live_cfg.get("enabled"):
return
if not live_cfg.get("mode_b"):
return
from .bridge_client import from_bridge_code
client = _make_exec_client(cfg)
if client is None:
return
# 1. 校正资金(bridge /account 为准)
acc_resp = client.get_account()
if acc_resp is None:
logger.warning("reconcile %s: get_account 失败,降级模拟账本", account_id)
return
account.cash = float(acc_resp.get("cash", account.cash))
account.market_value = float(acc_resp.get("market_value", account.market_value))
total = acc_resp.get("total")
if total is None:
total = account.equity
# 2. 重建持仓(bridge /positions 为准;bridge code → 纯数字 key
pos_resp = client.get_positions()
if pos_resp is None:
logger.warning("reconcile %s: get_positions 失败,降级模拟账本", account_id)
return
new_positions: dict[str, PositionLedger] = {}
for p in pos_resp:
code = from_bridge_code(p.get("code", ""))
vol = int(p.get("volume", 0))
if vol <= 0:
continue
can_use = int(p.get("can_use", vol))
new_positions[code] = PositionLedger(
code, volume=vol,
frozen=max(vol - can_use, 0),
avg_price=float(p.get("avg_price", 0.0)),
)
account.positions = new_positions
# 3. 持久化校正后账本(account scope + daily balance
save_positions(db_path, account_id, "account", {
sym: {"volume": p.volume, "frozen": p.frozen, "avg_price": p.avg_price}
for sym, p in account.positions.items()}, today)
save_daily_balance(db_path, account_id, today,
account.cash, account.market_value, total)
logger.info("reconcile %s @%s 完成: cash=%.2f mv=%.2f positions=%d",
account_id, today, account.cash, account.market_value,
len(account.positions))
except Exception as e: # noqa: BLE001 reconcile 绝不阻断 live_step
logger.warning("reconcile %s: 异常(不阻断): %s", account_id, e)
def list_live_accounts(db_path: str) -> list[int]:
"""所有 mode=live & status=running 的 account_idlive_runner 遍历用)。"""
with sqlite3.connect(db_path) as conn:
rows = conn.execute(
"SELECT id FROM paper_accounts WHERE mode='live' AND status='running'"
).fetchall()
return [r[0] for r in rows]
def run_live_step(db_path: str) -> None:
"""每日全局 step:遍历所有 live accounts 调 live_stepscheduler 20:30 调)。
lazy import _DataSourceWrapper 避免与 routes_paper 循环 import。
"""
from sanguo_data.config import find_config_path, load_config
from sanguo_api.routes_paper import _DataSourceWrapper
cfg = load_config(find_config_path())
data_source = _DataSourceWrapper(cfg)
for aid in list_live_accounts(db_path):
try:
live_step(db_path, aid, data_source, cfg)
except Exception as e: # noqa: BLE001
logger.error("live_step account %s 失败: %s", aid, e)