Skip to content

Commit 0eb5a30

Browse files
committed
Fix metrics
1 parent 9c60d70 commit 0eb5a30

File tree

15 files changed

+373
-68
lines changed

15 files changed

+373
-68
lines changed

investing_algorithm_framework/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
get_drawdown_series, get_max_drawdown, get_cagr, \
3232
get_standard_deviation_returns, get_standard_deviation_downside_returns, \
3333
get_max_drawdown_absolute, get_exposure_time, get_average_trade_duration, \
34-
get_net_profit, get_win_rate, get_win_loss_ratio, get_calmar_ratio
34+
get_net_profit, get_win_rate, get_win_loss_ratio, get_calmar_ratio, \
35+
get_trade_frequency
3536

3637
__all__ = [
3738
"Algorithm",
@@ -123,4 +124,5 @@
123124
"get_win_rate",
124125
"get_win_loss_ratio",
125126
"get_calmar_ratio",
127+
"get_trade_frequency",
126128
]

investing_algorithm_framework/domain/models/trade/trade.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,12 @@ def symbol(self):
148148

149149
@property
150150
def duration(self):
151+
"""
152+
Calculate the duration of the trade in hours.
153+
154+
Returns:
155+
float: The duration of the trade in hours.
156+
"""
151157
if TradeStatus.CLOSED.equals(self.status):
152158
# Get the total hours between the closed and opened datetime
153159
diff = self.closed_at - self.opened_at

investing_algorithm_framework/infrastructure/repositories/order_repository.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def _apply_query_params(self, db, query, query_params):
5353
query = query.filter_by(id=None)
5454

5555
if external_id_query_param:
56+
print("Filtering by external_id:", external_id_query_param)
5657
query = query.filter_by(external_id=external_id_query_param)
5758

5859
if side_query_param:

investing_algorithm_framework/metrics/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
from .standard_deviation import get_standard_deviation_downside_returns, \
1616
get_standard_deviation_returns
1717
from .net_profit import get_net_profit
18-
from .exposure import get_exposure_time, get_average_trade_duration
18+
from .exposure import get_exposure_time, get_average_trade_duration, \
19+
get_trade_frequency
1920
from .win_rate import get_win_rate, get_win_loss_ratio
2021
from .calmar_ratio import get_calmar_ratio
2122

@@ -41,4 +42,5 @@
4142
"get_win_rate",
4243
"get_win_loss_ratio",
4344
"get_calmar_ratio",
45+
"get_trade_frequency",
4446
]

investing_algorithm_framework/metrics/cagr.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,5 +60,5 @@ def get_cagr(report: BacktestReport) -> float:
6060
if num_days == 0 or start_value == 0:
6161
return 0.0
6262

63-
# CAGR formula
63+
# Apply CAGR formula
6464
return (end_value / start_value) ** (365 / num_days) - 1

investing_algorithm_framework/metrics/exposure.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
"""
2+
High exposure (>1) means you’re deploying capital aggressively, possibly with many simultaneous positions.
3+
4+
Exposure around 1 means capital is nearly fully invested most of the time, but not overlapping.
5+
6+
Low exposure (<1) means capital is mostly idle or only partially invested.
7+
"""
8+
19
from datetime import timedelta
210
from investing_algorithm_framework.domain import BacktestReport
311

@@ -18,13 +26,14 @@ def get_exposure_time(report: BacktestReport):
1826

1927
total_trade_duration = timedelta(0)
2028
for trade in trades:
21-
entry = trade.created_at
29+
entry = trade.opened_at
2230
exit = trade.closed_at or report.backtest_end_date # open trades counted up to end
2331

2432
if exit > entry:
2533
total_trade_duration += exit - entry
2634

2735
backtest_duration = report.backtest_end_date - report.backtest_start_date
36+
2837
if backtest_duration.total_seconds() == 0:
2938
return 0.0
3039

@@ -44,15 +53,16 @@ def get_average_trade_duration(report: BacktestReport):
4453
if not trades:
4554
return 0.0
4655

47-
total_duration = timedelta(0)
56+
total_duration = 0
57+
4858
for trade in trades:
4959
trade_duration = trade.duration
5060

5161
if trade_duration is not None:
5262
total_duration += trade_duration
5363

5464
average_trade_duration = total_duration / len(trades)
55-
return average_trade_duration.total_seconds() / 3600.0 # Convert to hours
65+
return average_trade_duration
5666

5767

5868
def get_trade_frequency(report: BacktestReport):
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import pandas as pd
2+
import numpy as np
3+
4+
from .cagr import get_cagr
5+
6+
7+
def get_mean_daily_return(report):
8+
"""
9+
Calculate the mean daily return from the total value of the snapshots.
10+
11+
This function computes the mean daily return based on the list of
12+
snapshots in the report. If the snapshots have a granularity of less
13+
than a day, the function will resample to daily frequency and compute
14+
average daily returns.
15+
16+
If there is less data then for a year, it will use cagr to
17+
calculate the mean daily return.
18+
19+
Args:
20+
report (BacktestReport): The report containing the equity curve.
21+
22+
Returns:
23+
float: The mean daily return.
24+
"""
25+
snapshots = report.get_snapshots()
26+
27+
if len(snapshots) < 2:
28+
return 0.0 # Not enough data
29+
30+
# Create DataFrame from snapshots
31+
data = [(s.created_at, s.total_value) for s in snapshots]
32+
df = pd.DataFrame(data, columns=["created_at", "total_value"])
33+
df['created_at'] = pd.to_datetime(df['created_at'])
34+
df = df.sort_values('created_at').drop_duplicates('created_at')\
35+
.set_index('created_at')
36+
37+
start_date = df.iloc[0].name
38+
end_date = df.iloc[-1].name
39+
40+
# Check if the period is less than a year
41+
if (end_date - start_date).days < 365:
42+
# Use CAGR to calculate mean daily return
43+
cagr = get_cagr(report)
44+
if cagr == 0.0:
45+
return 0.0
46+
return (1 + cagr) ** (1 / 365) - 1
47+
48+
# Resample to daily frequency using last value of the day
49+
daily_df = df.resample('1D').last().dropna()
50+
51+
# Calculate daily returns
52+
daily_df['return'] = daily_df['total_value'].pct_change()
53+
daily_df = daily_df.dropna()
54+
55+
if daily_df.empty:
56+
return 0.0
57+
58+
mean_return = daily_df['return'].mean()
59+
60+
if np.isnan(mean_return):
61+
return 0.0
62+
63+
return mean_return
64+
65+
66+
def get_mean_yearly_return(report, periods_per_year=365):
67+
"""
68+
Calculate the mean yearly return from a backtest report by
69+
annualizing the mean daily return.
70+
71+
Args:
72+
report (BacktestReport): The report containing the snapshots.
73+
periods_per_year (int): Number of periods in a year (e.g., 365 for daily data).
74+
75+
Returns:
76+
float: The mean yearly return (annualized).
77+
"""
78+
mean_daily_return = get_mean_daily_return(report)
79+
80+
if mean_daily_return == 0.0:
81+
return 0.0
82+
83+
return (1 + mean_daily_return) ** periods_per_year - 1
84+

investing_algorithm_framework/metrics/sharp_ratio.py

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,57 @@
1+
"""
2+
The Sharpe Ratio is a widely used risk-adjusted performance metric. It
3+
measures the excess return per unit of risk (volatility), where risk is
4+
represented by the standard deviation of returns.
5+
6+
| Sharpe Ratio | Interpretation |
7+
| -------------- | ------------------------------------------- |
8+
| **< 0** | Bad: Underperforms risk-free asset |
9+
| **0.0 – 1.0** | Suboptimal: Returns do not justify risk |
10+
| **1.0 – 1.99** | Acceptable: Reasonable risk-adjusted return |
11+
| **2.0 – 2.99** | Good: Strong risk-adjusted performance |
12+
| **3.0+** | Excellent: Exceptional risk-adjusted return |
13+
14+
Sharpe Ratio is highly sensitive to the volatility estimate: Inconsistent sampling frequency, short backtests, or low trade frequency can distort it.
15+
16+
Different strategies have different risk profiles:
17+
18+
High-frequency strategies may have high Sharpe Ratios (>3).
19+
20+
Trend-following strategies might have lower Sharpe (1–2) but strong CAGR and Calmar.
21+
22+
Use risk-free rate (~4–5% annual currently) if your backtest spans long periods.
23+
24+
### 📌 Practical Notes about the implementation:
25+
26+
- Use **daily returns** for consistent Sharpe Ratio calculation and **annualize** the result using this formula:
27+
28+
29+
Sharpe Ratio Formula:
30+
Sharpe Ratio = (Mean Daily Return × Periods Per Year - Risk-Free Rate) /
31+
(Standard Deviation of Daily Returns × sqrt(Periods Per Year))
32+
33+
- You can also calculate a **rolling Sharpe Ratio** (e.g., over a 90-day window) to detect changes in performance stability over time.
34+
35+
Mean daily return is either based on the real returns from the backtest or the CAGR, depending on the data duration.
36+
37+
When do we use actual returns vs CAGR?
38+
39+
| Data Duration | Use This Approach | Reason |
40+
| ------------- | --------------------------------------------------------------- | ----------------------------------------------------------------- |
41+
| **< 1 year** | Use **CAGR** directly and avoid Sharpe Ratio | Not enough data to estimate volatility robustly |
42+
| **1–2 years** | Use **CAGR + conservative vol estimate** OR Sharpe with caution | Sharpe may be unstable, consider adding error bars or disclaimers |
43+
| **> 2 years** | Use **Sharpe Ratio** based on periodic returns | Adequate data to reliably estimate risk-adjusted return |
44+
45+
"""
46+
147
from typing import Optional
248

49+
import math
50+
351
from investing_algorithm_framework.domain.models import BacktestReport
4-
from .cagr import get_cagr
52+
from .mean_daily_return import get_mean_daily_return
553
from .risk_free_rate import get_risk_free_rate_us
6-
from .standard_deviation import get_standard_deviation_returns
54+
from .standard_deviation import get_daily_returns_std
755

856

957
def get_sharpe_ratio(
@@ -25,14 +73,15 @@ def get_sharpe_ratio(
2573
Returns:
2674
float: The Sharpe Ratio.
2775
"""
28-
annualized_return = get_cagr(backtest_report)
29-
# Convert annualized return to decimal
30-
annualized_return = annualized_return / 100.0
31-
standard_deviation_downside = \
32-
get_standard_deviation_returns(backtest_report)
76+
snapshots = backtest_report.get_snapshots()
77+
snapshots = sorted(snapshots, key=lambda s: s.created_at)
78+
mean_daily_return = get_mean_daily_return(backtest_report)
79+
std_daily_return = get_daily_returns_std(snapshots)
3380

3481
if risk_free_rate is None:
3582
risk_free_rate = get_risk_free_rate_us()
3683

37-
# Calculate sharp ratio
38-
return (annualized_return - risk_free_rate) / standard_deviation_downside
84+
# Formula: Sharpe Ratio = (Mean Daily Return × Periods Per Year - Risk-Free Rate) /
85+
# (Standard Deviation of Daily Returns × sqrt(Periods Per Year))
86+
return (mean_daily_return * 365 - risk_free_rate) / \
87+
(std_daily_return * math.sqrt(365))

investing_algorithm_framework/metrics/sortino_ratio.py

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,21 @@
1111
| **1 to 2** | ✅ Acceptable/Good — Reasonable performance for most portfolios |
1212
| **2 to 3** | 💪 Strong — Very good risk-adjusted returns |
1313
| **> 3** | 🌟 Excellent — Rare, may indicate exceptional strategy or overfitting |
14+
15+
Formula:
16+
Sortino Ratio = (Mean Daily Return × Periods Per Year - Risk-Free Rate) /
17+
(Downside Standard Deviation of Daily Returns × sqrt(Periods Per Year))
18+
1419
"""
1520

1621
from typing import Optional
1722

23+
import math
24+
import numpy as np
1825
from investing_algorithm_framework.domain import BacktestReport
19-
from .cagr import get_cagr
26+
from .mean_daily_return import get_mean_daily_return
2027
from .risk_free_rate import get_risk_free_rate_us
21-
from .standard_deviation import get_standard_deviation_downside_returns
28+
from .standard_deviation import get_downside_std_of_daily_returns
2229

2330

2431
def get_sortino_ratio(
@@ -44,22 +51,24 @@ def get_sortino_ratio(
4451
Returns:
4552
float: The Sortino Ratio.
4653
"""
47-
annualized_return = get_cagr(report)
54+
snapshots = report.get_snapshots()
55+
56+
if not snapshots:
57+
return float('inf')
4858

49-
# Convert annualized return to decimal
50-
annualized_return = annualized_return / 100.0
51-
standard_deviation_downside = \
52-
get_standard_deviation_downside_returns(report)
59+
snapshots = sorted(snapshots, key=lambda s: s.created_at)
60+
mean_daily_return = get_mean_daily_return(report)
61+
std_downside_daily_return = get_downside_std_of_daily_returns(snapshots)
5362

5463
if risk_free_rate is None:
5564
risk_free_rate = get_risk_free_rate_us()
5665

57-
if standard_deviation_downside == 0.0:
58-
print("returning inf because standard deviation downside is 0")
59-
return float("inf")
66+
# Formula: Sharpe Ratio = (Mean Daily Return × Periods Per Year - Risk-Free Rate) /
67+
# (Standard Deviation of Daily Returns × sqrt(Periods Per Year))
68+
ratio = (mean_daily_return * 365 - risk_free_rate) / \
69+
(std_downside_daily_return * math.sqrt(365))
6070

61-
if annualized_return == 0:
62-
return 0
71+
if np.float64("inf") == ratio or np.float64("-inf") == ratio:
72+
return float('inf')
6373

64-
# Calculate sortino ratio
65-
return (annualized_return - risk_free_rate) / standard_deviation_downside
74+
return ratio if not np.isnan(ratio) else 0.0

0 commit comments

Comments
 (0)