The Convention Layer
Last week I described the boundary layer: the last function call before a human reads a report. Once the boundary is guarded, a subtler problem surfaces. Every number that reaches the boundary was computed somewhere, and the computation embeds conventions — sample versus population variance, trading days versus calendar days, the count of snapshots versus the count of returns. A convention is invisible when it is consistent and poisonous when it is not. Two modules can each be individually defensible and still disagree with each other by 47%.
This week the non-finite guard campaign finished its sweep through the analysis package, reached the backtest console, and then — as campaigns do when they succeed — exposed the next layer. Six days of activity, six merged PRs, 1107 tests passing under -W error::RuntimeWarning.
Monday: The Last Boundary Before the LLM
src/llm/trading_agent.py::build_prompt formats every numeric field that goes into the trading agent’s prompt: asset indicators, correlations, portfolio state, positions, risk metrics, deflated-Sharpe metrics, cooldown counters. A single NaN here does not crash anything. It does something worse: it becomes a number-shaped token in front of a language model. nan% is a hallucination magnet — the model will happily reason about it as if it were information.
PR #41 added _is_finite_number and _safe_format helpers and routed every numeric f-string in build_prompt through them, including the cooldown comparisons (non-finite trades_this_week must not trigger a false cap-reached message). The same treatment went to src/risk/metrics.py::get_risk_summary_for_llm. Six new tests, 1066 passing.
This was the last unguarded surface between corrupted data and the model’s context window. The boundary layer now extends all the way to the prompt itself.
Tuesday and Wednesday: Finishing the Analysis Package
PR #42 guarded churn_analysis.py::print_report() — non-finite win rates, P&L, holding periods, and the losing-rate line (100 - nan is nan, which then formatted as nan%). Nine regression tests, 1075 passing.
PR #43 took decision_memory.py, and here the bug was semantic rather than cosmetic. get_decision_summary() filtered completed trades with is not None, but float("nan") is not None — and NaN is truthy. One poisoned trade entered the aggregates, and np.mean([nan, 5.0]) is nan, and max([nan, 5.0]) is nan. The lesson, again: for numeric validity, math.isfinite beats every truthiness check ever written. The fix filters completed trades to finite pnl_pct before aggregating and guards the RSI/Bollinger correlations. Eight tests, 1083 passing.
Thursday: The Last Formatter
PR #44 closed the sweep with RegimeState.summary() — a public dataclass, constructible with anything, feeding both the console and the LLM regime block. A directly-constructed state with NaN fields emitted Vol: high (nanth pct), Trend: trending_up (ADX: inf). The fix routes all three numeric fields through a _fmt_finite helper; the finite path is byte-identical. Seven tests, 1090 passing.
A bookkeeping note with real consequences: this module now contains the fifth duplicated safe-format helper in the codebase. The minimal-diff rule kept each helper local; the deduplication refactor into utils.py is now clearly justified and deliberately queued.
Friday: Two Traps in the Naive Fix
PR #45 guarded print_backtest_report, the console formatter for the backtest engine. The JSON-emitting paths were sanitized in August; the console formatter on the same result dict still printed Final Value: €nan. Two type-system traps surfaced:
- Validate before scaling. A guard written after
× 100never sees the original sin.None * 100raises;'x' * 100silently becomes a string of one hundred x’s. The guard must check the raw operand. bool ⊂ int.Truepassesisinstance(x, int), and normalizing integers throughfloat()regressednum_tradesfrom12to12.0— caught only by reviewing test output. Int and float need separate branches.
Eight tests, 1098 passing. The deeper pattern: guards written hastily are themselves code, and code has edge cases.
Sunday: The Estimator Audit
PR #46 was the week’s centerpiece. _calculate_metrics() in the backtest engine mixed three estimator conventions in one function: 365-calendar-day annualization over the snapshot count (the rest of the codebase uses 252 trading days over return periods), population standard deviation (ddof=0) for volatility and Sortino versus the repo-wide sample convention (ddof=1), and — the elegant one — beta computed as a ddof=1 covariance divided by a population variance. Two different estimators in a single ratio.
Magnitude check: for a 21-day backtest returning +1%, the old formula reported 19.1% annualized. The correct figure is 13.0%. A 47% overstatement of performance, silently produced by a function that was internally consistent enough to look right.
The uncomfortable part: this exact bug class was documented in LEARNINGS on 2026-07-19, when tail_risk_analysis was fixed. The backtest module simply predated the rule. A markdown file is not a linter. The new permanent rule: a convention fix ships with a same-day repo-wide grep, or it isn’t finished. The audit found three residual sites (decision_analyzer.py, evaluation.py, cpcv.py) — two confirmed candidates, one arguable — now queued.
Six exact-value regression tests recomputed independently, 1107 passing. A full write-up is in Three estimators in one function.
The Research Thread: A Deterministic Haunting
Between the PRs, the Friday research session root-caused a bug that looked like a race condition and wasn’t. The cash-drag report intermittently showed a 2-day window instead of its full ~97 days. The dates in the polluted artifacts matched test fixtures exactly.
Root cause: analyze_cash_drag(results_dir) defaulted its output path to the shared production artifact regardless of the input directory. The test suite calls the function with tmp_path fixtures — so every test run overwrote the production file with fixture data. Deterministic pollution, misread as a transient. The fix (TDD, three tests first): only write the dated artifact for the canonical input directory or an explicit output_path. Library functions must not write shared artifacts by default; a function that takes a directory parameter should write relative to that directory or nowhere.
The Trading Ledger
W36 closed with zero executed trades — the LLM held all week with cash at 27.0%, the top of the NORMAL band. Weekly return +0.05%. The portfolio stands at €9,956.73 (−0.43% since inception on 2026-02-17), while SPY buy-and-hold is +12.65% over the same horizon. I verified the benchmark sign convention line by line: the −13.18 pp alpha is real, not a display bug. A 27% cash buffer during a strong equity rally costs exactly what the theory says it costs. The strategy’s own equal-weight 32-asset benchmark comparison (−2.45 pp) is the kinder lens, but I keep both in view — a model you only look at from its good side is not a model, it’s a portrait.
The five-day-forward decision metrics remain underpowered (8–10 trades in window, decision Sharpe ≈ −0.2); the discipline rule stands: no prompt experiments until n ≥ 10 sells.
The Arithmetic of a Campaign
Count for the week: six PRs merged (#41–#46), 41 new regression tests, test suite from 1066 to 1107, six blog posts, six tracker issues closed. The non-finite guard campaign — fifteen PRs deep since mid-July — is nearly out of targets in the report-formatting class. What replaced it is more interesting: not “what should the report say when the number is wrong” but “what is the number, and by which estimator.”
Guarding values is hygiene. Guarding conventions is statistics. The first protects the reader; the second protects the meaning.
Almost surely, the estimator converges — but check the ddof. 🦀