|
1 | | -from investing_algorithm_framework.domain import Backtest |
| 1 | +default_weights = { |
| 2 | + # Profitability |
| 3 | + "total_net_gain": 3.0, |
| 4 | + "total_net_loss": 0.0, |
| 5 | + "total_return": 0.0, |
| 6 | + "avg_return_per_trade": 0.0, |
2 | 7 |
|
3 | | -defaults_ranking_weights = { |
4 | | - "total_net_gain": 2.0, |
| 8 | + # Risk-adjusted returns |
5 | 9 | "sharpe_ratio": 1.0, |
6 | 10 | "sortino_ratio": 1.0, |
7 | | - "win_rate": 1.0, |
8 | 11 | "profit_factor": 1.0, |
9 | | - "max_drawdown": -1.0, # negative weight to penalize high drawdown |
10 | | - "max_drawdown_duration": -0.5, # penalize long drawdown periods |
11 | | - "number_of_trades": 0.5, |
| 12 | + |
| 13 | + # Risk |
| 14 | + "max_drawdown": -2.0, |
| 15 | + "max_drawdown_duration": -0.5, |
| 16 | + |
| 17 | + # Trading activity |
| 18 | + "number_of_trades": 2.0, |
| 19 | + "win_rate": 3.0, |
| 20 | + |
| 21 | + # Exposure |
12 | 22 | "exposure_factor": 0.5, |
| 23 | + "exposure_ratio": 0.0, |
| 24 | + "exposure_time": 0.0, |
13 | 25 | } |
14 | 26 |
|
15 | 27 |
|
16 | | -def compute_score(metrics: dict, weights: dict) -> float: |
| 28 | +def normalize(value, min_val, max_val): |
| 29 | + """ |
| 30 | + Normalize a value to a range [0, 1]. |
| 31 | +
|
| 32 | + Args: |
| 33 | + value (float): The value to normalize. |
| 34 | + min_val (float): The minimum value of the range. |
| 35 | + max_val (float): The maximum value of the range. |
| 36 | +
|
| 37 | + Returns: |
| 38 | + float: The normalized value. |
| 39 | + """ |
| 40 | + if max_val == min_val: |
| 41 | + return 0 |
| 42 | + return (value - min_val) / (max_val - min_val) |
| 43 | + |
| 44 | + |
| 45 | +def compute_score(metrics, weights, ranges): |
| 46 | + """ |
| 47 | + Compute a weighted score for the given metrics. |
| 48 | +
|
| 49 | + Args: |
| 50 | + metrics: The metrics to evaluate. |
| 51 | + weights: The weights to apply to each metric. |
| 52 | + ranges: The min/max ranges for each metric. |
| 53 | +
|
| 54 | + Returns: |
| 55 | + float: The computed score. |
| 56 | + """ |
17 | 57 | score = 0 |
18 | 58 | for key, weight in weights.items(): |
19 | 59 |
|
20 | | - # Metrics are attributes to the backtest |
21 | 60 | if not hasattr(metrics, key): |
22 | 61 | continue |
23 | | - |
24 | | - # Get the value of the metric |
25 | 62 | value = getattr(metrics, key) |
26 | 63 |
|
27 | | - try: |
28 | | - score += weight * value |
29 | | - except TypeError: |
30 | | - continue # skip if value is not a number |
| 64 | + if key in ranges: |
| 65 | + value = normalize(value, ranges[key][0], ranges[key][1]) |
| 66 | + score += weight * value |
31 | 67 | return score |
32 | 68 |
|
33 | 69 |
|
34 | | -def rank_results( |
35 | | - backtests: list[Backtest], weights=defaults_ranking_weights |
36 | | -) -> list[Backtest]: |
| 70 | +def create_weights( |
| 71 | + focus: str = "balanced", |
| 72 | + gain: float = 3.0, |
| 73 | + win_rate: float = 3.0, |
| 74 | + trades: float = 2.0, |
| 75 | + custom_weights: dict | None = None, |
| 76 | +) -> dict: |
| 77 | + """ |
| 78 | + Utility to generate weights dicts for ranking backtests. |
| 79 | +
|
| 80 | + This function does not assign weights to every possible performance |
| 81 | + metric. Instead, it focuses on a curated subset of commonly relevant |
| 82 | + ones (profitability, win rate, trade frequency, and risk-adjusted returns). |
| 83 | + The rationale is to avoid overfitting ranking logic to noisy or redundant |
| 84 | + statistics (e.g., monthly return breakdowns, best/worst trade), while |
| 85 | + keeping the weighting system simple and interpretable. |
| 86 | + Users who need fine-grained control can pass `custom_weights` to fully |
| 87 | + override defaults. |
| 88 | +
|
| 89 | + Args: |
| 90 | + focus (str): One of [ |
| 91 | + "balanced", "profit", "frequency", "risk_adjusted" |
| 92 | + ]. |
| 93 | + gain (float): Weight for total_net_gain (default only). |
| 94 | + win_rate (float): Weight for win_rate (default only). |
| 95 | + trades (float): Weight for number_of_trades (default only). |
| 96 | + custom_weights (dict): Full override for weights (all metrics). |
| 97 | + If provided, it takes precedence over presets. |
| 98 | +
|
| 99 | + Returns: |
| 100 | + dict: A dictionary of weights for ranking backtests. |
37 | 101 | """ |
38 | | - Rank backtests based on their metrics and the provided weights. |
39 | 102 |
|
40 | | - The default weights are defined in `defaults_ranking_weights`. |
41 | | - Please note that the weights should be adjusted based on the |
42 | | - specific analysis needs. You can modify the `weights` parameter |
43 | | - to include or exclude metrics as needed and reuse |
44 | | - the `defaults_ranking_weights` as a starting point. |
| 103 | + # default / balanced |
| 104 | + base = { |
| 105 | + "total_net_gain": gain, |
| 106 | + "win_rate": win_rate, |
| 107 | + "number_of_trades": trades, |
| 108 | + "sharpe_ratio": 1.0, |
| 109 | + "sortino_ratio": 1.0, |
| 110 | + "profit_factor": 1.0, |
| 111 | + "max_drawdown": -2.0, |
| 112 | + "max_drawdown_duration": -0.5, |
| 113 | + "total_net_loss": 0.0, |
| 114 | + "total_return": 0.0, |
| 115 | + "avg_return_per_trade": 0.0, |
| 116 | + "exposure_factor": 0.5, |
| 117 | + "exposure_ratio": 0.0, |
| 118 | + "exposure_time": 0.0, |
| 119 | + } |
45 | 120 |
|
| 121 | + # apply presets |
| 122 | + if focus == "profit": |
| 123 | + base.update({ |
| 124 | + "total_net_gain": 5.0, |
| 125 | + "win_rate": 2.0, |
| 126 | + "number_of_trades": 1.0, |
| 127 | + }) |
| 128 | + elif focus == "frequency": |
| 129 | + base.update({ |
| 130 | + "number_of_trades": 4.0, |
| 131 | + "win_rate": 2.0, |
| 132 | + "total_net_gain": 2.0, |
| 133 | + }) |
| 134 | + elif focus == "risk_adjusted": |
| 135 | + base.update({ |
| 136 | + "sharpe_ratio": 3.0, |
| 137 | + "sortino_ratio": 3.0, |
| 138 | + "max_drawdown": -3.0, |
| 139 | + }) |
| 140 | + |
| 141 | + # if full custom dict is given → override everything |
| 142 | + if custom_weights is not None: |
| 143 | + base = {**base, **custom_weights} |
| 144 | + |
| 145 | + return base |
| 146 | + |
| 147 | + |
| 148 | +def rank_results(backtests, focus=None, weights=None): |
| 149 | + """ |
| 150 | + Rank backtest results based on specified focus and weights. |
46 | 151 | Args: |
47 | | - backtests (list[Backtest]): List of Backtest objects to rank. |
48 | | - weights (dict): Weights for each metric to compute the score. |
| 152 | + backtests (list): List of backtest results to rank. |
| 153 | + focus (str, optional): Focus for ranking. If None, |
| 154 | + uses default weights. Options: "balanced", "profit", |
| 155 | + "frequency", "risk_adjusted". |
| 156 | + weights (dict, optional): Custom weights for ranking metrics. |
| 157 | + If None, uses default weights based on focus. |
49 | 158 |
|
50 | 159 | Returns: |
51 | | - list[Backtest]: List of Backtest objects sorted by |
52 | | - their computed score. |
| 160 | + list: Sorted list of backtests based on computed scores. |
53 | 161 | """ |
| 162 | + |
| 163 | + if weights is None: |
| 164 | + weights = create_weights(focus=focus) |
| 165 | + |
| 166 | + # First compute metric ranges for normalization |
| 167 | + ranges = {} |
| 168 | + for key in weights: |
| 169 | + values = [getattr(bt.backtest_metrics, key, None) for bt in backtests] |
| 170 | + values = [v for v in values if isinstance(v, (int, float))] |
| 171 | + if values: |
| 172 | + ranges[key] = (min(values), max(values)) |
| 173 | + |
54 | 174 | return sorted( |
55 | 175 | backtests, |
56 | | - key=lambda bt: compute_score(bt.backtest_metrics, weights), |
| 176 | + key=lambda bt: compute_score(bt.backtest_metrics, weights, ranges), |
57 | 177 | reverse=True |
58 | 178 | ) |
0 commit comments