Get in touch

9io.ai / Blog

Order safety by default in code that can place trades

How 9io Alpha keeps order code safe with paper ports, dry runs, order caps and broker-side exits, and the cancel-then-sell race we still have to fix.

Key takeaways

  • Start every order path in paper or dry-run mode, with live trading behind an explicit server setting.
  • Decide paper or live from the account the broker session reports, since ports are only a convention.
  • Validate and cap each order on your own server before any call to the broker.
  • Place exits at the broker with the entry, and show the user when an exit failed.
  • Treat a cancel as pending until the broker confirms it, then re-read the position before selling.

If software can send orders to a broker, make doing nothing its default. Every order path should start in a mode that can’t trade, and each order should pass checks on your own server before it goes anywhere. Exits belong at the broker, placed together with the entry. A cancel counts as pending until the broker confirms it.

9io Alpha is our own trading research platform, and it can place orders through Interactive Brokers (IBKR) and Zerodha’s Kite Connect. This post covers the defaults and checks we use, what the Knight Capital case says about them, and where they fall short. The biggest gap is an exit watcher that cancels resting orders and then sells at market after a fixed half-second wait. We explain the failure this allows and the fix we’re making. The suggested entry, target and stop can come from the research pipeline described in guarding an LLM research pipeline with two reviews and a judge.

The post describes engineering and is not investment advice. Broker behaviour is as described in each broker’s documentation, linked throughout.

Every order path starts where it can’t trade

In the web app, entry orders go out only when a person clicks a button, and no scan places one. Each broker path also starts in a mode that can’t spend money.

IBKR Zerodha Kite
Default mode Connects to the paper-trading port Dry run
To trade live A live port plus an explicit opt-in setting on the server An explicit opt-in setting on the server
What the default returns Real orders, in the paper account The exact order and exit parameters, with no call to Kite

Two details make these defaults hold. The opt-in is a server setting, separate from anything a user can change in the app. And the dry run returns the exact parameters the live path would send, so a person can read the order and its exit before anything is real. IBKR applies the same idea in its own software. TWS ships with its API in read-only mode as a precaution1, and IBKR’s setup guide lists switching that off among the settings needed to use the API2.

A port is only a convention. By default, TWS listens on port 7496 for live sessions and 7497 for paper ones1, but either can be changed, so a check based on the port can be fooled by a custom set-up. The sturdier check reads the account itself. When a client connects, TWS reports the account numbers available in that session3, and the client can compare them with the paper account it expects and refuse anything else. We’re moving our own check from the port to the account.

Checks that run before an order leaves the server

Both paths validate each order on our server before any call to the broker.

Check IBKR path Kite path
Live trading Refused on a live port without the opt-in Dry run without the opt-in
Quantity A whole number from 1 to 100,000 A whole number from 1 to 10,000
Order value At most $50,000 (quantity × entry) Bounded by the quantity cap
Price levels 0 < stop < entry < target Stop below target
Order type Limit entry Market or limit; a limit needs a price
Session The gateway must answer and the symbol must resolve A login token issued today, India time

Kite access tokens expire at 6 am the next day, which Zerodha’s docs describe as a regulatory requirement4. A request with an expired token gets a 403 session error5. Our check compares the date the token was saved with today’s date in India. Where that approximation and the real cut-off disagree, either we refuse an order Kite would have accepted, or Kite rejects it with a session error. Neither case places an order. Broker credentials are stored in a file that only its owner can read (mode 600).

Every check in the table works on one order at a time. The US market access rule, SEC Rule 15c3-5, requires brokers to reject orders that breach credit or capital thresholds, orders that break price or size limits per order or over a short period, and orders that look like duplicates6. The rule doesn’t bind a research platform like ours, but it makes a good checklist. Per-order checks are the first layer. Limits over a period and duplicate detection are the next one, and they catch failures a per-order cap can’t see, such as the same order sent twice.

What the Knight Capital order shows

The SEC’s 2013 order against Knight Capital is worth reading in full if you write order code7. On 1 August 2012, while processing 212 small retail orders, Knight’s router sent millions of orders into the market over about 45 minutes. They produced more than 4 million executions in 154 stocks, for more than 397 million shares. Knight ended up with a net long position of about $3.5 billion in 80 stocks and a net short of about $3.15 billion in 74, and lost more than $460 million. It paid a $12 million penalty8.

Three of the SEC’s findings are worth keeping in mind when writing order code.

  • A deployment missed one server. New code reached seven of eight servers. The eighth still held old code, which a repurposed flag switched on. No second technician reviewed the deployment.
  • The sender didn’t know the orders were filled. Another part of Knight’s system recognised that the parent orders were complete, but that information never reached the router, which kept sending child orders.
  • Warnings went unread. An internal system sent 97 automated emails about the error before the market opened. They weren’t designed as alerts, and staff generally didn’t review them.

Knight also had no pre-set capital thresholds wired to stop new orders, and a per-order cap is a small version of that control. The second finding has the same shape as the race in our exit watcher, in which code sends an order without first confirming what has already happened at the broker. The third is a reminder that a blocked or failed automated action needs to reach a person, and a line in a log doesn’t count.

Attaching the exit to the entry

We want every exit to live at the broker, where it keeps working even if our server stops. Both paths place the exit in the same action as the entry.

On IBKR the entry is a limit order, with a take-profit limit and a stop attached as child orders. A bracket is three orders, so IBKR warns that one could fill before the others are sent. Its answer is the transmit flag. The parent and the first child go to TWS with transmit off, which holds them there, and the last child goes with transmit on, which releases all three together9. Our client uses a library helper that sets those flags.

On Kite, when the request includes a target and a stop, we place a delivery buy and then a two-leg GTT (good-till-triggered) order for the exit. Kite’s two-leg trigger implements one-cancels-the-other, with one trigger for the stop and one for the target10. When either fires, the other is cancelled, and a GTT can stay active for up to 365 days11.

The two Kite calls aren’t atomic, so the response reports them separately. If the GTT call fails after the buy succeeds, the order ID comes back with a separate error for the exit, because the worst outcome is a person who believes a position has an exit when it doesn’t. That only helps if the screen shows it, so a failed exit needs its own warning next to the order, as visible as the confirmation of the buy.

Two more details matter for exits on Kite.

  • A triggered stop can miss. GTT legs are limit orders, the only order type Kite Connect’s GTT API lists10. Zerodha’s first listed reason for a triggered GTT not executing is that the market price no longer matched the limit price12. A stop leg whose limit equals its trigger leaves a falling price the least room to fill. A limit set somewhat below the trigger fills more often, at a worse price.
  • The exit can outlive the entry. If the GTT goes in as soon as the buy is accepted and a limit buy then expires unfilled, the GTT stays live, and it can later sell shares the account holds for some other reason. Place the GTT only after the buy fills, or cancel it when the buy expires.

The exit watcher and what it does

The bracket legs cover positions opened through 9io Alpha. The exit watcher covers the whole IBKR account. Once someone switches it on, it reads the account’s positions every 60 seconds by default and compares each long position’s market price with its average cost. If the gain or the loss passes a fixed percentage, it exits in three steps.

  1. Cancel every open order this client has on that stock.
  2. Wait 0.5 seconds.
  3. Sell the whole position at market.

It only sells longs, it respects the same live-trading gate as order placement, and it records every action, including the ones the gate blocks. It also acts on positions opened by hand, so a long-term holding in the same account gets the same short-term exit. It runs inside the web process, so a restart switches it off until someone starts it again. The bracket legs at the broker don’t depend on it.

The race between a cancel and a fill

Sending a cancel starts a process at the broker. IBKR’s docs describe the PendingCancel status as a cancel that has been sent and not yet confirmed, and warn that “you may still receive an execution while your cancellation request is pending”13. The same docs say order-status messages can repeat and aren’t guaranteed for every change14. A fixed sleep assumes the cancel lands within half a second. When it doesn’t, the events can run like this for a long position of N shares with a resting bracket stop.

Step Watcher Broker
1 Reads the position, and a price past its loss threshold The bracket stop is resting
2 Sends cancels for the stop and the take-profit The price falls through the stop, which triggers and starts to fill
3 Waits 0.5 s The fill completes before the cancel lands, and the account is flat
4 Sends a market sell for N shares The sell fills, and the account is now short N shares

Selling shares you don’t own is a short sale15, so in a margin account the second sell opens a short position of the same size. Partial fills cause a smaller version of the same problem, because the watcher sells the quantity it read before cancelling.

Two conditions widen the window.

  • The price can be minutes old. The watcher takes its price from the account’s portfolio updates. IBKR sends those when a position changes and otherwise on a fixed three-minute cycle that can’t be adjusted16. A loop that runs every 60 seconds can still be acting on a price three minutes old.
  • Orders placed by hand are out of reach. An API client can cancel only the orders it placed, apart from a global cancel of everything, and only client ID 0 can take over orders entered by hand in TWS1718. Our client connects with a different ID, so by default it can’t even see those orders19. Suppose someone opened a position in TWS and set a stop there. The watcher sells the position and leaves that stop working, and when it triggers, it sells shares the account no longer holds.

Confirming the cancel before selling

The fix replaces the sleep with confirmation and allows one exit per stock at a time.

  1. Connect the watcher as client ID 0, set as the master client, so it can see orders from TWS and other API connections and take over the ones entered by hand19. If a working order on the stock still can’t be cancelled, alert instead of selling.
  2. Mark the stock as exiting, so the next poll leaves it alone.
  3. Cancel the working orders, then wait, up to a deadline, until the broker reports each one as cancelled, filled or inactive.
  4. If the deadline passes, sell nothing. Alert, clear the mark and let the next poll try again.
  5. Re-read the position from the broker and sell only what is still held. If a bracket leg filled during the cancel, there may be nothing left.
  6. Take the price from a market-data request instead of the portfolio updates.

A sketch of the core steps, written against a generic broker client:

import asyncio
import time

FINAL = {"Cancelled", "ApiCancelled", "Filled", "Inactive"}
exiting: set[str] = set()  # cleared once the position reads flat

async def exit_long(broker, symbol: str, deadline_s: float = 10.0) -> str:
    if symbol in exiting:
        return "exit already in progress"
    if await broker.orders_we_cannot_cancel(symbol):
        return "alert: orders placed elsewhere, nothing sold"
    exiting.add(symbol)
    orders = await broker.open_orders(symbol)
    for order in orders:
        await broker.cancel(order)
    give_up = time.monotonic() + deadline_s
    while not all([await broker.status(o) in FINAL for o in orders]):
        if time.monotonic() > give_up:
            exiting.discard(symbol)
            return "alert: cancel not confirmed, nothing sold"
        await asyncio.sleep(0.25)
    held = await broker.position(symbol)  # re-read: a leg may have filled meanwhile
    if held > 0:
        await broker.sell_market(symbol, held)
    return f"sold {held}"

Another option we’re weighing is to let the broker enforce a single exit. IBKR’s one-cancels-all groups have a “with block” mode that routes only one order in the group at a time, to rule out overfills20. If the watcher’s exit joined the same group as the bracket legs, the broker would make sure only one of them fills. We haven’t tested how that interacts with brackets.

We haven’t measured how long cancels take on our paper account, and the fix doesn’t need that number.

Sizing with half-Kelly, a cut-off and a cap

The platform’s ranking scan suggests a size for each candidate as a share of capital, starting from the Kelly criterion21. For a bet with two outcomes, the Kelly stake is the edge divided by the odds22. In trade terms, with p the chance of a win and b the average win divided by the average loss, that is f = p − (1 − p) / b. The code halves it and then scales it down.

def size_pct(p: float, b: float, conviction: str, regime: float, confidence: float) -> float:
    kelly = max(0.0, p - (1 - p) / b)
    pct = 100 * kelly / 2                                      # half-Kelly, % of capital
    pct *= {"HIGH": 1.0, "MEDIUM": 0.8, "LOW": 0.5}[conviction]
    pct *= regime                                              # 0 to 1, from market conditions
    pct *= 0.5 + 0.5 * confidence                              # confidence from 0 to 1
    return 0.0 if pct < 0.25 else min(pct, 5.0)                # drop under 0.25%, cap at 5%

We halve it because the inputs are estimates. MacLean, Thorp and Ziemba describe full Kelly as potentially very risky in the short term, and in their continuous-time approximation, staking twice the Kelly amount cuts growth to the risk-free rate22. Our p and b start as hard-coded assumptions and blend toward measured results over a setup’s first 20 recorded trades, so they may well be optimistic.

Two questions are worth asking of any Kelly-based sizing. The first is what the fraction applies to. In Kelly’s bet, the stake is what you lose when you lose, and with a stop that is the position’s value times the distance to the stop. Applying the fraction to the whole position’s value instead is far more cautious than the name suggests. The second is whether a cap is doing the deciding. When we computed sizes from our own default assumptions, with medium conviction, a normal-market regime and a confidence of 0.5, the 5% cap set the size for most setup types. At that point the cap, and not Kelly, is the sizing rule.

Each setup type also has to keep earning its place. After 20 recorded trades, a setup whose results fall below a set floor is switched off. A switched-off setup takes no new trades, so its record can’t improve on its own. Scoring its signals on paper while it is off gives it a way to earn its way back.

A checklist for code that can place orders

  • Default every order path to paper or a dry run, and put live trading behind an explicit server setting.
  • Decide paper or live from the account the session reports, checked against an allow-list.
  • Validate quantity, the order of the price levels and the order’s value on the server, before any broker call.
  • Add limits over time and duplicate detection, the parts of Rule 15c3-5 that a per-order cap misses.
  • Place exits at the broker with the entry, using the broker’s own linking, such as transmit flags or OCO triggers.
  • Report a failed exit separately from the entry, show it on screen, and cancel exits whose entry never filled.
  • Treat a cancel as pending until the broker confirms it, and re-read the position before acting.
  • Allow one automated exit per instrument at a time, and keep automation away from orders it can’t cancel.
  • Check what your sizing formula actually decides. If a cap binds most of the time, the cap is your sizing rule.

  1. Interactive Brokers, TWS API initial setup (legacy API docs), https://interactivebrokers.github.io/tws-api/initial_setup.html ↩↩

  2. Interactive Brokers, TWS configuration for API use, https://www.interactivebrokers.com/docs/tws-api/doc/tws-settings/tws-configuration-for-api-use/introduction ↩

  3. Interactive Brokers, Establishing an API connection, https://www.interactivebrokers.com/docs/tws-api/doc/connectivity/establishing-an-api-connection ↩

  4. Zerodha, Kite Connect v3 documentation, User, https://kite.trade/docs/connect/v3/user/ ↩

  5. Zerodha, Kite Connect v3 documentation, Exceptions and errors, https://kite.trade/docs/connect/v3/exceptions/ ↩

  6. 17 CFR 240.15c3-5, Risk management controls for brokers or dealers with market access, https://www.law.cornell.edu/cfr/text/17/240.15c3-5 ↩

  7. SEC, In the Matter of Knight Capital Americas LLC, Release No. 34-70694, 16 October 2013, https://www.sec.gov/files/litigation/admin/2013/34-70694.pdf ↩

  8. SEC press release 2013-222, https://www.sec.gov/newsroom/press-releases/2013-222 ↩

  9. Interactive Brokers, Bracket Orders, https://www.interactivebrokers.com/docs/general/order-types/complex-orders/bracket-orders ↩

  10. Zerodha, Kite Connect v3 documentation, GTT, https://kite.trade/docs/connect/v3/gtt/ ↩↩

  11. Zerodha Support, What is the Good Till Triggered (GTT) feature?, https://support.zerodha.com/category/trading-and-markets/gtt/articles/what-is-the-good-till-triggered-gtt-feature ↩

  12. Zerodha Support, Why did my GTT order trigger but was not executed?, https://support.zerodha.com/category/trading-and-markets/gtt/articles/why-did-my-gtt-order-trigger-but-was-not-executed ↩

  13. Interactive Brokers, Understanding Order Status Message, https://www.interactivebrokers.com/docs/tws-api/doc/order-management/order-status/understanding-order-status-message ↩

  14. Interactive Brokers, Order submission (legacy API docs), https://interactivebrokers.github.io/tws-api/order_submission.html ↩

  15. SEC, Key Points About Regulation SHO, https://www.sec.gov/investor/pubs/regsho.htm ↩

  16. Interactive Brokers, Receiving Account Updates, https://www.interactivebrokers.com/docs/tws-api/doc/account-portfolio-data/account-updates/receiving-account-updates, and Account Updates (legacy API docs), https://interactivebrokers.github.io/tws-api/account_updates.html ↩

  17. Interactive Brokers, Cancel Individual Order, https://www.interactivebrokers.com/docs/tws-api/doc/orders/cancelling-an-order/cancel-individual-order ↩

  18. Interactive Brokers, API client’s orders, https://www.interactivebrokers.com/docs/tws-api/doc/order-management/requesting-currently-active-orders/api-clients-orders ↩

  19. Interactive Brokers, ClientId 0 and the Master Client ID, https://www.interactivebrokers.com/docs/tws-api/doc/order-management/client-id-0-and-the-master-client-id ↩↩

  20. Interactive Brokers, OCA Types, https://www.interactivebrokers.com/docs/general/order-types/complex-orders/oca-types ↩

  21. J. L. Kelly Jr., “A New Interpretation of Information Rate”, Bell System Technical Journal 35(4), July 1956, https://archive.org/details/bstj35-4-917 ↩

  22. MacLean, Thorp and Ziemba, “Good and bad properties of the Kelly criterion”, 2010, https://www.stat.berkeley.edu/~aldous/157/Papers/Good_Bad_Kelly.pdf ↩↩

Frequently asked questions

How do I stop a trading bot from placing live orders by accident?

Default every order path to paper trading or a dry run, and require an explicit server-side setting for live orders. Then confirm the account type from what the broker session reports.

What is a bracket order in the IBKR API?

An entry order with a take-profit and a stop-loss attached as child orders. IBKR’s docs recommend sending the first two with the transmit flag off, so all three reach the broker together.

What is a two-leg GTT on Zerodha Kite?

A good-till-triggered order with a stop trigger and a target trigger, where one cancels the other. When a trigger fires, a limit order is placed, and it can still fail to fill if the price has moved on.

Why is cancel-then-sell risky in an automated exit?

Until the broker confirms a cancel, the resting order can still fill. A market sell sent in that window can sell the position twice and, in a margin account, open a short.

What is half-Kelly position sizing?

Staking half of what the Kelly criterion suggests. It gives up some long-run growth for more safety, which helps when the win probability and payoff are estimates.

What happened in the Knight Capital incident?

On 1 August 2012, a partial deployment left old code active in Knight’s order router, which sent millions of orders in about 45 minutes. Knight lost more than $460 million, and the SEC found its market access controls inadequate.

Work with us

Building something like this?

9io is a small team of senior engineers with a fractional CTO, and we work by the hour. Send us a note about your product. The reply comes from the person who'd do the work.