Files
sanguo_vnpy_v2/sanguo_trader/live_orchestrator.py
T
claude_dev ff84b3d4b0 feat(live): D-3 sanguo实盘分支(影子下单)+D期设计文档
D-3 模式A影子下单(spec §5):
- bridge_client.py: QMT bridge HTTP客户端(urllib, X-Bridge-Token, 失败不抛返回None)
- live_orchestrator: _shadow_trades_to_bridge 当日成交POST bridge(默认enabled=false)
- persistence: paper_shadow_orders幂等表+save_shadow_order/is_trade_shadowed
- config: data_platform.yaml加live段, token走env(BRIDGE_TOKEN)
- to_bridge_code symbol转换与guess_exchange一致(2位前缀)
安全: enabled=false默认关+token走env+幂等防重复+影子失败不阻断live_step

docs: phase3d-live-trading-design.md(D期完整设计)
2026-07-11 00:03:46 +08:00

255 lines
11 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,
load_pending_orders, save_pending_orders,
save_shadow_order, is_trade_shadowed,
)
from .strategy_runner import StrategyRunner
logger = logging.getLogger(__name__)
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)
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
token = os.environ.get("BRIDGE_TOKEN")
if not token:
logger.warning("live_step %s: 影子下单启用但 BRIDGE_TOKEN 未设,跳过", account_id)
return
url = live_cfg.get("bridge_url")
if not url:
logger.warning("live_step %s: 影子下单启用但 bridge_url 未配,跳过", account_id)
return
from .bridge_client import BridgeClient, to_bridge_code
client = BridgeClient(url, token)
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 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)