Skip to content

Commit b0afdec

Browse files
authored
Merge pull request #414 from coding-kitties/release/dev-to-main
Merge dev to main (v7.30.0)
2 parents 33cfc18 + 1e0b763 commit b0afdec

5 files changed

Lines changed: 564 additions & 29 deletions

File tree

investing_algorithm_framework/domain/models/time_frame.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,16 @@ class TimeFrame(Enum):
1010
FIVE_MINUTE = "5m"
1111
TEN_MINUTE = "10m"
1212
FIFTEEN_MINUTE = "15m"
13+
TWENTY_MINUTE = "20m"
1314
THIRTY_MINUTE = "30m"
1415
ONE_HOUR = "1h"
1516
TWO_HOUR = "2h"
1617
FOUR_HOUR = "4h"
18+
SIX_HOUR = "6h"
19+
EIGHT_HOUR = "8h"
1720
TWELVE_HOUR = "12h"
1821
ONE_DAY = "1d"
22+
THREE_DAY = "3d"
1923
ONE_WEEK = "1W"
2024
ONE_MONTH = "1M"
2125
ONE_YEAR = "1Y"
@@ -36,7 +40,16 @@ def from_string(value: str):
3640
if value == entry.value.replace("H", "h"):
3741
return entry
3842

39-
# For hour timeframes compare with and without H
43+
# For hour timeframes compare with and without h
44+
if "h" in entry.value:
45+
46+
if value == entry.value:
47+
return entry
48+
49+
if value == entry.value.replace("h", "H"):
50+
return entry
51+
52+
# For day timeframes compare with and without D
4053
if "d" in entry.value:
4154

4255
if value == entry.value:
@@ -100,6 +113,9 @@ def amount_of_minutes(self):
100113
if self.equals(TimeFrame.FIFTEEN_MINUTE):
101114
return 15
102115

116+
if self.equals(TimeFrame.TWENTY_MINUTE):
117+
return 20
118+
103119
if self.equals(TimeFrame.THIRTY_MINUTE):
104120
return 30
105121

@@ -112,12 +128,21 @@ def amount_of_minutes(self):
112128
if self.equals(TimeFrame.FOUR_HOUR):
113129
return 240
114130

131+
if self.equals(TimeFrame.SIX_HOUR):
132+
return 360
133+
134+
if self.equals(TimeFrame.EIGHT_HOUR):
135+
return 480
136+
115137
if self.equals(TimeFrame.TWELVE_HOUR):
116138
return 720
117139

118140
if self.equals(TimeFrame.ONE_DAY):
119141
return 1440
120142

143+
if self.equals(TimeFrame.THREE_DAY):
144+
return 4320
145+
121146
if self.equals(TimeFrame.ONE_WEEK):
122147
return 10080
123148

investing_algorithm_framework/services/metrics/drawdown.py

Lines changed: 32 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -109,16 +109,17 @@ def get_max_drawdown(snapshots: List[PortfolioSnapshot]) -> float:
109109

110110
def get_max_daily_drawdown(snapshots: List[PortfolioSnapshot]) -> float:
111111
"""
112-
Calculate the maximum daily drawdown of the portfolio as a percentage from the peak.
112+
Calculate the worst single-day decline of the portfolio as a percentage.
113113
114-
This is the largest drop in equity (in percentage) from a peak to a trough
115-
during the backtest period, calculated on a daily basis.
114+
This is the largest day-over-day percentage drop in equity,
115+
NOT the peak-to-trough drawdown (use get_max_drawdown for that).
116116
117117
Args:
118118
snapshots (List[PortfolioSnapshot]): List of portfolio snapshots
119119
120120
Returns:
121-
float: The maximum daily drawdown as a negative percentage (e.g., -5.0 for a 5% drawdown).
121+
float: The maximum single-day drawdown as a positive percentage
122+
(e.g., 0.05 for a 5% single-day decline).
122123
"""
123124
# Create DataFrame from snapshots
124125
data = [(s.created_at, s.total_value) for s in snapshots]
@@ -136,54 +137,58 @@ def get_max_daily_drawdown(snapshots: List[PortfolioSnapshot]) -> float:
136137
# Filter out non-positive values
137138
positive_values = daily_df[daily_df['total_value'] > 0]['total_value']
138139

139-
if positive_values.empty:
140+
if positive_values.empty or len(positive_values) < 2:
140141
return 0.0
141142

142-
peak = positive_values.iloc[0]
143-
max_daily_drawdown_pct = 0.0
143+
# Compute day-over-day returns; the worst single-day decline
144+
# is the most negative return (ignore positive returns)
145+
daily_returns = positive_values.pct_change().dropna()
146+
negative_returns = daily_returns[daily_returns < 0]
144147

145-
for equity in positive_values:
146-
if equity > peak:
147-
peak = equity
148-
149-
# Avoid division by zero (shouldn't happen but extra safety)
150-
if peak <= 0:
151-
continue
152-
153-
drawdown_pct = (equity - peak) / peak
154-
max_daily_drawdown_pct = min(max_daily_drawdown_pct, drawdown_pct)
148+
if negative_returns.empty:
149+
return 0.0
155150

156-
return abs(max_daily_drawdown_pct) # Return as positive percentage
151+
return abs(negative_returns.min())
157152

158153
def get_max_drawdown_duration(snapshots: List[PortfolioSnapshot]) -> int:
159154
"""
160155
Calculate the maximum duration of drawdown in days.
161156
162-
This is the longest period where the portfolio equity was below its peak.
157+
This is the longest period (in calendar days) where the portfolio
158+
equity was below its peak.
163159
164160
Args:
165161
snapshots (List[PortfolioSnapshot]): List of portfolio snapshots
166162
167163
Returns:
168-
int: The maximum drawdown duration in days.
164+
int: The maximum drawdown duration in calendar days.
169165
"""
170166
equity_curve = get_equity_curve(snapshots)
171167
if not equity_curve:
172168
return 0
173169

174170
peak = equity_curve[0][0]
175171
max_duration = 0
176-
current_duration = 0
172+
drawdown_start = None
177173

178-
for equity, _ in equity_curve:
174+
for equity, timestamp in equity_curve:
179175
if equity < peak:
180-
current_duration += 1
176+
# Entering or continuing a drawdown
177+
if drawdown_start is None:
178+
drawdown_start = timestamp
181179
else:
182-
max_duration = max(max_duration, current_duration)
183-
current_duration = 0
184-
peak = equity # Reset peak to current equity
180+
# Recovered to or above the peak
181+
if drawdown_start is not None:
182+
elapsed = (timestamp - drawdown_start).days
183+
max_duration = max(max_duration, elapsed)
184+
drawdown_start = None
185+
peak = equity
185186

186-
max_duration = max(max_duration, current_duration) # Final check
187+
# If still in drawdown at the end of the series
188+
if drawdown_start is not None and len(equity_curve) > 0:
189+
last_timestamp = equity_curve[-1][1]
190+
elapsed = (last_timestamp - drawdown_start).days
191+
max_duration = max(max_duration, elapsed)
187192

188193
return max_duration
189194

investing_algorithm_framework/services/metrics/equity_curve.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,7 @@ def get_equity_curve(
2121
total_size = snapshot.total_value
2222
series.append((total_size, timestamp))
2323

24+
# Sort by timestamp to ensure chronological order
25+
series.sort(key=lambda x: x[1])
26+
2427
return series
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
from unittest import TestCase
2+
3+
from investing_algorithm_framework.domain.models.time_frame import TimeFrame
4+
5+
6+
class TestTimeFrameNewEnumValues(TestCase):
7+
"""Test that the new enum members have the correct string values."""
8+
9+
def test_twenty_minute_value(self):
10+
self.assertEqual(TimeFrame.TWENTY_MINUTE.value, "20m")
11+
12+
def test_six_hour_value(self):
13+
self.assertEqual(TimeFrame.SIX_HOUR.value, "6h")
14+
15+
def test_eight_hour_value(self):
16+
self.assertEqual(TimeFrame.EIGHT_HOUR.value, "8h")
17+
18+
def test_three_day_value(self):
19+
self.assertEqual(TimeFrame.THREE_DAY.value, "3d")
20+
21+
22+
class TestTimeFrameNewAmountOfMinutes(TestCase):
23+
"""Test that amount_of_minutes returns correct values for new members."""
24+
25+
def test_twenty_minute_minutes(self):
26+
self.assertEqual(TimeFrame.TWENTY_MINUTE.amount_of_minutes, 20)
27+
28+
def test_six_hour_minutes(self):
29+
self.assertEqual(TimeFrame.SIX_HOUR.amount_of_minutes, 360)
30+
31+
def test_eight_hour_minutes(self):
32+
self.assertEqual(TimeFrame.EIGHT_HOUR.amount_of_minutes, 480)
33+
34+
def test_three_day_minutes(self):
35+
self.assertEqual(TimeFrame.THREE_DAY.amount_of_minutes, 4320)
36+
37+
38+
class TestTimeFrameNewFromString(TestCase):
39+
"""Test from_string parsing for new members, including case variants."""
40+
41+
def test_twenty_minute_lowercase(self):
42+
self.assertEqual(TimeFrame.from_string("20m"), TimeFrame.TWENTY_MINUTE)
43+
44+
def test_six_hour_lowercase(self):
45+
self.assertEqual(TimeFrame.from_string("6h"), TimeFrame.SIX_HOUR)
46+
47+
def test_six_hour_uppercase(self):
48+
self.assertEqual(TimeFrame.from_string("6H"), TimeFrame.SIX_HOUR)
49+
50+
def test_eight_hour_lowercase(self):
51+
self.assertEqual(TimeFrame.from_string("8h"), TimeFrame.EIGHT_HOUR)
52+
53+
def test_eight_hour_uppercase(self):
54+
self.assertEqual(TimeFrame.from_string("8H"), TimeFrame.EIGHT_HOUR)
55+
56+
def test_three_day_lowercase(self):
57+
self.assertEqual(TimeFrame.from_string("3d"), TimeFrame.THREE_DAY)
58+
59+
def test_three_day_uppercase(self):
60+
self.assertEqual(TimeFrame.from_string("3D"), TimeFrame.THREE_DAY)
61+
62+
63+
class TestTimeFrameNewFromValue(TestCase):
64+
"""Test from_value parsing for new members."""
65+
66+
def test_twenty_minute_from_value_string(self):
67+
self.assertEqual(
68+
TimeFrame.from_value("20m"), TimeFrame.TWENTY_MINUTE
69+
)
70+
71+
def test_six_hour_from_value_string(self):
72+
self.assertEqual(TimeFrame.from_value("6h"), TimeFrame.SIX_HOUR)
73+
74+
def test_eight_hour_from_value_string(self):
75+
self.assertEqual(TimeFrame.from_value("8h"), TimeFrame.EIGHT_HOUR)
76+
77+
def test_three_day_from_value_string(self):
78+
self.assertEqual(TimeFrame.from_value("3d"), TimeFrame.THREE_DAY)
79+
80+
def test_twenty_minute_from_value_enum(self):
81+
self.assertEqual(
82+
TimeFrame.from_value(TimeFrame.TWENTY_MINUTE),
83+
TimeFrame.TWENTY_MINUTE,
84+
)
85+
86+
def test_six_hour_from_value_enum(self):
87+
self.assertEqual(
88+
TimeFrame.from_value(TimeFrame.SIX_HOUR), TimeFrame.SIX_HOUR
89+
)
90+
91+
def test_eight_hour_from_value_enum(self):
92+
self.assertEqual(
93+
TimeFrame.from_value(TimeFrame.EIGHT_HOUR), TimeFrame.EIGHT_HOUR
94+
)
95+
96+
def test_three_day_from_value_enum(self):
97+
self.assertEqual(
98+
TimeFrame.from_value(TimeFrame.THREE_DAY), TimeFrame.THREE_DAY
99+
)
100+
101+
102+
class TestTimeFrameNewEquals(TestCase):
103+
"""Test equals method for new members against string and enum values."""
104+
105+
def test_twenty_minute_equals_string(self):
106+
self.assertTrue(TimeFrame.TWENTY_MINUTE.equals("20m"))
107+
108+
def test_twenty_minute_equals_enum(self):
109+
self.assertTrue(TimeFrame.TWENTY_MINUTE.equals(TimeFrame.TWENTY_MINUTE))
110+
111+
def test_twenty_minute_not_equals_other(self):
112+
self.assertFalse(TimeFrame.TWENTY_MINUTE.equals(TimeFrame.THIRTY_MINUTE))
113+
114+
def test_six_hour_equals_string(self):
115+
self.assertTrue(TimeFrame.SIX_HOUR.equals("6h"))
116+
117+
def test_six_hour_equals_enum(self):
118+
self.assertTrue(TimeFrame.SIX_HOUR.equals(TimeFrame.SIX_HOUR))
119+
120+
def test_eight_hour_equals_string(self):
121+
self.assertTrue(TimeFrame.EIGHT_HOUR.equals("8h"))
122+
123+
def test_eight_hour_equals_enum(self):
124+
self.assertTrue(TimeFrame.EIGHT_HOUR.equals(TimeFrame.EIGHT_HOUR))
125+
126+
def test_three_day_equals_string(self):
127+
self.assertTrue(TimeFrame.THREE_DAY.equals("3d"))
128+
129+
def test_three_day_equals_enum(self):
130+
self.assertTrue(TimeFrame.THREE_DAY.equals(TimeFrame.THREE_DAY))
131+
132+
def test_three_day_not_equals_other(self):
133+
self.assertFalse(TimeFrame.THREE_DAY.equals(TimeFrame.ONE_DAY))
134+
135+
136+
class TestTimeFrameNewOrdering(TestCase):
137+
"""Test ordering of new members relative to their neighbors."""
138+
139+
# TWENTY_MINUTE sits between FIFTEEN_MINUTE and THIRTY_MINUTE
140+
def test_fifteen_lt_twenty_minute(self):
141+
self.assertLess(TimeFrame.FIFTEEN_MINUTE, TimeFrame.TWENTY_MINUTE)
142+
143+
def test_twenty_lt_thirty_minute(self):
144+
self.assertLess(TimeFrame.TWENTY_MINUTE, TimeFrame.THIRTY_MINUTE)
145+
146+
# SIX_HOUR sits between FOUR_HOUR and EIGHT_HOUR
147+
def test_four_hour_lt_six_hour(self):
148+
self.assertLess(TimeFrame.FOUR_HOUR, TimeFrame.SIX_HOUR)
149+
150+
def test_six_hour_lt_eight_hour(self):
151+
self.assertLess(TimeFrame.SIX_HOUR, TimeFrame.EIGHT_HOUR)
152+
153+
# EIGHT_HOUR sits between SIX_HOUR and TWELVE_HOUR
154+
def test_eight_hour_lt_twelve_hour(self):
155+
self.assertLess(TimeFrame.EIGHT_HOUR, TimeFrame.TWELVE_HOUR)
156+
157+
# THREE_DAY sits between ONE_DAY and ONE_WEEK
158+
def test_one_day_lt_three_day(self):
159+
self.assertLess(TimeFrame.ONE_DAY, TimeFrame.THREE_DAY)
160+
161+
def test_three_day_lt_one_week(self):
162+
self.assertLess(TimeFrame.THREE_DAY, TimeFrame.ONE_WEEK)
163+
164+
# Verify >= and <= also work
165+
def test_twenty_minute_le_thirty_minute(self):
166+
self.assertLessEqual(TimeFrame.TWENTY_MINUTE, TimeFrame.THIRTY_MINUTE)
167+
168+
def test_eight_hour_ge_six_hour(self):
169+
self.assertGreaterEqual(TimeFrame.EIGHT_HOUR, TimeFrame.SIX_HOUR)

0 commit comments

Comments
 (0)