|
| 1 | +"""Live Engine — alpha-v1.0 运行时系统. |
| 2 | +
|
| 3 | +把 batch backtest 改成 event-driven 持续运行程序. |
| 4 | +
|
| 5 | +架构: |
| 6 | + while market_is_open: |
| 7 | + data = get_data() |
| 8 | + state.update(data) |
| 9 | + signal = alpha(state) |
| 10 | + position = risk(signal) |
| 11 | + broker.send_orders(position) |
| 12 | + sleep(interval) |
| 13 | +
|
| 14 | +当前: paper mode (mock data + simulated execution) |
| 15 | +""" |
| 16 | + |
| 17 | +from __future__ import annotations |
| 18 | + |
| 19 | +import logging |
| 20 | +import time |
| 21 | +from dataclasses import dataclass, field |
| 22 | +from datetime import datetime, timedelta |
| 23 | +from pathlib import Path |
| 24 | +from typing import Any, Callable |
| 25 | + |
| 26 | +import pandas as pd |
| 27 | +import numpy as np |
| 28 | + |
| 29 | +logger = logging.getLogger(__name__) |
| 30 | + |
| 31 | +# ── 策略参数 (alpha-v1.0, 已锁定) ── |
| 32 | +SIGNAL_H = 40 |
| 33 | +HOLD_H = 80 |
| 34 | +SELECT_PCT = 0.20 |
| 35 | +VOL_PERCENTILE = 0.70 |
| 36 | +INITIAL_CAPITAL = 10_000_000 |
| 37 | +COST_BPS = 15 |
| 38 | +MAX_DRAWDOWN = 0.30 |
| 39 | + |
| 40 | + |
| 41 | +@dataclass |
| 42 | +class Position: |
| 43 | + asset: str |
| 44 | + entry_date: str |
| 45 | + exit_date: str |
| 46 | + shares: int |
| 47 | + entry_price: float |
| 48 | + |
| 49 | + |
| 50 | +@dataclass |
| 51 | +class State: |
| 52 | + """系统运行时状态.""" |
| 53 | + cash: float = INITIAL_CAPITAL |
| 54 | + positions: list[Position] = field(default_factory=list) |
| 55 | + equity_peak: float = INITIAL_CAPITAL |
| 56 | + current_equity: float = INITIAL_CAPITAL |
| 57 | + total_trades: int = 0 |
| 58 | + last_rebalance: str = "" |
| 59 | + next_rebalance: str = "" |
| 60 | + vol_series: list[float] = field(default_factory=list) |
| 61 | + status: str = "init" |
| 62 | + |
| 63 | + |
| 64 | +class LiveEngine: |
| 65 | + """alpha-v1.0 Live Runtime. |
| 66 | +
|
| 67 | + Usage: |
| 68 | + engine = LiveEngine(data_provider, broker) |
| 69 | + engine.run() |
| 70 | + """ |
| 71 | + |
| 72 | + def __init__(self, data_provider=None, broker=None): |
| 73 | + self.data_provider = data_provider |
| 74 | + self.broker = broker |
| 75 | + self.state = State() |
| 76 | + self._history: pd.DataFrame | None = None |
| 77 | + self._day_count = 0 |
| 78 | + self._rebalance_dates: list[pd.Timestamp] = [] |
| 79 | + |
| 80 | + # ── 初始化 ── |
| 81 | + |
| 82 | + def load_history(self, returns: pd.DataFrame, prices: pd.DataFrame): |
| 83 | + """加载历史数据用于状态初始化.""" |
| 84 | + self._returns = returns |
| 85 | + self._prices = prices |
| 86 | + # 预计算调仓日 |
| 87 | + dates = returns.index |
| 88 | + self._rebalance_dates = [dates[i] for i in range(HOLD_H, len(dates) - SIGNAL_H, HOLD_H)] |
| 89 | + self.state.next_rebalance = str(self._rebalance_dates[0])[:10] if self._rebalance_dates else "" |
| 90 | + logger.info("History loaded: %d days, %d rebalance dates", len(returns), len(self._rebalance_dates)) |
| 91 | + |
| 92 | + # ── 核心循环 ── |
| 93 | + |
| 94 | + def run_once(self, date: pd.Timestamp) -> dict[str, Any]: |
| 95 | + """单日运行 (每次调用 = 一个交易日). |
| 96 | +
|
| 97 | + 这是 live engine 的核心——每次市场收盘后调用一次. |
| 98 | + """ |
| 99 | + if self._returns is None or self._prices is None: |
| 100 | + return {"status": "no_data"} |
| 101 | + |
| 102 | + date_str = str(date)[:10] |
| 103 | + self._day_count += 1 |
| 104 | + result: dict[str, Any] = {"date": date_str, "action": "none"} |
| 105 | + |
| 106 | + # 1. 更新权益 |
| 107 | + self._update_equity(date) |
| 108 | + |
| 109 | + # 2. 检查是否需要调仓 |
| 110 | + is_rebalance = date in self._rebalance_dates |
| 111 | + |
| 112 | + if is_rebalance: |
| 113 | + result["action"] = self._execute_rebalance(date) |
| 114 | + self.state.last_rebalance = date_str |
| 115 | + |
| 116 | + # 更新下次调仓日 |
| 117 | + idx = self._rebalance_dates.index(date) if date in self._rebalance_dates else -1 |
| 118 | + if idx >= 0 and idx + 1 < len(self._rebalance_dates): |
| 119 | + self.state.next_rebalance = str(self._rebalance_dates[idx + 1])[:10] |
| 120 | + |
| 121 | + # 3. 检查 kill switch |
| 122 | + drawdown = (self.state.equity_peak - self.state.current_equity) / self.state.equity_peak |
| 123 | + if drawdown > MAX_DRAWDOWN: |
| 124 | + self._emergency_flat(date) |
| 125 | + result["action"] = "kill_switch" |
| 126 | + result["reason"] = f"drawdown={drawdown:.2%}" |
| 127 | + |
| 128 | + result["equity"] = self.state.current_equity |
| 129 | + result["cash"] = self.state.cash |
| 130 | + result["drawdown"] = drawdown |
| 131 | + result["n_positions"] = len(self.state.positions) |
| 132 | + |
| 133 | + return result |
| 134 | + |
| 135 | + def run(self, start: str | None = None, end: str | None = None): |
| 136 | + """跑完整回放 (batch 模式, 模拟持续运行).""" |
| 137 | + if self._returns is None: |
| 138 | + raise ValueError("No data") |
| 139 | + |
| 140 | + dates = self._returns.index |
| 141 | + if start: |
| 142 | + dates = dates[dates >= start] |
| 143 | + if end: |
| 144 | + dates = dates[dates <= end] |
| 145 | + |
| 146 | + logger.info("LiveEngine starting: %s -> %s (%d days)", |
| 147 | + str(dates[0])[:10], str(dates[-1])[:10], len(dates)) |
| 148 | + |
| 149 | + results = [] |
| 150 | + for date in dates: |
| 151 | + r = self.run_once(date) |
| 152 | + if r["action"] != "none": |
| 153 | + logger.info("[%s] %s%s", r["date"], r["action"], |
| 154 | + f" ({r.get('reason','')})" if r.get("reason") else "") |
| 155 | + results.append(r) |
| 156 | + |
| 157 | + final = results[-1] |
| 158 | + logger.info("LiveEngine done. Equity: %.2f, Trades: %d, Status: %s", |
| 159 | + final["equity"], self.state.total_trades, self.state.status) |
| 160 | + return results |
| 161 | + |
| 162 | + # ── 内部方法 ── |
| 163 | + |
| 164 | + def _update_equity(self, date: pd.Timestamp): |
| 165 | + """计算当日总权益 (逐日盯市).""" |
| 166 | + pos_value = 0.0 |
| 167 | + if self._prices is not None and date in self._prices.index: |
| 168 | + px = self._prices.loc[date] |
| 169 | + for pos in self.state.positions: |
| 170 | + if pos.asset in px.index and not pd.isna(px[pos.asset]): |
| 171 | + pos_value += pos.shares * px[pos.asset] |
| 172 | + |
| 173 | + self.state.current_equity = self.state.cash + pos_value |
| 174 | + if self.state.current_equity > self.state.equity_peak: |
| 175 | + self.state.equity_peak = self.state.current_equity |
| 176 | + |
| 177 | + def _execute_rebalance(self, date: pd.Timestamp) -> str: |
| 178 | + """执行调仓.""" |
| 179 | + if self._returns is None or self._prices is None: |
| 180 | + return "error" |
| 181 | + |
| 182 | + day_idx = self._returns.index.get_loc(date) |
| 183 | + n_stocks = len(self._returns.columns) |
| 184 | + cost = COST_BPS / 10000 |
| 185 | + |
| 186 | + # ── 平到期仓位 ── |
| 187 | + date_str = str(date)[:10] |
| 188 | + self.state.positions = [p for p in self.state.positions if p.exit_date > date_str] |
| 189 | + |
| 190 | + # ── Vol filter ── |
| 191 | + market_vol = self._returns.mean(axis=1).rolling(20).std() |
| 192 | + vol_thresh = market_vol.quantile(VOL_PERCENTILE) |
| 193 | + if date in market_vol.index and market_vol[date] > vol_thresh: |
| 194 | + return "skip_high_vol" |
| 195 | + |
| 196 | + # ── 信号 ── |
| 197 | + past_ret = self._returns.rolling(SIGNAL_H, min_periods=SIGNAL_H).apply( |
| 198 | + lambda x: np.prod(1 + x) - 1 if len(x) > 0 else 0, raw=True |
| 199 | + ) |
| 200 | + pr = past_ret.iloc[day_idx] |
| 201 | + valid = pr.dropna().sort_values() |
| 202 | + n_select = max(1, int(n_stocks * SELECT_PCT)) |
| 203 | + |
| 204 | + if len(valid) < n_select: |
| 205 | + return "skip_insufficient_data" |
| 206 | + |
| 207 | + selected = valid.head(n_select) |
| 208 | + n = len(selected) |
| 209 | + cap_per = self.state.cash / n |
| 210 | + |
| 211 | + # ── 买入 ── |
| 212 | + price_row = self._prices.loc[date] if date in self._prices.index else None |
| 213 | + if price_row is None: |
| 214 | + return "skip_no_price" |
| 215 | + |
| 216 | + total_spent = 0.0 |
| 217 | + for asset in selected.index: |
| 218 | + ep = price_row[asset] if asset in price_row.index else 0 |
| 219 | + if pd.isna(ep) or ep <= 0: |
| 220 | + continue |
| 221 | + shares = max(100, int(cap_per / ep / 100) * 100) |
| 222 | + spent = shares * ep |
| 223 | + if total_spent + spent > self.state.cash: |
| 224 | + break # 现金不够 |
| 225 | + |
| 226 | + exit_date = self._returns.index[min(day_idx + HOLD_H, len(self._returns.index) - 1)] |
| 227 | + self.state.positions.append(Position( |
| 228 | + asset=asset, |
| 229 | + entry_date=str(date)[:10], |
| 230 | + exit_date=str(exit_date)[:10], |
| 231 | + shares=shares, |
| 232 | + entry_price=ep, |
| 233 | + )) |
| 234 | + total_spent += spent |
| 235 | + |
| 236 | + self.state.cash -= total_spent # 买入不扣成本, 卖出时扣 |
| 237 | + if total_spent > 0: |
| 238 | + self.state.total_trades += 1 |
| 239 | + |
| 240 | + self.state.total_trades += 1 |
| 241 | + return f"enter_{n}" |
| 242 | + |
| 243 | + def _emergency_flat(self, date: pd.Timestamp): |
| 244 | + """紧急平仓 (kill switch).""" |
| 245 | + cost = COST_BPS / 10000 |
| 246 | + price_row = self._prices.loc[date] if date in self._prices.index else None |
| 247 | + |
| 248 | + for pos in self.state.positions: |
| 249 | + if price_row is not None and pos.asset in price_row.index: |
| 250 | + px = price_row[pos.asset] |
| 251 | + if not pd.isna(px) and px > 0: |
| 252 | + self.state.cash += pos.shares * px * (1 - cost) |
| 253 | + |
| 254 | + self.state.positions = [] |
| 255 | + self.state.status = "killed" |
| 256 | + logger.warning("[%s] KILL SWITCH triggered — all positions closed", str(date)[:10]) |
| 257 | + |
| 258 | + def status_report(self) -> str: |
| 259 | + """生成状态报告.""" |
| 260 | + dd = (self.state.equity_peak - self.state.current_equity) / self.state.equity_peak if self.state.equity_peak > 0 else 0 |
| 261 | + return ( |
| 262 | + f"\n{'=' * 60}\n" |
| 263 | + f" LiveEngine — alpha-v1.0\n" |
| 264 | + f"{'=' * 60}\n" |
| 265 | + f" Status: {self.state.status}\n" |
| 266 | + f" Equity: {self.state.current_equity:>12,.2f}\n" |
| 267 | + f" Cash: {self.state.cash:>12,.2f}\n" |
| 268 | + f" Positions: {len(self.state.positions):>6d}\n" |
| 269 | + f" Drawdown: {dd*100:>10.2f}%\n" |
| 270 | + f" Trades: {self.state.total_trades:>6d}\n" |
| 271 | + f" Last rebal: {self.state.last_rebalance}\n" |
| 272 | + f" Next rebal: {self.state.next_rebalance}\n" |
| 273 | + f"{'=' * 60}" |
| 274 | + ) |
0 commit comments