e77c9df0d4
- bridge_client: from_bridge_code(sh/sz→纯数字码, to_bridge_code逆函数) - live_orchestrator: reconcile_from_bridge 读bridge /account /positions校正account现金+持仓+持久化, 默认mode_b=false - live_step step8: 影子后调reconcile(mode_b=true生效, mode_b=false跳过) - config: live.mode_b开关(默认false模式A) - test_reconcile: 10例(cash/positions校正+code转换+失败降级+mode_b跳过) - NAS环境15 passed(reconcile10+shadow5无回归) - 真桥集成: live_step mode_b=true → reconcile读bridge → account校正(1000万/空仓=bridge真实账本)+持久化 安全: mode_b默认关+bridge失败降级不阻断+token走env
331 lines
15 KiB
Python
331 lines
15 KiB
Python
"""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 增量推 NAS(run_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 _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:
|
||
"""实走单日 step(scheduler 每日调)。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. 恢复 account(cash + 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 单)
|
||
# 实走每日单根 bar,ArrayManager 需 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 close(fetch_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 完成 @%s,pending=%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 立即 return,live_step 行为完全不变
|
||
_shadow_trades_to_bridge(db_path, account_id, today, cfg)
|
||
|
||
# 8. 模式 B reconcile(D-4c,spec §5.3 模式 B):bridge 回报校正 account 账本
|
||
# mode_b=false 时 reconcile_from_bridge 立即 return,live_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
|
||
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 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
|
||
token = os.environ.get("BRIDGE_TOKEN")
|
||
if not token:
|
||
logger.warning("reconcile %s: mode_b 启用但 BRIDGE_TOKEN 未设,跳过", account_id)
|
||
return
|
||
url = live_cfg.get("bridge_url")
|
||
if not url:
|
||
logger.warning("reconcile %s: mode_b 启用但 bridge_url 未配,跳过", account_id)
|
||
return
|
||
|
||
from .bridge_client import BridgeClient, from_bridge_code
|
||
|
||
client = BridgeClient(url, token)
|
||
|
||
# 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_id(live_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_step(scheduler 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)
|