18 May 2026 · 4 min read
Post #1When my own script lied to me
Why my dashboard showed zero positions while IBKR had fourteen
Saturday morning May 16, 09:00. I open playitsmart.nl/live to check how the paper portfolio looks after Friday's first 14 orders. The page loads. At the bottom is the number that makes me pause.
Positions: 0 of 8.
But I know for sure there have been fills. IBKR Portal showed them on Friday afternoon. Fourteen of them. Seven Dutch stocks, seven American. I click over to my IBKR Portal in another tab. They're still there. Fourteen positions, all OPEN, all with entry prices matching the orders we submitted on Friday.
My own system says zero. IBKR Portal says fourteen. One of the two is lying. And I don't know which one.
Three hypotheses
Before I start a fix, I write down three possible causes. That's a habit I've imposed on myself for live trading work. Think first, act second.
Hypothesis A: fill_sync isn't working and isn't pulling executions from IBKR.
Hypothesis B: positions_sync isn't working and isn't syncing open positions back to Supabase.
Hypothesis C: there is no positions_sync at all. The system reads positions from fills, and if fill_sync is broken, the whole picture is broken.
Write a diagnostic script, don't fix yet. I open Render Shell on the playitsmart-trader-daily service and manually run a script that does IBKR's get_positions() call via the existing IBKRClient class. It should immediately return 14 positions.
It returns zero.
The detail I hadn't seen
Pause, make coffee. The IBKRClient does establish a connection with Gateway. Logs show connected client_id=11. So the IBKR connection itself works. But the positions request returns an empty list.
I open the IBKR API documentation. For reqPositions you need an account specifier. My account ID at IBKR is DUP*****. That's what IBKR knows and recognizes.
Then I look at the constructor of the class in reconcile.py. The line that would later teach me about the cost of shortcuts:
ibkr = IBKRClient(gateway, args.account_id)
args.account_id wasn't DUP*****. It was nl-individual-***. That's my internal app account identifier. A field I use to distinguish portfolios in my Supabase tables.
The IBKRClient constructor accepted any string as account ID. No validation. No error. An internal app identifier was sent to IBKR's API as if it were a valid IBKR account. IBKR politely said "I don't recognize that account, here are zero positions" and my system accepted that.
The fix that took longer than I thought
Two options to resolve this.
Option 1: in reconcile.py directly hardcode the right IBKR account ID or pull it from env.
Option 2: build a factory pattern where the right account ID is centrally injected, so the same error doesn't reappear elsewhere.
I choose option 2. Not because it's the fastest fix, but because grep'ing through the codebase reveals the same mistake in route_orders.py and orchestrator.py. Three scripts with the same error. A factory prevents a fourth script from repeating the same pattern in the future.
scripts/ibkr_client_factory.py as a new file. Central point where IBKR_ACCOUNT_ID is read from env and validated. Three scripts updated. One new test to check the factory passes the right account through. Commit pushed.
sync_fills.py was already correct, by the way. It read the account ID directly from the right env var. An earlier version of me had thought it through there, while I'd been sloppy with reconcile.py.
What I took from it
The bug itself was small. One parameter. One line. The fix was ten minutes of code and twenty minutes of tests. But the consequences were big. Fourteen positions that weren't visible. A dashboard that was wrong. Confusion during a launch-prep weekend.
The lesson: constructors that accept any input without validation are an attack surface for future mistakes. Cursor didn't suggest validation because it wasn't explicitly asked. It built what I asked, nothing more. That's what good AI does. But I need to be more specific as a reviewer about what I want protected.
The other lesson: writing three hypotheses before fixing saves you from early conclusions. I could easily have jumped to debugging fill_sync, while that wasn't even the problem. The habit of writing what I suspect, ranking by likelihood, and then validating, costs five minutes and sometimes saves hours.
The take-away
My own script didn't lie on purpose. It did exactly what I asked. But what I asked wasn't what I meant. Between "construct an IBKR client with this string as account" and "construct an IBKR client with the right IBKR account" lies a world of difference. A world Cursor doesn't fill in for me, because that's my job.
Fourteen positions were sitting in IBKR, right where they belonged. Not the world that was broken. Just my view of it.