diff --git a/rdagent/scenarios/qlib/developer/factor_runner.py b/rdagent/scenarios/qlib/developer/factor_runner.py index afc41077..f3caf5f8 100644 --- a/rdagent/scenarios/qlib/developer/factor_runner.py +++ b/rdagent/scenarios/qlib/developer/factor_runner.py @@ -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, }) diff --git a/scripts/predix_full_eval.py b/scripts/predix_full_eval.py index ebe498dc..ae32fd21 100644 --- a/scripts/predix_full_eval.py +++ b/scripts/predix_full_eval.py @@ -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, diff --git a/test/qlib/test_factor_eval_bugs.py b/test/qlib/test_factor_eval_bugs.py index 97abd749..a0a03838 100644 --- a/test/qlib/test_factor_eval_bugs.py +++ b/test/qlib/test_factor_eval_bugs.py @@ -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 # =============================================================================