diff --git a/app.py b/app.py index d8bd271..cb854fd 100644 --- a/app.py +++ b/app.py @@ -60,6 +60,12 @@ if not up_to_date(Path(root)): # data symbols are stored lowercase; input is resolved case-insensitively _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: """Map parsed symbols onto the data's symbol case (insensitive match).""" @@ -160,9 +166,6 @@ bench_spec = st.sidebar.text_input( "benchmark.")) 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( "Statistics (comma-separated, in display 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" "beta, alpha and ann_return_bench only have a value when a " "benchmark is set. Sharpe, Sortino and alpha are computed in " - "excess of the Risk-free rate. Empty field = show all, " - "default order.")) + "excess of the 3-mo T-bill (BIL daily total return), as in the " + "Fund Lab. Empty field = show all, default order.")) _stats_cols = [_STATS_RENAME.get(s.strip(), 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] @@ -231,7 +234,7 @@ if _end_ts == "bad": _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, 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) 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"{scheme} · start {start} · cost {cost_bps} bps · " f"tax LT/ST/div {lt_rate:.0%}/{st_rate:.0%}/{div_rate:.0%} " - f"+NIIT {niit:.1%} +state/local {sl_rate:.1%} · " - f"rf {rf_rate:.1%} (Sharpe/Sortino/alpha are rf-adjusted)" + + (" · Sharpe/Sortino/alpha in excess of the 3-mo T-bill (BIL)" + 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)}" if benchmarks else "")) @@ -462,12 +467,12 @@ with tab_stats: summaries = {} for name, pre, after in rows: if both: - summaries[f"{name} (pre-tax)"] = pick(m.summary(pre, bm, rf_rate)) - summaries[f"{name} (after-tax)"] = pick(m.summary(after, 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)) elif show_pre: - summaries[name] = pick(m.summary(pre, bm, rf_rate)) + summaries[name] = pick(m.summary(pre, bm, rf)) 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') if benchmarks: diff --git a/metrics.py b/metrics.py index a98d532..7a6f892 100644 --- a/metrics.py +++ b/metrics.py @@ -18,6 +18,17 @@ def daily_returns(price: pd.Series | pd.DataFrame) -> pd.Series | pd.DataFrame: 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: 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)) -def sharpe(returns: pd.Series, rf: float = 0.0) -> float: - r = returns - rf / ANN +def sharpe(returns: pd.Series, rf: float | pd.Series | None = 0.0) -> float: + r = excess(returns, rf) sd = r.std() return float(r.mean() / sd * np.sqrt(ANN)) if sd > 0 else 0.0 -def sortino(returns: pd.Series, rf: float = 0.0) -> float: - r = returns - rf / ANN +def sortino(returns: pd.Series, rf: float | pd.Series | None = 0.0) -> float: + r = excess(returns, rf) 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 @@ -53,18 +64,20 @@ def calmar(price: pd.Series | pd.DataFrame) -> float: 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).""" - r = (returns - rf / ANN).dropna() - b = (bench - rf / ANN).dropna() + r = excess(returns, rf).dropna() + b = excess(bench, rf).dropna() r, b = r.align(b, join="inner") 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) 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) out = { "total_return": total_return(price), diff --git a/tests/test_app.py b/tests/test_app.py index c2033ae..7745533 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -166,11 +166,28 @@ def _run() -> None: check("old statistic names migrated", cols == ["return", "vol", "alpha"], 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") 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"]) alpha_bench = float(t0.loc["benchmark: v", "alpha"][:-1]) / 100 # 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 and abs(alpha_bench) < 1e-9, f"beta={beta_bench} alpha={alpha_bench}") - rf_widget = next(n for n in app().sidebar.number_input - if "Risk-free" in n.label) - check("default risk-free rate is 4%", rf_widget.value == 4.0, - 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() + caps = " ".join(c.value for c in app().main.caption) + check("caption states the T-bill reference", + "3-mo T-bill (BIL)" in caps, caps[:400]) print("date range", flush=True) # total_return (cumulative) is range-sensitive; the annualized 'return'