"""因子回测服务 — IC/IR 分析 + 分层回测 + 多空组合。 纯 Polars 向量化实现,无 pandas 依赖。 """ from __future__ import annotations import logging import time import uuid from dataclasses import dataclass, field from datetime import date, timedelta from typing import Literal import numpy as np import polars as pl from app.backtest.engine import BacktestEngine from app.strategy.scoring import ( VIRTUAL_SCORING_DEPENDENCIES as DERIVED_FACTOR_DEPENDENCIES, ) from app.strategy.scoring import ( materialize_scoring_columns, ) logger = logging.getLogger(__name__) # 可研究因子目录。保留历史 ID 兼容已有候选方案; 价格尺度相关指标优先提供归一化版本。 FACTOR_COLUMNS: list[dict] = [ {"id": "momentum_5d", "label": "5日动量", "group": "动量", "desc": "5个交易日累计收益率"}, {"id": "momentum_10d", "label": "10日动量", "group": "动量", "desc": "10个交易日累计收益率"}, {"id": "momentum_20d", "label": "20日动量", "group": "动量", "desc": "20个交易日累计收益率"}, {"id": "momentum_30d", "label": "30日动量", "group": "动量", "desc": "30个交易日累计收益率"}, {"id": "momentum_60d", "label": "60日动量", "group": "动量", "desc": "60个交易日累计收益率"}, {"id": "change_pct", "label": "日涨跌幅", "group": "动量", "desc": "当日收盘相对前收盘的收益率"}, {"id": "ma5_bias", "label": "MA5乖离", "group": "均线偏离", "desc": "收盘价 / MA5 - 1"}, {"id": "ma10_bias", "label": "MA10乖离", "group": "均线偏离", "desc": "收盘价 / MA10 - 1"}, {"id": "ma20_bias", "label": "MA20乖离", "group": "均线偏离", "desc": "收盘价 / MA20 - 1"}, {"id": "ma30_bias", "label": "MA30乖离", "group": "均线偏离", "desc": "收盘价 / MA30 - 1"}, {"id": "ma60_bias", "label": "MA60乖离", "group": "均线偏离", "desc": "收盘价 / MA60 - 1"}, {"id": "ema5_bias", "label": "EMA5乖离", "group": "均线偏离", "desc": "收盘价 / EMA5 - 1"}, {"id": "ema10_bias", "label": "EMA10乖离", "group": "均线偏离", "desc": "收盘价 / EMA10 - 1"}, {"id": "ema20_bias", "label": "EMA20乖离", "group": "均线偏离", "desc": "收盘价 / EMA20 - 1"}, {"id": "ema30_bias", "label": "EMA30乖离", "group": "均线偏离", "desc": "收盘价 / EMA30 - 1"}, {"id": "ema60_bias", "label": "EMA60乖离", "group": "均线偏离", "desc": "收盘价 / EMA60 - 1"}, {"id": "rsi_6", "label": "RSI(6)", "group": "超买超卖", "desc": "6日相对强弱指标"}, {"id": "rsi_14", "label": "RSI(14)", "group": "超买超卖", "desc": "14日相对强弱指标"}, {"id": "rsi_24", "label": "RSI(24)", "group": "超买超卖", "desc": "24日相对强弱指标"}, {"id": "macd_hist", "label": "MACD柱(原值)", "group": "趋势", "desc": "兼容历史研究; 跨股票比较建议优先使用MACD柱强度"}, {"id": "macd_dif_pct", "label": "MACD DIF强度", "group": "趋势", "desc": "MACD DIF / 收盘价"}, {"id": "macd_dea_pct", "label": "MACD DEA强度", "group": "趋势", "desc": "MACD DEA / 收盘价"}, {"id": "macd_hist_pct", "label": "MACD柱强度", "group": "趋势", "desc": "MACD柱 / 收盘价, 消除股价尺度影响"}, {"id": "kdj_k", "label": "KDJ-K", "group": "趋势", "desc": "KDJ指标K值"}, {"id": "kdj_d", "label": "KDJ-D", "group": "趋势", "desc": "KDJ指标D值"}, {"id": "kdj_j", "label": "KDJ-J", "group": "趋势", "desc": "KDJ指标J值"}, {"id": "boll_position", "label": "布林位置", "group": "趋势", "desc": "收盘价在布林带下轨到上轨之间的位置"}, {"id": "annual_vol_20d", "label": "20日波动率", "group": "波动率", "desc": "20日收益率年化标准差"}, {"id": "atr_14", "label": "ATR(14)原值", "group": "波动率", "desc": "兼容历史研究; 跨股票比较建议优先使用ATR相对波动"}, {"id": "atr_pct", "label": "ATR相对波动", "group": "波动率", "desc": "ATR(14) / 收盘价"}, {"id": "amplitude", "label": "日振幅", "group": "波动率", "desc": "当日高低价差 / 前收盘价"}, {"id": "boll_width", "label": "布林带宽", "group": "波动率", "desc": "布林带上下轨宽度 / MA20"}, {"id": "vol_ratio_5d", "label": "5日量比", "group": "量价", "desc": "当日成交量 / 前5日平均成交量"}, {"id": "vol_ratio_10d", "label": "10日量比", "group": "量价", "desc": "当日成交量 / 前10日平均成交量"}, {"id": "vol_trend_5_10", "label": "成交量趋势", "group": "量价", "desc": "5日平均成交量 / 10日平均成交量 - 1"}, {"id": "turnover_rate", "label": "换手率", "group": "量价", "desc": "使用历史时点流通股本计算的当日换手率"}, {"id": "turnover_ratio_5d", "label": "换手率放大", "group": "量价", "desc": "当日换手率 / 前5日平均换手率 - 1"}, {"id": "log_amount", "label": "成交额对数", "group": "量价", "desc": "ln(成交额 + 1), 降低极端规模影响"}, {"id": "amount_ratio_5d", "label": "成交额放大", "group": "量价", "desc": "当日成交额 / 前5日平均成交额 - 1"}, {"id": "gap_return", "label": "开盘跳空", "group": "价格位置", "desc": "开盘价 / 前收盘价 - 1"}, {"id": "intraday_return", "label": "日内收益", "group": "价格位置", "desc": "收盘价 / 开盘价 - 1"}, {"id": "close_position", "label": "收盘位置", "group": "价格位置", "desc": "收盘价在当日最低价到最高价之间的位置"}, {"id": "distance_to_high_60d", "label": "距60日高点", "group": "价格位置", "desc": "收盘价 / 60日最高收盘价 - 1"}, {"id": "distance_from_low_60d", "label": "距60日低点", "group": "价格位置", "desc": "收盘价 / 60日最低收盘价 - 1"}, ] FACTOR_WARMUP_DAYS = 120 @dataclass class FactorConfig: factor_name: str symbols: list[str] | None start: date end: date n_groups: int = 5 rebalance: Literal["daily", "weekly", "monthly"] = "monthly" weight: Literal["equal", "factor_weight"] = "equal" fees_pct: float = 0.0002 slippage_bps: float = 5.0 asset_type: str = "stock" @dataclass class GroupStats: group: int label: str total_return: float annual_return: float max_drawdown: float sharpe: float win_rate: float @dataclass class FactorResult: run_id: str config: dict # IC 分析 ic_mean: float | None = None ic_std: float | None = None ir: float | None = None ic_win_rate: float | None = None ic_series: list[dict] = field(default_factory=list) # 分层 group_stats: list[dict] = field(default_factory=list) group_nav: list[dict] = field(default_factory=list) # 多空 long_short_stats: dict = field(default_factory=dict) long_short_nav: list[dict] = field(default_factory=list) # 元信息 elapsed_ms: float = 0.0 n_symbols: int = 0 n_dates: int = 0 error: str | None = None @dataclass class FactorBatchConfig: factor_names: list[str] symbols: list[str] | None start: date end: date n_groups: int = 5 rebalance: Literal["daily", "weekly", "monthly"] = "monthly" weight: Literal["equal", "factor_weight"] = "equal" fees_pct: float = 0.0002 slippage_bps: float = 5.0 asset_type: str = "stock" @dataclass class FactorBatchItem: factor_name: str label: str group: str ic_mean: float | None = None ir: float | None = None ic_win_rate: float | None = None long_short_return: float | None = None long_short_max_drawdown: float | None = None n_symbols: int = 0 n_dates: int = 0 elapsed_ms: float = 0.0 error: str | None = None @dataclass class FactorBatchResult: run_id: str config: dict results: list[FactorBatchItem] = field(default_factory=list) elapsed_ms: float = 0.0 n_symbols: int = 0 n_dates: int = 0 error: str | None = None class FactorBacktestService: def __init__(self, engine: BacktestEngine) -> None: self.engine = engine def run(self, config: FactorConfig) -> FactorResult: t0 = time.perf_counter() run_id = uuid.uuid4().hex[:10] panel = self._load_factor_panel(config, [config.factor_name]) if panel.is_empty(): return self._error_result(config, run_id, t0, "无数据, 请检查日期范围或先运行盘后管道") return self._evaluate_panel(panel, config, run_id, t0) def run_batch(self, config: FactorBatchConfig) -> FactorBatchResult: """在同一份 Panel 上依次评估多个因子, 避免重复读取和计算指标。""" t0 = time.perf_counter() run_id = uuid.uuid4().hex[:10] factor_names = list(dict.fromkeys(config.factor_names)) result_config = self._batch_config_to_dict(config, factor_names) if not factor_names: return FactorBatchResult( run_id=run_id, config=result_config, error="至少选择一个因子", ) panel = self._load_factor_panel(config, factor_names) if panel.is_empty(): return FactorBatchResult( run_id=run_id, config=result_config, error="无数据, 请检查日期范围或先运行盘后管道", elapsed_ms=round((time.perf_counter() - t0) * 1000, 1), ) metadata = {item["id"]: item for item in FACTOR_COLUMNS} items: list[FactorBatchItem] = [] for factor_name in factor_names: item_t0 = time.perf_counter() factor_config = FactorConfig( factor_name=factor_name, symbols=config.symbols, start=config.start, end=config.end, n_groups=config.n_groups, rebalance=config.rebalance, weight=config.weight, fees_pct=config.fees_pct, slippage_bps=config.slippage_bps, asset_type=config.asset_type, ) meta = metadata.get(factor_name, {}) try: result = self._evaluate_panel( panel, factor_config, f"{run_id}-{len(items) + 1}", item_t0, ) long_short = result.long_short_stats items.append(FactorBatchItem( factor_name=factor_name, label=str(meta.get("label", factor_name)), group=str(meta.get("group", "")), ic_mean=result.ic_mean, ir=result.ir, ic_win_rate=result.ic_win_rate, long_short_return=long_short.get("total_return"), long_short_max_drawdown=long_short.get("max_drawdown"), n_symbols=result.n_symbols, n_dates=result.n_dates, elapsed_ms=result.elapsed_ms, error=result.error, )) except Exception as exc: # 单因子失败不能中止整个筛选批次 logger.exception("factor batch item failed: %s", factor_name) items.append(FactorBatchItem( factor_name=factor_name, label=str(meta.get("label", factor_name)), group=str(meta.get("group", "")), elapsed_ms=round((time.perf_counter() - item_t0) * 1000, 1), error=str(exc), )) n_symbols = max((item.n_symbols for item in items), default=0) n_dates = max((item.n_dates for item in items), default=0) return FactorBatchResult( run_id=run_id, config=result_config, results=items, elapsed_ms=round((time.perf_counter() - t0) * 1000, 1), n_symbols=n_symbols, n_dates=n_dates, ) def _load_factor_panel( self, config: FactorConfig | FactorBatchConfig, factor_names: list[str], ) -> pl.DataFrame: panel_columns = [ "symbol", "date", "open", "high", "low", "close", "volume", "amount", "turnover_rate", ] panel_columns.extend(name for name in factor_names if name not in panel_columns) load_start = config.start if any(name != "turnover_rate" for name in factor_names): load_start = config.start - timedelta(days=FACTOR_WARMUP_DAYS) panel = self.engine.load_panel( config.symbols, load_start, config.end, columns=panel_columns, asset_type=config.asset_type, ) if panel.is_empty(): return panel missing = set(factor_names) - set(panel.columns) if missing: panel = self._compute_missing_factors(panel, missing) return panel def _evaluate_panel( self, source_panel: pl.DataFrame, config: FactorConfig, run_id: str, t0: float, ) -> FactorResult: def _err(msg: str) -> FactorResult: return self._error_result(config, run_id, t0, msg) factor_col = config.factor_name if factor_col not in source_panel.columns: return _err(f"因子列 '{factor_col}' 不存在于 enriched 数据中, 且无法从基础行情计算") if "close" not in source_panel.columns: return _err("enriched 数据缺少收盘价 close") panel = source_panel.select(["symbol", "date", "close", factor_col]) panel = panel.filter((pl.col("date") >= config.start) & (pl.col("date") <= config.end)) # 过滤有效行 panel = panel.filter( pl.col(factor_col).is_not_null() & pl.col("close").is_not_null() & (pl.col("close") > 0) ) if panel.is_empty(): return _err("过滤后无有效数据") panel = panel.sort(["symbol", "date"]) n_symbols = panel["symbol"].n_unique() n_dates = panel["date"].n_unique() # 计算下期收益 # 根据调仓频率计算不同周期的 forward return if config.rebalance == "daily": panel = panel.with_columns( (pl.col("close").shift(-1).over("symbol") / pl.col("close") - 1) .alias("_next_return") ) else: # weekly/monthly: 计算到下个调仓日的收益 panel = self._calc_period_return(panel, config.rebalance) # ── 1. IC 分析 ── ic_df = self._calc_ic(panel, factor_col) ic_series = [ {"date": str(row["date"]), "ic": round(float(row["ic"]), 4)} for row in ic_df.iter_rows(named=True) if row["ic"] is not None and not np.isnan(float(row["ic"])) ] ic_values = [r["ic"] for r in ic_series] ic_mean = float(np.mean(ic_values)) if ic_values else None ic_std = float(np.std(ic_values)) if ic_values else None ir = (ic_mean / ic_std) if (ic_mean is not None and ic_std and ic_std > 1e-8) else None ic_win_rate = (sum(1 for v in ic_values if v > 0) / len(ic_values)) if ic_values else None # ── 2. 分层回测 ── panel = self._add_groups(panel, factor_col, config.n_groups) group_nav = self._calc_group_nav(panel, config) group_stats = self._calc_group_stats(group_nav, config.start, config.end, config.rebalance) # ── 3. 多空组合 ── long_short_nav, long_short_stats = self._calc_long_short(group_nav, config) elapsed = (time.perf_counter() - t0) * 1000 return FactorResult( run_id=run_id, config=self._config_to_dict(config), ic_mean=round(ic_mean, 4) if ic_mean is not None else None, ic_std=round(ic_std, 4) if ic_std is not None else None, ir=round(ir, 4) if ir is not None else None, ic_win_rate=round(ic_win_rate, 4) if ic_win_rate is not None else None, ic_series=ic_series, group_stats=group_stats, group_nav=group_nav, long_short_stats=long_short_stats, long_short_nav=long_short_nav, elapsed_ms=round(elapsed, 1), n_symbols=n_symbols, n_dates=n_dates, ) @staticmethod def _compute_missing_factors(panel: pl.DataFrame, factor_cols: set[str]) -> pl.DataFrame: required = {"symbol", "date", "open", "high", "low", "close", "volume"} if not required.issubset(panel.columns): missing = sorted(required - set(panel.columns)) logger.warning("factors %s cannot be computed, missing columns: %s", factor_cols, missing) return panel from app.indicators.pipeline import compute_indicators derived = factor_cols & set(DERIVED_FACTOR_DEPENDENCIES) indicator_columns = factor_cols - derived for factor_name in derived: indicator_columns.update(DERIVED_FACTOR_DEPENDENCIES[factor_name]) panel = compute_indicators(panel, needed=indicator_columns) return FactorBacktestService._compute_derived_factors(panel, derived) @staticmethod def _compute_derived_factors(panel: pl.DataFrame, factor_cols: set[str]) -> pl.DataFrame: return materialize_scoring_columns(panel, factor_cols) @staticmethod def _error_result( config: FactorConfig, run_id: str, started_at: float, message: str, ) -> FactorResult: return FactorResult( run_id=run_id, config=FactorBacktestService._config_to_dict(config), error=message, elapsed_ms=round((time.perf_counter() - started_at) * 1000, 1), ) # ── IC 计算 ── @staticmethod def _calc_ic(panel: pl.DataFrame, factor_col: str) -> pl.DataFrame: """计算截面 Rank IC (因子值 rank vs 下期收益 rank 的相关系数)。""" return ( panel.filter(pl.col("_next_return").is_not_null()) .group_by("date") .agg( pl.corr( pl.col(factor_col).rank(method="average"), pl.col("_next_return").rank(method="average"), ).alias("ic") ) .sort("date") ) # ── 调仓期收益 ── @staticmethod def _calc_period_return(panel: pl.DataFrame, rebalance: str) -> pl.DataFrame: """计算到下个调仓日的收益。 weekly: 下个周调仓日 close / 今日 close - 1 monthly: 下个月调仓日 close / 今日 close - 1 只在调仓日标记行有效,其他行为 null。 """ import datetime as _dt all_dates = sorted(panel["date"].unique().to_list()) if rebalance == "weekly": # 调仓日 = 每周一 rebalance_dates = set() for d in all_dates: if hasattr(d, "weekday"): wd = d.weekday() else: wd = _dt.date.fromisoformat(str(d)).weekday() if wd == 0: # Monday rebalance_dates.add(d) else: # monthly # 调仓日 = 每月首个交易日 seen_months: set[str] = set() rebalance_dates = set() for d in sorted(all_dates): m = str(d)[:7] # "YYYY-MM" if m not in seen_months: seen_months.add(m) rebalance_dates.add(d) if not rebalance_dates: panel = panel.with_columns(pl.lit(None).cast(pl.Float64).alias("_next_return")) return panel # 对每个调仓日,找到下一个调仓日 (仅在 unique 日期上做, 成本极低) sorted_rebalance = sorted(rebalance_dates) reb_dates: list = [] next_dates: list = [] for i, d in enumerate(sorted_rebalance): if i + 1 < len(sorted_rebalance): reb_dates.append(d) next_dates.append(sorted_rebalance[i + 1]) # 最后一个调仓日没有下一个,不计算收益 if not reb_dates: panel = panel.with_columns(pl.lit(None).cast(pl.Float64).alias("_next_return")) return panel panel = panel.sort(["symbol", "date"]) date_dtype = panel.schema["date"] # 调仓日 → 下一调仓日 的映射表 (向量化 JOIN, 替代 Python 逐行 price_map 循环) rebal_df = pl.DataFrame( {"date": reb_dates, "_next_reb_date": next_dates} ).with_columns( pl.col("date").cast(date_dtype), pl.col("_next_reb_date").cast(date_dtype), ) # (symbol, 下一调仓日) → 该日 close 的查找表 (等价于原 price_map, 重复取 last) price_lookup = ( panel.select( pl.col("symbol"), pl.col("date").alias("_next_reb_date"), pl.col("close").alias("_next_close"), ) .unique(subset=["symbol", "_next_reb_date"], keep="last") ) # 只在调仓日标记行有效: 下一调仓日该股 close / 当日 close - 1; 缺价或非调仓日为 null panel = ( panel.join(rebal_df, on="date", how="left") .join(price_lookup, on=["symbol", "_next_reb_date"], how="left") .with_columns( pl.when( pl.col("_next_reb_date").is_not_null() & pl.col("_next_close").is_not_null() & (pl.col("close") > 0) ) .then(pl.col("_next_close") / pl.col("close") - 1.0) .otherwise(None) .cast(pl.Float64) .alias("_next_return") ) .drop(["_next_reb_date", "_next_close"]) .sort(["symbol", "date"]) ) return panel # ── 分组 ── @staticmethod def _add_groups(panel: pl.DataFrame, factor_col: str, n_groups: int) -> pl.DataFrame: """截面序号分桶,避免 qcut 在重复因子值截面上抛错。""" return ( panel.sort(["date", factor_col, "symbol"]) .with_columns( (pl.cum_count("symbol").over("date") - 1).alias("_factor_ord"), pl.len().over("date").alias("_factor_count"), ) .with_columns( ( pl.lit("Q") + ( ((pl.col("_factor_ord") * n_groups) / pl.col("_factor_count")) .floor() .cast(pl.Int64) + 1 ) .clip(1, n_groups) .cast(pl.Utf8) ) .alias("_group") ) .drop(["_factor_ord", "_factor_count"]) ) @staticmethod def _group_sort_key(group: str) -> int: if group.startswith("Q"): try: return int(group[1:]) except ValueError: pass return 0 # ── 分组净值 ── @staticmethod def _calc_group_nav(panel: pl.DataFrame, config: FactorConfig) -> list[dict]: """计算分组净值曲线 — 只在调仓日更新净值。""" # 只保留有下期收益的行 (= 调仓日) group_ret = ( panel.filter(pl.col("_next_return").is_not_null() & pl.col("_group").is_not_null()) .group_by(["date", "_group"]) .agg(pl.col("_next_return").mean().alias("group_return")) ) # pivot: date × group pivot = group_ret.pivot(index="date", columns="_group", values="group_return").sort("date") if pivot.is_empty(): return [] group_cols = sorted([c for c in pivot.columns if c != "date"], key=FactorBacktestService._group_sort_key) # 累乘净值曲线 result: list[dict] = [] nav_values: dict[str, float] = {c: 1.0 for c in group_cols} for row in pivot.iter_rows(named=True): entry: dict = {"date": str(row["date"])[:10]} for c in group_cols: ret = float(row[c]) if row[c] is not None else 0.0 nav_values[c] *= (1 + ret) entry[c] = round(nav_values[c], 4) result.append(entry) return result # ── 分组统计 ── @staticmethod def _calc_group_stats( group_nav: list[dict], start: date, end: date, rebalance: str = "monthly", ) -> list[dict]: if not group_nav: return [] group_cols = sorted( [k for k in group_nav[0] if k != "date"], key=FactorBacktestService._group_sort_key, ) n_days = max((end - start).days, 1) years = n_days / 365.25 stats = [] for i, c in enumerate(group_cols): values = [r[c] for r in group_nav if r.get(c) is not None] if not values: continue total_return = values[-1] - 1.0 annual_return = (values[-1]) ** (1 / max(years, 0.01)) - 1 if values[-1] > 0 else 0.0 # 最大回撤 peak = 1.0 max_dd = 0.0 for v in values: peak = max(peak, v) dd = (v - peak) / peak max_dd = min(max_dd, dd) # 日收益序列 daily_rets = [] for j in range(1, len(values)): if values[j - 1] > 0: daily_rets.append(values[j] / values[j - 1] - 1) # 夏普 — 年化系数必须匹配 group_nav 的调仓频率 (每个净值点 = 一个调仓周期收益); # 周/月频收益若乘 √252 会把 Sharpe 高估 √(252/期数) 倍 (月频 ≈4.6x, 周频 ≈2.2x)。 if daily_rets: arr = np.array(daily_rets) _ann = {"daily": 252, "weekly": 52, "monthly": 12}.get(rebalance, 252) sharpe = float(np.mean(arr) / np.std(arr)) * np.sqrt(_ann) if np.std(arr) > 0 else 0.0 win_rate = float(np.mean(arr > 0)) else: sharpe = 0.0 win_rate = 0.0 stats.append({ "group": i + 1, "label": c, "total_return": round(total_return, 4), "annual_return": round(annual_return, 4), "max_drawdown": round(max_dd, 4), "sharpe": round(sharpe, 2), "win_rate": round(win_rate, 4), }) return stats # ── 多空组合 ── @staticmethod def _calc_long_short( group_nav: list[dict], config: FactorConfig, ) -> tuple[list[dict], dict]: """多空组合: 做多最高组 + 做空最低组。""" if not group_nav: return [], {} group_cols = sorted( [k for k in group_nav[0] if k != "date"], key=FactorBacktestService._group_sort_key, ) if len(group_cols) < 2: return [], {} top_col = group_cols[-1] # Q5 (最高) bottom_col = group_cols[0] # Q1 (最低) # 独立计算 top 和 bottom 的日收益,然后合成 ls_value = 1.0 prev_top = 1.0 prev_bot = 1.0 peak = 1.0 max_dd = 0.0 ls_nav: list[dict] = [] for row in group_nav: top_nav = float(row.get(top_col, 1.0)) if row.get(top_col) is not None else 1.0 bot_nav = float(row.get(bottom_col, 1.0)) if row.get(bottom_col) is not None else 1.0 # top 组收益 (做多) top_ret = (top_nav / prev_top - 1) if prev_top > 0 else 0.0 # bottom 组收益 (做空 = 取反) bot_ret = -(bot_nav / prev_bot - 1) if prev_bot > 0 else 0.0 # 多空组合收益 ls_ret = (top_ret + bot_ret) / 2 # 各分配 50% 资金 ls_value *= (1 + ls_ret) prev_top = top_nav prev_bot = bot_nav peak = max(peak, ls_value) dd = (ls_value - peak) / peak if peak > 0 else 0.0 max_dd = min(max_dd, dd) ls_nav.append({"date": row["date"], "value": round(ls_value, 4)}) total_ret = ls_value - 1.0 ls_stats = { "total_return": round(total_ret, 4), "max_drawdown": round(max_dd, 4), "top_group": top_col, "bottom_group": bottom_col, } return ls_nav, ls_stats @staticmethod def _config_to_dict(c: FactorConfig) -> dict: return { "factor_name": c.factor_name, "symbols": c.symbols, "start": str(c.start), "end": str(c.end), "n_groups": c.n_groups, "rebalance": c.rebalance, "weight": c.weight, "fees_pct": c.fees_pct, "slippage_bps": c.slippage_bps, "asset_type": c.asset_type, } @staticmethod def _batch_config_to_dict(c: FactorBatchConfig, factor_names: list[str]) -> dict: return { "factor_names": factor_names, "symbols": c.symbols, "start": str(c.start), "end": str(c.end), "n_groups": c.n_groups, "rebalance": c.rebalance, "weight": c.weight, "fees_pct": c.fees_pct, "slippage_bps": c.slippage_bps, "asset_type": c.asset_type, }