Files
sanguo_vnpy_v2/sanguo_trader/shadow/runner.py
T

231 lines
9.3 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.
"""影子柜台常驻进程入口(P1VPS Windows / miniQMT 行情)。
与组合实盘(``sanguo_portfolio.runner_live``)同一个 bullet_trade LiveEngine
唯一区别:broker_factory 换成 ShadowBroker(本地撮合,订单不出门)。
策略/行情/调度完全同款 → 双轨一致性验证(设计 §8)的基础。
环境变量(复用 live_strategy.py 的 SANGUO_LIVE_* 命名 + 影子专属 SANGUO_SHADOW_*):
SANGUO_LIVE_STRATEGY/_MAX_POOL/_BENCHMARK/_CASH 策略配置(live_strategy.py 读)
SANGUO_SHADOW_DB / SANGUO_SHADOW_ACCOUNT_ID 落库目标(paper 库)
SANGUO_SHADOW_COMMISSION/_STAMP/_MIN_COMM/_SLIPPAGE 费率滑点(对齐实盘券商参数)
手动用法(VPS 交易日):
set SANGUO_LIVE_STRATEGY=all_weather
set SANGUO_SHADOW_DB=C:\\sanguo_vnpy_v2\\data\\paper.db
python -m sanguo_trader.shadow
不做多账户轮询:MVP 一进程一账户(与 runner_live 一致),多账户由 supervisor
按 paper_accounts(engine='shadow')逐行拉子进程(后续接入)。
"""
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, Optional
logger = logging.getLogger(__name__)
# 策略适配文件与组合实盘共用(读 SANGUO_LIVE_* env)
ADAPTER_FILE = Path(__file__).resolve().parents[2] / "sanguo_portfolio" / "live_strategy.py"
def shadow_env() -> Dict[str, str]:
"""解析影子柜台 env(独立出来便于单测)。"""
return {
"db": os.environ.get("SANGUO_SHADOW_DB", ""),
"account_id": os.environ.get("SANGUO_SHADOW_ACCOUNT_ID", ""),
"commission": os.environ.get("SANGUO_SHADOW_COMMISSION", "0.0003"),
"stamp": os.environ.get("SANGUO_SHADOW_STAMP", "0.001"),
"min_comm": os.environ.get("SANGUO_SHADOW_MIN_COMM", "5"),
"slippage": os.environ.get("SANGUO_SHADOW_SLIPPAGE", "0.001"),
"snapshot_sec": os.environ.get("SANGUO_SHADOW_SNAPSHOT_SEC", "30"),
}
def build_price_getter(provider: Any) -> Any:
"""从数据 provider 取标的最新价(实时/最新收盘)。返回闭包给 ShadowBroker。"""
def get_price(security: str) -> Optional[float]:
from datetime import datetime, timedelta
end = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
start = (datetime.now() - timedelta(days=10)).strftime("%Y-%m-%d")
try:
df = provider.get_price(
security=security, start_date=start, end_date=end,
frequency="daily", fields=["close"], fq="pre",
)
if df is None or len(df) == 0:
return None
return float(df["close"].iloc[-1])
except Exception: # noqa: BLE001 - provider 接口差异兜底
cols = [c for c in ("close", "Close") if c in (df.columns if df is not None else [])]
if cols:
return float(df[cols[0]].iloc[-1])
return None
return get_price
def build_limit_getter(provider: Any) -> Any:
"""从数据 provider 取标的实时涨跌停/停牌状态(P1.3,ShadowBroker 拒单用)。
优先 ``get_live_current``(miniQMT 实时 tick:lastPrice vs UpStop/DownStop,
与实盘同源同刻);无实时能力(回测 provider)→ 回退 ``get_limit_status_batch``
(日线 prev_close×幅度口径)。查不到 → None(ShadowBroker 放行,等价旧行为)。
"""
from datetime import datetime
from typing import Optional as _Opt
def get_limit(security: str) -> _Opt[dict]:
live_fn = getattr(provider, "get_live_current", None)
if live_fn is not None:
try:
cur = live_fn(security) or {}
last = cur.get("last_price")
high = cur.get("high_limit") or 0.0
low = cur.get("low_limit") or 0.0
if last:
return {
"is_limit_up": bool(high and float(last) >= float(high)),
"is_limit_down": bool(low and float(last) <= float(low)),
"is_paused": bool(cur.get("paused")),
}
except Exception: # noqa: BLE001 - 实时源失败试批量源
pass
batch_fn = getattr(provider, "get_limit_status_batch", None)
if batch_fn is not None:
try:
return (batch_fn([security], datetime.now().strftime("%Y-%m-%d"))
or {}).get(security)
except Exception: # noqa: BLE001
return None
return None
return get_limit
def _paper_on_trade(db: str, account_id: int, strategy_id: str):
"""成交回调:落 paper_trades(与组合实走 EOD 同表,前端模拟盘页直接可见)。"""
from sanguo_trader.persistence import save_trade
def hook(trade: Dict[str, Any]) -> None:
side = trade["side"]
save_trade(db, account_id, {
"strategy_id": strategy_id,
"datetime": trade["datetime"],
"symbol": trade["security"],
"direction": "long" if side == "buy" else "short",
"offset": "open" if side == "buy" else "close",
"match_session": "shadow_realtime",
"price": trade["price"],
"volume": trade["amount"],
"commission": trade["commission"],
"stamp_duty": trade["stamp_duty"],
"bar_date": trade["datetime"][:10],
})
return hook
def _snapshot_loop(broker: Any, db: str, account_id: int,
interval_sec: float = 30.0) -> None:
"""后台线程:定期把影子账户快照落 paper_positions/paper_daily_balance。"""
from sanguo_trader.persistence import save_daily_balance, save_positions
while True:
time.sleep(interval_sec)
try:
info = broker.get_account_info()
positions = {
p["security"]: {"volume": float(p["amount"]), "frozen": 0.0,
"avg_price": p["avg_cost"]}
for p in info["positions"]
}
save_positions(db, account_id, "account", positions,
date=broker.trades[-1]["datetime"][:10] if broker.trades else "")
save_daily_balance(
db, account_id, info.get("as_of", ""),
cash=info["available_cash"], market_value=info["market_value"],
total_equity=info["total_value"],
)
except Exception as exc: # noqa: BLE001 - 落库失败不中断柜台
logger.warning("[shadow-snapshot] 落库失败 (account=%s): %s", account_id, exc)
def _instance_adapter(account_id: str) -> Path:
"""按账户复制一份策略适配文件(同 runner_live._instance_adapter)。
bullet_trade 实例锁按 strategy_path+broker_type+account_identity 判重;
多影子账户共用 ShadowBroker(同 identity)时须路径互异才不被判重复实例。
"""
if not account_id:
return ADAPTER_FILE
dst = (Path(__file__).resolve().parents[2] / "runtime"
/ f"shadow_{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_shadow(provider_config: Optional[Dict[str, Any]] = None) -> None:
"""装配 LiveEngine(影子 broker)并 run(阻塞)。"""
from bullet_trade.core.live_engine import LiveEngine # type: ignore
from bullet_trade.data.api import set_data_provider # type: ignore
from sanguo_portfolio.runner_live import build_provider
from .broker import ShadowBroker
cfg = shadow_env()
from sanguo_portfolio.runner_live import live_env
le = live_env()
provider = build_provider(provider_config)
set_data_provider(provider)
broker = ShadowBroker(
initial_cash=float(le["cash"]),
commission_rate=float(cfg["commission"]),
stamp_duty_rate=float(cfg["stamp"]),
min_commission=float(cfg["min_comm"]),
slippage=float(cfg["slippage"]),
price_getter=build_price_getter(provider),
limit_getter=build_limit_getter(provider),
on_trade=_paper_on_trade(cfg["db"], int(cfg["account_id"]), le["strategy"])
if cfg["db"] and cfg["account_id"] else None,
)
logger.info(
"影子柜台启动: strategy=%s cash=%s 费率=佣金%s/印花%s/最低%s 滑点%s db=%s",
le["strategy"], le["cash"], cfg["commission"], cfg["stamp"],
cfg["min_comm"], cfg["slippage"], cfg["db"] or "(不落库)",
)
engine = LiveEngine(
_instance_adapter(cfg["account_id"]),
broker_factory=lambda: broker,
# 独立 runtime 目录:bullet_trade 单实例锁默认 ./runtime,
# 影子与实盘并行(双轨§8)会互抢锁,按账户分目录隔离
live_config={"runtime_dir": str(
Path(__file__).resolve().parent.parent.parent / "runtime"
/ f"shadow_{cfg['account_id'] or 'solo'}")},
)
if cfg["db"] and cfg["account_id"]:
t = threading.Thread(
target=_snapshot_loop,
args=(broker, cfg["db"], int(cfg["account_id"]), float(cfg["snapshot_sec"])),
daemon=True, name="shadow-snapshot",
)
t.start()
engine.run()