Order engine¶
The order engine is the background process that places every order the REST API accepts. The API worker checks your request, hands the order to the engine and waits for its answer. The engine places it, applies the risk limits, and can keep working an order after your request has been answered. That last ability is what makes brackets, trailing stops, time-sliced orders and every other synthetic order type possible.
The engine places real orders, and can place them later
An order can reach a broker after POST /api/orders/place has answered: an armed trigger fires when the price arrives, a scheduled order is sent at its time, and a bracket places its stop and target when the entry fills. Each of those is a real order. Stopping the API does not stop the engine; stop unified-orders@order_engine.service as well.
Why every order goes through the engine¶
The API used to have a second mode, direct, in which the API worker sent a plain order to the broker itself. It was removed, because it could not do most of what the place route promises and failed quietly when asked to: it placed a bracket or an iceberg as one plain order, and it sent a price or a quantity of 0 for an order that gave only a price_reference or a quantity_reference. The table below lists what the engine does that the direct mode did not.
| What the engine does | |
|---|---|
synthetic order types |
All of them, as described in Synthetic orders |
price_reference, quantity_reference |
Resolved from the live quote and the positions before the order is built |
| Rate budget | At most the order messages a second, minute and hour that unified.broker_order_costs gives each broker (10 a second by default where it gives none, 5 for Zerodha and INDmoney), shared with the modify and cancel routes |
| Loss lockout | New orders refused once the day's loss passes the configured limit |
| Daily order caps | Counted and enforced with 429 |
| Parallel placement | One lane of worker threads per broker, so a slow broker holds up only its own orders |
| Extra keys in the answer | intent_id, and parent_id for an order the engine recorded |
The cost is three extra Redis round trips per order: an EXISTS on the engine's lock, an XADD and a BLPOP. The hand-over was measured to add about half a millisecond. UNIFIED_BROKER_INTERFACE_API_ORDER_PLACEMENT no longer exists and is ignored if it is still set. The API cannot place an order while the engine is not running; it answers 503 the order engine is not running, so the order was not placed; start unified-orders@order_engine.service.
The life of an intent¶
An intent is one accepted order on its way from an API worker to the engine. The worker checks everything it can check without a broker, writes the intent to a Redis stream, and blocks on a Redis list named after the intent until the engine pushes an answer onto it.
sequenceDiagram
autonumber
participant C as Your program
participant A as API worker
participant S as unified:orders:intents:stream
participant E as Order engine
participant L as unified:orders:intents:result:<id>
participant B as Broker
C->>A: POST /api/orders/place
A->>A: check token, validate body,<br/>resolve instrument_id
A->>S: XADD intent (max about 10,000 entries)
A->>L: BLPOP, wait up to ENGINE_TIMEOUT_SECONDS
E->>S: XREADGROUP as group engine
E->>E: past its deadline? loss lockout?
E->>E: build the synthetic order type's parent
E->>B: place leg(s)
B-->>E: answer
E->>L: RPUSH answer, EXPIRE RESULT_TTL_SECONDS
E->>S: XACK
L-->>A: answer
A-->>C: the engine's answer, plus intent_id
The intent written to the stream carries these fields.
| Field | Meaning |
|---|---|
intent_id |
A random 32-character hex id, which names the answer list |
created_at |
The Unix time the worker wrote the intent |
deadline_at |
created_at plus the worker's wait, after which the worker has given up |
reply_key |
unified:orders:intents:result:<intent_id> |
api_worker |
The host and process id of the worker, for diagnosis |
synthetic_type |
synthetic.type from the body; virtual_limit for a plain limit order that is held while UNIFIED_BROKER_INTERFACE_API_ORDER_HOLD_LIMITS is on; marketable_limit for a plain market order sent as a marketable limit while UNIFIED_BROKER_INTERFACE_API_ORDER_MARKET_AS_LIMIT is on; otherwise simple |
instrument_id |
The instrument the worker resolved, so the lookup rule lives in one place |
body |
Your JSON body, exactly as it arrived |
request_index |
Only for an order from an orders list: its place in the list, which matches its answer to it on the list's shared reply key |
command |
Only for a change to a parent the engine owns, such as cancel_leg; see Changing an order the engine owns |
The worker validates the body before writing the intent, so a malformed order is refused with 400 without a queue hop. It does not check the synthetic type's own fields; the engine checks those when it builds the order, and answers with 400 if they are wrong.
When the engine does not answer in time¶
The worker waits UNIFIED_BROKER_INTERFACE_API_ORDER_ENGINE_TIMEOUT_SECONDS (5 by default). Every failure after the intent has been written ends with 504 and outcome: unknown, because the order is already on the stream and may still be placed. The body below is a real recording from python -m test_runs.order_engine_routes, against a stubbed engine; the timing is illustrative.
{
"broker": null,
"instrument_id": "11111111-1111-5111-8111-000000000001",
"tag": "lostTag",
"outcome": "unknown",
"order_id": null,
"status_message": "the order engine did not answer within 5.0 seconds, so this order may still be placed",
"broker_response": null,
"skipped": [],
"intent_id": "0000000000004000800000000000abcd",
"timing_ms": {"preparation": 5001.7}
}
The engine keeps the other half of that promise. When it reads an intent more than UNIFIED_BROKER_INTERFACE_API_ORDER_ENGINE_STALE_INTENT_SECONDS (30 by default) past its deadline, it does not place it. It answers 409 the order engine read this order after the caller had stopped waiting for it, so it was not placed, with intent_id and expired_seconds, so a restart cannot fire an abandoned order into a market that has moved. The answer stays in the list for UNIFIED_BROKER_INTERFACE_API_ORDER_ENGINE_RESULT_TTL_SECONDS (300 by default).
The table below lists every answer that comes from the engine or the hand-over rather than from a broker.
| Status | Message | Meaning |
|---|---|---|
| 202 | outcome is armed |
A synthetic order is recorded and waiting for a price, a time or a fill. Nothing may have reached a broker yet. |
| 400 | the order engine does not run '<type>' orders; it runs ... |
synthetic.type is not one of the registered types. |
| 400 | a type's own field message | The synthetic object is missing or has a wrong field for its type. |
| 403 | the day is down <loss>, which is past the <limit> limit, so no new order is being placed |
The daily loss lockout is on. |
| 409 | the order engine read this order after the caller had stopped waiting for it, so it was not placed |
The intent went stale. |
| 409 | the order engine had already started this order before it read it again, so it was not placed a second time; read the parent for its outcome |
The intent was read again after a restart, and had already started a parent. |
| 409 | a quantity reference message | A quantity_reference asked to reduce or close a position that is not there. |
| 409 | nobody is offering this instrument, so the BUY could not be priced and nothing was sent, or the same for a sell, a missing quote or a stale one |
A market order sent as a marketable limit found nothing on the other side of the book to price against. |
| 429 | <broker> has been sent <n> order messages today, ... |
The broker's daily order cap has no room for this kind of order. |
| 503 | the order engine is not running, so the order was not placed; start unified-orders@order_engine.service |
No engine holds unified:orders:engine:lock, which a running engine refreshes every ten seconds and which expires thirty seconds after it stops. The API reads the key before it writes the intent, so nothing was queued. |
| 503 | the order could not be written for the order engine: <error> |
The XADD failed; nothing was queued. |
| 503 | today's instrument catalogue is not published yet: the catalogue for <date> has expired, and the daily mapping has not published a new one |
The engine refused the instrument as not mapped and found that the date's whole catalogue has expired. |
| 503 | the order rate budget is full, so this order was not sent; try again in a moment |
The broker's one-second window had no room within the wait. |
| 503 | a price reference needs a tick size the brokers agree on and there is none for this instrument |
A price reference cannot be snapped to a tick. |
| 503 | the order engine failed before sending this order to any broker (<exception>), so nothing was placed |
The engine raised before any of the order's legs was written down as sending, such as when its database write failed. Every leg is written down before it is sent, so nothing reached a broker. The body also carries intent_id and parent_id, and the parent is closed as rejected. |
| 504 | the order engine did not answer within <n> seconds, so this order may still be placed |
The wait ran out. |
| 504 | the order was written for the order engine but its answer could not be read (<error>), so this order may still be placed |
Redis failed during the wait. |
| 504 | the order engine answered with something that could not be read, so the outcome of this order is unknown |
The answer was not a JSON object with a body. |
| 504 | the order engine failed while placing this order (<exception>), so its outcome is unknown |
The engine raised after at least one leg was written down as sending, so that leg may have reached its broker. |
The engine process¶
bin/unified/orders/order_engine runs as the systemd unit unified-orders@order_engine.service. Only one engine may run against one Redis, because two engines reading the same consumer group could place the same order twice.
- It takes a lock.
unified:orders:engine:lockholds the engine's process id for 30 seconds and is refreshed every 10. A second engine cannot take it and exits with code 1, and an engine that finds the lock taken over by another process stops placing and exits 1. - It prepares the event table. It applies the DDL for
unified.synthetic_order_events, and exits 1 if it cannot. - It builds its placement code and its worker lanes, and reads the broker cost table. A misspelt broker selector, an empty
unified.broker_order_costs, unreadable daily caps, an unreadableUNIFIED_BROKER_INTERFACE_API_ORDER_RATE_PER_BROKER_PER_SECONDor a misspeltUNIFIED_BROKER_INTERFACE_API_ORDER_WORKERS_PER_BROKERexit with code 2, which systemd does not restart. A cost table that cannot be read because PostgreSQL is down exits with code 1, which systemd restarts. The table is read again every day at 06:00 IST; see Choosing a broker by cost. - It recovers. It replays today's events (and up to 366 days of events for the types that carry a parent overnight, the longest a GTT can be valid plus a day) through the same state machine the live path uses, rebuilds every parent that had not finished, and parks in
failedany parent with a leg its broker's order book does not hold. What the brokers did with the other legs while the engine was down is applied by the first order book pass, described under Changes no socket delivered, which runs before the engine reads anything and records each change the way a socket's update is recorded. A failure here exits 1, because placing new orders without knowing what is already at a broker is worse than not starting. - It loops. Its main thread reads both
unified:orders:intents:streamandunified:order-updates:streamin oneXREADGROUPcall as the groupengine, up to 100 entries at a time, blocking for one second, and hands the work to the worker lanes described below. After each read it hands a clock tick and a price tick to the parents of the types that asked for them, and rebuilds its caches when the day rolls over at 06:00 IST, once every worker is idle.
Broker lanes¶
The main thread places nothing itself. Each broker has a lane of worker threads, and one more lane, unassigned, takes orders whose broker could not be chosen at intake. The engine keeps one piece of work about one parent on one worker, so no two threads ever touch the same parent and every order type's code still runs top to bottom with a broker call that waits for its answer.
| Work | Where it goes |
|---|---|
| A new intent | The main thread checks it was not already started or too old, chooses its broker with the configured selector exactly as a placement would, and hands it to the least busy worker in that broker's lane. The worker records itself as the parent's owner before anything is sent, and the parent's first legs go to that broker. |
| A broker's order update | The main thread finds the parent through unified:orders:children and hands the update to the parent's owner. An update for an order no parent owns yet is held, as above. |
| A change found in a broker's polled order book | The main thread compares the open legs with the books every few seconds, as described below, and hands each change it finds to the parent's owner, exactly as it hands an order update. |
| A clock or price tick | The main thread finds the parents that want one and hands each tick to its owner, skipping a parent whose previous tick has not run yet. |
| A parent recovered at start | It is given an owner the first time work for it arrives, in the lane of the broker its legs went to. |
A lane starts with UNIFIED_BROKER_INTERFACE_API_ORDER_WORKERS_PER_BROKER workers, and starts one more whenever a new intent arrives while every worker in the lane has work, up to UNIFIED_BROKER_INTERFACE_API_ORDER_MAXIMUM_WORKERS_PER_BROKER. Workers are not stopped during the day, because a worker usually owns parents that are still live; the lanes go back to their starting size when the engine restarts. When the engine stops, each worker finishes the work already handed to it, and an intent it had not acknowledged is read again at the next start.
python -m test_runs.order_engine_throughput measures the lanes against ten stub brokers that each take 200 ms to answer. With one worker per broker, 300 orders took 6.04 seconds, 5 orders a second per broker, which is all one thread can do when each call takes a fifth of a second. With ten workers per broker they took 2.24 seconds, and no broker was sent more than 10 in any one second: the rate budget, not the workers, was the limit, which is 100 orders a second across ten brokers.
A broker chosen at intake binds only the legs a parent places when its intent arrives. A parent that waits for a price or a time before placing anything lets the selector choose again when it fires, as it always has, so a broker that has logged out since is passed over.
The consumer group starts at the beginning of the intent stream, because an intent written while the engine was down is an order somebody is still owed an answer for. It starts at the end of the order-update stream, because older updates are about orders the engine never placed. An order update that names no known leg is held for up to 30 seconds rather than dropped, because a broker can report a fill before the engine has saved the order's id; after every batch the engine looks the held orders up again in one round trip and applies, in arrival order, the updates whose order has become known. An intent is acknowledged only after its answer has been pushed, so an engine that dies in between reads the intent again at its next start. Every parent is saved with its intent id before its first leg is sent, so the engine finds that the intent already started a parent and answers 409 the order engine had already started this order before it read it again, so it was not placed a second time; read the parent for its outcome, with intent_id and parent_id, instead of placing it twice. This check comes before the stale-intent check, so an intent that was placed and then went stale is reported as already started rather than as not placed. An intent that cannot be read at all is acknowledged unplaced, so that one bad entry cannot block every order behind it.
Changes no socket delivered¶
The order-update stream is fed only by the brokers' order websockets. A broker with no order socket running, such as Flattrade today, or a socket that drops a message, would leave an order the broker has already filled or cancelled looking live to the engine, and a parent waiting on it would never finish. The live test on 27 September 2026 left two parents in cancelling this way, one at Flattrade and one at INDmoney, although both orders were cancelled at the broker.
So every UNIFIED_BROKER_INTERFACE_API_ORDER_ENGINE_RECONCILE_SECONDS (5 by default), the main thread compares every leg the engine has sent and not seen finish with its entry in <broker>:orders:orders, the book each broker's REST poller keeps. It reads the open parents in one round trip and the book entries in a second. A book entry that shows a finished status, or more filled than the leg records, becomes an order update and goes to the parent's owner, where it is applied exactly as a socket's update would be: the leg moves, the order type reacts, and the parent finishes if it should.
Only changes that move a leg forward are taken. A poll can be older than a socket message about the same order, so a book that still says OPEN beside a leg the socket already filled is the book being behind, not the order reopening, and nothing changes. The owner reads the parent again before it applies the change, so a socket update that reached it first leaves nothing to apply. How quickly a missed change is found depends on the poller as well: it is at most this interval plus the broker's poll interval, which UNIFIED_BROKER_INTERFACE_BOOK_POLL_SECONDS can lengthen. One pass also runs when the engine starts, before it reads anything, so a fill or a cancel that happened while it was down is recorded and reacted to; recovery used to change such legs in memory only, which left a cancelled parent's legs finished but the parent itself stuck in cancelling, and repeated the same change on every restart because it was never recorded. Setting the interval to 0 turns the periodic passes off, but not the one at start. The engine logs how many passes ran and how many changes they found when it stops.
Recovery and orphans¶
The engine writes each transition to the database and commits it before acting on it. A leg is recorded in the state sending before its request leaves, so a crash between the two leaves evidence that an order may exist at the broker. On restart the orphan matcher looks for that order in the broker's book. It attributes the order only when exactly one unclaimed order matches every field that was sent, within 2 seconds of when it was sent. With zero matches or several, the parent is parked in failed for a person to look at, because hanging a stop and a target on the wrong position is worse than admitting the engine does not know.
Changing an order the engine owns¶
An order the engine placed is a leg of one of its parents, and the parent's order type may be about to move it, reduce it or cancel it in reaction to a fill or a tick. So the order routes never change one behind the engine's back. PUT /api/orders/modify and DELETE /api/orders/cancel look each order up in unified:orders:children, in the Redis read they already make, and hand an order the engine owns to the worker that owns its parent as a command intent: cancel_leg or modify_leg. The worker runs it through the order type, which records it and carries on from it; see Modify an order.
DELETE /api/orders/parents, and DELETE /api/orders/cancel with parent_id, send cancel_parent, which cancels every leg still resting and ends the parent as cancelled; with part it cancels only that part of a plan, and with dry_run it says what would be cancelled. POST /api/orders/flatten sends halt before it cancels anything: the main thread hands every open parent's owner a halt, which ends the parent as cancelled without touching its legs, and answers at once with how many there were. Each worker runs its halts before any fill or tick handed to it afterwards.
| Command | Sent by | What the worker does |
|---|---|---|
cancel_leg |
DELETE /api/orders/cancel |
Cancels the leg through cancel_leg, recording leg_cancel_requested and leg_cancelled |
modify_leg |
PUT /api/orders/modify |
Sends the change through apply_outside_modification, records it as a leg_update, and calls the type's on_leg_modified |
cancel_parent |
DELETE /api/orders/parents, or DELETE /api/orders/cancel with parent_id |
Cancels every resting leg and ends the parent as cancelled, or with part stops one part of a plan through PlanOrder.cancel_part |
modify_held |
PUT /api/orders/modify with parent_id |
Changes the price or quantity of an order the engine is still holding, such as a virtual_limit order, or, with part, the price, trigger price or quantity of a plan part that has not been sent, without sending anything to a broker |
halt |
POST /api/orders/flatten |
Ends every open parent as cancelled, leaving its legs to flatten |
Risk gates¶
Every order the engine sends passes the same set of limits, held together in one object. They are real limits only because exactly one engine runs; the same limits inside each gunicorn worker would each be enforced once per worker.
| Gate | When it is checked | What it does | Refusal |
|---|---|---|---|
| Daily loss lockout | Before the order is even understood | Adds pnl.realized and pnl.unrealized from unified:portfolio:funds. When the sum is at or below minus the limit, the order is refused. Off unless ORDER_DAILY_LOSS_LIMIT is above zero. An unreadable funds document does not lock trading out. |
403 |
| Daily order cap | After the broker is chosen, before anything is recorded | Refuses new entries once the broker's count reaches the entry limit, and every message at the cap itself. See Daily order caps. | 429 |
| Rate budget | After the leg is recorded, before it is sent | At most the broker's orders_per_sec, orders_per_minute and orders_per_hour from unified.broker_order_costs in any one-second, one-minute and one-hour span, falling back to ORDER_RATE_PER_BROKER_PER_SECOND for the second when the table gives none. They are counted in Redis in unified:orders:rate:<broker>, :minute and :hour, and shared with the REST API's modifications and cancellations. A message waits up to ORDER_RATE_WAIT_SECONDS for room, and is refused only if none comes. An optional limit across every broker is counted the same way. |
503 |
| Re-pricing throttle | Before a resting leg is moved | Refuses to move one leg again sooner than ORDER_REPRICE_MINIMUM_SECONDS after its last move. The move is dropped and the next tick works out a fresh price. |
recorded against the leg |
| Order-to-trade ratio | After each send and fill | Counts orders sent and orders filled per broker, and reports them when the engine stops. It refuses nothing. | none |
The rate budget covers every message: the engine's placements and its own changes to its legs, and the modifications and cancellations the REST API sends, including flatten's cancels. The loss lockout covers placements only. PUT /api/orders/modify and DELETE /api/orders/cancel still go straight from an API worker to a broker, so the lockout does not hold them, though they take room in the rate budget and are counted against a daily cap.
Parents, legs and where they are kept¶
The engine thinks in parents and legs. A parent is one order you asked for; a leg is one real broker order the engine sends on its behalf. A plain order has one leg, while a bracket has an entry, a stop and a target. The engine runs a simple order with a class of its own and every other type as a plan of that type's preset.
stateDiagram-v2
[*] --> received
received --> working
received --> rejected
received --> failed
received --> cancelled
working --> protecting
working --> completed
working --> cancelled
working --> failed
protecting --> completed
protecting --> cancelled
protecting --> failed
received --> cancelling
working --> cancelling
protecting --> cancelling
cancelling --> cancelled
cancelling --> failed
completed --> [*]
cancelled --> [*]
rejected --> [*]
failed --> [*]
A parent's state moves only along the arrows above. cancelling means you cancelled the parent but a broker refused one of its legs' cancels, so a leg may still be live; the parent stops acting and becomes cancelled once every leg has finished. failed means a person has to look: the engine never retries out of it and never arms protective legs for a parent in it. A leg has its own states: planned, sending, sent, acknowledged, partially_filled, filled, rejected, cancelled and unknown. No parent reaches protecting any more: since 2026-10-03 every type runs as a plan, which shows working while it guards a position, and the state stays only so that older parents replay correctly.
The engine keeps the same state in two places, and they have different jobs.
| Store | Keys or table | Job |
|---|---|---|
| PostgreSQL | unified.synthetic_order_events |
The record. One row per transition, committed before it is acted on, and read back at every start. |
| Redis | unified:orders:parents |
A cache of every parent, by parent id |
| Redis | unified:orders:parents:open |
The set of parents that are not finished |
| Redis | unified:orders:children |
Each leg's <broker>:<broker order id> to its parent id, which is how an order update is matched to a leg |
| Redis | unified:orders:parents:intents |
Each intent id to the parent it started, which is how an intent read a second time is recognised |
The four Redis keys expire at the next 06:00 IST, and every write moves that expiry forward. A flushed Redis costs a slower start, not a lost position, because recovery rebuilds all four from the table.
The unified.synthetic_order_events table is a TimescaleDB hypertable with one-day chunks, compressed after seven days. Its columns are listed below.
| Columns | Meaning |
|---|---|
time, parent_order_id, sequence, event |
When, which parent, the parent's own counter, and what happened (parent_received, parent_state_changed, leg_requested, leg_answered, leg_update and others, including outside_cancel_requested and outside_cancelled for an order a square-off cancels that is not one of its own legs) |
synthetic_type, parent_state |
The type, and the parent's state after the event |
leg_id, leg_role, leg_state |
The leg the event is about, its role (such as entry, stop, target, slice, chase) and its state |
broker, broker_order_id, exchange_order_id |
Where the leg went and the ids it got |
tag_sent, identifier_sent |
The tag and the broker token or symbol the request carried, which the orphan matcher compares |
intent_id, instrument_id |
The intent that started the parent, and the leg's instrument |
transaction_type, product, order_type, validity, quantity, filled_quantity, price, trigger_price, average_price |
The leg's order |
outcome, status_message |
The broker's answer |
engine_instance |
The host and process id that wrote the row |
detail |
JSON: the caller's body, the request as it actually went out, or the broker's response |
The synthetic limit order book¶
A virtual_limit order is held by the engine instead of being sent, and a real limit order is placed only when the other side of the book reaches your price. The engine reads one quote a second, which cannot tell it how the order would have fared resting at the exchange, so a separate process works that out.
bin/unified/orders/virtual_book runs as unified-orders@virtual_book.service. Every two seconds it reads the engine's open virtual_limit parents whose trigger has not fired, and every order of an open plan still waiting on a limit_marketable trigger. It follows unified:quotes:stream with a plain XREAD from the moment it starts, 1,000 entries at a time, and keeps one queue estimate per held order in the hash unified:orders:virtual_queue, keyed by parent id, or for a plan's order by <parent id>/<path>. The engine reads that hash; no API route exposes it, so read it with redis-cli HGET unified:orders:virtual_queue <parent id>.
| Estimate field | Meaning |
|---|---|
parent_order_id, instrument_id, side, price, quantity |
Which held order this is |
ahead |
How much is queued ahead of the order at its price |
queue_filled |
How much a resting order at that price would have filled |
filled, remaining |
What the order itself would have filled, and what is left |
touched_at |
When the other side of the book reached the price |
last_volume, last_broker, last_visible, updated_at, updates |
Bookkeeping for the next quote |
The arithmetic lives in unified_broker_interface/utilities/order_engine/utilities/virtual_queue.py. Trades at the price serve the queue ahead first, a trade through the price fills everything, and cancellations come off the queue ahead in proportion to its share of the level. A price beyond the five visible levels has no place until it comes into view. A stale quote, a change of the broker supplying the quote, or a new session is not read as trading. After a restart, each estimate takes its next quote as a new baseline, so the trading it missed is not counted.
Settings that tune the engine¶
The table below lists every environment variable the engine reads, with its default from utilities/configurations.py. The selector, exclusion and warming settings on the Orders page apply to the engine too.
| Variable | Default | Effect |
|---|---|---|
UNIFIED_BROKER_INTERFACE_API_ORDER_ENGINE_TIMEOUT_SECONDS |
5 |
How long a worker waits for the engine's answer |
UNIFIED_BROKER_INTERFACE_API_ORDER_ENGINE_RESULT_TTL_SECONDS |
300 |
How long an answer list is kept |
UNIFIED_BROKER_INTERFACE_API_ORDER_WORKERS_PER_BROKER |
10 |
Worker threads each broker's lane starts with, such as 10,zerodha=6 |
UNIFIED_BROKER_INTERFACE_API_ORDER_MAXIMUM_WORKERS_PER_BROKER |
30 |
The most workers one lane grows to |
UNIFIED_BROKER_INTERFACE_API_ORDER_DATABASE_CONNECTIONS |
8 |
PostgreSQL connections the workers share for the event table |
UNIFIED_BROKER_INTERFACE_API_ORDER_ENGINE_STALE_INTENT_SECONDS |
30 |
How far past its deadline an intent may be and still be placed |
UNIFIED_BROKER_INTERFACE_API_ORDER_ENGINE_RECONCILE_SECONDS |
5 |
How often the open legs are compared with the brokers' polled order books; 0 turns off all but the pass at start |
UNIFIED_BROKER_INTERFACE_API_ORDER_RATE_PER_SECOND |
0 (off) |
Messages in any one-second span across every broker |
UNIFIED_BROKER_INTERFACE_API_ORDER_RATE_PER_BROKER_PER_SECOND |
10,zerodha=5,indmoney=5 |
Messages in any one-second span to one broker: a default and broker=number overrides |
UNIFIED_BROKER_INTERFACE_API_ORDER_RATE_WAIT_SECONDS |
1 |
The longest a message waits for room in the budget |
UNIFIED_BROKER_INTERFACE_API_ORDER_RATE_WINDOW_SECONDS |
1 |
The span the rate limits are counted over; a little more than 1 leaves a margin for uneven arrival |
UNIFIED_BROKER_INTERFACE_API_ORDER_DAILY_LOSS_LIMIT |
0 (off) |
The most the day may lose, as a positive number |
UNIFIED_BROKER_INTERFACE_API_ORDER_REPRICE_MINIMUM_SECONDS |
1 |
The shortest gap between two moves of one resting leg |
UNIFIED_BROKER_INTERFACE_API_ORDER_DAILY_CAPS |
empty | Daily message caps, as broker=number,broker=number |
UNIFIED_BROKER_INTERFACE_API_ORDER_DAILY_CAP_EXIT_RESERVE |
0.05 |
The share of each cap kept for closing positions, from 0 up to but not including 1 |
SEBI's retail algorithmic trading framework treats more than ten orders a second as an algorithm that needs registration. The limit is kept per broker, so the default of 10 messages a second (5 for Zerodha and INDmoney) applies to each broker separately, and adding brokers raises what the system can send in total. The budget counts a sliding window rather than refilling a token bucket, because a bucket that holds ten and earns ten a second can send nineteen within one second; the window never lets an eleventh message into any one-second span. The window counts when each message is given room, and the request leaves a few milliseconds later, so a broker counting arrivals can occasionally see eleven within one second when one request is delayed more than its neighbour: the load test below saw that in two runs of six. Setting UNIFIED_BROKER_INTERFACE_API_ORDER_RATE_WINDOW_SECONDS a little above 1, such as 1.05, keeps a margin for that at about 9.5 messages a second.
Under the hood
- Files:
bin/unified/orders/order_engine,bin/unified/orders/virtual_book, andunified_broker_interface/utilities/order_engine/, whoseutilities/folder holds the runner, the handoff, the gates, the stores, the registry of the two classes the engine runs (simpleandplan) and the routing that runs every other type as a plan. - Classes:
IntentHandoffwrites an intent and waits;OrderIntentis the intent;OrderEngineis the loop;RiskGatesholds the limits;ParentStoreis the Redis cache;VirtualBookkeeps the queue estimates. - Offline checks:
python -m test_runs.order_engine_routesrecords the hand-over against a stubbed engine,python -m test_runs.order_routesruns the real engine behind the route for every broker,python -m test_runs.order_engineruns the daemon against scripted intents and stubbed brokers, andpython -m test_runs.virtual_queuechecks the queue estimate, andpython -m test_runs.order_engine_throughputmeasures the lanes under load.