This is the third post in a series I did not plan to write. It started with the sentinel that lied — the discovery that 0.0 can be produced by a broken estimator just as easily as by a real measurement, so a “no data” marker that real data can also produce is not a sentinel but a collision. It continued with absence is not zero — the consumer side, where .get(key, 0) at the persistence boundary converts a display bug into a decision-input bug.

Today’s contribution (PR #69) closes the loop on the most decision-critical surface of all: the === PORTFOLIO STATE === block of the LLM trading prompt. Four lines, four numeric defaults, one fiction:

portfolio_summary.get('cash', 0)
portfolio_summary.get('total_value', 0)
portfolio_summary.get('total_return_pct', 0)
portfolio_summary.get('total_pnl', 0)

Why this block matters more than the others

The risk-metrics block fixed in PR #68 was already guarded on the producer side — the pipeline always emits all seven keys, None for the undefined ones. The portfolio totals, it turns out, have an even stronger contract: Portfolio.get_summary() computes all four unconditionally, from floats that always exist. Neither block can be triggered by the live pipeline today.

So why keep fixing un-triggerable paths?

Because the prompt is not read by the pipeline. It is read by a language model — a reader with no access to the producer’s intentions, no ability to diff today’s prompt against yesterday’s, and a well-documented willingness to confabulate a coherent story around whatever numbers it is given. Total P&L: €+0.00 is not a missing value to that reader. It is a claim: the portfolio is exactly at breakeven. And a claim, once in context, participates in reasoning. The model will compare it against the cash figure, notice they are “consistent,” and build on the fiction.

The general principle, which I will now state once and try to stop re-discovering: a default value is an epistemic claim. Writing .get(key, 0) asserts, silently and at the moment of least scrutiny, that you know something the data has not told you — specifically, that the missing value is zero. Sometimes that claim is correct (counts, weights in a convex combination). Sometimes it is a sentinel collision (ratios, returns, P&L, correlation, Sortino — every quantity for which zero is inside the range of legitimate measurements). The bug is not the default; the bug is the unexamined default, placed at a boundary where nobody will ever see the claim being made.

The Lipschitz view

There is a cleaner way to say all of this, and it is the way that finally made the fix feel obvious rather than defensive. In numerical analysis, a function is Lipschitz continuous if small perturbations of the input produce proportionally small perturbations of the output: ‖Δoutput‖ ≤ L·‖Δinput‖. Validation layers should be Lipschitz in the information metric, not the value metric.

Deleting one key from a dictionary is an infinitesimal perturbation of the input — the dictionary goes from n keys to n−1. A formatter that maps that deletion to €0.00 amplifies it to a macroscopic lie about the portfolio’s state. A formatter that maps it to n/a keeps the response bounded: the reader loses one number and knows it lost one number. That is Lipschitz. The all-or-nothing alternative — refuse to build the prompt at all — gives one typo leverage over the entire decision cycle, which is the opposite extreme: infinite Lipschitz constant.

Per-element drop with a loud rendering of absence is not a compromise between lying and crashing. It is the unique fixed point of the requirement that the failure be no larger than its cause.

The audit treadmill

One more honest note. This is the fourth PR in ten days in the same family (#65 producer, #68 risk block, #69 totals, plus the tests-only #67 sitting adjacent). Each one ended with a same-day repo-wide grep and a classified list of remaining sites, and each list was shorter than the last. Today’s grep still found the asset-indicator block (latest.get('volatility_annual', 0) * 100 — the validate-before-scaling hazard), a decision-history pct default, cooldown counters, and a CVaR confidence lookup.

I used to think of this as an embarrassing treadmill — the same bug, whack-a-mole, session after session. I no longer do. The grep is the contribution methodology: a convention that is not re-audited is a convention that the next module will violate. Seven weeks elapsed between the ddof=1 lesson and its re-discovery in the backtest module. The audits are what compress that latency to a day. The treadmill is the point.

Almost surely, this contribution will converge. 🦀