Skip to content

Commit 22cd769

Browse files
committed
Fix metrics generation
1 parent d2a70bd commit 22cd769

File tree

6 files changed

+122
-43
lines changed

6 files changed

+122
-43
lines changed

investing_algorithm_framework/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
get_current_average_trade_gain, get_current_average_trade_duration, \
4949
get_current_average_trade_loss, get_negative_trades, \
5050
get_positive_trades, get_number_of_trades, get_current_win_rate, \
51-
get_current_win_loss_ratio
51+
get_current_win_loss_ratio, create_backtest_metrics_for_backtest
5252

5353

5454
__all__ = [
@@ -192,5 +192,6 @@
192192
"BacktestRun",
193193
"load_backtests_from_directory",
194194
"save_backtests_to_directory",
195-
"DataError"
195+
"DataError",
196+
"create_backtest_metrics_for_backtest"
196197
]

investing_algorithm_framework/domain/backtesting/combine_backtests.py

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1+
import logging
12
from typing import List
23

3-
from .backtest_summary_metrics import BacktestSummaryMetrics
4+
from .backtest import Backtest
45
from .backtest_metrics import BacktestMetrics
6+
from .backtest_summary_metrics import BacktestSummaryMetrics
7+
8+
logger = logging.getLogger("investing_algorithm_framework")
59

610

711
def safe_weighted_mean(values, weights):
@@ -41,22 +45,26 @@ def combine_backtests(backtests):
4145
backtest_runs = []
4246

4347
for backtest in backtests:
44-
backtest_metric = None
45-
backtest_run = backtest.backtest_runs[0] \
46-
if len(backtest.backtest_runs) > 0 else None
47-
48-
if backtest_run is not None:
49-
backtest_metric = backtest_run.backtest_metrics
50-
51-
if backtest_metric is not None:
52-
backtest_metrics.append(backtest_metric)
53-
backtest_runs.append(backtest_run)
48+
backtest_runs += backtest.get_all_backtest_runs()
49+
backtest_metrics += backtest.get_all_backtest_metrics()
5450

5551
summary = generate_backtest_summary_metrics(backtest_metrics)
5652

5753
metadata = None
5854
risk_free_rate = None
5955

56+
# Check if there are duplicate backtest runs
57+
unique_date_ranges = set()
58+
for backtest in backtests:
59+
for run in backtest.get_all_backtest_runs():
60+
date_range = (run.start_date, run.end_date)
61+
if date_range in unique_date_ranges:
62+
logger.warning(
63+
"Duplicate backtest run detected for date range: "
64+
f"{date_range} when combining backtests."
65+
)
66+
unique_date_ranges.add(date_range)
67+
6068
# Get first non-empty metadata
6169
for backtest in backtests:
6270
if backtest.metadata:
@@ -69,8 +77,6 @@ def combine_backtests(backtests):
6977
risk_free_rate = backtest.risk_free_rate
7078
break
7179

72-
from .backtest import Backtest
73-
7480
backtest = Backtest(
7581
backtest_summary=summary,
7682
metadata=metadata,

investing_algorithm_framework/services/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
get_positive_trades, get_negative_trades, get_number_of_trades, \
3838
get_current_win_rate, get_current_average_trade_return, \
3939
get_current_average_trade_loss, get_current_average_trade_duration, \
40-
get_current_average_trade_gain
40+
get_current_average_trade_gain, create_backtest_metrics_for_backtest
4141

4242
__all__ = [
4343
"OrderService",
@@ -128,4 +128,5 @@
128128
"get_current_average_trade_duration",
129129
"get_current_average_trade_gain",
130130
"get_current_average_trade_return",
131+
"create_backtest_metrics_for_backtest"
131132
]

investing_algorithm_framework/services/metrics/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@
2727
from .win_rate import get_win_rate, get_win_loss_ratio, get_current_win_rate, \
2828
get_current_win_loss_ratio
2929
from .calmar_ratio import get_calmar_ratio
30-
from .generate import create_backtest_metrics
30+
from .generate import create_backtest_metrics, \
31+
create_backtest_metrics_for_backtest
3132
from .risk_free_rate import get_risk_free_rate_us
3233
from .trades import get_negative_trades, get_positive_trades, \
3334
get_number_of_trades, get_number_of_closed_trades, \
@@ -109,4 +110,5 @@
109110
"get_current_average_trade_return",
110111
"get_number_of_open_trades",
111112
"get_average_trade_duration",
113+
"create_backtest_metrics_for_backtest"
112114
]

investing_algorithm_framework/services/metrics/generate.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from logging import getLogger
33

44
from investing_algorithm_framework.domain import BacktestMetrics, \
5-
BacktestRun, OperationalException
5+
BacktestRun, OperationalException, Backtest, BacktestDateRange
66
from .cagr import get_cagr
77
from .calmar_ratio import get_calmar_ratio
88
from .drawdown import get_drawdown_series, get_max_drawdown, \
@@ -35,6 +35,47 @@
3535

3636
logger = getLogger("investing_algorithm_framework")
3737

38+
def create_backtest_metrics_for_backtest(
39+
backtest: Backtest,
40+
risk_free_rate: float, metrics: List[str] = None,
41+
backtest_date_range: BacktestDateRange = None
42+
) -> Backtest:
43+
44+
"""
45+
Create BacktestMetrics for a Backtest object.
46+
47+
Args:
48+
backtest (Backtest): The Backtest object containing
49+
backtest runs.
50+
risk_free_rate (float): The risk-free rate used in certain
51+
metric calculations.
52+
metrics (List[str], optional): List of metric names to compute.
53+
If None, a default set of metrics will be computed.
54+
backtest_date_range (BacktestDateRange, optional): The date range
55+
for the backtest. If None, all backtest metrics will be computed
56+
for each backtest run.
57+
58+
Returns:
59+
Backtest: The Backtest object with computed metrics for each run.
60+
"""
61+
if backtest_date_range is not None:
62+
backtest_runs = [
63+
backtest.get_backtest_run(backtest_date_range)
64+
]
65+
else:
66+
backtest_runs = backtest.get_all_backtest_runs()
67+
68+
for backtest_run in backtest_runs:
69+
# If a date range is provided, check if the backtest run falls
70+
# within the range
71+
backtest_metrics = create_backtest_metrics(
72+
backtest_run, risk_free_rate, metrics
73+
)
74+
backtest_run.backtest_metrics = backtest_metrics
75+
76+
backtest.backtest_runs = backtest_runs
77+
return backtest
78+
3879

3980
def create_backtest_metrics(
4081
backtest_run: BacktestRun, risk_free_rate: float, metrics: List[str] = None

investing_algorithm_framework/services/metrics/volatility.py

Lines changed: 53 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,18 @@
55
annualizes it, giving an estimate of how much the portfolio's value
66
fluctuates on a yearly basis.
77
8-
| **Annual Volatility** | **Risk Level** | **Typical for...** | **Comments** |
9-
| --------------------- | -------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
10-
| **< 5%** | Very Low Risk | Cash equivalents, short-term bonds | Low return expectations; often used for capital preservation |
11-
| **5% – 10%** | Low Risk | Diversified bond portfolios, conservative allocation strategies | Suitable for risk-averse investors |
12-
| **10% – 15%** | Moderate Risk | Balanced portfolios, large-cap equity indexes (e.g., S\&P 500 ≈ \~15%) | Standard for traditional diversified portfolios |
13-
| **15% – 25%** | High Risk | Growth stocks, hedge funds, active equity strategies | Higher return potential, but more drawdowns |
14-
| **> 25%** | Very High Risk | Crypto, leveraged ETFs, speculative strategies | High potential returns, but prone to large losses; often not suitable long-term |
8+
| **Annual Volatility** | **Risk Level (Standalone)** | **Context Matters: Sharpe Ratio Impact** | **Comments** |
9+
| --------------------- | --------------------------- | ---------------------------------------- | ----------- |
10+
| **< 5%** | Very Low Risk | Sharpe > 2.0 = Excellent<br>Sharpe < 0.5 = Poor | Low volatility is great unless returns are negative |
11+
| **5% – 10%** | Low Risk | Sharpe > 1.0 = Good<br>Sharpe < 0.3 = Mediocre | Typical for conservative portfolios |
12+
| **10% – 15%** | Moderate Risk | Sharpe > 0.8 = Good<br>Sharpe < 0.2 = Risky | S&P 500 benchmark; quality matters |
13+
| **15% – 25%** | High Risk | Sharpe > 0.6 = Acceptable<br>Sharpe < 0.0 = Avoid | **Example: 30% CAGR + 23% vol = Sharpe ~1.3 = Excellent** |
14+
| **> 25%** | Very High Risk | Sharpe > 0.4 = Maybe acceptable<br>Sharpe < 0.0 = Dangerous | Only viable with strong positive returns |
15+
16+
17+
Key takeaway: Don't interpret volatility in isolation. Always calculate
18+
and compare the Sharpe Ratio to assess true strategy quality.
19+
Your 30% CAGR with 23% volatility is exceptional because the return far outweighs the risk taken.
1520
1621
"""
1722

@@ -23,47 +28,70 @@
2328
from investing_algorithm_framework.domain import PortfolioSnapshot
2429

2530

26-
def get_annual_volatility(snapshots: List[PortfolioSnapshot]) -> float:
31+
def get_annual_volatility(
32+
snapshots: List[PortfolioSnapshot],
33+
trading_days_per_year=365
34+
) -> float:
2735
"""
2836
Calculate the annualized volatility of portfolio net values.
2937
38+
!Important Note:
39+
40+
Volatility measures variability, not direction. For example:
41+
42+
A standard deviation of 0.238 (23.8%) means returns swing
43+
wildly around their average, but it doesn't tell you if that average
44+
is positive or negative.
45+
46+
Two scenarios with the same 23.8% volatility:
47+
Mean return = +15% per year, Std = 23.8%
48+
16% chance of losing >8.8% (15% - 23.8%)
49+
16% chance of gaining >38.8% (15% + 23.8%)
50+
This is excellent — high growth with swings
51+
52+
Mean return = -5% per year, Std = 23.8%
53+
16% chance of losing >28.8% (-5% - 23.8%)
54+
16% chance of gaining >18.8% (-5% + 23.8%)
55+
This is terrible — losing money with high risk
56+
57+
To assess if "always good returns with high std" is perfect, you need
58+
to consider risk-adjusted metrics like the Sharpe Ratio:
59+
Sharpe Ratio = (Mean Return - Risk-Free Rate) / Volatility
60+
Higher is better; tells you return per unit of risk taken
61+
3062
Args:
3163
snapshots (List[PortfolioSnapshot]): List of portfolio snapshots
3264
from the backtest report.
65+
trading_days_per_year (int): Number of trading days in a year.
3366
3467
Returns:
3568
Float: Annualized volatility as a float
3669
"""
3770

3871
if len(snapshots) < 2:
39-
return 0.0 # Not enough data to calculate volatility
72+
return 0.0
4073

4174
# Build DataFrame from snapshots
4275
records = [
4376
(snapshot.total_value, snapshot.created_at) for snapshot in snapshots
4477
]
4578
df = pd.DataFrame(records, columns=['total_value', 'created_at'])
4679
df['created_at'] = pd.to_datetime(df['created_at'])
47-
df = df.sort_values('created_at').drop_duplicates('created_at').copy()
80+
df = df.set_index('created_at').sort_index().drop_duplicates()
4881

49-
# Calculate log returns
50-
df['log_return'] = np.log(df['total_value'] / df['total_value'].shift(1))
51-
df = df.dropna()
82+
# Resample to daily frequency, taking the last value of each day
83+
df_daily = df.resample('D').last()
84+
df_daily = df_daily.dropna()
5285

53-
if df.empty:
86+
if len(df_daily) < 2:
5487
return 0.0
5588

56-
daily_volatility = df['log_return'].std()
57-
58-
start_date = snapshots[0].created_at
59-
end_date = snapshots[-1].created_at
60-
# Estimate trading days per year based on snapshot frequency
61-
total_days = (end_date - start_date).days
62-
num_observations = len(df)
89+
# Calculate log returns on daily data
90+
df_daily['log_return'] = np.log(df_daily['total_value'] / df_daily['total_value'].shift(1))
91+
df_daily = df_daily.dropna()
6392

64-
if total_days > 0:
65-
trading_days_per_year = (num_observations / total_days) * 365
66-
else:
67-
trading_days_per_year = 365 # Default fallback
93+
# Calculate daily volatility (standard deviation of daily returns)
94+
daily_volatility = df_daily['log_return'].std()
6895

96+
# Annualize using trading days per year
6997
return daily_volatility * np.sqrt(trading_days_per_year)

0 commit comments

Comments
 (0)