Skip to content

Commit 0b356d6

Browse files
committed
Add multiple strategies combinations for backtesting
1 parent 43b08c6 commit 0b356d6

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+2843
-1514
lines changed

examples/bitvavo_trading_bot.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from dotenv import load_dotenv
2+
from typing import Dict, Any
23
import logging.config
34

45
from investing_algorithm_framework import TimeUnit, TradingStrategy, \
@@ -27,7 +28,7 @@ class BitvavoTradingStrategy(TradingStrategy):
2728
DataSource(data_type="Ticker", market="bitvavo", symbol="BTC/EUR", identifier="BTC/EUR-ticker")
2829
]
2930

30-
def run_strategy(self, context: Context, data):
31+
def run_strategy(self, context: Context, data: Dict[str, Any]):
3132
print(data["BTC/EUR-ohlcv"])
3233
print(data["BTC/EUR-ticker"])
3334

examples/coinbase_trading_bot.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from dotenv import load_dotenv
2+
from typing import Dict, Any
23
import logging.config
4+
35
from investing_algorithm_framework import TimeUnit, \
46
DataSource, TradingStrategy, create_app, DEFAULT_LOGGING_CONFIG, Context
57
"""
@@ -22,7 +24,7 @@ class CoinbaseTradingStrategy(TradingStrategy):
2224
DataSource(data_type="Ticker", market="coinbase", symbol="BTC/EUR", identifier="BTC/EUR-ticker")
2325
]
2426

25-
def apply_strategy(self, context: Context, data):
27+
def run_strategy(self, context: Context, data: Dict[str, Any]):
2628
print(data["BTC/EUR-ohlcv"])
2729
print(data["BTC/EUR-ticker"])
2830

examples/tutorial/strategies/ema_crossover_rsi_filter/strategy.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class EMACrossoverRSIFFilterStrategy(TradingStrategy):
1212

1313
def __init__(
1414
self,
15+
symbols: list[str],
1516
rsi_timeframe: str,
1617
rsi_period: int,
1718
rsi_overbought_threshold,
@@ -30,6 +31,7 @@ def __init__(
3031
market: str = "BITVAVO",
3132
metadata: dict = None
3233
):
34+
self.symbols = symbols
3335
self.rsi_timeframe = rsi_timeframe
3436
self.rsi_period = rsi_period
3537
self.rsi_result_column = rsi_result_column

investing_algorithm_framework/__init__.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
Trade, SYMBOLS, RESERVED_BALANCES, APP_MODE, AppMode, DATETIME_FORMAT, \
1717
BacktestDateRange, convert_polars_to_pandas, \
1818
DEFAULT_LOGGING_CONFIG, DataType, DataProvider, \
19-
BacktestResult, TradeStatus, TradeRiskType, \
19+
TradeStatus, TradeRiskType, \
2020
APPLICATION_DIRECTORY, DataSource, OrderExecutor, PortfolioProvider, \
2121
SnapshotInterval, AWS_S3_STATE_BUCKET_NAME, BacktestEvaluationFocus
2222
from .infrastructure import AzureBlobStorageStateHandler, \
@@ -30,7 +30,7 @@
3030
get_profit_factor, get_cumulative_profit_factor_series, \
3131
get_rolling_profit_factor_series, get_cagr, \
3232
get_standard_deviation_returns, get_standard_deviation_downside_returns, \
33-
get_max_drawdown_absolute, get_total_return, get_exposure, \
33+
get_max_drawdown_absolute, get_total_return, get_exposure_ratio, \
3434
get_average_trade_duration, get_win_rate, get_win_loss_ratio, \
3535
get_calmar_ratio, get_trade_frequency, get_yearly_returns, \
3636
get_monthly_returns, get_best_year, get_best_month, get_worst_year, \
@@ -41,7 +41,7 @@
4141
get_trades_per_year, get_average_monthly_return_losing_months, \
4242
get_average_monthly_return_winning_months, get_percentage_winning_years, \
4343
get_rolling_sharpe_ratio, create_backtest_metrics, get_growth, \
44-
get_growth_percentage
44+
get_growth_percentage, get_cumulative_exposure
4545

4646

4747
__all__ = [
@@ -75,7 +75,6 @@
7575
"AppMode",
7676
"DATETIME_FORMAT",
7777
"Backtest",
78-
"BacktestResult",
7978
"BacktestDateRange",
8079
"convert_polars_to_pandas",
8180
"AzureBlobStorageStateHandler",
@@ -118,7 +117,8 @@
118117
"get_standard_deviation_downside_returns",
119118
"get_max_drawdown_absolute",
120119
"get_total_return",
121-
"get_exposure",
120+
"get_exposure_ratio",
121+
"get_cumulative_exposure",
122122
"get_average_trade_duration",
123123
"get_win_rate",
124124
"get_win_loss_ratio",
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
from .backtest_data_ranges import select_backtest_date_ranges
2-
from .ranking import rank_results, create_weights
2+
from .ranking import rank_results, create_weights, combine_backtest_metrics
33
from .permutation import create_ohlcv_permutation
44

55
__all__ = [
66
"select_backtest_date_ranges",
77
"rank_results",
88
"create_weights",
9-
"create_ohlcv_permutation"
9+
"create_ohlcv_permutation",
10+
"combine_backtest_metrics"
1011
]

investing_algorithm_framework/app/analysis/ranking.py

Lines changed: 159 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import math
2+
from typing import List
3+
from statistics import mean
24

3-
from investing_algorithm_framework.domain import BacktestEvaluationFocus
4-
BacktestEvaluationFocus
5+
from investing_algorithm_framework.domain import BacktestEvaluationFocus, \
6+
BacktestDateRange, Backtest, BacktestMetrics, OperationalException
57

68

79
def normalize(value, min_val, max_val):
@@ -61,9 +63,6 @@ def create_weights(
6163
6264
Args:
6365
focus (BacktestEvaluationFocus | str | None): The focus for ranking.
64-
gain (float): Weight for total_net_gain (default only).
65-
win_rate (float): Weight for win_rate (default only).
66-
trades (float): Weight for number_of_trades (default only).
6766
custom_weights (dict): Full override for weights (all metrics).
6867
If provided, it takes precedence over presets.
6968
@@ -82,12 +81,18 @@ def create_weights(
8281
return weights
8382

8483

85-
def rank_results(backtests, focus=None, weights=None, filter_fn=None):
84+
def rank_results(
85+
backtests: List[Backtest],
86+
focus=None,
87+
weights=None,
88+
filter_fn=None,
89+
backtest_date_range: BacktestDateRange = None
90+
) -> List[Backtest]:
8691
"""
8792
Rank backtest results based on specified focus, weights, and filters.
8893
8994
Args:
90-
backtests (list): List of backtest results to rank.
95+
backtests (List[Backtest]): List of backtest results to rank.
9196
focus (str, optional): Focus for ranking. If None,
9297
uses default weights. Options: "balanced", "profit",
9398
"frequency", "risk_adjusted".
@@ -98,45 +103,178 @@ def rank_results(backtests, focus=None, weights=None, filter_fn=None):
98103
- If callable: receives metrics and should return True/False.
99104
- If dict: mapping {metric_name: condition_fn},
100105
all conditions must pass.
106+
backtest_date_range (BacktestDateRange, optional): If provided,
107+
only backtests matching this date range are considered.
101108
102109
Returns:
103-
list: Sorted list of backtests based on computed scores.
110+
List[Backtest]: Sorted list of backtests based on computed scores.
104111
"""
105112

106113
if weights is None:
107114
weights = create_weights(focus=focus)
108115

109-
# Apply filtering
116+
# Pair backtests with their metrics
117+
paired = []
118+
for backtest in backtests:
119+
if backtest_date_range is not None:
120+
metrics = backtest.get_backtest_metrics(backtest_date_range)
121+
else:
122+
metrics = backtest.backtest_summary
123+
124+
if metrics is not None:
125+
paired.append((backtest, metrics))
126+
127+
# Apply filtering on metrics
110128
if filter_fn is not None:
111129
if callable(filter_fn):
112-
backtests = [
113-
bt for bt in backtests
114-
if filter_fn(bt.backtest_metrics)
130+
paired = [
131+
(bt, m) for bt, m in paired if filter_fn(m)
115132
]
116133
elif isinstance(filter_fn, dict):
117-
backtests = [
118-
bt for bt in backtests
134+
paired = [
135+
(bt, m) for bt, m in paired
119136
if all(
120-
cond(getattr(bt.backtest_metrics, key, None))
137+
cond(getattr(m, key, None))
121138
for key, cond in filter_fn.items()
122139
)
123140
]
124141

125-
# First compute metric ranges for normalization
142+
# Compute normalization ranges
126143
ranges = {}
127144
for key in weights:
128-
values = [getattr(bt.backtest_metrics, key, None) for bt in backtests]
145+
values = [
146+
getattr(m, key, None) for _, m in paired
147+
]
129148
values = [
130149
v for v in values
131150
if isinstance(v, (int, float)) and v is not None
132151
and not math.isnan(v) and not math.isinf(v)
133152
]
134-
135153
if values:
136154
ranges[key] = (min(values), max(values))
137155

138-
return sorted(
139-
backtests,
140-
key=lambda bt: compute_score(bt.backtest_metrics, weights, ranges),
156+
# Sort Backtests by score
157+
ranked = sorted(
158+
paired,
159+
key=lambda bm: compute_score(bm[1], weights, ranges),
141160
reverse=True
142161
)
162+
163+
return [bt for bt, _ in ranked]
164+
165+
166+
def combine_backtest_metrics(
167+
backtest_metrics: List[BacktestMetrics]
168+
) -> BacktestMetrics:
169+
"""
170+
Combine backtest metrics from multiple backtests into a single list.
171+
172+
Args:
173+
backtest_metrics (List[BacktestMetrics]): List of backtest
174+
metrics to combine.
175+
176+
Returns:
177+
BacktestMetrics: Combined list of backtest metrics.
178+
"""
179+
if not backtest_metrics:
180+
raise OperationalException("No BacktestMetrics provided")
181+
182+
# Helper to take mean safely
183+
184+
def safe_mean(values):
185+
vals = [v for v in values if v is not None]
186+
return mean(vals) if vals else 0.0
187+
188+
# Dates
189+
190+
start_date = min(m.backtest_start_date for m in backtest_metrics)
191+
end_date = max(m.backtest_end_date for m in backtest_metrics)
192+
193+
# Aggregate
194+
return BacktestMetrics(
195+
backtest_start_date=start_date,
196+
backtest_end_date=end_date,
197+
equity_curve=[], # leave empty to avoid misleading curves
198+
growth=safe_mean([m.growth for m in backtest_metrics]),
199+
growth_percentage=safe_mean(
200+
[m.growth_percentage for m in backtest_metrics]),
201+
total_net_gain=safe_mean([m.total_net_gain for m in backtest_metrics]),
202+
total_net_gain_percentage=safe_mean(
203+
[m.total_net_gain_percentage for m in backtest_metrics]),
204+
final_value=safe_mean([m.final_value for m in backtest_metrics]),
205+
cagr=safe_mean([m.cagr for m in backtest_metrics]),
206+
sharpe_ratio=safe_mean([m.sharpe_ratio for m in backtest_metrics]),
207+
rolling_sharpe_ratio=[],
208+
sortino_ratio=safe_mean([m.sortino_ratio for m in backtest_metrics]),
209+
calmar_ratio=safe_mean([m.calmar_ratio for m in backtest_metrics]),
210+
profit_factor=safe_mean([m.profit_factor for m in backtest_metrics]),
211+
gross_profit=sum(m.gross_profit or 0 for m in backtest_metrics),
212+
gross_loss=sum(m.gross_loss or 0 for m in backtest_metrics),
213+
annual_volatility=safe_mean(
214+
[m.annual_volatility for m in backtest_metrics]),
215+
monthly_returns=[],
216+
yearly_returns=[],
217+
drawdown_series=[],
218+
max_drawdown=max(m.max_drawdown for m in backtest_metrics),
219+
max_drawdown_absolute=max(
220+
m.max_drawdown_absolute for m in backtest_metrics),
221+
max_daily_drawdown=max(m.max_daily_drawdown for m in backtest_metrics),
222+
max_drawdown_duration=max(
223+
m.max_drawdown_duration for m in backtest_metrics),
224+
trades_per_year=safe_mean(
225+
[m.trades_per_year for m in backtest_metrics]
226+
),
227+
trade_per_day=safe_mean([m.trade_per_day for m in backtest_metrics]),
228+
exposure_ratio=safe_mean(
229+
[m.exposure_ratio for m in backtest_metrics]
230+
),
231+
trades_average_gain=safe_mean(
232+
[m.trades_average_gain for m in backtest_metrics]),
233+
trades_average_loss=safe_mean(
234+
[m.trades_average_loss for m in backtest_metrics]),
235+
best_trade=max((
236+
m.best_trade for m in backtest_metrics if m.best_trade),
237+
key=lambda t: t.net_gain if t else float('-inf'),
238+
default=None
239+
),
240+
worst_trade=min(
241+
(m.worst_trade for m in backtest_metrics if m.worst_trade),
242+
key=lambda t: t.net_gain if t else float('inf'),
243+
default=None
244+
),
245+
average_trade_duration=safe_mean(
246+
[m.average_trade_duration for m in backtest_metrics]),
247+
number_of_trades=sum(m.number_of_trades for m in backtest_metrics),
248+
win_rate=safe_mean([m.win_rate for m in backtest_metrics]),
249+
win_loss_ratio=safe_mean([m.win_loss_ratio for m in backtest_metrics]),
250+
percentage_winning_months=safe_mean(
251+
[m.percentage_winning_months for m in backtest_metrics]),
252+
percentage_winning_years=safe_mean(
253+
[m.percentage_winning_years for m in backtest_metrics]),
254+
average_monthly_return=safe_mean(
255+
[m.average_monthly_return for m in backtest_metrics]),
256+
average_monthly_return_losing_months=safe_mean(
257+
[m.average_monthly_return_losing_months for m in backtest_metrics]
258+
),
259+
average_monthly_return_winning_months=safe_mean(
260+
[m.average_monthly_return_winning_months for m in backtest_metrics]
261+
),
262+
best_month=max(
263+
(m.best_month for m in backtest_metrics if m.best_month),
264+
key=lambda x: x[0] if x else float('-inf'),
265+
default=None
266+
),
267+
best_year=max((m.best_year for m in backtest_metrics if m.best_year),
268+
key=lambda x: x[0] if x else float('-inf'),
269+
default=None),
270+
worst_month=min(
271+
(m.worst_month for m in backtest_metrics if m.worst_month),
272+
key=lambda x: x[0] if x else float('inf'),
273+
default=None
274+
),
275+
worst_year=min(
276+
(m.worst_year for m in backtest_metrics if m.worst_year),
277+
key=lambda x: x[0] if x else float('inf'),
278+
default=None
279+
),
280+
)

0 commit comments

Comments
 (0)