Skip to content

Commit 655c792

Browse files
Add calculation of strategy volatility for plot_portfolio.py. Implement test functions for volatility calculation.
1 parent 6be8cd8 commit 655c792

File tree

3 files changed

+104
-13
lines changed

3 files changed

+104
-13
lines changed

src/backtest_bay/plot/plot_portfolio.py

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import numpy as np
12
import pandas as pd
23
import plotly.graph_objects as go
34
from plotly.subplots import make_subplots
@@ -7,27 +8,40 @@
78
pd.options.plotting.backend = "plotly"
89

910

10-
def plot_portfolio(portfolio, title, tac):
11+
def plot_portfolio(portfolio, title, tac, cash):
1112
"""Main function to plot the portfolio performance and metrics."""
1213
portfolio_return = _calculate_portfolio_return(portfolio["assets"])
1314
annualized_return = _calculate_annualized_return(portfolio["assets"])
14-
buy_and_hold_return = _calculate_annualized_return(portfolio["Close"])
15-
trades = _calculate_trades(portfolio["shares"])
15+
annualized_volatility = _calculate_annualized_volatility(portfolio["assets"])
16+
17+
# Benchmark: Buy and Hold Strategy
18+
portfolio["buy_and_hold"] = _buy_and_hold_strategy(cash, portfolio["Close"])
19+
buy_and_hold_return = _calculate_annualized_return(portfolio["buy_and_hold"])
20+
buy_and_hold_volatility = _calculate_annualized_volatility(
21+
portfolio["buy_and_hold"]
22+
)
1623

24+
trades = _calculate_trades(portfolio["shares"])
1725
fig = make_subplots(
1826
rows=2,
1927
cols=1,
2028
shared_xaxes=True,
21-
row_heights=[0.70, 0.30],
22-
vertical_spacing=0.2,
29+
row_heights=[0.60, 0.40],
30+
vertical_spacing=0.15,
2331
specs=[[{"type": "xy"}], [{"type": "domain"}]],
2432
)
2533

2634
for trace in _create_portfolio_traces(portfolio):
2735
fig.add_trace(trace, row=1, col=1)
2836

2937
metrics_table = _create_metrics_table(
30-
portfolio_return, annualized_return, trades, buy_and_hold_return, tac
38+
portfolio_return,
39+
annualized_return,
40+
annualized_volatility,
41+
trades,
42+
buy_and_hold_return,
43+
buy_and_hold_volatility,
44+
tac,
3145
)
3246
fig.add_trace(metrics_table, row=2, col=1)
3347

@@ -51,7 +65,13 @@ def _create_portfolio_traces(portfolio):
5165

5266

5367
def _create_metrics_table(
54-
portfolio_return, annualized_return, trades, buy_and_hold_return, tac
68+
portfolio_return,
69+
annualized_return,
70+
annualized_volatility,
71+
trades,
72+
buy_and_hold_return,
73+
buy_and_hold_volatility,
74+
tac,
5575
):
5676
"""Create a Plotly table for the portfolio metrics."""
5777
table = go.Table(
@@ -63,18 +83,22 @@ def _create_metrics_table(
6383
cells={
6484
"values": [
6585
[
66-
"Total Return",
67-
"Annualized Return",
86+
"Total Strategy Return",
87+
"Annualized Strategy Return",
88+
"Annualized Strategy Volatility",
6889
"Trades",
6990
"Assumed TAC",
70-
"Benchmark: Annualized Buy and Hold Return",
91+
"Annualized Buy and Hold Return",
92+
"Annualized Buy and Hold Volatility",
7193
],
7294
[
7395
f"{portfolio_return:.2f}%",
7496
f"{annualized_return:.2f}%",
97+
f"{annualized_volatility:.2f}%",
7598
trades,
7699
f"{tac * 100:.2f}%",
77100
f"{buy_and_hold_return:.2f}%",
101+
f"{buy_and_hold_volatility:.2f}%",
78102
],
79103
],
80104
"align": "left",
@@ -131,3 +155,29 @@ def _calculate_trades(shares):
131155
"""Calculate the number of trades by counting changes in the shares held."""
132156
trades = shares.diff().fillna(0).ne(0).sum()
133157
return trades
158+
159+
160+
def _calculate_annualized_volatility(stock):
161+
if len(stock) <= 1:
162+
return 0
163+
164+
daily_log_returns = np.log(stock / stock.shift(1)).dropna()
165+
daily_volatility = daily_log_returns.std()
166+
years = _calculate_years(stock.index)
167+
days_per_year = 365 / years
168+
169+
if years == 0:
170+
return 0
171+
172+
annualized_volatility = daily_volatility * np.sqrt(days_per_year) * 100
173+
return annualized_volatility
174+
175+
176+
def _buy_and_hold_strategy(initial_cash, prices):
177+
first_price = prices.iloc[0]
178+
179+
if first_price == 0:
180+
return 0
181+
182+
shares = np.floor(initial_cash / first_price)
183+
return shares * prices

src/backtest_bay/plot/task_plot.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import pandas as pd
22
import pytask
33

4-
from backtest_bay.config import BLD, PARAMS, SRC, TAC
4+
from backtest_bay.config import BLD, INITIAL_CASH, PARAMS, SRC, TAC
55
from backtest_bay.plot.plot_portfolio import plot_portfolio
66
from backtest_bay.plot.plot_signals import plot_signals
77

@@ -35,5 +35,5 @@ def task_plot(
3535
portfolio = pd.read_pickle(backtest_path)
3636
fig = plot_signals(portfolio, id_backtest)
3737
fig.write_html(produces.get("plot_signals"))
38-
fig = plot_portfolio(portfolio, id_backtest, TAC)
38+
fig = plot_portfolio(portfolio, id_backtest, TAC, INITIAL_CASH)
3939
fig.write_html(produces.get("plot_portfolio"))

tests/plot/test_plot_portfolio.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
import pytest
44

55
from backtest_bay.plot.plot_portfolio import (
6+
_buy_and_hold_strategy,
67
_calculate_annualized_return,
8+
_calculate_annualized_volatility,
79
_calculate_portfolio_return,
810
_calculate_trades,
911
_calculate_years,
@@ -22,7 +24,7 @@
2224
],
2325
)
2426
def test_calculate_portfolio_return_valid_calculation(stock, expected_return):
25-
"""Check if calculated return equals expected value."""
27+
"""Test if _calculate_portfolio_return returns the correct return value."""
2628
result = _calculate_portfolio_return(stock)
2729
assert result == expected_return
2830

@@ -39,6 +41,7 @@ def test_calculate_portfolio_return_valid_calculation(stock, expected_return):
3941
],
4042
)
4143
def test_calculate_years(index, expected):
44+
"""Test if _calculate_years correctly computes the number of years."""
4245
result = _calculate_years(index)
4346
assert np.isclose(result, expected, atol=1e-2)
4447

@@ -69,6 +72,7 @@ def test_calculate_years(index, expected):
6972
],
7073
)
7174
def test_calculate_annualized_return(stock, expected):
75+
"""Test if _calculate_annualized_return correctly calculates annualized returns."""
7276
result = _calculate_annualized_return(stock)
7377
assert np.isclose(result, expected, atol=1e-1)
7478

@@ -83,5 +87,42 @@ def test_calculate_annualized_return(stock, expected):
8387
],
8488
)
8589
def test_calculate_trades(shares, expected):
90+
"""Test if _calculate_trades correctly counts the number of trades."""
8691
result = _calculate_trades(shares)
8792
assert result == expected
93+
94+
95+
# Tests for _calculate_annualized_volatility
96+
@pytest.mark.parametrize(
97+
("stock_prices", "expected_volatility"),
98+
[
99+
([100, 100, 100, 100, 100], 0),
100+
([100, 101, 102, 103, 104], pytest.approx(2.26, rel=1e-2)),
101+
([100], 0),
102+
],
103+
)
104+
def test_calculate_annualized_volatility(stock_prices, expected_volatility):
105+
stock = pd.Series(
106+
stock_prices, index=pd.date_range(start="2022-01-01", periods=len(stock_prices))
107+
)
108+
result = _calculate_annualized_volatility(stock)
109+
assert result == expected_volatility
110+
111+
112+
# Tests for _buy_and_hold_strategy
113+
@pytest.mark.parametrize(
114+
("initial_cash", "prices", "expected"),
115+
[
116+
# Standard case with positive prices
117+
(100, pd.Series([10, 12, 15, 18]), pd.Series([100.0, 120.0, 150.0, 180.0])),
118+
# Not enough cash to buy even one share
119+
(5, pd.Series([10, 12, 15, 18]), pd.Series([0.0, 0.0, 0.0, 0.0])),
120+
# Cash exactly enough to buy one share
121+
(10, pd.Series([10, 12, 15, 18]), pd.Series([10.0, 12.0, 15.0, 18.0])),
122+
],
123+
)
124+
def test_buy_and_hold_strategy(initial_cash, prices, expected):
125+
"""Test if _calculate_annualized_volatility correctly calculates annualized
126+
volatility."""
127+
result = _buy_and_hold_strategy(initial_cash, prices)
128+
pd.testing.assert_series_equal(result, expected)

0 commit comments

Comments
 (0)