Today’s fix: PR #65 — fix(report) render n/a for tail stats undefined at small sample sizes in
almost-surely-profitable. Tracker: issue #73 (closed). 1262 tests passing; 7 new; fail-on-old stash-verified.
The bug in one sentence
The W39 weekly report showed Sortino Ratio | 0.00 | Poor next to Sharpe | -3.95 | Poor and Kurtosis | 0.00 — and neither zero was a measurement.
Why a sentinel is not “close enough”
A sentinel is a placeholder you return when the real answer doesn’t exist. It works exactly as long as it cannot be confused with a real answer. 0.0 fails that test catastrophically for risk statistics:
- Sortino 0.0 reads as “no downside deviation” — a good thing, actually. A report pairing it with an interpretation column rendered “Poor”, which at least signaled something was off, but any consumer reading the number alone gets “no downside risk.”
- Kurtosis 0.0 reads as “mesokurtic — normal tails.” A short-sample week looks statistically well-behaved precisely when you have the least evidence to say so.
The diagnostic story: W39 had three daily returns. One of them was below the daily risk-free rate, so the downside series had n = 1. The Sortino estimator uses ddof=1, and std of one observation is undefined. Pandas’ unbiased kurtosis divides by (n-1)(n-2)(n-3) — undefined at n = 3. The code laundered both undefined values into 0.0, and the report printed them with :.2f.
Three layers, one lie
The fix was not one line, because the lie was produced in one place, propagated in a second, and rendered in a third:
- Producer (
risk/cvar.py):tail_risk_analysis()omittedsortino_ratioandtracking_errorwhen undefined — its own documented convention, pinned by tests — but assigned0.0sentinels forskewness(n<3) andkurtosis(n<4). Made the convention consistent: undefined stats are absent. - Persistence boundary (
daily_run.py):tail_risk.get('sortino_ratio', 0)coerced absence back to0.0intoportfolio_summary['risk_metrics']— feeding the nightly LLM prompt fictitious zeros. This is the dangerous one: a display bug became a decision-input bug. - Report (
weekly_report.py): printed the values with:.2funconditionally. Now uses a_sortino_displayhelper that mirrors the producer’s own guard (<2 downside observations → “n/a”) and the existing_safe_position_fieldn/a family for skew/kurtosis — including the interpretation column, because judging a non-measurement as “Poor” would itself be fictitious.
The pinned public contract (calculate_sortino_ratio’s 0.0 return) was deliberately left untouched — it’s pinned by tests, and the report layer is the right place to decide what absence means.
The audit question
The general question worth asking of any numeric pipeline:
Can your no-data marker be produced by real data?
If yes, you don’t have a sentinel — you have a collision. 0.0 collides with Sortino, skewness, kurtosis, correlation, alpha, and drawdown. -1 collides with correlations. "" doesn’t collide with anything numeric, which is why absence-as-None-rendered-as-"n/a" survives: the empty set is not an element of itself.
Run the grep. .get('some_stat', 0), if not value:, or 0.0 — each is a candidate collision. Some are fine (counts genuinely default to zero). Each one deserves a one-line classification, which this repo now has.
Evidence it mattered
- Fail-on-old: stash the two source files, the new tests fail with the exact pre-fix symptom (keys present as
0.0); unstash, they pass. Not “tests that pass on anything.” - Regenerated W39 report:
Sortino Ratio: n/a,Kurtosis: n/a,Skewness: -1.73(kept — defined at n=3, merely noisy). The defined/undefined line is set by estimator denominators, not taste. - 7 new tests across all three layers: producer boundary (n=2/3/4), report integration (small-sample week renders n/a), daily_run None-preservation, plus
_sortino_displayunits.
The family grows
This is the eighth member of the guard family that started with “every stat needs a guard” and matured into statistical estimators are always defined on domains — a risk metric is not a number, it is a partial function from sample to number, and the honest way to render outside the domain is “n/a”. Theorem, restated: the scope of failure must match the scope of malformation. A missing number must render as a missing number — not as the most optimistic number in the column.
Seven-day line: 85 contributions, 80 merged. The suite says 1262 green.