fix(broker-agnostic-adapter): stop reporting unclassifiable order statuses as live

normalize_status defaulted every unmapped broker status to OrderStatus.PENDING,
which asserts the order is live and working. Hand-written maps miss real terminal
statuses, and the shipped maps covered only four each. Verified against broker
documentation, the following all resolved to PENDING on the previous code:

  Zerodha LAPSED        -> order expired unexecuted (terminal)
  IBKR    ApiCancelled  -> cancelled via API (terminal)
  Alpaca  done_for_day  -> finished for the session
  Alpaca  replaced      -> superseded by another order
  IBKR    "FILLED"      -> FILLED, missed because IBKR alone used a
                           case-sensitive lookup while the others upper-cased

A strategy reading any of these as PENDING waits forever on a dead order or
re-sends one that already resolved. Unmapped statuses now return a new
OrderStatus.UNKNOWN and log at ERROR; UNKNOWN is neither live nor terminal
(OrderResult.is_terminal is False), so callers must re-query or reconcile.

Status maps completed from published documentation: Kite Connect's ~13 lifecycle
states, Alpaca's 12+ FIX-derived states, and IBKR's eight orderStatus values.
Lookup is case-insensitive and whitespace-trimmed for all adapters.

Factory safety:
- The registry no longer ships simulated adapters bound to production broker
  names. create("zerodha") previously returned MockZerodhaAdapter, which
  fabricates fills, so a system resolving config["broker"] would trade against
  invented prices with no error anywhere. The registry now starts empty; call
  register_simulated_adapters() to opt in for offline work.
- register() rejects classes that do not implement BaseBrokerAdapter, so
  mis-wiring fails at startup rather than on the first order.
- Added reset() for test isolation, since the registry is process-wide state.

Request validation, hoisted to the base class so a new adapter cannot omit it:
- LIMIT orders without a price were accepted and returned a fabricated fill
  price of 100.50. `if request.price` also treated Decimal("0") as absent, so a
  zero limit price was silently replaced. Both now raise.
- MARKET orders carrying a price, STOP without stop_price, and STOP_LIMIT
  missing either are rejected.
- float quantities and prices are rejected rather than coerced. A float passed
  the old checks and propagated into OrderResult, defeating the module's stated
  Decimal guarantee and surfacing later as a TypeError against a Decimal. int is
  widened losslessly.
- Decimal("NaN") previously raised decimal.InvalidOperation straight through the
  adapter boundary, the exact leaky abstraction this skill warns against;
  non-finite values now raise OrderExecutionError.

OrderResult gained client_order_id, echoed by every adapter. Without it a caller
cannot correlate a response to its request, which retry-safe submission requires.
cancel_order's contract is documented as a cancellation request whose outcome
must be confirmed via get_order_status, not as a cancellation.

Documentation: status tables in standards.md now list each broker's documented
enumeration with sources rather than the subset the code mapped, and the Upstox
row is marked unverified rather than assumed from the Zerodha pattern.

Tests: 8 -> 36. The previous suite asserted the fail-open behaviour
(normalize_status("unknown_status") == PENDING).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UoqCTfNGdU91RQWxC3Jwyv
This commit is contained in:
Claude
2026-08-13 17:19:26 +00:00
parent b1b375e8b0
commit add49e977c
7 changed files with 997 additions and 182 deletions
+8 -8
View File
@@ -1,6 +1,6 @@
{
"version": "1.0.0",
"generated_at": "2026-08-13T17:06:53.595088+00:00",
"generated_at": "2026-08-13T17:18:16.212582+00:00",
"repository": "https://github.com/HimanshuJ16/Algo-Trading-Skills",
"domain": "algorithmic-trading",
"total_skills": 504,
@@ -1155,7 +1155,7 @@
},
{
"name": "broker-agnostic-adapter-interface",
"description": "Use when designing quantitative trading systems to decouple strategy logic from broker APIs using a unified abstract adapter interface, standardized order models, and pluggable broker factories. This institutional-grade implementation utilizes precise Decimal arithmetic for all currency calculations, robust typed exceptions, and normalized cross-venue status mapping.",
"description": "Use when designing quantitative trading systems to decouple strategy logic from broker APIs using a unified abstract adapter interface, standardized order models, and pluggable broker factories. Enforces Decimal arithmetic at the boundary, typed exceptions, and cross-venue status normalization that reports an unrecognized broker status as UNKNOWN rather than guessing the order is live.",
"domain": "algorithmic-trading",
"subdomain": "broker-integration",
"tags": [
@@ -1164,15 +1164,15 @@
"broker-agnostic",
"trading-architecture",
"order-routing",
"institutional-grade"
"status-normalization"
],
"brokers_frameworks": [
"Zerodha Kite",
"Alpaca",
"Interactive Brokers",
"Upstox"
"Zerodha Kite Connect",
"Alpaca Trading API",
"Interactive Brokers TWS API",
"Upstox API"
],
"version": "2.0.0",
"version": "3.0.0",
"author": "algo-trading-skills-contributors",
"license": "Apache-2.0",
"path": "skills/broker-agnostic-adapter-interface",
+106 -42
View File
@@ -2,9 +2,9 @@
name: broker-agnostic-adapter-interface
description: Use when designing quantitative trading systems to decouple strategy
logic from broker APIs using a unified abstract adapter interface, standardized
order models, and pluggable broker factories. This institutional-grade implementation
utilizes precise Decimal arithmetic for all currency calculations, robust typed
exceptions, and normalized cross-venue status mapping.
order models, and pluggable broker factories. Enforces Decimal arithmetic at the
boundary, typed exceptions, and cross-venue status normalization that reports an
unrecognized broker status as UNKNOWN rather than guessing the order is live.
domain: algorithmic-trading
subdomain: broker-integration
tags:
@@ -13,73 +13,137 @@ tags:
- broker-agnostic
- trading-architecture
- order-routing
- institutional-grade
- status-normalization
brokers_frameworks:
- Zerodha Kite
- Alpaca
- Interactive Brokers
- Upstox
version: "2.0.0"
- Zerodha Kite Connect
- Alpaca Trading API
- Interactive Brokers TWS API
- Upstox API
version: "3.0.0"
author: algo-trading-skills-contributors
license: Apache-2.0
---
## When to Use
Invoke this whenever building a trading platform or strategy engine intended to run across multiple brokers or exchange venues. Coupling strategy code directly to specific broker SDKs (e.g. Zerodha `kiteconnect`, Alpaca `alpaca-py`, or IBKR `ibapi`) creates codebase fragmentation, risks floating point inaccuracies, and prevents seamless venue migration. Designing an abstract broker adapter interface (`BaseBrokerAdapter`) with standardized data models (`OrderRequest`, `OrderResult`, `Position`, `AccountBalance`) using `Decimal` isolates strategy code from broker API drift and preserves financial precision.
Invoke this whenever building a trading platform or strategy engine intended to run
across multiple brokers or venues. Coupling strategy code directly to broker SDKs
(Zerodha `kiteconnect`, Alpaca `alpaca-py`, IBKR `ibapi`) fragments the codebase, leaks
float imprecision into prices, and makes venue migration a rewrite. An abstract
`BaseBrokerAdapter` with standardized models (`OrderRequest`, `OrderResult`, `Position`,
`AccountBalance`) in `Decimal` isolates strategy code from broker API drift.
The two things this layer must get right, because everything downstream trusts it: the
**status normalization** (does the strategy believe this order is live?) and the
**request validation** (does a malformed order reach the venue?).
## When NOT to Use
- **As a working broker client.** `scripts/broker_adapter.py` defines the contract and
ships *simulated* adapters that fabricate fills. It performs no network I/O, no auth,
no rate limiting. Real adapters wrap real SDKs behind this interface.
- **As a single-broker abstraction.** If you will only ever trade one venue, the
indirection costs more than it saves; use the SDK directly and keep the enum
normalization.
- **For anything the interface does not model.** Bracket/OCO orders, order modification,
multi-leg and options strategies, streaming order updates, and per-venue product types
(Zerodha's MIS/CNC/NRML) are outside `OrderRequest`. Extend the model deliberately
rather than smuggling them through a broker-specific side channel — that reintroduces
the coupling this skill exists to remove.
- **As a substitute for idempotency or auth handling** — see the Related Skills.
## Prerequisites
- Python `abc` (Abstract Base Classes) module for strict interface definition.
- Python `decimal.Decimal` for exact arithmetic on quantities and prices to prevent float truncation.
- Unified enum definitions for order sides (`BUY`, `SELL`), order types (`MARKET`, `LIMIT`, `STOP`), and order statuses (`PENDING`, `FILLED`, `REJECTED`, etc.).
- Robust custom exception hierarchy (`BrokerAdapterError`, `OrderExecutionError`, etc.).
- Factory pattern registry for dynamic broker adapter instantiation.
- Python `abc` for the interface and `decimal.Decimal` for all monetary values.
- Each broker's **documented status enumeration**, not the four statuses you happened to
see in testing. `references/standards.md` lists them with sources.
- A typed exception hierarchy (`BrokerAdapterError` and subclasses) that every adapter
maps its SDK errors into.
- A registry key per broker, supplied by configuration.
## Workflow
1. **Define Standardized Domain Data Models**:
- Create unified dataclasses for `OrderRequest`, `OrderResult`, `Position`, and `AccountBalance`.
- Ensure all price, quantity, and balance fields strictly utilize `Decimal`.
- Normalize broker-specific field names into standard attributes (e.g., standardizing `commissions` and `timestamps`).
1. **Model the domain in `Decimal`, and enforce it at the boundary.** `_to_decimal`
accepts `Decimal` and `int` (exact) and **rejects `float`** with an explanatory error.
A float that slips through survives every comparison and only fails much later, as a
`TypeError` the first time it meets a `Decimal` in arithmetic — far from the code that
introduced it.
2. **Define Abstract Base Adapter Class (`BaseBrokerAdapter`)**:
- Declare mandatory abstract methods `@abstractmethod`:
- `place_order(request: OrderRequest) -> OrderResult`
- `cancel_order(order_id: str) -> bool`
- `get_order_status(order_id: str) -> OrderResult`
- `get_positions() -> List[Position]`
- `get_account_balance() -> AccountBalance`
- `normalize_status(broker_status: str) -> OrderStatus`
2. **Validate the request on the base class, not in each adapter.** `_validate_request`
enforces symbol, enum types, finite positive quantity, and order-type/price
consistency: LIMIT and STOP_LIMIT require a positive price, STOP and STOP_LIMIT
require a positive stop price, and MARKET must **not** carry one. Putting it on the
base class means a newly written adapter cannot forget it.
3. **Implement Concrete Broker Adapters**:
- Subclass `BaseBrokerAdapter` for each broker (e.g., `MockZerodhaAdapter`, `MockAlpacaAdapter`, `MockIBKRAdapter`).
- Translate standard `OrderRequest` into broker-specific payloads.
- Implement the `normalize_status` mapping dictionary to translate string-based broker responses into standardized `OrderStatus` Enum values.
3. **Normalize status conservatively.** `normalize_status` upper-cases and looks up
`_STATUS_MAP`. **An unmapped status returns `OrderStatus.UNKNOWN` and logs at ERROR —
never `PENDING`.** Handle `UNKNOWN` by re-querying or reconciling; it is neither live
nor terminal, and `OrderResult.is_terminal` returns False for it. Treat its appearance
as a defect report: the broker has a status your map does not know.
4. **Register with Broker Factory**:
- Implement `BrokerAdapterFactory.register("broker_name", AdapterClass)` to enable runtime instantiation of broker adapters via configuration strings.
4. **Echo `client_order_id` on every `OrderResult`.** Without it the caller cannot
correlate a response to the request that produced it, and retry-safe submission is
impossible.
5. **Register adapters explicitly; the factory registry starts empty.** `create()` raises
until you register a real adapter. The simulated adapters are **not** bound to
production broker names by default — call `register_simulated_adapters()` to opt in
for offline work. `register()` rejects any class that is not a `BaseBrokerAdapter`, so
a bad wiring fails at startup rather than on the first order.
6. **Treat `cancel_order` as a request, not a cancellation.** A `True` return means the
broker accepted the cancellation *request*. The order can still fill in the race
window. Confirm with `get_order_status` before releasing risk budget or reusing the ID.
> Full step-by-step procedure with broker-specific detail: see `references/workflows.md`.
> Broker/framework coverage table for this skill: see `references/standards.md`.
> Documented status sets per broker, with sources: see `references/standards.md`.
> Printable pre-flight checklist: see `assets/checklist.md`.
## Common Pitfalls
- **Floating Point Truncation**: Using standard Python `float` instead of `Decimal` for currency pairs or crypto quantities. Always use exact precision.
- **Leaky Abstractions**: Allowing broker-specific exception types (like `kiteconnect.exceptions.NetworkException`) or raw JSON dictionary responses to leak past the adapter boundary into strategy logic.
- **Inconsistent Enum Normalization**: Leaving status strings like `"COMPLETE"` (Zerodha) vs `"filled"` (Alpaca) vs `"Submitted"` (IBKR) un-normalized.
- **Tight Coupling to Specific SDKs**: Hardcoding SDK dependencies inside strategy modules instead of injecting abstract `BaseBrokerAdapter` instances.
- **Defaulting an unrecognized status to PENDING.** This is the worst default available:
it asserts the order is live and working. Real terminal statuses get missed by
hand-written maps — Zerodha's `LAPSED`, IBKR's `ApiCancelled`, Alpaca's `done_for_day`
and `replaced` are all finished, and calling any of them PENDING leaves the strategy
waiting on a dead order or re-sending one that already resolved.
- **Case-sensitive status lookup.** IBKR returns mixed-case strings (`PreSubmitted`,
`ApiCancelled`). A lookup that matches `"Filled"` but not `"FILLED"` sends the variant
to the default branch — a filled order reported as still working.
- **Mapping only the statuses you saw in testing.** Kite documents roughly a dozen order
states and Alpaca a dozen more; the four obvious ones are not the contract.
- **Binding simulated adapters to production broker names.** A factory that resolves
`config["broker"]` to a mock reports every order FILLED at an invented price, with no
error anywhere.
- **Falsy-checking a price.** `if request.price` treats `Decimal("0")` as absent, so a
zero limit price gets silently replaced by a default instead of rejected.
- **Floating point at the boundary.** `float` cannot represent ordinary decimal prices
and tick sizes exactly; construct `Decimal(str(value))` where the value enters.
- **Leaky abstractions.** Broker SDK exceptions, raw JSON, and — easy to miss —
`decimal.InvalidOperation` from a `NaN` comparison must all be wrapped into
`BrokerAdapterError` subclasses before crossing the adapter boundary.
- **Treating a cancel acknowledgement as a cancellation.**
- **Mutating the shared registry from library code.** It is process-wide class state; use
`reset()` for test isolation.
## Verification
- Instantiate multiple concrete adapters (`MockZerodhaAdapter`, `MockAlpacaAdapter`, `MockIBKRAdapter`) via `BrokerAdapterFactory`.
- Execute `place_order()` across different adapters and verify responses return uniform `OrderResult` objects with `Decimal` metrics.
- Verify status normalization accurately maps diverse broker-specific status codes to standard `OrderStatus`.
- Run unit test suite `python -m unittest test_broker_adapter.py` and confirm 100% pass rate.
- Run the unit suite and confirm every test passes:
`python -m unittest discover -s skills/broker-agnostic-adapter-interface/scripts`
- For each adapter, assert an invented status string returns `UNKNOWN`, not `PENDING`.
This is the highest-value single assertion in the suite.
- Assert every status in the broker's **documented** enumeration maps to something, and
that terminal broker states map to terminal `OrderStatus` values.
- Assert case variants (`"Filled"`, `"FILLED"`, `"filled"`) normalize identically.
- Assert a `float` quantity or price is rejected, and that an `int` widens to `Decimal`
losslessly.
- Assert `create()` raises on an empty registry, and that `register()` refuses a class
that does not implement `BaseBrokerAdapter`.
- Place orders through every adapter and confirm `filled_quantity`, `average_price` and
`commission` are all `Decimal` instances.
## Related Skills
- `order-placement-idempotency`
- `broker-api-idempotent-cancel-requests`
- `headless-broker-auth-patterns`
- `multi-broker-rate-limit-handling`
@@ -11,11 +11,22 @@ Use this checklist during architecture reviews or PR approvals when introducing
## Base Interface & Implementation
- [ ] Does the new adapter inherit cleanly from `BaseBrokerAdapter`?
- [ ] Are all `@abstractmethod` implementations present and correctly signature-typed?
- [ ] Does the `normalize_status()` method exhaustively cover the broker's native string statuses?
- [ ] Is error handling wrapped in the standardized exception hierarchy (e.g., `OrderExecutionError`, `NetworkError`) rather than leaking `requests` or `SDK` exceptions?
- [ ] Does `place_order()` call `self._validate_request(request)` **before** touching the SDK?
- [ ] Does `OrderResult` echo the request's `client_order_id`?
- [ ] Is error handling wrapped in the standardized exception hierarchy (e.g., `OrderExecutionError`, `NetworkError`) rather than leaking `requests` or `SDK` exceptions — including `decimal.InvalidOperation` from non-finite values?
- [ ] Is `cancel_order()` documented and treated as a cancellation **request**, with the outcome confirmed via `get_order_status()` before the order is considered dead?
## Status Normalization
- [ ] Is `_STATUS_MAP` built from the broker's **documented** status enumeration (see `references/standards.md`), not from statuses observed during testing?
- [ ] Does an unrecognized status return `OrderStatus.UNKNOWN` — **never** `PENDING` — and log at ERROR?
- [ ] Are the broker's terminal states mapped to terminal `OrderStatus` values? Check the easily-missed ones: Kite `LAPSED`, IBKR `ApiCancelled`, Alpaca `done_for_day` and `replaced`.
- [ ] Do case variants (`"Filled"`, `"FILLED"`, `"filled"`) normalize identically?
- [ ] Is `UNKNOWN` handled downstream by re-query/reconcile rather than being treated as live or terminal?
## Factory Registry & Tests
- [ ] Is the new adapter successfully registered in `BrokerAdapterFactory` using a lowercase key?
- [ ] Do unit tests directly invoke the factory using `BrokerAdapterFactory.create('broker_name')`?
- [ ] Are order placement edge cases (like zero or negative quantity) validated and rejected gracefully?
- [ ] Do 100% of unit tests pass under `python -m unittest test_broker_adapter.py`?
- [ ] Is the registry free of simulated adapters in any process with live market access? (`register_simulated_adapters()` must never be called there.)
- [ ] Does `register()` reject classes that do not implement `BaseBrokerAdapter`, so mis-wiring fails at startup rather than on the first order?
- [ ] Do tests call `BrokerAdapterFactory.reset()` between cases, given the registry is process-wide class state?
- [ ] Are order placement edge cases validated and rejected: zero/negative/NaN/Infinity quantity, `float` quantity or price, LIMIT without price, `Decimal("0")` limit price, MARKET carrying a price, STOP without stop price?
- [ ] Do 100% of unit tests pass under `python -m unittest discover -s skills/broker-agnostic-adapter-interface/scripts`?
@@ -1,19 +1,116 @@
# Broker & Framework Coverage — broker-agnostic-adapter-interface
| Broker SDK / API | Original Status Code | Standard Normalized Status |
## How to read the status tables
These are each broker's **documented** status enumerations, not the subset a given
implementation happens to map. That distinction is the point: a map covering only the
four obvious statuses will meet the others in production, and what it does then decides
whether a dead order looks alive.
Broker status sets change. Re-check against the linked documentation before trusting a
mapping, and let `OrderStatus.UNKNOWN` tell you when a new one appears.
## 1. Zerodha Kite Connect
Documented lifecycle statuses. An order passes through several interim states — often too
fast to observe — before reaching a final one.
| Kite status | Normalized | Terminal? |
|---|---|---|
| Zerodha Kite Connect | `"COMPLETE"`, `"REJECTED"`, `"OPEN"`, `"CANCELLED"` | `OrderStatus.FILLED`, `REJECTED`, `PENDING`, `CANCELLED` |
| Alpaca Trading API | `"filled"`, `"rejected"`, `"new"`, `"canceled"`, `"expired"` | `OrderStatus.FILLED`, `REJECTED`, `PENDING`, `CANCELLED`, `EXPIRED` |
| IBKR TWS API | `"Submitted"`, `"Filled"`, `"Cancelled"`, `"Inactive"` | `OrderStatus.PENDING`, `FILLED`, `CANCELLED`, `REJECTED` |
| Upstox API | `"complete"`, `"rejected"`, `"open"`, `"cancelled"` | `OrderStatus.FILLED`, `REJECTED`, `PENDING`, `CANCELLED` |
| `COMPLETE` | `FILLED` | yes |
| `REJECTED` | `REJECTED` | yes |
| `CANCELLED` | `CANCELLED` | yes |
| `LAPSED` | `EXPIRED` | yes — order expired without executing |
| `OPEN` | `PENDING` | no |
| `OPEN PENDING`, `VALIDATION PENDING`, `PUT ORDER REQ RECEIVED` | `PENDING` | no |
| `MODIFIED`, `MODIFY PENDING`, `MODIFY VALIDATION PENDING` | `PENDING` | no |
| `CANCEL PENDING` | `PENDING` | no |
| `TRIGGER PENDING` | `PENDING` | no — stop-loss order awaiting its trigger |
| `AMO REQ RECEIVED` | `PENDING` | no — after-market order accepted |
Sources: [Kite Connect v3 — Orders](https://kite.trade/docs/connect/v3/orders/),
[Kite Connect forum — list of order status](https://kite.trade/forum/discussion/516/list-of-order-status).
> `LAPSED` is the trap: it is terminal, it is not obvious, and a map missing it will
> report an expired order as working.
## 2. Alpaca Trading API
Alpaca's statuses mirror FIX order states.
| Alpaca status | Normalized | Notes |
|---|---|---|
| `filled` | `FILLED` | terminal, no further updates |
| `partially_filled` | `PARTIALLY_FILLED` | |
| `canceled` | `CANCELLED` | user request or time-in-force expiry |
| `expired` | `EXPIRED` | terminal |
| `rejected` | `REJECTED` | terminal |
| `done_for_day` | `EXPIRED` | finished executing for the session; no further updates today |
| `replaced` | `CANCELLED` | superseded by another order or a corporate action |
| `new` | `PENDING` | usual initial state — received and routed |
| `pending_new`, `accepted` | `PENDING` | rare interim states |
| `pending_cancel`, `pending_replace` | `PENDING` | awaiting cancel/replace |
| `suspended` | `PENDING` | not currently eligible for trading |
| `stopped`, `calculated`, `accepted_for_bidding` | `PENDING` | rare |
Source: [Alpaca — Placing Orders / order lifecycle](https://docs.alpaca.markets/us/docs/orders-at-alpaca).
> `done_for_day` and `replaced` both mean "stop waiting for this order", and both are
> easy to omit.
## 3. Interactive Brokers TWS API
`orderStatus` returns mixed-case strings — normalize case before lookup.
| IBKR status | Normalized | Meaning |
|---|---|---|
| `Filled` | `FILLED` | |
| `Cancelled` | `CANCELLED` | |
| `ApiCancelled` | `CANCELLED` | cancelled via an API client before acknowledgement |
| `Inactive` | `REJECTED` | order is not working; commonly rejected or invalid |
| `Submitted` | `PENDING` | accepted at the destination and working |
| `PreSubmitted` | `PENDING` | held by IB until a trigger condition; uncommonly seen |
| `PendingSubmit` | `PENDING` | sent from TWS, destination has not confirmed receipt |
| `PendingCancel` | `PENDING` | cancel request sent, not yet confirmed |
Sources: [TWS API — Placing Orders](https://interactivebrokers.github.io/tws-api/order_submission.html),
[TWS API — Order status](https://interactivebrokers.github.io/tws-api/orders.html).
> `Inactive` is genuinely ambiguous at IBKR — it covers rejection *and* orders halted by a
> system condition. Mapping it to `REJECTED` is the conservative reading (treat the order
> as not working); confirm against the accompanying error text before acting.
## 4. Upstox
Upstox's order statuses (`complete`, `rejected`, `open`, `cancelled`, and interim states)
follow a similar pattern to Kite's. **The full enumeration has not been verified against
Upstox's current API documentation for this skill** — no adapter is shipped for it, and
the mapping should be built from their live docs rather than assumed from the Zerodha
table.
## Precision Standard
All quantitative financial logic *must* use `decimal.Decimal` rather than standard floating-point numbers. Standard float precision loss is unacceptable in production environments when rounding to tick sizes or summing large volume orders.
All monetary and quantity values use `decimal.Decimal`. `float` is **rejected** at the
adapter boundary rather than coerced: binary floating point cannot represent ordinary
decimal prices and tick sizes exactly, and a float that passes validation propagates into
`OrderResult` and surfaces only later as a `TypeError` against a `Decimal`. `int` is
accepted and widened, since it is exact.
`Decimal("NaN")` and `Decimal("Infinity")` are rejected explicitly — comparing them raises
`decimal.InvalidOperation`, which would otherwise escape the adapter boundary as a raw
stdlib exception.
## Category
`broker-integration` — see the top-level `mappings/` directory for how this category rolls up across the full skill library.
`broker-integration` — see the top-level `mappings/` directory for how this category rolls
up across the full skill library.
## Regulatory & Operational Notes
## Operational Notes
Intersects with software architecture decoupling principles, multi-venue execution routing, latency minimization, and financial software design standards. Use unified logging and robust exception typing (e.g. `BrokerAdapterError`) to guarantee operational resilience.
- Every adapter must wrap SDK and transport exceptions into `BrokerAdapterError`
subclasses (`NetworkError`, `AuthenticationError`, `OrderExecutionError`) so no
broker-specific type escapes the boundary.
- `cancel_order` returning True means the *request* was accepted, not that the order is
cancelled. Confirm via `get_order_status`.
- The factory registry is process-wide class state and starts empty by design; simulated
adapters must be opted into explicitly.
@@ -16,20 +16,62 @@ This file holds the full institutional-grade technical procedure referenced by `
- Require `@abstractmethod` implementations for `place_order`, `cancel_order`, `get_order_status`, `get_positions`, `get_account_balance`, and `normalize_status`.
3. **Develop Concrete Broker Adapters:**
- Create subclass implementations for each targeted broker (`MockZerodhaAdapter`, `MockAlpacaAdapter`, `MockIBKRAdapter`).
- Internally define `_STATUS_MAP` dictionaries on each class to handle raw broker strings.
- Validate incoming models (e.g. `request.quantity <= 0` raises `OrderExecutionError`).
- Map domain `OrderRequest` into broker SDK parameters and reliably unpack broker responses back into standard `OrderResult`.
- Create subclass implementations for each targeted broker (`MockZerodhaAdapter`,
`MockAlpacaAdapter`, `MockIBKRAdapter` are *simulated* references, not clients).
- Define `_STATUS_MAP` on each class, keyed **upper-case**; the base class upper-cases
before lookup so matching is case-insensitive. IBKR returns mixed-case strings
(`PreSubmitted`, `ApiCancelled`), and a case-sensitive lookup would resolve `"Filled"`
while sending `"FILLED"` to the unmapped branch.
- Populate the map from the broker's **documented** enumeration (see
`references/standards.md`), not from the statuses observed during testing.
- Call `self._validate_request(request)` first in `place_order`. Validation lives on the
base class precisely so a new adapter cannot omit it.
- Echo `request.client_order_id` onto the returned `OrderResult` so callers can
correlate responses with requests.
- Map domain `OrderRequest` into broker SDK parameters and unpack broker responses back
into a standard `OrderResult`.
4. **Register with Broker Factory:**
- Add adapter classes to `BrokerAdapterFactory` using class methods.
- Enforce case-insensitive registration (`name.lower()`) for seamless config-based injection.
- The registry **starts empty**. Register your real adapter explicitly; call
`register_simulated_adapters()` only for offline work.
- `register()` rejects non-`BaseBrokerAdapter` classes, so a mis-wired registration
fails at startup rather than on the first order.
- Registration is case-insensitive for config-based injection.
- The registry is process-wide class state — use `reset()` for test isolation.
## Status Normalization Policy
An unmapped status resolves to `OrderStatus.UNKNOWN` and logs at ERROR. It must **not**
fall back to `PENDING`.
- `PENDING` asserts the order is live and working. Applied to an unclassified status, that
is an active lie: the strategy waits on an order that is finished, or re-sends one that
already resolved.
- `UNKNOWN` is neither live nor terminal — `OrderResult.is_terminal` is False for it — so
the correct handling is to re-query or reconcile, never to act.
- Its appearance is a defect report. Confirm the status's meaning against the broker's
documentation, then add it to `_STATUS_MAP`.
## Failure Modes Observed in Production
- **Precision Loss / Rounding Errors:** Passing standard floats into exchange APIs which expect strict tick sizes, leading to order rejection. Use `Decimal`.
- **Leaky Abstraction Leaks:** Permitting broker SDK exceptions (like `requests.exceptions.Timeout` underlying an SDK) to spill past the adapter. The adapter should catch these and raise a standardized `NetworkError`.
- **Un-Normalized Enum Mapping:** Failing to translate broker status codes (`"COMPLETE"`, `"Inactive"`) into unified `OrderStatus` values, which breaks universal strategy reconciliation loops.
- **Unknown-Status Fallback:** Defaulting an unrecognized broker status to `PENDING`.
Terminal states a hand-written map misses — Kite `LAPSED`, IBKR `ApiCancelled`, Alpaca
`done_for_day` / `replaced` — are then reported as working orders.
- **Case-Sensitive Status Lookup:** Matching `"Filled"` but not `"FILLED"`, so a filled
order reads as still pending.
- **Precision Loss / Rounding Errors:** Passing standard floats into exchange APIs which
expect strict tick sizes, leading to order rejection. Reject `float` at the boundary
rather than coercing it.
- **Falsy Price Checks:** `if request.price` treats `Decimal("0")` as absent, so a zero
limit price is silently replaced by a default instead of rejected.
- **Leaky Abstraction Leaks:** Permitting broker SDK exceptions (like
`requests.exceptions.Timeout` underlying an SDK) to spill past the adapter — and, easily
missed, `decimal.InvalidOperation` raised by comparing a `NaN` quantity. Catch these and
raise a standardized `NetworkError` / `OrderExecutionError`.
- **Simulated Adapters Under Production Names:** A factory that resolves `config["broker"]`
to a mock reports every order FILLED at an invented price, silently.
- **Cancel Acknowledgement Mistaken For Cancellation:** `cancel_order` returning True means
the request was accepted; the order can still fill before the venue acts on it.
## Production Implementation Reference
@@ -1,12 +1,44 @@
"""
broker-agnostic-adapter-interface:
A unified adapter contract that decouples strategy logic from broker SDKs —
standard order models in ``Decimal``, a typed exception hierarchy, normalized
cross-venue status mapping, and a factory registry.
Two design decisions here exist because the obvious alternatives lose money:
1. **An unrecognised broker status becomes ``OrderStatus.UNKNOWN``, never
``PENDING``.** Defaulting an unmapped string to PENDING tells the strategy
"this order is live and working", which is the single most dangerous thing
you can say about an order you cannot classify. Real brokers ship statuses
that a hand-written map will miss — Zerodha's ``LAPSED``, IBKR's
``ApiCancelled``, Alpaca's ``done_for_day`` are all terminal, and reporting
any of them as PENDING leaves the strategy waiting forever on a dead order
or re-sending one it thinks never went through. ``UNKNOWN`` forces the
caller to re-query or reconcile instead of guessing.
2. **The simulated adapters are not registered under production broker names
by default.** ``BrokerAdapterFactory.create("zerodha")`` raises until a real
adapter is registered. The simulated adapters fabricate fills; binding them
to the name a config file will supply is how a paper implementation reaches
production and reports every order FILLED at an invented price. Opt in
explicitly with ``BrokerAdapterFactory.register_simulated_adapters()``.
Scope: this module defines the *contract* and a set of simulated adapters that
demonstrate it. It performs no network I/O. Real adapters must wrap their SDK's
exceptions into this module's hierarchy so nothing broker-specific escapes the
boundary.
See ``references/standards.md`` for each broker's documented status set and the
sources behind the mappings below.
"""
import logging
import time
import uuid
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime, timezone
from decimal import Decimal
from decimal import Decimal, InvalidOperation
from enum import Enum
from typing import Any, Dict, List, Optional, Type
from typing import Dict, List, Optional, Type, Union
logger = logging.getLogger(__name__)
@@ -17,7 +49,7 @@ class BrokerAdapterError(Exception):
class OrderExecutionError(BrokerAdapterError):
"""Raised when an order placement fails."""
"""Raised when an order placement fails or the request is invalid."""
class AuthenticationError(BrokerAdapterError):
@@ -28,6 +60,10 @@ class NetworkError(BrokerAdapterError):
"""Raised when network issues occur while communicating with the broker API."""
class AdapterRegistrationError(BrokerAdapterError):
"""Raised when a broker adapter cannot be registered or resolved."""
# --- Enums ---
class OrderSide(str, Enum):
BUY = "BUY"
@@ -42,12 +78,66 @@ class OrderType(str, Enum):
class OrderStatus(str, Enum):
"""
Normalized order status across venues.
``UNKNOWN`` is not a broker state — it is this layer admitting it could not
classify one. Treat it as "re-query or reconcile before acting". Never treat
it as live and never treat it as terminal.
"""
PENDING = "PENDING"
FILLED = "FILLED"
PARTIALLY_FILLED = "PARTIALLY_FILLED"
REJECTED = "REJECTED"
CANCELLED = "CANCELLED"
EXPIRED = "EXPIRED"
UNKNOWN = "UNKNOWN"
# Statuses after which no further updates are expected. UNKNOWN is deliberately
# excluded: an unclassifiable order is not known to be finished.
TERMINAL_STATUSES = frozenset({
OrderStatus.FILLED, OrderStatus.REJECTED,
OrderStatus.CANCELLED, OrderStatus.EXPIRED,
})
# Quantities and prices accept Decimal or int. int is exact, so it is widened
# losslessly; float is refused because binary floating point cannot represent
# ordinary decimal prices exactly, which is the whole reason this module exists.
Numeric = Union[Decimal, int]
def _to_decimal(name: str, value: object, *, allow_none: bool = False) -> Optional[Decimal]:
"""
Coerce a quantity/price to Decimal, refusing float and non-finite values.
Accepting a float here would silently defeat the module's precision
guarantee: it survives the comparison checks, propagates into OrderResult,
and only surfaces later as a TypeError the first time it meets a Decimal in
arithmetic — usually far from the code that introduced it.
"""
if value is None:
if allow_none:
return None
raise OrderExecutionError(f"{name} is required and must not be None.")
if isinstance(value, bool):
raise OrderExecutionError(f"{name} must be a Decimal, got bool {value!r}")
if isinstance(value, float):
raise OrderExecutionError(
f"{name} must be a Decimal, got float {value!r}. Binary floats cannot represent "
f"decimal prices and tick sizes exactly; construct it as Decimal(str(value)) at "
f"the boundary instead of letting the imprecision through."
)
if isinstance(value, int):
value = Decimal(value)
if not isinstance(value, Decimal):
raise OrderExecutionError(f"{name} must be a Decimal, got {type(value).__name__}")
# NaN and Infinity raise InvalidOperation on comparison rather than returning
# False, so they must be rejected before any threshold test.
if not value.is_finite():
raise OrderExecutionError(f"{name} must be finite, got {value!r}")
return value
# --- Data Models ---
@@ -56,14 +146,21 @@ class OrderRequest:
symbol: str
side: OrderSide
order_type: OrderType
quantity: Decimal
price: Optional[Decimal] = None
stop_price: Optional[Decimal] = None
quantity: Numeric
price: Optional[Numeric] = None
stop_price: Optional[Numeric] = None
client_order_id: str = field(default_factory=lambda: uuid.uuid4().hex)
@dataclass
class OrderResult:
"""
Normalized outcome of an order operation.
``client_order_id`` echoes the request's ID. Without it the caller cannot
correlate a response to the request that produced it, which makes retry-safe
submission impossible — see the ``order-placement-idempotency`` skill.
"""
order_id: str
broker_name: str
status: OrderStatus
@@ -72,6 +169,12 @@ class OrderResult:
message: str = ""
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
commission: Decimal = Decimal("0.0")
client_order_id: str = ""
@property
def is_terminal(self) -> bool:
"""True when no further updates are expected. UNKNOWN is never terminal."""
return self.status in TERMINAL_STATUSES
@dataclass
@@ -96,76 +199,190 @@ class AccountBalance:
class BaseBrokerAdapter(ABC):
"""Abstract interface defining the contract for all broker execution adapters."""
#: Broker-native status string -> normalized OrderStatus. Subclasses override.
#: Keys are matched case-insensitively; see ``normalize_status``.
_STATUS_MAP: Dict[str, OrderStatus] = {}
@property
@abstractmethod
def broker_name(self) -> str:
"""Return the unique identifier for this broker."""
pass
@abstractmethod
def place_order(self, request: OrderRequest) -> OrderResult:
"""Place an order and return the normalized execution result."""
pass
"""
Place an order and return the normalized execution result.
Implementations must call ``self._validate_request(request)`` first and
must wrap SDK exceptions in this module's hierarchy.
"""
@abstractmethod
def cancel_order(self, order_id: str) -> bool:
"""Cancel an existing order by its broker-assigned order_id."""
pass
"""
**Request** cancellation of an order.
Returns True when the broker *accepted the cancellation request*, which
is not the same as the order being cancelled. Most venues answer a cancel
request asynchronously and may refuse it — an order can fill in the race
window between the request and its acknowledgement. Confirm the outcome
with ``get_order_status`` before treating the order as dead, and never
release its risk budget on a True return alone.
"""
@abstractmethod
def get_order_status(self, order_id: str) -> OrderResult:
"""Query the latest status of an order."""
pass
@abstractmethod
def get_positions(self) -> List[Position]:
"""Fetch all current open positions."""
pass
@abstractmethod
def get_account_balance(self) -> AccountBalance:
"""Fetch real-time account balances and margin utilization."""
pass
@abstractmethod
def normalize_status(self, broker_status: str) -> OrderStatus:
"""Map a broker-specific status string to the standardized OrderStatus enum."""
pass
"""
Map a broker-native status string to the standardized enum.
An unmapped status returns ``OrderStatus.UNKNOWN`` and logs at ERROR. It
deliberately does **not** fall back to PENDING: brokers add and rename
statuses, and silently calling an unclassified order "live" is how a
terminal state (Zerodha ``LAPSED``, IBKR ``ApiCancelled``, Alpaca
``done_for_day``) gets mistaken for a working order.
Override only to change the lookup strategy; extend ``_STATUS_MAP`` to
add statuses.
"""
if not isinstance(broker_status, str):
logger.error(
f"{self.broker_name}: non-string status {broker_status!r}; returning UNKNOWN."
)
return OrderStatus.UNKNOWN
mapped = self._STATUS_MAP.get(broker_status.strip().upper())
if mapped is None:
logger.error(
f"{self.broker_name}: unmapped order status {broker_status!r}. Returning UNKNOWN "
f"— re-query or reconcile this order; do not assume it is working. Add the "
f"status to {type(self).__name__}._STATUS_MAP once its meaning is confirmed."
)
return OrderStatus.UNKNOWN
return mapped
def _validate_request(self, request: OrderRequest) -> OrderRequest:
"""
Validate and normalize an order request before it reaches the broker.
Lives on the base class so a new adapter cannot forget it. Returns a
request whose numeric fields are guaranteed Decimal and finite, so
implementations can use them without re-checking.
Raises:
OrderExecutionError: the request would be rejected on protocol or
sanity grounds. Raised rather than returned so a malformed order
can never be silently reshaped into a valid-looking one.
"""
if not isinstance(request, OrderRequest):
raise OrderExecutionError(
f"request must be an OrderRequest, got {type(request).__name__}"
)
if not isinstance(request.symbol, str) or not request.symbol.strip():
raise OrderExecutionError("Order symbol must be a non-empty string.")
if not isinstance(request.side, OrderSide):
raise OrderExecutionError(f"side must be an OrderSide, got {request.side!r}")
if not isinstance(request.order_type, OrderType):
raise OrderExecutionError(
f"order_type must be an OrderType, got {request.order_type!r}"
)
try:
quantity = _to_decimal("quantity", request.quantity)
price = _to_decimal("price", request.price, allow_none=True)
stop_price = _to_decimal("stop_price", request.stop_price, allow_none=True)
except InvalidOperation as exc: # pragma: no cover - defensive
raise OrderExecutionError(f"Invalid decimal value in order request: {exc}") from exc
if quantity <= Decimal("0"):
raise OrderExecutionError("Quantity must be greater than zero.")
needs_price = request.order_type in (OrderType.LIMIT, OrderType.STOP_LIMIT)
needs_stop = request.order_type in (OrderType.STOP, OrderType.STOP_LIMIT)
if needs_price:
# `if request.price` would treat Decimal("0") as absent and silently
# substitute a default, turning a bad limit price into a plausible one.
if price is None:
raise OrderExecutionError(
f"{request.order_type.value} orders require an explicit price."
)
if price <= Decimal("0"):
raise OrderExecutionError(f"Limit price must be positive, got {price}.")
elif request.order_type is OrderType.MARKET and price is not None:
raise OrderExecutionError(
"MARKET orders must not carry a price; supplying one hides the caller's intent."
)
if needs_stop:
if stop_price is None:
raise OrderExecutionError(
f"{request.order_type.value} orders require an explicit stop_price."
)
if stop_price <= Decimal("0"):
raise OrderExecutionError(f"Stop price must be positive, got {stop_price}.")
request.quantity = quantity
request.price = price
request.stop_price = stop_price
return request
# --- Concrete Implementations ---
# --- Simulated Implementations ---
# These fabricate responses. They demonstrate the contract and back the test
# suite; they are NOT registered under production broker names by default.
class MockZerodhaAdapter(BaseBrokerAdapter):
"""Zerodha Kite Connect adapter implementation."""
"""
Simulated Zerodha Kite Connect adapter.
Status set per Kite Connect's documented order lifecycle. Orders traverse
several interim states before reaching a final one; ``LAPSED`` is terminal
(the order expired without executing) and must not be read as still working.
"""
_STATUS_MAP = {
"COMPLETE": OrderStatus.FILLED,
"REJECTED": OrderStatus.REJECTED,
"OPEN": OrderStatus.PENDING,
"CANCELLED": OrderStatus.CANCELLED,
"LAPSED": OrderStatus.EXPIRED,
"OPEN": OrderStatus.PENDING,
"OPEN PENDING": OrderStatus.PENDING,
"VALIDATION PENDING": OrderStatus.PENDING,
"PUT ORDER REQ RECEIVED": OrderStatus.PENDING,
"TRIGGER PENDING": OrderStatus.PENDING,
"MODIFIED": OrderStatus.PENDING,
"MODIFY PENDING": OrderStatus.PENDING,
"MODIFY VALIDATION PENDING": OrderStatus.PENDING,
"CANCEL PENDING": OrderStatus.PENDING,
"AMO REQ RECEIVED": OrderStatus.PENDING,
}
@property
def broker_name(self) -> str:
return "zerodha"
def normalize_status(self, broker_status: str) -> OrderStatus:
return self._STATUS_MAP.get(broker_status.upper(), OrderStatus.PENDING)
def place_order(self, request: OrderRequest) -> OrderResult:
if request.quantity <= Decimal("0"):
raise OrderExecutionError("Quantity must be greater than zero.")
# Simulate network logic & mock response
request = self._validate_request(request)
broker_status = "COMPLETE"
order_id = f"Z_{int(time.time()*1000)}"
order_id = f"Z_{int(time.time() * 1000)}"
return OrderResult(
order_id=order_id,
broker_name=self.broker_name,
status=self.normalize_status(broker_status),
filled_quantity=request.quantity,
average_price=request.price if request.price else Decimal("100.50"),
message="Order executed via Zerodha Kite Connect",
commission=Decimal("20.0") # typical fixed flat fee in INR
average_price=request.price if request.price is not None else Decimal("100.50"),
message="Simulated fill — Zerodha Kite Connect",
commission=Decimal("20.0"),
client_order_id=request.client_order_id,
)
def cancel_order(self, order_id: str) -> bool:
@@ -177,11 +394,12 @@ class MockZerodhaAdapter(BaseBrokerAdapter):
broker_name=self.broker_name,
status=OrderStatus.FILLED,
filled_quantity=Decimal("0"),
average_price=Decimal("0.0")
average_price=Decimal("0.0"),
)
def get_positions(self) -> List[Position]:
return [Position(symbol="INFY", quantity=Decimal("50"), average_price=Decimal("1500.00"), unrealized_pnl=Decimal("250.00"))]
return [Position(symbol="INFY", quantity=Decimal("50"),
average_price=Decimal("1500.00"), unrealized_pnl=Decimal("250.00"))]
def get_account_balance(self) -> AccountBalance:
return AccountBalance(
@@ -189,43 +407,50 @@ class MockZerodhaAdapter(BaseBrokerAdapter):
margin_used=Decimal("20000.00"),
total_equity=Decimal("120000.00"),
currency="INR",
buying_power=Decimal("500000.00")
buying_power=Decimal("500000.00"),
)
class MockAlpacaAdapter(BaseBrokerAdapter):
"""Alpaca Trading API adapter implementation."""
"""Simulated Alpaca Trading API adapter, mapping Alpaca's documented statuses."""
_STATUS_MAP = {
"filled": OrderStatus.FILLED,
"rejected": OrderStatus.REJECTED,
"new": OrderStatus.PENDING,
"canceled": OrderStatus.CANCELLED,
"partially_filled": OrderStatus.PARTIALLY_FILLED,
"expired": OrderStatus.EXPIRED,
"FILLED": OrderStatus.FILLED,
"PARTIALLY_FILLED": OrderStatus.PARTIALLY_FILLED,
"REJECTED": OrderStatus.REJECTED,
"CANCELED": OrderStatus.CANCELLED,
"EXPIRED": OrderStatus.EXPIRED,
"REPLACED": OrderStatus.CANCELLED,
# done_for_day: finished for the session, no further updates today.
"DONE_FOR_DAY": OrderStatus.EXPIRED,
"NEW": OrderStatus.PENDING,
"PENDING_NEW": OrderStatus.PENDING,
"ACCEPTED": OrderStatus.PENDING,
"PENDING_CANCEL": OrderStatus.PENDING,
"PENDING_REPLACE": OrderStatus.PENDING,
"SUSPENDED": OrderStatus.PENDING,
"STOPPED": OrderStatus.PENDING,
"CALCULATED": OrderStatus.PENDING,
"ACCEPTED_FOR_BIDDING": OrderStatus.PENDING,
}
@property
def broker_name(self) -> str:
return "alpaca"
def normalize_status(self, broker_status: str) -> OrderStatus:
return self._STATUS_MAP.get(broker_status.lower(), OrderStatus.PENDING)
def place_order(self, request: OrderRequest) -> OrderResult:
if request.quantity <= Decimal("0"):
raise OrderExecutionError("Quantity must be greater than zero.")
request = self._validate_request(request)
broker_status = "filled"
order_id = f"ALP_{int(time.time()*1000)}"
order_id = f"ALP_{int(time.time() * 1000)}"
return OrderResult(
order_id=order_id,
broker_name=self.broker_name,
status=self.normalize_status(broker_status),
filled_quantity=request.quantity,
average_price=request.price if request.price else Decimal("250.00"),
message="Order executed via Alpaca Trading API",
commission=Decimal("0.0")
average_price=request.price if request.price is not None else Decimal("250.00"),
message="Simulated fill — Alpaca Trading API",
commission=Decimal("0.0"),
client_order_id=request.client_order_id,
)
def cancel_order(self, order_id: str) -> bool:
@@ -237,11 +462,12 @@ class MockAlpacaAdapter(BaseBrokerAdapter):
broker_name=self.broker_name,
status=OrderStatus.FILLED,
filled_quantity=Decimal("0"),
average_price=Decimal("0.0")
average_price=Decimal("0.0"),
)
def get_positions(self) -> List[Position]:
return [Position(symbol="AAPL", quantity=Decimal("10"), average_price=Decimal("180.00"), unrealized_pnl=Decimal("50.00"))]
return [Position(symbol="AAPL", quantity=Decimal("10"),
average_price=Decimal("180.00"), unrealized_pnl=Decimal("50.00"))]
def get_account_balance(self) -> AccountBalance:
return AccountBalance(
@@ -249,41 +475,51 @@ class MockAlpacaAdapter(BaseBrokerAdapter):
margin_used=Decimal("5000.00"),
total_equity=Decimal("55000.00"),
currency="USD",
buying_power=Decimal("100000.00")
buying_power=Decimal("100000.00"),
)
class MockIBKRAdapter(BaseBrokerAdapter):
"""Interactive Brokers TWS API adapter implementation."""
"""
Simulated Interactive Brokers TWS API adapter.
IBKR's status strings are mixed case (``PreSubmitted``, ``ApiCancelled``);
the base class upper-cases before lookup so the map is written upper-case and
matching is case-insensitive. A case-sensitive lookup would send ``"FILLED"``
to the unmapped branch while ``"Filled"`` resolved correctly.
"""
_STATUS_MAP = {
"Submitted": OrderStatus.PENDING,
"Filled": OrderStatus.FILLED,
"Cancelled": OrderStatus.CANCELLED,
"Inactive": OrderStatus.REJECTED,
"FILLED": OrderStatus.FILLED,
"CANCELLED": OrderStatus.CANCELLED,
"APICANCELLED": OrderStatus.CANCELLED,
# Inactive: the order is not working — commonly a rejection or an invalid
# order that TWS did not accept.
"INACTIVE": OrderStatus.REJECTED,
"SUBMITTED": OrderStatus.PENDING,
"PRESUBMITTED": OrderStatus.PENDING,
"PENDINGSUBMIT": OrderStatus.PENDING,
"PENDINGCANCEL": OrderStatus.PENDING,
"APIPENDING": OrderStatus.PENDING,
}
@property
def broker_name(self) -> str:
return "ibkr"
def normalize_status(self, broker_status: str) -> OrderStatus:
return self._STATUS_MAP.get(broker_status, OrderStatus.PENDING)
def place_order(self, request: OrderRequest) -> OrderResult:
if request.quantity <= Decimal("0"):
raise OrderExecutionError("Quantity must be greater than zero.")
request = self._validate_request(request)
broker_status = "Submitted"
order_id = f"IB_{int(time.time()*1000)}"
order_id = f"IB_{int(time.time() * 1000)}"
return OrderResult(
order_id=order_id,
broker_name=self.broker_name,
status=self.normalize_status(broker_status),
filled_quantity=Decimal("0"),
average_price=Decimal("0.0"),
message="Order placed via IBKR TWS API",
commission=Decimal("1.00")
message="Simulated acknowledgement — IBKR TWS API",
commission=Decimal("1.00"),
client_order_id=request.client_order_id,
)
def cancel_order(self, order_id: str) -> bool:
@@ -295,7 +531,7 @@ class MockIBKRAdapter(BaseBrokerAdapter):
broker_name=self.broker_name,
status=OrderStatus.PENDING,
filled_quantity=Decimal("0"),
average_price=Decimal("0.0")
average_price=Decimal("0.0"),
)
def get_positions(self) -> List[Position]:
@@ -307,26 +543,102 @@ class MockIBKRAdapter(BaseBrokerAdapter):
margin_used=Decimal("0.00"),
total_equity=Decimal("1000000.00"),
currency="USD",
buying_power=Decimal("4000000.00")
buying_power=Decimal("4000000.00"),
)
class BrokerAdapterFactory:
"""Factory registry for instantiating broker adapters."""
#: Simulated adapters, keyed by broker name. Registered only on explicit request.
SIMULATED_ADAPTERS: Dict[str, Type[BaseBrokerAdapter]] = {
"zerodha": MockZerodhaAdapter,
"alpaca": MockAlpacaAdapter,
"ibkr": MockIBKRAdapter,
}
_registry: Dict[str, Type[BaseBrokerAdapter]] = {
"zerodha": MockZerodhaAdapter,
"alpaca": MockAlpacaAdapter,
"ibkr": MockIBKRAdapter,
}
class BrokerAdapterFactory:
"""
Factory registry for instantiating broker adapters by configuration string.
**The registry starts empty.** The simulated adapters above are deliberately
not pre-registered under their production broker names: they fabricate fills
and report every order complete. A system that resolves its adapter from
``config["broker"]`` would otherwise pick up a mock and trade against
invented prices with no error anywhere. Call
``register_simulated_adapters()`` to opt in for demos and tests.
The registry is class-level shared state; ``register`` mutates it process
wide. Use ``reset()`` between tests to avoid leaking registrations.
"""
_registry: Dict[str, Type[BaseBrokerAdapter]] = {}
@classmethod
def register(cls, name: str, adapter_cls: Type[BaseBrokerAdapter]):
cls._registry[name.lower()] = adapter_cls
def register(cls, name: str, adapter_cls: Type[BaseBrokerAdapter]) -> None:
"""
Register an adapter class under a config key.
Raises:
AdapterRegistrationError: the name is empty or the class does not
implement ``BaseBrokerAdapter``. Checked here rather than at
``create`` time so a bad registration fails at wiring time, not
on the first order.
"""
if not isinstance(name, str) or not name.strip():
raise AdapterRegistrationError("Adapter name must be a non-empty string.")
if not (isinstance(adapter_cls, type) and issubclass(adapter_cls, BaseBrokerAdapter)):
raise AdapterRegistrationError(
f"{adapter_cls!r} does not implement BaseBrokerAdapter and cannot be registered "
f"under '{name}'."
)
key = name.strip().lower()
if key in cls._registry and cls._registry[key] is not adapter_cls:
logger.warning(
f"Broker adapter '{key}' is already registered to "
f"{cls._registry[key].__name__}; replacing with {adapter_cls.__name__}."
)
cls._registry[key] = adapter_cls
@classmethod
def register_simulated_adapters(cls) -> None:
"""
Opt in to the simulated adapters under their broker names.
For demos, tests and offline development only. These adapters invent
fills; never call this in a process that can reach a live venue.
"""
logger.warning(
"Registering SIMULATED broker adapters. These fabricate fills and must never be "
"used in a process with live market access."
)
for name, adapter_cls in SIMULATED_ADAPTERS.items():
cls.register(name, adapter_cls)
@classmethod
def reset(cls) -> None:
"""Clear the registry. Intended for test isolation."""
cls._registry = {}
@classmethod
def available(cls) -> List[str]:
"""Names currently registered."""
return sorted(cls._registry)
@classmethod
def create(cls, name: str, **kwargs) -> BaseBrokerAdapter:
key = name.lower()
"""
Instantiate a registered adapter.
Raises:
KeyError: no adapter is registered under ``name``.
"""
if not isinstance(name, str) or not name.strip():
raise KeyError("Broker name must be a non-empty string.")
key = name.strip().lower()
if key not in cls._registry:
raise KeyError(f"Broker adapter '{name}' not found in factory registry. Available: {list(cls._registry.keys())}")
raise KeyError(
f"Broker adapter '{name}' not found in factory registry. "
f"Registered: {cls.available() or 'none'}. The registry starts empty by design; "
f"register your production adapter, or call register_simulated_adapters() for "
f"the offline simulated ones."
)
return cls._registry[key](**kwargs)
@@ -1,42 +1,195 @@
import unittest
from decimal import Decimal
from broker_adapter import (
AccountBalance,
AdapterRegistrationError,
BaseBrokerAdapter,
BrokerAdapterFactory,
MockAlpacaAdapter,
MockZerodhaAdapter,
MockIBKRAdapter,
MockZerodhaAdapter,
OrderExecutionError,
OrderRequest,
OrderResult,
OrderSide,
OrderStatus,
OrderType,
Position,
OrderExecutionError,
)
class TestBrokerAgnosticAdapter(unittest.TestCase):
class FactoryTestCase(unittest.TestCase):
"""Registry is process-wide shared state; isolate every test from it."""
def setUp(self):
BrokerAdapterFactory.reset()
BrokerAdapterFactory.register_simulated_adapters()
def tearDown(self):
BrokerAdapterFactory.reset()
class TestFactory(FactoryTestCase):
def test_factory_creation(self):
zerodha = BrokerAdapterFactory.create("zerodha")
self.assertIsInstance(zerodha, MockZerodhaAdapter)
self.assertEqual(zerodha.broker_name, "zerodha")
alpaca = BrokerAdapterFactory.create("alpaca")
self.assertIsInstance(alpaca, MockAlpacaAdapter)
self.assertEqual(alpaca.broker_name, "alpaca")
ibkr = BrokerAdapterFactory.create("ibkr")
self.assertIsInstance(ibkr, MockIBKRAdapter)
self.assertEqual(ibkr.broker_name, "ibkr")
for name, cls in (("zerodha", MockZerodhaAdapter),
("alpaca", MockAlpacaAdapter),
("ibkr", MockIBKRAdapter)):
adapter = BrokerAdapterFactory.create(name)
self.assertIsInstance(adapter, cls)
self.assertEqual(adapter.broker_name, name)
def test_unregistered_broker_raises_key_error(self):
with self.assertRaises(KeyError):
BrokerAdapterFactory.create("unsupported_broker")
def test_registry_starts_empty_so_a_config_string_cannot_pick_up_a_mock(self):
"""
Regression: the shipped registry bound production broker names to
simulated adapters, so `create(config["broker"])` returned a mock that
reports every order FILLED at an invented price.
"""
BrokerAdapterFactory.reset()
self.assertEqual(BrokerAdapterFactory.available(), [])
with self.assertRaises(KeyError) as ctx:
BrokerAdapterFactory.create("zerodha")
self.assertIn("registry starts empty", str(ctx.exception))
def test_register_rejects_a_class_that_is_not_an_adapter(self):
"""Regression: any object could be registered and only failed at call time."""
class NotAnAdapter:
pass
with self.assertRaises(AdapterRegistrationError):
BrokerAdapterFactory.register("bogus", NotAnAdapter)
self.assertNotIn("bogus", BrokerAdapterFactory.available())
def test_register_rejects_empty_name(self):
with self.assertRaises(AdapterRegistrationError):
BrokerAdapterFactory.register(" ", MockZerodhaAdapter)
def test_registration_is_case_insensitive(self):
BrokerAdapterFactory.register("MyBroker", MockAlpacaAdapter)
self.assertIsInstance(BrokerAdapterFactory.create("mybroker"), MockAlpacaAdapter)
class TestStatusNormalization(FactoryTestCase):
"""
An unmapped status must never be reported as PENDING. Saying "this order is
live" about an order you cannot classify is how a terminal state gets treated
as working — the strategy waits forever, or re-sends an order that already
filled.
"""
def test_unknown_status_is_unknown_not_pending(self):
for name in ("zerodha", "alpaca", "ibkr"):
adapter = BrokerAdapterFactory.create(name)
with self.subTest(broker=name):
self.assertEqual(
adapter.normalize_status("some_status_invented_next_quarter"),
OrderStatus.UNKNOWN,
)
def test_non_string_status_is_unknown(self):
adapter = BrokerAdapterFactory.create("alpaca")
for value in (None, 42, object()):
with self.subTest(value=value):
self.assertEqual(adapter.normalize_status(value), OrderStatus.UNKNOWN)
def test_zerodha_terminal_lapsed_is_not_reported_as_working(self):
"""Regression: LAPSED is terminal at Kite but fell through to PENDING."""
adapter = BrokerAdapterFactory.create("zerodha")
self.assertEqual(adapter.normalize_status("LAPSED"), OrderStatus.EXPIRED)
def test_zerodha_documented_statuses(self):
adapter = BrokerAdapterFactory.create("zerodha")
cases = {
"COMPLETE": OrderStatus.FILLED,
"REJECTED": OrderStatus.REJECTED,
"CANCELLED": OrderStatus.CANCELLED,
"OPEN": OrderStatus.PENDING,
"TRIGGER PENDING": OrderStatus.PENDING,
"VALIDATION PENDING": OrderStatus.PENDING,
"PUT ORDER REQ RECEIVED": OrderStatus.PENDING,
"CANCEL PENDING": OrderStatus.PENDING,
}
for raw, expected in cases.items():
with self.subTest(raw=raw):
self.assertEqual(adapter.normalize_status(raw), expected)
def test_ibkr_apicancelled_is_cancelled_not_pending(self):
"""Regression: a real IBKR terminal status was unmapped and read as live."""
adapter = BrokerAdapterFactory.create("ibkr")
self.assertEqual(adapter.normalize_status("ApiCancelled"), OrderStatus.CANCELLED)
def test_ibkr_lookup_is_case_insensitive(self):
"""
Regression: IBKR used a case-sensitive lookup while the other adapters
upper-cased, so "FILLED" fell through to the PENDING default while
"Filled" resolved — a filled order reported as still working.
"""
adapter = BrokerAdapterFactory.create("ibkr")
for raw in ("Filled", "FILLED", "filled"):
with self.subTest(raw=raw):
self.assertEqual(adapter.normalize_status(raw), OrderStatus.FILLED)
def test_ibkr_documented_statuses(self):
adapter = BrokerAdapterFactory.create("ibkr")
cases = {
"Filled": OrderStatus.FILLED,
"Cancelled": OrderStatus.CANCELLED,
"ApiCancelled": OrderStatus.CANCELLED,
"Inactive": OrderStatus.REJECTED,
"Submitted": OrderStatus.PENDING,
"PreSubmitted": OrderStatus.PENDING,
"PendingSubmit": OrderStatus.PENDING,
"PendingCancel": OrderStatus.PENDING,
}
for raw, expected in cases.items():
with self.subTest(raw=raw):
self.assertEqual(adapter.normalize_status(raw), expected)
def test_alpaca_documented_statuses(self):
adapter = BrokerAdapterFactory.create("alpaca")
cases = {
"filled": OrderStatus.FILLED,
"partially_filled": OrderStatus.PARTIALLY_FILLED,
"rejected": OrderStatus.REJECTED,
"canceled": OrderStatus.CANCELLED,
"expired": OrderStatus.EXPIRED,
"done_for_day": OrderStatus.EXPIRED,
"new": OrderStatus.PENDING,
"pending_new": OrderStatus.PENDING,
"accepted": OrderStatus.PENDING,
"pending_cancel": OrderStatus.PENDING,
}
for raw, expected in cases.items():
with self.subTest(raw=raw):
self.assertEqual(adapter.normalize_status(raw), expected)
def test_unknown_is_not_treated_as_terminal(self):
result = OrderResult(
order_id="X", broker_name="alpaca", status=OrderStatus.UNKNOWN,
filled_quantity=Decimal("0"), average_price=Decimal("0"),
)
self.assertFalse(result.is_terminal)
def test_terminal_statuses_report_terminal(self):
for status in (OrderStatus.FILLED, OrderStatus.REJECTED,
OrderStatus.CANCELLED, OrderStatus.EXPIRED):
with self.subTest(status=status):
result = OrderResult(
order_id="X", broker_name="alpaca", status=status,
filled_quantity=Decimal("0"), average_price=Decimal("0"),
)
self.assertTrue(result.is_terminal)
class TestOrderExecution(FactoryTestCase):
def test_zerodha_order_execution(self):
adapter = BrokerAdapterFactory.create("zerodha")
req = OrderRequest(symbol="INFY", side=OrderSide.BUY, order_type=OrderType.MARKET, quantity=Decimal("10"))
req = OrderRequest(symbol="INFY", side=OrderSide.BUY,
order_type=OrderType.MARKET, quantity=Decimal("10"))
res = adapter.place_order(req)
self.assertIsInstance(res, OrderResult)
@@ -47,34 +200,156 @@ class TestBrokerAgnosticAdapter(unittest.TestCase):
def test_alpaca_order_execution(self):
adapter = BrokerAdapterFactory.create("alpaca")
req = OrderRequest(symbol="AAPL", side=OrderSide.BUY, order_type=OrderType.LIMIT, quantity=Decimal("5"), price=Decimal("180.0"))
req = OrderRequest(symbol="AAPL", side=OrderSide.BUY, order_type=OrderType.LIMIT,
quantity=Decimal("5"), price=Decimal("180.0"))
res = adapter.place_order(req)
self.assertIsInstance(res, OrderResult)
self.assertEqual(res.status, OrderStatus.FILLED)
self.assertEqual(res.filled_quantity, Decimal("5"))
self.assertEqual(res.average_price, Decimal("180.0"))
def test_ibkr_order_execution(self):
adapter = BrokerAdapterFactory.create("ibkr")
req = OrderRequest(symbol="TSLA", side=OrderSide.SELL, order_type=OrderType.MARKET, quantity=Decimal("15"))
req = OrderRequest(symbol="TSLA", side=OrderSide.SELL,
order_type=OrderType.MARKET, quantity=Decimal("15"))
res = adapter.place_order(req)
self.assertIsInstance(res, OrderResult)
self.assertEqual(res.status, OrderStatus.PENDING)
self.assertEqual(res.broker_name, "ibkr")
def test_invalid_quantity_raises_error(self):
def test_result_echoes_client_order_id(self):
"""Without the echo the caller cannot correlate a response to its request."""
adapter = BrokerAdapterFactory.create("zerodha")
req = OrderRequest(symbol="INFY", side=OrderSide.BUY, order_type=OrderType.MARKET, quantity=Decimal("-5"))
with self.assertRaises(OrderExecutionError):
adapter.place_order(req)
req = OrderRequest(symbol="INFY", side=OrderSide.BUY,
order_type=OrderType.MARKET, quantity=Decimal("1"),
client_order_id="my-idempotency-key")
self.assertEqual(adapter.place_order(req).client_order_id, "my-idempotency-key")
def test_status_normalization(self):
class TestRequestValidation(FactoryTestCase):
"""Validation lives on the base class so a new adapter cannot omit it."""
def setUp(self):
super().setUp()
self.adapters = [BrokerAdapterFactory.create(n) for n in ("zerodha", "alpaca", "ibkr")]
def _assert_all_reject(self, req_factory):
for adapter in self.adapters:
with self.subTest(broker=adapter.broker_name):
with self.assertRaises(OrderExecutionError):
adapter.place_order(req_factory())
def test_invalid_quantity_rejected(self):
for bad in (Decimal("-5"), Decimal("0")):
self._assert_all_reject(
lambda b=bad: OrderRequest(symbol="INFY", side=OrderSide.BUY,
order_type=OrderType.MARKET, quantity=b)
)
def test_non_finite_quantity_rejected_without_leaking_decimal_errors(self):
"""
Regression: Decimal('NaN') raised decimal.InvalidOperation straight through
the adapter boundary — the exact leaky abstraction this skill warns about.
"""
for bad in (Decimal("NaN"), Decimal("Infinity")):
self._assert_all_reject(
lambda b=bad: OrderRequest(symbol="INFY", side=OrderSide.BUY,
order_type=OrderType.MARKET, quantity=b)
)
def test_float_quantity_rejected(self):
"""
Regression: a float passed validation and propagated into OrderResult,
defeating the module's stated precision guarantee and only surfacing
later as a TypeError against a Decimal.
"""
self._assert_all_reject(
lambda: OrderRequest(symbol="INFY", side=OrderSide.BUY,
order_type=OrderType.MARKET, quantity=0.1)
)
def test_float_price_rejected(self):
self._assert_all_reject(
lambda: OrderRequest(symbol="INFY", side=OrderSide.BUY,
order_type=OrderType.LIMIT, quantity=Decimal("1"),
price=180.5)
)
def test_int_quantity_is_widened_losslessly(self):
adapter = BrokerAdapterFactory.create("zerodha")
req = OrderRequest(symbol="INFY", side=OrderSide.BUY,
order_type=OrderType.MARKET, quantity=7)
res = adapter.place_order(req)
self.assertEqual(res.filled_quantity, Decimal("7"))
self.assertIsInstance(res.filled_quantity, Decimal)
def test_limit_order_without_price_rejected(self):
"""Regression: the price fell back to a fabricated constant (100.50)."""
self._assert_all_reject(
lambda: OrderRequest(symbol="INFY", side=OrderSide.BUY,
order_type=OrderType.LIMIT, quantity=Decimal("1"))
)
def test_zero_limit_price_rejected(self):
"""
Regression: `if request.price` treats Decimal('0') as absent, so a zero
limit price was silently replaced by the fabricated default.
"""
self._assert_all_reject(
lambda: OrderRequest(symbol="INFY", side=OrderSide.BUY,
order_type=OrderType.LIMIT, quantity=Decimal("1"),
price=Decimal("0"))
)
def test_market_order_with_price_rejected(self):
self._assert_all_reject(
lambda: OrderRequest(symbol="INFY", side=OrderSide.BUY,
order_type=OrderType.MARKET, quantity=Decimal("1"),
price=Decimal("100"))
)
def test_stop_order_without_stop_price_rejected(self):
self._assert_all_reject(
lambda: OrderRequest(symbol="INFY", side=OrderSide.BUY,
order_type=OrderType.STOP, quantity=Decimal("1"))
)
def test_stop_limit_requires_both_prices(self):
self._assert_all_reject(
lambda: OrderRequest(symbol="INFY", side=OrderSide.BUY,
order_type=OrderType.STOP_LIMIT, quantity=Decimal("1"),
price=Decimal("100"))
)
def test_empty_symbol_rejected(self):
self._assert_all_reject(
lambda: OrderRequest(symbol=" ", side=OrderSide.BUY,
order_type=OrderType.MARKET, quantity=Decimal("1"))
)
def test_wrong_enum_types_rejected(self):
self._assert_all_reject(
lambda: OrderRequest(symbol="INFY", side="BUY",
order_type=OrderType.MARKET, quantity=Decimal("1"))
)
def test_valid_stop_limit_accepted(self):
adapter = BrokerAdapterFactory.create("alpaca")
self.assertEqual(adapter.normalize_status("filled"), OrderStatus.FILLED)
self.assertEqual(adapter.normalize_status("rejected"), OrderStatus.REJECTED)
self.assertEqual(adapter.normalize_status("unknown_status"), OrderStatus.PENDING)
req = OrderRequest(symbol="AAPL", side=OrderSide.SELL,
order_type=OrderType.STOP_LIMIT, quantity=Decimal("3"),
price=Decimal("175.00"), stop_price=Decimal("176.00"))
self.assertEqual(adapter.place_order(req).filled_quantity, Decimal("3"))
class TestInterfaceContract(FactoryTestCase):
def test_incomplete_adapter_cannot_be_instantiated(self):
class Incomplete(BaseBrokerAdapter):
@property
def broker_name(self) -> str:
return "incomplete"
with self.assertRaises(TypeError):
Incomplete()
def test_positions_and_balance_normalization(self):
adapter = BrokerAdapterFactory.create("zerodha")
@@ -86,5 +361,19 @@ class TestBrokerAgnosticAdapter(unittest.TestCase):
self.assertIsInstance(balance, AccountBalance)
self.assertIsInstance(balance.cash_available, Decimal)
def test_all_adapters_return_decimal_metrics(self):
for name in ("zerodha", "alpaca", "ibkr"):
adapter = BrokerAdapterFactory.create(name)
with self.subTest(broker=name):
req = OrderRequest(symbol="X", side=OrderSide.BUY,
order_type=OrderType.MARKET, quantity=Decimal("2"))
res = adapter.place_order(req)
self.assertIsInstance(res.filled_quantity, Decimal)
self.assertIsInstance(res.average_price, Decimal)
self.assertIsInstance(res.commission, Decimal)
balance = adapter.get_account_balance()
self.assertIsInstance(balance.total_equity, Decimal)
if __name__ == "__main__":
unittest.main()