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."))
|
"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",
|
||||||
help=("Which statistics to show on the Statistics tab, in the order they "
|
help=("Which statistics to show on the Statistics tab, in the order they "
|
||||||
"appear.\n"
|
"appear.\n"
|
||||||
"Available: " + ", ".join(_STATS_VALID) + "\n"
|
"Available: " + ", ".join(_STATS_VALID) + "\n"
|
||||||
"beta, alpha_ann 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. Empty field = show all, default order."))
|
"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())
|
_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]
|
||||||
|
|
@ -226,7 +231,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, 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)
|
date_window=win, range_start=range_start, range_end=range_end)
|
||||||
|
|
||||||
if not spec.strip():
|
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"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%}"
|
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)}"
|
+ (f" · benchmark: {' ; '.join(b['label'] for b in benchmarks)}"
|
||||||
if benchmarks else ""))
|
if benchmarks else ""))
|
||||||
|
|
||||||
|
|
@ -440,8 +446,9 @@ with tab_stats:
|
||||||
if bench is not None:
|
if bench is not None:
|
||||||
rows.append((f"benchmark: {bench['label']}", rng(bench["price"]),
|
rows.append((f"benchmark: {bench['label']}", rng(bench["price"]),
|
||||||
rng(bench["after"])))
|
rng(bench["after"])))
|
||||||
bm = (rng(bench["price"]).resample("ME").last()
|
# daily price series — metrics.summary() computes daily returns
|
||||||
if bench is not None else None)
|
# 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:
|
def pick(d: dict) -> dict:
|
||||||
# user-configured selection + order; skip columns the summary
|
# user-configured selection + order; skip columns the summary
|
||||||
|
|
@ -455,12 +462,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))
|
summaries[f"{name} (pre-tax)"] = pick(m.summary(pre, bm, rf_rate))
|
||||||
summaries[f"{name} (after-tax)"] = pick(m.summary(after, bm))
|
summaries[f"{name} (after-tax)"] = pick(m.summary(after, bm, rf_rate))
|
||||||
elif show_pre:
|
elif show_pre:
|
||||||
summaries[name] = pick(m.summary(pre, bm))
|
summaries[name] = pick(m.summary(pre, bm, rf_rate))
|
||||||
else:
|
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')
|
st.dataframe(m.format_summary_table(summaries), width='stretch')
|
||||||
|
|
||||||
if benchmarks:
|
if benchmarks:
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,33 @@ 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)
|
||||||
|
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)
|
print("date range", flush=True)
|
||||||
# total_return (cumulative) is range-sensitive; the annualized 'return'
|
# total_return (cumulative) is range-sensitive; the annualized 'return'
|
||||||
# can coincidentally match at 1-decimal precision
|
# can coincidentally match at 1-decimal precision
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user