Skip to content

Commit 59463ed

Browse files
committed
feat: extract consistency and stability score functions into reusable metrics module
- Move CV consistency, normalized stability, consistency score, and stability score functions from inline code in combine_backtests.py into domain/backtesting/consistency.py - Re-export from services/metrics and top-level package - Update combine_backtests.py to use the extracted functions
1 parent e6bdbbf commit 59463ed

6 files changed

Lines changed: 197 additions & 82 deletions

File tree

investing_algorithm_framework/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,9 @@
6767
get_current_average_trade_loss, get_negative_trades, \
6868
get_positive_trades, get_number_of_trades, get_current_win_rate, \
6969
get_current_win_loss_ratio, create_backtest_metrics_for_backtest, \
70-
recalculate_backtests, TradeTakeProfitService, TradeStopLossService
70+
recalculate_backtests, TradeTakeProfitService, TradeStopLossService, \
71+
get_cv_consistency, get_normalized_stability, \
72+
get_consistency_score, get_stability_score
7173

7274

7375
__all__ = [
@@ -255,6 +257,10 @@
255257
"FXRateProvider",
256258
"StaticFXRateProvider",
257259
"load_ipython_extension",
260+
"get_cv_consistency",
261+
"get_normalized_stability",
262+
"get_consistency_score",
263+
"get_stability_score",
258264
]
259265

260266

investing_algorithm_framework/domain/backtesting/combine_backtests.py

Lines changed: 26 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
import math
33
from typing import List
44

5+
from .consistency import (
6+
get_cv_consistency, get_normalized_stability,
7+
get_consistency_score, get_stability_score,
8+
)
59
from .backtest_metrics import BacktestMetrics
610
from .backtest_summary_metrics import BacktestSummaryMetrics
711

@@ -441,55 +445,25 @@ def generate_backtest_summary_metrics(
441445
) if consecutive_losses else None
442446

443447
# === CONSISTENCY METRICS ===
444-
# Two complementary approaches to measure cross-window stability.
445-
#
446-
# 1) CV-based consistency: 1 - CV (CV = std / |mean|), capped [0, 1].
447-
# Standard statistical measure; scale-invariant.
448-
# Drawback: undefined when mean ≈ 0.
449-
#
450-
# 2) Normalized-std stability: 1 - std/max_std, capped [0, 1].
451-
# Uses a domain-specific max_std for normalization.
452-
# More intuitive for bounded metrics (win rate 0-100,
453-
# Sharpe typically -2 to +4).
454-
455448
return_consistency = None
456449
win_rate_consistency = None
457450
sharpe_consistency = None
458-
consistency_score = None
451+
consistency_score_val = None
459452
return_stability = None
460453
win_rate_stability = None
461454
sharpe_stability = None
462-
stability_score = None
463-
464-
def _cv_consistency(values):
465-
"""1 - CV capped to [0, 1], or None if insufficient data."""
466-
if len(values) < 2:
467-
return None
468-
mean = sum(values) / len(values)
469-
if abs(mean) < 1e-9:
470-
return 0.0 # mean ≈ 0 → unstable
471-
var = sum((x - mean) ** 2 for x in values) / (len(values) - 1)
472-
cv = math.sqrt(var) / abs(mean)
473-
return max(0.0, min(1.0, 1.0 - cv))
474-
475-
def _norm_stability(values, max_std):
476-
"""1 - std/max_std capped to [0, 1], or None if insufficient."""
477-
if len(values) < 2:
478-
return None
479-
mean = sum(values) / len(values)
480-
var = sum((x - mean) ** 2 for x in values) / (len(values) - 1)
481-
std = math.sqrt(var)
482-
return max(0.0, min(1.0, 1.0 - std / max_std))
455+
stability_score_val = None
483456

484457
if len(valid_metrics) >= 2:
485458
# --- Per-window returns ---
486459
per_window_returns = [
487460
b.total_net_gain_percentage for b in valid_metrics
488461
if b.total_net_gain_percentage is not None
489462
]
490-
return_consistency = _cv_consistency(per_window_returns)
491-
# max_std = 100: a std of 100% of initial capital → score 0
492-
return_stability = _norm_stability(per_window_returns, 100.0)
463+
return_consistency = get_cv_consistency(per_window_returns)
464+
return_stability = get_normalized_stability(
465+
per_window_returns, 100.0
466+
)
493467

494468
# --- Per-window win rates ---
495469
per_window_win_rates = [
@@ -498,10 +472,10 @@ def _norm_stability(values, max_std):
498472
and b.number_of_trades_closed is not None
499473
and b.number_of_trades_closed > 0
500474
]
501-
return_consistency = _cv_consistency(per_window_returns)
502-
win_rate_consistency = _cv_consistency(per_window_win_rates)
503-
# max_std = 50: theoretical max std for a [0, 100] range
504-
win_rate_stability = _norm_stability(per_window_win_rates, 50.0)
475+
win_rate_consistency = get_cv_consistency(per_window_win_rates)
476+
win_rate_stability = get_normalized_stability(
477+
per_window_win_rates, 50.0
478+
)
505479

506480
# --- Per-window Sharpe ratios ---
507481
per_window_sharpe = [
@@ -510,45 +484,19 @@ def _norm_stability(values, max_std):
510484
and not math.isnan(b.sharpe_ratio)
511485
and not math.isinf(b.sharpe_ratio)
512486
]
513-
sharpe_consistency = _cv_consistency(per_window_sharpe)
514-
# max_std = 2: Sharpe ratios typically range -2 to +4;
515-
# a std of 2 means wildly inconsistent
516-
sharpe_stability = _norm_stability(per_window_sharpe, 2.0)
487+
sharpe_consistency = get_cv_consistency(per_window_sharpe)
488+
sharpe_stability = get_normalized_stability(
489+
per_window_sharpe, 2.0
490+
)
517491

518492
# --- Composite scores ---
519-
# Both use the same weighting scheme:
520-
# 35% returns, 25% win rate, 20% Sharpe, 20% profitable
521-
# window ratio.
522-
def _composite(ret_c, wr_c, sh_c):
523-
components = []
524-
weights_c = []
525-
if ret_c is not None:
526-
components.append(ret_c)
527-
weights_c.append(0.35)
528-
if wr_c is not None:
529-
components.append(wr_c)
530-
weights_c.append(0.25)
531-
if sh_c is not None:
532-
components.append(sh_c)
533-
weights_c.append(0.20)
534-
if number_of_windows and number_of_windows > 0:
535-
pw_ratio = (
536-
number_of_profitable_windows / number_of_windows
537-
)
538-
components.append(pw_ratio)
539-
weights_c.append(0.20)
540-
if not components:
541-
return None
542-
total_w = sum(weights_c)
543-
return sum(
544-
c * w for c, w in zip(components, weights_c)
545-
) / total_w
546-
547-
consistency_score = _composite(
548-
return_consistency, win_rate_consistency, sharpe_consistency
493+
consistency_score_val = get_consistency_score(
494+
return_consistency, win_rate_consistency, sharpe_consistency,
495+
number_of_profitable_windows, number_of_windows,
549496
)
550-
stability_score = _composite(
551-
return_stability, win_rate_stability, sharpe_stability
497+
stability_score_val = get_stability_score(
498+
return_stability, win_rate_stability, sharpe_stability,
499+
number_of_profitable_windows, number_of_windows,
552500
)
553501

554502
return BacktestSummaryMetrics(
@@ -602,9 +550,9 @@ def _composite(ret_c, wr_c, sh_c):
602550
return_consistency=return_consistency,
603551
win_rate_consistency=win_rate_consistency,
604552
sharpe_consistency=sharpe_consistency,
605-
consistency_score=consistency_score,
553+
consistency_score=consistency_score_val,
606554
return_stability=return_stability,
607555
win_rate_stability=win_rate_stability,
608556
sharpe_stability=sharpe_stability,
609-
stability_score=stability_score,
557+
stability_score=stability_score_val,
610558
)
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import math
2+
3+
4+
def get_cv_consistency(values):
5+
"""
6+
CV-based consistency: 1 - CV (CV = std / |mean|), capped [0, 1].
7+
8+
Standard statistical measure; scale-invariant.
9+
Returns None if fewer than 2 values.
10+
Returns 0.0 when mean ≈ 0 (unstable).
11+
12+
Args:
13+
values: list of numeric values (e.g. per-window returns)
14+
15+
Returns:
16+
float in [0, 1] or None
17+
"""
18+
if len(values) < 2:
19+
return None
20+
mean = sum(values) / len(values)
21+
if abs(mean) < 1e-9:
22+
return 0.0
23+
var = sum((x - mean) ** 2 for x in values) / (len(values) - 1)
24+
cv = math.sqrt(var) / abs(mean)
25+
return max(0.0, min(1.0, 1.0 - cv))
26+
27+
28+
def get_normalized_stability(values, max_std):
29+
"""
30+
Normalized-std stability: 1 - std/max_std, capped [0, 1].
31+
32+
Uses a domain-specific max_std for normalization.
33+
More intuitive for bounded metrics (win rate 0-100,
34+
Sharpe typically -2 to +4).
35+
Returns None if fewer than 2 values.
36+
37+
Args:
38+
values: list of numeric values (e.g. per-window win rates)
39+
max_std: domain-specific maximum standard deviation for
40+
normalization
41+
42+
Returns:
43+
float in [0, 1] or None
44+
"""
45+
if len(values) < 2:
46+
return None
47+
mean = sum(values) / len(values)
48+
var = sum((x - mean) ** 2 for x in values) / (len(values) - 1)
49+
std = math.sqrt(var)
50+
return max(0.0, min(1.0, 1.0 - std / max_std))
51+
52+
53+
def get_consistency_score(
54+
return_consistency,
55+
win_rate_consistency,
56+
sharpe_consistency,
57+
number_of_profitable_windows=None,
58+
number_of_windows=None,
59+
):
60+
"""
61+
Composite consistency score using weighted components:
62+
35% returns, 25% win rate, 20% Sharpe, 20% profitable window ratio.
63+
64+
Args:
65+
return_consistency: CV consistency of per-window returns
66+
win_rate_consistency: CV consistency of per-window win rates
67+
sharpe_consistency: CV consistency of per-window Sharpe ratios
68+
number_of_profitable_windows: count of profitable windows
69+
number_of_windows: total number of windows
70+
71+
Returns:
72+
float in [0, 1] or None
73+
"""
74+
return _composite(
75+
return_consistency,
76+
win_rate_consistency,
77+
sharpe_consistency,
78+
number_of_profitable_windows,
79+
number_of_windows,
80+
)
81+
82+
83+
def get_stability_score(
84+
return_stability,
85+
win_rate_stability,
86+
sharpe_stability,
87+
number_of_profitable_windows=None,
88+
number_of_windows=None,
89+
):
90+
"""
91+
Composite stability score using weighted components:
92+
35% returns, 25% win rate, 20% Sharpe, 20% profitable window ratio.
93+
94+
Args:
95+
return_stability: normalized stability of per-window returns
96+
win_rate_stability: normalized stability of per-window win rates
97+
sharpe_stability: normalized stability of per-window Sharpe ratios
98+
number_of_profitable_windows: count of profitable windows
99+
number_of_windows: total number of windows
100+
101+
Returns:
102+
float in [0, 1] or None
103+
"""
104+
return _composite(
105+
return_stability,
106+
win_rate_stability,
107+
sharpe_stability,
108+
number_of_profitable_windows,
109+
number_of_windows,
110+
)
111+
112+
113+
def _composite(
114+
ret_c, wr_c, sh_c,
115+
number_of_profitable_windows=None,
116+
number_of_windows=None,
117+
):
118+
components = []
119+
weights_c = []
120+
if ret_c is not None:
121+
components.append(ret_c)
122+
weights_c.append(0.35)
123+
if wr_c is not None:
124+
components.append(wr_c)
125+
weights_c.append(0.25)
126+
if sh_c is not None:
127+
components.append(sh_c)
128+
weights_c.append(0.20)
129+
if number_of_windows and number_of_windows > 0 \
130+
and number_of_profitable_windows is not None:
131+
pw_ratio = number_of_profitable_windows / number_of_windows
132+
components.append(pw_ratio)
133+
weights_c.append(0.20)
134+
if not components:
135+
return None
136+
total_w = sum(weights_c)
137+
return sum(c * w for c, w in zip(components, weights_c)) / total_w

investing_algorithm_framework/services/__init__.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@
3838
get_current_win_rate, get_current_average_trade_return, \
3939
get_current_average_trade_loss, get_current_average_trade_duration, \
4040
get_current_average_trade_gain, create_backtest_metrics_for_backtest, \
41-
recalculate_backtests
41+
recalculate_backtests, get_cv_consistency, get_normalized_stability, \
42+
get_consistency_score, get_stability_score
4243

4344
__all__ = [
4445
"get_mean_daily_return",
@@ -134,5 +135,9 @@
134135
"recalculate_backtests",
135136
"TradeStopLossService",
136137
"TradeTakeProfitService",
137-
"get_mean_yearly_return"
138+
"get_mean_yearly_return",
139+
"get_cv_consistency",
140+
"get_normalized_stability",
141+
"get_consistency_score",
142+
"get_stability_score"
138143
]

investing_algorithm_framework/services/metrics/__init__.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@
4040
get_average_trade_duration
4141
from .mean_daily_return import get_mean_daily_return, get_mean_yearly_return
4242
from .standard_deviation import get_daily_returns_std
43+
from .consistency import get_cv_consistency, get_normalized_stability, \
44+
get_consistency_score, get_stability_score
4345

4446
__all__ = [
4547
"get_mean_daily_return",
@@ -115,5 +117,9 @@
115117
"get_number_of_open_trades",
116118
"get_average_trade_duration",
117119
"create_backtest_metrics_for_backtest",
118-
"get_mean_yearly_return"
120+
"get_mean_yearly_return",
121+
"get_cv_consistency",
122+
"get_normalized_stability",
123+
"get_consistency_score",
124+
"get_stability_score"
119125
]
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from investing_algorithm_framework.domain.backtesting.consistency import (
2+
get_cv_consistency,
3+
get_normalized_stability,
4+
get_consistency_score,
5+
get_stability_score,
6+
)
7+
8+
__all__ = [
9+
"get_cv_consistency",
10+
"get_normalized_stability",
11+
"get_consistency_score",
12+
"get_stability_score",
13+
]

0 commit comments

Comments
 (0)