What should a trading engine do when an order request times out and nobody knows whether it reached the exchange?
Intro
The Timeout Problem
You send an order to your broker, and the connection times out. No answer, no error, just silence. Did the order reach the exchange, or did it vanish on the way? Both stories are possible, and guessing wrong costs real money. Today we look at how Kestrel handles an order whose fate is unknown.
- Request sent
- No answer
- Did it fill?
Where We Are
In the previous episode we covered broker plumbing: the daily login, secret redaction, and rate limiters. Now we look at what actually travels through that plumbing. We will see the request, local rejections, order tags, a classifier for responses, a lookup mechanism, and a write-ahead ledger. A caution first: the live code is lightly exercised, so treat this as design, not proof. Educational only, not financial advice.
- Episode 22: broker plumbing
- Today: order placement
- Design, not proof
The Two-Fill Disaster
Here is the disaster we are designing against. The engine wants to buy one hundred shares. The first request reaches the broker and fills, but the reply is lost. The engine sees a timeout, and sends again. Now the exchange has two fills, and the account holds two hundred shares instead of one hundred. Everything that follows exists to prevent this picture.
- Retry after timeout
- Both requests arrive
- Position doubles
The Request
The Place Request
Placing an order is one HTTP call: a POST to slash api slash v3 slash orders slash sync. The body carries the symbol, the quantity, the order type, the side, the product type, a limit price, a stop price, a validity, an offline order flag that is always false, and finally an order tag. That last field looks like decoration. It turns out to be the most important one.
- POST /api/v3/orders/sync
- Fields: symbol, qty, type, side
- orderTag identifies it
Codes and Cancels
The type is a number: one for limit, two for market, three for stop. The side is one for buy and minus one for sell. Validity is either DAY, which lasts the session, or IOC, immediate or cancel. Cancelling is the mirror image: a DELETE to the same path, with the order id in the body. Notice a cancel needs the broker's id, which is another reason a lost order is so awkward.
- type: 1 limit, 2 market, 3 stop
- side: 1 buy, -1 sell
- validity: DAY or IOC
Local Rejections
Reject Before Sending
Before anything touches the network, a function called mapOrder translates our order into that request, and it is allowed to refuse. Index symbols are rejected, because you cannot trade an index directly. Expired contracts are rejected. So are symbols missing from the master file, and fractional quantities. A local rejection is the best kind of failure: it is certain, instant, and costs nothing.
- mapOrder checks locally
- Definite and free
- Nothing leaves the process
Lots and Exchanges
More local checks follow. Futures and options quantities must be an exact multiple of the lot size. An IOC order on MCX, the commodity exchange, is refused. And on MCX the quantity we hold internally is in units, so mapOrder converts it into lots before sending. Each check mirrors a rule the broker would enforce anyway, but catching it here means we never have an ambiguous answer to a question we could have answered ourselves.
- F&O qty must be lot multiple
- IOC refused on MCX
- MCX units become lots
Conservative Rounding
Prices must sit on the tick grid, and here the rounding is deliberately conservative. A buy limit rounds down, a sell limit rounds up, so rounding never makes us pay more or receive less than asked. Take an illustrative tick of five paise. A buy limit at one hundred rupees and three paise becomes one hundred exactly. A sell limit at the same price becomes one hundred rupees and five paise. Stops round the opposite way.
- Buy limit rounds down
- Sell limit rounds up
- Stops round opposite
Order Tags
Anatomy of an Order Tag
Now the order tag. It must be alphanumeric and at most thirty characters. Kestrel builds it from three parts: the word kestrel, a six character session tag in base thirty-six, and the order id, also in base thirty-six. For example, kestrel k three x nine q one one c two. The broker stores this string with the order, so it is a label that survives even when every reply is lost.
- kestrel + session tag + id
- Alphanumeric, max 30 chars
- Example: kestrelk3x9q11c2
The Session Tag
The session tag is a hash of the current time, the process id, and a random value. It is generated fresh every time the service starts. That means two different runs of the service will, with overwhelming likelihood, never share a session tag, even if the process id repeats or the clock is coarse. The order id after it is just a counter within the session.
- Hash of time, pid, random
- New on every service start
- Never reused
Why Uniqueness Matters
Why does this matter so much? Because the tag is the only way to find an order whose outcome is unknown. We do not have a broker id yet, by definition. Imagine the counter restarting at one after a restart, with no session tag. Order one today would collide with order one from this morning, and a search might happily adopt the wrong order. Unique tags make a search answer unambiguous.
- Counter restarts at 1
- Tag is the only handle
- Repeats would match old orders
Base Thirty-Six Arithmetic
Base thirty-six uses the digits zero to nine, then the letters a to z for ten to thirty-five. So seventeen is the single letter h. A longer id like one c two works out as one times thirteen hundred ninety-six, plus twelve times thirty-six, plus two. That is thirteen ninety-six plus four thirty-two plus two, which is seventeen hundred and thirty. Short ids stay short, and the tag stays well under thirty characters.
- Digits 0-9 then a-z
- 17 is h
- 1c2 is 1730
Definite or Uncertain
Two Kinds of Answer
Every response from the broker falls into one of two classes. A definite answer tells us what happened: the order exists, or it was refused. An uncertain answer leaves both possibilities open. The two classes get opposite treatment. After a definite refusal we may react freely. After an uncertain one, we must not send the order again, and must go looking for it instead.
- Definite: we know the outcome
- Uncertain: we do not
- Different rules for each
The definite Function
This is the classifier, shown verbatim. It is a const noexcept method on the response. Read it top to bottom as a decision list. The first line to match decides the answer, and the order of the lines is part of the design.
- FyersResponse::definite()
- Six ordered checks
- Conservative by default
Success and the 201 Trap
The first line catches code two hundred and one, which the broker uses to say the request was made but there was no acknowledgement. That is the textbook uncertain case, and it is checked before anything else, because it could otherwise be mistaken for success. The second line says that if the response is ok, meaning success with an order id, then we know the outcome, and the answer is definite.
- Code 201: no ack, uncertain
- ok() with id: definite
- Checked first on purpose
Server Errors and Client Errors
Next, HTTP status. A server error, five hundred or above, is uncertain: the server may have crashed after queueing our order. A client error, four hundred or above, is definite: the server looked at the request and refused it, so nothing was placed. Then transport errors, such as timeouts and broken connections. Those say nothing about what the server did, so they are uncertain too.
- 5xx: uncertain
- 4xx: definite
- Transport error: uncertain
The Body and the Catch
The last line reads the JSON body. Only a status of error together with a negative code counts as a definite refusal. If the body cannot be parsed at all, the catch block returns false: uncertain. The same goes for a response that is not JSON, or one that says ok but carries no order id. Whenever we cannot prove the outcome, the function says we do not know.
- status error with negative code
- Parse failure: uncertain
- Non-JSON, ok without id too
The Full Decision Tree
Put together, the whole tree has a clear shape. Green leaves are the cases where we can prove what happened: success with an id, any four hundred class status, and an explicit error with a negative code. Everything else is amber. The asymmetry is intentional. Wrongly calling something uncertain costs us a short lookup. Wrongly calling something definite could cost us a second fill.
- Green: definite
- Amber: uncertain
- Default is amber
Finding Lost Orders
Lookups by Tag
When a placement ends uncertain, the order goes into a lookup queue. Once per worker cycle, processLookups fetches the entire, unfiltered order book from the broker, a single request however many orders are waiting. Then it scans that list locally, comparing each entry's tag with the tags we are hunting. One fetch serves every pending lookup.
- processLookups per worker cycle
- Fetch unfiltered order book once
- Match tag locally
Do Not Trust the Filter
There is a subtlety. FYERS stores the tag with a prefix, one colon, then our tag, so the broker's copy reads one colon kestrel and so on. Kestrel accepts both the bare and the prefixed form. And although the broker offers a server-side filter by tag, the code deliberately does not trust it. A filter that silently returned nothing would look exactly like a lost order. A local match against the full book is something we can verify ourselves.
- FYERS stores 1:<tag>
- Both forms accepted
- Server-side filter not trusted
Backoff Timeline
Why more than one lookup? The order may still be in flight, so the first search can miss. The lookups retry after one, two, four, and eight seconds, in five attempts in total. Picture the timeline: the ledger write, then the POST, then the timeout, then lookups spaced further and further apart. As soon as one finds the tag, the order is adopted and tracked like any other.
- Retry at 1, 2, 4, 8 seconds
- Five attempts
- Found: adopt
When It Never Appears
What if all five attempts fail to find it? Then the order is marked Rejected, with the message: place outcome unknown, verify on Fyers, followed by the order tag. An ERROR line is logged as well. Rejecting unblocks the rest of the system, and the message puts the exact tag in front of a human who can check the broker's own screen.
- Reject with explanation
- ERROR log with the tag
- Human can verify on Fyers
The Thirty-Second Recheck
But a rejection here is a statement about our knowledge, not about the world. So the tag stays on a watch list, and every thirty seconds the order book is checked again. If the order shows up late, it is adopted as an orphan, and tracked from then on. This closes the last hole: even an order that surfaced minutes later cannot become an invisible position.
- Recheck every 30 s
- Appears later: adopt as orphan
- Reject was not final truth
Tracking
Polling Cheaply
Once an order is known, we track it by polling. Every orderPollMs, never less than two thousand milliseconds, one order book fetch covers every open order. The more expensive trade book is fetched only when some order's filled quantity has grown. That keeps requests low, which matters, because in the previous episode we saw how tightly the broker rate limits are managed.
- orderPollMs, minimum 2000
- One order-book fetch for all
- Trade book only if filledQty grew
Status Mapping
The broker reports status as a number, and we map it. Two means Filled. One and seven mean Cancelled. Five means Rejected. Four and six mean the order is still New, working at the exchange. A small table, but every row is a state transition the OMS can reason about, rather than a raw broker integer leaking through the system.
- 2 Filled
- 1 and 7 Cancelled
- 5 Rejected; 4 and 6 New
Dedupe and Gap Fills
Fills arrive from the trade book, and each carries a trade number. We remember the numbers we have seen, so polling the same trade twice never counts it twice. One more edge case: the order book says an order is filled, but the trade book shows nothing. After ten minutes, Kestrel emits a synthetic gap fill at the order's average price, and logs a warning. For example, a hundred shares at two hundred fifty rupees and fifty paise, illustrative.
- Fills deduped by tradeNumber
- No trades in 10 min: gap fill
- Synthetic fill at average price
Write-Ahead Ledger
Write Intent First
Tags help when the process survives. But what if Kestrel itself crashes between sending an order and learning the result? For that we use a write-ahead ledger, the same idea databases use. Before the POST, the intent is appended as one JSON line to a file under the strategy's live directory, named by date. The record holds the tag and the order details, and it goes to disk first.
- Append before POST
- <positionOutputDir>/<strategy>/.live/<date>.jsonl
- One JSON line per intent
The Ledger Append
Here is the shape of it inside submit, simplified. We build a JSON object with a kind field of order, the tag, the order id, and the other details, and append it to the ledger. If that append throws, we do not carry on. We reject the order with the message: live ledger write failed, order not sent. The code path to the network is only reachable after the write succeeds.
- Simplified from FyersLiveVenue::submit
- Failure means no order sent
- Message: live ledger write failed
Failing Closed
This is failing closed. If the disk is full or the directory is unwritable, an order that cannot be recorded is an order we could not recover after a crash. Refusing to send is a small, visible cost. Sending anyway would risk an invisible position that no restart could find. The trade-off is chosen on purpose: the engine would rather miss a trade than lose track of one.
- No record, no order
- Full disk, bad permissions
- Safer than trading blind
Recovering After a Crash
Now suppose the process dies and restarts. A new session tag is generated, which is exactly why old tags cannot collide. Then recover reads today's ledger, looking at entries from older session tags. For each, it finds the order by the recorded broker id or by tag, re-adopts it, cancels it if it is still open, and applies any trades it missed. The book is rebuilt from facts.
- Read today's ledger
- Find by id or tag
- Adopt, cancel open, apply trades
Never Touch Manual Orders
One last safeguard in recovery: scope. The broker account may also hold orders placed by hand, or by other tools. Recovery matches only the orders recorded in our own ledger. An order with any other tag is never touched, never adopted, never cancelled. The engine cleans up its own mess and nobody else's.
- Only recorded tags are matched
- Manual orders left alone
- Scope is explicit
Worked Example
Order 17 Times Out
Let us walk through one example. Session tag k three x nine q one, order seventeen. Seventeen in base thirty-six is h, so the tag is kestrel k three x nine q one h. First, the ledger line is written to disk. Then the POST goes out, and the call times out. A transport error is uncertain, so definite returns false, and the order enters the lookup queue.
- Session k3x9q1, order 17
- Tag: kestrelk3x9q1h
- Ledger written, POST sent
Lookups at One and Two Seconds
One second later, the first lookup fetches the whole order book. No entry carries our tag: a miss. The request may simply have been slow. At the second lookup, the book contains an order tagged one colon kestrel k three x nine q one h. Found. Kestrel adopts it, tracks it by polling, and applies its fills. The order was placed exactly once, and we never sent it again.
- +1 s: miss
- +2 s: found 1:kestrelk3x9q1h
- Adopt, no second POST
Pitfalls
Three Pitfalls
Three pitfalls summarise the episode. First, retrying on timeout: it feels like robustness, but it is how double fills happen. Second, trusting a server-side filter: an empty result is not proof of absence. Third, tags that repeat after a restart: a counter without a session tag can match yesterday's orders. Each pitfall looks harmless in code review, and each is a bug that only appears on a bad day.
- Retry on timeout
- Trusting server filters
- Tags repeating after restart
Recap
Recap
To recap. Order placement is a conversation with an unreliable network. Local checks reject what we can know is wrong. Every order carries a unique tag, built from a session tag and an order id. The definite function sorts responses into proven and unknown. Unknown outcomes are searched for in the full order book, never re-sent. And a write-ahead ledger lets a restarted service recover its own orders and nothing else.
- Classify with definite()
- Find by tag, never retry
- Write ledger before POST
Next: The Order Service
We now know how one order is sent safely. But who owns all the orders from all strategies? In the next episode we meet the order service, with its sessions, its shadow books, and crash recovery from the other side.
- Episode 24: kestrel_orders
- Sessions and shadow books
- Subscribe for the rest