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:
+8
-8
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user