feat(trader): C-S3实走后端骨架—live_orchestrator+全局scheduler job+routes live

架构(简化,避per-account闭包注入):
- live_orchestrator live_step(account_id)自包含: 恢复cash/positions/pending→fetch_day raw当日→engine.step→存状态
- run_live_step(db)遍历live accounts调live_step; scheduler register_live_step_job全局20:30 job
- app startup注册全局job; routes create mode=live存account running(不跑回放)
- TODO(分期项): prev_close昨日raw/listing_days IPO算/realized_pnl恢复
- 113/113通过, live_orchestrator import OK
This commit is contained in:
2026-07-08 06:49:31 +08:00
parent 20bdd689af
commit 6931a7b541
4 changed files with 202 additions and 1 deletions
+10
View File
@@ -37,4 +37,14 @@ def create_app(db_path: str, file_dir=None, auth_config=None, max_workers: int =
app.include_router(paper_router, prefix="/api/v1")
set_db_path(db_path)
@app.on_event("startup")
def _register_live_step():
"""容器启动:注册每日 20:30 全局实走 step jobC-S3,遍历 live accounts)。"""
try:
from sanguo_trader.scheduler import register_live_step_job
register_live_step_job(db_path)
except Exception as e: # noqa: BLE001 本机无 apscheduler 时跳过
import logging
logging.getLogger(__name__).warning("register_live_step_job 失败: %s", e)
return app
+4
View File
@@ -72,6 +72,10 @@ def create_paper(req: PaperCreateRequest):
update_account_status(db, aid, "failed", str(e))
threading.Thread(target=_bg, daemon=True).start()
status = "running"
elif req.mode == "live": # 实走:全局 job 每日 20:30 遍历 step(不跑回放)
from sanguo_trader.persistence import update_account_status
update_account_status(db, aid, "running")
status = "running"
return {"account_id": aid, "status": status}
+169
View File
@@ -0,0 +1,169 @@
"""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 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,
)
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) -> None:
"""实走单日 stepscheduler 每日调)。data_source=_DataSourceWrapper, cfg=data 配置。"""
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)
runner = StrategyRunner(s["name"], strategy=strat, paper_cta_engine=cta,
symbol=s["symbol"])
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. 当日 raw barMac launchd 已推 NAS
today = datetime.now().strftime("%Y-%m-%d")
bars = {}
for sym in symbols:
bar = data_source.fetch_day(sym, today, interval, adjust="raw", cfg=cfg)
if bar is not None:
bars[sym] = bar
if not bars:
logger.info("live_step %s: 当日无 raw bar%s 非交易日或未推?),跳过", account_id, today)
return
# 4. 恢复 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
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
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, adjust="raw")
pending_new, _closes = pe.step(today, 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))
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)
+19 -1
View File
@@ -54,7 +54,10 @@ def _default_step(account_id: int) -> None:
def restore_live_jobs(db_path: str, job_func=None) -> int:
"""容器启动:遍历 status=running & mode=live 重建 jobspec §9.2 H-3)。"""
"""容器启动:遍历 status=running & mode=live 重建 jobspec §9.2 H-3)。
简化架构:实走用全局 jobregister_live_step_job),此函数保留兼容老 per-account 设计。
"""
with sqlite3.connect(db_path) as conn:
rows = conn.execute(
"SELECT id FROM paper_accounts WHERE status='running' AND mode='live'"
@@ -62,3 +65,18 @@ def restore_live_jobs(db_path: str, job_func=None) -> int:
for (aid,) in rows:
register_paper_job(aid, job_func=job_func)
return len(rows)
def register_live_step_job(db_path: str, hour: int = 20, minute: int = 30) -> str:
"""全局每日 step job20:30 遍历所有 live accounts 调 run_live_stepC-S3 简化架构)。
job_func 用 lambda 闭包 db_pathadd_job 只传无参 callable)。
"""
from .live_orchestrator import run_live_step # lazy 避循环 import
s = start_scheduler()
s.add_job(
lambda: run_live_step(db_path),
"cron", hour=hour, minute=minute,
id="live_step_global", replace_existing=True,
)
return "live_step_global"