Statistics tab: use the BIL daily total return as rf (Fund Lab convention)

- No fixed risk-free rate: the statistics page now nets Sharpe, Sortino
  and CAPM alpha against BIL (SPDR 1-3 Month T-Bill) daily total
  returns from the data bundle — the same reference the Fund Lab uses
  for all its alphas (pre-2007 dates fill 0, as in fundlab.decompose).
- metrics: sharpe/sortino/beta_alpha/summary accept a daily rf SERIES
  (or scalar annual rate as before) via a shared excess() helper.
- Also fixes a latent double-count: the old scalar-rf alpha subtracted
  rf twice (once in the returns, once in the intercept term); alpha is
  now mean(excess fund) - beta * mean(excess bench), the standard CAPM
  intercept on excess returns.
- Page caption states the T-bill reference (or warns if BIL is absent).
- Tests: series-rf identities (sharpe/sortino/beta/alpha), benchmark
  self-row beta 1 / alpha 0, caption check.
This commit is contained in:
Greg Pomerantz 2026-09-02 07:03:17 -04:00
parent d5a7bb70b0
commit bdb887dee8
3 changed files with 62 additions and 37 deletions

29
app.py
View File

@ -60,6 +60,12 @@ if not up_to_date(Path(root)):
# data symbols are stored lowercase; input is resolved case-insensitively # data symbols are stored lowercase; input is resolved case-insensitively
_LOWER2SYM = {s.lower(): s for s in bundle.adj.columns} _LOWER2SYM = {s.lower(): s for s in bundle.adj.columns}
# risk-free rate for the statistics page: BIL (SPDR 1-3 Month T-Bill)
# daily total return — the same convention as the Fund Lab (alphas in
# excess of the 3-mo T-bill, cash earns exactly the rf rate)
_bil = _LOWER2SYM.get("bil")
rf = m.daily_returns(bundle.adj[_bil]) if _bil is not None else None
def _resolve_weights(w: dict) -> dict: def _resolve_weights(w: dict) -> dict:
"""Map parsed symbols onto the data's symbol case (insensitive match).""" """Map parsed symbols onto the data's symbol case (insensitive match)."""
@ -160,9 +166,6 @@ bench_spec = st.sidebar.text_input(
"benchmark.")) "benchmark."))
st.sidebar.subheader("Statistics") st.sidebar.subheader("Statistics")
rf_rate = st.sidebar.number_input(
"Risk-free rate % (Sharpe/Sortino/alpha are in excess of this)",
0.0, 20.0, float(_settings.get("rf_rate", 4.0))) / 100
stats_order = st.sidebar.text_input( stats_order = st.sidebar.text_input(
"Statistics (comma-separated, in display order)", "Statistics (comma-separated, in display order)",
value=_settings.get("stats_order", _STATS_DEFAULT), key="stats_order", value=_settings.get("stats_order", _STATS_DEFAULT), key="stats_order",
@ -171,8 +174,8 @@ stats_order = st.sidebar.text_input(
"Available: " + ", ".join(_STATS_VALID) + "\n" "Available: " + ", ".join(_STATS_VALID) + "\n"
"beta, alpha and ann_return_bench only have a value when a " "beta, alpha and ann_return_bench only have a value when a "
"benchmark is set. Sharpe, Sortino and alpha are computed in " "benchmark is set. Sharpe, Sortino and alpha are computed in "
"excess of the Risk-free rate. Empty field = show all, " "excess of the 3-mo T-bill (BIL daily total return), as in the "
"default order.")) "Fund Lab. Empty field = show all, default order."))
_stats_cols = [_STATS_RENAME.get(s.strip(), s.strip()) _stats_cols = [_STATS_RENAME.get(s.strip(), s.strip())
for s in stats_order.split(",") if s.strip()] for s in stats_order.split(",") if s.strip()]
_unknown_stats = [s for s in _stats_cols if s not in _STATS_VALID] _unknown_stats = [s for s in _stats_cols if s not in _STATS_VALID]
@ -231,7 +234,7 @@ if _end_ts == "bad":
_remember(spec=spec, bench_spec=bench_spec, scheme_index=_scheme_labels.index(scheme), _remember(spec=spec, bench_spec=bench_spec, scheme_index=_scheme_labels.index(scheme),
cost_bps=cost_bps, lt_rate=lt_rate * 100, st_rate=st_rate * 100, cost_bps=cost_bps, lt_rate=lt_rate * 100, st_rate=st_rate * 100,
div_rate=div_rate * 100, niit=niit * 100, sl_rate=sl_rate * 100, div_rate=div_rate * 100, niit=niit * 100, sl_rate=sl_rate * 100,
period=period, rf_rate=rf_rate * 100, stats_order=stats_order, period=period, stats_order=stats_order,
date_window=win, range_start=range_start, range_end=range_end) date_window=win, range_start=range_start, range_end=range_end)
if not spec.strip(): if not spec.strip():
@ -379,8 +382,10 @@ if len(results) > 1:
st.caption(f"Comparing: {', '.join(r['name'] for r in results)}") st.caption(f"Comparing: {', '.join(r['name'] for r in results)}")
st.caption(f"{scheme} · start {start} · cost {cost_bps} bps · " st.caption(f"{scheme} · start {start} · cost {cost_bps} bps · "
f"tax LT/ST/div {lt_rate:.0%}/{st_rate:.0%}/{div_rate:.0%} " f"tax LT/ST/div {lt_rate:.0%}/{st_rate:.0%}/{div_rate:.0%} "
f"+NIIT {niit:.1%} +state/local {sl_rate:.1%} · " + (" · Sharpe/Sortino/alpha in excess of the 3-mo T-bill (BIL)"
f"rf {rf_rate:.1%} (Sharpe/Sortino/alpha are rf-adjusted)" if rf is not None
else " · no T-bill (BIL) in the data — "
"Sharpe/Sortino/alpha unadjusted")
+ (f" · benchmark: {' ; '.join(b['label'] for b in benchmarks)}" + (f" · benchmark: {' ; '.join(b['label'] for b in benchmarks)}"
if benchmarks else "")) if benchmarks else ""))
@ -462,12 +467,12 @@ with tab_stats:
summaries = {} summaries = {}
for name, pre, after in rows: for name, pre, after in rows:
if both: if both:
summaries[f"{name} (pre-tax)"] = pick(m.summary(pre, bm, rf_rate)) summaries[f"{name} (pre-tax)"] = pick(m.summary(pre, bm, rf))
summaries[f"{name} (after-tax)"] = pick(m.summary(after, bm, rf_rate)) summaries[f"{name} (after-tax)"] = pick(m.summary(after, bm, rf))
elif show_pre: elif show_pre:
summaries[name] = pick(m.summary(pre, bm, rf_rate)) summaries[name] = pick(m.summary(pre, bm, rf))
else: else:
summaries[name] = pick(m.summary(after, bm, rf_rate)) summaries[name] = pick(m.summary(after, bm, rf))
st.dataframe(m.format_summary_table(summaries), width='stretch') st.dataframe(m.format_summary_table(summaries), width='stretch')
if benchmarks: if benchmarks:

View File

@ -18,6 +18,17 @@ def daily_returns(price: pd.Series | pd.DataFrame) -> pd.Series | pd.DataFrame:
return price.pct_change().fillna(0.0) return price.pct_change().fillna(0.0)
def excess(returns: pd.Series, rf: float | pd.Series | None) -> pd.Series:
"""Net returns against the risk-free rate.
rf may be a scalar ANNUAL rate (subtracted as rf/252 per day), a
DAILY rate series aligned by index (e.g. BIL total returns, filled 0
where absent), or None/0 for no adjustment."""
if rf is None or isinstance(rf, (int, float)):
return returns - rf / ANN
return returns - rf.reindex(returns.index).fillna(0.0)
def total_return(price: pd.Series | pd.DataFrame) -> float: def total_return(price: pd.Series | pd.DataFrame) -> float:
return float(price.iloc[-1] / price.iloc[0] - 1.0) return float(price.iloc[-1] / price.iloc[0] - 1.0)
@ -31,14 +42,14 @@ def annualized_vol(returns: pd.Series | pd.DataFrame) -> float:
return float(returns.std() * np.sqrt(ANN)) return float(returns.std() * np.sqrt(ANN))
def sharpe(returns: pd.Series, rf: float = 0.0) -> float: def sharpe(returns: pd.Series, rf: float | pd.Series | None = 0.0) -> float:
r = returns - rf / ANN r = excess(returns, rf)
sd = r.std() sd = r.std()
return float(r.mean() / sd * np.sqrt(ANN)) if sd > 0 else 0.0 return float(r.mean() / sd * np.sqrt(ANN)) if sd > 0 else 0.0
def sortino(returns: pd.Series, rf: float = 0.0) -> float: def sortino(returns: pd.Series, rf: float | pd.Series | None = 0.0) -> float:
r = returns - rf / ANN r = excess(returns, rf)
dd = float(np.sqrt(np.mean(np.minimum(r, 0.0) ** 2))) dd = float(np.sqrt(np.mean(np.minimum(r, 0.0) ** 2)))
return float(r.mean() / dd * np.sqrt(ANN)) if dd > 0 else 0.0 return float(r.mean() / dd * np.sqrt(ANN)) if dd > 0 else 0.0
@ -53,18 +64,20 @@ def calmar(price: pd.Series | pd.DataFrame) -> float:
return float(annualized_return(price) / -mdd) if mdd < 0 else 0.0 return float(annualized_return(price) / -mdd) if mdd < 0 else 0.0
def beta_alpha(returns: pd.Series, bench: pd.Series, rf: float = 0.0): def beta_alpha(returns: pd.Series, bench: pd.Series,
rf: float | pd.Series | None = 0.0):
"""CAPM regression. Returns (beta, annualized_alpha).""" """CAPM regression. Returns (beta, annualized_alpha)."""
r = (returns - rf / ANN).dropna() r = excess(returns, rf).dropna()
b = (bench - rf / ANN).dropna() b = excess(bench, rf).dropna()
r, b = r.align(b, join="inner") r, b = r.align(b, join="inner")
beta = np.cov(r, b)[0, 1] / np.var(b) beta = np.cov(r, b)[0, 1] / np.var(b)
alpha_daily = r.mean() - (rf / ANN + beta * (b.mean() - rf / ANN)) # r and b are already rf-adjusted
alpha_daily = r.mean() - beta * b.mean()
return float(beta), float(alpha_daily * ANN) return float(beta), float(alpha_daily * ANN)
def summary(price: pd.Series, bench: pd.Series | None = None, def summary(price: pd.Series, bench: pd.Series | None = None,
rf: float = 0.0) -> dict[str, float]: rf: float | pd.Series | None = 0.0) -> dict[str, float]:
r = daily_returns(price) r = daily_returns(price)
out = { out = {
"total_return": total_return(price), "total_return": total_return(price),

View File

@ -166,11 +166,28 @@ def _run() -> None:
check("old statistic names migrated", cols == ["return", "vol", "alpha"], check("old statistic names migrated", cols == ["return", "vol", "alpha"],
str(cols)) str(cols))
print("risk-free rate", flush=True) print("risk-free rate (BIL)", flush=True)
import metrics as _m
import numpy as _np
import pandas as _pd
idx = _pd.bdate_range("2020-01-01", periods=252)
_rng = _np.random.default_rng(7)
r = _pd.Series(_rng.normal(0.0005, 0.01, len(idx)), idx)
b = _pd.Series(_rng.normal(0.0002, 0.008, len(idx)), idx)
c = 0.0002
rf_s = _pd.Series(c, idx)
check("series rf: sharpe equals rf-shifted series",
abs(_m.sharpe(r, rf_s) - _m.sharpe(r - c)) < 1e-12)
check("series rf: sortino equals rf-shifted series",
abs(_m.sortino(r, rf_s) - _m.sortino(r - c)) < 1e-12)
check("series rf: beta invariant, alpha = shifted-series alpha",
abs(_m.beta_alpha(r, b, rf_s)[0] - _m.beta_alpha(r - c, b - c, 0.0)[0]) < 1e-12
and abs(_m.beta_alpha(r, b, rf_s)[1] - _m.beta_alpha(r - c, b - c, 0.0)[1]) < 1e-12)
check("scalar rf equals daily-constant series rf",
abs(_m.sharpe(r, c * _m.ANN) - _m.sharpe(r, rf_s)) < 1e-12)
run_app("MSFT", "V", stats_order="sharpe,sortino,beta,alpha,ann_return_bench") run_app("MSFT", "V", stats_order="sharpe,sortino,beta,alpha,ann_return_bench")
t0 = app().main.tabs[0].dataframe[0].value t0 = app().main.tabs[0].dataframe[0].value
alpha0 = t0.loc["Current", "alpha"]
sharpe0 = t0.loc["Current", "sharpe"]
beta_bench = float(t0.loc["benchmark: v", "beta"]) beta_bench = float(t0.loc["benchmark: v", "beta"])
alpha_bench = float(t0.loc["benchmark: v", "alpha"][:-1]) / 100 alpha_bench = float(t0.loc["benchmark: v", "alpha"][:-1]) / 100
# the benchmark vs ITSELF must be beta 1 / alpha 0 — catches the # the benchmark vs ITSELF must be beta 1 / alpha 0 — catches the
@ -179,19 +196,9 @@ def _run() -> None:
not app().exception and abs(beta_bench - 1.0) < 1e-9 not app().exception and abs(beta_bench - 1.0) < 1e-9
and abs(alpha_bench) < 1e-9, and abs(alpha_bench) < 1e-9,
f"beta={beta_bench} alpha={alpha_bench}") f"beta={beta_bench} alpha={alpha_bench}")
rf_widget = next(n for n in app().sidebar.number_input caps = " ".join(c.value for c in app().main.caption)
if "Risk-free" in n.label) check("caption states the T-bill reference",
check("default risk-free rate is 4%", rf_widget.value == 4.0, "3-mo T-bill (BIL)" in caps, caps[:400])
str(rf_widget.value))
rf_widget.set_value(10.0).run()
t1 = app().main.tabs[0].dataframe[0].value
check("alpha adjusts for the risk-free rate",
not app().exception and t1.loc["Current", "alpha"] != alpha0,
f"{t1.loc['Current', 'alpha']} vs {alpha0}")
check("sharpe adjusts for the risk-free rate",
t1.loc["Current", "sharpe"] != sharpe0,
f"{t1.loc['Current', 'sharpe']} vs {sharpe0}")
rf_widget.set_value(4.0).run()
print("date range", flush=True) print("date range", flush=True)
# total_return (cumulative) is range-sensitive; the annualized 'return' # total_return (cumulative) is range-sensitive; the annualized 'return'