diff --git a/quantstats/reports.py b/quantstats/reports.py index f0d8a9ad..a445c0d9 100644 --- a/quantstats/reports.py +++ b/quantstats/reports.py @@ -279,9 +279,10 @@ def html( # Handle strategy title - can be single string or list for multiple columns strategy_title = kwargs.get("strategy_title", "Strategy") - if isinstance(returns, _pd.DataFrame): - if len(returns.columns) > 1 and isinstance(strategy_title, str): - strategy_title = list(returns.columns) + if isinstance(returns, _pd.DataFrame) and isinstance(strategy_title, str): + strategy_title = ( + list(returns.columns) if len(returns.columns) > 1 else [strategy_title] + ) # Process benchmark data if provided if benchmark is not None: @@ -2239,6 +2240,12 @@ def _calc_dd(df, display=True, as_pct=False): else: ret_dd = dd_info + if isinstance(ret_dd.columns, _pd.MultiIndex) and ret_dd.columns.nlevels > 1: + strategy_levels = ret_dd.columns.get_level_values(0) + if strategy_levels.nunique() == 1: + # A single-column DataFrame has metric names at the second level. + ret_dd = ret_dd.xs(strategy_levels[0], axis=1, level=0) + # Calculate drawdown statistics based on data structure if ( any(ret_dd.columns.get_level_values(0).str.contains("returns")) diff --git a/quantstats/utils.py b/quantstats/utils.py index 14c235da..bdb525ce 100644 --- a/quantstats/utils.py +++ b/quantstats/utils.py @@ -126,16 +126,26 @@ def _generate_cache_key(data, rf, nperiods): Cache key string or None if hashing fails """ try: + # Include container metadata because equivalent Series and one-column + # DataFrames have the same value hash but must keep distinct results. # Create a hash from the data if isinstance(data, _pd.Series): data_hash = _pd.util.hash_pandas_object(data).sum() + metadata = ("Series", repr(data.name), str(data.dtype)) elif isinstance(data, _pd.DataFrame): data_hash = _pd.util.hash_pandas_object(data).sum() + metadata = ( + "DataFrame", + tuple(repr(column) for column in data.columns), + repr(data.columns.names), + tuple(str(dtype) for dtype in data.dtypes), + ) else: data_hash = hash(str(data)) + metadata = (type(data).__name__,) # Include parameters in the key - key = f"{data_hash}_{rf}_{nperiods}" + key = f"{data_hash}_{metadata}_{rf}_{nperiods}" return key except (ValueError, TypeError, AttributeError, MemoryError): # If hashing fails, return None to skip caching diff --git a/tests/test_reports.py b/tests/test_reports.py index e03bcc97..fb2d8fbe 100644 --- a/tests/test_reports.py +++ b/tests/test_reports.py @@ -9,7 +9,7 @@ import os import quantstats as qs -from quantstats import reports +from quantstats import reports, utils @pytest.fixture @@ -246,6 +246,26 @@ def test_html_with_dataframe(self, sample_returns, sample_benchmark): if os.path.exists(output_path): os.remove(output_path) + @pytest.mark.parametrize("warm_series_cache", [False, True]) + def test_html_single_column_dataframe_cache_isolation( + self, sample_returns, sample_benchmark, warm_series_cache + ): + """Single-column DataFrames work with cold and Series-warmed caches.""" + df = pd.DataFrame({"Strategy": sample_returns}) + utils._PREPARE_RETURNS_CACHE.clear() + if warm_series_cache: + utils._prepare_returns(sample_returns) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False) as f: + output_path = f.name + + try: + reports.html(df, sample_benchmark, output=output_path) + assert os.path.exists(output_path) + finally: + if os.path.exists(output_path): + os.remove(output_path) + def test_metrics_empty_benchmark_title(self, sample_returns, sample_benchmark): """Test metrics when benchmark has no name.""" benchmark_no_name = sample_benchmark.copy() diff --git a/tests/test_utils.py b/tests/test_utils.py index e42d0e72..06f117a0 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -52,6 +52,48 @@ def test_handles_dataframe(self, sample_prices): assert isinstance(result, pd.DataFrame) assert len(result.columns) == 2 + def test_cache_preserves_series_and_dataframe_types(self): + """Equivalent Series and DataFrame inputs must not share a cache entry.""" + index = pd.date_range("2024-01-01", periods=3) + series = pd.Series([0.01, -0.02, 0.03], index=index, name="Strategy") + frame = series.to_frame() + + utils._PREPARE_RETURNS_CACHE.clear() + prepared_frame = utils._prepare_returns(frame) + prepared_series = utils._prepare_returns(series) + + assert isinstance(prepared_frame, pd.DataFrame) + assert isinstance(prepared_series, pd.Series) + assert prepared_series.name == "Strategy" + assert utils._generate_cache_key(frame, 0.0, None) != utils._generate_cache_key( + series, 0.0, None + ) + assert utils._generate_cache_key( + series, 0.0, None + ) != utils._generate_cache_key(series.rename("Benchmark"), 0.0, None) + + utils._PREPARE_RETURNS_CACHE.clear() + prepared_series = utils._prepare_returns(series) + prepared_frame = utils._prepare_returns(frame) + + assert isinstance(prepared_series, pd.Series) + assert isinstance(prepared_frame, pd.DataFrame) + + def test_cache_preserves_dataframe_column_labels(self): + """Equivalent values with different labels must not share a cache entry.""" + index = pd.date_range("2024-01-01", periods=3) + frame_a = pd.DataFrame({"A": [0.01, -0.02, 0.03]}, index=index) + frame_b = pd.DataFrame({"B": [0.01, -0.02, 0.03]}, index=index) + + utils._PREPARE_RETURNS_CACHE.clear() + utils._prepare_returns(frame_a) + prepared_b = utils._prepare_returns(frame_b) + + assert prepared_b.columns.tolist() == ["B"] + assert utils._generate_cache_key( + frame_a, 0.0, None + ) != utils._generate_cache_key(frame_b, 0.0, None) + class TestToReturns: """Test to_returns function."""