Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .husky/commit-msg
Binary file not shown.
2 changes: 1 addition & 1 deletion src/quant_research_starter/backtest/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Backtest module public API."""
"""Backtesting engines and portfolio simulation."""

from .vectorized import VectorizedBacktest

Expand Down
24 changes: 22 additions & 2 deletions src/quant_research_starter/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from .backtest import VectorizedBacktest
from .data import SampleDataLoader, SyntheticDataGenerator
from .factors import MomentumFactor, SizeFactor, ValueFactor, VolatilityFactor
from .metrics import RiskMetrics
from .metrics import RiskMetrics, create_equity_curve_plot


@click.group()
Expand Down Expand Up @@ -127,7 +127,13 @@ def compute_factors(data_file, factors, output):
help="Output file for results",
)
@click.option("--plot/--no-plot", default=True, help="Generate plot")
def backtest(data_file, signals_file, initial_capital, output, plot):
@click.option(
"--plotly",
is_flag=True,
default=False,
help="Also generate interactive Plotly HTML chart",
)
def backtest(data_file, signals_file, initial_capital, output, plot, plotly):
"""Run backtest with given signals."""
click.echo("Running backtest...")

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

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

# Generate Plotly HTML chart if requested
if plotly:
html_path = output_path.parent / "backtest_plot.html"

create_equity_curve_plot(
dates=results_dict["dates"],
portfolio_values=results_dict["portfolio_value"],
initial_capital=initial_capital,
output_path=str(html_path),
plot_type="html",
)

click.echo(f"Plotly HTML chart saved -> {html_path}")

click.echo("Backtest completed!")
click.echo(f"Final portfolio value: ${results['final_value']:,.2f}")
click.echo(f"Total return: {results['total_return']:.2%}")
Expand Down
3 changes: 2 additions & 1 deletion src/quant_research_starter/metrics/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Metrics module public API."""

from .plotting import create_equity_curve_plot
from .risk import RiskMetrics

__all__ = ["RiskMetrics"]
__all__ = ["create_equity_curve_plot", "RiskMetrics"]
94 changes: 94 additions & 0 deletions src/quant_research_starter/metrics/plotting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""Plotting utilities for backtest results."""

from pathlib import Path
from typing import List

import pandas as pd

try:
import plotly.graph_objects as go

PLOTLY_AVAILABLE = True
except ImportError:
PLOTLY_AVAILABLE = False

import matplotlib.pyplot as plt


def create_equity_curve_plot(
dates: List[str],
portfolio_values: List[float],
initial_capital: float,
output_path: str,
plot_type: str = "png",
) -> str:
"""
Create equity curve plot in specified format.
Args:
dates: List of date strings
portfolio_values: List of portfolio values
initial_capital: Starting capital
output_path: Path to save the plot
plot_type: 'png' for matplotlib, 'html' for plotly

Returns:
Path to saved file
"""
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
dates = pd.to_datetime(dates)
portfolio_series = pd.Series(portfolio_values, index=dates)
if plot_type == "html" and PLOTLY_AVAILABLE:
return _create_plotly_chart(portfolio_series, initial_capital, output_path)
else:
return _create_matplotlib_chart(portfolio_series, output_path)


def _create_plotly_chart(
portfolio_series: pd.Series, initial_capital: float, output_path: str
) -> str:
"""Create interactive Plotly HTML chart."""
fig = go.Figure()
fig.add_trace(
go.Scatter(
x=portfolio_series.index,
y=portfolio_series.values,
mode="lines",
name="Portfolio Value",
line={"color": "#2E86AB", "width": 3},
hovertemplate="<b>Date</b>: %{x|%Y-%m-%d}<br><b>Value</b>: $%{y:,.2f}<extra></extra>",
)
)
# Add initial capital reference line
fig.add_hline(
y=initial_capital,
line_dash="dash",
line_color="red",
annotation_text=f"Initial Capital: ${initial_capital:,.0f}",
annotation_position="bottom right",
)
total_return_pct = ((portfolio_series.iloc[-1] / initial_capital) - 1) * 10
fig.update_layout(
title=f"Backtest Performance (Total Return: {total_return_pct:+.1f}%)",
xaxis_title="Date",
yaxis_title="Portfolio Value ($)",
template="plotly_white",
hovermode="x unified",
height=500,
)
fig.update_yaxes(tickprefix="$", tickformat=",.0f")
fig.write_html(output_path)
return output_path


def _create_matplotlib_chart(portfolio_series: pd.Series, output_path: str) -> str:
"""Create static matplotlib PNG chart."""
plt.figure(figsize=(10, 6))
plt.plot(portfolio_series.index, portfolio_series.values, linewidth=2)
plt.title("Portfolio Value")
plt.ylabel("USD")
plt.grid(True, alpha=0.3)
plt.gcf().autofmt_xdate()
plt.tight_layout()
plt.savefig(output_path, dpi=150, bbox_inches="tight")
plt.close()
return output_path
55 changes: 55 additions & 0 deletions tests/test_plotting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import os

from quant_research_starter.metrics.plotting import create_equity_curve_plot


def test_plotly_html_creation():
# Create test data matching the backtest results structure
dates = [f"2020-01-{i+1:02d}" for i in range(20)]
portfolio_values = [1000000 + i * 5000 for i in range(20)]

# Test HTML creation
test_html_path = "test_output/backtest_plot.html"
html_path = create_equity_curve_plot(
dates=dates,
portfolio_values=portfolio_values,
initial_capital=1000000,
output_path=test_html_path,
plot_type="html",
)

# Verify file was created
assert os.path.exists(html_path)
assert html_path.endswith(".html")
assert os.path.getsize(html_path) > 1000

# Cleanup
if os.path.exists(html_path):
os.remove(html_path)
if os.path.exists("test_output"):
os.rmdir("test_output")


def test_plotly_fallback_to_matplotlib():
"""Test that PNG is created even if Plotly is not available."""
dates = [f"2020-01-{i+1:02d}" for i in range(15)]
portfolio_values = [1000000 + i * 3000 for i in range(15)]

# Test PNG creation
test_png_path = "test_output/backtest_plot.png"
png_path = create_equity_curve_plot(
dates=dates,
portfolio_values=portfolio_values,
initial_capital=1000000,
output_path=test_png_path,
plot_type="png",
)

assert os.path.exists(png_path)
assert png_path.endswith(".png")

# Cleanup
if os.path.exists(png_path):
os.remove(png_path)
if os.path.exists("test_output"):
os.rmdir("test_output")
Loading