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:
parent
d7302b1646
commit
d5a7bb70b0
27
app.py
27
app.py
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user