Skip to content

Commit 864d4e9

Browse files
feat: add Plotly PnL chart to CLI
closes issue #5
2 parents e057e37 + 3516995 commit 864d4e9

File tree

6 files changed

+174
-4
lines changed

6 files changed

+174
-4
lines changed

.husky/commit-msg

-75 Bytes
Binary file not shown.

src/quant_research_starter/backtest/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Backtest module public API."""
1+
"""Backtesting engines and portfolio simulation."""
22

33
from .vectorized import VectorizedBacktest
44

src/quant_research_starter/cli.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from .backtest import VectorizedBacktest
1111
from .data import SampleDataLoader, SyntheticDataGenerator
1212
from .factors import MomentumFactor, SizeFactor, ValueFactor, VolatilityFactor
13-
from .metrics import RiskMetrics
13+
from .metrics import RiskMetrics, create_equity_curve_plot
1414

1515

1616
@click.group()
@@ -127,7 +127,13 @@ def compute_factors(data_file, factors, output):
127127
help="Output file for results",
128128
)
129129
@click.option("--plot/--no-plot", default=True, help="Generate plot")
130-
def backtest(data_file, signals_file, initial_capital, output, plot):
130+
@click.option(
131+
"--plotly",
132+
is_flag=True,
133+
default=False,
134+
help="Also generate interactive Plotly HTML chart",
135+
)
136+
def backtest(data_file, signals_file, initial_capital, output, plot, plotly):
131137
"""Run backtest with given signals."""
132138
click.echo("Running backtest...")
133139

@@ -214,6 +220,20 @@ def backtest(data_file, signals_file, initial_capital, output, plot):
214220

215221
click.echo(f"Plot saved -> {plot_path}")
216222

223+
# Generate Plotly HTML chart if requested
224+
if plotly:
225+
html_path = output_path.parent / "backtest_plot.html"
226+
227+
create_equity_curve_plot(
228+
dates=results_dict["dates"],
229+
portfolio_values=results_dict["portfolio_value"],
230+
initial_capital=initial_capital,
231+
output_path=str(html_path),
232+
plot_type="html",
233+
)
234+
235+
click.echo(f"Plotly HTML chart saved -> {html_path}")
236+
217237
click.echo("Backtest completed!")
218238
click.echo(f"Final portfolio value: ${results['final_value']:,.2f}")
219239
click.echo(f"Total return: {results['total_return']:.2%}")
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Metrics module public API."""
22

3+
from .plotting import create_equity_curve_plot
34
from .risk import RiskMetrics
45

5-
__all__ = ["RiskMetrics"]
6+
__all__ = ["create_equity_curve_plot", "RiskMetrics"]
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""Plotting utilities for backtest results."""
2+
3+
from pathlib import Path
4+
from typing import List
5+
6+
import pandas as pd
7+
8+
try:
9+
import plotly.graph_objects as go
10+
11+
PLOTLY_AVAILABLE = True
12+
except ImportError:
13+
PLOTLY_AVAILABLE = False
14+
15+
import matplotlib.pyplot as plt
16+
17+
18+
def create_equity_curve_plot(
19+
dates: List[str],
20+
portfolio_values: List[float],
21+
initial_capital: float,
22+
output_path: str,
23+
plot_type: str = "png",
24+
) -> str:
25+
"""
26+
Create equity curve plot in specified format.
27+
Args:
28+
dates: List of date strings
29+
portfolio_values: List of portfolio values
30+
initial_capital: Starting capital
31+
output_path: Path to save the plot
32+
plot_type: 'png' for matplotlib, 'html' for plotly
33+
34+
Returns:
35+
Path to saved file
36+
"""
37+
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
38+
dates = pd.to_datetime(dates)
39+
portfolio_series = pd.Series(portfolio_values, index=dates)
40+
if plot_type == "html" and PLOTLY_AVAILABLE:
41+
return _create_plotly_chart(portfolio_series, initial_capital, output_path)
42+
else:
43+
return _create_matplotlib_chart(portfolio_series, output_path)
44+
45+
46+
def _create_plotly_chart(
47+
portfolio_series: pd.Series, initial_capital: float, output_path: str
48+
) -> str:
49+
"""Create interactive Plotly HTML chart."""
50+
fig = go.Figure()
51+
fig.add_trace(
52+
go.Scatter(
53+
x=portfolio_series.index,
54+
y=portfolio_series.values,
55+
mode="lines",
56+
name="Portfolio Value",
57+
line={"color": "#2E86AB", "width": 3},
58+
hovertemplate="<b>Date</b>: %{x|%Y-%m-%d}<br><b>Value</b>: $%{y:,.2f}<extra></extra>",
59+
)
60+
)
61+
# Add initial capital reference line
62+
fig.add_hline(
63+
y=initial_capital,
64+
line_dash="dash",
65+
line_color="red",
66+
annotation_text=f"Initial Capital: ${initial_capital:,.0f}",
67+
annotation_position="bottom right",
68+
)
69+
total_return_pct = ((portfolio_series.iloc[-1] / initial_capital) - 1) * 10
70+
fig.update_layout(
71+
title=f"Backtest Performance (Total Return: {total_return_pct:+.1f}%)",
72+
xaxis_title="Date",
73+
yaxis_title="Portfolio Value ($)",
74+
template="plotly_white",
75+
hovermode="x unified",
76+
height=500,
77+
)
78+
fig.update_yaxes(tickprefix="$", tickformat=",.0f")
79+
fig.write_html(output_path)
80+
return output_path
81+
82+
83+
def _create_matplotlib_chart(portfolio_series: pd.Series, output_path: str) -> str:
84+
"""Create static matplotlib PNG chart."""
85+
plt.figure(figsize=(10, 6))
86+
plt.plot(portfolio_series.index, portfolio_series.values, linewidth=2)
87+
plt.title("Portfolio Value")
88+
plt.ylabel("USD")
89+
plt.grid(True, alpha=0.3)
90+
plt.gcf().autofmt_xdate()
91+
plt.tight_layout()
92+
plt.savefig(output_path, dpi=150, bbox_inches="tight")
93+
plt.close()
94+
return output_path

tests/test_plotting.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import os
2+
3+
from quant_research_starter.metrics.plotting import create_equity_curve_plot
4+
5+
6+
def test_plotly_html_creation():
7+
# Create test data matching the backtest results structure
8+
dates = [f"2020-01-{i+1:02d}" for i in range(20)]
9+
portfolio_values = [1000000 + i * 5000 for i in range(20)]
10+
11+
# Test HTML creation
12+
test_html_path = "test_output/backtest_plot.html"
13+
html_path = create_equity_curve_plot(
14+
dates=dates,
15+
portfolio_values=portfolio_values,
16+
initial_capital=1000000,
17+
output_path=test_html_path,
18+
plot_type="html",
19+
)
20+
21+
# Verify file was created
22+
assert os.path.exists(html_path)
23+
assert html_path.endswith(".html")
24+
assert os.path.getsize(html_path) > 1000
25+
26+
# Cleanup
27+
if os.path.exists(html_path):
28+
os.remove(html_path)
29+
if os.path.exists("test_output"):
30+
os.rmdir("test_output")
31+
32+
33+
def test_plotly_fallback_to_matplotlib():
34+
"""Test that PNG is created even if Plotly is not available."""
35+
dates = [f"2020-01-{i+1:02d}" for i in range(15)]
36+
portfolio_values = [1000000 + i * 3000 for i in range(15)]
37+
38+
# Test PNG creation
39+
test_png_path = "test_output/backtest_plot.png"
40+
png_path = create_equity_curve_plot(
41+
dates=dates,
42+
portfolio_values=portfolio_values,
43+
initial_capital=1000000,
44+
output_path=test_png_path,
45+
plot_type="png",
46+
)
47+
48+
assert os.path.exists(png_path)
49+
assert png_path.endswith(".png")
50+
51+
# Cleanup
52+
if os.path.exists(png_path):
53+
os.remove(png_path)
54+
if os.path.exists("test_output"):
55+
os.rmdir("test_output")

0 commit comments

Comments
 (0)