mirror of
https://github.com/NicolasBohn/NexQuant.git
synced 2026-07-27 15:37:44 +00:00
fix: correct Sharpe/MaxDD/WinRate in direct factor eval (was computing on raw factor, now on strategy returns)
This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user