From 843f8bbc3dbbbdedf1a6288bc7d274f9dbfd4074 Mon Sep 17 00:00:00 2001 From: Satvik-Singh192 Date: Mon, 27 Oct 2025 12:35:18 +0530 Subject: [PATCH 1/2] feat: add Plotly PnL chart to CLI --- .husky/commit-msg | Bin 119 -> 44 bytes .../backtest/__init__.py | 2 +- src/quant_research_starter/cli.py | 24 ++++- .../metrics/__init__.py | 3 +- .../metrics/plotting.py | 89 ++++++++++++++++++ tests/test_plotting.py | 55 +++++++++++ 6 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 src/quant_research_starter/metrics/plotting.py create mode 100644 tests/test_plotting.py diff --git a/.husky/commit-msg b/.husky/commit-msg index dd711a89772ba85ae8eb45b29c4577ad364a7406..6e57fd81a8a6ca696a573732ea3180c0e07513d3 100644 GIT binary patch literal 44 ucmezWFOQ*sA(ugcp@^ZBArD9=GvovLxeS>MB@8)0G7m^AFz_;PF#rGs-3e6y literal 119 zcmcDq0RsgOS4S7Fyn {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..03b4492 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..09f2572 --- /dev/null +++ b/src/quant_research_starter/metrics/plotting.py @@ -0,0 +1,89 @@ +"""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..6249a57 --- /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") From 351699531060616c753b807a94815394bbac08a3 Mon Sep 17 00:00:00 2001 From: Satvik-Singh192 Date: Mon, 27 Oct 2025 12:42:20 +0530 Subject: [PATCH 2/2] style: format code with black --- src/quant_research_starter/cli.py | 4 +- .../metrics/__init__.py | 2 +- .../metrics/plotting.py | 49 ++++++++++--------- tests/test_plotting.py | 8 +-- 4 files changed, 34 insertions(+), 29 deletions(-) diff --git a/src/quant_research_starter/cli.py b/src/quant_research_starter/cli.py index b7a7249..18ca8c1 100644 --- a/src/quant_research_starter/cli.py +++ b/src/quant_research_starter/cli.py @@ -131,7 +131,7 @@ def compute_factors(data_file, factors, output): "--plotly", is_flag=True, default=False, - help="Also generate interactive Plotly HTML chart" + help="Also generate interactive Plotly HTML chart", ) def backtest(data_file, signals_file, initial_capital, output, plot, plotly): """Run backtest with given signals.""" @@ -229,7 +229,7 @@ def backtest(data_file, signals_file, initial_capital, output, plot, plotly): portfolio_values=results_dict["portfolio_value"], initial_capital=initial_capital, output_path=str(html_path), - plot_type="html" + plot_type="html", ) click.echo(f"Plotly HTML chart saved -> {html_path}") diff --git a/src/quant_research_starter/metrics/__init__.py b/src/quant_research_starter/metrics/__init__.py index 03b4492..6438e82 100644 --- a/src/quant_research_starter/metrics/__init__.py +++ b/src/quant_research_starter/metrics/__init__.py @@ -3,4 +3,4 @@ from .plotting import create_equity_curve_plot from .risk import RiskMetrics -__all__ = [ "create_equity_curve_plot","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 index 09f2572..1baa26c 100644 --- a/src/quant_research_starter/metrics/plotting.py +++ b/src/quant_research_starter/metrics/plotting.py @@ -7,6 +7,7 @@ try: import plotly.graph_objects as go + PLOTLY_AVAILABLE = True except ImportError: PLOTLY_AVAILABLE = False @@ -19,7 +20,7 @@ def create_equity_curve_plot( portfolio_values: List[float], initial_capital: float, output_path: str, - plot_type: str = "png" + plot_type: str = "png", ) -> str: """ Create equity curve plot in specified format. @@ -40,50 +41,54 @@ def create_equity_curve_plot( 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 + 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}' - )) + 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" + 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 + xaxis_title="Date", + yaxis_title="Portfolio Value ($)", + template="plotly_white", + hovermode="x unified", + height=500, ) - fig.update_yaxes(tickprefix='$', tickformat=',.0f') + 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.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.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 index 6249a57..45a6a3d 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -15,12 +15,12 @@ def test_plotly_html_creation(): portfolio_values=portfolio_values, initial_capital=1000000, output_path=test_html_path, - plot_type="html" + plot_type="html", ) # Verify file was created assert os.path.exists(html_path) - assert html_path.endswith('.html') + assert html_path.endswith(".html") assert os.path.getsize(html_path) > 1000 # Cleanup @@ -42,11 +42,11 @@ def test_plotly_fallback_to_matplotlib(): portfolio_values=portfolio_values, initial_capital=1000000, output_path=test_png_path, - plot_type="png" + plot_type="png", ) assert os.path.exists(png_path) - assert png_path.endswith('.png') + assert png_path.endswith(".png") # Cleanup if os.path.exists(png_path):