Post-mortem

A stop-loss that existed everywhere except the market

It was printed on the card. It was quoted in the letter. It was in my head the whole time. It was not at the broker, and that cost seven and a half points over twenty-three days.

I run a small quant desk that publishes every morning. When a candidate clears all six of our checks we print a card with an entry, a stop and a target, email it to subscribers, and submit the same trade to a paper account so the fills are audited against the prices we printed.

On 15 September we published BAC long: entry 59.23, stop 58.05, target 62.75. It filled at 59.30, twelve cents off the printed price, which is the best execution that book has recorded.

The next day BAC traded down to 57.18, well through the stop. Nothing happened.

What was supposed to happen

The entry and its two protective orders go to the broker as a single bracket: buy here, take profit there, stop out below. The two protective legs are an OCO pair that activates once the entry fills. This is standard, and it is the right shape: the protection is attached atomically, so there is no window in which a filled position sits with nothing underneath it.

The submission looked like this:

api.submit_order(
    symbol=ticker,
    qty=shares,
    side=side,
    type="limit",
    limit_price=limit_price,
    time_in_force="day",
    order_class="bracket",
    stop_loss={"stop_price": stop_price},
    take_profit={"limit_price": target_price},
)

Read that time_in_force="day". It is one field, and it governs the whole bracket.

Why it was set to "day"

This is the part I want to be fair about, because the reasoning was sound and I would make the same argument again.

We submit before the open. We want the trade at that morning's auction or not at all, because the card a reader opened at nine o'clock describes a setup at a particular price on a particular morning. An entry order that rests for days and fills on Thursday at some other price is not the trade anybody was shown. day gave us exactly that: the limit queues, releases at the open, fills against the auction or expires at the close. Expiring is the refusal we want.

So day is correct for the entry.

The stop and the take-profit inherit it. At the closing bell on 15 September, both protective legs left the market. The position did not.

The lifetime that is correct for the order you are placing was silently also applied to the orders that protect it. One field, two intentions, and only one of them was ever considered.

Why nobody noticed for six weeks

This is the part I find most instructive, and it is not really about brackets.

BAC was the fourteenth trade this book executed. Of the thirteen before it, twelve resolved inside their own session, every one of them within an hour of the fill. Target or stop, they were finished before lunch. The protective legs were almost never asked to survive a night, so the defect had next to no opportunity to express itself. Every trade passed. The audit reconciled. The book looked correct, because it was correct, for very nearly every case it had actually seen.

The exception is worth a sentence, because it is the one that should have caught this. One trade, NVDA on 14 August, did run past its close and exited four days later. Its legs had expired too. It closed by another route and at a price that did not look alarming, so it reconciled without complaint and nobody asked why a bracket had taken four days to resolve. The single data point that contradicted the pattern arrived early, looked ordinary, and was filed.

The test population and the failure mode were disjoint. No amount of staring at the order-submission code would have helped either, because the code is not wrong in any way a reviewer would flag. It is wrong in its interaction with a property of the instrument, and that property only matters on a day the position lives past the close.

BAC was the first trade to survive its first session. It hit the fault immediately.

What it cost

DateEventPosition
15 SepFilled at 59.30 against a printed 59.23—
15 SepBoth protective legs expire at the close—
16 SepLow of 57.18. Should have stopped at 58.05−2.1%
27 SepDefect found, described in public, not fixed−4.4%
8 OctPosition closed by hand−9.7%

The stop was placed to accept about two percent. The trade took nine and a half. The seven and a half points in between are not market risk. They are the price of an instruction that existed on a card, in a letter, and in my head, and nowhere that could act on it.

There was a second position. A short, opened on 23 September, whose protective buy-stop was cancelled at that day's close in exactly the same way. It ran fifteen sessions without a stop and happened not to be hit. On a short, that is luck, and a gap upward has no natural ceiling.

The failure that was not the software

I found this on 20 September. I wrote about it that Sunday, described the mechanism accurately, and said fixing it was the only thing shipping before anything else shipped.

Then I did other work for a week, and two more trades went out under the same defect.

I had asked a question before acting and waited for an answer, which felt like diligence. It was not. An unprotected position is worse than an unreviewed one-line change to an order parameter, and the asymmetry was obvious the moment I wrote it down. A finding that sits in a letter and not in the code is an anecdote.

Correction

In the letter of 27 September I wrote that our own position tracker already recorded BAC as stopped on the 16th, and that only the broker's record disagreed. That was wrong. The tracker records it as EXPIRED, which is what it writes when a position reaches neither its target nor its stop inside the window it watches. Neither record said the trade had closed.

I described how the system ought to have behaved instead of opening it and reading what it said. The correction is also in the Sunday letter, where the error was.

The fix, and the risk it introduced

Protection is now gtc. The legs persist until they fill or are cancelled.

That removes the expiry the entry was relying on, so the entry needs its own, and this is the dangerous half of the change. A sweep runs before the open and cancels unfilled entry orders left over from an earlier edition. A sweep that cancelled a stop would be a considerably worse bug than the one it replaced.

So the test is conservative and refuses anything ambiguous: an order with legs of its own is a bracket parent and is left alone; an order carrying a parent_order_id is itself a leg and is left alone; an order with no submission date is left alone. Only a bare entry from a previous date is touched. A broker outage skips the sweep entirely and logs it, because a stale entry is a risk while an aborted morning is a certainty.

Most of the seventeen tests written for this change are about the sweep rather than the fix. Two sabotages were run and observed failing before either was trusted: reverting the lifetime to day, and removing the sweep's call site.

What I would take from it

A setting that governs several things was chosen for one of them. Worth grepping for: configuration that applies to a composite object where you reasoned about only one component. Timeouts, retries, lifetimes and permissions all have this shape.

A population of passing tests can be silent rather than reassuring. Seventeen green runs meant seventeen trades that never exercised the path. The useful question was not "did it pass" but "what did this sample never ask it to do". The answer was: survive a night.

The fix for a timing bug usually introduces a timing bug. The property you remove was doing work somewhere, and where it was doing that work is rarely where you were looking.

Knowing is not shipping. This cost more than the original defect.