The Currency Assumption Inside a Risk Gate
Every codebase has assumptions it doesn't know it's making. The dangerous ones aren't written anywhere — they're ambient, absorbed from the environment the code happened to grow up in. This is the story of one of them: my bot assumed the world was denominated in US dollars, and it took three spec revisions to fully stop it.
The bug that didn't look like a bug
The bot's order gates work on a simple principle: before any position opens, compute the worst-case loss the structure could produce, and compare it against the account's net liquidation value. If the risk is too large a fraction of the account, refuse the trade. Straightforward, defensive, boring — exactly what a risk gate should be.
Except the two sides of that comparison weren't in the same currency. Options on US underlyings are priced in USD, so the max-loss figure comes out in USD. The account, however, isn't a USD account — the broker reports its net liquidation value in the account's base currency. The gate was dividing a USD number by a non-USD number and treating the result as a meaningful risk fraction.
Nothing crashed. Nothing logged a warning. The resulting ratio was still a plausible-looking number — just wrong. That's the signature of a units bug: the code runs happily, the output looks sane, and the error hides inside a number nobody eyeballs. A dimensional-analysis check would have caught it instantly; a type system that knew about currencies would have refused to compile it. I had neither, so it sat there until an audit pass asked the awkward question: what currency is each side of this division in?
Revision one: convert — but convert the right thing
The spec for the fix seemed obvious: convert the USD max-loss into the account currency before comparing. The first implementation did a conversion — but of the wrong quantity. Instead of converting the payoff-derived max loss, it leaned on the broker's what-if margin figure, which conveniently arrives in the account currency already.
That's a seductive substitution, and it's wrong. Margin and max loss are different concepts that sometimes coincide. The margin requirement is the broker's collateral demand; the payoff max loss is what the structure can actually lose. A gate specified in terms of one must not silently switch to the other just because the other has friendlier units. The adversarial review caught it, and revision two of the spec pinned it down: convert the payoff max loss itself, using an approved currency-conversion input — no hardcoded fallback rate. If the conversion cannot be validated, the gate fails closed rather than guessing.
Revision two's leftover question: converting from what you think into what you assume
With the conversion in place, a subtler problem surfaced. Converting USD into "the account currency" requires knowing what the account currency is — and the code was inferring it at runtime from broker-reported account data. Inference sounds adaptive and robust. In practice it meant the correctness of every risk gate depended on a heuristic reading of an API response, one that could be ambiguous when the account summary reports values under multiple currency labels.
An intermediate fix tightened the inference: resolve the currency unambiguously or refuse to proceed. Better. But a follow-up audit pushed on it one more time, and that pressure produced revision three, which finally named the actual principle at stake.
Revision three: stop inferring; configure and verify
The account's base currency is not a market variable. It doesn't change intraday. It doesn't change at all unless a human does paperwork with the broker. It is an environmental fact — a property of the deployment, like which broker you use. And environmental facts have a correct handling pattern that is neither hardcoding nor inference:
Configure the fact explicitly, then validate it against reality at startup.
The final design makes the base currency an operator-supplied configuration value. At startup, the bot cross-checks it against what the broker reports. Match: proceed. Mismatch: fail loudly before any trading logic runs. The runtime never decides what the currency is; it only verifies the operator's declaration.
This splits the failure modes more cleanly. Hardcoding fails silently when the environment changes. Inference fails silently when the heuristic misreads. Configure-and-validate makes disagreement explicit: when the independent check is reliable, a wrong config gets caught at startup, and so does an environment that changed underneath you. It isn't an absolute guarantee — a validation can itself be incomplete, defective, or fooled by ambiguous broker data — but it substantially shrinks the silent-failure surface, converting most of an entire class of quiet unit corruption into a visible refusal to boot.
The general lesson
It took three spec revisions because each fix answered the question one layer down and stopped: convert (but you converted the wrong quantity), convert the right quantity (but from a currency you inferred), infer carefully (but why are you inferring at all?). The arc only terminated when the fix stopped patching the symptom and classified the underlying thing correctly: this is a stable, safety-critical deployment fact, and facts like that get configured and validated rather than inferred.
I've started auditing for the pattern elsewhere. Anywhere the code answers a question about its own environment — what account is this, what timezone, what mode, what venue — by sniffing runtime data, there's a potential silent-failure path. Not every environmental question deserves this treatment: some systems legitimately rely on authoritative service discovery or runtime negotiation for facts that are dynamic by design. But for facts that are stable, that a human controls, and whose incorrect inference would silently corrupt safety calculations, the right shape is: make the operator say it, make the system check it, and make disagreement fatal at startup instead of quietly wrong at runtime.
Assumptions you've written down can be reviewed. It's the ones the code absorbed from its environment that you have to go digging for.