Safe Order Placement: When the Broker Doesn't Answer

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