Concept Lesson · Order Execution Safety

Phantom fills:
when your broker lies
about a trade.

Your API says "order filled." But the exchange never saw it. You place a stop-loss for a position that doesn't exist. Now you have a naked long losing real money.

Real incident: 21 May 2026
Strategy: DecayDoctor
BUG-175
The happy path

How you think an order works

Your code
Place SELL
Broker API
Received
Exchange
Matched
API says
"Complete"
You
Place SL

You send the order. Broker forwards it to the exchange. Exchange matches it with a buyer. Broker tells you "complete." You place a stop-loss to protect the position.

Simple, linear, predictable. Except it isn't always.
What actually happens

The broker has a queue between you and the exchange

Your code
Place SELL
Broker OMS
"Scheduled"
???
Exchange
Not yet

Upstox has an internal Order Management System (OMS). When you place an order, it doesn't go straight to the exchange. It sits in the OMS first — validated, queued, then forwarded.

On BSE (SENSEX options), this queue can hold orders for 5+ minutes before forwarding them. The Upstox UI shows these as "Scheduled".

The order exists at the broker. It does not exist at the exchange. These are not the same thing.
The dangerous moment

The API says "complete" — but is it?

order_manager.py
# Place a LIMIT SELL order
result = broker.place_order(sell_order)         # "order accepted"

# Wait 5 seconds for it to fill
time.sleep(5)

# Ask the broker: did it fill?
status = broker.get_order_status(order_id)
if status == "complete":                         # Upstox says YES
    place_stop_loss(...)                         # We trust it blindly

The Upstox API can return status = "complete" while the order is still in the OMS queue. Most of the time the fill is real. But if the exchange later rejects the order, you've placed a stop-loss for a position that doesn't exist.

21 May 2026 · real incident

What happened to us today

09:53:31
DecayDoctor places a LIMIT SELL for SENSEX 75700 PE at 187.05
09:53:36
System checks status after 5 seconds — Upstox says "complete", avg_price = 187.05
09:53:36
System immediately places SL BUY at trigger 280.60 — before confirming the position actually exists on BSE
10:07:56
SL BUY triggers at 280.94 — loss of Rs 9,389

Today the fill was real — we got lucky. But the code had zero defense against a false positive. If the exchange had rejected the SELL, the SL BUY would have created a naked long position losing money with no hedge.

Root cause

Why does the API lie?

It doesn't lie on purpose. It's a timing mismatch between layers:

1

Your code

Asks the broker API: "Is my order filled?"

2

Broker OMS

Knows the order was accepted and priced. Reports "complete" optimistically.

3

Exchange

Might still be validating, or hasn't even seen the order yet. The "complete" refers to the OMS step, not the exchange step.

One API. Two different systems behind it. No way to tell which one answered.
The worst case

What happens when the exchange rejects

Step 1
SELL order
Step 2
API: "filled"
Step 3
SL BUY placed
Step 4
Exchange rejects SELL

Now you have no short position (the SELL was rejected) but a live SL BUY order sitting on the exchange.

When the price hits the trigger, the SL BUY fires. You buy something you never sold. A naked long position, losing real money, with no hedge.

The system created risk instead of protecting against it.
The fix · BUG-175

Don't trust one source. Cross-check.

Before (trust blindly)

  1. Place SELL order
  2. Ask broker: "Is it filled?"
  3. Broker says "yes"
  4. Immediately place SL

After (cross-check)

  1. Place SELL order
  2. Ask broker: "Is it filled?"
  3. Broker says "yes"
  4. Ask broker: "Show me my positions"
  5. Position book confirms short exists?
  6. Only then place SL
Two independent data sources. Both must agree before we risk money.
The code

The position-book guard

order_manager.py
# After broker says "filled", ask a DIFFERENT question:
# "Do I actually HOLD this position?"

positions = broker.get_positions()

for p in positions:
    if p.instrument_id == my_instrument:
        if p.sell_quantity >= expected_qty:
            return fill_result       # Position confirmed — safe to place SL

# Position not found — PHANTOM FILL!
log.critical("PHANTOM FILL: order says filled but no position found")
send_alert("PHANTOM FILL BLOCKED — verify manually!", severity="CRITICAL")
return None                  # Do NOT place SL. Report failure to engine.

The engine receives None (no fill), so it never places an SL. The operator gets a CRITICAL Telegram alert and can verify manually.

Design decision

When should you trust and when should you block?

Fail-open (trust the fill)

When get_positions() itself throws an error (API is down, timeout, network issue).

Why: If we blocked every fill during API instability, we'd miss real trades on volatile days — exactly when trading matters most. The order API already said "filled" — it's probably right.

Fail-closed (block the fill)

When get_positions() returns data successfully but the position is missing or quantity is wrong.

Why: The API worked fine — it just told us the position isn't there. That's a clear signal. Placing an SL now would create the exact naked position we're trying to prevent.

Fail-open on errors. Fail-closed on contradictions.
The general principle

Never trust a single data source for money decisions

"Is my order filled?"
Check order status AND position book. Two APIs, same question, must agree.
"Is my SL still active?"
Don't trust your local variable. Query the broker. The exchange can cancel or reject it without telling you.
"Did squareoff close everything?"
After closing, re-query the position book. If anything is still open, alert immediately.
"Is the market open today?"
Don't hardcode. Load the holiday file. And make sure the holiday file itself is current — last year's will silently trade on a closed exchange.
If money depends on it, verify it from two independent sources.
New vocabulary

Words you'll use from now on

OMS

Order Management System

The broker's internal queue. Your order sits here before reaching the exchange. Upstox, Zerodha, everyone has one.

Phantom fill

Ghost confirmation

The API says "filled" but the exchange hasn't confirmed. The position might not exist. Trusting it blindly creates naked risk.

Position book

What you actually hold

The broker's list of your real positions. It's updated after the exchange confirms. It's the truth, not the order status.

Naked position

Unhedged exposure

A BUY without a corresponding SELL (or vice versa). You're exposed to unlimited loss in one direction with no protection.

The rule

Order status tells you what the broker thinks.

Position book tells you what the exchange knows.

Check both before placing a stop-loss. Always.

← Back to course index

Arrow keys to navigate · F for fullscreen
1 / 13