Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 21 additions & 7 deletions quantstats/reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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 = " &bull; ".join(params_parts)
if params_str:
Expand Down Expand Up @@ -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 = {
Expand All @@ -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)
Expand Down Expand Up @@ -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",
Expand Down
25 changes: 20 additions & 5 deletions quantstats/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.")

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand All @@ -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(
Expand All @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down
36 changes: 33 additions & 3 deletions quantstats/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
30 changes: 30 additions & 0 deletions tests/test_reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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."""
Expand Down
13 changes: 13 additions & 0 deletions tests/test_stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 21 additions & 0 deletions tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down