Skip to content

Commit 843f8bb

Browse files
feat: add Plotly PnL chart to CLI
1 parent e057e37 commit 843f8bb

File tree

6 files changed

+169
-4
lines changed

6 files changed

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