Your Position Size Is a Number in a YAML File
A one-line currency check meant a live strategy sized every position from a hardcoded capital figure instead of the real account balance — and it failed silently for months. What the bug was, why it was invisible, and the validator that now sits between signal and submission.
The line that looked fine
Position sizing in a systematic strategy is usually four tokens long:
qty = int((capital * risk_pct) / (entry - stop))
Risk 1% of capital, divide by the per-share risk, floor it. Every algo trading tutorial arrives at some version of this, and it is correct.
The interesting question is not the formula. It is where capital comes from.
In this framework it came from config:
# PA_Momentum/config_pa_momentum.yaml
risk:
account_size: 50000
# Div_Strategy/config_div_stocks.yaml
risk:
capital_aud: 50000
That is the project convention and a good one — no hardcoded numbers in .py files,
every threshold lives in YAML. But it quietly encodes an assumption: that the number in
the file still matches the money in the account.
Nothing was checking.
The currency check that disabled itself
One of the two strategies did better. It tried to read the real Net Liquidation value from the broker rather than trusting config. The lookup walked the IBKR account values and picked the matching row.
It required currency == "USD".
The account’s base currency is AUD.
The match never fired. Not once. And because the lookup was written defensively — if no row matches, fall back to the config value — it never raised, never logged an error, and never behaved differently from a strategy that had no NAV lookup at all.
This is the part worth internalising. The bug was not “we forgot to check the balance.” The bug was “we checked the balance, the check couldn’t match, and the fallback was indistinguishable from success.”
A US-based trader running the identical code would never have found it. The condition is only wrong if your account’s base currency isn’t the currency you trade in — which is every non-US retail trader trading US equities.
Why a backtest can never catch this
Backtests run on a capital number you supply. That is the entire point — you are asking “what would this have returned on $50,000?”
So the backtest and the live path agree perfectly, right up until the live account holds something other than $50,000. Then:
- If the real account is smaller, every position is oversized. The 1% risk cap is a fiction, margin rejections start appearing, and the drawdown that the backtest said was survivable no longer is.
- If the real account is larger, every position is undersized. Less dramatic, entirely invisible, and the strategy simply underperforms its own research forever.
Neither shows up as an error. Both show up as “the live results don’t quite match the backtest,” which is the single easiest thing in trading to explain away as slippage.
The fix: a gate that fails closed
The repair was not to patch the currency comparison. Patching it would have fixed this instance and left the class of bug intact — the next silent fallback would be just as invisible.
Instead there is now a pre-trade validator sitting at the last point before order submission, after all sizing logic has run. It takes a snapshot of real account state and answers one question: can this account actually fund this order right now?
It is built on four rules.
1. Skip, never resize. If any check fails, the order is rejected outright. Automatic downsizing is opt-in and off by default. A strategy that quietly trades smaller than designed is back to the original problem — the system doing something you did not authorise, without telling you.
2. Fail closed. A missing, stale, or unparseable account snapshot rejects the trade. There is deliberately no “assume it’s fine” branch. That branch is exactly what the currency bug exploited.
3. Currency is explicit. The account reports in its base currency; the instrument is priced in another. Every comparison happens in base currency after a conversion that is performed deliberately and written to the log. No implicit matching on a currency string.
4. Pure and testable. Validation touches no network. Fetching the account snapshot is a separate step, so tests construct snapshots directly and assert on outcomes — including the failure paths, which are the ones that matter and the ones integration tests habitually skip.
Reasons are codes, not sentences
Every rejection returns a stable identifier rather than a human-readable string:
ACCOUNT_SNAPSHOT_UNAVAILABLE
ACCOUNT_SNAPSHOT_STALE
INVALID_ORDER_PARAMS
INVALID_RISK_PER_SHARE
RISK_EXCEEDS_LIMIT
NOTIONAL_EXCEEDS_LIMIT
INSUFFICIENT_AVAILABLE_FUNDS
INSUFFICIENT_BUYING_POWER
POST_TRADE_MARGIN_BUFFER_TOO_LOW
CURRENCY_CONVERSION_UNAVAILABLE
This matters more than it looks. Codes are greppable across months of logs, countable
(“how often did we hit the margin buffer last quarter?”), and safe to assert on in tests.
A log line reading Order skipped: not enough funds is unsearchable prose that changes
the moment someone rewords it.
Broker APIs are not one API
A detail worth stealing regardless of your broker: the tags IBKR reports account values
under vary across TWS versions and account types. NetLiquidation may arrive as
NetLiquidationByCurrency; AvailableFunds may arrive as AvailableFunds-S.
So each value is resolved from a list of candidate spellings in priority order rather than a single hardcoded key. Reading one exact tag name is how you end up with a lookup that works on your machine and silently returns nothing on a different account type — which is, once again, the same bug.
What this generalises to
The specific defect was a currency string. The pattern is much broader: a defensive fallback that makes a broken lookup look successful.
Worth auditing your own system for:
- Any
try/exceptaround a broker or data call whose handler substitutes a default. - Any config value that duplicates something the broker already knows — capital, buying power, open positions, contract multipliers. If both exist, they will eventually disagree, and the config copy will win by default because it is always available.
- Any equality check against a string you did not choose. Currency codes, exchange names, security types, order status values.
- Any code path that has never appeared in a log. If you have never seen the line that proves the real NAV was read, you do not know that it runs.
The cheapest version of this audit costs one log line: print the capital figure the
strategy actually sized from, every run, next to the source it came from. If it says
config when you expected broker, you have found it.
The uncomfortable part
This ran for months. The strategies worked. Orders filled, stops triggered, the trade registry recorded everything correctly, and the equity curve looked like the backtest.
The system was not broken in any way that showed up on a dashboard. It was sizing every position from a number typed into a file, and it would have kept doing that indefinitely, because nothing in the design ever asked the account what it was actually worth.
If you take one thing from this: the dangerous bugs in a trading system are not the ones that throw exceptions. Those get fixed the same day. The dangerous ones return a number that looks entirely reasonable.