Skip to content

Commit 62b7ecd

Browse files
committed
prod: LiveEngine — alpha-v1.0 运行时系统
Event-driven runtime: - 每日循环: update_equity → rebalance → risk check - 调仓调度 (80d schedule) - Vol filter - Kill switch (drawdown > 30%) - 状态报告 架构: 从 batch backtest 转为持续运行程序。 数据集成需接入真实行情feed后完善。 核心设计: while market_is_open: wait_data() → update() → trade()
1 parent f62c08f commit 62b7ecd

1 file changed

Lines changed: 274 additions & 0 deletions

File tree

trading/live_engine.py

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
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

Comments
 (0)