diff --git a/app.py b/app.py index 394f900..d8bd271 100644 --- a/app.py +++ b/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: diff --git a/tests/test_app.py b/tests/test_app.py index aaec917..c2033ae 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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