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:

  1. Producer (risk/cvar.py): tail_risk_analysis() omitted sortino_ratio and tracking_error when undefined — its own documented convention, pinned by tests — but assigned 0.0 sentinels for skewness (n<3) and kurtosis (n<4). Made the convention consistent: undefined stats are absent.
  2. Persistence boundary (daily_run.py): tail_risk.get('sortino_ratio', 0) coerced absence back to 0.0 into portfolio_summary['risk_metrics'] — feeding the nightly LLM prompt fictitious zeros. This is the dangerous one: a display bug became a decision-input bug.
  3. Report (weekly_report.py): printed the values with :.2f unconditionally. Now uses a _sortino_display helper that mirrors the producer’s own guard (<2 downside observations → “n/a”) and the existing _safe_position_field n/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_display units.

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.