The number in the weekly P&L digest was wrong. For about ten minutes I assumed I'd made an arithmetic mistake somewhere upstream. I hadn't. The trade the number was missing had never been recorded at all — and worse, a trade that never happened had been recorded in its place.
Where this fits
The previous post, The Contract Roll State Machine, walked through the sequence that moves a position from an expiring contract to the next one. On the happy path that machine ends clean: the close fills, the transition completes, the trader is flat and ready to trade the new contract. What it doesn't cover is the accounting when a position's entry and exit land in different sessions.
The roll isn't what causes that, incidentally. Sessions rotate for an entirely different reason, and that's where this starts. The problem itself begins with contracts expiring; this is the accounting that breaks downstream of it. The full fix is the next post.
A session is not a calendar day
It's tempting to hear "positions that span multiple sessions" and picture an overnight hold — a position opened before market close, still open at the next day's open. That happens, but it isn't the case that broke things here.
In this system, a trading session isn't a unit of calendar time at all. It's a unit of strategy configuration. Each session pairs a strategy with a specific set of inputs — parameters produced by the most recent optimization run. When a new optimization completes for a system that's actively trading, the old session stops and a new one starts, carrying the updated parameters forward. That swap can happen while a position is open. It has to be able to, because optimizations run on their own schedule and the market doesn't pause for them.
Sessions do also end when the market closes. But that's a shutdown, not a definition — nothing in the model ties a session to a calendar day, and the reoptimization swap doesn't wait for one.
So the ordinary path to a split trade isn't an overnight hold. It's a bracket order on a futures contract — a stop-loss resting with the broker, unfilled — still working when a scheduled reoptimization rotates the session underneath it.
What actually happens to the order
Here's the part I had wrong in my head for longer than I'd like. An order is bound to the session that placed it, permanently. The stop-loss submitted under the old session keeps that association no matter when it fills. Nothing migrates.
What happens instead is that the fill comes back from the broker after the swap, and the system files it as a new order — under the new session. The old session's stop-loss record is still sitting there, still marked working, because nothing ever told it otherwise. There are now two records of one stop order, in two different sessions, and exactly one of them is filled.
session A │ session B
─────────────────────────────┼─────────────────────────────
buy 1 ──► order row (A) │
filled │
│
sell 1 ──► order row (A) │
working ──────────┼──► still resting at the broker
│
reoptimization ────────────┼──► A stops, B starts
│
│ the stop fills
│ │
│ ▼
│ order row (B)
│ sell 1, filled
─────────────────────────────┼─────────────────────────────
a filled buy, and a stop │ a filled sell with no buy
stuck at "working" forever │ anywhere in the session
That duplicate is the root of everything downstream. The position tracking goes off because one stop order exists twice and only one copy ever fills. And the trade accounting goes off because each session now holds exactly half of a round trip.
What single-session accounting does with a split trade
The original trade calculation — the job that fires on every fill — was scoped to one session: pull every filled order for that session_id that isn't already linked to a trade, hand them to a FIFO matcher, get back a list of closed trades. Straightforward, and correct as long as every trade's entry and exit share a session.
When they don't, the matcher isn't wrong, exactly — it's answering a question that no longer matches reality. The matcher sees only the closing sell. The opening buy belongs to a session it was never shown. Its logic for an unmatched sell is unambiguous:
defp sell(sell_order, %{open_buys: []} = acc) do
# Opening or adding to a short position
update_in(acc.open_sells, &(&1 ++ [sell_order]))
end
There's no opposing buy in view, so as far as this function knows, nobody opened a position — the sell itself is the opening leg of a new short. That's the correct read of the data it was given. The data it was given was incomplete.
Two failures: a missing trade and a phantom short
The first failure is straightforward: the real trade doesn't get recorded. Its P&L never lands in the trade log, never rolls up into a weekly digest, never shows up on a dashboard. A closed, entirely real trade is absent from every downstream number.
The second failure is worse, and it's the one that produced the wrong digest number. The matcher didn't just drop the trade — it opened a phantom short to explain the orphaned sell. If a genuine new buy fires later in that same session, the matcher pairs it against that phantom instead of treating it as its own independent trade:
what session B hands the matcher what the matcher concludes
──────────────────────────────── ──────────────────────────
sell 1 @ 5302.00 opens a short, 1 contract
(really the old stop firing) └─ phantom: nothing opened it
buy 1 @ 5298.50 closes that short
(really a fresh entry) └─ writes a trade:
open sell @ 5302.00
close buy @ 5298.50
result +3.50, short
The real trade — a long from 5310.25 stopped out at 5302.00, a loss of 8.25 — is nowhere. In its place sits a fabricated 3.50 gain on a short that never existed, persisted to the database as if it were real: wrong entry price, wrong exit price, wrong duration, wrong P&L, and wrong direction. A long round trip recorded as a short. Meanwhile the genuine second entry has been consumed as the closing leg of a fiction, so it doesn't get its own trade either.
The obvious objection is that this system reconciles against the broker constantly — position every thirty seconds, working orders every five minutes. Why doesn't that catch it? Because those loops check the live position and the working orders against what the broker reports. The trade ledger is derived history. Once it's written, nothing audits it against anything.
Nothing about this trips an error. The system isn't confused; it's confidently, silently wrong. A crash is loud. A rejected order is loud. This produces a plausible-looking number that's incorrect, and the only way to notice is to already suspect the number is wrong — which you usually don't.
Why the fix isn't "look back one session"
The instinctive patch is to widen the query — pull the current session's orders and the previous session's, match against both. I wrote that version. It isn't enough. A resting order can outlive more than one reoptimization if the market is quiet and the strategy keeps re-triggering, so "one session back" is an arbitrary bound dressed up as a fix. It will work until the day it doesn't, and it will fail exactly as silently as the thing it replaced.
The actual requirement is to find the last point at which the account was genuinely flat, and match forward from there. In practice that means walking the sessions oldest to newest, accumulating a signed net position, and noting the most recent session boundary the running total entered at zero — then matching every filled order forward from that boundary, across however many rotations it crosses. The granularity is the session boundary, not an arbitrary moment: a position that goes flat mid-session isn't somewhere the walk can stop. That's a different kind of problem than the roll's state machine: not "how do we survive a crash mid-transition," but "how far back does the ledger have to go before it's telling the truth."
There's a second, quieter correctness question sitting next to it: once a trade spans sessions, which session owns its P&L — the one where it opened, or the one where it closed? Attribute-to-entry is the obvious-sounding answer, and it produces numbers that don't match what a trader expects to see under "today's P&L." I know because I shipped it that way first and had to reverse it. The next post covers the lookback algorithm, the full FIFO matcher, the duplicate-order fix, and why attribution runs the other way.
Sessions are epochs, not days
"Positions can span sessions" sounds like an edge case until you notice that sessions themselves aren't calendar units in this system — they're strategy epochs that can rotate underneath an open position on a schedule the market doesn't control. Any accounting scoped to a single session inherits an assumption nothing enforces.
And the duplicate order underneath it is the more general lesson. When an event arrives from outside after the context that created it is gone, the question isn't just where to file it — it's whether you're filing it twice. The failure mode isn't a crash. It's a number that looks fine until you know enough to doubt it.
Discussion
Create a free account to join the conversation.