Orders¶
The order routes read today's orders and trades across every broker, place one order at a time, and change or cancel one order or a whole list of them. You name an instrument when you place an order, and the API decides which broker receives it. When you change or cancel an order, the API finds the broker that holds it by looking the order id up in every broker's order book.
These routes place, change and cancel real orders
POST /api/orders/place, PUT /api/orders/modify and DELETE /api/orders/cancel send real requests to real broker accounts, and nothing is retried or undone for you. Every one of them accepts "dry_run": true, which builds the exact broker request and returns it without sending it. Send a dry run first whenever you are unsure. A list sent to modify or cancel sends a request for every order in it that passes its checks, and its dry_run covers the whole list.
The table below lists the five routes on this page. The emergency route that cancels and closes everything has a page of its own, Flatten everything.
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/orders/details |
Today's orders at every broker, from a document kept in Redis |
| GET | /api/orders/trades |
Today's trades at every broker, from a document kept in Redis |
| POST | /api/orders/place |
Places one order at a broker the API chooses, or each order of a list |
| GET | /api/orders/intents/<intent_id> |
The order engine's answer for one order, after the place request stopped waiting |
| GET | /api/orders/parents |
One of the order engine's parents, or every one still open |
| DELETE | /api/orders/parents |
Cancels one of the engine's parents with its resting legs, or each of a list |
| PUT | /api/orders/modify |
Changes one open order at the broker that holds it, or each order of a list |
| DELETE | /api/orders/cancel |
Cancels one open order at the broker that holds it, or each order of a list |
Glossary of constants¶
The order routes accept a small shared vocabulary. The words are upper-cased before they are checked, so buy and BUY are both accepted. The table below lists every enumerated value the routes take.
| Parameter | Values | Meaning |
|---|---|---|
transaction_type |
BUY, SELL |
The side of the order |
product |
CNC |
Delivery: shares are bought into, or sold out of, the demat account |
MIS |
Intraday: the position is closed the same day | |
NRML |
Normal: a derivatives position carried overnight | |
order_type |
MARKET |
Trade at the best available price; takes no price and no trigger_price |
LIMIT |
Trade at price or better; needs price |
|
SL |
A stop-loss limit order; needs both price and trigger_price |
|
SL-M |
A stop-loss market order; needs trigger_price and takes no price |
|
validity |
DAY |
The order lives until the end of the day (the default) |
IOC |
Immediate or cancel: whatever does not fill at once is cancelled | |
exchange |
nse, bse, mcx, ncdex |
The exchange, when you name an instrument by its fields rather than by instrument_id |
option_type |
CE, PE |
Call or put, for an option named by its fields |
outcome (answer) |
accepted |
The broker took the request; answered with 200 |
rejected |
The broker refused it, or the connection could not even be opened; answered with 422 | |
unknown |
The broker may or may not have acted; answered with 504 | |
order status (book) |
PENDING, OPEN, COMPLETE, CANCELLED, REJECTED, EXPIRED |
The normalized status every broker's order is converted to |
An order whose status is COMPLETE, CANCELLED, REJECTED or EXPIRED is finished. The modify and cancel routes refuse a finished order with 409 and never call the broker.
The segment names below are the ones POST /api/orders/place accepts in segment. Each one has a shape, which decides the identity fields you must give with it.
| Shape | Segments | Identity fields |
|---|---|---|
| security | equities, fixed_income, currencies, commodities, mutual_funds, exchange_traded_funds, investment_trusts |
symbol |
| future | equity_futures, equity_index_futures, fixed_income_futures, fixed_income_index_futures, currency_futures, currency_index_futures, commodity_futures, commodity_index_futures |
underlying_symbol, expiry_date |
| option | equity_options, equity_index_options, fixed_income_options, fixed_income_index_options, currency_options, currency_index_options, commodity_options, commodity_index_options |
underlying_symbol, expiry_date, strike_price, option_type |
Index segments and the uncategorised segment are deliberately missing, because an index cannot be traded and uncategorised does not say whether an instrument is a cash instrument or a derivative.
Order book¶
GET /api/orders/detailsaccess-token
This route answers with today's orders at every broker. It never asks a broker. It reads the document that bin/unified/orders/api_order_details rewrites in the Redis key unified:orders:orders every half second, so the answer is at most about half a second behind the brokers' own books.
Request parameters¶
With only the token header, the route answers with the whole day. The query parameters below narrow it; several together must all match. A filtered answer keeps the day's summary and brokers and adds page.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
access-token |
header | string | yes | The token from POST /api/session/connect |
order_id |
query | string | no | Keep only these orders: give it more than once, or as a comma-separated list |
parent_id |
query | string | no | Keep only the orders of this order engine parent |
intent_id |
query | string | no | Keep only the orders placed for this intent, as a place answer names it |
broker |
query | string | no | Keep only this broker's |
status |
query | string | no | open for everything not yet COMPLETE, CANCELLED, REJECTED or EXPIRED, or one status such as COMPLETE |
limit |
query | number | no | The most entries to return, from 1 to 10000 |
cursor |
query | number | no | How many matching entries to skip; pass the previous page's page.next_cursor |
Response¶
The example below shows one order and is shortened to two brokers in brokers. The keys are exactly the ones the combining script writes; the values are made up.
{
"orders": [
{
"broker": "zerodha",
"order_id": "250915000000011",
"exchange_order_id": "1100000012345678",
"parent_order_id": null,
"status": "OPEN",
"status_message": null,
"id": "NSE:RELIANCE",
"instrument_token": "738561",
"tradingsymbol": "RELIANCE",
"exchange": "NSE",
"transaction_type": "BUY",
"product": "MIS",
"order_type": "LIMIT",
"validity": "DAY",
"quantity": 10,
"filled_quantity": 0,
"pending_quantity": 10,
"cancelled_quantity": 0,
"disclosed_quantity": 0,
"price": 2500.0,
"trigger_price": 0.0,
"average_price": 0.0,
"order_timestamp": "2026-09-15T09:20:04+05:30",
"exchange_timestamp": "2026-09-15T09:20:04+05:30",
"tag": null,
"instrument_id": "11111111-1111-5111-8111-000000000001",
"engine_parent_id": null,
"synthetic_type": null,
"intent_id": null,
"leg_role": null
}
],
"summary": {
"count": 1,
"by_status": {"OPEN": 1},
"filled_value": 0.0
},
"brokers": [
{"broker": "dhan", "status": "missing", "as_of": null},
{"broker": "zerodha", "status": "ok", "as_of": "2026-09-15 09:20:05.120000"}
],
"as_of": "2026-09-15T09:20:05"
}
Response attributes¶
The document has four top-level keys. Each order in orders follows the order contract described in Data contracts.
| Attribute | Type | Description |
|---|---|---|
orders |
array | Every order at every broker, sorted by broker, then order time, then order id |
orders[].broker |
string | The broker holding the order |
orders[].order_id |
string | The broker's own order id, which is what modify and cancel take |
orders[].status |
string | One of the normalized statuses in the glossary |
orders[].exchange |
string | The broker's own exchange code, such as NSE, NSE_EQ or nse_cm |
orders[].quantity, filled_quantity, pending_quantity, cancelled_quantity, disclosed_quantity |
number | Quantities, as the broker reports them |
orders[].price, trigger_price, average_price |
number | Prices, as the broker reports them |
orders[].order_timestamp, exchange_timestamp |
string | ISO timestamps with the IST offset |
orders[].instrument_id |
string or null | The unified instrument id, or null when the order could not be resolved to one |
orders[].engine_parent_id |
string or null | The order engine parent that placed this order, or null for an order placed elsewhere |
orders[].leg_role |
string or null | The order's role in that parent: its part's path in the plan, such as root or root.each_fill.children.0, or entry for a simple order; parents placed before 2026-10-03 also show the roles of the retired classes, such as stop or slice |
orders[].synthetic_type |
string or null | The parent's order type: plan for every type but simple, since every other type runs as a plan; the type asked for is the parent's routed_from |
orders[].intent_id |
string or null | The intent the parent was placed for, as the place answer named it |
summary.count |
number | How many orders there are in the day, whatever the filters |
summary.by_status |
object | How many orders there are in each status |
summary.filled_value |
number | The sum of filled quantity times average price, rounded to two places |
brokers[] |
array | One entry per broker, with broker, status and as_of |
brokers[].status |
string | ok (seen within a minute), stale (older than a minute, still included), missing (no data today) or unreadable |
as_of |
string | When the document was written, in local time |
page.matched |
number | With filters: how many entries matched |
page.returned |
number | With filters: how many entries this answer carries |
page.cursor, page.next_cursor |
number or null | With filters: where this page started, and where the next one starts, which is null on the last page |
Status codes¶
The status codes below come from the token check, from the filter reader in unified_broker_interface/utilities/order_book_filter.py, and from the shared document reader in unified_broker_interface/utilities/unified_documents.py.
| Status | When |
|---|---|
| 200 | The document is fresh and at least one broker's data was read. |
| 400 | A paging parameter cannot be read: limit must be a whole number, not '<text>', limit must be from 1 to 10000, not <n>, cursor must be a whole number, not '<text>' or cursor must be at least 0, not <n>. |
| 401 | Access token is required, Invalid access token or Access token has expired. |
| 502 | Unable to retrieve orders information: the document exists, but no broker is ok or stale. The body still lists brokers. |
| 503 | Orders are not available: nothing is keeping unified:orders:orders: the key is missing or unreadable, so the combining script is not running. |
| 503 | Orders are out of date: unified:orders:orders was last written at <as_of>: the document is more than 30 seconds old. The body carries as_of and brokers. |
Under the hood
- Redis keys read:
unified:orders:orders, written bybin/unified/orders/api_order_detailsevery 0.5 seconds. - Where that script reads from: each broker's
<broker>:orders:ordershash, which the broker'sapi_order_detailspoller andwebsocket_order_detailssocket both write. - Freshness limit: 30 seconds, passed to
read_documentinunified_broker_interface/blueprints/orders.py. - Class:
OrdersBlueprint.
Trade book¶
GET /api/orders/tradesaccess-token
This route answers with today's fills at every broker. Like the order book, it never asks a broker. It reads the document that bin/unified/orders/api_trade_details rewrites in unified:orders:trades every half second.
Request parameters¶
The trade book takes the same filters as the order book, each applied to the trade's own order.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
access-token |
header | string | yes | The token from POST /api/session/connect |
order_id |
query | string | no | Keep only the fills of these orders: give it more than once, or as a comma-separated list |
parent_id |
query | string | no | Keep only the fills of this order engine parent |
intent_id |
query | string | no | Keep only the fills of orders placed for this intent, as a place answer names it |
broker |
query | string | no | Keep only this broker's |
limit |
query | number | no | The most entries to return, from 1 to 10000 |
cursor |
query | number | no | How many matching entries to skip; pass the previous page's page.next_cursor |
Response¶
The example below shows one trade, with brokers shortened to one entry. The keys are the ones the combining script writes; the values are made up.
{
"trades": [
{
"broker": "zerodha",
"trade_id": "10000001",
"order_id": "250915000000011",
"exchange_order_id": "1100000012345678",
"exchange_trade_id": "10000001",
"instrument_id": "11111111-1111-5111-8111-000000000001",
"instrument_token": "738561",
"tradingsymbol": "RELIANCE",
"exchange": "NSE",
"transaction_type": "BUY",
"product": "MIS",
"quantity": 10,
"price": 2500.0,
"value": 25000.0,
"trade_timestamp": "2026-09-15 09:20:06",
"exchange_timestamp": "2026-09-15 09:20:06",
"engine_parent_id": null,
"synthetic_type": null,
"intent_id": null,
"leg_role": null
}
],
"summary": {
"count": 1,
"buy_value": 25000.0,
"sell_value": 0.0,
"total_value": 25000.0
},
"brokers": [
{"broker": "zerodha", "status": "ok", "as_of": "2026-09-15 09:20:06.500000"}
],
"as_of": "2026-09-15T09:20:07"
}
Response attributes¶
Each trade is one fill, in the same shape for every broker.
| Attribute | Type | Description |
|---|---|---|
trades |
array | Every fill, sorted by broker, then trade time, then trade id |
trades[].trade_id, order_id |
string | The broker's own ids for the fill and its order |
trades[].engine_parent_id, leg_role, synthetic_type, intent_id |
string or null | The order engine parent the fill's order belongs to, as for an order; null for an order placed elsewhere |
trades[].exchange_order_id, exchange_trade_id |
string or null | The exchange's ids, when the broker reports them |
trades[].transaction_type, product |
string | On the shared vocabulary |
trades[].quantity, price |
number | The fill's size and price |
trades[].value |
number | Quantity times price, rounded to paise |
trades[].trade_timestamp, exchange_timestamp |
string or null | Passed through as each broker writes them, so they only sort within one broker |
summary.count |
number | How many fills there are |
summary.buy_value, sell_value, total_value |
number | The summed value of the buys, of the sells, and of both |
brokers[] |
array | One entry per broker, with broker, status (ok, stale, missing or unreadable) and as_of |
as_of |
string | When the document was written, in local time |
Status codes¶
The trade book shares the order book's rules.
| Status | When |
|---|---|
| 200 | The document is fresh and at least one broker's data was read. |
| 400 | A paging parameter cannot be read, with the same messages as the order book. |
| 401 | Access token is required, Invalid access token or Access token has expired. |
| 502 | Unable to retrieve trades information: no broker is ok or stale. |
| 503 | Trades are not available: nothing is keeping unified:orders:trades. |
| 503 | Trades are out of date: unified:orders:trades was last written at <as_of>, when it is more than 30 seconds old. |
Under the hood
- Redis keys read:
unified:orders:trades, written bybin/unified/orders/api_trade_detailsevery 0.5 seconds from each broker's<broker>:orders:trades. - Freshness limit: 30 seconds.
Place an order¶
POST /api/orders/placeaccess-token
This route places one order at one broker. You name the instrument, the side, the product, the order type and the quantity in units, and the API offers the order to the brokers in the order its broker selector ranks them. The first broker that can take the order gets it. The route reads only Redis before it calls the broker, and it never retries a sent order at another broker.
One request, one real order
Without dry_run, a 200 means a real order now rests at a real broker. A 504 means the order may exist. Check the order book before you send the same order again.
Limit orders are held until they can fill¶
A plain LIMIT order is not sent to a broker straight away. The order engine holds it in its own virtual order book, as a virtual_limit order, and sends it only once the other side of the book reaches its price: for a buy, when the best offer is at or below it. A limit that never fills therefore costs no order messages at all, where a resting one costs a place and a cancel.
A held order answers 202 with outcome: armed and a parent_id, and no broker order id, because none exists yet. You can change its price or quantity while it is held, through PUT /api/orders/modify with parent_id, and cancel it through DELETE /api/orders/parents.
The table below shows which orders are held.
| Order | Held? |
|---|---|
LIMIT with a price, DAY validity, no synthetic object |
Yes |
LIMIT with after_market true, which the broker queues for the next session |
No, sent at once, because no live price arrives to release it |
LIMIT with "synthetic": {"type": "simple"} |
No, sent at once |
LIMIT with IOC validity, which means trade now or never |
No, sent at once |
MARKET, which is sent at once as a marketable limit, as the next section describes |
No |
SL, SL-M, or a LIMIT priced only by price_reference |
No, sent at once |
A plan, or a ladder, scheduled, good_till_time, time_stop, account_conditional, limit_if_touched, indicator_triggered, cross_instrument, gtt, bracket, cover, scale_out, oto, oca, scale_with_profit_taker, freeze_slicer, twap or implementation_shortfall order run as a plan, unless it gives hold_limits: false |
Its limit orders, entry, every OCA candidate, each ladder rung or each timed slice is held until the market reaches it, while exits and profit-takers rest at the broker; Holding orders until the market reaches them lists the exceptions |
Any order naming another synthetic type |
Run as that type |
The setting UNIFIED_BROKER_INTERFACE_API_ORDER_HOLD_LIMITS, on by default, turns this off for every order, and for the rungs of a ladder run as a plan.
Market orders are sent as marketable limits¶
A plain MARKET order is not sent to a broker as a market order. The order engine runs it as a marketable_limit order instead: a LIMIT priced two ticks past the other side's best price, which for a buy is the best offer, so it trades at once against what rests there but can never fill far from the price you saw. The engine moves it after that price on every tick until it fills, and cancels whatever has not filled 30 seconds after it was placed.
The order is refused with 409, and nothing is sent to any broker, when it cannot be priced as it arrives. This happens when nobody is offering for a buy, nobody is bidding for a sell, no live quote for the instrument has arrived, or the quote is marked stale. The table below lists the refusal messages.
| Book when the order arrives | status_message |
|---|---|
| A buy, and nobody is offering | nobody is offering this instrument, so the BUY could not be priced and nothing was sent |
| A sell, and nobody is bidding | nobody is bidding for this instrument, so the SELL could not be priced and nothing was sent |
| No live quote yet | no live quote has arrived for this instrument, so the <side> could not be priced and nothing was sent |
| The quote is marked stale | the live quote for this instrument is marked stale, so the <side> could not be priced and nothing was sent |
A sent order answers like any other, with 200 and the broker's order id, as soon as the broker accepts the limit. The 30 seconds run after that answer, so you see the end of the order in the order book or through GET /api/orders/parents: the parent ends completed when anything filled and cancelled when nothing did.
Three kinds of market order are still sent as market orders:
- an order with
after_markettrue, because the broker queues it for the next session, when there is no live book to price a limit from; - an order that names any
synthetictype,simpleincluded, which is howPOST /api/orders/flattenkeeps its closing orders as market orders; - every market order while
UNIFIED_BROKER_INTERFACE_API_ORDER_MARKET_AS_LIMITis off.
To choose a different buffer or time for one order, name the type yourself, as in "synthetic": {"type": "marketable_limit", "buffer_ticks": 0, "fill_within_seconds": 10}.
A limit on what crossing the spread may cost¶
A marketable limit can also be refused for what it would cost. With maximum_cost_bps set, on the order as "synthetic": {"type": "marketable_limit", "maximum_cost_bps": 15} or for every market order through UNIFIED_BROKER_INTERFACE_API_ORDER_MAXIMUM_COST_BPS, the engine works out, just before sending, what filling the whole quantity against the visible book would cost in basis points of the mid-price, using the pre-trade estimate. The setting is unset by default, so no limit applies until you choose one. The table below lists the two refusals, both answered with 409 before anything is sent.
| Why | status_message |
|---|---|
| The estimate is above the maximum | crossing the spread now would cost an estimated <cost> basis points, more than the maximum of <maximum>, so nothing was sent |
| The five visible levels do not hold the whole quantity | the visible book holds only <visible> of the <quantity> units this <side> needs, so what crossing the spread would cost cannot be estimated and nothing was sent; a type that sends it in pieces, such as twap, participation or iceberg, can work it instead |
The estimate walks the whole quantity through the book, while the order itself is a limit only buffer_ticks past the best price, which would rest whatever the first levels cannot fill. So the guard is cautious: it refuses some orders that would only have rested part of their quantity.
Reaching as deep as the order's size¶
Two ticks past the best price is enough for a small order, but an order bigger than what the first levels hold fills only part at once and then chases the price. With reaches_size: true, on the order or for every market order through UNIFIED_BROKER_INTERFACE_API_ORDER_MARKET_REACHES_SIZE, the limit goes no nearer than the deepest visible level the order's unfilled quantity reaches, so it takes those levels at once. It never goes past the fifth visible level, and on each later tick it is set again for whatever is still unfilled. The table below shows a book that offers 100 at each price from 1000.05 to 1000.25.
| Buy | Limit without reaches_size |
Limit with it |
|---|---|---|
| 10 | 1000.15 | 1000.15 |
| 350 | 1000.15 | 1000.20 |
| 600 | 1000.15 | 1000.25, the last visible level |
The setting is off by default, because it lets a market order trade further from the best price than two ticks; a maximum_cost_bps can be set beside it to bound that.
Each move of the resting limit is a modification, which counts against the broker's daily order cap like a placement. The repricing throttle allows each order one move a second by default (UNIFIED_BROKER_INTERFACE_API_ORDER_REPRICE_MINIMUM_SECONDS), so one order can send at most about 30 modifications before it is cancelled.
A dry run still shows the body as a market order in request, because a dry run builds the request from the body as you wrote it, and adds the plan the order would run as in plan.
Request parameters¶
The body is a JSON object. You name the instrument in one of two ways: by instrument_id, or by exchange, segment and that segment's identity fields. When instrument_id is given, the identity fields are ignored.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
access-token |
header | string | yes | The day's token. |
instrument_id |
body | string (UUID) | one way | The unified instrument id. It must parse as a UUID, and any spelling is normalized to lower case. |
exchange |
body | string | other way | nse, bse, mcx or ncdex, needed when instrument_id is absent. |
segment |
body | string | other way | A segment from the table above. It may carry the exchange prefix, as in nse_equities. |
symbol |
body | string | security | The trading symbol of a security, such as RELIANCE. |
underlying_symbol |
body | string | future, option | The underlying, such as NIFTY. |
expiry_date |
body | string | future, option | The expiry as YYYY-MM-DD. |
strike_price |
body | number or string | option | The strike, as a finite number. |
option_type |
body | string | option | CE or PE. |
transaction_type |
body | string | yes | BUY or SELL. |
product |
body | string | yes | CNC, MIS or NRML. |
order_type |
body | string | yes | MARKET, LIMIT, SL or SL-M. |
quantity |
body | integer | yes | The quantity in units, a whole number of at least 1. A numeric string such as "10" is accepted. It may be left out only when quantity_reference is given. |
validity |
body | string | no | DAY (default) or IOC. |
price |
body | number | depends | The limit price, a finite number of at least 0. LIMIT and SL need it; MARKET and SL-M refuse it. |
trigger_price |
body | number | depends | The trigger, a finite number of at least 0. SL and SL-M need it; MARKET and LIMIT refuse it. |
disclosed_quantity |
body | integer | no | A whole number of at least 0, and never more than quantity. Defaults to 0. |
after_market |
body | boolean | no | Places an after-market order. Defaults to false. |
tag |
body | string | no | Your own label, 1 to 20 letters and digits, with surrounding spaces removed. |
dry_run |
body | boolean | no | Builds and returns the broker request without sending it. Defaults to false. |
price_reference |
body | object | no | Order engine only. Works the price out from the live quote. See Price and quantity references. |
quantity_reference |
body | object | no | Order engine only. Works the quantity out from the position held. |
synthetic |
body | object | no | Order engine only. Runs the order as one of the synthetic order types. |
The true-or-false fields after_market and dry_run accept a JSON boolean, or the text true, 1, yes, false, 0, no or an empty string, in any case. Anything else is refused with 400.
The order engine then runs these checks against the instrument and the chosen broker. Each failure is answered without calling a broker.
- The instrument must be mapped today, or the answer is 404
the instrument is not mapped. When the whole catalogue is missing rather than the one instrument, which happens every night between midnight, when the day's catalogue keys expire, and the morning mapping that publishes the next one, the answer is 503today's instrument catalogue is not published yet: the catalogue for <date> has expired, and the daily mapping has not published a new oneinstead. That check runs only after an order has already been refused as not mapped, so an order that is placed pays nothing for it. - The instrument's segment must be tradeable, or the answer is 400
orders are not sent for <segment> instruments. - For a currency or commodity instrument, today's contract size decision must be trusted, and both quantities must be whole lots of it (see contract sizes).
- A broker must be able to take the order (see How the broker is chosen).
- For a securities instrument,
quantitymust be a whole number of the chosen broker's lot size. priceandtrigger_pricemust be whole numbers of the tick size most brokers agree on.
curl -X POST http://127.0.0.1:8080/api/orders/place \
-H "access-token: $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"exchange": "bse",
"segment": "equities",
"symbol": "KWIL",
"transaction_type": "SELL",
"product": "CNC",
"order_type": "LIMIT",
"quantity": 5,
"price": "101.25",
"validity": "ioc",
"tag": "abc123",
"disclosed_quantity": 2,
"dry_run": true
}'
import os
import requests
order = {
'exchange': 'bse',
'segment': 'equities',
'symbol': 'KWIL',
'transaction_type': 'SELL',
'product': 'CNC',
'order_type': 'LIMIT',
'quantity': 5,
'price': '101.25',
'validity': 'ioc',
'tag': 'abc123',
'disclosed_quantity': 2,
'dry_run': True,
}
response = requests.post(
'http://127.0.0.1:8080/api/orders/place',
headers={'access-token': os.environ['ACCESS_TOKEN']},
json=order,
timeout=15,
)
print(response.status_code, response.json())
Response¶
The bodies below are real answers recorded by the offline suite python -m test_runs.order_routes, which runs the route against an in-memory Redis and stubbed brokers, so no real order was involved. The suite records only the names of the timing_ms keys, so the numbers shown here are illustrative.
{
"broker": "zerodha",
"instrument_id": "11111111-1111-5111-8111-000000000002",
"intent_id": "00000000000040008000000000000001",
"parent_id": "00000000-0000-4000-8000-000000000002",
"tag": "abc123",
"outcome": "accepted",
"order_id": "250915000000011",
"status_message": null,
"broker_response": {
"data": {"order_id": "250915000000011"},
"status": "success"
},
"skipped": [],
"timing_ms": {"preparation": 1.412, "broker": 86.207}
}
{
"broker": "zerodha",
"instrument_id": "11111111-1111-5111-8111-000000000002",
"intent_id": "00000000000040008000000000000001",
"tag": "abc123",
"dry_run": true,
"request": {
"method": "POST",
"url": "https://api.kite.trade/orders/regular",
"form": {
"disclosed_quantity": 2,
"exchange": "BSE",
"order_type": "LIMIT",
"price": "101.25",
"product": "CNC",
"quantity": 5,
"tag": "abc123",
"tradingsymbol": "KWIL-zerodha",
"transaction_type": "SELL",
"trigger_price": "0",
"validity": "IOC"
}
},
"skipped": [],
"timing_ms": {"preparation": 1.207}
}
{
"broker": "zerodha",
"instrument_id": "11111111-1111-5111-8111-000000000001",
"intent_id": "00000000000040008000000000000001",
"parent_id": "00000000-0000-4000-8000-000000000002",
"tag": null,
"outcome": "rejected",
"order_id": null,
"status_message": "bad request",
"broker_response": {"message": "bad request"},
"skipped": [],
"timing_ms": {"preparation": 1.1, "broker": 40.3}
}
{
"error": "no broker can take this order",
"instrument_id": "11111111-1111-5111-8111-000000000001",
"intent_id": "00000000000040008000000000000001",
"skipped": [
{"broker": "dhan", "reason": "has no login in Redis"},
{"broker": "flattrade", "reason": "has no login in Redis"},
{"broker": "fyers", "reason": "has no login in Redis"}
]
}
The last example is shortened: the recording lists all ten brokers in skipped, each with the same reason.
Response attributes¶
A sent order and a dry run share most keys. Every answer also carries intent_id, and an order the engine recorded carries parent_id; see Order engine.
| Attribute | Type | Description |
|---|---|---|
broker |
string | The broker the order went to, or would have gone to. |
instrument_id |
string | The resolved instrument id. |
intent_id |
string | The id the order was handed to the order engine under; read its answer later by it. |
parent_id |
string | The order engine parent recorded for the order. Absent on a dry run and on a refusal. |
tag |
string or null | The tag the broker request carried. |
outcome |
string | accepted, rejected or unknown. Absent on a dry run. |
order_id |
string or null | The broker's order id, for an accepted order. Keep it: modify and cancel take it. |
status_message |
string or null | Why the outcome is not accepted, cut to 300 characters. |
broker_response |
object, string or null | The broker's own body, decoded as JSON when possible and otherwise its first 300 characters. |
dry_run |
boolean | true, on a dry run only. |
request |
object | On a dry run only: the method, URL and the form, JSON or query the broker would have received. |
skipped |
array | Each broker passed over before the chosen one, with broker and reason. |
timing_ms.preparation |
number | Milliseconds the API spent before the request left the machine. |
timing_ms.broker |
number | Milliseconds the broker call took. Absent on a dry run. |
Status codes¶
Every failure below is answered without calling a broker, except the three outcomes of a sent order. The messages are the exact strings in the code; <...> marks a value filled in.
| Status | When |
|---|---|
| 200 | The broker accepted the order, or this was a dry run. |
| 400 | The body failed validation. The messages include the request body must be a JSON object, transaction_type must be one of BUY, SELL, product must be one of CNC, MIS, NRML, order_type must be one of MARKET, LIMIT, SL, SL-M, validity must be one of DAY, IOC, quantity is required, quantity must be a whole number of at least 1, disclosed_quantity must be a whole number of at least 0, disclosed_quantity cannot be more than quantity, price must be a number of at least 0, trigger_price must be a number of at least 0, a <type> order needs a price, a <type> order takes no price, a <type> order needs a trigger_price, a <type> order takes no trigger_price, after_market must be true or false, dry_run must be true or false and tag must be 1 to 20 letters and digits. |
| 400 | The instrument was named badly: instrument_id must be a UUID, give instrument_id, or an exchange of nse, bse, mcx or ncdex with a segment and its identity fields, orders are not sent for the segment '<segment>', a security segment needs symbol, a future segment needs underlying_symbol and expiry_date (or an option segment ...), expiry_date must be a date in YYYY-MM-DD format, an option segment needs a numeric strike_price, option_type must be CE or PE, or the identity fields match more than one instrument, so give instrument_id. |
| 400 | A reference was malformed: price_reference must be a JSON object, price_reference kind must be one of ..., an absolute price_reference needs a price above zero, level must be a whole number from 1 to 5, which is as deep as the unified quote carries, and the same shapes for quantity_reference. |
| 400 | The order does not fit the instrument: orders are not sent for <segment> instruments, quantity must be a whole number of lots of <lot>, disclosed_quantity must be a whole number of lots of <lot>, price must be a whole number of ticks of <tick> or trigger_price must be a whole number of ticks of <tick>. |
| 401 | Access token is required, Invalid access token or Access token has expired. |
| 404 | the instrument is not mapped: no instrument matches the id or the identity fields today. |
| 422 | outcome is rejected: the broker refused the order, or the connection to it could not be opened, so nothing was sent. |
| 503 | Redis could not be read: <error>, the order engine is not running, so the order was not placed; start unified-orders@order_engine.service, the order could not be written for the order engine: <error>, no instruments have been mapped yet, today's instrument catalogue is not published yet: the catalogue for <date> has expired, and the daily mapping has not published a new one, every broker is excluded from order placement, no broker can take this order (with skipped), or the contract size of this <segment> instrument is not trusted today (<status>), so no order is sent (with contract_size_status). |
| 504 | outcome is unknown: the broker answered with a server error, answered success without an order id, or the network failed after the request left. The order may exist. |
The engine adds 202, 403, 409, 429 and more 503 and 504 cases; they are listed on the Order engine page.
What happens, step by step¶
The sequence below shows a placement by instrument_id with the default lowest-cost selector.
sequenceDiagram
autonumber
participant C as Your program
participant A as API worker
participant R as Redis
participant E as Order engine
participant B as Chosen broker
C->>A: POST /api/orders/place
A->>A: header present?
A->>R: pipeline: token, mapping date, warm id
R-->>A: replies
A->>A: check token, validate body
A->>R: EXISTS engine lock, XADD intent
A->>R: BLPOP the intent's reply list
E->>R: read the intent, choose the broker<br/>(one EVAL of every broker's counts, skip unfit brokers)
E->>E: tradeable? contract size?<br/>lot size and tick size checks
alt dry_run
E->>R: RPUSH 200 with the request it would send
else send
E->>B: one HTTP request
B-->>E: answer
E->>R: RPUSH 200 accepted / 422 rejected / 504 unknown
end
R-->>A: the answer
A-->>C: the answer, plus intent_id
An instrument named by its identity fields costs the API one more round trip, a ZRANGEBYLEX on the segment's catalogue, unless the worker has already looked that name up under the current warm.
Under the hood
- Redis keys the route reads:
last_login(the API's token),unified:catalogue:current_date,unified:catalogue:warm_identifier,:catalogue:<segment>(only for a lookup by fields),unified:orders:engine:lock, and the intent's reply list. - Redis keys the route writes:
unified:orders:intents:stream. - What the engine reads and writes: every broker's login and settings, the instrument's catalogue data, the rate budget's windows, and
unified:orders:daily_count:<broker>, which the lowest-cost selector reads for every broker and the daily count writes for a capped one; see Order engine. - Stores never read: MongoDB and PostgreSQL. The broker cost table comes from PostgreSQL, but only when a process starts and at 06:00 IST, never while an order waits.
- Classes:
PlaceOrderRequestvalidates the body,IntentHandoffwrites the intent and waits,OrderPlacementchooses the broker and builds the request inside the engine, and each broker'sBrokerOrderssubclass builds its own request and reads its own answer. - Answer rules:
BrokerAnswermapsaccepted,rejectedandunknownto 200, 422 and 504.
Read an answer later¶
GET /api/orders/intents/<intent_id>access-token
This route returns what the order engine did with one order, for a caller whose place request stopped waiting before the answer came. Every place answer, single or listed, carries the order's intent_id. The engine keeps each answer for UNIFIED_BROKER_INTERFACE_API_ORDER_ENGINE_RESULT_TTL_SECONDS, 300 by default, after it gives it.
Request parameters¶
| Name | In | Type | Required | Description |
|---|---|---|---|---|
access-token |
header | string | Yes | The token from connect |
intent_id |
path | string | Yes | The intent_id from the place answer |
Response¶
{
"intent_id": "5e0c4f0c8f3a4f7e9a1d2b3c4d5e6f70",
"status": 200,
"response": {"broker": "flattrade", "outcome": "accepted", "order_id": "26091500000021", "parent_id": "…", "intent_id": "5e0c4f0c8f3a4f7e9a1d2b3c4d5e6f70", "…": "…"}
}
| Attribute | Type | Description |
|---|---|---|
intent_id |
string | The id asked for |
status |
integer | The HTTP status the place route would have answered this order with |
response |
object | The body the place route would have answered this order with |
Errors¶
| Status | Meaning |
|---|---|
| 401 | The token is missing, wrong or expired. |
| 404 | no answer is stored for this intent: the order engine has not answered it yet, the id is not one, or its answer has expired |
| 503 | Redis could not be read: <error> |
Under the hood
- Redis key read:
unified:orders:intents:answer:<intent_id>, which the engine writes, only if absent, as it answers each intent. - Classes:
IntentHandoff.stored_answer.
Modify an order¶
PUT /api/orders/modifyaccess-token
This route changes one open order at the broker that holds it. You name the order by the broker's own order id and give at least one field to change. Every field you leave out keeps the stored order's value. The route finds the broker by looking the id up in every broker's order book in Redis, checks the change against the instrument's lot size and tick size, and sends one request to that broker.
A modification changes a live order
A changed price or quantity takes effect at the exchange as soon as the broker accepts it. Send it with dry_run first to see the exact request.
An order the engine placed¶
When the order is a leg of one of the order engine's parents, which the route finds in unified:orders:children, the route still checks the change as above, and then hands it to the worker that owns the parent instead of sending it itself. The order type records the change and carries on from the new price or quantity: a trailing stop ratchets from the trigger you set, a chaser steps on from the price you set, and a linked pair of exits stays sized to the open position. A plan carries on in the same way, through the part that placed the order, and refuses to raise an order that closes a position with 409 an order that closes a position can only be reduced, and <quantity> is more than its <current>. The change cannot race the type's own repricing, because only that worker touches the parent. The answer carries parent_id and synthetic_type, and the change passes the engine's re-pricing throttle, daily cap and rate budget.
Only price, trigger_price and quantity can be changed on such an order. Changing order_type, validity or disclosed_quantity is refused with 409 this order belongs to an order the engine manages, which can only have its price, trigger_price or quantity changed, not <field>, because the type would then be managing an order that is not the one it placed. A dry run is answered by the route as for any other order.
A held order¶
An order the engine is still holding, such as a held limit order, has no broker order id yet, so you name it by the parent_id the place route answered with. Only its price and quantity can change, and nothing is sent to a broker: the engine's worker that owns the order changes the terms it is held by, and it is sent at the new price and quantity when the other side reaches it. The queue estimate starts again, as a changed price at the exchange goes to the back of the queue. dry_run checks the change without making it.
The answer is 200 with parent_id, synthetic_type, held: true, the new price and quantity, and outcome: accepted. A change is refused with 400 when it names any other field or neither, when the price is not a whole number of ticks, or when the quantity is not a whole number of lots at any broker; with 404 when the engine holds no such parent; and with 409 when the order has already been sent (the answer then names its broker and order_id, which the ordinary form takes), has finished, is a type that holds no order of its own, or is a paper order that has already filled the new quantity.
A part of a plan that has not been sent¶
A plan is a tree of parts, and a part sends nothing until its turn comes: a bracket's stop and target wait for the entry to fill, and an order on a trigger waits for the trigger. Such a part has no broker order id yet, so you name it by the plan's parent_id and the part's path in part, as GET /api/orders/parents shows it under parameters.parts, such as root.each_fill.children.0 for a bracket's stop. Its price, trigger_price and quantity can change, and nothing is sent to a broker: the part keeps the new values and sends them when its turn comes. dry_run checks the change without making it, and the list form takes the same items.
{"parent_id": "00000000-0000-4000-8000-000000000002", "part": "root.each_fill.children.0", "trigger_price": 985, "price": 983}
These rules decide what a part can take.
- A
priceortrigger_pricereplaces what the part's pricing gives when it is sent, so only a part with a fixed price can take one: a plain limit or anative_stop. A part priced from the market when it is sent, such asmarketable,pegortrail, is refused with 400. Atrigger_priceneeds a stop. AMARKETorder given a price is sent as aLIMIT, and anSL-Mas anSL. - A
quantitybecomes the part's total. An order that closes a position can only be reduced. A part sized by what an earlier part fills, such as a bracket's exits, has no quantity until those fills arrive, so it is refused with 409; change its broker order once it is sent instead. - A part held on a
limit_marketabletrigger is changed as a held order is. - A part kept whole, such as a
grid, keeps its orders by its own rules and is refused with 409.
The answer below is a real recording from the offline suite.
{
"parent_id": "00000000-0000-4000-8000-000000000002",
"synthetic_type": "plan",
"part": "root.each_fill.children.0",
"state": "pending",
"price": "983",
"trigger_price": "985",
"quantity": 10,
"outcome": "accepted",
"status_message": "changed before it was sent; nothing was sent to a broker",
"intent_id": "00000000000040008000000000000006"
}
| Status | When |
|---|---|
| 200 | The part was changed, or this was a dry run. |
| 400 | part must name a part of the plan, such as root.first, a plan part that has not been sent can only change its price, trigger_price and quantity, not <field>, give price, trigger_price or quantity to change, part <path> works out its price from the market when it is sent, so its price cannot be set beforehand, part <path> is not a stop, so it has no trigger price to change, or a price or quantity off the tick or lot. |
| 404 | the order engine holds no parent with this id, or the plan has no order at part <path>. |
| 409 | part <path> is already <state>, so change its broker orders with broker and order_id instead, with orders listing each broker and order_id; part <path> is sized by what an earlier part fills, so its quantity is not known until then; an order that closes a position can only be reduced, ...; a part kept whole; a plan that has finished; or a <type> order has no parts, ... for any other type. |
| 503 | The order engine is not running. |
Request parameters¶
order_id, broker and dry_run may come from the body or the query string; the body wins when both are given. The fields to change come from the body only, and a field that is absent or empty is not changed.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
access-token |
header | string | yes | The day's token. |
order_id |
body or query | string | yes | The broker's order id, as place and the order book answer it: 1 to 64 letters, digits, hyphens or underscores. A JSON integer is accepted. |
broker |
body or query | string | no | One of the ten broker names. Needed only when two brokers hold an order with the same id. |
dry_run |
body or query | boolean | no | Returns the request without sending it. |
quantity |
body | integer | at least one change | The new total quantity in units, a whole number of at least 1. |
disclosed_quantity |
body | integer | at least one change | The new disclosed quantity in units, at least 0 and not more than the quantity. |
price |
body | number | at least one change | The new limit price, at least 0. |
trigger_price |
body | number | at least one change | The new trigger price, at least 0. |
order_type |
body | string | at least one change | MARKET, LIMIT, SL or SL-M. |
validity |
body | string | at least one change | DAY or IOC. |
Prices are merged with the stored order by these rules, which follow from the order type after the change.
- A price you give is used as it is, but it must fit the new order type: a
LIMITneeds a price and refuses a trigger price, and so on, exactly asplacechecks. - A price you leave out is carried over from the stored order only when the new order type needs it and the old one needed it too. So changing
SLtoLIMITdrops the trigger price, and changingLIMITtoSLwithout atrigger_priceis refused witha SL order needs a trigger_price. - A carried-over price that Redis does not hold as a positive number is refused with 503
Redis does not hold this <type> order's <field>, so give <field>.
Not every broker can change every field. The matrix below comes from each broker's MODIFIABLE_FIELDS in unified_broker_interface/utilities/broker_orders/. Asking to change a field the broker cannot change is refused with 400 <broker> cannot change <field> on an order.
| Broker | quantity |
disclosed_quantity |
price |
trigger_price |
order_type |
validity |
Order types it can change to |
|---|---|---|---|---|---|---|---|
| Dhan | any | ||||||
| Flattrade | LIMIT, SL |
||||||
| Fyers | any | ||||||
| Groww | any | ||||||
| INDmoney | none (no SL or SL-M at all) |
||||||
| Kotak | any | ||||||
| Shoonya | LIMIT, SL |
||||||
| Stoxkart | any | ||||||
| Wisdom Capital | any | ||||||
| Zerodha | any |
Flattrade and Shoonya share the Noren platform, whose modify request can only carry LIMIT or SL, so changing an order to MARKET or SL-M there is refused with <broker> cannot change an order to <type>. INDmoney takes no stop-loss orders, so asking it for SL or SL-M is refused with indmoney takes no <type> orders.
import os
import requests
change = {
'order_id': '250915000000011',
'quantity': 20,
'price': '2501.5',
'dry_run': True,
}
response = requests.put(
'http://127.0.0.1:8080/api/orders/modify',
headers={'access-token': os.environ['ACCESS_TOKEN']},
json=change,
timeout=15,
)
print(response.status_code, response.json())
Response¶
These bodies are real recordings from the offline suite, against stubbed brokers. The timing numbers are illustrative.
{
"broker": "zerodha",
"order_id": "250915000000011",
"instrument_id": "11111111-1111-5111-8111-000000000001",
"status_before_modify": "OPEN",
"outcome": "accepted",
"status_message": null,
"broker_response": {"s": "ok", "stat": "Ok", "status": "success", "type": "success"},
"timing_ms": {"preparation": 0.912, "broker": 71.44}
}
{
"broker": "zerodha",
"order_id": "250915000000011",
"instrument_id": "11111111-1111-5111-8111-000000000001",
"status_before_modify": "OPEN",
"dry_run": true,
"request": {
"method": "PUT",
"url": "https://api.kite.trade/orders/amo/250915000000011",
"form": {"order_type": "LIMIT", "price": "2501.5", "quantity": 20}
},
"timing_ms": {"preparation": 0.85}
}
Response attributes¶
The answer names the broker and says what the order's status was before the change.
| Attribute | Type | Description |
|---|---|---|
broker |
string | The broker holding the order. |
order_id |
string | The order id you gave. |
instrument_id |
string or null | The instrument the stored order resolves to, or null when it could not be found in today's catalogue. |
status_before_modify |
string | The stored order's status when the request arrived. |
outcome |
string | accepted, rejected or unknown. accepted means the broker took the instruction, not that the exchange has acted on it. |
status_message |
string or null | Why the outcome is not accepted. |
broker_response |
object, string or null | The broker's own body. |
dry_run, request |
boolean, object | On a dry run only. |
timing_ms |
object | preparation, and broker when sent. |
Status codes¶
Every refusal after the order has been found carries broker and order_id beside error.
| Status | When |
|---|---|
| 200 | The broker accepted the modification, or this was a dry run. |
| 400 | The parameters are invalid: the request body must be a JSON object, order_id is required, order_id must be 1 to 64 letters, digits, hyphens or underscores, broker must be one of ..., dry_run must be true or false, give at least one of quantity, disclosed_quantity, price, trigger_price, order_type or validity to change, or one of the number and choice messages place uses. |
| 400 | The broker cannot make the change: <broker> cannot change <field> on an order, <broker> takes no <type> orders or <broker> cannot change an order to <type>. |
| 400 | The order after the change does not fit: a <type> order needs a <field>, a <type> order takes no <field>, quantity must be a whole number of lots of <lot>, disclosed_quantity must be a whole number of lots of <lot>, price must be a whole number of ticks of <tick>, trigger_price must be a whole number of ticks of <tick> or disclosed_quantity cannot be more than quantity. |
| 401 | Access token is required, Invalid access token or Access token has expired. |
| 404 | no broker order book in Redis holds this order_id. |
| 409 | the order is already <status>, more than one broker holds an order with this order_id, so give broker (with brokers), or the order has <field> <value>, which the modify route does not handle, for a stored order whose product, order type, validity or side is outside the route's words. |
| 422 | outcome is rejected. |
| 501 | modifying orders is not implemented for <broker>, for a broker with no modifiable fields. Every broker lists at least one today, so this is not expected. |
| 503 | Redis is missing something the request needs: Redis could not be read: <error>, <broker> has no login in Redis, <broker> has no <settings> in its Redis settings, Redis does not hold this order's <field> yet, so try again after the broker's next order book poll, Redis does not hold this order's <field> as a whole number, ..., or a broker-specific login problem such as a missing Kotak session id. |
| 503 | A changed quantity cannot be converted: the order's instrument could not be found in today's catalogue, so a changed quantity cannot be converted into the broker's terms, the contract size of this <segment> instrument is not trusted today (<status>), so its quantity cannot be changed, <broker> does not know how it counts quantity in <market> orders or <broker>'s mapping carries no whole lot size for the instrument. |
| 504 | outcome is unknown; the change may have been applied. |
A price-only change never needs the instrument, so it goes ahead even when the instrument cannot be found or its contract size is not trusted; only the tick check is skipped.
Modify several orders¶
PUT /api/orders/modifyaccess-token
A body with an orders list changes each order in it. Each item carries what the single body carries, order_id, an optional broker and the fields to change, or parent_id with price and quantity for a held order, and also part and trigger_price for a part of a plan, and each order is checked exactly as a single modification is. Held and sent orders can be mixed in one list, and the results come back in the list's own order. Several orders in one request describes the list, its answer and its statuses.
Under the hood
- Redis keys read:
last_login,settings,unified:catalogue:current_date,unified:catalogue:warm_identifier, every broker's<broker>:orders:orders(oneHGETeach),unified:orders:children(oneHMGETper order, to see whether the engine owns it), thenunified:catalogue:<date>:tokens:<broker>and the candidates'identity,order_handlesandcontract_sizes. - A list:
modify_order_listreads every order's entries in the first pipeline, thenwarm_order_instrumentsreads every order's token candidates in one pipeline and their catalogue data in another, each skipped when this worker holds it. Each order is then prepared as a single one is, byprepare_modification, into aPreparedModification. - How the instrument is found: the stored order's broker token is looked up in
tokens:<broker>. A candidate is kept only when it is tradeable, its market is one the broker takes, the broker's handle carries the same token, and its market matches the stored exchange code. Exactly one candidate must remain, because several brokers number tokens per exchange. - Classes:
ModifyOrderRequestvalidates the parameters andOrderModificationlays the change over the stored order. - Rate budget: before sending, the route takes room in the per-broker rate budget the order engine also uses, waiting up to
UNIFIED_BROKER_INTERFACE_API_ORDER_RATE_WAIT_SECONDS. With no room it answers 503the order rate budget is full, so this change was not sent; try again in a moment; in a list, only that item. The order engine's loss lockout does not hold it, and the daily order count does count it.
Cancel an order¶
DELETE /api/orders/cancelaccess-token
This route cancels one open order at the broker that holds it. It reads Redis once, finds the broker by the order id, and sends one cancel request. An order can be cancelled only after the broker's order poller or order websocket has recorded it in Redis. An order the order engine manages can instead be named by its parent_id, which cancels the whole parent or, with part, one part of a plan, as modify names a held order or a plan part.
A cancel cannot be taken back
outcome: accepted means the broker took the cancel request. The order may still fill in the moment before the exchange acts on it, so read the order book to see the final status.
An order the engine placed¶
When the order is a leg of one of the order engine's parents, the cancel is handed to the worker that owns the parent, which records it and lets the order type react to a cancel it knows about. The answer then carries parent_id and synthetic_type. Cancelling one leg does not cancel the parent; cancel the parent to stop it placing anything more. The engine also reads the order from the broker's order book, so an order it has only just placed can be cancelled once the broker's poller or websocket has recorded it, as for any other order.
A plan does not send again what you cancelled, and it reacts as the older order types do. A TWAP's cancelled slice is skipped and the later slices go out on time, an iceberg whose slice you cancel shows no more, a bracket whose entry you cancel drops its exits, and a cancelled bracket stop leaves the target resting. To stop a part sending its later pieces as well, cancel the part.
A whole parent¶
A body with parent_id and no part cancels the whole parent, exactly as DELETE /api/orders/parents does: every leg still resting at a broker is cancelled, and the parent ends as cancelled, so it places, moves and cancels nothing more. This is the only way to cancel a parent that has placed nothing yet, such as an armed trigger or a held limit order. With dry_run the answer lists the legs a cancel would be sent for under resting_legs, and nothing is cancelled.
The answer is the one DELETE /api/orders/parents gives, with its 207 when a leg's cancel was refused.
A part of a plan¶
A plan is a tree of parts, and you name one by the plan's parent_id and the part's path in part, as GET /api/orders/parents shows it under parameters.parts, such as root.each_fill.children.0 for a bracket's stop. Cancelling a part stops it sending anything more, whether or not its turn has come.
- A part whose turn has not come, such as a bracket's stop before the entry fills, is not sent when its turn comes, and is marked done as
cancelledthen. Nothing is sent to a broker. The rest of the plan carries on: when the entry fills, the bracket's target is still sent. - A part waiting on its trigger is marked done as
cancelledat once. - A part that has sent orders sends no further piece, such as a TWAP's later slices or a peg's next move, and each of its orders still resting is cancelled. It is marked done once the brokers confirm. Each order in the answer's
orderssays whether its broker accepted the cancel incancel_accepted.
The plan then reacts as it does to that part finishing: a bracket whose entry you cancel before it fills drops its exits. dry_run lists the part's resting orders and changes nothing. The answer below is a real recording from the offline suite.
{
"parent_id": "00000000-0000-4000-8000-000000000002",
"synthetic_type": "plan",
"part": "root.each_fill.children.0",
"state": "pending",
"orders": [],
"outcome": "accepted",
"status_message": "it will not be sent when its turn comes; nothing was sent to a broker",
"intent_id": "00000000000040008000000000000006"
}
| Status | When |
|---|---|
| 200 | The part is cancelled and every resting order's cancel was accepted, or none was resting, or this was a dry run. |
| 207 | A broker did not accept the cancel of one of the part's orders, which may still be resting. outcome is partial, or rejected when no cancel was accepted. The part still sends nothing more, and asking again retries the cancels. |
| 400 | part must name a part of the plan, such as root.first, or name the order either by parent_id or by order_id, not both; <field> cannot be given with parent_id. |
| 404 | the order engine holds no parent with this id, or the plan has no order at part <path>. |
| 409 | part <path> is already done: <reason>; part <path> is already cancelled, and will not be sent when its turn comes; a part kept whole, such as a scale_out, that has not started, which keeps its orders by its own rules (cancel it once it has started, or cancel the whole parent); a plan that has finished; or a <type> order has no parts, ... for any other type. |
| 503 | The order engine is not running. |
Request parameters¶
order_id, broker and dry_run may come from the JSON body or the query string, and the body wins when both are given. parent_id and part come from the body only.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
access-token |
header | string | yes | The day's token. |
order_id |
body or query | string | one of order_id and parent_id |
The broker's order id: 1 to 64 letters, digits, hyphens or underscores. A JSON integer is accepted; a boolean is not. |
broker |
body or query | string | no | One of the ten broker names, needed only when two brokers hold the same id. Not taken with parent_id. |
parent_id |
body | string | one of order_id and parent_id |
The parent the place route answered with, to cancel the whole parent. |
part |
body | string | no | With parent_id, the path of one part of a plan to cancel. |
dry_run |
body or query | boolean | no | Returns the request without sending it, or, with parent_id, says what would be cancelled. |
Response¶
These bodies are real recordings from the offline suite, against stubbed brokers. The timing numbers are illustrative.
Response attributes¶
The cancel answer mirrors the modify answer, without an instrument.
| Attribute | Type | Description |
|---|---|---|
broker |
string | The broker holding the order. |
order_id |
string | The order id you gave. |
status_before_cancel |
string | The stored order's status when the request arrived. |
outcome |
string | accepted, rejected or unknown. |
status_message |
string or null | Why the outcome is not accepted. |
broker_response |
object, string or null | The broker's own body. |
dry_run, request |
boolean, object | On a dry run only. |
timing_ms |
object | preparation, and broker when sent. |
Status codes¶
The cancel route has fewer failure modes than modify, because it needs no instrument.
| Status | When |
|---|---|
| 200 | The broker accepted the cancel, or this was a dry run. |
| 400 | the request body must be a JSON object, order_id is required, order_id must be 1 to 64 letters, digits, hyphens or underscores, broker must be one of ... or dry_run must be true or false. |
| 401 | Access token is required, Invalid access token or Access token has expired. |
| 404 | no broker order book in Redis holds this order_id. |
| 409 | the order is already <status>, or more than one broker holds an order with this order_id, so give broker. |
| 422 | outcome is rejected. |
| 503 | Redis could not be read: <error>, <broker> has no login in Redis, <broker> has no <settings> in its Redis settings, or a message saying Redis does not yet hold a value the broker's cancel needs. |
| 504 | outcome is unknown; the cancel may have been applied. |
The sequence below shows a cancel from start to finish. Two Redis round trips happen before the broker call: one pipeline that reads everything, and one that takes room in the rate budget. A dry run needs only the first.
sequenceDiagram
autonumber
participant C as Your program
participant A as API worker
participant R as Redis
participant B as Broker holding the order
C->>A: DELETE /api/orders/cancel?order_id=...
A->>A: header present? order_id valid?
A->>R: one pipeline: token, logins, settings,<br/>HGET <broker>:orders:orders for all ten,<br/>HMGET unified:orders:children
R-->>A: replies
A->>A: check token
A->>A: find_stored_order: exactly one broker?
alt none holds it
A-->>C: 404
else two hold it and no broker given
A-->>C: 409 with brokers
else already finished
A-->>C: 409 the order is already COMPLETE
else
A->>A: login and settings present? build cancel
A->>R: take room in the rate budget
A->>B: one HTTP request
B-->>A: answer
A-->>C: 200 / 422 / 504
end
Cancel several orders¶
DELETE /api/orders/cancelaccess-token
A body with an orders list cancels each order in it. Each item carries what the single body carries, order_id and an optional broker, or parent_id for a whole parent and also part for a part of a plan, and each order is checked exactly as a single cancel is. Broker orders and parents can be mixed in one list, and the results come back in the list's own order. Several orders in one request describes the list, its answer and its statuses.
import os
import requests
response = requests.delete(
'http://127.0.0.1:8080/api/orders/cancel',
headers={
'access-token': os.environ['ACCESS_TOKEN'],
},
json={
'orders': [
{
'order_id': '250926000123456',
},
{
'order_id': '26092600000021',
'broker': 'flattrade',
},
],
},
timeout=30,
)
for result in response.json()['results']:
print(result['request_index'], result['status'], result['response'].get('outcome') or result['response'].get('error'))
Under the hood
- Redis keys read:
last_login,settings,<broker>:orders:ordersfor every broker, andunified:orders:children, which says whether the order engine owns the order. - Why the stored order matters: several brokers' cancel requests need values only their own order book carries, such as Zerodha's variety (the
amoin the URL above comes from the stored order), Kotak's after-market flag or Wisdom Capital's identifier. - Classes:
CancelOrderRequestvalidates a cancel named byorder_id, andParentCancelone named byparent_id, which is handed to the engine as acancel_parentcommand and runsPlanOrder.cancel_partfor a part. - A list:
cancel_order_listreads every broker order's entries in the same single pipeline, however many orders it has, and prepares each withprepare_cancelinto aPreparedCancel. Theparent_iditems are handed to the engine together, as the modify list hands its held orders.
The engine's parents¶
GET /api/orders/parentsaccess-token
A parent is one order you asked the order engine for, such as a bracket, and its legs are the broker orders it placed for it. This route shows one parent, named by parent_id in the query string, or every parent that has not finished. It is the only way to see a parent that has placed nothing yet, such as an armed trigger or a scheduled order.
| Name | In | Type | Required | Description |
|---|---|---|---|---|
access-token |
header | string | Yes | The token from connect |
parent_id |
query | string | No | The parent to show; without it, every open parent is listed under parents |
The answer is the parent as the engine holds it in unified:orders:parents: parent_order_id, synthetic_type, state, instrument_id, the caller's body, the type's parameters and one entry per leg with its broker, order id, state, quantities and prices. A named parent the engine does not hold is answered 404 the order engine holds no parent with this id.
Cancel a parent¶
DELETE /api/orders/parentsaccess-token
This route cancels one parent, named by parent_id in the body, or each of a list given as parents, an array of objects each holding parent_id. The worker that owns the parent cancels every leg still resting at a broker and ends the parent as cancelled, so it places, moves and cancels nothing more. DELETE /api/orders/cancel with parent_id does the same, and also takes part, dry_run and a list mixing parents with broker orders; this route is kept for the programs that already call it.
An order placed a moment earlier may not have reached its broker's order book in Redis yet, and a cancel is built from that book, so the engine waits up to 3 seconds for it to appear before giving up. When it still has not appeared, that leg's cancel is rejected with the reason, because nothing was sent. When a broker refuses a leg's cancel, or its outcome is unknown, the answer is 207 and the parent's state is cancelling, not cancelled, because that leg may still be live. Each entry of cancelled_legs gives its own outcome and status_message, so you can see which leg is still resting. The parent no longer acts, becomes cancelled on its own once the broker reports the leg finished, and sending the same cancel again retries the legs still resting. In the live test of 2026-09-27, INDmoney refused one cancel for its rate limit, and before this rule the parent was reported cancelled while the order stayed pending at the broker.
Cancelling a parent cancels live orders
Every leg the parent still has resting at a broker is cancelled. A position the parent already opened is not closed; close it yourself or use flatten.
{
"parent_id": "0c2d4e6f-8a1b-4c3d-9e5f-7a8b9c0d1e2f",
"synthetic_type": "bracket",
"state": "cancelled",
"cancelled_legs": [
{"leg_id": "0c2d4e6f-8a1b-4c3d-9e5f-7a8b9c0d1e2f:2", "broker": "flattrade", "order_id": "26091500000031", "outcome": "accepted", "status_message": null}
],
"intent_id": "5e0c4f0c8f3a4f7e9a1d2b3c4d5e6f70"
}
| Status | Meaning |
|---|---|
| 200 | The parent is cancelled; cancelled_legs says what each leg's cancel came back as. A list is answered with results, one entry per parent. |
| 207 | A leg's cancel was refused or its outcome is unknown, so the parent's state is cancelling. |
| 400 | parent_id must name a parent, parents must be a non-empty list, or, for one entry of a list, each entry of parents must name a parent_id |
| 404 | the order engine holds no parent with this id |
| 409 | the parent is already <state> |
| 503 | The order engine is not running. |
Under the hood
- Redis keys read:
unified:orders:parentsandunified:orders:parents:openfor the read; the cancel is handed to the engine as acancel_parentcommand onunified:orders:intents:stream. - Classes:
ParentCommandsruns the cancel through the parent's own order type, withSyntheticOrder.cancel_by_caller.
Several orders in one request¶
POST /api/orders/place, PUT /api/orders/modify and DELETE /api/orders/cancel take a list of orders on the same paths. The list form is chosen by the body: a body with an orders key is a list and is answered with {"results": [...]}, even when it holds one order, and a body without one is the single form, answered exactly as described above. The shape of the answer follows the form of the request, never the number of orders.
Request body¶
| Name | In | Type | Required | Description |
|---|---|---|---|---|
access-token |
header | string | Yes | The token from connect |
orders |
body | array of objects | Yes | One item per order. For place, an item is one order exactly as the single body gives it, including a synthetic object for any order type, and a list holds at most UNIFIED_BROKER_INTERFACE_API_ORDER_PLACE_LIST_MAXIMUM items, 500 by default. For modify and cancel, an item carries order_id, an optional broker and, for modify, the fields to change, with no limit on the number. An item may instead carry parent_id: for modify with price and quantity, for a held order, and with part for a plan part; for cancel alone for a whole parent, or with part for a plan part. |
dry_run |
body, or query for modify and cancel |
boolean | No | true shows every order's broker request instead of sending it. It applies to the whole list, including the items named by parent_id, which are checked and not changed. |
The list takes no other key, in the body or the query string, so a field such as price or broker has to go inside each order. That rule is there so that nobody can put price beside the list expecting it to apply to every order, and it is why dry_run is refused inside an order: one order must never go live while its neighbours are only shown.
Placing a list¶
A placed list goes to the order engine in one step, and the engine's worker lanes place the orders in parallel, each at the broker its selector chooses, so a list spread over ten brokers goes out about ten times as fast as one order after another. A broker given in an item is ignored, as it is in the single form. An item that is not a valid order, or whose instrument cannot be found, gets its own entry and does not hold up the rest.
The request waits for the engine's answers for the single form's wait plus a tenth of a second per order, and never longer than UNIFIED_BROKER_INTERFACE_API_ORDER_PLACE_LIST_WAIT_SECONDS, 25 by default, which is below gunicorn's 30-second worker timeout. An order still unanswered then gets 504 with outcome unknown and its intent_id, because the engine may still place it; read its answer later by that id. A place entry carries intent_id beside request_index, and it is null for an item refused before it reached the engine.
{
"results": [
{"request_index": 0, "intent_id": "5e0c4f0c8f3a4f7e9a1d2b3c4d5e6f70", "status": 200, "response": {"broker": "flattrade", "outcome": "accepted", "order_id": "26091500000021", "parent_id": "…", "…": "…"}},
{"request_index": 1, "intent_id": null, "status": 400, "response": {"error": "quantity must be a whole number of at least 1"}},
{"request_index": 2, "intent_id": "8b1f2e3d4c5b6a79881726354a3b2c1d", "status": 200, "response": {"broker": "fyers", "outcome": "accepted", "order_id": "26091500000013", "…": "…"}}
]
}
The whole list is refused, with nothing placed, when orders is missing, empty or longer than the maximum, when the body has a key other than orders and dry_run, and with 503 when the order engine is not running.
Response¶
Each entry carries the order's position in the list, the status the single form would have answered with, and in response exactly the body the single form would have answered with. The example below is shortened.
{
"results": [
{"request_index": 0, "status": 200, "response": {"broker": "zerodha", "order_id": "250926000123456", "status_before_cancel": "OPEN", "outcome": "accepted", "...": "..."}},
{"request_index": 1, "status": 422, "response": {"broker": "dhan", "order_id": "112509260000012", "outcome": "rejected", "status_message": "bad request", "...": "..."}},
{"request_index": 2, "status": 409, "response": {"error": "the order is already COMPLETE", "broker": "zerodha", "order_id": "250926000099999"}},
{"request_index": 3, "status": 400, "response": {"error": "this order is already named at request_index 0", "order_id": "250926000123456"}}
]
}
| Attribute | Type | Description |
|---|---|---|
results |
array of objects | One entry per item of orders, in the same order |
results[].request_index |
integer | The item's position in orders, counting from 0 |
results[].status |
integer | The status the single form answers this order with: 200, 422 or 504 once a request was sent, or the refusal's status |
results[].response |
object | The body the single form answers this order with |
Status codes¶
The status of the whole response says only whether the list could be read. Check each entry's status to learn what happened to each order, because a 200 can hold orders that were refused or whose outcome is unknown.
| Status | When |
|---|---|
| 200 | The list was read and every order has an entry. |
| 400 | orders must be a non-empty list, a list may hold at most <maximum> orders, not <n> (for place), a list takes only orders and dry_run, so give <name> inside each order, a list takes only dry_run in the query string, so give <name> inside each order, or dry_run must be true or false. |
| 401 | Access token is required, Invalid access token or Access token has expired. |
| 503 | Redis could not be read: <error>, and for place also no instruments have been mapped yet or the order engine is not running, so the order was not placed; start unified-orders@order_engine.service. |
An entry gets any status the single form gives. Three refusals exist only in a list, each with 400: each entry of orders must be an object, dry_run applies to the whole list, so give it beside orders rather than inside an order, and this order is already named at request_index <n> for an item that names an order an earlier item already names. Two items name the same order when their order_id is the same and their broker is the same or either leaves it out, so the same id at two named brokers is two orders.
How a list is sent¶
Every order is checked before anything is sent, and the orders that pass are sent to their brokers up to four at a time, each exactly once. The requests of one list therefore leave in no fixed order, and a list is not all-or-nothing: an order that is refused, or whose outcome is unknown, does not stop the others. A sending failure that the broker code did not expect is answered as a 504 entry with sending failed unexpectedly, so whether the broker received it is unknown: <error>, so that it cannot hide the answers of orders already sent.
Every order sent counts towards its broker's daily order cap, exactly as a single request does.
| List | Redis round trips before the broker calls |
|---|---|
DELETE /cancel |
1 whatever the number of orders, plus one per cancel sent, to take room in the rate budget |
PUT /modify |
1 to 3 whatever the number of orders: the first pipeline, then every order's token candidates, then their catalogue data, each skipped when this worker already holds it; plus one per change sent, to take room in the rate budget |
These counts are recorded by test_runs/order_change_lists.py: a cancel list of 60 orders that no broker holds costs one round trip, a dry-run modify list of one order at each of the ten brokers costs three, and the same modify list sent costs thirteen.
How the broker is chosen¶
POST /api/orders/place names an instrument, not a broker, so the API has to pick one. It does this in three stages. First it drops the brokers that configuration excludes, then the broker selector ranks the rest, and finally each broker in that ranking is asked whether it can take this particular order. The first broker that says yes gets the order.
flowchart TD
A["All ten brokers"] --> B{"Named in<br/>ORDER_EXCLUDED_BROKERS?"}
B -- yes --> X["Left out of the rotation"]
B -- no --> C["Rotation"]
C --> D{"Any broker left?"}
D -- no --> E["503 every broker is excluded<br/>from order placement"]
D -- yes --> F["Selector ranks the rotation<br/>lowest_cost, round_robin or fixed_priority"]
F --> G["Next broker in the ranking"]
G --> H{"place_skip_reason<br/>says it can take it?"}
H -- no --> I["Add to skipped<br/>with the reason"]
I --> J{"More brokers?"}
J -- yes --> G
J -- no --> K["503 no broker can take this order<br/>with skipped"]
H -- yes --> L["Lot size and tick size checks"]
L --> M["Build and send the request"]
Excluded brokers¶
UNIFIED_BROKER_INTERFACE_API_ORDER_EXCLUDED_BROKERS is a comma-separated list of broker names that never receive a placement. When it names every broker, every placement is refused with 503 every broker is excluded from order placement. Modify and cancel ignore this setting, because an order already at a broker can only be changed there.
The three selectors¶
UNIFIED_BROKER_INTERFACE_API_ORDER_BROKER_SELECTOR picks the ranking algorithm, and an unknown name stops the API from starting rather than falling back. The table below compares the three selectors.
| Selector | How it ranks | Redis cost | When to use it |
|---|---|---|---|
lowest_cost (default) |
Cheapest broker for the order's category first, keeping every broker inside its per-second, per-minute, per-hour and per-day budgets, with every number read from unified.broker_order_costs; see Choosing a broker by cost |
One EVAL, queued on the pipeline that reads the instrument |
Paying the least brokerage without running a broker out of its order budget |
round_robin |
Starts at INCR unified:orders:round_robin modulo the rotation's length and walks the rotation from there. Every gunicorn worker shares the counter. |
One INCR, queued on the pipeline that reads the instrument |
Spreading orders evenly across accounts |
fixed_priority |
Puts the brokers named in UNIFIED_BROKER_INTERFACE_API_ORDER_BROKER_PRIORITY first, in that order, then the rest of the rotation in turn order |
None | Sending everything to one preferred broker, with the others as fallbacks |
With round robin, a skipped broker's turn passes to the next broker in the rotation, and the counter is then moved on past every broker passed over with one INCRBY, so the next order starts after the broker that took this one. A run of orders some brokers cannot take, such as after-market orders, is therefore spread evenly over the brokers that can. Before this, the broker after a skipped one took two turns in a row, and in the live test of 2026-09-27 INDmoney received 30 of 100 after-market orders.
Why a broker is skipped¶
Each broker's place_skip_reason answers "can you take this order?" before anything is built. The checks run in the order below, and the first one that fails becomes the reason in skipped.
| Check | Reason text |
|---|---|
The instrument's market is in the broker's MARKETS |
does not take <exchange> <asset class> <kind> orders |
| For a currency or commodity market, the broker knows how it counts quantity there | does not know how it counts quantity in <market> orders |
| The broker has an order handle for the instrument | has no mapping for the instrument |
| The handle carries the field the broker names instruments by | its mapping carries no broker_token or its mapping carries no order_symbol |
| Where the broker counts in its own lot size, the handle has a whole one | its mapping carries no whole lot size |
| Redis holds a login with an access token | has no login in Redis |
| Broker-specific login checks, such as Kotak's session id | a broker-specific message |
| The settings the place request needs are present | has no <fields> in its Redis settings |
The broker takes SL and SL-M orders, when this is one |
takes no <type> orders |
| The broker takes after-market orders, when this is one | takes no after-market orders |
| The broker has the free cash for the order, when the lowest-cost selector's funds check is on | needs about <margin> of margin but has <cash> free, its funds are stale, its funds were read <n> seconds ago or has no funds in unified:portfolio:funds |
The last check comes from the selector rather than the broker: the lowest-cost selector estimates the margin the order needs at each broker and compares it with that broker's free cash, without calling the broker. It applies only when the selector chooses, so an order that names its broker is never checked. Choosing a broker by cost explains the estimate.
The markets each broker takes are listed below, from the MARKETS table of each order class. A market is an exchange, an asset class and a kind of instrument.
| Market | Dhan | Flattrade | Fyers | Groww | INDmoney | Kotak | Shoonya | Stoxkart | Wisdom Capital | Zerodha |
|---|---|---|---|---|---|---|---|---|---|---|
| NSE cash | ||||||||||
| BSE cash | ||||||||||
| NSE derivatives | ||||||||||
| BSE derivatives | ||||||||||
| MCX commodity derivatives | ||||||||||
| NSE currency derivatives | ||||||||||
| BSE currency derivatives | ||||||||||
| NSE commodity derivatives | ||||||||||
| NCDEX commodity derivatives | ||||||||||
| Takes after-market orders | ||||||||||
Takes SL and SL-M |
Contract sizes, lots and ticks¶
The size checks differ between the securities markets and the currency and commodity markets, because the brokers agree on lot sizes in the first and disagree in the second.
- Securities (equities, fixed income, funds, trusts and their derivatives):
quantitymust be a whole number of the chosen broker's ownlot_size. The quantity is sent to the broker unchanged, in units. - Currency and commodity: the lot size comes only from this morning's contract size decision in
unified:catalogue:<date>:contract_sizes, because brokers count these lots in different units. When the decision is missing, not marked tradeable, or not a positive number, the order is refused with 503 andcontract_size_status(for exampleconflictorundecided). Otherwisequantityanddisclosed_quantitymust both be whole lots, and each broker converts them into its own terms, which isbroker_lot_size(lots times the broker's own lot size) for every broker and market listed today. - Ticks:
priceandtrigger_pricemust be whole numbers of the tick size that the most brokers' handles agree on. When two tick sizes tie, the check is skipped.
Finding the broker that holds an order¶
Modify and cancel take a broker's order id, and the API has to find which of the ten brokers issued it. It reads the id from every broker's <broker>:orders:orders hash in the same pipeline that reads the token, and then find_stored_order decides.
- When you gave
broker, only that broker's entry is considered. - An entry that is missing, not JSON, or not a JSON object counts as not holding the order.
- When no broker holds it, the answer is 404
no broker order book in Redis holds this order_id. - When more than one broker holds it, the answer is 409 with
brokerslisting them, and you retry withbrokerset. - When the one stored order is finished (
COMPLETE,CANCELLED,REJECTEDorEXPIRED), the answer is 409the order is already <status>.
Because the lookup reads Redis rather than the broker, an order placed a moment ago may not be there yet. The broker's poller records it within about a second, and its order websocket often sooner.
Daily order caps¶
Some brokers refuse every order message past a fixed number a day, and a modification or a cancellation counts as a message just like a placement. A broker's cap is its orders_per_day in unified.broker_order_costs, which caps Zerodha at 4,500 and Fyers at 100,000. For a broker the table leaves uncapped, UNIFIED_BROKER_INTERFACE_API_ORDER_DAILY_CAPS can still set one, written as broker=number,broker=number, for example dhan=7000. That setting is empty by default. A broker capped by neither is never counted, which costs nothing.
When a broker is capped, every request actually sent to it is counted in the Redis key unified:orders:daily_count:<broker>. The count expires at the next 06:00 IST, so it starts again each trading day. A request that could not even connect is not counted; one the broker refused is.
| Setting | Default | Effect |
|---|---|---|
UNIFIED_BROKER_INTERFACE_API_ORDER_DAILY_CAPS |
empty | Caps for brokers whose orders_per_day in the cost table is empty |
UNIFIED_BROKER_INTERFACE_API_ORDER_DAILY_CAP_EXIT_RESERVE |
0.05 |
The share of each cap kept back for orders that close a position |
The diagram below shows how a cap of 5,000 with the default reserve is split.
flowchart LR
A["0 messages"] -->|"entries and exits allowed"| B["4,750<br/>entry limit"]
B -->|"only exits allowed"| C["5,000<br/>cap"]
C -->|"everything refused"| D["429"]
The refusal with 429 happens in the order engine, which checks the count before it places each leg. PUT /api/orders/modify and DELETE /api/orders/cancel are counted but never refused because of the count.
When the engine refuses an order, the message says which limit was hit. For a new entry it is <broker> has been sent <n> order messages today, and the last <m> of its daily cap of <cap> are kept for closing positions, so this was not sent. For an exit it is <broker> has been sent <n> order messages today, which is its daily cap of <cap>, so this was not sent. A count that cannot be read never refuses an order; the failure is logged instead.
Connection warming¶
Opening a new TLS connection to a broker costs 50 to 130 ms more than using an open one, so every connection in each broker's pool is kept freshly used. UNIFIED_BROKER_INTERFACE_API_ORDER_WARM_BROKERS names the brokers to warm, comma-separated, and is all unless set; set it empty to turn warming off. For each warmed broker, a background thread sends a HEAD request with no credentials through the same connection pool the orders use. After the answer, it watches the connection for one second and returns it to the pool only if the server has not closed it.
A warmed broker's pool hands out the connection that has waited longest, rather than urllib3's usual most recent one, so single pings rotate through the whole pool. The warmer first sends one ping per place in the pool back to back, which opens every connection, and then pings one connection at a time, spaced so that each is used about once every WARM_INTERVAL_SECONDS. A ping that fails is followed by a pause of the whole interval. The order engine's pool for each broker holds UNIFIED_BROKER_INTERFACE_API_ORDER_MAXIMUM_WORKERS_PER_BROKER plus one connections (31 by default), one for every worker a broker's lane can grow to and one for the ping; an API worker's holds five, one for each of its four send threads and one for the ping. A broker that is not warmed keeps urllib3's usual order, which keeps its one busy connection hot.
Each broker's pool also refuses to reuse a connection that has been idle longer than MAXIMUM_IDLE_SECONDS, well before the broker's server would drop it. The table below lists both values for each broker.
| Broker | Ping interval (s) | Longest idle reuse (s) | Warmed host |
|---|---|---|---|
| Dhan | 60 | 180 | https://api.dhan.co/ |
| Flattrade | 60 | 300 | https://piconnect.flattrade.in/ |
| Fyers | 60 | 300 | https://api-t1.fyers.in/ |
| Groww | 60 | 300 | https://api.groww.in/ |
| INDmoney | 60 | 300 | https://api.indstocks.com/ |
| Kotak | 60 | 300 | none until the first Kotak request names its host |
| Shoonya | 15 | 45 | https://api.shoonya.com/ |
| Stoxkart | 60 | 300 | https://openapi.stoxkart.com/ |
| Wisdom Capital | 15 | 45 | https://trade.wisdomcapital.in/ |
| Zerodha | 60 | 300 | https://api.kite.trade/ |
One round of a pool takes WARM_INTERVAL_SECONDS or the pool size times the one-second settle check, whichever is longer: about 31 seconds for a pool of 31, which is below the shortest idle limit, Shoonya's and Wisdom Capital's 45 seconds.
Warming only saves time, so nothing about it can stop an order. An unknown broker name is logged and ignored, every exception in the warming thread is caught and logged, and a ping never touches the session's cookies or headers. Once a real request has gone to a broker, later pings go to that request's host instead of the default one.
Redis round trips per route¶
The order routes were written so that the API's own work adds as little as possible to the time the broker takes. The table below counts the Redis round trips each route makes before its broker call, as the code and the recorded fixtures show them.
| Route | Round trips | What they are |
|---|---|---|
GET /api/orders/details, /trades |
token check, then 1 | The token check, then one GET of the document |
POST /place |
4 or 5 | The first pipeline, an optional lookup by fields, the engine lock's EXISTS, the XADD of the intent and the BLPOP for the answer; the engine's own reads are not counted here |
PUT /modify |
2 to 4 | The first pipeline with every broker's order book; the token candidates; their catalogue data, each skipped when held by this worker; one more for the rate budget before sending |
PUT /modify, a list |
1 to 3, plus one per change sent | The same three, each read once for the whole list, plus one for the rate budget per change sent |
DELETE /cancel |
2 | One pipeline with the token, logins, settings and every broker's order book, and one for the rate budget |
DELETE /cancel, a list |
1, plus one per cancel sent | The same pipeline, holding every order of the list, and one for the rate budget per cancel |
POST /flatten |
1, plus more | One read of everything; the engine lock's EXISTS, an XADD and a BLPOP to halt every open parent; one for the rate budget per cancel; one re-read of the order books every 0.25 s while waiting for the cancels; an HGET per position closed, then the engine lock's EXISTS, one pipeline of XADDs and a BLPOP per answer; one re-read of the position books every 0.25 s while waiting for the closed positions to show zero |
When a broker is capped by ORDER_DAILY_CAPS, each request sent to it costs one more pipeline afterwards, to increment the count.