diff --git a/lumibot/tools/indicators.py b/lumibot/tools/indicators.py index 59c0ea34b..fb3fa08d7 100644 --- a/lumibot/tools/indicators.py +++ b/lumibot/tools/indicators.py @@ -1742,15 +1742,17 @@ def _write_tearsheet_metrics_json(reason: str, scalar_metrics: dict | None = Non logger.error("Not enough data to create a tearsheet, at least 2 days of data are required. Skipping") return ''' - # Set the name of the benchmark column so that quantstats can use it in the report - df_final["benchmark"].name = str(benchmark_asset) + # Set the name of the benchmark column so that quantstats can use it in the report. + # df_final["benchmark"] returns a new Series each time, so we must capture it; + # assigning to .name on a throwaway accessor is a no-op. + _benchmark_series = df_final["benchmark"].rename(str(benchmark_asset)) # Run quantstats reports surpressing any logs because it can be noisy for no reason try: with open(os.devnull, "w") as f, contextlib.redirect_stdout(f), contextlib.redirect_stderr(f): result = qs.reports.html( df_final["strategy"], - df_final["benchmark"], + _benchmark_series, title=title, output=tearsheet_file, download_filename=tearsheet_file, # Consider if you need a different name for clarity @@ -1836,7 +1838,7 @@ def _histogram_no_kde( with open(os.devnull, "w") as f, contextlib.redirect_stdout(f), contextlib.redirect_stderr(f): result = qs.reports.html( df_final["strategy"], - df_final["benchmark"], + _benchmark_series, title=title, output=tearsheet_file, download_filename=tearsheet_file, @@ -1941,7 +1943,7 @@ def _write_tearsheet_metrics_json_fallback() -> None: with open(os.devnull, "w") as f, contextlib.redirect_stdout(f), contextlib.redirect_stderr(f): metrics_df = qs.reports.metrics( df_final["strategy"], - df_final["benchmark"], + _benchmark_series, rf=risk_free_rate, display=False, custom_metrics=custom_metrics, @@ -1992,7 +1994,7 @@ def _write_tearsheet_metrics_json_fallback() -> None: with open(os.devnull, "w") as f, contextlib.redirect_stdout(f), contextlib.redirect_stderr(f): metrics_json_fn( df_final["strategy"], - df_final["benchmark"], + _benchmark_series, rf=risk_free_rate, output=tearsheet_metrics_file, summary_only=True, diff --git a/tests/test_tearsheet_quantstats_args.py b/tests/test_tearsheet_quantstats_args.py new file mode 100644 index 000000000..14a32adbd --- /dev/null +++ b/tests/test_tearsheet_quantstats_args.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from datetime import datetime + +import pandas as pd + +from lumibot.tools.indicators import create_tearsheet + + +def _make_inputs(tmp_path): + idx = pd.date_range(datetime(2025, 12, 8), periods=5, freq="1D") + strategy_df = pd.DataFrame({"portfolio_value": [100, 101, 99, 102, 103]}, index=idx) + benchmark_df = pd.DataFrame({"symbol_cumprod": [1.0, 1.005, 1.002, 1.01, 1.011]}, index=idx) + return strategy_df, benchmark_df, tmp_path / "tearsheet.html" + + +def _base_kwargs(tearsheet_file, benchmark_asset): + return dict( + strat_name="TestStrategy", + tearsheet_file=str(tearsheet_file), + benchmark_asset=benchmark_asset, + show_tearsheet=False, + save_tearsheet=True, + risk_free_rate=0.0, + strategy_parameters={}, + lumibot_version="dev", + backtesting_data_source="yahoo", + backtesting_data_sources="yahoo", + backtest_time_seconds=1.0, + ) + + +def _fake_html_writer(output): + with open(output, "w", encoding="utf-8") as f: + f.write("ok") + return pd.DataFrame({"Metric": ["Total Return"], "Strategy": ["1.00%"]}) + + +def test_benchmark_series_name_passed_to_quantstats(monkeypatch, tmp_path): + """Regression: benchmark label must be the asset symbol, not 'benchmark' / 'BENCHMARK'. + + df_final["benchmark"] returns a new Series on each access, so assigning to .name on + that throwaway object is a no-op. The fix captures the series with .rename() before + passing to qs.reports.html. Without the fix, QuantStats uppercases the column name + and shows 'BENCHMARK' in the tearsheet instead of the actual symbol (e.g. 'SPY'). + """ + import quantstats_lumi as qs + + captured = {} + + def fake_html(returns, benchmark=None, **kwargs): + captured["benchmark_name"] = getattr(benchmark, "name", None) + return _fake_html_writer(kwargs.get("output")) + + monkeypatch.setattr(qs.reports, "html", fake_html) + + strategy_df, benchmark_df, out = _make_inputs(tmp_path) + create_tearsheet(strategy_df=strategy_df, benchmark_df=benchmark_df, **_base_kwargs(out, "SPY")) + + assert captured.get("benchmark_name") == "SPY", ( + f"Expected benchmark Series name 'SPY' but got {captured.get('benchmark_name')!r}. " + "The no-op .name assignment bug causes QuantStats to see name='benchmark' and " + "uppercase it to 'BENCHMARK' in the tearsheet header." + ) + + +def test_benchmark_series_name_preserved_on_kde_retry(monkeypatch, tmp_path): + """Regression: benchmark label must also be correct on the KDE-failure retry path. + + The fix assigns _benchmark_series once before both qs.reports.html call sites. + This test verifies the retry path receives the same named Series as the primary path. + """ + import quantstats_lumi as qs + + benchmark_names = [] + + def fake_html(returns, benchmark=None, **kwargs): + benchmark_names.append(getattr(benchmark, "name", None)) + if len(benchmark_names) == 1: + raise RuntimeError("singular data covariance matrix ... gaussian_kde") + return _fake_html_writer(kwargs.get("output")) + + monkeypatch.setattr(qs.reports, "html", fake_html) + + strategy_df, benchmark_df, out = _make_inputs(tmp_path) + create_tearsheet(strategy_df=strategy_df, benchmark_df=benchmark_df, **_base_kwargs(out, "QQQ")) + + assert len(benchmark_names) == 2, f"Expected 2 qs.reports.html calls (primary + retry), got {len(benchmark_names)}" + for i, name in enumerate(benchmark_names): + assert name == "QQQ", ( + f"Call {i + 1}: expected benchmark Series name 'QQQ' but got {name!r}. " + "The retry path must use _benchmark_series, not df_final['benchmark']." + )