Skip to content

Commit dcf944e

Browse files
committed
Add metrics
1 parent 165f9f8 commit dcf944e

File tree

11 files changed

+284
-15
lines changed

11 files changed

+284
-15
lines changed

investing_algorithm_framework/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
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
34+
get_net_profit, get_win_rate, get_win_loss_ratio, get_calmar_ratio
3535

3636
__all__ = [
3737
"Algorithm",
@@ -120,4 +120,7 @@
120120
"get_exposure_time",
121121
"get_average_trade_duration",
122122
"get_net_profit",
123+
"get_win_rate",
124+
"get_win_loss_ratio",
125+
"get_calmar_ratio",
123126
]

investing_algorithm_framework/metrics/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
get_standard_deviation_returns
1717
from .net_profit import get_net_profit
1818
from .exposure import get_exposure_time, get_average_trade_duration
19+
from .win_rate import get_win_rate, get_win_loss_ratio
20+
from .calmar_ratio import get_calmar_ratio
1921

2022
__all__ = [
2123
"get_volatility",
@@ -36,4 +38,7 @@
3638
"get_net_profit",
3739
"get_exposure_time",
3840
"get_average_trade_duration",
41+
"get_win_rate",
42+
"get_win_loss_ratio",
43+
"get_calmar_ratio",
3944
]

investing_algorithm_framework/metrics/cagr.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,12 @@ def get_cagr(report: BacktestReport) -> float:
4545

4646
# Convert snapshots to DataFrame
4747
data = [(s.total_value, s.created_at) for s in snapshots]
48-
df = pd.DataFrame(data, columns=["net_size", "created_at"])
48+
df = pd.DataFrame(data, columns=["total_value", "created_at"])
4949
df['created_at'] = pd.to_datetime(df['created_at'])
5050
df = df.sort_values('created_at')
5151

52-
start_value = df.iloc[0]['net_size']
53-
end_value = df.iloc[-1]['net_size']
52+
start_value = df.iloc[0]['total_value']
53+
end_value = df.iloc[-1]['total_value']
5454

5555
start_date = df.iloc[0]['created_at']
5656
end_date = df.iloc[-1]['created_at']
@@ -61,7 +61,4 @@ def get_cagr(report: BacktestReport) -> float:
6161
return 0.0
6262

6363
# CAGR formula
64-
cagr = (end_value / start_value) ** (365 / num_days) - 1
65-
# Return as percentage
66-
cagr = round(cagr * 100, 2) # Convert to percentage and 2 decimal places
67-
return cagr
64+
return (end_value / start_value) ** (365 / num_days) - 1
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""
2+
| **Calmar Ratio** | **Interpretation** |
3+
| ---------------- | ----------------------------------------------------------- |
4+
| **> 3.0** | **Excellent** – strong return vs. drawdown |
5+
| **2.0 – 3.0** | **Very Good** – solid risk-adjusted performance |
6+
| **1.0 – 2.0** | **Acceptable** – decent, especially for volatile strategies |
7+
| **< 1.0** | **Poor** – high drawdowns relative to return |
8+
"""
9+
10+
11+
from investing_algorithm_framework.domain import BacktestReport
12+
from .cagr import get_cagr
13+
from .drawdown import get_max_drawdown
14+
15+
16+
def get_calmar_ratio(report: BacktestReport):
17+
"""
18+
Calculate the Calmar Ratio, which is the ratio of the annualized
19+
return to the maximum drawdown.
20+
21+
Formula:
22+
Calmar Ratio = CAGR / |Maximum Drawdown|
23+
24+
The Calmar Ratio is a measure of risk-adjusted return,
25+
where a higher ratio indicates a more favorable risk-return profile.
26+
27+
Args:
28+
report: An object that provides methods to get trades and equity curve.
29+
30+
Returns:
31+
float: The Calmar Ratio.
32+
"""
33+
cagr = get_cagr(report)
34+
max_drawdown = get_max_drawdown(report)
35+
36+
if max_drawdown == 0 or max_drawdown is None:
37+
return 0.0
38+
39+
return cagr / max_drawdown

investing_algorithm_framework/metrics/drawdown.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ def get_max_drawdown(backtest_report: BacktestReport) -> float:
7979
drawdown_pct = (equity - peak) / peak # Will be 0 or negative
8080
max_drawdown_pct = min(max_drawdown_pct, drawdown_pct)
8181

82-
return max_drawdown_pct * 100.0 # Return as percentage (e.g., -10.0)
82+
return abs(max_drawdown_pct)
8383

8484

8585
def get_max_drawdown_absolute(backtest_report: BacktestReport) -> float:
@@ -110,4 +110,4 @@ def get_max_drawdown_absolute(backtest_report: BacktestReport) -> float:
110110
drawdown = peak - equity # Drop from peak
111111
max_drawdown = max(max_drawdown, drawdown)
112112

113-
return max_drawdown
113+
return abs(max_drawdown) # Return as positive number (e.g., €10,000)

investing_algorithm_framework/metrics/exposure.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,23 @@ def get_average_trade_duration(report: BacktestReport):
5353

5454
average_trade_duration = total_duration / len(trades)
5555
return average_trade_duration.total_seconds() / 3600.0 # Convert to hours
56+
57+
58+
def get_trade_frequency(report: BacktestReport):
59+
"""
60+
Calculates the trade frequency as the number of trades per day
61+
during the backtest period.
62+
63+
Returns:
64+
A float representing the average number of trades per day.
65+
"""
66+
trades = report.get_trades()
67+
68+
if not trades:
69+
return 0.0
70+
71+
total_days = (report.backtest_end_date - report.backtest_start_date).days + 1
72+
if total_days <= 0:
73+
return 0.0
74+
75+
return len(trades) / total_days
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""
2+
| Metric | High Value Means... | Weakness if Used Alone |
3+
| ------------------ | --------------------------- | ----------------------------------- |
4+
| **Win Rate** | Many trades are profitable | Doesn't say how big wins/losses are |
5+
| **Win/Loss Ratio** | Big wins relative to losses | Doesn’t say how *often* you win |
6+
7+
8+
Example of Non-Overlap:
9+
Strategy A: 90% win rate, but average win is $1, average loss is $10 → not profitable.
10+
11+
Strategy B: 30% win rate, but average win is $300, average loss is $50 → highly profitable.
12+
"""
13+
14+
from investing_algorithm_framework import BacktestReport, TradeStatus
15+
16+
17+
def get_win_rate(backtest_report: BacktestReport) -> float:
18+
"""
19+
Calculate the win rate of the portfolio based on the backtest report.
20+
21+
Win Rate is defined as the percentage of trades that were profitable.
22+
The percentage of trades that are profitable.
23+
24+
Formula:
25+
Win Rate = (Number of Profitable Trades / Total Number of Trades) * 100
26+
27+
Example: If 60 out of 100 trades are profitable, the win rate is 60%.
28+
29+
Args:
30+
backtest_report (BacktestReport): The backtest report containing
31+
trade history.
32+
33+
Returns:
34+
float: The win rate as a percentage (e.g., 75.0 for 75% win rate).
35+
"""
36+
return backtest_report.percentage_positive_trades
37+
38+
39+
def get_win_loss_ratio(backtest_report: BacktestReport) -> float:
40+
"""
41+
Calculate the win/loss ratio of the portfolio based on the backtest report.
42+
43+
Win/Loss Ratio is defined as the average profit of winning trades divided by
44+
the average loss of losing trades.
45+
46+
Formula:
47+
Win/Loss Ratio = Average Profit of Winning Trades
48+
/ Average Loss of Losing Trades
49+
50+
Example: If the average profit of winning trades is $200 and the
51+
average loss of losing trades is $100, the win/loss ratio is 2.0.
52+
53+
Args:
54+
backtest_report (BacktestReport): The backtest report containing
55+
trade history.
56+
57+
Returns:
58+
float: The win/loss ratio.
59+
"""
60+
trades = backtest_report.get_trades(trade_status=TradeStatus.CLOSED)
61+
62+
if not trades:
63+
return 0.0
64+
65+
# Separate winning and losing trades
66+
winning_trades = [t for t in trades if t.net_gain > 0]
67+
losing_trades = [t for t in trades if t.net_gain < 0]
68+
69+
if not winning_trades or not losing_trades:
70+
return 0.0
71+
72+
# Compute averages
73+
avg_win = sum(t.net_gain for t in winning_trades) / len(winning_trades)
74+
avg_loss = abs(
75+
sum(t.net_gain for t in losing_trades) / len(losing_trades))
76+
77+
# Avoid division by zero
78+
if avg_loss == 0:
79+
return float('inf')
80+
81+
return avg_win / avg_loss

tests/metrics/test_cagr.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def test_cagr_for_return_less_then_a_year(self):
4242
report_x = self.create_report(prices, start_date)
4343

4444
cagr = get_cagr(report_x)
45-
self.assertAlmostEqual(cagr, 12, delta=1)
45+
self.assertAlmostEqual(cagr, 0.12034875793587707, delta=1)
4646

4747
def test_cagr_for_return_exactly_a_year(self):
4848
"""
@@ -63,7 +63,7 @@ def test_cagr_for_return_exactly_a_year(self):
6363
report_x = self.create_report(prices, start_date)
6464

6565
cagr = get_cagr(report_x)
66-
self.assertAlmostEqual(cagr, 12, delta=1)
66+
self.assertAlmostEqual(cagr, 0.12034875793587663, delta=1)
6767

6868
def test_cagr_for_return_more_then_a_year(self):
6969
"""
@@ -84,4 +84,4 @@ def test_cagr_for_return_more_then_a_year(self):
8484
report_x = self.create_report(prices, start_date)
8585

8686
cagr = get_cagr(report_x)
87-
self.assertAlmostEqual(cagr, 12, delta=1)
87+
self.assertAlmostEqual(cagr, 0.1203487579358764, delta=1)

tests/metrics/test_calmer_ratio.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import unittest
2+
from datetime import datetime
3+
from unittest.mock import patch, MagicMock
4+
from investing_algorithm_framework.domain import BacktestReport
5+
6+
from investing_algorithm_framework import get_calmar_ratio
7+
8+
9+
class TestGetCalmarRatio(unittest.TestCase):
10+
11+
def setUp(self):
12+
# Generate mocked equity curve: net_size over time
13+
self.timestamps = [
14+
datetime(2024, 1, 1),
15+
datetime(2024, 1, 2),
16+
datetime(2024, 1, 3),
17+
datetime(2024, 1, 4),
18+
datetime(2024, 1, 5),
19+
]
20+
21+
self.net_sizes = [1000, 1200, 900, 1100, 1300] # Simulates rise, fall, recovery, new high
22+
23+
# Create mock snapshot objects
24+
self.snapshots = []
25+
for ts, net_size in zip(self.timestamps, self.net_sizes):
26+
snapshot = MagicMock()
27+
snapshot.created_at = ts
28+
snapshot.total_value = net_size
29+
self.snapshots.append(snapshot)
30+
31+
# Create a mocked BacktestReport
32+
self.backtest_report = MagicMock()
33+
self.backtest_report.get_snapshots.return_value = self.snapshots
34+
35+
def _create_report(self, total_size_series, timestamps):
36+
report = MagicMock(spec=BacktestReport)
37+
report.get_snapshots.return_value = [
38+
MagicMock(created_at=ts, total_value=size)
39+
for ts, size in zip(timestamps, total_size_series)
40+
]
41+
return report
42+
43+
def test_typical_case(self):
44+
# Create a report with total sizes for a whole year, with intervals of 31 days.
45+
report = self._create_report(
46+
[1000, 1200, 900, 1100, 1300],
47+
[datetime(2024, i, 1) for i in range(1, 6)]
48+
)
49+
ratio = get_calmar_ratio(report)
50+
self.assertEqual(ratio, 4.8261927891975365) # Expected ratio based on the mock data
51+
52+
def test_calmar_ratio_zero_drawdown(self):
53+
# Create a report with total sizes for a whole year, with intervals of 31 days, and no drawdowns.
54+
report = self._create_report(
55+
[1000, 1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900, 2000],
56+
[datetime(2024, 1, i) for i in range(1, 11)]
57+
)
58+
ratio = get_calmar_ratio(report)
59+
self.assertEqual(ratio, 0.0)
60+
61+
def test_calmar_ratio_with_only_drawdown(self):
62+
# Create a report with total sizes for a whole year, with intervals of 31 days, and no drawdowns.
63+
report = self._create_report(
64+
[1000, 900, 800, 700, 600, 500, 400, 300, 200, 100],
65+
[datetime(2024, 1, i) for i in range(1, 11)]
66+
)
67+
ratio = get_calmar_ratio(report)
68+
self.assertEqual(ratio, -1.1111111111111112)

tests/metrics/test_drawdowns.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,7 @@ def test_drawdown_series(self):
4949

5050
def test_max_drawdown(self):
5151
max_drawdown = get_max_drawdown(self.backtest_report)
52-
print(max_drawdown)
53-
expected_max = ((900 - 1200) / 1200) * 100# -0.25
52+
expected_max = abs((900 - 1200) / 1200) # 0.25
5453
self.assertAlmostEqual(max_drawdown, expected_max, places=6)
5554

5655
def test_max_drawdown_absolute(self):

tests/metrics/test_win_rate.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import unittest
2+
from unittest.mock import MagicMock
3+
from types import SimpleNamespace
4+
5+
# Assuming your function is defined in a module called strategy_metrics
6+
from investing_algorithm_framework import get_win_loss_ratio
7+
8+
9+
class TestGetWinLossRatio(unittest.TestCase):
10+
11+
def create_mock_trade(self, net_gain):
12+
return SimpleNamespace(net_gain=net_gain)
13+
14+
def test_mixed_winning_and_losing_trades(self):
15+
report = MagicMock()
16+
report.get_trades.return_value = [
17+
self.create_mock_trade(100),
18+
self.create_mock_trade(200),
19+
self.create_mock_trade(-50),
20+
self.create_mock_trade(-150),
21+
]
22+
ratio = get_win_loss_ratio(report)
23+
expected_avg_win = (100 + 200) / 2 # 150
24+
expected_avg_loss = abs((-50 + -150) / 2) # 100
25+
expected_ratio = expected_avg_win / expected_avg_loss # 1.5
26+
self.assertAlmostEqual(ratio, expected_ratio)
27+
28+
def test_all_winning_trades(self):
29+
report = MagicMock()
30+
report.get_trades.return_value = [
31+
self.create_mock_trade(100),
32+
self.create_mock_trade(300),
33+
]
34+
self.assertEqual(get_win_loss_ratio(report), 0.0)
35+
36+
def test_all_losing_trades(self):
37+
report = MagicMock()
38+
report.get_trades.return_value = [
39+
self.create_mock_trade(-100),
40+
self.create_mock_trade(-200),
41+
]
42+
self.assertEqual(get_win_loss_ratio(report), 0.0)
43+
44+
def test_empty_trade_list(self):
45+
report = MagicMock()
46+
report.get_trades.return_value = []
47+
self.assertEqual(get_win_loss_ratio(report), 0.0)
48+
49+
def test_division_by_zero_loss(self):
50+
# Should not happen with realistic data, but test edge case
51+
report = MagicMock()
52+
report.get_trades.return_value = [
53+
self.create_mock_trade(100),
54+
self.create_mock_trade(200),
55+
self.create_mock_trade(0), # zero gain/loss
56+
]
57+
self.assertEqual(get_win_loss_ratio(report), 0.0)

0 commit comments

Comments
 (0)