diff --git a/quantstats/reports.py b/quantstats/reports.py index f0d8a9ad..ed4d7b4d 100644 --- a/quantstats/reports.py +++ b/quantstats/reports.py @@ -97,6 +97,19 @@ def _get_trading_periods(periods_per_year=252): return periods_per_year, half_year +def _get_rf_display_value(rf, index=None): + """Return a scalar risk-free rate for report display.""" + return _get_utils()._to_scalar(rf, index) + + +def _format_rf(rf, index=None): + """Format scalar and time-varying risk-free rates for report parameters.""" + value = _get_rf_display_value(rf, index) + if isinstance(rf, (_pd.Series, _pd.DataFrame)): + return f"time-varying (avg {value:.1%})" + return f"{value:.1%}" + + def _print_parameters_table( benchmark_title=None, periods_per_year=252, @@ -127,7 +140,7 @@ def _print_parameters_table( if benchmark_title: print(f"{'Benchmark':<25}{benchmark_title.upper():>15}") print(f"{'Periods/Year':<25}{periods_per_year:>15}") - print(f"{'Risk-Free Rate':<25}{rf:>14.1%}") + print(f"{'Risk-Free Rate':<25}{_format_rf(rf):>15}") print(f"{'Compounded':<25}{'Yes' if compounded else 'No':>15}") if benchmark_title: print(f"{'Match Dates':<25}{'Yes' if match_dates else 'No':>15}") @@ -304,7 +317,7 @@ def html( if isinstance(benchmark, str): # Download the full benchmark data benchmark_original = _get_utils().download_returns(benchmark) - if rf != 0: + if _get_utils()._is_non_zero(rf): benchmark_original = _get_utils().to_excess_returns( benchmark_original, rf, nperiods=periods_per_year ) @@ -343,7 +356,7 @@ def html( if benchmark_title: params_parts.append(f"Benchmark: {benchmark_title.upper()}") params_parts.append(f"Periods/Year: {periods_per_year}") - params_parts.append(f"RF: {rf:.1%}") + params_parts.append(f"RF: {_format_rf(rf, returns.index)}") params_str = " • ".join(params_parts) if params_str: @@ -1263,10 +1276,11 @@ def metrics( df["returns_" + str(i + 1)] = returns[strategy_col] # Calculate start and end dates for each series + display_rf = _get_rf_display_value(rf, df.index) if isinstance(returns, _pd.Series): s_start = {"returns": df["returns"].index.strftime("%Y-%m-%d")[0]} s_end = {"returns": df["returns"].index.strftime("%Y-%m-%d")[-1]} - s_rf = {"returns": rf} + s_rf = {"returns": display_rf} elif isinstance(returns, _pd.DataFrame): df_strategy_columns = [col for col in df.columns if col != "benchmark"] s_start = { @@ -1277,13 +1291,13 @@ def metrics( strategy_col: df[strategy_col].dropna().index.strftime("%Y-%m-%d")[-1] for strategy_col in df_strategy_columns } - s_rf = {strategy_col: rf for strategy_col in df_strategy_columns} + s_rf = {strategy_col: display_rf for strategy_col in df_strategy_columns} # Add benchmark dates if present if "benchmark" in df: s_start["benchmark"] = df["benchmark"].index.strftime("%Y-%m-%d")[0] s_end["benchmark"] = df["benchmark"].index.strftime("%Y-%m-%d")[-1] - s_rf["benchmark"] = rf + s_rf["benchmark"] = display_rf # Fill missing values with zeros for calculations df = df.fillna(0) @@ -1802,7 +1816,7 @@ def metrics( params_data = { "Parameter": ["Risk-Free Rate", "Periods/Year", "Compounded", "Match Dates"], "Value": [ - f"{rf:.1%}" if rf != 0 else "0.0%", + _format_rf(rf, df.index), str(periods_per_year), "Yes" if compounded else "No", "Yes" if match_dates else "No", diff --git a/quantstats/stats.py b/quantstats/stats.py index 266f9513..3a5bded7 100644 --- a/quantstats/stats.py +++ b/quantstats/stats.py @@ -873,7 +873,7 @@ def sharpe( validate_input(returns) # Validate parameters for risk-free rate handling - if rf != 0 and periods is None: + if _utils._is_non_zero(rf) and periods is None: raise ValueError("periods parameter is required when risk-free rate (rf) is non-zero. " "This is needed to properly annualize the risk-free rate.") @@ -963,7 +963,7 @@ def rolling_sharpe( >>> print(rolling_sharpe_ratio) """ # Validate parameters for risk-free rate handling - if rf != 0 and rolling_period is None: + if _utils._is_non_zero(rf) and rolling_period is None: raise Exception("Must provide periods if rf != 0") if prepare_returns: @@ -1018,7 +1018,7 @@ def sortino( validate_input(returns) # Validate parameters for risk-free rate handling - if rf != 0 and periods is None: + if _utils._is_non_zero(rf) and periods is None: raise ValueError("periods parameter is required when risk-free rate (rf) is non-zero. " "This is needed to properly annualize the risk-free rate.") @@ -1114,7 +1114,7 @@ def rolling_sortino( >>> print(rolling_sortino_ratio) """ # Validate parameters for risk-free rate handling - if rf != 0 and rolling_period is None: + if _utils._is_non_zero(rf) and rolling_period is None: raise Exception("Must provide periods if rf != 0") if kwargs.get("prepare_returns", True): @@ -1219,6 +1219,8 @@ def probabilistic_ratio( >>> prob_ratio = probabilistic_ratio(returns, base="sharpe") >>> print(f"Probabilistic Sharpe ratio: {prob_ratio:.4f}") """ + rf = _utils._to_scalar(rf, series.index) + # Calculate the base ratio depending on the selected metric if base.lower() == "sharpe": base = sharpe(series, periods=periods, annualize=False, smart=smart) @@ -1237,6 +1239,11 @@ def probabilistic_ratio( n = len(series) + if isinstance(series, _pd.DataFrame): + base = _np.asarray(base, dtype=float) + skew_no = _np.asarray(skew_no, dtype=float) + kurtosis_no = _np.asarray(kurtosis_no, dtype=float) + # Calculate standard error of the ratio incorporating higher moments # Formula accounts for skewness and kurtosis effects on ratio distribution sigma_sr = _np.sqrt( @@ -1247,6 +1254,8 @@ def probabilistic_ratio( # Calculate standardized ratio and convert to probability ratio = (base - rf) / sigma_sr psr = _norm.cdf(ratio) + if isinstance(series, _pd.DataFrame): + psr = _pd.Series(psr, index=series.columns) # Annualize if requested if annualize: @@ -1388,7 +1397,7 @@ def treynor_ratio(returns, benchmark, periods=252.0, rf=0.0): return 0 # Calculate excess return over risk-free rate divided by beta - return (comp(returns) - rf) / beta + return (comp(returns) - _utils._to_scalar(rf, returns.index)) / beta def omega( @@ -1723,6 +1732,8 @@ def ulcer_performance_index(returns, rf=0): >>> upi_value = ulcer_performance_index(returns) >>> print(f"Ulcer Performance Index: {upi_value:.4f}") """ + rf = _utils._to_scalar(rf, returns.index) + # Calculate excess return divided by Ulcer Index ulcer = ulcer_index(returns) @@ -1778,6 +1789,8 @@ def serenity_index(returns, rf=0): Based on KeyQuant whitepaper: https://www.keyquant.com/Download/GetFile?Filename=%5CPublications%5CKeyQuant_WhitePaper_APT_Part1.pdf """ + rf = _utils._to_scalar(rf, returns.index) + # Convert returns to drawdown series dd = to_drawdown_series(returns) @@ -2350,6 +2363,8 @@ def recovery_factor(returns, rf=0.0, prepare_returns=True): >>> rf_value = recovery_factor(returns) >>> print(f"Recovery factor: {rf_value:.4f}") """ + rf = _utils._to_scalar(rf, returns.index) + if prepare_returns: returns = _utils._prepare_returns(returns) diff --git a/quantstats/utils.py b/quantstats/utils.py index 14c235da..c97d55ca 100644 --- a/quantstats/utils.py +++ b/quantstats/utils.py @@ -101,6 +101,26 @@ def validate_input(data, allow_empty=False): return True +def _is_non_zero(value): + """Return True if a scalar or pandas object contains any non-zero values.""" + if isinstance(value, (_pd.Series, _pd.DataFrame)): + return bool((value.fillna(0) != 0).to_numpy().any()) + return bool(value != 0) + + +def _to_scalar(value, index=None): + """Return a scalar value, averaging pandas objects after optional index alignment.""" + if isinstance(value, (_pd.Series, _pd.DataFrame)): + values = value + if index is not None: + values = values[values.index.isin(index)] + result = values.mean() + if isinstance(result, _pd.Series): + result = result.mean() + return 0.0 if _pd.isna(result) else float(result) + return value + + # Cache for _prepare_returns function with thread safety _PREPARE_RETURNS_CACHE = {} _CACHE_MAX_SIZE = 100 @@ -134,8 +154,15 @@ def _generate_cache_key(data, rf, nperiods): else: data_hash = hash(str(data)) + if isinstance(rf, _pd.Series): + rf_hash = _pd.util.hash_pandas_object(rf).sum() + elif isinstance(rf, _pd.DataFrame): + rf_hash = _pd.util.hash_pandas_object(rf).sum() + else: + rf_hash = rf + # Include parameters in the key - key = f"{data_hash}_{rf}_{nperiods}" + key = f"{data_hash}_{rf_hash}_{nperiods}" return key except (ValueError, TypeError, AttributeError, MemoryError): # If hashing fails, return None to skip caching @@ -534,7 +561,10 @@ def to_excess_returns(returns: Returns, rf: float, nperiods: int | None = None) rf = _np.power(1 + rf, 1.0 / nperiods) - 1.0 # Calculate excess returns - df = returns - rf + if isinstance(returns, _pd.DataFrame) and isinstance(rf, _pd.Series): + df = returns.sub(rf, axis=0) + else: + df = returns - rf df = df.tz_localize(None) return df @@ -638,7 +668,7 @@ def _prepare_returns(data, rf=0.0, nperiods=None): # Calculate excess returns if rf > 0 and function needs it if function not in unnecessary_function_calls: - if rf > 0: + if _is_non_zero(rf): result = to_excess_returns(data, rf, nperiods) # Cache the result if cache_key: diff --git a/tests/test_reports.py b/tests/test_reports.py index e03bcc97..a1d2c26c 100644 --- a/tests/test_reports.py +++ b/tests/test_reports.py @@ -140,6 +140,24 @@ def test_html_custom_rf(self, sample_returns): if os.path.exists(output_path): os.remove(output_path) + def test_html_time_varying_rf(self, sample_returns): + """Test time-varying risk-free rate in parameters.""" + rf = pd.Series( + np.linspace(0.01, 0.03, len(sample_returns)), + index=sample_returns.index, + ) + with tempfile.NamedTemporaryFile(mode="w", suffix=".html", delete=False) as f: + output_path = f.name + + try: + reports.html(sample_returns, output=output_path, rf=rf) + with open(output_path, encoding="utf-8") as f: + content = f.read() + assert "RF: time-varying (avg 2.0%)" in content + finally: + if os.path.exists(output_path): + os.remove(output_path) + class TestMetrics: """Test metrics function.""" @@ -174,6 +192,18 @@ def test_metrics_with_rf(self, sample_returns): # Results should be different assert not result_no_rf.equals(result_with_rf) + def test_metrics_with_time_varying_rf(self, sample_returns): + """Test metrics with time-varying risk-free rate.""" + rf = pd.Series( + np.linspace(0.01, 0.03, len(sample_returns)), + index=sample_returns.index, + ) + + result = reports.metrics(sample_returns, rf=rf, display=False) + + assert isinstance(result, pd.DataFrame) + assert result.loc["Risk-Free Rate", "Strategy"] == 2.0 + class TestMatchDates: """Test date matching functionality.""" diff --git a/tests/test_stats.py b/tests/test_stats.py index 5a22f857..5cf9754e 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -140,6 +140,19 @@ def test_sharpe_with_rf(self, sample_returns): # Higher rf should lower Sharpe ratio assert result_with_rf < result_no_rf + def test_sharpe_with_time_varying_rf(self, sample_returns): + """Test Sharpe ratio with time-varying risk-free rate.""" + rf = pd.Series( + np.linspace(0.01, 0.03, len(sample_returns)), + index=sample_returns.index, + ) + + result = stats.sharpe(sample_returns, rf=rf) + + if isinstance(result, pd.Series): + result = result.iloc[0] + assert np.isfinite(result) + def test_sortino(self, sample_returns): """Test Sortino ratio calculation.""" result = stats.sortino(sample_returns) diff --git a/tests/test_utils.py b/tests/test_utils.py index e42d0e72..76c75a6b 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -64,6 +64,27 @@ def test_to_returns_from_prices(self, sample_prices): assert result.dropna().abs().max() < 1 +class TestToExcessReturns: + """Test excess returns calculation.""" + + def test_dataframe_subtracts_time_varying_rf_by_index(self): + """Test DataFrame returns subtract Series RF by date index.""" + dates = pd.date_range("2020-01-01", periods=3, freq="D") + returns = pd.DataFrame( + {"A": [0.02, 0.03, 0.04], "B": [0.01, 0.02, 0.03]}, + index=dates, + ) + rf = pd.Series([0.001, 0.002, 0.003], index=dates) + + result = utils.to_excess_returns(returns, rf) + + expected = pd.DataFrame( + {"A": [0.019, 0.028, 0.037], "B": [0.009, 0.018, 0.027]}, + index=dates, + ) + pd.testing.assert_frame_equal(result, expected) + + class TestToPrices: """Test to_prices function."""