diff --git a/.husky/commit-msg b/.husky/commit-msg index dd711a8..6e57fd8 100644 Binary files a/.husky/commit-msg and b/.husky/commit-msg differ diff --git a/src/quant_research_starter/backtest/__init__.py b/src/quant_research_starter/backtest/__init__.py index c32e8c8..98647cf 100644 --- a/src/quant_research_starter/backtest/__init__.py +++ b/src/quant_research_starter/backtest/__init__.py @@ -1,4 +1,4 @@ -"""Backtest module public API.""" +"""Backtesting engines and portfolio simulation.""" from .vectorized import VectorizedBacktest diff --git a/src/quant_research_starter/cli.py b/src/quant_research_starter/cli.py index 2fdb8a6..18ca8c1 100644 --- a/src/quant_research_starter/cli.py +++ b/src/quant_research_starter/cli.py @@ -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() @@ -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...") @@ -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%}") diff --git a/src/quant_research_starter/metrics/__init__.py b/src/quant_research_starter/metrics/__init__.py index b2b4850..6438e82 100644 --- a/src/quant_research_starter/metrics/__init__.py +++ b/src/quant_research_starter/metrics/__init__.py @@ -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"] diff --git a/src/quant_research_starter/metrics/plotting.py b/src/quant_research_starter/metrics/plotting.py new file mode 100644 index 0000000..1baa26c --- /dev/null +++ b/src/quant_research_starter/metrics/plotting.py @@ -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="Date: %{x|%Y-%m-%d}
Value: $%{y:,.2f}", + ) + ) + # 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 diff --git a/tests/test_plotting.py b/tests/test_plotting.py new file mode 100644 index 0000000..45a6a3d --- /dev/null +++ b/tests/test_plotting.py @@ -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")