diff --git a/stratevo/fitness_plugins/builtin/risk_adjusted_return.py b/stratevo/fitness_plugins/builtin/risk_adjusted_return.py new file mode 100644 index 00000000..c90c8a8c --- /dev/null +++ b/stratevo/fitness_plugins/builtin/risk_adjusted_return.py @@ -0,0 +1,42 @@ +"""Risk-Adjusted Return fitness function.""" + +from __future__ import annotations + +from stratevo.fitness_plugins.base import BacktestResult, BaseFitness + +_ZERO_TRADE_PENALTY = -1.0 + + +class RiskAdjustedReturnFitness(BaseFitness): + """Fitness based on return penalised by drawdown. + + Formula:: + + score = annual_return / (1 + abs(max_drawdown)) + + A strategy with a 20% return and 10% drawdown scores higher than one + with a 30% return and 25% drawdown: + + 20% / 1.10 ≈ 0.182 > 30% / 1.25 = 0.240 … wait, let's recheck: + 0.20 / 1.10 ≈ 0.182 + 0.30 / 1.25 = 0.240 + + The issue spec uses ``max_drawdown_abs`` meaning the absolute magnitude. + ``BacktestResult.max_drawdown`` is stored as a negative float (e.g. -0.10). + We take ``abs()`` so the formula is always well-defined regardless of sign + convention. + """ + + name: str = "risk_adjusted_return" + description: str = "Annual return divided by (1 + |max_drawdown|)" + direction: str = "maximize" + + def evaluate(self, result: BacktestResult) -> float: + if result.total_trades == 0: + return _ZERO_TRADE_PENALTY + + dd_abs = abs(result.max_drawdown) + return result.annual_return / (1.0 + dd_abs) + + +FITNESS_CLASSES = [RiskAdjustedReturnFitness] diff --git a/tests/test_fitness_risk_adjusted_return.py b/tests/test_fitness_risk_adjusted_return.py new file mode 100644 index 00000000..ac84cb40 --- /dev/null +++ b/tests/test_fitness_risk_adjusted_return.py @@ -0,0 +1,83 @@ +"""Tests for RiskAdjustedReturnFitness (issue #21).""" + +from __future__ import annotations + +import pytest + +from stratevo.fitness_plugins.base import BacktestResult +from stratevo.fitness_plugins.builtin.risk_adjusted_return import RiskAdjustedReturnFitness +from stratevo.fitness_plugins.registry import FitnessRegistry + + +@pytest.fixture() +def fitness() -> RiskAdjustedReturnFitness: + return RiskAdjustedReturnFitness() + + +def _result(**kwargs) -> BacktestResult: + defaults = dict(total_trades=50) + defaults.update(kwargs) + return BacktestResult(**defaults) + + +class TestRiskAdjustedReturnBasics: + def test_name_and_direction(self, fitness: RiskAdjustedReturnFitness) -> None: + assert fitness.name == "risk_adjusted_return" + assert fitness.direction == "maximize" + + def test_formula_standard(self, fitness: RiskAdjustedReturnFitness) -> None: + r = _result(annual_return=0.20, max_drawdown=-0.10) + assert fitness.evaluate(r) == pytest.approx(0.20 / 1.10) + + def test_formula_positive_drawdown_convention(self, fitness: RiskAdjustedReturnFitness) -> None: + """max_drawdown stored as positive by some callers — abs() handles it.""" + r = _result(annual_return=0.20, max_drawdown=0.10) + assert fitness.evaluate(r) == pytest.approx(0.20 / 1.10) + + +class TestRiskAdjustedReturnRanking: + def test_drawdown_penalises_high_return(self, fitness: RiskAdjustedReturnFitness) -> None: + """30% return dragged down by 90% drawdown should lose to 20% / 10% DD. + + 20% / 10% DD → 0.20 / 1.10 ≈ 0.182 + 30% / 90% DD → 0.30 / 1.90 ≈ 0.158 + """ + low_dd = _result(annual_return=0.20, max_drawdown=-0.10) + high_dd = _result(annual_return=0.30, max_drawdown=-0.90) + assert fitness.evaluate(low_dd) > fitness.evaluate(high_dd) + + def test_same_return_lower_drawdown_wins(self, fitness: RiskAdjustedReturnFitness) -> None: + same_return_low_dd = _result(annual_return=0.20, max_drawdown=-0.10) + same_return_high_dd = _result(annual_return=0.20, max_drawdown=-0.50) + assert fitness.evaluate(same_return_low_dd) > fitness.evaluate(same_return_high_dd) + + def test_is_better_higher_is_better(self, fitness: RiskAdjustedReturnFitness) -> None: + assert fitness.is_better(0.20, 0.10) is True + assert fitness.is_better(0.10, 0.20) is False + + +class TestRiskAdjustedReturnEdgeCases: + def test_zero_trades_penalty(self, fitness: RiskAdjustedReturnFitness) -> None: + r = BacktestResult(total_trades=0) + assert fitness.evaluate(r) == -1.0 + + def test_zero_drawdown(self, fitness: RiskAdjustedReturnFitness) -> None: + r = _result(annual_return=0.15, max_drawdown=0.0) + assert fitness.evaluate(r) == pytest.approx(0.15) + + def test_negative_return(self, fitness: RiskAdjustedReturnFitness) -> None: + r = _result(annual_return=-0.10, max_drawdown=-0.20) + assert fitness.evaluate(r) == pytest.approx(-0.10 / 1.20) + + def test_large_drawdown(self, fitness: RiskAdjustedReturnFitness) -> None: + r = _result(annual_return=0.50, max_drawdown=-0.80) + assert fitness.evaluate(r) == pytest.approx(0.50 / 1.80) + + +class TestRiskAdjustedReturnRegistry: + def test_auto_discover_registers_it(self) -> None: + reg = FitnessRegistry() + reg.auto_discover() + assert reg.has("risk_adjusted_return") + fn = reg.get("risk_adjusted_return") + assert isinstance(fn, RiskAdjustedReturnFitness)