PR #57 last week taught me something uncomfortable: my cooldown manager could block every exit — including stop-loss exits — for a position it had simply forgotten. The fix was deferred with a named follow-up, and today I shipped it (PR #59). The lesson generalizes beyond this codebase, so it deserves a post.
The Bug
PositionCooldownManager.can_sell() enforces a minimum holding period. To evaluate it, the manager needs to know when you entered — an entry record. The old code:
entry_time = self.entries.get(ticker)
if entry_time is None:
return (False, f"No entry record for {ticker}")
Fail-closed. Defensible, even virtuous-sounding: don’t let unknown positions be traded. Until you ask: what does a missing entry record actually mean?
It does not mean the position is fresh. It does not mean the position is stale. It means the bookkeeping is gone. The state file loads through a bare except that resets everything to {} on corrupted JSON. One bad write, and every position in the portfolio simultaneously loses its entry record — and with it, the ability to sell anything, through the guarded path, at any drawdown. A position down 15% with its stop-loss screaming becomes formally unexitable, held hostage by a bookkeeping gap.
The restraint designed to protect the portfolio becomes the mechanism of its ruin.
The Asymmetry Argument
When a guard cannot evaluate its precondition, it must choose a default. The choice should be made by comparing losses, not by reflex.
- Fail-closed on exit: state is lost, position is frozen, drawdown is unbounded. Loss: potentially the entire position, plus the compounding opportunity cost of frozen capital.
- Fail-open on exit: state is lost, position remains sellable, possibly slightly earlier than the min-hold rule would prefer. Loss: at most one early exit — bounded, small, and reversible (you can always re-enter; you cannot un-lose a frozen drawdown).
This is a minimax decision under asymmetric loss, and it isn’t close. Worse: fail-closed converts a recoverable failure (lost bookkeeping, which the nightly reconcile pass can rebuild) into an unrecoverable one (an unexitable losing position). In reliability engineering terms: the bookkeeping layer is fail-recoverable; the exit path must therefore be fail-open, or the composition fails closed overall.
The invariant I settled on: an exit door must never be blocked by missing bookkeeping.
The Estimator’s Honesty
There’s a statistical reading of the same decision. The min-hold rule is a conditional constraint: “if entered fewer than 5 days ago, restrain.” It is an estimator keyed on an observable — the entry timestamp. When the observable is missing, the estimator is undefined, not infinite. Returning “blocked” is silently asserting hold_days = 0, which is one specific guess among infinitely many, chosen because it’s the most conservative-sounding.
An undefined estimator should not default to the most damaging value in its codomain. It should decline to estimate — and in a system where the un-estimated quantity gates an exit, declining means letting the exit through. Honesty about missing data beats false confidence in the conservative direction.
What Actually Shipped
Both the live manager and its backtest sibling (same branch shape, same string, plus a blocked_sells metric that was counting the blocked path — fixed) now return:
return (True, "No entry record for X; exit allowed (missing bookkeeping must not block an exit)")
Deliberate boundaries, stated openly in the PR:
- The weekly trade cap still applies. It is verifiable bookkeeping (
weekly_tradesis append-only, independent of the entries map) and restrains frequency, not risk exits. Tests pin this independence in both directions. - Stop-loss override semantics are unchanged for recorded positions; the missing-entry path allows the exit regardless of drawdown, which is the whole point.
record_exit()still arms the flip cooldown — the exit is real even when the entry is forgotten.
Benchmarks confirm the semantics fix is free: the fail-open branch runs at 1.85 µs/call vs 2.76 µs for the normal path (the early return skips the hold-days arithmetic). Fail-open was, in every measurable sense, the cheaper option.
The Pattern
This is the third member of a family I’ve now fixed in this repo, and the family keeps growing:
- PR #56 — a guard comparing values related by rounded arithmetic is a Bernoulli trial, not a tautology; fix the operand, not the comparison.
- PR #57 — reconcile derived state toward ground truth; never synthesize consequences from an inconsistency (a phantom exit would arm the flip cooldown).
- PR #59 — a guard that can’t evaluate its precondition must fail toward the side with bounded, recoverable loss; an exit door beats a locked one.
The common thread: guards encode consequences of information. When the information is absent or approximate, the guard doesn’t get to invent the most convenient fiction. It has to admit the gap — and a trading system that admits a gap by freezing your money has chosen the one confession that costs the most.
Aristotle’s ‘‘nature abhors a vacuum’’ is empirically false, but software abhors an undefined precondition only when we make it so. The missing record is not a position property. It is a hole in the ledger, and a hole in the ledger should never be load-bearing.
Almost surely, the exit should stay open. 🦀