Statistics tab: fix benchmark alignment + rf-adjusted Sharpe/Sortino/alpha

- Pass the benchmark's DAILY price series to metrics.summary() instead of
  a month-end-resampled one; summary() derives daily returns and
  annualizes with 252d, so the old resample made beta/alpha regress the
  fund's month-end daily returns against whole-month benchmark returns
  (and skewed ann_return_bench).
- New sidebar setting 'Risk-free rate %' (default 4%, persisted) passed
  through to summary(), so Sharpe, Sortino and CAPM alpha are computed
  in excess of rf; noted in the page caption and stats help.
This commit is contained in:
Greg Pomerantz 2026-09-02 06:53:36 -04:00
parent d7302b1646
commit d5a7bb70b0
2 changed files with 44 additions and 10 deletions

27
app.py
View File

@ -160,14 +160,19 @@ 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",
help=("Which statistics to show on the Statistics tab, in the order they "
"appear.\n"
"Available: " + ", ".join(_STATS_VALID) + "\n"
"beta, alpha_ann and ann_return_bench only have a value when a "
"benchmark is set. Empty field = show all, default order."))
"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."))
_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]
@ -226,7 +231,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, stats_order=stats_order,
period=period, rf_rate=rf_rate * 100, stats_order=stats_order,
date_window=win, range_start=range_start, range_end=range_end)
if not spec.strip():
@ -374,7 +379,8 @@ 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"+NIIT {niit:.1%} +state/local {sl_rate:.1%} · "
f"rf {rf_rate:.1%} (Sharpe/Sortino/alpha are rf-adjusted)"
+ (f" · benchmark: {' ; '.join(b['label'] for b in benchmarks)}"
if benchmarks else ""))
@ -440,8 +446,9 @@ with tab_stats:
if bench is not None:
rows.append((f"benchmark: {bench['label']}", rng(bench["price"]),
rng(bench["after"])))
bm = (rng(bench["price"]).resample("ME").last()
if bench is not None else None)
# daily price series — metrics.summary() computes daily returns
# and annualizes with 252 days/yr, so never pre-resample
bm = rng(bench["price"]) if bench is not None else None
def pick(d: dict) -> dict:
# user-configured selection + order; skip columns the summary
@ -455,12 +462,12 @@ with tab_stats:
summaries = {}
for name, pre, after in rows:
if both:
summaries[f"{name} (pre-tax)"] = pick(m.summary(pre, bm))
summaries[f"{name} (after-tax)"] = pick(m.summary(after, bm))
summaries[f"{name} (pre-tax)"] = pick(m.summary(pre, bm, rf_rate))
summaries[f"{name} (after-tax)"] = pick(m.summary(after, bm, rf_rate))
elif show_pre:
summaries[name] = pick(m.summary(pre, bm))
summaries[name] = pick(m.summary(pre, bm, rf_rate))
else:
summaries[name] = pick(m.summary(after, bm))
summaries[name] = pick(m.summary(after, bm, rf_rate))
st.dataframe(m.format_summary_table(summaries), width='stretch')
if benchmarks:

View File

@ -166,6 +166,33 @@ def _run() -> None:
check("old statistic names migrated", cols == ["return", "vol", "alpha"],
str(cols))
print("risk-free rate", flush=True)
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
# monthly-resampled benchmark (daily-vs-monthly returns mismatch)
check("benchmark row is beta 1 / alpha 0 (daily-aligned regression)",
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()
print("date range", flush=True)
# total_return (cumulative) is range-sensitive; the annualized 'return'
# can coincidentally match at 1-decimal precision