fix: correct Sharpe/MaxDD/WinRate in direct factor eval (was computing on raw factor, now on strategy returns)

This commit is contained in:
TPTBusiness
2026-05-03 12:17:27 +02:00
parent e31963713b
commit 037f7ba7d2
3 changed files with 49 additions and 54 deletions
@@ -544,23 +544,32 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
except Exception:
rank_ic = ic
# Compute Sharpe-like metric
factor_mean = factor_col.loc[valid_idx].mean()
factor_std = factor_col.loc[valid_idx].std()
sharpe = factor_mean / factor_std if factor_std > 0 else 0
# Compute strategy returns from factor signal + forward returns
# signal: long(1) when factor > 0, short(-1) when factor <= 0
signal = np.where(factor_col.loc[valid_idx] > 0, 1.0, -1.0)
strategy_ret = signal * forward_ret.loc[valid_idx]
# Annualized return (approximate)
ann_factor = np.sqrt(252 * 1440 / 96)
annualized_return = factor_mean * ann_factor * 100
# Annualization factor for 1-minute bars
bars_per_year = 252 * 1440 # ~362880
bars_per_forward = 96
ann_factor = np.sqrt(bars_per_year / bars_per_forward)
# Max drawdown (approximate)
cum_perf = factor_col.loc[valid_idx].cumsum()
running_max = cum_perf.expanding().max()
drawdown = (cum_perf - running_max) / running_max.replace(0, np.nan)
max_drawdown = drawdown.min() if len(drawdown) > 0 else 0
# Sharpe: annualized mean/vol of strategy returns
ret_mean = strategy_ret.mean()
ret_std = strategy_ret.std()
sharpe = (ret_mean / ret_std * ann_factor) if ret_std > 0 else 0.0
# Win rate
win_rate = (factor_col.loc[valid_idx] > 0).sum() / len(valid_idx)
# Annualized return
annualized_return = float(ret_mean * bars_per_year / bars_per_forward * 100)
# Max drawdown on equity curve
equity = (1.0 + strategy_ret).cumprod()
running_max = equity.expanding().max()
drawdown = (equity - running_max) / running_max.replace(0, np.nan)
max_drawdown = float(drawdown.min()) if len(drawdown) > 0 else 0.0
# Win rate: fraction of positive strategy returns
win_rate = float((strategy_ret > 0).sum()) / len(strategy_ret) if len(strategy_ret) > 0 else 0.0
# Create result series compatible with Qlib backtest result format
result = pd.Series({
@@ -570,7 +579,7 @@ class QlibFactorRunner(CachedRunner[QlibFactorExperiment]):
"1day.excess_return_with_cost.max_drawdown": max_drawdown,
"win_rate": win_rate,
"1day.excess_return_with_cost.information_ratio": rank_ic,
"1day.excess_return_with_cost.std": factor_std,
"1day.excess_return_with_cost.std": float(ret_std),
"1day.pos": len(valid_idx),
"factor_name": factor_name,
})
+19 -13
View File
@@ -357,23 +357,29 @@ def evaluate_factor_full(factor: FactorInfo, full_data: pd.DataFrame,
ic = factor_val.loc[valid_idx].corr(forward_ret.loc[valid_idx])
rank_ic = factor_val.loc[valid_idx].corr(forward_ret.loc[valid_idx], method="spearman")
# Compute Sharpe
factor_mean = factor_val.loc[valid_idx].mean()
factor_std = factor_val.loc[valid_idx].std()
sharpe = factor_mean / factor_std if factor_std > 0 else 0
# Compute strategy returns from factor signal
signal = np.where(factor_val.loc[valid_idx] > 0, 1.0, -1.0)
strategy_ret = signal * forward_ret.loc[valid_idx]
bars_per_year = 252 * 1440
ann_factor = np.sqrt(bars_per_year / forward_return_bars)
# Sharpe: annualized mean/vol of strategy returns
ret_mean = strategy_ret.mean()
ret_std = strategy_ret.std()
sharpe = float(ret_mean / ret_std * ann_factor) if ret_std > 0 else 0.0
# Annualized return
ann_factor = np.sqrt(252 * 1440 / forward_return_bars)
annualized_return = float(factor_mean * ann_factor * 100)
annualized_return = float(ret_mean * bars_per_year / forward_return_bars * 100)
# Max drawdown
cum_perf = factor_val.loc[valid_idx].cumsum()
running_max = cum_perf.expanding().max()
drawdown = (cum_perf - running_max) / running_max.replace(0, np.nan)
max_drawdown = float(drawdown.min()) if len(drawdown) > 0 else 0
# Max drawdown on equity curve
equity = (1.0 + strategy_ret).cumprod()
running_max = equity.expanding().max()
drawdown = (equity - running_max) / running_max.replace(0, np.nan)
max_drawdown = float(drawdown.min()) if len(drawdown) > 0 else 0.0
# Win rate
win_rate = float((factor_val.loc[valid_idx] > 0).sum()) / len(valid_idx)
# Win rate: fraction of positive strategy returns
win_rate = float((strategy_ret > 0).sum()) / len(strategy_ret) if len(strategy_ret) > 0 else 0.0
return EvalResult(
factor_name=factor.factor_name,
+6 -26
View File
@@ -122,39 +122,19 @@ class TestEqualValueRatioAccRateUndefined:
# =============================================================================
class TestAnnualizationFactorInDirectEval:
"""Verify direct evaluation uses correct annualization with forward_return_bars."""
def test_annualization_factor_uses_forward_bars(self):
"""The direct eval method hardcodes 96 instead of using forward_return_bars param."""
def test_uses_bars_per_year_strategy_ret(self):
import inspect
from rdagent.scenarios.qlib.developer.factor_runner import QlibFactorRunner
source = inspect.getsource(QlibFactorRunner._evaluate_factor_directly)
assert "bars_per_year" in source
assert "strategy_ret" in source
# Check that the method uses `np.sqrt(252 * 1440 / 96)` which hardcodes 96
# This should ideally be parameterized or at least consistent with the
# forward return calculation at line ~530 which also uses 96.
assert "np.sqrt(252 * 1440 / 96)" in source or "np.sqrt(252*1440/96)" in source, (
"The annualization factor in _evaluate_factor_directly should match "
"the forward_return_bars used for computing forward returns."
)
def test_ann_factor_is_consistent_with_forward_ret(self):
"""Verify both the forward return shift and annualization use 96 bars."""
def test_signal_based_on_factor_sign(self):
import inspect
from rdagent.scenarios.qlib.developer.factor_runner import QlibFactorRunner
source = inspect.getsource(QlibFactorRunner._evaluate_factor_directly)
# forward return uses `.shift(-96)` at line ~530
assert '.shift(-96)' in source, "Forward return shift should use 96 bars (1 day)"
# annualization should also use 96
assert '1440 / 96' in source, (
"Annualization factor should use the same number (96) as the forward return shift"
)
assert "np.where" in source
assert "signal" in source
# =============================================================================