Skip to content

Commit 532f5a8

Browse files
committed
Fix report formatting
1 parent a937f69 commit 532f5a8

File tree

16 files changed

+1070
-0
lines changed

16 files changed

+1070
-0
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from .backtest_data_ranges import select_backtest_date_ranges
2+
3+
__all__ = [
4+
"select_backtest_date_ranges"
5+
]
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import pandas as pd
2+
from investing_algorithm_framework.domain import BacktestDateRange, \
3+
OperationalException
4+
from typing import List, Union
5+
6+
7+
def select_backtest_date_ranges(
8+
df: pd.DataFrame, window: Union[str, int] = '365D'
9+
) -> List[BacktestDateRange]:
10+
"""
11+
Identifies the best upturn, worst downturn, and sideways periods
12+
for the given window duration. This allows you to quickly select
13+
interesting periods for backtesting.
14+
"""
15+
df = df.copy()
16+
df = df.sort_index()
17+
18+
if isinstance(window, int):
19+
window = pd.Timedelta(days=window)
20+
elif isinstance(window, str):
21+
window = pd.to_timedelta(window)
22+
else:
23+
raise OperationalException("window must be a string or integer")
24+
25+
if len(df) < 2 or df.index[-1] - df.index[0] < window:
26+
raise OperationalException(
27+
"DataFrame must contain at least two rows and span "
28+
"the full window duration"
29+
)
30+
31+
best_upturn = {
32+
"name": "UpTurn", "return": float('-inf'), "start": None, "end": None
33+
}
34+
worst_downturn = {
35+
"name": "DownTurn", "return": float('inf'), "start": None, "end": None
36+
}
37+
most_sideways = {
38+
"name": "SideWays",
39+
"volatility": float('inf'),
40+
"return": None,
41+
"start": None,
42+
"end": None
43+
}
44+
45+
for i in range(len(df)):
46+
start_time = df.index[i]
47+
end_time = start_time + window
48+
window_df = df[(df.index >= start_time) & (df.index <= end_time)]
49+
50+
if len(window_df) < 2 or (window_df.index[-1] - start_time) < window:
51+
continue
52+
53+
start_price = window_df['Close'].iloc[0]
54+
end_price = window_df['Close'].iloc[-1]
55+
ret = (end_price / start_price) - 1 # relative return
56+
volatility = window_df['Close'].std()
57+
58+
# Ensure datetime for BacktestDateRange
59+
start_time = pd.Timestamp(start_time).to_pydatetime()
60+
end_time = pd.Timestamp(window_df.index[-1]).to_pydatetime()
61+
62+
if ret > best_upturn["return"]:
63+
best_upturn.update(
64+
{"return": ret, "start": start_time, "end": end_time}
65+
)
66+
67+
if ret < worst_downturn["return"]:
68+
worst_downturn.update(
69+
{"return": ret, "start": start_time, "end": end_time}
70+
)
71+
72+
if volatility < most_sideways["volatility"]:
73+
most_sideways.update({
74+
"return": ret,
75+
"volatility": volatility,
76+
"start": start_time,
77+
"end": end_time
78+
})
79+
80+
return [
81+
BacktestDateRange(
82+
start_date=best_upturn['start'],
83+
end_date=best_upturn['end'],
84+
name=best_upturn['name']
85+
),
86+
BacktestDateRange(
87+
start_date=worst_downturn['start'],
88+
end_date=worst_downturn['end'],
89+
name=worst_downturn['name']
90+
),
91+
BacktestDateRange(
92+
start_date=most_sideways['start'],
93+
end_date=most_sideways['end'],
94+
name=most_sideways['name']
95+
)
96+
]
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import plotly.graph_objects as go
2+
import pandas as pd
3+
import plotly.io as pio
4+
5+
def get_ohlcv_data_completeness_chart(
6+
df,
7+
timeframe='1min',
8+
windowsize=100,
9+
title="OHLCV Data completenes"
10+
):
11+
df = df.copy()
12+
df['Datetime'] = pd.to_datetime(df['Datetime'])
13+
df = df.sort_values('Datetime').tail(windowsize)
14+
start = df['Datetime'].iloc[0]
15+
end = df['Datetime'].iloc[-1]
16+
freq = pd.to_timedelta(timeframe)
17+
expected = pd.date_range(start, end, freq=freq)
18+
actual = df['Datetime']
19+
missing = expected.difference(actual)
20+
21+
# Calculte the percentage completeness
22+
completeness = len(actual) / len(expected) * 100
23+
title += f" ({completeness:.2f}% complete)"
24+
fig = go.Figure()
25+
fig.add_trace(
26+
go.Scatter(
27+
x=actual,
28+
y=[1]*len(actual),
29+
mode='markers',
30+
name='Present',
31+
marker=dict(color='green', size=6)
32+
)
33+
)
34+
fig.add_trace(
35+
go.Scatter(
36+
x=missing,
37+
y=[1]*len(missing),
38+
mode='markers',
39+
name='Missing',
40+
marker=dict(color='red', size=6, symbol='x')
41+
)
42+
)
43+
fig.update_layout(
44+
title=title,
45+
xaxis_title='Datetime',
46+
yaxis=dict(showticklabels=False),
47+
height=300,
48+
showlegend=True
49+
)
50+
51+
return pio.to_html(fig, full_html=False, include_plotlyjs='cdn')
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import unittest
2+
from datetime import datetime, timedelta
3+
from unittest.mock import MagicMock
4+
5+
from investing_algorithm_framework import get_cagr
6+
7+
8+
# Mock Snapshot class
9+
class Snapshot:
10+
def __init__(self, total_value, created_at):
11+
self.total_value = total_value
12+
self.created_at = created_at
13+
14+
class TestGetCagr(unittest.TestCase):
15+
16+
def create_report(self, prices, start_date):
17+
""" Helper to create a mocked BacktestReport from a price list """
18+
snapshots = [
19+
Snapshot(net_size, start_date + timedelta(weeks=i))
20+
for i, net_size in enumerate(prices)
21+
]
22+
report = MagicMock()
23+
report.get_snapshots.return_value = snapshots
24+
return report
25+
26+
def test_cagr_for_return_less_then_a_year(self):
27+
"""
28+
Test a scenario where the CAGR is calculated
29+
for a period less than a year (3 months). Given that the
30+
portfolio return 12% annually,
31+
the CAGR should be approximately 3% for 3 months.
32+
"""
33+
# Convert annualized return to approximate weekly returns
34+
weekly_return = (1 + 0.12) ** (1 / 52) - 1 # ≈ 0.0022
35+
weeks = 13 # 3 months ≈ 13 weeks
36+
37+
# Generate weekly prices (cumulative compounding)
38+
start_price = 100
39+
prices = [start_price * ((1 + weekly_return) ** i) for i in range(weeks + 1)]
40+
41+
start_date = datetime(2022, 1, 1)
42+
report_x = self.create_report(prices, start_date)
43+
44+
cagr = get_cagr(report_x)
45+
self.assertAlmostEqual(cagr, 0.12034875793587707, delta=1)
46+
47+
def test_cagr_for_return_exactly_a_year(self):
48+
"""
49+
Test a scenario where the CAGR is calculated
50+
for a period less than a year (3 months). Given that the
51+
portfolio return 12% annually,
52+
the CAGR should be approximately 3% for 3 months.
53+
"""
54+
# Convert annualized return to approximate weekly returns
55+
weekly_return = (1 + 0.12) ** (1 / 52) - 1 # ≈ 0.0022
56+
weeks = 52
57+
58+
# Generate weekly prices (cumulative compounding)
59+
start_price = 100
60+
prices = [start_price * ((1 + weekly_return) ** i) for i in range(weeks + 1)]
61+
62+
start_date = datetime(2022, 1, 1)
63+
report_x = self.create_report(prices, start_date)
64+
65+
cagr = get_cagr(report_x)
66+
self.assertAlmostEqual(cagr, 0.12034875793587663, delta=1)
67+
68+
def test_cagr_for_return_more_then_a_year(self):
69+
"""
70+
Test a scenario where the CAGR is calculated
71+
for a period less than a year (3 months). Given that the
72+
portfolio return 12% annually,
73+
the CAGR should be approximately 3% for 3 months.
74+
"""
75+
# Convert annualized return to approximate weekly returns
76+
weekly_return = (1 + 0.12) ** (1 / 52) - 1 # ≈ 0.0022
77+
weeks = 73
78+
79+
# Generate weekly prices (cumulative compounding)
80+
start_price = 100
81+
prices = [start_price * ((1 + weekly_return) ** i) for i in range(weeks + 1)]
82+
83+
start_date = datetime(2022, 1, 1)
84+
report_x = self.create_report(prices, start_date)
85+
86+
cagr = get_cagr(report_x)
87+
self.assertAlmostEqual(cagr, 0.1203487579358764, delta=1)
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)
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import unittest
2+
from datetime import datetime, timedelta
3+
from unittest.mock import MagicMock
4+
from investing_algorithm_framework import get_drawdown_series, \
5+
get_max_drawdown, get_max_drawdown_absolute
6+
7+
8+
class TestDrawdownFunctions(unittest.TestCase):
9+
10+
def setUp(self):
11+
# Generate mocked equity curve: net_size over time
12+
self.timestamps = [
13+
datetime(2024, 1, 1),
14+
datetime(2024, 1, 2),
15+
datetime(2024, 1, 3),
16+
datetime(2024, 1, 4),
17+
datetime(2024, 1, 5),
18+
]
19+
20+
self.net_sizes = [1000, 1200, 900, 1100, 1300] # Simulates rise, fall, recovery, new high
21+
22+
# Create mock snapshot objects
23+
self.snapshots = []
24+
for ts, net_size in zip(self.timestamps, self.net_sizes):
25+
snapshot = MagicMock()
26+
snapshot.created_at = ts
27+
snapshot.total_value = net_size
28+
self.snapshots.append(snapshot)
29+
30+
# Create a mocked BacktestReport
31+
self.backtest_report = MagicMock()
32+
self.backtest_report.get_snapshots.return_value = self.snapshots
33+
34+
def test_drawdown_series(self):
35+
drawdown_series = get_drawdown_series(self.backtest_report)
36+
37+
expected_drawdowns = [
38+
0.0, # baseline
39+
0.0, # new high
40+
(900 - 1200) / 1200, # drop from peak
41+
(1100 - 1200) / 1200, # partial recovery
42+
0.0 # new peak again
43+
]
44+
45+
self.assertEqual(len(drawdown_series), len(expected_drawdowns))
46+
47+
for i, (_, drawdown) in enumerate(drawdown_series):
48+
self.assertAlmostEqual(drawdown, expected_drawdowns[i], places=6)
49+
50+
def test_max_drawdown(self):
51+
max_drawdown = get_max_drawdown(self.backtest_report)
52+
expected_max = abs((900 - 1200) / 1200) # 0.25
53+
self.assertAlmostEqual(max_drawdown, expected_max, places=6)
54+
55+
def test_max_drawdown_absolute(self):
56+
max_drawdown = get_max_drawdown_absolute(self.backtest_report)
57+
self.assertEqual(max_drawdown, 300) # 1200 - 900 = 300

0 commit comments

Comments
 (0)