- 新增 finance/tests/ 6 个测试套件(agents/backtest/dao_upsert/factors/features/fundamental_lookahead) - 数据层: data_manager / dao 优化,新增 upsert 逻辑 - 因子层: 基本面因子抽象定位 _mapping、ROE/PE/PB 重构 - 回测层: vectorbt/engine 大改动(251 行),report 增强 - ML 层: features/backtest_integration 特征工程与回测优化 - CLI: agent_cli 重构 - config/settings 扩充配置项
137 lines
5.1 KiB
Python
137 lines
5.1 KiB
Python
"""
|
||
ML 模型回测集成。
|
||
|
||
MLStrategy: 将 ML 预测值作为交易信号接入回测引擎。
|
||
MLBenchmark: 多模型基准对比。
|
||
"""
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
|
||
from backtest.base import BaseStrategy
|
||
from backtest.vectorbt.engine import VectorBTEngine
|
||
from models.base import BaseModel
|
||
from models.features import FeatureEngine
|
||
|
||
|
||
class MLStrategy(BaseStrategy):
|
||
"""
|
||
ML 预测 → 交易信号。
|
||
|
||
用模型预测未来 N 日收益,按预测值分位数生成信号:
|
||
- 预测值 > buy_quantile → 买入
|
||
- 预测值 < sell_quantile → 平仓
|
||
|
||
参数:
|
||
model: 已训练的 BaseModel
|
||
feature_engine: 已 fit 的 FeatureEngine
|
||
buy_quantile: 买入分位阈值(0.7 = 预测值最高的30%买入)
|
||
sell_quantile: 卖出分位阈值(0.3 = 预测值最低的30%平仓)
|
||
rebalance_freq: 调仓间隔(交易日)
|
||
"""
|
||
|
||
category = "ml"
|
||
|
||
def __init__(
|
||
self,
|
||
model: BaseModel,
|
||
feature_engine: FeatureEngine,
|
||
buy_quantile: float = 0.7,
|
||
sell_quantile: float = 0.3,
|
||
rebalance_freq: int = 5,
|
||
):
|
||
self.model = model
|
||
self.feature_engine = feature_engine
|
||
self.buy_quantile = buy_quantile
|
||
self.sell_quantile = sell_quantile
|
||
self.rebalance_freq = rebalance_freq
|
||
self.name = f"ml_{model.name}"
|
||
|
||
def generate_signals(self, factor_df: pd.DataFrame) -> pd.Series:
|
||
X, _ = self.feature_engine.build(factor_df, factor_df, fit=False)
|
||
if X.empty:
|
||
return pd.Series(-1, index=factor_df.index)
|
||
|
||
preds = self.model.predict(X)
|
||
# 用预测值本身的分布作为阈值(相对排序,避免模型偏差影响)
|
||
buy_threshold = preds.quantile(self.buy_quantile)
|
||
sell_threshold = preds.quantile(self.sell_quantile)
|
||
|
||
signals = pd.Series(-1, index=factor_df.index)
|
||
common = signals.index.intersection(preds.index)
|
||
buy_mask = preds.loc[common] > buy_threshold
|
||
sell_mask = preds.loc[common] < sell_threshold
|
||
signals.loc[buy_mask[buy_mask].index] = 1
|
||
signals.loc[sell_mask[sell_mask].index] = 0
|
||
|
||
signals = self._filter_rebalance(signals)
|
||
return signals
|
||
|
||
def _filter_rebalance(self, signals: pd.Series) -> pd.Series:
|
||
"""每隔 rebalance_freq 个交易日保留第一个非持有信号。"""
|
||
result = signals.copy()
|
||
last_active = -self.rebalance_freq - 1
|
||
for i in range(len(result)):
|
||
sig = result.iloc[i]
|
||
if sig in (0, 1):
|
||
if i - last_active >= self.rebalance_freq:
|
||
last_active = i
|
||
else:
|
||
result.iloc[i] = -1
|
||
return result
|
||
|
||
|
||
class MLBenchmark:
|
||
"""ML 模型基准对比测试。
|
||
|
||
入参的 feature_engine 应已用模型训练集 fit 过(scaler/winsor/median 已缓存)。
|
||
run() 对 **样本外测试数据**(test_factor_df/test_price_df)用 fit=False 转换后
|
||
计算预测 IC,避免"同一时间段既训练又评估"的前视泄漏。
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
models: list[BaseModel],
|
||
feature_engine: FeatureEngine,
|
||
test_factor_df: pd.DataFrame,
|
||
test_price_df: pd.DataFrame,
|
||
bt_engine: VectorBTEngine | None = None,
|
||
):
|
||
self.models = models
|
||
self.feature_engine = feature_engine
|
||
self.test_factor_df = test_factor_df
|
||
self.test_price_df = test_price_df
|
||
self.bt_engine = bt_engine or VectorBTEngine()
|
||
|
||
def run(self) -> pd.DataFrame:
|
||
"""对比各模型在样本外测试集上的预测质量和回测表现。"""
|
||
rows = []
|
||
# 标签由规则(前视收益)决定,预测时可直接用同一 build_labels 构造,
|
||
# 避免依赖 build(fit=False) 不产标签的语义。
|
||
y_test = self.feature_engine.build_labels(self.test_price_df)
|
||
X_test, _ = self.feature_engine.build(self.test_factor_df, self.test_price_df, fit=False)
|
||
if X_test is None or X_test.empty or y_test is None or y_test.dropna().empty:
|
||
raise RuntimeError(
|
||
"MLBenchmark: 样本外测试集为空或 feature_engine 未在训练集上 fit。")
|
||
|
||
for model in self.models:
|
||
# OOS 回测:MLStrategy 用同一已 fit engine 对测试集生成信号
|
||
strategy = MLStrategy(model, self.feature_engine)
|
||
report = self.bt_engine.run(strategy, self.test_price_df, self.test_factor_df)
|
||
|
||
preds = model.predict(X_test)
|
||
common = X_test.index.intersection(y_test.dropna().index)
|
||
ic = preds.reindex(common).astype(float).corr(y_test.reindex(common).astype(float)) if len(common) > 1 else 0
|
||
|
||
rows.append({
|
||
"model": model.name,
|
||
"ic": round(ic, 4),
|
||
"total_return": report.total_return,
|
||
"cagr": report.cagr,
|
||
"max_dd": report.max_drawdown,
|
||
"sharpe": report.sharpe_ratio,
|
||
"win_rate": report.win_rate,
|
||
"trades": report.total_trades,
|
||
})
|
||
return pd.DataFrame(rows).set_index("model")
|