================================================================================ ALPHA QUANT TRADING SDK - COMPLETE REFERENCE ================================================================================ ## SDK DOCUMENTATION ---------------------------------------- ### Index --- layout: home hero: name: "AQC Trading SDK" text: "Python SDK for IBKR execution via AQC" tagline: "Fast, clean, and strategy-first trading primitives." actions: - theme: brand text: Getting Started link: /getting-started - theme: alt text: Core Concepts link: /core-concepts - theme: alt text: Migration Guide link: /migration-guide features: - title: Strategy-aware routing details: Every request can be tagged with your strategy ID for live/paper separation. - title: IBKR Web API aligned details: Pythonic wrappers for the execution engine’s REST + WebSocket endpoints. - title: Options-ready details: Chain lookup, greeks, delta targeting, and multi-leg combo orders. - title: Execution controls details: Brackets, trailing stops, adaptive orders, and what-if margin checks. - title: Sync + async details: Blocking API plus AsyncTradingClient for event loops. - title: Minimal ceremony details: Sensible defaults, clear errors, and small, composable methods. --- ## What this is The AQC Trading SDK is a Python client for the AQC execution engine. It’s designed for quant teams who want reliable, strategy-scoped order routing, market data access, and options tooling without dealing directly with the IBKR Web API. ## Quickstart ```bash pip install --extra-index-url https://@pypi.fury.io/alphaquantcapital/ aqc-trading-sdk ``` ```python from aqc_trading_sdk import TradingClient client = TradingClient(strategy_id="AQC-P1A2B3C4") quote = client.get_quote("AAPL") order = client.buy("AAPL", 10) ``` Next: head to `Getting Started` for setup details, `Core Concepts` for conventions like strategy IDs and conids, and `Migration Guide` for strategy refactoring best practices. ### Getting Started # Getting Started ## Install ```bash pip install --extra-index-url https://@pypi.fury.io/alphaquantcapital/ aqc-trading-sdk ``` > REPLACE **** WITH ACTUAL TOKEN PASSWORD. REQUEST VIG FOR IT, AND DON'T SHARE IT WITH ANYONE. ## Import and Initialize ```python from aqc_trading_sdk import TradingClient client = TradingClient( strategy_id="AQC-P1A2B3C4", base_url="https://engine.alpha-techlab.com" ) ``` ## Strategy ID Strategy IDs route orders and data to the correct account and tag all activity for attribution. | Prefix | Account | |--------|---------| | `AQC-Pxxxxxx` | Paper (simulated) | | `AQC-Lxxxxxx` | Live | | `AQC-Fxxxxxx` | Fund | The SDK sends this as the `X-Strategy-Id` header on every request. The engine infers the account (paper/live/fund) solely from the ID prefix — no extra configuration needed in strategy code. ## Local Development ```python client = TradingClient( strategy_id="AQC-P1A2B3C4", base_url="http://localhost:8000" ) ``` ## Async Client Use `AsyncTradingClient` inside asyncio loops: ```python import asyncio from aqc_trading_sdk import AsyncTradingClient async def main(): client = AsyncTradingClient(strategy_id="AQC-P1A2B3C4") quote = await client.get_quote("SPY") print(quote) await client.close() asyncio.run(main()) ``` ## Connectivity Model You do not need `connect()` / `disconnect()` / reconnection loops in strategy code. - the execution engine owns IBKR gateway connectivity - the SDK is request-oriented - if you want a startup check, use: ```python status = client.get_connection_status() if not status["broker_ready"]: raise RuntimeError("Broker connection is not ready") ``` ### Core Concepts # Core Concepts ## Strategy Routing All requests carry a strategy ID. The engine uses it to route to the correct account and tag every order, trade, and position for attribution. - Header: `X-Strategy-Id: AQC-Pxxxxxx` - Set at construction: `TradingClient(strategy_id=...)` | Prefix | Account | Mode | |--------|---------|------| | `AQC-P` | Paper (simulated, IBKR paper gateway) | `paper` | | `AQC-L` | Live (real capital, IBKR live gateway) | `live` | | `AQC-F` | Fund (separate real-capital IBKR account) | `fund` | The engine infers the mode from the strategy ID prefix automatically. Strategy code does not need to specify a mode explicitly. ::: warning Fund and Live accounts Both `AQC-L` and `AQC-F` strategies trade real capital. Fund strategies route to a separate IBKR account (port 5002) distinct from the live account (port 5001). ::: ## Contract IDs (conid) Most market data and order endpoints accept a `conid` (IBKR contract identifier). Use these helpers: ```python conid = client.get_conid("AAPL") spx_conid = client.get_index_conid("SPX") ``` ## Security Types The `sec_type` parameter controls which IBKR instrument class to look up or trade. | Value | Instrument | |-------|-----------| | `STK` | Stock / ETF (default when omitted) | | `IND` | Index (SPX, NDX, VIX, etc.) | | `OPT` | Option | | `FUT` | Future | | `CRYPTO` | Cryptocurrency (BTC, ETH, etc.) — use `exchange="PAXOS"` | ```python # Stocks default to STK — no sec_type needed conid = client.get_conid("AAPL") # Indices require sec_type="IND" spx = client.get_conid("SPX", sec_type="IND") # Futures es = client.get_conid("ES", sec_type="FUT", exchange="CME") # Crypto — explicit hints required btc = client.get_conid("BTC", sec_type="CRYPTO", exchange="PAXOS") eth = client.get_conid("ETH", sec_type="CRYPTO", exchange="PAXOS") ``` ## Non-US Exchanges `get_conid`, `qualify_contract`, and all order placement methods accept `exchange`, `primary_exchange`, `country`, and `currency` parameters for routing to non-US markets. ```python # Canadian stock on TSX conid = client.get_conid("RY", exchange="TSX", currency="CAD", country="CA") # UK stock on LSE conid = client.get_conid("VOD", exchange="LSE", currency="GBP", country="GB") # German stock on XETRA conid = client.get_conid("SAP", exchange="IBIS", currency="EUR", country="DE") # Futures on CME es = client.get_conid("ES", sec_type="FUT", exchange="CME") ``` Pass the same parameters to `place_order` or `buy_market`/`sell_market` to ensure routing to the correct exchange: ```python result = client.buy_market( "RY", quantity=100, exchange="TSX", currency="CAD", country="CA", ) ``` ::: tip primary_exchange `primary_exchange` pins the listing exchange when SMART routing is active (e.g., `primary_exchange="NYSE"` for a US stock). Use `exchange` to override the routing destination entirely for non-US markets. ::: ## Time Formats - Historical bars (`get_history`): `period="1d"`, `bar_size="1min"` - Historical bars (`get_historical_bars`): `duration="1 D"`, `bar_size="30 mins"` - Tick history: `YYYYMMDD-HH:MM:SS` - Calendar/trading days: `YYYY-MM-DD` ## Order Confirmations Some IBKR orders return a confirmation prompt. Use `force=True` on order requests to auto-confirm warnings. ## Data Availability Some data depends on IBKR subscriptions: - Options OI/volume - Level 2 depth - Certain greeks/market data fields When unavailable, the SDK returns empty datasets with a note. ## Engine Status ```python status = client.get_status() connection = client.get_connection_status() ``` Use `get_connection_status()` when strategy code needs a simple readiness check for the current account mode. ### Orders # Orders Basic order methods return `OrderResult`. Bracket, spread, and iron condor methods return `Dict`. ```python OrderResult( success=True, order_id=738082887, message=None, raw={'result': [...], 'status': 'success'} ) ``` ## Market Orders ```python result = client.buy_market("SPY", 10) result = client.sell_market("SPY", 10) ``` Optional parameters: `conid`, `tif`, `exchange`, `country`, `currency`, `primary_exchange`, `sec_type`. ## Limit Orders ```python result = client.buy_limit("SPY", 10, price=690.50) result = client.sell_limit("SPY", 10, price=695.00) ``` Optional parameters: `conid`, `tif`, `exchange`, `country`, `currency`, `primary_exchange`. ## Stop Orders ```python result = client.buy_stop("SPY", 10, stop_price=695.00) result = client.sell_stop("SPY", 10, stop_price=685.00) ``` ## Stop-Limit Orders ```python result = client.buy_stop_limit("SPY", 10, stop_price=695.00, limit_price=695.50) result = client.sell_stop_limit("SPY", 10, stop_price=685.00, limit_price=684.50) ``` ## Contract-Based Order Placement ```python contract = client.qualify_contract("QQQ") result = client.place_contract_order(contract, "BUY", 10, order_type="MKT") ``` This is the migration-friendly equivalent of `ib.placeOrder(contract, order)`. ## Generic Order ```python result = client.place_order( symbol="SPY", quantity=10, side="BUY", order_type="MKT", price=None, aux_price=None, conid=None, force=True, tif="DAY", exchange="SMART", ) ``` **Parameters:** | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `symbol` | `str` | required | Ticker symbol | | `quantity` | `float` | required | Order quantity | | `side` | `str` | required | BUY / SELL | | `order_type` | `str` | "MKT" | MKT / LMT / STP / STP LMT | | `price` | `float` | None | Limit price | | `aux_price` | `float` | None | Stop price | | `conid` | `int` | None | Contract ID (auto-resolved if None) | | `force` | `bool` | True | Auto-confirm IBKR warnings | | `tif` | `str` | "DAY" | Time in force: DAY, GTC, IOC, OPG | | `exchange` | `str` | "SMART" | Routing exchange | | `country` | `str` | None | Country code (for non-US) | | `currency` | `str` | None | Currency (for non-US) | | `primary_exchange` | `str` | None | Listing exchange pin | | `sec_type` | `str` | None | STK / IND / OPT / FUT / CRYPTO | ## Cancel Order ```python success = client.cancel_order(738082887) # Returns bool ``` ## Cancel All Orders ```python cancelled_count = client.cancel_all_orders() # Returns int ``` Attempts bulk cancel first, falls back to individual cancellation if bulk fails. ## Modify Order ```python result = client.modify_order(order_id, price=695.00, quantity=20) ``` **Parameters:** `price`, `aux_price`, `quantity` — only pass fields to change. ## Wait for Fill ```python status = client.wait_for_fill(order_id, timeout=60, poll_interval=0.5) ``` **Returns:** `Dict` with `status`, `filled_quantity`, `avg_fill_price`. ## Wait for Submitted Waits until an order is acknowledged by the exchange. Critical for multi-leg strategies. ```python acknowledged = client.wait_for_submitted(order_id, timeout=30, poll_interval=0.3) ``` **Returns:** `bool` — `True` if submitted/presubmitted/filled, `False` if rejected/cancelled/timeout. **Example - Iron Condor leg sequencing:** ```python result = client.place_order(symbol="SPX", quantity=1, side="SELL", ...) if result.success and result.order_id: if client.wait_for_submitted(result.order_id, timeout=15): client.place_order(symbol="SPX", quantity=1, side="BUY", ...) else: logger.error("First leg not acknowledged") ``` ## Order Status ```python status = client.get_order_status(order_id) ``` **Returns:** `Dict` with `order_id`, `status`, `symbol`, `side`, `quantity`, `filled_quantity`, `remaining_quantity`, `avg_fill_price`, `limit_price`, `order_type`, `tif`. ## Bracket Orders Place an entry order with attached stop-loss and take-profit orders. All three orders are linked via IBKR's OCA (One-Cancels-All) group. ```python result = client.place_bracket_order( symbol="SPY", quantity=10, side="BUY", entry_price=690.00, stop_loss=685.00, take_profit=700.00, entry_type="LMT", force=False, ) ``` **Returns:** `Dict` with order IDs for parent and child orders. ## Trailing Stop Orders ### Main Method ```python result = client.place_trailing_stop( symbol="SPY", quantity=10, side="SELL", trail_amount=2.00, # OR trail_percent=1.0 (not both) ) ``` ### Convenience Methods ```python result = client.sell_trail("SPY", 10, trail_amount=2.00) # Protect long result = client.buy_trail("SPY", 10, trail_percent=1.0) # Protect short ``` ::: warning Provide either `trail_amount` OR `trail_percent`, not both. ::: ## Adaptive Orders IBKR adaptive algo orders that adjust execution strategy based on market conditions. ```python result = client.place_adaptive_order( symbol="SPY", quantity=100, side="BUY", priority="Normal", # Normal / Patient / Urgent order_type="MKT", price=None, # Max price for MKT orders force=False, ) ``` ## Order Impact (What-If) Get margin impact for a hypothetical order without placing it. ```python impact = client.get_order_impact("SPY", quantity=100, side="BUY", order_type="MKT") ``` **Returns:** `Dict` with margin requirements. ## Combo Orders Combo orders (spreads) trade multiple option legs as a single order. ### ComboLeg ```python from aqc_trading_sdk import ComboLeg ComboLeg( conid=123456, # Contract ID (required) ratio=1, # Leg ratio (required) action="SELL", # "BUY" or "SELL" (required) exchange="CBOE" # Required for options ) ``` ### Exchange Routing | Underlying | Exchange | |------------|----------| | SPX/SPXW | `CBOE` | | SPY, QQQ, TQQQ | `SMART` | ### Side Concept For combo orders, `side="BUY"` means **buying the spread structure** (opening the position), regardless of credit/debit. Individual leg `action` fields determine which legs are sold/bought. ### Bear Call Spread ```python from aqc_trading_sdk import ComboLeg short_call = client.get_option_contract( symbol="SPX", expiry="20260130", strike=6100, right="C", trading_class="SPXW" ) long_call = client.get_option_contract( symbol="SPX", expiry="20260130", strike=6110, right="C", trading_class="SPXW" ) short_conid = [c for c in short_call if c.get("maturityDate") == "20260130"][0]["conid"] long_conid = [c for c in long_call if c.get("maturityDate") == "20260130"][0]["conid"] legs = [ ComboLeg(conid=short_conid, ratio=1, action="SELL", exchange="CBOE"), ComboLeg(conid=long_conid, ratio=1, action="BUY", exchange="CBOE") ] result = client.place_combo_order(legs=legs, quantity=1, side="BUY", order_type="MKT") ``` ### Vertical Spread (Shortcut) ```python result = client.place_vertical_spread( symbol="SPX", expiry="20260130", long_strike=6000, short_strike=6010, right="P", quantity=1, trading_class="SPXW", order_type="MKT", force=False, ) ``` ### Iron Condor (Shortcut) ```python result = client.place_iron_condor( symbol="SPX", expiry="20260130", put_long_strike=5900, put_short_strike=5950, call_short_strike=6050, call_long_strike=6100, quantity=1, trading_class="SPXW", order_type="MKT", force=False, ) ``` ### How Combo Orders Work (Server-Side) IBKR requires a spread `conidex` string. The server builds it as: ``` {spread_conid}@{exchange};;;{leg_conid1}/{signed_ratio},{leg_conid2}/{signed_ratio} ``` - `signed_ratio` is positive for BUY legs, negative for SELL legs - For index options, `spread_conid=0` - Example: `0@CBOE;;;843975599/-1,843975601/1` ## Options with conid For options, always provide the `conid`: ```python contracts = client.get_option_contract( symbol="SPX", expiry="20260126", strike=6900, right="P", trading_class="SPXW" ) opt_conid = contracts[0]["conid"] result = client.buy_limit("SPX", 1, price=5.00, conid=opt_conid) ``` ### Market Data # Market Data ## Quotes ```python quote = client.get_quote("SPY") ``` **Returns:** `Quote` dataclass ```python Quote( conid=756733, symbol='SPY', last=693.2, bid=693.19, ask=693.2, bid_size=5560, ask_size=480, volume=17353480, change=3.97, change_pct=0.58 ) ``` | Field | Type | Description | |-------|------|-------------| | `conid` | `int` | Contract ID | | `symbol` | `str` | Ticker symbol | | `last` | `float` | Last trade price | | `bid` | `float` | Best bid price | | `ask` | `float` | Best ask price | | `bid_size` | `int` | Bid size | | `ask_size` | `int` | Ask size | | `volume` | `int` | Daily volume | | `change` | `float` | Price change | | `change_pct` | `float` | Percent change | ### Quick Price ```python price = client.get_price("SPY") # Returns float: 693.2 ``` Returns `last` price, falls back to `bid` or `ask` if unavailable. ## Batch Quotes Fetch quotes for multiple symbols in a single call: ```python quotes = client.get_tickers("SPY", "QQQ", "IWM", "DIA") ``` **Returns:** `List[Quote]` ```python [ Quote(conid=756733, symbol='SPY', last=693.2, bid=693.19, ask=693.2, ...), Quote(conid=320227571, symbol='QQQ', last=520.15, bid=520.14, ask=520.16, ...), ... ] ``` More efficient than calling `get_quote()` in a loop. Falls back to individual lookups if batch endpoint fails. ## Dividend Yield ```python data = client.get_dividend_yield("SPY") ``` **Returns:** `Dict` ```python { 'conid': 756733, 'symbol': 'SPY', 'dividend_yield': 0.0099, 'annual_dividend_forward': 7.32, 'annual_dividend_ttm': 7.38, 'spot_price': 738.94, 'yield_basis': 'forward_12m', 'market_data_availability': 'RpB' } ``` ::: warning Fund Account Behavior Fund accounts may return `None` for dividend yield and greeks fields. Paper accounts return string-formatted values. ::: ## Historical Bars ```python bars = client.get_historical_bars("SPY", duration="2 M", bar_size="1 day") ``` **Returns:** `List[Bar]` ```python Bar( timestamp=1769178600000, open=688.15, high=690.96, low=687.16, close=689.23, volume=1207682 ) ``` | Field | Type | Description | |-------|------|-------------| | `timestamp` | `int` | Unix timestamp (ms) | | `open` | `float` | Open price | | `high` | `float` | High price | | `low` | `float` | Low price | | `close` | `float` | Close price | | `volume` | `int` | Volume | ### Duration & Bar Size Options **Duration (IB-style strings):** | Days | Weeks | Months | Years | |------|-------|--------|-------| | `"1 D"`, `"2 D"`, `"3 D"`, `"4 D"`, `"5 D"`, `"7 D"`, `"10 D"`, `"14 D"` | `"1 W"`, `"2 W"`, `"3 W"`, `"4 W"` | `"1 M"`, `"2 M"`, `"3 M"`, `"6 M"` | `"1 Y"`, `"2 Y"` | Case-insensitive: `"5 D"`, `"5 d"`, and `"5D"` all work. **Bar Size:** `"1 min"`, `"5 mins"`, `"15 mins"`, `"30 mins"`, `"1 hour"`, `"1 day"` ### Historical Data (pandas) ```python df = client.get_historical_data("SPY", duration="1 D", bar_size="30 mins") ``` **Returns:** `pd.DataFrame` with columns: `date`, `open`, `high`, `low`, `close`, `volume`. ### Short-form History ```python bars = client.get_history("SPY", period="1d", bar_size="5min") ``` **Returns:** `List[Bar]` **Period (short-form):** `"1d"`, `"2d"`, `"3d"`, `"4d"`, `"5d"`, `"7d"`, `"10d"`, `"14d"`, `"1w"`–`"4w"`, `"1m"`–`"6m"`, `"1y"`–`"2y"` **Bar Size:** `"1min"`, `"5min"`, `"15min"`, `"30min"`, `"1h"`, `"1d"` ## Market Depth (L2) ```python depth = client.get_market_depth(conid, rows=3) ``` **Returns:** `Dict` ```python { 'conid': 756733, 'bid': 693.19, 'ask': 693.2, 'bid_size': 5560.0, 'ask_size': 480.0, 'rows': 3 } ``` Returns parsed floats. Use this instead of `get_option_quote()` when you need numeric bid/ask for options. ## Last Trade ```python last = client.get_last_trade(conid) ``` **Returns:** `Dict` ```python { 'conid': 756733, 'last': 693.2, 'last_size': 100.0, 'volume': 17353480.0 } ``` ::: warning For indices like SPX, `last` may be `None`. Use `get_quote("SPX")` which pulls snapshot fields for indices. ::: ## Tick History ```python ticks = client.get_ticks(conid, start="20260126-09:30:00", end="20260126-16:00:00") ``` **Returns:** `Dict` with tick data. `tick_type` defaults to `"TRADES"`. ## Halt Status ```python status = client.get_halt_status(conid) halted = client.is_halted(conid) # Returns bool ``` ## Trading Hours ```python hours = client.get_trading_hours("SPY") is_open = client.is_market_open_for("SPY") # Returns bool ``` ## Contract ID Resolution ```python conid = client.get_conid("SPY") # Stock: 756733 conid = client.get_index_conid("SPX") # Index: 416904 ``` **Returns:** `int` or `None` ### Options # Options ## Option Contract Lookup ```python contracts = client.get_option_contract( symbol="SPX", expiry="20260126", strike=6900, right="C", trading_class="SPXW" ) ``` **Returns:** `Dict` or `List[Dict]` — single contract if one match, list if multiple. ```python { 'conid': 843637708, 'symbol': 'SPX', 'secType': 'OPT', 'right': 'C', 'strike': 6955.0, 'currency': 'USD', 'maturityDate': '20260126', 'multiplier': '100', 'tradingClass': 'SPXW', 'exchange': 'SMART', 'validExchanges': 'SMART,CBOE,IBUSOPT', 'desc2': "(SPXW) JAN 26 '26 6955 Call" } ``` ::: warning Returns contracts across multiple expiries. Filter by `maturityDate` for specific dates: ```python today = "20260126" matches = [c for c in contracts if c.get("maturityDate") == today] ``` ::: ## Batch Contract Lookup ```python contracts = client.get_option_contracts( symbol="SPX", expiry="20260130", strikes=[6000, 6010, 6020, 6030], right="P", trading_class="SPXW" ) ``` **Returns:** `List[Dict]` ## Option Chain ```python chain = client.get_options_chain("SPX") chain = client.get_options_chain("SPX", month="202601") # Filter by month ``` **Returns:** `Dict` with chain data. ## Strikes ```python strikes = client.get_strikes("SPX", month="202601") ``` **Returns:** `Dict` with available strikes. ## Find Option by Delta Finds the option contract closest to a target delta using real IBKR Greeks. ```python contract = client.get_option_by_delta( symbol="SPX", expiry="20260130", target_delta=-0.35, # Negative for puts, positive for calls right="P", trading_class="SPXW" ) ``` **Returns:** `Dict` or `None` ```python { 'conid': 843975599, 'strike': 5985.0, 'delta': -0.348, 'expiry': '20260130' } ``` ::: tip Delta Sign Convention - **Puts:** Use negative delta (e.g., `-0.35`) - **Calls:** Use positive delta (e.g., `0.35`) ::: ## ATM Strike ```python atm = client.get_atm_strike("SPX", expiry="20260130") # Returns float or None ``` ## Option Expirations ```python expirations = client.get_option_expirations("SPX") # or expirations = client.get_expirations("SPX") # Alias ``` **Returns:** `List[str]` — dates in `YYYYMMDD` format. ## Option Quote ```python quote = client.get_option_quote(conid) ``` **Returns:** `Dict` of raw IBKR snapshot fields. ```python { 'conid': 843975599, '31': 'C34.75', # last (string, may be prefixed) '84': '34.70', # bid (string) '86': '34.80', # ask (string) '6509': 'DPBd', # market data availability '_updated': 1769534179785 } ``` ::: warning Values are strings. Use `get_market_depth()` for parsed float bid/ask. ::: ### Batch Quotes ```python quotes = client.get_option_quotes([conid1, conid2, conid3]) ``` **Returns:** `List[Dict]` ## Option Greeks ### Single Contract ```python greeks = client.get_greeks(conid) # or greeks = client.get_option_greeks(conid) # Alias ``` **Returns:** `Greeks` dataclass ```python Greeks(conid=843975599, delta=0.42, gamma=0.01, theta=-0.12, vega=0.34, iv=18.2) ``` ### Batch ```python greeks_list = client.get_option_greeks_batch([conid1, conid2]) ``` **Returns:** `List[Dict]` with fields: `7308` (delta), `7309` (gamma), `7310` (theta), `7311` (vega), `7633` (iv). ## Open Interest ```python oi = client.get_option_oi("SPX", expiry="20260130") ``` **Returns:** `Dict` mapping strikes to open interest values. ## Volume ```python volume = client.get_option_volume("SPX", expiry="20260130") ``` **Returns:** `Dict` mapping strikes to volume values. ## Market Depth for Options ```python depth = client.get_market_depth(opt_conid, rows=1) ``` **Returns:** `Dict` with parsed `bid`, `ask`, `bid_size`, `ask_size` as floats. ## Placing Option Orders Always use `conid` for option orders: ```python contracts = client.get_option_contract( symbol="SPX", expiry="20260126", strike=6900, right="P", trading_class="SPXW" ) today_contracts = [c for c in contracts if c.get("maturityDate") == "20260126"] opt_conid = today_contracts[0]["conid"] depth = client.get_market_depth(opt_conid, rows=1) result = client.buy_limit("SPX", 1, price=depth["ask"], conid=opt_conid, tif="DAY") ``` ## Trading Classes | Underlying | Trading Class | Description | |------------|---------------|-------------| | SPX | SPXW | Weekly SPX options (0DTE) | | SPX | SPX | Monthly SPX options | | SPY | SPY | SPY ETF options | | QQQ | QQQ | QQQ ETF options | ## Option Spreads (Combo Orders) See [Combo Orders](/orders#combo-orders) for multi-leg strategies. Key points: - `exchange="CBOE"` for SPX/SPXW options - `exchange="SMART"` for ETF options (SPY, QQQ) - `side="BUY"` for the combo order (individual leg actions determine buy/sell) ### Contracts # Contracts ## Get Contract ID (conid) ### US Stocks ```python conid = client.get_conid("SPY") # Returns: 756733 conid = client.get_conid("AAPL") # Returns: 265598 ``` **Returns:** `int` or `None` ### Non-US Stocks Use `exchange`, `currency`, and `country` to disambiguate listings on foreign markets. ```python conid = client.get_conid("RY", exchange="TSX", currency="CAD", country="CA") conid = client.get_conid("VOD", exchange="LSE", currency="GBP", country="GB") conid = client.get_conid("SAP", exchange="IBIS", currency="EUR", country="DE") ``` ### Futures ```python conid = client.get_conid("ES", sec_type="FUT", exchange="CME") # S&P 500 future conid = client.get_conid("NQ", sec_type="FUT", exchange="CME") # Nasdaq future conid = client.get_conid("CL", sec_type="FUT", exchange="NYMEX") # Crude oil future ``` ### Crypto Crypto conid lookups require explicit hints — bare-symbol lookup is not supported. ```python conid = client.get_conid("BTC", sec_type="CRYPTO", exchange="PAXOS") conid = client.get_conid("ETH", sec_type="CRYPTO", exchange="PAXOS") ``` ::: warning IBKR account enablement required Crypto trading must be enabled on the IBKR account before lookups work. ::: ## Search Contracts ```python results = client.search_contracts("Apple", sec_type="STK", limit=5) ``` **Returns:** `List[Dict]` — contracts matching the query. **Parameters:** `query`, `sec_type`, `exchange`, `country`, `currency`, `primary_exchange`, `limit` (default 10). ## Qualify Contract ```python contract = client.qualify_contract("QQQ") index_contract = client.qualify_contract("VIX", sec_type="IND", exchange="CBOE") canadian = client.qualify_contract("RY", exchange="TSX", currency="CAD", country="CA") ``` **Returns:** `ContractRef` ```python ContractRef( conid=320227571, symbol='QQQ', sec_type='STK', exchange='SMART', currency='USD', listing_exchange='NASDAQ' ) ``` ## Indices ```python conid = client.get_index_conid("SPX") # Returns: 416904 conid = client.get_index_conid("VIX") # Returns: 13455763 ``` ## Contract Details ```python details = client.get_contract_details(756733) ``` **Returns:** `Dict` with nested `secdef` array ```python { 'secdef': [{ 'conid': 756733, 'ticker': 'SPY', 'name': 'STATE STREET SPDR S&P 500 ETF', 'assetClass': 'STK', 'listingExchange': 'ARCA', 'currency': 'USD', 'countryCode': 'US', 'type': 'ETF', 'hasOptions': True, 'isUS': True, 'multiplier': 0.0, 'allExchanges': 'AMEX,NYSE,CBOE,...' }] } ``` ::: tip Accessing Details Always extract from nested structure: ```python secdef = details.get("secdef", [{}]) info = secdef[0] if secdef else {} ticker = info.get("ticker") asset_class = info.get("assetClass") ``` ::: ## Options Contract Details For options, `get_contract_details(opt_conid)` returns: ```python { 'secdef': [{ 'conid': 843131455, 'ticker': 'SPX', 'assetClass': 'OPT', 'expiry': '20260122', 'strike': '6905', 'putOrCall': 'C', 'multiplier': 100.0, 'undConid': 416904, 'listingExchange': 'CBOE' }] } ``` ::: warning `tradingClass` is NOT returned by `get_contract_details()`. Use `get_option_contract()` if you need trading class info. ::: ### Account Portfolio # Account & Portfolio ## Account Summary ```python account = client.get_account() # or account = client.get_account_summary() # Alias ``` **Returns:** `Account` dataclass ```python Account( account_id='DUO903441', net_liquidation=1004242.125, available_funds=1002600.0625, buying_power=6684000.5 ) ``` | Field | Type | Description | |-------|------|-------------| | `account_id` | `str` | IBKR account ID | | `net_liquidation` | `float` | Net liquidation value | | `available_funds` | `float` | Available funds for trading | | `buying_power` | `float` | Total buying power | ### Account ID ```python account_id = client.get_account_id() # Returns str ``` ## Account Values ```python values = client.get_account_values() net_liq = client.get_account_value("NetLiquidation") buying_power = client.get_account_value("buying_power") ``` **Returns:** `Dict[str, Any]` with both original and normalized keys. This is the migration-friendly equivalent of `ib.accountValues()`. ## P&L ```python pnl = client.get_pnl() ``` **Returns:** `Dict` with `daily_pnl`, `unrealized_pnl`, `realized_pnl`, `total_pnl`, `net_liquidation`. ### Position P&L ```python pnl = client.get_position_pnl("AAPL") ``` ## Margin ```python margin = client.get_margin() ``` **Returns:** `Dict` with `buying_power`, `available_funds`, and margin requirements. ## Positions ```python positions = client.get_positions() ``` **Returns:** `List[Position]` ```python Position( conid=265598, symbol='AAPL', position=7.0, avg_cost=279.90, market_value=1784.07, unrealized_pnl=-175.24, asset_class='STK' ) ``` | Field | Type | Description | |-------|------|-------------| | `conid` | `int` | Contract ID | | `symbol` | `str` | Ticker symbol | | `position` | `float` | Quantity (negative = short) | | `avg_cost` | `float` | Average cost basis | | `market_value` | `float` | Current market value | | `unrealized_pnl` | `float` | Unrealized P&L | | `asset_class` | `str` | Asset class (STK, OPT, etc.) | ### Position Caching `get_positions()` uses a 1-second in-memory cache. Rapid consecutive calls return cached data. ```python client.invalidate_position_cache() # Clear cache after placing orders positions = client.get_positions(force_refresh=True) # Force fresh fetch ``` ### Single Position ```python position = client.get_position("AAPL") # Returns Position or None has = client.has_position("AAPL") # Returns bool qty = client.get_open_position("AAPL") # Returns float (0 if none) ``` ### Close Position ```python result = client.close_position("AAPL") # Market order to flatten ``` **Returns:** `OrderResult` ## Orders ```python orders = client.get_orders() # or orders = client.get_open_orders() # Alias ``` **Returns:** `List[Dict]` - All orders from IBKR ```python { 'orderId': 1499005467, 'order_ref': 'AQC-PNDVMMR-1769445542279', 'description1': 'NVDA', 'side': 'SELL', 'orderType': 'Market', 'status': 'Filled', 'filledQuantity': 6.0, 'remainingQuantity': 0.0, 'avgPrice': '186.90', 'price': '' } ``` Common fields: | Field | Type | Description | |-------|------|-------------| | `orderId` | `int` | IBKR order ID | | `order_ref` | `str` | cOID with strategy ID | | `description1` | `str` | Symbol | | `side` | `str` | BUY / SELL | | `orderType` | `str` | Market / Limit / Stop | | `status` | `str` | Filled / Submitted / Cancelled | | `filledQuantity` | `float` | Filled size | | `remainingQuantity` | `float` | Remaining size | | `avgPrice` | `str` | Average fill price (string) | ## Open Trades ```python open_trades = client.get_open_trades() ``` **Returns:** `List[OpenTrade]` for working orders only (submitted/presubmitted). ```python OpenTrade( order_id='1499005467', symbol='NVDA', side='SELL', quantity=6.0, status='Submitted', filled_quantity=0.0, remaining_quantity=6.0, avg_fill_price=None, conid=4815747, order_ref='AQC-PNDVMMR-1769445542279' ) ``` ## Trades ```python trades = client.get_trades() ``` **Returns:** `List[Dict]` - Recent executions (fills) ```python { 'execution_id': '00025b49.697429fe.01.01', 'order_id': 1499005467, 'order_ref': 'AQC-PNDVMMR-1769445542279', 'symbol': 'NVDA', 'side': 'B', 'size': 6.0, 'price': '186.90', 'commission': '1.79', 'trade_time': '20260126-16:39:02', 'exchange': 'NASDAQ.NMS', 'conid': 4815747 } ``` | Field | Type | Description | |-------|------|-------------| | `execution_id` | `str` | Unique execution ID | | `order_id` | `int` | Parent order ID | | `order_ref` | `str` | cOID with strategy ID | | `side` | `str` | 'B' (buy) or 'S' (sell) | | `size` | `float` | Filled quantity | | `price` | `str` | Fill price | | `commission` | `str` | Commission charged | ### Calendar Time # Calendar & Time ## Market Time ```python market_time = client.get_market_time() ``` **Returns:** `MarketTime` dataclass ```python MarketTime( eastern_time='2026-01-26T11:11:00.793328-05:00', is_weekday=True, is_market_hours=True, market_open='2026-01-26T09:30:00-05:00', market_close='2026-01-26T16:00:00-05:00' ) ``` | Field | Type | Description | |-------|------|-------------| | `eastern_time` | `str` | Current time in ET (ISO format) | | `is_weekday` | `bool` | True if Mon-Fri | | `is_market_hours` | `bool` | True if 9:30-16:00 ET | | `market_open` | `str` | Today's open time | | `market_close` | `str` | Today's close time | ## Quick Checks ```python is_open = client.is_market_open() # Returns bool ``` ## Eastern Time ```python from datetime import time et = client.get_eastern_time() # Returns datetime in US/Eastern ``` ### Trade Window ```python # Check if current time is within 10:00-15:00 ET if client.is_within_trade_window(time(10, 0), time(15, 0)): # Place orders ``` ### End of Day ```python if client.is_eod(): # Default: 15:30 ET # Exit positions ``` Custom EOD time: ```python if client.is_eod(time(14, 0)): # 2:00 PM ET ``` ## Trading Calendar ```python calendar = client.get_trading_calendar(days=30) ``` **Returns:** `Dict` with holidays and early closes. ### Next Open/Close ```python next_open = client.get_next_market_open("NYSE") next_close = client.get_next_market_close("NYSE") ``` ### Count Trading Days ```python days = client.count_trading_days("2026-01-01", "2026-03-31") ``` **Returns:** `int` — number of trading days in the range. ## Usage Patterns ### Gate Trading on Market Hours ```python if not client.is_market_open(): print("Market closed, skipping trade") return ``` ### Time-Based Strategy Logic ```python et = client.get_eastern_time() # Exit-only mode after 1:00 PM ET if et.hour >= 13: print("Exit-only mode") ``` ### Safe Order Wrapper ```python def safe_order(client, symbol, qty, price): if not client.is_market_open(): raise Exception("Market is closed") return client.buy_limit(symbol, qty, price) ``` ### Streaming # Streaming ## Quote Stream ```python import asyncio from aqc_trading_sdk import TradingClient client = TradingClient(strategy_id="AQC-P1A2B3C4") async def on_quote(data): print("Quote:", data) asyncio.run(client.stream_quotes(["AAPL", "SPY"], on_quote)) ``` ## Order Updates ```python import asyncio from aqc_trading_sdk import TradingClient client = TradingClient(strategy_id="AQC-P1A2B3C4") def on_order_update(update): print("Order Update:", update) asyncio.run(client.stream_order_updates(on_order_update)) ``` The WebSocket endpoint is derived from `base_url` by replacing `http(s)` with `ws(s)` and connecting to `/ws/market`. ### Async Client # Async Client `AsyncTradingClient` provides full parity with `TradingClient`. Every sync method has an async equivalent. ## Basic Usage ```python import asyncio from aqc_trading_sdk import AsyncTradingClient async def main(): client = AsyncTradingClient(strategy_id="AQC-P1A2B3C4") # All methods are async account = await client.get_account() quote = await client.get_quote("SPY") positions = await client.get_positions() result = await client.buy_market("SPY", 10) await client.close() asyncio.run(main()) ``` ## Position Cache `AsyncTradingClient` has the same 1-second position cache as the sync client: ```python positions = await client.get_positions() # Cached position = await client.get_position("AAPL") # From cache position = await client.get_position("AAPL", force_refresh=True) # Fresh client.invalidate_position_cache() # Clear cache ``` ## Session Management Always call `close()` when done: ```python await client.close() ``` Or use async context management: ```python async with AsyncTradingClient(strategy_id="AQC-P1A2B3C4") as client: quote = await client.get_quote("SPY") ``` ## Streaming ```python async def on_order_update(update): print("Order:", update) await client.stream_order_updates(on_order_update) ``` ## Method Parity All sync methods are available as async: | Category | Methods | |----------|---------| | Account | `get_account`, `get_account_summary`, `get_account_id`, `get_account_values`, `get_account_value`, `get_pnl`, `get_position_pnl`, `get_margin` | | Positions | `get_positions`, `get_position`, `has_position`, `get_open_position`, `close_position`, `invalidate_position_cache` | | Orders | `get_orders`, `get_open_orders`, `get_open_trades`, `get_trades`, `place_order`, `buy_market`, `sell_market`, `buy_limit`, `sell_limit`, `buy_stop`, `sell_stop`, `buy_stop_limit`, `sell_stop_limit`, `buy_trail`, `sell_trail`, `cancel_order`, `cancel_all_orders`, `modify_order`, `get_order_status`, `wait_for_fill`, `wait_for_submitted`, `place_bracket_order`, `place_trailing_stop`, `place_adaptive_order`, `get_order_impact`, `place_combo_order`, `place_vertical_spread`, `place_iron_condor` | | Market Data | `get_quote`, `get_price`, `get_tickers`, `get_dividend_yield`, `get_history`, `get_historical_bars`, `get_historical_data`, `get_market_depth`, `get_last_trade`, `get_ticks`, `get_halt_status`, `is_halted`, `get_trading_hours`, `is_market_open_for` | | Options | `get_option_contract`, `get_option_contracts`, `get_options_chain`, `get_strikes`, `get_option_by_delta`, `get_atm_strike`, `get_option_expirations`, `get_option_quote`, `get_option_quotes`, `get_greeks`, `get_option_greeks_batch`, `get_option_oi`, `get_option_volume` | | Contracts | `get_conid`, `get_index_conid`, `search_contracts`, `get_contract_details`, `qualify_contract` | | Calendar | `get_market_time`, `is_market_open`, `get_eastern_time`, `is_within_trade_window`, `is_eod`, `get_trading_calendar`, `get_next_market_open`, `get_next_market_close`, `count_trading_days` | | Emergency | `account_kill_switch`, `flatten_all`, `strategy_kill_switch` | | Status | `get_status`, `is_connected`, `get_connection_status` | | Streaming | `stream_order_updates` | ### Emergency Controls # Emergency Controls Kill switch methods for rapid risk reduction. These are destructive operations — use with caution. ## Account Kill Switch Cancels all working orders and closes all live positions for an account. ```python # Paper account result = client.account_kill_switch(mode="paper") # Live account result = client.account_kill_switch(mode="live") # Fund account result = client.account_kill_switch(mode="fund") # If mode is omitted, inferred from the client's strategy_id prefix result = client.account_kill_switch() ``` **Returns:** ```python { "status": "success", "result": { "mode": "paper", "cancelled_orders": [ {"order_id": "1234567", "result": {...}} ], "closed_positions": [ {"conid": 265598, "symbol": "AAPL", "side": "SELL", "qty": 10, "result": {...}} ], "skipped_orders": [ {"order_id": "1234569", "status": "filled"} ], "skipped_positions": [ {"conid": 999, "symbol": "SPX BAG", "reason": "combo/BAG position (requires manual close)"} ], "errors": [] } } ``` **Behavior:** - Cancels all non-final orders (skips filled/cancelled/inactive) - Closes all non-zero positions via market orders - Skips combo/BAG positions (must be closed leg-by-leg manually) - Invalidates the position cache after execution ## Flatten All Alias of `account_kill_switch()`: ```python result = client.flatten_all(mode="paper") result = client.flatten_all(mode="live") result = client.flatten_all(mode="fund") ``` ## Strategy Kill Switch (Deprecated) ```python result = client.strategy_kill_switch() ``` **Deprecated.** Use `flatten_all()` or `account_kill_switch()` instead. Emits a `DeprecationWarning`. ## Async Client ```python result = await async_client.account_kill_switch(mode="paper") result = await async_client.flatten_all(mode="live") ``` ### Errors # Errors & Confirmations ## HTTP Errors The SDK raises `requests`/`aiohttp` exceptions for non-2xx responses. Wrap calls in try/except for trading-safe behavior: ```python try: client.buy("AAPL", 10) except Exception as exc: print("Order failed:", exc) ``` ## Confirmations Some IBKR orders return warning prompts. Use `force=True` on order methods to auto-confirm. ```python client.place_bracket_order( symbol="AAPL", quantity=10, side="BUY", entry_price=150.0, stop_loss=145.0, take_profit=160.0, force=True ) ``` ## Data Availability If a data field requires a market data subscription, the SDK returns empty data with a `note` field where applicable. Check the response before relying on it in production logic. ### Examples # Usage Patterns ## Strategy Bootstrapping ```python from aqc_trading_sdk import TradingClient client = TradingClient(strategy_id="AQC-P1A2B3C4") # Readiness check status = client.get_connection_status() if not status["broker_ready"]: raise RuntimeError("Broker not ready") account = client.get_account() positions = client.get_positions() ``` ## Signal to Order ```python price = client.get_price("AAPL") if price and price < 150: client.buy_limit("AAPL", 10, price=149.50) ``` ## Batch Market Data ```python quotes = client.get_tickers("SPY", "QQQ", "GLD") for q in quotes: print(f"{q.symbol}: ${q.last}") ``` ## Options Selection by Delta ```python opt = client.get_option_by_delta( symbol="SPX", expiry="20250115", target_delta=-0.35, right="P", trading_class="SPXW" ) if opt: client.place_order( symbol="SPX", quantity=1, side="SELL", order_type="MKT", conid=opt["conid"] ) ``` ## Bracketed Entry ```python client.place_bracket_order( symbol="AAPL", quantity=10, side="BUY", entry_price=150.0, stop_loss=145.0, take_profit=160.0, force=True ) ``` ## Trailing Stop ```python # Protect a long position with $2 trailing stop client.sell_trail("SPY", 100, trail_amount=2.00) ``` ## Multi-Leg Sequencing ```python # Place first leg, wait for acknowledgment, then place second result = client.place_order(symbol="SPX", quantity=1, side="SELL", ...) if result.success and result.order_id: if client.wait_for_submitted(result.order_id, timeout=15): client.place_order(symbol="SPX", quantity=1, side="BUY", ...) ``` ## Async Strategy ```python import asyncio from aqc_trading_sdk import AsyncTradingClient async def run_strategy(): client = AsyncTradingClient(strategy_id="AQC-P1A2B3C4") quote = await client.get_quote("SPY") positions = await client.get_positions() if quote.last < 700: await client.buy_market("SPY", 10) await client.close() asyncio.run(run_strategy()) ``` ## Streaming Quotes ```python import asyncio async def on_quote(data): print(data) asyncio.run(client.stream_quotes(["AAPL", "SPY"], on_quote)) ``` ### Api Examples # Method Index A compact index of SDK methods, grouped by area. ## Account & Portfolio - `get_account`, `get_account_summary`, `get_account_id` - `get_account_values`, `get_account_value` - `get_positions(force_refresh)`, `get_position(symbol, force_refresh)`, `has_position(symbol)`, `get_open_position(symbol, sec_type)` - `close_position(symbol)`, `invalidate_position_cache()` - `get_orders`, `get_open_orders`, `get_open_trades`, `get_trades` - `get_pnl`, `get_position_pnl`, `get_margin` - `get_status`, `is_connected`, `get_connection_status` ## Market Data - `get_quote`, `get_tickers`, `get_price` - `get_dividend_yield` - `get_history`, `get_historical_bars`, `get_historical_data` - `get_market_depth`, `get_last_trade`, `get_ticks` - `get_halt_status`, `is_halted` - `get_trading_hours`, `is_market_open_for` ## Orders - `place_order`, `place_contract_order` - `buy`, `sell`, `buy_market`, `sell_market`, `buy_limit`, `sell_limit` - `buy_stop`, `sell_stop`, `buy_stop_limit`, `sell_stop_limit` - `buy_trail`, `sell_trail`, `place_trailing_stop` - `place_bracket_order`, `place_adaptive_order` - `place_combo_order`, `place_vertical_spread`, `place_iron_condor` - `cancel_order`, `cancel_all_orders`, `modify_order` - `get_order_status`, `wait_for_fill`, `wait_for_submitted` - `get_order_impact` ## Emergency Controls - `account_kill_switch(mode)` — Cancel all orders and close all positions - `flatten_all(mode)` — Alias of `account_kill_switch()` - `strategy_kill_switch()` — **Deprecated**, use `flatten_all()` See [Emergency Controls](/emergency-controls) for details. ## Options - `get_option_contract`, `get_option_contracts`, `get_options_chain`, `get_strikes` - `get_option_by_delta`, `get_atm_strike` - `get_option_expirations`, `get_expirations` - `get_option_quote`, `get_option_quotes` - `get_greeks`, `get_option_greeks`, `get_option_greeks_batch` - `get_option_oi`, `get_option_volume` ## Contracts - `get_conid`, `get_index_conid` - `search_contracts`, `get_contract_details`, `qualify_contract` ## Calendars & Time - `get_market_time`, `is_market_open` - `get_eastern_time`, `is_within_trade_window`, `is_eod` - `get_trading_calendar`, `count_trading_days` - `get_next_market_open`, `get_next_market_close` ## Streaming - `stream_quotes`, `stream_order_updates` ## Async Client - `AsyncTradingClient` — Full parity with sync client. All methods are `async`. - `close()` — Release the HTTP session See [Async Client](/async-client) for details. ### Markdown Examples # SDK Data Types The SDK returns lightweight dataclasses and dictionaries for flexibility. ## Account ```python Account( account_id: str, net_liquidation: float, available_funds: float, buying_power: float ) ``` ## Position ```python Position( conid: int, symbol: str, position: float, avg_cost: float, market_value: float, unrealized_pnl: float, asset_class: str ) ``` ## Quote ```python Quote( conid: int, symbol: str, last: float, bid: float, ask: float, bid_size: float, ask_size: float, volume: float, change: float, change_pct: float ) ``` ## Bar ```python Bar( timestamp: int, open: float, high: float, low: float, close: float, volume: float ) ``` ## Greeks ```python Greeks( conid: int, delta: float, gamma: float, theta: float, vega: float, iv: float ) ``` ## OrderResult ```python OrderResult( success: bool, order_id: str, message: str, raw: dict ) ``` ## ComboLeg ```python ComboLeg( conid: int, ratio: int, action: str, exchange: str = "SMART" ) ``` ## ContractRef ```python ContractRef( conid: int, symbol: str, sec_type: str, exchange: str, currency: str, listing_exchange: str, country: str, trading_class: str, raw: dict ) ``` ## MarketTime ```python MarketTime( eastern_time: str, is_weekday: bool, is_market_hours: bool, market_open: str, market_close: str ) ``` ## OpenTrade ```python OpenTrade( order_id: str, symbol: str, side: str, quantity: float, status: str, filled_quantity: float, remaining_quantity: float, avg_fill_price: float, conid: int, order_ref: str ) ``` ### Migration Guide # Strategy Migration Guide Use this when refactoring an `ib_insync` or `ib_async` strategy to the AQC SDK. The goal is not just API translation. The goal is to preserve the original strategy's trading and operational behavior. ## Core rule Do not call a migration complete just because the script runs without exceptions. For trading parity, verify: - same expiry universe - same exact contracts - same pricing inputs - same order lifecycle - same restart and recovery behavior ## Best practices - Preserve the original decision path before cleaning up style. - Keep the same config values first: - DTE windows - strike widths - thresholds - walk logic - timeouts - order budget - For options, qualify on exact expiry, strike, and right. - Keep `SMART` on IBKR option discovery and combo legs unless the original strategy explicitly used something else. - Treat the execution engine as part of runtime behavior, not just a transport layer. - Reconcile live broker state on restart. Missing local state does not mean flat. - For combo strategies, verify entry and exit leg directions, ratios, and exchanges exactly. - Verify post-fill behavior explicitly: - GTC placement - stop placement - pending-close reconciliation - DTE-stop handling ## Common mistakes - Replacing exact expiries with approximate dates or month placeholders. - Using endpoints that return `200` but the wrong contract universe. - Omitting `SMART` in option secdef discovery or combo-leg exchange fields. - Porting only entry logic and forgetting exit, GTC, stop, or reconciliation logic. - Assuming local `positions_file` or `risk_file` is the source of truth. - Treating endpoint health as strategy parity. - Changing economic logic during the migration: - capital basis - carry treatment - P&L math - daily loss accounting - Re-qualifying the same option contracts repeatedly inside one cycle when the original logic reused them. ## Practical translation patterns ### Quote ```python # ib_insync / ib_async contract = Stock("QQQ", "SMART", "USD") ib.qualifyContracts(contract) # SDK quote = await client.get_quote("QQQ") price = float(quote.get("last") or quote.get("mid") or quote.get("close") or 0) ``` ### Historical bars ```python # ib_insync / ib_async bars = ib.reqHistoricalData(contract, endDateTime="", durationStr="2 M", barSizeSetting="1 day") # SDK bars = await client.get_historical_bars("QQQ", duration="2 M", bar_size="1 day") ``` ### Positions and open orders ```python # ib_insync / ib_async positions = ib.positions() orders = ib.openTrades() # SDK positions = await client.get_positions() orders = await client.get_open_trades() ``` ### Contract order placement ```python # ib_insync / ib_async trade = ib.placeOrder(contract, order) # SDK result = await client.place_contract_order( contract, side="BUY", quantity=10, order_type="MKT", ) ``` ## Required parity checks Before trusting a migrated strategy live, verify: - Expiry parity: - same DTE-filtered expiry set as the original - Contract parity: - same expiry, strike, right, and conid target - Pricing parity: - same quote and greeks inputs for selected legs - Execution parity: - same entry order shape - same walk and cancel behavior - same GTC or stop placement sequence - State parity: - same behavior after restart with live positions or open orders ================================================================================ ## SDK SOURCE CODE (aqc_trading_sdk.py) ---------------------------------------- #!/usr/bin/env python3 """ Trading SDK Client A Python SDK for interacting with the IBKR Execution Engine API. Provides simple wrapper functions for all trading operations. Usage: from aqc_trading_sdk import TradingClient # Initialize with strategy ID for tracking client = TradingClient(strategy_id="AQC-P1A2B3C4") # Get account info account = client.get_account() print(f"NAV: ${account.net_liquidation:,.2f}") # Get quote quote = client.get_quote("AAPL") print(f"AAPL: ${quote['last']}") # Place order (automatically tagged with strategy_id) order = client.buy("AAPL", 10) # Stream market data async def on_quote(data): print(f"Quote update: {data}") await client.stream_quotes(["AAPL", "SPY"], on_quote) """ import asyncio import json import time import warnings from dataclasses import dataclass from datetime import datetime, time as dtime from typing import Optional, List, Dict, Any, Callable, Union import ssl import certifi import requests import aiohttp import pytz import pandas as pd import re try: from .events import EventEmitter, BackgroundStreamManager, run_event_streams except ImportError: # allow `import aqc_trading_sdk` outside the package from events import EventEmitter, BackgroundStreamManager, run_event_streams __version__ = "0.2.1" _RETRYABLE_HTTP_STATUSES = {502, 503, 504} _REQUEST_RETRY_ATTEMPTS = 3 _REQUEST_RETRY_BASE_DELAY_SECONDS = 1 _REQUEST_TIMEOUT_SECONDS = 30 _REQUEST_CONNECT_TIMEOUT_SECONDS = 10 def _raise_ws_stream_error(payload: Dict[str, Any]) -> None: message = payload.get("message") or "WebSocket stream failed" raise RuntimeError(message) # ==================== Data Classes ==================== @dataclass class Account: """Account information.""" account_id: str net_liquidation: Optional[float] = None available_funds: Optional[float] = None buying_power: Optional[float] = None @dataclass class Position: """Portfolio position.""" conid: int symbol: str position: float avg_cost: float market_value: Optional[float] = None unrealized_pnl: Optional[float] = None asset_class: Optional[str] = None @dataclass class Quote: """Market quote.""" conid: int symbol: str last: Optional[float] = None bid: Optional[float] = None ask: Optional[float] = None bid_size: Optional[int] = None ask_size: Optional[int] = None volume: Optional[int] = None change: Optional[float] = None change_pct: Optional[float] = None @dataclass class Bar: """OHLCV bar.""" timestamp: int open: float high: float low: float close: float volume: Optional[int] = None @property def typical_price(self) -> float: """Typical price = (high + low + close) / 3.""" return (self.high + self.low + self.close) / 3 def calculate_vwap(bars: List["Bar"]) -> Optional[float]: """ Calculate Volume Weighted Average Price from bars. VWAP = Σ(typical_price × volume) / Σ(volume) where typical_price = (high + low + close) / 3 :param bars: List of Bar objects :return: VWAP value or None if no volume data """ if not bars: return None total_pv = 0.0 total_volume = 0.0 for bar in bars: if bar.volume and bar.volume > 0: total_pv += bar.typical_price * bar.volume total_volume += bar.volume if total_volume == 0: return None return total_pv / total_volume @dataclass class Greeks: """Option Greeks.""" conid: int delta: Optional[float] = None gamma: Optional[float] = None theta: Optional[float] = None vega: Optional[float] = None iv: Optional[float] = None @dataclass class ComboLeg: """Combo order leg.""" conid: int ratio: int action: str exchange: str = "SMART" @dataclass class ContractRef: """Normalized contract reference for migration-friendly order flows.""" conid: int symbol: str sec_type: str = "STK" exchange: str = "SMART" currency: str = "USD" listing_exchange: Optional[str] = None country: Optional[str] = None trading_class: Optional[str] = None local_symbol: Optional[str] = None expiry: Optional[str] = None raw: Optional[Dict[str, Any]] = None @dataclass class OpenTrade: """Normalized working-order view for ib_insync-style migration.""" order_id: Optional[str] symbol: str side: Optional[str] quantity: float status: Optional[str] filled_quantity: float = 0.0 remaining_quantity: float = 0.0 avg_fill_price: Optional[float] = None conid: Optional[int] = None order_ref: Optional[str] = None raw: Optional[Dict[str, Any]] = None @dataclass class OrderResult: """Order placement result.""" success: bool order_id: Optional[str] = None message: Optional[str] = None raw: Optional[Dict] = None @dataclass class MarketTime: """Market time information.""" eastern_time: str is_weekday: bool is_market_hours: bool market_open: str market_close: str # ==================== SDK Client ==================== class TradingClient(EventEmitter): """ Trading SDK Client for IBKR Execution Engine. Provides synchronous methods for all trading operations. For streaming data, use the async methods or the event API: client.on("fill", handler); mgr = client.start_streams(); ... mgr.stop() """ _STRATEGY_ID_RE = re.compile(r'^AQC-[PLF][A-Z0-9]{6}$') def __init__( self, strategy_id: str, base_url: str = "https://engine.alpha-techlab.com" ): """ Initialize the trading client. :param strategy_id: Strategy ID (e.g., "AQC-P1A2B3C4"). Required. Format: AQC-P{6chars} for paper, AQC-L{6chars} for live, AQC-F{6chars} for fund :param base_url: Execution engine API URL (default: https://engine.alpha-techlab.com) """ sid = strategy_id.upper() if strategy_id else "" if not self._STRATEGY_ID_RE.match(sid): raise ValueError( f"Invalid strategy_id '{strategy_id}'. " "Expected format: AQC-P/L/F + 6 alphanumeric chars (e.g., AQC-P1A2B3C4)." ) self.strategy_id = sid self.base_url = base_url.rstrip("/") self.session = requests.Session() self._ws_session: Optional[aiohttp.ClientSession] = None # Position caching (like ib_insync's in-memory position tracking) self._position_cache: Dict[str, Position] = {} self._position_cache_time: float = 0.0 self._position_cache_ttl: float = 1.0 # Cache valid for 1 second @staticmethod def _normalize_account_value_key(key: Any) -> str: return "".join(ch for ch in str(key or "").lower() if ch.isalnum()) def _mode(self) -> str: strategy_id = str(self.strategy_id or "").upper() if strategy_id.startswith("AQC-L"): return "live" if strategy_id.startswith("AQC-F"): return "fund" return "paper" def _flatten_account_values(self, payload: Dict[str, Any]) -> Dict[str, Any]: flattened: Dict[str, Any] = {} if not isinstance(payload, dict): return flattened for key, value in payload.items(): flattened[key] = value normalized = self._normalize_account_value_key(key) if normalized and normalized not in flattened: flattened[normalized] = value return flattened def _order_matches_strategy(self, order: Dict[str, Any]) -> bool: if not self.strategy_id: return True strategy_id = str(self.strategy_id) candidates = ( order.get("order_ref"), order.get("orderRef"), order.get("cOID"), order.get("customer_order_id"), order.get("customerOrderId"), ) return any(strategy_id in str(candidate or "") for candidate in candidates) @staticmethod def _parse_open_trade(order: Dict[str, Any]) -> OpenTrade: quantity = order.get("totalSize") if quantity is None: quantity = order.get("quantity") if quantity is None: quantity = order.get("size") filled_quantity = order.get("filledQuantity") remaining_quantity = order.get("remainingQuantity") avg_fill_price = order.get("avgPrice") conid = order.get("conid") return OpenTrade( order_id=str(order.get("orderId") or order.get("order_id") or "") or None, symbol=str(order.get("description1") or order.get("symbol") or order.get("ticker") or ""), side=order.get("side"), quantity=float(quantity or 0), status=order.get("status"), filled_quantity=float(filled_quantity or 0), remaining_quantity=float(remaining_quantity or 0), avg_fill_price=float(avg_fill_price) if avg_fill_price not in (None, "", "None") else None, conid=int(conid) if conid not in (None, "") else None, order_ref=order.get("order_ref") or order.get("orderRef"), raw=order, ) # ==================== Account Methods ==================== def get_account(self) -> Account: """ Get account information. :return: Account object with ID, NAV, available funds, buying power """ response = self._get("/account") return Account( account_id=response.get("account_id", ""), net_liquidation=response.get("net_liquidation"), available_funds=response.get("available_funds"), buying_power=response.get("buying_power") ) def get_account_summary(self) -> Account: """ Get account summary (alias for get_account). Matches ib_insync naming. :return: Account object """ return self.get_account() def get_account_id(self) -> str: """ Get account ID. :return: Account ID string """ return self.get_account().account_id def get_account_values(self) -> Dict[str, Any]: """ Get flattened account values for migration from ib_insync accountValues(). """ response = self._get("/account/values") return self._flatten_account_values(response) def get_account_value(self, key: str, default: Any = None) -> Any: """ Get a single account value using case/underscore-insensitive lookup. """ values = self.get_account_values() normalized = self._normalize_account_value_key(key) return values.get(key, values.get(normalized, default)) def get_positions(self, force_refresh: bool = False) -> List[Position]: """ Get all portfolio positions with caching. Uses in-memory cache (like ib_insync) to avoid redundant API calls. :param force_refresh: Force a fresh fetch from server :return: List of Position objects """ # Check if cache is still valid if not force_refresh and self._position_cache and (time.time() - self._position_cache_time) < self._position_cache_ttl: return list(self._position_cache.values()) response = self._get("/account/positions") positions = [ Position( conid=pos.get("conid", 0), symbol=pos.get("symbol", ""), position=pos.get("position", 0), avg_cost=pos.get("avg_cost", 0), market_value=pos.get("market_value"), unrealized_pnl=pos.get("unrealized_pnl"), asset_class=pos.get("asset_class") ) for pos in response ] # Update cache self._position_cache = {pos.symbol.upper(): pos for pos in positions} self._position_cache_time = time.time() return positions def get_position(self, symbol: str, force_refresh: bool = False) -> Optional[Position]: """ Get position for a specific symbol using cache. Much more efficient than ib_insync equivalent when called multiple times. :param symbol: Stock/contract symbol :param force_refresh: Force a fresh fetch from server :return: Position object or None if no position """ # Refresh cache if needed if force_refresh or not self._position_cache or (time.time() - self._position_cache_time) >= self._position_cache_ttl: self.get_positions(force_refresh=force_refresh) return self._position_cache.get(symbol.upper()) def has_position(self, symbol: str, force_refresh: bool = False) -> bool: """ Check if there's an open position for a symbol. Uses cached position data for efficiency. :param symbol: Stock/contract symbol :param force_refresh: Force a fresh fetch from server :return: True if position exists """ pos = self.get_position(symbol, force_refresh=force_refresh) return pos is not None and pos.position != 0 def get_open_position(self, symbol: str, sec_type: str = "STK", force_refresh: bool = False) -> float: """ Get open position quantity for a symbol. Matches the strategy pattern: get_open_position(symbol, sectype) Uses cached position data for efficiency. :param symbol: Stock/contract symbol :param sec_type: Security type (STK, OPT, FUT, IND) :param force_refresh: Force a fresh fetch from server :return: Position quantity (0 if no position) """ pos = self.get_position(symbol, force_refresh=force_refresh) if pos and (sec_type == "STK" or pos.asset_class == sec_type): return pos.position return 0.0 def invalidate_position_cache(self) -> None: """ Invalidate the position cache. Call this after placing orders that affect positions. """ self._position_cache.clear() self._position_cache_time = 0.0 def close_position(self, symbol: str) -> OrderResult: """ Close entire position for a symbol. :param symbol: Stock/contract symbol :return: OrderResult """ pos = self.get_position(symbol) if not pos or pos.position == 0: return OrderResult(success=True, message="No position to close") side = "SELL" if pos.position > 0 else "BUY" # Use round() to handle fractional shares properly instead of truncating with int() quantity = round(abs(pos.position)) if quantity == 0: return OrderResult(success=True, message="Position too small to close") # Pass conid directly so we close the exact contract held (critical for futures, # options, and any non-STK asset where symbol alone is ambiguous). return self.place_order( symbol, quantity, side, "MKT", conid=pos.conid or None, sec_type=pos.asset_class, ) def get_orders(self) -> List[Dict]: """ Get all open orders. :return: List of order dictionaries """ response = self._get("/account/orders") return response.get("orders", []) def get_open_orders(self) -> List[Dict]: """ Get working/active orders only (submitted, presubmitted, pendingsubmit). Matches ib_insync naming. :return: List of order dictionaries """ working_statuses = {"submitted", "presubmitted", "pendingsubmit"} return [o for o in self.get_orders() if str(o.get("status") or "").lower() in working_statuses] def get_open_trades(self) -> List[OpenTrade]: """ Get normalized working orders, similar to ib_insync openTrades(). """ working_statuses = {"submitted", "presubmitted", "pendingsubmit"} open_trades: List[OpenTrade] = [] for order in self.get_orders(): status = str(order.get("status") or "").lower() if status not in working_statuses: continue if not self._order_matches_strategy(order): continue open_trades.append(self._parse_open_trade(order)) return open_trades def get_trades(self) -> List[Dict]: """ Get recent trades. :return: List of trade dictionaries """ return self._get("/account/trades") # ==================== Market Data Methods ==================== def get_quote( self, symbol: str, sec_type: Optional[str] = None, exchange: Optional[str] = None, currency: Optional[str] = None, primary_exchange: Optional[str] = None, expiry: Optional[str] = None, local_symbol: Optional[str] = None, ) -> Quote: """ Get current quote for a symbol. :param symbol: Stock/contract symbol :param sec_type: Security type hint (e.g. "CRYPTO"). Pass for non-STK symbols. :param exchange: Exchange hint (e.g. "PAXOS" for crypto). :return: Quote object """ params: Dict[str, str] = {} if sec_type: params["sec_type"] = sec_type if exchange: params["exchange"] = exchange if currency: params["currency"] = currency if primary_exchange: params["primary_exchange"] = primary_exchange if expiry: params["expiry"] = expiry if local_symbol: params["local_symbol"] = local_symbol response = self._get(f"/market/quote/{symbol}", params=params or None) return Quote( conid=response.get("conid", 0), symbol=response.get("symbol", symbol), last=response.get("last"), bid=response.get("bid"), ask=response.get("ask"), bid_size=response.get("bid_size"), ask_size=response.get("ask_size"), volume=response.get("volume"), change=response.get("change"), change_pct=response.get("change_pct") ) def get_price( self, symbol: str, sec_type: Optional[str] = None, exchange: Optional[str] = None, currency: Optional[str] = None, primary_exchange: Optional[str] = None, expiry: Optional[str] = None, local_symbol: Optional[str] = None, ) -> Optional[float]: """ Get current price for a symbol (convenience method). :param symbol: Stock/contract symbol :return: Last price or None """ quote = self.get_quote( symbol, sec_type=sec_type, exchange=exchange, currency=currency, primary_exchange=primary_exchange, expiry=expiry, local_symbol=local_symbol, ) return quote.last or quote.bid or quote.ask def get_dividend_yield(self, symbol: str) -> Dict: """ Get dividend yield payload for an equity symbol. :param symbol: Underlying symbol :return: Raw dividend-yield payload from execution_api """ return self._get(f"/market/dividend-yield/{symbol}") def get_tickers(self, *symbols: str) -> List[Quote]: """ Get ticker data for multiple symbols in a batch request. Falls back to individual calls for any symbols the batch misses. :param symbols: One or more symbols :return: List of Quote objects """ if not symbols: return [] results: Dict[str, Quote] = {} try: response = self._post("/market/quotes", {"symbols": list(symbols)}) for q in response.get("quotes", []): sym = str(q.get("symbol", "")).upper() if sym: results[sym] = Quote( conid=q.get("conid", 0), symbol=sym, last=q.get("last"), bid=q.get("bid"), ask=q.get("ask"), bid_size=q.get("bid_size"), ask_size=q.get("ask_size"), volume=q.get("volume"), change=q.get("change"), change_pct=q.get("change_pct"), ) except Exception: pass for sym in symbols: if sym.upper() not in results: try: results[sym.upper()] = self.get_quote(sym) except Exception: pass return [results[s.upper()] for s in symbols if s.upper() in results] def get_history( self, symbol: str, period: str = "1d", bar_size: str = "1min", sec_type: Optional[str] = None, exchange: Optional[str] = None, currency: Optional[str] = None, primary_exchange: Optional[str] = None, expiry: Optional[str] = None, local_symbol: Optional[str] = None, ) -> List[Bar]: """ Get historical OHLCV bars. :param symbol: Stock/contract symbol :param period: Time period (1d, 1w, 1m, 1y) :param bar_size: Bar size (1min, 5min, 15min, 30min, 1h, 1d) :return: List of Bar objects """ params: Dict[str, str] = {"period": period, "bar": bar_size} if sec_type: params["sec_type"] = sec_type if exchange: params["exchange"] = exchange if currency: params["currency"] = currency if primary_exchange: params["primary_exchange"] = primary_exchange if expiry: params["expiry"] = expiry if local_symbol: params["local_symbol"] = local_symbol response = self._get(f"/market/history/{symbol}", params=params) bars = response.get("bars", []) return [ Bar( timestamp=bar.get("timestamp", 0), open=bar.get("open", 0), high=bar.get("high", 0), low=bar.get("low", 0), close=bar.get("close", 0), volume=bar.get("volume"), ) for bar in bars ] def get_historical_data( self, symbol: str, duration: str = "1 D", bar_size: str = "30 mins" ) -> pd.DataFrame: """ Get historical data as pandas DataFrame. Accepts IB-style duration strings for compatibility. :param symbol: Stock/contract symbol :param duration: Duration string (e.g., "1 D", "1 W", "1 M", "2 M") :param bar_size: Bar size (e.g., "1 min", "5 mins", "30 mins", "1 hour", "1 day") :return: DataFrame with OHLCV data """ # Convert IB-style duration to API format period_map = { # Day durations "1 D": "1d", "1 d": "1d", "1D": "1d", "2 D": "2d", "2 d": "2d", "2D": "2d", "3 D": "3d", "3 d": "3d", "3D": "3d", "4 D": "4d", "4 d": "4d", "4D": "4d", "5 D": "5d", "5 d": "5d", "5D": "5d", "7 D": "7d", "7 d": "7d", "7D": "7d", "10 D": "10d", "10 d": "10d", "10D": "10d", "14 D": "14d", "14 d": "14d", "14D": "14d", # Week durations "1 W": "1w", "1 w": "1w", "1W": "1w", "2 W": "2w", "2 w": "2w", "2W": "2w", "3 W": "3w", "3 w": "3w", "3W": "3w", "4 W": "4w", "4 w": "4w", "4W": "4w", # Month durations "1 M": "1m", "1 m": "1m", "1M": "1m", "2 M": "2m", "2 m": "2m", "2M": "2m", "3 M": "3m", "3 m": "3m", "3M": "3m", "6 M": "6m", "6 m": "6m", "6M": "6m", "12 M": "1y", "12 m": "1y", "12M": "1y", # Year durations "1 Y": "1y", "1 y": "1y", "1Y": "1y", "2 Y": "2y", "2 y": "2y", "2Y": "2y", } period = period_map.get(duration, "1d") # Convert IB-style bar size to API format bar_map = { "1 min": "1min", "1 mins": "1min", "1min": "1min", "5 mins": "5min", "5 min": "5min", "5min": "5min", "15 mins": "15min", "15 min": "15min", "15min": "15min", "30 mins": "30min", "30 min": "30min", "30min": "30min", "1 hour": "1h", "1 h": "1h", "1h": "1h", "1 day": "1d", "1 d": "1d", "1d": "1d" } bar = bar_map.get(bar_size, "1min") bars = self.get_history(symbol, period, bar) if not bars: return pd.DataFrame(columns=["date", "open", "high", "low", "close", "volume"]) df = pd.DataFrame([ { "date": datetime.fromtimestamp(b.timestamp / 1000), "open": b.open, "high": b.high, "low": b.low, "close": b.close, "volume": b.volume } for b in bars ]) return df def get_historical_bars(self, symbol: str, duration: str = "1 D", bar_size: str = "30 mins") -> List[Bar]: """ Get historical bars (alias with IB-style duration strings). :param symbol: Stock/contract symbol :param duration: Duration string (e.g., "1 D", "2 M") :param bar_size: Bar size string (e.g., "30 mins") :return: List of Bar objects """ # Convert IB-style duration to API format period_map = { # Day durations "1 D": "1d", "1 d": "1d", "1D": "1d", "2 D": "2d", "2 d": "2d", "2D": "2d", "3 D": "3d", "3 d": "3d", "3D": "3d", "4 D": "4d", "4 d": "4d", "4D": "4d", "5 D": "5d", "5 d": "5d", "5D": "5d", "7 D": "7d", "7 d": "7d", "7D": "7d", "10 D": "10d", "10 d": "10d", "10D": "10d", "14 D": "14d", "14 d": "14d", "14D": "14d", # Week durations "1 W": "1w", "1 w": "1w", "1W": "1w", "2 W": "2w", "2 w": "2w", "2W": "2w", "3 W": "3w", "3 w": "3w", "3W": "3w", "4 W": "4w", "4 w": "4w", "4W": "4w", # Month durations "1 M": "1m", "1 m": "1m", "1M": "1m", "2 M": "2m", "2 m": "2m", "2M": "2m", "3 M": "3m", "3 m": "3m", "3M": "3m", "6 M": "6m", "6 m": "6m", "6M": "6m", "12 M": "1y", "12 m": "1y", "12M": "1y", # Year durations "1 Y": "1y", "1 y": "1y", "1Y": "1y", "2 Y": "2y", "2 y": "2y", "2Y": "2y", } period = period_map.get(duration, "1d") bar_map = { "1 min": "1min", "1 mins": "1min", "5 mins": "5min", "5 min": "5min", "15 mins": "15min", "15 min": "15min", "30 mins": "30min", "30 min": "30min", "1 hour": "1h", "1 h": "1h", "1 day": "1d", "1 d": "1d" } bar = bar_map.get(bar_size, "30min") return self.get_history(symbol, period, bar) def get_conid( self, symbol: str, sec_type: Optional[str] = None, country: Optional[str] = None, currency: Optional[str] = None, exchange: Optional[str] = None, primary_exchange: Optional[str] = None, expiry: Optional[str] = None, local_symbol: Optional[str] = None, ) -> Optional[int]: """ Get contract ID for a symbol. :param symbol: Stock/contract symbol :param sec_type: Security type filter (STK, IND, FUT, etc). Default prefers STK then IND. :return: Contract ID or None """ params = {} if sec_type: params["sec_type"] = sec_type if country: params["country"] = country if currency: params["currency"] = currency if exchange: params["exchange"] = exchange if primary_exchange: params["primary_exchange"] = primary_exchange if expiry: params["expiry"] = expiry if local_symbol: params["local_symbol"] = local_symbol response = self._get(f"/market/conid/{symbol}", params=params if params else None) conid = response.get("conid") return int(conid) if conid else None def get_index_conid(self, symbol: str) -> Optional[int]: """ Get contract ID for an index symbol (e.g., SPX, NDX, VIX). :param symbol: Index symbol :return: Contract ID or None """ return self.get_conid(symbol, sec_type="IND") # ==================== Options Methods ==================== def get_options_chain(self, symbol: str, month: Optional[str] = None) -> Dict: """ Get options chain for a symbol. :param symbol: Underlying symbol :param month: Optional expiration month (e.g., "DEC24") :return: Options chain data """ params = {} if month: params["month"] = month return self._get(f"/options/chain/{symbol}", params=params) def get_strikes(self, symbol: str, month: str) -> Dict: """ Get available strikes for options. :param symbol: Underlying symbol :param month: Expiration month (e.g., "DEC24") :return: Dictionary with "call" and "put" strike lists """ return self._get(f"/options/strikes/{symbol}", params={"month": month}) def get_option_contract( self, symbol: str, expiry: str, strike: float, right: str, trading_class: Optional[str] = None ) -> Union[Dict, List[Dict]]: """ Get a specific option contract. :param symbol: Underlying symbol :param expiry: Expiration date (YYYYMMDD) :param strike: Strike price :param right: "C" for Call, "P" for Put :param trading_class: Trading class filter (e.g., "SPXW" for 0DTE weekly options) :return: Option contract data """ payload = { "symbol": symbol, "expiry": expiry, "strike": strike, "right": right } if trading_class: payload["trading_class"] = trading_class data = self._post("/options/contract", payload) if isinstance(data, list): return data[0] if len(data) == 1 else data return data def get_option_contracts( self, symbol: str, expiry: str, right: str, strikes: List[float], trading_class: Optional[str] = None ) -> List[Dict]: """ Get multiple option contracts by strike list. :param symbol: Underlying symbol :param expiry: Expiration date (YYYYMMDD) :param right: "C" for Call, "P" for Put :param strikes: List of strike prices :param trading_class: Trading class filter (e.g., "SPXW" for 0DTE weekly options) :return: List of option contract data """ payload = { "symbol": symbol, "expiry": expiry, "right": right, "strikes": strikes } if trading_class: payload["trading_class"] = trading_class response = self._post("/options/contracts", payload) return response.get("contracts", []) def get_option_quote(self, conid: int) -> Optional[Dict]: """ Get bid/ask/last for an option contract by conid. """ quotes = self.get_option_quotes([conid]) return quotes[0] if quotes else None def get_option_quotes(self, conids: List[int]) -> List[Dict]: """ Get bid/ask/last for multiple option conids. """ response = self._post("/options/quotes", {"conids": conids}) if isinstance(response, list): return response return response.get("quotes", []) def get_option_greeks_batch(self, conids: List[int]) -> List[Dict]: """ Get Greeks for multiple option conids. """ response = self._post("/options/greeks", {"conids": conids}) return response.get("greeks", []) def get_greeks(self, conid: int) -> Greeks: """ Get Greeks for an option contract. :param conid: Option contract ID :return: Greeks object """ response = self._get(f"/options/greeks/{conid}") return Greeks( conid=response.get("conid", conid), delta=response.get("delta"), gamma=response.get("gamma"), theta=response.get("theta"), vega=response.get("vega"), iv=response.get("iv") ) def get_option_expirations( self, symbol: str, min_dte: Optional[int] = None, max_dte: Optional[int] = None, ) -> List[str]: """ Get all available expiration dates for a symbol. :param symbol: Underlying symbol :param min_dte: Optional minimum DTE filter :param max_dte: Optional maximum DTE filter :return: List of expiration dates (YYYYMMDD format) """ params = {} if min_dte is not None: params["min_dte"] = int(min_dte) if max_dte is not None: params["max_dte"] = int(max_dte) response = self._get(f"/options/expirations/{symbol}", params=params or None) return response.get("expirations", []) def get_option_by_delta( self, symbol: str, expiry: str, target_delta: float, right: str, trading_class: Optional[str] = None ) -> Optional[Dict]: """ Find option contract by target delta. :param symbol: Underlying symbol :param expiry: Expiration date (YYYYMMDD) :param target_delta: Target delta (e.g., 0.30 for 30-delta) :param right: "C" for call, "P" for put :param trading_class: Trading class (e.g., "SPXW") :return: Option contract matching target delta or None """ payload = { "symbol": symbol, "expiry": expiry, "target_delta": target_delta, "right": right.upper() } if trading_class: payload["trading_class"] = trading_class # Server returns contract directly, not wrapped return self._post("/options/by-delta", payload) def get_atm_strike(self, symbol: str, expiry: str) -> Optional[float]: """ Get the at-the-money strike price for a symbol. :param symbol: Underlying symbol :param expiry: Expiration date (YYYYMMDD) - required :return: ATM strike price """ response = self._get(f"/options/atm/{symbol}", params={"expiry": expiry}) return response.get("atm_strike") def get_option_oi(self, symbol: str, expiry: str) -> Dict: """ Get open interest for options. :param symbol: Underlying symbol :param expiry: Expiration date (YYYYMMDD) - required :return: Dictionary with open interest data """ return self._get(f"/options/oi/{symbol}", params={"expiry": expiry}) def get_option_volume(self, symbol: str, expiry: str) -> Dict: """ Get volume for options. :param symbol: Underlying symbol :param expiry: Expiration date (YYYYMMDD) - required :return: Dictionary with volume data """ return self._get(f"/options/volume/{symbol}", params={"expiry": expiry}) # ==================== P&L Methods ==================== def get_pnl(self) -> Dict: """ Get portfolio-level P&L (daily, realized, unrealized). :return: Dictionary with daily_pnl, realized_pnl, unrealized_pnl, total_pnl """ return self._get("/account/pnl") def get_position_pnl(self, symbol: str) -> Dict: """ Get P&L for a specific position. :param symbol: Symbol to get P&L for :return: Dictionary with position P&L details """ return self._get(f"/positions/{symbol}/pnl") def get_margin(self) -> Dict: """ Get account margin information. :return: Dictionary with margin requirements and buying power details """ return self._get("/account/margin") # ==================== Market Data Methods (Extended) ==================== def get_market_depth(self, conid: int, rows: int = 5) -> Dict: """ Get market depth (Level 2 data) for a contract. :param conid: Contract ID :param rows: Number of depth levels (default 5, max 20) :return: Dictionary with bids and asks arrays """ return self._get(f"/market/depth/{conid}", params={"rows": rows}) def get_last_trade(self, conid: int) -> Dict: """ Get last trade details for a contract. :param conid: Contract ID :return: Dictionary with price, size, time, exchange """ return self._get(f"/market/last-trade/{conid}") def get_ticks( self, conid: int, start: str, end: str, tick_type: str = "TRADES" ) -> Dict: """ Get historical tick data. :param conid: Contract ID :param start: Start datetime (YYYYMMDD-HH:MM:SS) :param end: End datetime (YYYYMMDD-HH:MM:SS) :param tick_type: "TRADES", "BID_ASK", or "MIDPOINT" :return: Dictionary with ticks array """ return self._get(f"/market/ticks/{conid}", params={ "start": start, "end": end, "tick_type": tick_type }) def get_halt_status(self, conid: int) -> Dict: """ Check if a contract is halted. :param conid: Contract ID :return: Dictionary with halted status and reason """ return self._get(f"/market/halt/{conid}") def is_halted(self, conid: int) -> bool: """ Check if a contract is currently halted. :param conid: Contract ID :return: True if halted """ response = self.get_halt_status(conid) return response.get("is_halted", False) def get_trading_hours(self, symbol: str) -> Dict: """ Get trading hours for a symbol. :param symbol: Symbol to check :return: Dictionary with trading hours info """ return self._get(f"/market/hours/{symbol}") def is_market_open_for(self, symbol: str) -> bool: """ Check if market is currently open for a specific symbol. :param symbol: Symbol to check :return: True if market is open """ response = self._get(f"/market/is-open/{symbol}") return response.get("is_open", False) # ==================== Contract Methods ==================== def search_contracts( self, query: str, sec_type: Optional[str] = None, exchange: Optional[str] = None, country: Optional[str] = None, currency: Optional[str] = None, primary_exchange: Optional[str] = None, expiry: Optional[str] = None, local_symbol: Optional[str] = None, limit: int = 10 ) -> List[Dict]: """ Search for contracts by name or symbol. :param query: Search query :param sec_type: Security type filter (STK, OPT, FUT, etc.) :param exchange: Exchange filter :param limit: Maximum results :return: List of contract dictionaries """ params = {"query": query, "limit": limit} if sec_type: params["sec_type"] = sec_type if exchange: params["exchange"] = exchange if country: params["country"] = country if currency: params["currency"] = currency if primary_exchange: params["primary_exchange"] = primary_exchange if expiry: params["expiry"] = expiry if local_symbol: params["local_symbol"] = local_symbol response = self._get("/contracts/search", params=params) return response.get("contracts", []) def get_contract_details(self, conid: int) -> Dict: """ Get full contract details. :param conid: Contract ID :return: Dictionary with full contract details """ return self._get(f"/contracts/{conid}") def qualify_contract( self, symbol: str, sec_type: str = "STK", exchange: str = "SMART", currency: str = "USD", country: Optional[str] = None, primary_exchange: Optional[str] = None, expiry: Optional[str] = None, local_symbol: Optional[str] = None, ) -> ContractRef: """ Resolve a symbol into a normalized contract reference. """ requested_sec_type = str(sec_type or "").upper() effective_exchange = exchange effective_currency = currency if ( requested_sec_type == "IND" and str(exchange or "").upper() == "SMART" and str(currency or "").upper() == "USD" and not country and not primary_exchange ): effective_exchange = None effective_currency = None conid = self.get_conid( symbol, sec_type=sec_type, country=country, currency=effective_currency, exchange=effective_exchange, primary_exchange=primary_exchange, expiry=expiry, local_symbol=local_symbol, ) if not conid: raise ValueError(f"Could not qualify contract for {symbol} ({sec_type})") details = self.get_contract_details(conid) secdef = details.get("secdef", [{}]) info = secdef[0] if secdef else {} return ContractRef( conid=int(info.get("conid") or conid), symbol=info.get("ticker") or symbol, sec_type=info.get("assetClass") or sec_type, exchange=info.get("exchange") or exchange, currency=info.get("currency") or currency, listing_exchange=info.get("listingExchange"), country=info.get("countryCode") or country, trading_class=info.get("tradingClass"), local_symbol=info.get("localSymbol"), expiry=info.get("lastTradingDay") or info.get("lastTradingDayOrContractMonth") or expiry, raw=details, ) def qualify_futures_contract( self, symbol: str, expiry: Optional[str] = None, exchange: Optional[str] = None, local_symbol: Optional[str] = None, currency: Optional[str] = None, ) -> ContractRef: """ Resolve a futures contract into a normalized contract reference. """ return self.qualify_contract( symbol, sec_type="FUT", exchange=exchange or "CME", currency=currency or "USD", expiry=expiry, local_symbol=local_symbol, ) def qualify_crypto_contract(self, symbol: str, exchange: str = "PAXOS", currency: str = "USD") -> ContractRef: """Resolve a crypto contract (e.g. BTC, ETH) into a contract reference.""" return self.qualify_contract(symbol, sec_type="CRYPTO", exchange=exchange, currency=currency) def qualify_cfd_contract(self, symbol: str, exchange: str = "SMART", currency: str = "USD") -> ContractRef: """Resolve a CFD contract into a contract reference.""" return self.qualify_contract(symbol, sec_type="CFD", exchange=exchange, currency=currency) def qualify_bond_contract(self, symbol: str, exchange: str = "SMART", currency: str = "USD") -> ContractRef: """Resolve a bond contract into a contract reference.""" return self.qualify_contract(symbol, sec_type="BOND", exchange=exchange, currency=currency) def qualify_mutual_fund_contract(self, symbol: str, exchange: str = "FUNDSERV", currency: str = "USD") -> ContractRef: """Resolve a mutual-fund contract into a contract reference.""" return self.qualify_contract(symbol, sec_type="FUND", exchange=exchange, currency=currency) def qualify_warrant_contract(self, symbol: str, exchange: str = "SMART", currency: str = "USD") -> ContractRef: """Resolve a warrant contract into a contract reference.""" return self.qualify_contract(symbol, sec_type="WAR", exchange=exchange, currency=currency) def qualify_commodity_contract(self, symbol: str, exchange: str = "SMART", currency: str = "USD") -> ContractRef: """Resolve a commodity (metals etc.) contract into a contract reference.""" return self.qualify_contract(symbol, sec_type="CMDTY", exchange=exchange, currency=currency) def qualify_contfuture_contract(self, symbol: str, exchange: str = "CME", currency: str = "USD") -> ContractRef: """Resolve a continuous-futures contract into a contract reference.""" return self.qualify_contract(symbol, sec_type="CONTFUT", exchange=exchange, currency=currency) # ==================== Option Calc / Market Structure ==================== def compute_iv_given_price(self, conid: int, option_price: float, underlying_price: float, risk_free_rate: float = 0.04, dividend_yield: float = 0.0) -> Dict: """What-if: solve implied volatility for a hypothetical option price (server-side Black-Scholes).""" return self._post("/options/calculate", { "conid": conid, "option_price": option_price, "underlying_price": underlying_price, "risk_free_rate": risk_free_rate, "dividend_yield": dividend_yield, }) def compute_price_given_iv(self, conid: int, implied_vol: float, underlying_price: float, risk_free_rate: float = 0.04, dividend_yield: float = 0.0) -> Dict: """What-if: theoretical option price for a hypothetical IV (server-side Black-Scholes).""" return self._post("/options/calculate", { "conid": conid, "implied_vol": implied_vol, "underlying_price": underlying_price, "risk_free_rate": risk_free_rate, "dividend_yield": dividend_yield, }) def get_contract_schedule(self, symbol: str, asset_class: str = "STK", exchange: Optional[str] = None) -> Dict: """Trading schedule for a symbol (IBKR /trsrv/secdef/schedule).""" params = {"asset_class": asset_class} if exchange: params["exchange"] = exchange return self._get(f"/market/schedule/{symbol}", params=params) def get_market_rule(self, conid: int, is_buy: bool = True) -> Dict: """Order rules / tick increments for a contract (IBKR /iserver/contract/rules).""" return self._post("/market/rules", {"conid": conid, "is_buy": is_buy}) # ==================== Calendar Methods ==================== def get_trading_calendar(self, days: int = 30) -> Dict: """ Get trading calendar with holidays and early closes. :param days: Number of days to return (default 30, max 365) :return: Dictionary with dates array containing trading day info """ return self._get("/market/calendar", params={"days": days}) def get_next_market_open(self, exchange: str = "NYSE") -> Dict: """ Get the next market open time. :param exchange: Exchange (default NYSE) :return: Dictionary with next_open timestamp and is_open status """ return self._get("/market/next-open", params={"exchange": exchange}) def get_next_market_close(self, exchange: str = "NYSE") -> Dict: """ Get the next market close time. :param exchange: Exchange (default NYSE) :return: Dictionary with next_close timestamp and is_early_close status """ return self._get("/market/next-close", params={"exchange": exchange}) def count_trading_days(self, start: str, end: str) -> int: """ Count trading days between two dates. :param start: Start date (YYYY-MM-DD) :param end: End date (YYYY-MM-DD) :return: Number of trading days """ response = self._get("/market/trading-days", params={ "start": start, "end": end }) return response.get("trading_days", 0) # ==================== Order Methods ==================== def place_order( self, symbol: str, quantity: float, side: str, order_type: str = "MKT", price: Optional[float] = None, aux_price: Optional[float] = None, conid: Optional[int] = None, force: bool = True, tif: Optional[str] = None, exchange: Optional[str] = None, country: Optional[str] = None, currency: Optional[str] = None, primary_exchange: Optional[str] = None, sec_type: Optional[str] = None, expiry: Optional[str] = None, local_symbol: Optional[str] = None, order_params: Optional[Dict[str, Any]] = None, ) -> OrderResult: """ Place an order. :param symbol: Stock/contract symbol :param quantity: Number of shares/contracts (float for fractional/crypto) :param side: "BUY" or "SELL" :param order_type: "MKT", "LMT", "STP", "STP LMT", "REL", or "MIDPRICE" :param price: Limit price (for LMT/STP LMT orders) :param aux_price: Stop trigger price (for STP/STP LMT orders) :param conid: Contract ID (optional, looked up if not provided) :param force: Auto-confirm IBKR warning prompts (e.g., no market data) :param order_params: Extra IBKR order fields. Whitelisted server-side; allowed keys: hidden, allOrNone, notHeld, goodTillDate, displaySize, minQty, percentOffset, outsideRTH, discretionaryAmt, sweepToFill, blockOrder. :return: OrderResult object """ payload = { "symbol": symbol, "quantity": quantity, "side": side.upper(), "order_type": order_type.upper() } if price is not None: payload["price"] = price if aux_price is not None: payload["aux_price"] = aux_price if conid: payload["conid"] = conid if force: payload["force"] = True if tif: payload["tif"] = tif if exchange: payload["exchange"] = exchange if country: payload["country"] = country if currency: payload["currency"] = currency if primary_exchange: payload["primary_exchange"] = primary_exchange if sec_type: payload["sec_type"] = sec_type if expiry: payload["expiry"] = expiry if local_symbol: payload["local_symbol"] = local_symbol if order_params: payload["order_params"] = dict(order_params) # Include strategy_id as customer order reference for tracking if self.strategy_id: payload["cOID"] = f"{self.strategy_id}-{int(time.time() * 1000)}" payload["customer_order_id"] = self.strategy_id try: response = self._post("/orders", payload) except Exception as e: return OrderResult(success=False, message=str(e)) result = response.get("result") if isinstance(response, dict) else None def _get_order_id(obj): if isinstance(obj, dict): # Prefer order_id/orderId over id (id is often reply UUID, not order ID) oid = obj.get("order_id") or obj.get("orderId") if oid: return oid # Only use 'id' if it looks like a numeric order ID, not a UUID raw_id = obj.get("id") if raw_id and str(raw_id).isdigit(): return raw_id # Check for nested 'result' (IBKR sometimes returns {"result": [...], "status": "success"}) nested_result = obj.get("result") if nested_result: return _get_order_id(nested_result) return None if isinstance(obj, list): for item in obj: if isinstance(item, dict): oid = _get_order_id(item) if oid: return oid return None def _get_message(resp, res): if isinstance(resp, dict): msg = resp.get("message") or resp.get("detail") if msg: return msg if isinstance(res, dict): msg = res.get("message") or res.get("detail") if msg: return msg if isinstance(res, list) and res: first = res[0] if isinstance(first, dict): msg = first.get("message") if isinstance(msg, list): return " ".join(str(m) for m in msg) if msg: return msg return None def _get_error(resp, res): """Check for IBKR error in response or result.""" # Check top-level response for error if isinstance(resp, dict): err = resp.get("error") if err: return err # Check result for error (e.g., {'status': 'success', 'result': {'error': '...'}}) if isinstance(res, dict): err = res.get("error") if err: return err return None order_id = _get_order_id(result) message = _get_message(response, result) error = _get_error(response, result) # If there's an error in the result, treat as failure if error: return OrderResult( success=False, order_id=None, message=str(error), raw=response ) return OrderResult( success=isinstance(response, dict) and response.get("status") == "success" and order_id is not None, order_id=str(order_id) if order_id else None, message=str(message) if message else None, raw=response ) def place_contract_order( self, contract: Union[ContractRef, Dict[str, Any]], side: str, quantity: float, order_type: str = "MKT", price: Optional[float] = None, aux_price: Optional[float] = None, force: bool = True, tif: Optional[str] = None, exchange: Optional[str] = None, ) -> OrderResult: """ Place an order using a normalized contract object/reference. """ if isinstance(contract, ContractRef): conid = contract.conid symbol = contract.symbol exchange_value = exchange or contract.exchange elif isinstance(contract, dict): conid = contract.get("conid") symbol = contract.get("symbol") or contract.get("ticker") exchange_value = exchange or contract.get("exchange") or contract.get("listingExchange") or "SMART" else: raise TypeError("contract must be a ContractRef or dict") if not conid or not symbol: raise ValueError("contract must include both conid and symbol") return self.place_order( symbol=str(symbol), quantity=quantity, side=side, order_type=order_type, price=price, aux_price=aux_price, conid=int(conid), force=force, tif=tif, exchange=exchange_value, ) def buy( self, symbol: str, quantity: float, order_type: str = "MKT", price: Optional[float] = None, force: bool = True, sec_type: Optional[str] = None, exchange: Optional[str] = None, expiry: Optional[str] = None, local_symbol: Optional[str] = None, conid: Optional[int] = None, ) -> OrderResult: """ Buy shares/contracts (convenience method). :param symbol: Stock/contract symbol :param quantity: Number of shares/contracts (float for fractional/crypto) :param order_type: "MKT" or "LMT" :param price: Limit price (for LMT orders) :param expiry: Contract expiry (required for futures when conid/local_symbol not given) :param local_symbol: IBKR local symbol (e.g. "MBTM6") — preferred futures identifier :param conid: Contract ID (bypasses symbol resolution entirely) :return: OrderResult object """ return self.place_order( symbol, quantity, "BUY", order_type, price, force=force, sec_type=sec_type, exchange=exchange, expiry=expiry, local_symbol=local_symbol, conid=conid, ) def sell( self, symbol: str, quantity: float, order_type: str = "MKT", price: Optional[float] = None, force: bool = True, sec_type: Optional[str] = None, exchange: Optional[str] = None, expiry: Optional[str] = None, local_symbol: Optional[str] = None, conid: Optional[int] = None, ) -> OrderResult: """ Sell shares/contracts (convenience method). :param symbol: Stock/contract symbol :param quantity: Number of shares/contracts (float for fractional/crypto) :param order_type: "MKT" or "LMT" :param price: Limit price (for LMT orders) :param expiry: Contract expiry (required for futures when conid/local_symbol not given) :param local_symbol: IBKR local symbol (e.g. "MBTM6") — preferred futures identifier :param conid: Contract ID (bypasses symbol resolution entirely) :return: OrderResult object """ return self.place_order( symbol, quantity, "SELL", order_type, price, force=force, sec_type=sec_type, exchange=exchange, expiry=expiry, local_symbol=local_symbol, conid=conid, ) def buy_market( self, symbol: str, quantity: float, conid: Optional[int] = None, tif: Optional[str] = None, exchange: Optional[str] = None, country: Optional[str] = None, currency: Optional[str] = None, primary_exchange: Optional[str] = None, sec_type: Optional[str] = None, ) -> OrderResult: """Place a market buy order.""" return self.place_order( symbol, quantity, "BUY", "MKT", conid=conid, tif=tif, exchange=exchange, country=country, currency=currency, primary_exchange=primary_exchange, sec_type=sec_type, ) def sell_market( self, symbol: str, quantity: float, conid: Optional[int] = None, tif: Optional[str] = None, exchange: Optional[str] = None, country: Optional[str] = None, currency: Optional[str] = None, primary_exchange: Optional[str] = None, sec_type: Optional[str] = None, ) -> OrderResult: """Place a market sell order.""" return self.place_order( symbol, quantity, "SELL", "MKT", conid=conid, tif=tif, exchange=exchange, country=country, currency=currency, primary_exchange=primary_exchange, sec_type=sec_type, ) def buy_limit( self, symbol: str, quantity: float, price: float, conid: Optional[int] = None, tif: Optional[str] = None, exchange: Optional[str] = None, country: Optional[str] = None, currency: Optional[str] = None, primary_exchange: Optional[str] = None, ) -> OrderResult: """Place a limit buy order.""" return self.place_order( symbol, quantity, "BUY", "LMT", price, conid=conid, tif=tif, exchange=exchange, country=country, currency=currency, primary_exchange=primary_exchange, ) def sell_limit( self, symbol: str, quantity: float, price: float, conid: Optional[int] = None, tif: Optional[str] = None, exchange: Optional[str] = None, country: Optional[str] = None, currency: Optional[str] = None, primary_exchange: Optional[str] = None, ) -> OrderResult: """Place a limit sell order.""" return self.place_order( symbol, quantity, "SELL", "LMT", price, conid=conid, tif=tif, exchange=exchange, country=country, currency=currency, primary_exchange=primary_exchange, ) def buy_stop( self, symbol: str, quantity: float, stop_price: float, conid: Optional[int] = None, tif: Optional[str] = None, exchange: Optional[str] = None, ) -> OrderResult: """Place a stop buy order.""" return self.place_order( symbol, quantity, "BUY", "STP", aux_price=stop_price, conid=conid, tif=tif, exchange=exchange ) def sell_stop( self, symbol: str, quantity: float, stop_price: float, conid: Optional[int] = None, tif: Optional[str] = None, exchange: Optional[str] = None, ) -> OrderResult: """Place a stop sell order.""" return self.place_order( symbol, quantity, "SELL", "STP", aux_price=stop_price, conid=conid, tif=tif, exchange=exchange ) def buy_stop_limit( self, symbol: str, quantity: float, stop_price: float, limit_price: float, conid: Optional[int] = None, tif: Optional[str] = None, exchange: Optional[str] = None, ) -> OrderResult: """ Place a stop-limit buy order. :param symbol: Stock/contract symbol :param quantity: Number of shares/contracts :param stop_price: Price that triggers the order (auxPrice) :param limit_price: Limit price for the order once triggered :return: OrderResult object """ return self.place_order( symbol, quantity, "BUY", "STP LMT", price=limit_price, aux_price=stop_price, conid=conid, tif=tif, exchange=exchange, ) def sell_stop_limit( self, symbol: str, quantity: float, stop_price: float, limit_price: float, conid: Optional[int] = None, tif: Optional[str] = None, exchange: Optional[str] = None, ) -> OrderResult: """ Place a stop-limit sell order. :param symbol: Stock/contract symbol :param quantity: Number of shares/contracts :param stop_price: Price that triggers the order (auxPrice) :param limit_price: Limit price for the order once triggered :return: OrderResult object """ return self.place_order( symbol, quantity, "SELL", "STP LMT", price=limit_price, aux_price=stop_price, conid=conid, tif=tif, exchange=exchange, ) def buy_trail( self, symbol: str, quantity: float, trail_amount: Optional[float] = None, trail_percent: Optional[float] = None, conid: Optional[int] = None, force: bool = True, ) -> OrderResult: """ Place a trailing stop buy order. :param symbol: Stock/contract symbol :param quantity: Number of shares/contracts :param trail_amount: Trail by fixed dollar amount :param trail_percent: Trail by percentage (0-100) :param conid: Optional contract ID :param force: Auto-confirm warnings (default True) :return: OrderResult """ return self.place_trailing_stop( symbol=symbol, quantity=quantity, side="BUY", trail_amount=trail_amount, trail_percent=trail_percent, conid=conid, force=force, ) def sell_trail( self, symbol: str, quantity: float, trail_amount: Optional[float] = None, trail_percent: Optional[float] = None, conid: Optional[int] = None, force: bool = True, ) -> OrderResult: """ Place a trailing stop sell order. :param symbol: Stock/contract symbol :param quantity: Number of shares/contracts :param trail_amount: Trail by fixed dollar amount :param trail_percent: Trail by percentage (0-100) :param conid: Optional contract ID :param force: Auto-confirm warnings (default True) :return: OrderResult """ return self.place_trailing_stop( symbol=symbol, quantity=quantity, side="SELL", trail_amount=trail_amount, trail_percent=trail_percent, conid=conid, force=force, ) def cancel_order(self, order_id: Union[str, int]) -> bool: """ Cancel an order. :param order_id: Order ID to cancel :return: True if successful :raises: Exception on network/server errors (not swallowed) """ response = self._delete(f"/orders/{order_id}") return response.get("status") == "success" def cancel_all_orders(self) -> int: """ Cancel all open orders in a SINGLE bulk request. Much more efficient than cancelling one by one. :return: Number of orders cancelled """ try: # Use bulk cancel endpoint - ONE API call response = self._delete("/orders") return response.get("cancelled_count", 0) except Exception: # Fallback to individual cancellation if bulk fails orders = self.get_orders() cancelled = 0 for order in orders: order_id = order.get("orderId") or order.get("order_id") if order_id: try: if self.cancel_order(order_id): cancelled += 1 except Exception: pass return cancelled def account_kill_switch(self, mode: Optional[str] = None) -> Dict: """ EMERGENCY: Cancel ALL orders and close ALL positions for the account. This is a full account liquidation — use with caution. If `mode` is omitted, paper/live/fund is inferred from this client's strategy_id. :param mode: "paper", "live", or "fund". If omitted, inferred from strategy_id. :return: Dict with keys: - status: "success" or "error" - result: - mode: "paper", "live", or "fund" - cancelled_orders: list of cancelled orders - closed_positions: list of closed positions - errors: list of errors - skipped_orders: orders already filled/cancelled - skipped_positions: zero-quantity or combo positions Example: result = client.account_kill_switch(mode="paper") print(f"Cancelled {len(result['result']['cancelled_orders'])} orders") print(f"Closed {len(result['result']['closed_positions'])} positions") """ endpoint = f"/kill/account?mode={mode}" if mode else "/kill/account" response = self._post(endpoint, {}) self.invalidate_position_cache() return response def flatten_all(self, mode: Optional[str] = None) -> Dict: """ EMERGENCY: Alias of account_kill_switch(). Cancel all orders and close all positions. :param mode: "paper", "live", or "fund". If omitted, inferred from strategy_id. :return: Results dictionary """ endpoint = f"/orders/flatten?mode={mode}" if mode else "/orders/flatten" response = self._post(endpoint, {}) self.invalidate_position_cache() return response def place_combo_order( self, legs: List[ComboLeg], quantity: float, side: str, order_type: str = "MKT", price: Optional[float] = None, force: bool = True, tif: str = "DAY", delta_neutral: Optional[Dict[str, Any]] = None, ) -> OrderResult: """ Place a combo (BAG) order. :param delta_neutral: Optional {conid, delta, price} hedge contract for delta-neutral combos (passed through to IBKR as deltaNeutralContract). :return: OrderResult with success, order_id, message, and raw response """ payload = { "legs": [ { "conid": leg.conid, "ratio": leg.ratio, "action": leg.action, "exchange": leg.exchange } for leg in legs ], "quantity": quantity, "side": side.upper(), "order_type": order_type.upper(), "tif": tif } if price is not None: payload["price"] = price if delta_neutral: payload["delta_neutral"] = dict(delta_neutral) if force: payload["force"] = True if self.strategy_id: payload["cOID"] = f"{self.strategy_id}-{int(time.time() * 1000)}" payload["customer_order_id"] = self.strategy_id try: response = self._post("/orders/combo", payload) except Exception as e: return OrderResult(success=False, message=str(e)) # Check for error in response (IBKR rejection) if isinstance(response, dict): error_msg = response.get("error") result = response.get("result") if isinstance(result, dict) and result.get("error"): error_msg = result.get("error") if error_msg: return OrderResult( success=False, order_id=None, message=str(error_msg), raw=response ) # Extract order_id from response (server now returns it at top level) order_id = None if isinstance(response, dict): # Prefer top-level order_id from updated server response order_id = response.get("order_id") or response.get("orderId") if not order_id: # Fallback to nested result result = response.get("result") if isinstance(result, list) and result: order_id = result[0].get("order_id") or result[0].get("orderId") elif isinstance(result, dict): order_id = result.get("order_id") or result.get("orderId") is_success = isinstance(response, dict) and response.get("status") == "success" and order_id is not None return OrderResult( success=is_success, order_id=str(order_id) if order_id else None, message=response.get("message") if isinstance(response, dict) else None, raw=response ) # ==================== Order Lifecycle Methods ==================== def get_order_status(self, order_id: Union[str, int]) -> Dict: """ Get detailed status of an order including fills. :param order_id: Order ID :return: Order status dictionary with status, fills, filled_qty, avg_fill_price """ return self._get(f"/orders/{order_id}") def wait_for_fill( self, order_id: Union[str, int], timeout: int = 60, poll_interval: float = 0.5 ) -> Dict: """ Wait for an order to fill (or timeout). :param order_id: Order ID :param timeout: Maximum wait time in seconds :param poll_interval: How often to poll (seconds) :return: Order status dictionary """ return self._get(f"/orders/{order_id}/wait", params={ "timeout": timeout, "poll_interval": poll_interval }) def wait_for_submitted( self, order_id: Union[str, int], timeout: int = 30, poll_interval: float = 0.3 ) -> bool: """ Wait for an order to be submitted/acknowledged by the exchange. Critical for multi-leg options strategies to ensure first leg is accepted before placing subsequent legs. Matches ib_insync pattern: while order.orderStatus.status not in ('Submitted', 'Filled'): await asyncio.sleep(0.1) :param order_id: Order ID to wait for :param timeout: Maximum wait time in seconds (default 30) :param poll_interval: How often to poll (seconds, default 0.3) :return: True if order was submitted/filled, False if timeout or error """ start_time = time.time() submitted_statuses = {'submitted', 'presubmitted', 'filled', 'partialfilled'} while time.time() - start_time < timeout: try: status_response = self.get_order_status(order_id) status = status_response.get("status", "").lower() if status in submitted_statuses: return True # Check for rejection/cancellation - don't keep waiting if status in {'cancelled', 'canceled', 'rejected', 'error', 'inactive'}: return False except Exception: pass # Network error, keep trying time.sleep(poll_interval) return False # Timeout def modify_order( self, order_id: Union[str, int], price: Optional[float] = None, aux_price: Optional[float] = None, quantity: Optional[float] = None ) -> Dict: """ Modify an existing order. :param order_id: Order ID to modify :param price: New limit price :param aux_price: New stop price :param quantity: New quantity :return: Modification result """ payload = {} if price is not None: payload["price"] = price if aux_price is not None: payload["aux_price"] = aux_price if quantity is not None: payload["quantity"] = quantity return self._put(f"/orders/{order_id}", payload) # ==================== Advanced Order Types ==================== def place_bracket_order( self, symbol: str, quantity: float, side: str, entry_price: float, stop_loss: float, take_profit: float, entry_type: str = "LMT", conid: Optional[int] = None, force: bool = False ) -> Dict: """ Place a bracket order (entry + stop loss + take profit). :param symbol: Stock/contract symbol :param quantity: Number of shares/contracts :param side: "BUY" or "SELL" :param entry_price: Entry limit price :param stop_loss: Stop loss price :param take_profit: Take profit price :param entry_type: Entry order type (default "LMT") :param conid: Optional contract ID :param force: Auto-confirm warnings :return: Order result with parent and child order IDs """ payload = { "symbol": symbol, "quantity": quantity, "side": side.upper(), "entry_price": entry_price, "entry_type": entry_type, "stop_loss": stop_loss, "take_profit": take_profit } if conid: payload["conid"] = conid if force: payload["force"] = True if self.strategy_id: payload["cOID"] = f"{self.strategy_id}-{int(time.time() * 1000)}" payload["customer_order_id"] = self.strategy_id return self._post("/orders/bracket", payload) def place_trailing_stop( self, symbol: str, quantity: float, side: str, trail_amount: Optional[float] = None, trail_percent: Optional[float] = None, limit_offset: Optional[float] = None, conid: Optional[int] = None, force: bool = False ) -> OrderResult: """ Place a trailing stop order. :param symbol: Stock/contract symbol :param quantity: Number of shares/contracts :param side: "BUY" or "SELL" :param trail_amount: Trail by fixed dollar amount :param trail_percent: Trail by percentage (0-100) :param limit_offset: Optional limit offset for trailing stop-limit :param conid: Optional contract ID :param force: Auto-confirm warnings :return: OrderResult """ payload = { "symbol": symbol, "quantity": quantity, "side": side.upper() } if trail_amount is not None: payload["trail_amount"] = trail_amount if trail_percent is not None: payload["trail_percent"] = trail_percent if limit_offset is not None: payload["limit_offset"] = limit_offset if conid: payload["conid"] = conid if force: payload["force"] = True if self.strategy_id: payload["cOID"] = f"{self.strategy_id}-{int(time.time() * 1000)}" payload["customer_order_id"] = self.strategy_id try: response = self._post("/orders/trailing", payload) return OrderResult( success=response.get("status") == "success", order_id=response.get("order_id"), message=response.get("message"), raw=response ) except Exception as e: return OrderResult(success=False, message=str(e)) def place_adaptive_order( self, symbol: str, quantity: float, side: str, priority: str = "Normal", order_type: str = "MKT", price: Optional[float] = None, conid: Optional[int] = None, force: bool = False ) -> OrderResult: """ Place an IBKR adaptive algo order. :param symbol: Stock/contract symbol :param quantity: Number of shares/contracts :param side: "BUY" or "SELL" :param priority: "Urgent", "Normal", or "Patient" :param order_type: "MKT" or "LMT" :param price: Limit price (for LMT orders) :param conid: Optional contract ID :param force: Auto-confirm warnings :return: OrderResult """ payload = { "symbol": symbol, "quantity": quantity, "side": side.upper(), "priority": priority, "order_type": order_type, } if price is not None: payload["max_price"] = price if conid: payload["conid"] = conid if force: payload["force"] = True if self.strategy_id: payload["cOID"] = f"{self.strategy_id}-{int(time.time() * 1000)}" payload["customer_order_id"] = self.strategy_id try: response = self._post("/orders/algo/adaptive", payload) return OrderResult( success=response.get("status") == "success", order_id=response.get("order_id"), message=response.get("message"), raw=response ) except Exception as e: return OrderResult(success=False, message=str(e)) def place_algo_order( self, symbol: str, quantity: float, side: str, algo_strategy: str, algo_params: Optional[Dict[str, Any]] = None, order_type: str = "MKT", price: Optional[float] = None, tif: Optional[str] = "DAY", conid: Optional[int] = None, force: bool = False, ) -> OrderResult: """ Place a generic IBKR algo order (TWAP, VWAP, ArrivalPrice, DarkIce, etc.). :param algo_strategy: IBKR algo strategy name (e.g. "Twap", "Vwap", "Adaptive") :param algo_params: Strategy-specific tag/value pairs (e.g. {"startTime": "...", "noTakeLiq": "1"}) """ payload = { "symbol": symbol, "quantity": quantity, "side": side.upper(), "algo_strategy": algo_strategy, "algo_params": algo_params or {}, "order_type": order_type.upper(), "tif": tif or "DAY", } if price is not None: payload["price"] = price if conid: payload["conid"] = conid if force: payload["force"] = True if self.strategy_id: payload["cOID"] = f"{self.strategy_id}-{int(time.time() * 1000)}" payload["customer_order_id"] = self.strategy_id try: response = self._post("/orders/algo", payload) return OrderResult( success=response.get("status") in ("success", "confirmed"), order_id=response.get("order_id"), message=response.get("message"), raw=response, ) except Exception as e: return OrderResult(success=False, message=str(e)) def place_oca_order( self, group_name: str, orders: List[Dict[str, Any]], oca_type: int = 1, force: bool = False, ) -> OrderResult: """ Place a group of orders linked by OCA (One-Cancels-All). When one leg fills, the others are cancelled (oca_type=1) or reduced (oca_type=2 or 3) per IBKR semantics. :param group_name: Unique identifier for the OCA group :param orders: List of leg dicts. Each leg accepts the same keys as place_order (symbol, quantity, side, order_type, price, aux_price, conid, etc.). :param oca_type: 1=CancelWithBlock, 2=ReduceWithBlock, 3=ReduceNonBlock :param force: Auto-confirm IBKR warning prompts :return: OrderResult (aggregate — fields apply to the IBKR response for the batch) """ if not orders or len(orders) < 2: return OrderResult(success=False, message="OCA group requires at least 2 legs") payload: Dict[str, Any] = { "group_name": group_name, "oca_type": oca_type, "orders": [dict(leg) for leg in orders], "force": force, } if self.strategy_id: payload["cOID"] = f"{self.strategy_id}-{int(time.time() * 1000)}" payload["customer_order_id"] = self.strategy_id try: response = self._post("/orders/oca", payload) return OrderResult( success=response.get("status") in ("success", "confirmed"), order_id=response.get("order_id"), message=response.get("message"), raw=response, ) except Exception as e: return OrderResult(success=False, message=str(e)) def place_midprice_order( self, symbol: str, quantity: float, side: str, price: Optional[float] = None, conid: Optional[int] = None, force: bool = True, sec_type: Optional[str] = None, exchange: Optional[str] = None, ) -> OrderResult: """ Place a MidPrice order — IBKR pegs to the midpoint of the NBBO. :param price: Optional price cap (max for BUY / min for SELL) """ return self.place_order(symbol, quantity, side, order_type="MIDPRICE", price=price, conid=conid, force=force, sec_type=sec_type, exchange=exchange) def place_relative_order( self, symbol: str, quantity: float, side: str, offset: float, limit_cap: Optional[float] = None, conid: Optional[int] = None, force: bool = True, sec_type: Optional[str] = None, exchange: Optional[str] = None, ) -> OrderResult: """ Place a Relative (pegged-to-primary) order. :param offset: Amount to add to the bid (BUY) / subtract from ask (SELL) :param limit_cap: Optional absolute limit price cap """ return self.place_order(symbol, quantity, side, order_type="REL", price=limit_cap, aux_price=offset, conid=conid, force=force, sec_type=sec_type, exchange=exchange) def get_order_impact( self, symbol: str, quantity: float, side: str, order_type: str = "MKT", price: Optional[float] = None, conid: Optional[int] = None ) -> Dict: """ Get what-if margin impact for a hypothetical order. :param symbol: Stock/contract symbol :param quantity: Number of shares/contracts :param side: "BUY" or "SELL" :param order_type: Order type :param price: Limit price :param conid: Optional contract ID :return: Dictionary with margin impact details """ payload = { "symbol": symbol, "quantity": quantity, "side": side.upper(), "order_type": order_type.upper() } if price is not None: payload["price"] = price if conid: payload["conid"] = conid return self._post("/orders/impact", payload) # ==================== Options Structure Orders ==================== def place_vertical_spread( self, symbol: str, expiry: str, long_strike: float, short_strike: float, right: str, quantity: float, trading_class: Optional[str] = None, order_type: str = "MKT", price: Optional[float] = None, force: bool = False ) -> Dict: """ Place a vertical spread (bull/bear spread). :param symbol: Underlying symbol :param expiry: Expiration date (YYYYMMDD) :param long_strike: Strike to buy :param short_strike: Strike to sell :param right: "C" for call spread, "P" for put spread :param quantity: Number of spreads :param trading_class: Trading class (e.g., "SPXW") :param order_type: "MKT" or "LMT" :param price: Net debit/credit for LMT orders :param force: Auto-confirm warnings :return: Order result """ payload = { "symbol": symbol, "expiry": expiry, "long_strike": long_strike, "short_strike": short_strike, "right": right.upper(), "quantity": quantity, "order_type": order_type.upper() } if trading_class: payload["trading_class"] = trading_class if price is not None: payload["price"] = price if force: payload["force"] = True if self.strategy_id: payload["cOID"] = f"{self.strategy_id}-{int(time.time() * 1000)}" payload["customer_order_id"] = self.strategy_id return self._post("/orders/vertical-spread", payload) def place_iron_condor( self, symbol: str, expiry: str, put_long_strike: float, put_short_strike: float, call_short_strike: float, call_long_strike: float, quantity: float, trading_class: Optional[str] = None, order_type: str = "MKT", price: Optional[float] = None, force: bool = False ) -> Dict: """ Place an iron condor (sell put spread + sell call spread). :param symbol: Underlying symbol :param expiry: Expiration date (YYYYMMDD) :param put_long_strike: Long put strike (lowest) :param put_short_strike: Short put strike :param call_short_strike: Short call strike :param call_long_strike: Long call strike (highest) :param quantity: Number of iron condors :param trading_class: Trading class (e.g., "SPXW") :param order_type: "MKT" or "LMT" :param price: Net credit for LMT orders :param force: Auto-confirm warnings :return: Order result """ payload = { "symbol": symbol, "expiry": expiry, "put_long_strike": put_long_strike, "put_short_strike": put_short_strike, "call_short_strike": call_short_strike, "call_long_strike": call_long_strike, "quantity": quantity, "order_type": order_type.upper() } if trading_class: payload["trading_class"] = trading_class if price is not None: payload["price"] = price if force: payload["force"] = True if self.strategy_id: payload["cOID"] = f"{self.strategy_id}-{int(time.time() * 1000)}" payload["customer_order_id"] = self.strategy_id return self._post("/orders/iron-condor", payload) # ==================== Deprecated Aliases (Backward Compatibility) ==================== def strategy_kill_switch(self) -> Dict: """Deprecated: use flatten_all() or account_kill_switch() instead.""" warnings.warn( "strategy_kill_switch() is deprecated and will be removed in a future version. " "Use flatten_all() instead.", DeprecationWarning, stacklevel=2, ) return self.flatten_all() def get_option_greeks(self, conid: int) -> Greeks: """Alias for get_greeks().""" return self.get_greeks(conid) def get_expirations( self, symbol: str, min_dte: Optional[int] = None, max_dte: Optional[int] = None, ) -> List[str]: """Alias for get_option_expirations().""" return self.get_option_expirations(symbol, min_dte=min_dte, max_dte=max_dte) # ==================== Utility Methods ==================== def get_status(self) -> Dict: """ Get execution engine status. :return: Status dictionary """ return self._get("/status") def get_market_time(self) -> MarketTime: """ Get current market time and status. :return: MarketTime object """ response = self._get("/time") return MarketTime( eastern_time=response.get("eastern_time", ""), is_weekday=response.get("is_weekday", False), is_market_hours=response.get("is_market_hours", False), market_open=response.get("market_open", ""), market_close=response.get("market_close", "") ) def is_market_open(self) -> bool: """ Check if market is currently open. :return: True if market is open """ return self.get_market_time().is_market_hours def get_eastern_time(self) -> datetime: """ Get current Eastern time. :return: datetime in US/Eastern timezone """ eastern = pytz.timezone("US/Eastern") return datetime.now(eastern) def is_within_trade_window(self, start: dtime, end: dtime) -> bool: """ Check if current time is within a trade window. Matches the is_within_trade_window() pattern from strategies. :param start: Window start time (Eastern) :param end: Window end time (Eastern) :return: True if within window """ now = self.get_eastern_time().time() return start <= now <= end def is_eod(self, eod_time: dtime = dtime(15, 30)) -> bool: """ Check if it's end of day. Matches the is_eod() pattern from strategies. :param eod_time: EOD time (default 15:30 Eastern) :return: True if past EOD time """ now = self.get_eastern_time().time() return now >= eod_time def is_connected(self) -> bool: """ Check if execution engine is connected to IBKR. :return: True if connected """ try: status = self.get_status() mode = self._mode() broker_ready = status.get("broker_ready") if isinstance(broker_ready, dict): return bool(broker_ready.get(mode, False)) authenticated = status.get("ibkr_authenticated") connected = status.get("ibkr_connected") if isinstance(authenticated, dict) and isinstance(connected, dict): return bool(authenticated.get(mode, False) and connected.get(mode, False)) return bool(authenticated) except Exception: return False def get_connection_status(self) -> Dict[str, Any]: """ Get current engine/broker connectivity status for this client's mode. """ status = self.get_status() mode = self._mode() return { "mode": mode, "engine_running": status.get("status") == "running", "ibkr_connected": bool((status.get("ibkr_connected") or {}).get(mode, False)), "ibkr_authenticated": bool((status.get("ibkr_authenticated") or {}).get(mode, False)), "account_selected": bool((status.get("account_selected") or {}).get(mode, False)), "broker_ready": bool((status.get("broker_ready") or {}).get(mode, False)), "timestamp": status.get("timestamp"), } # ==================== Streaming Methods (Async) ==================== async def stream_quotes( self, symbols: List[str], callback: Callable[[Dict], None], on_connected: Optional[Callable[[], None]] = None ): """ Stream real-time quotes via WebSocket. :param symbols: List of symbols to stream :param callback: Function to call with each quote update :param on_connected: Optional callback when connected """ ws_url = self.base_url.replace("http://", "ws://").replace("https://", "wss://") ws_url = f"{ws_url}/ws/market" async with aiohttp.ClientSession(connector=aiohttp.TCPConnector(ssl=ssl.create_default_context(cafile=certifi.where()))) as session: async with session.ws_connect(ws_url, headers=self._headers()) as ws: if on_connected: on_connected() # Subscribe to symbols await ws.send_str(json.dumps({ "action": "subscribe", "symbols": symbols, "strategy_id": self.strategy_id })) # Process incoming messages async for msg in ws: if msg.type == aiohttp.WSMsgType.TEXT: data = json.loads(msg.data) if data.get("type") == "market_data": callback(data.get("data", {})) elif data.get("type") == "error": _raise_ws_stream_error(data) elif msg.type == aiohttp.WSMsgType.ERROR: break async def stream_order_updates( self, callback: Callable[[Dict], None], on_connected: Optional[Callable[[], None]] = None ): """ Stream order updates via WebSocket. """ ws_url = self.base_url.replace("http://", "ws://").replace("https://", "wss://") ws_url = f"{ws_url}/ws/market" async with aiohttp.ClientSession(connector=aiohttp.TCPConnector(ssl=ssl.create_default_context(cafile=certifi.where()))) as session: async with session.ws_connect(ws_url, headers=self._headers()) as ws: if on_connected: on_connected() async for msg in ws: if msg.type == aiohttp.WSMsgType.TEXT: data = json.loads(msg.data) if data.get("type") == "order_update": callback(data.get("data", {})) elif data.get("type") == "error": _raise_ws_stream_error(data) elif msg.type == aiohttp.WSMsgType.ERROR: break async def _stream_typed(self, msg_type: str, callback, on_connected=None): """Listen on /ws/market and invoke callback for messages of a given type.""" ws_url = self.base_url.replace("http://", "ws://").replace("https://", "wss://") ws_url = f"{ws_url}/ws/market" async with aiohttp.ClientSession(connector=aiohttp.TCPConnector(ssl=ssl.create_default_context(cafile=certifi.where()))) as session: async with session.ws_connect(ws_url, headers=self._headers()) as ws: if on_connected: on_connected() async for msg in ws: if msg.type == aiohttp.WSMsgType.TEXT: data = json.loads(msg.data) if data.get("type") == msg_type: callback(data.get("data", {})) elif data.get("type") == "error": _raise_ws_stream_error(data) elif msg.type == aiohttp.WSMsgType.ERROR: break async def stream_pnl(self, callback: Callable[[Dict], None], on_connected: Optional[Callable[[], None]] = None): """Stream portfolio-level P&L updates via WebSocket.""" await self._stream_typed("pnl_update", callback, on_connected) async def stream_position_updates(self, callback: Callable[[Dict], None], on_connected: Optional[Callable[[], None]] = None): """Stream position/ledger updates (per-position quantity & balance changes).""" await self._stream_typed("ledger_update", callback, on_connected) async def stream_trades(self, callback: Callable[[Dict], None], on_connected: Optional[Callable[[], None]] = None): """Stream trade executions (fills + commission reports) via WebSocket.""" await self._stream_typed("trade_update", callback, on_connected) def start_streams(self, symbols: Optional[List[str]] = None) -> BackgroundStreamManager: """ Start the unified event stream on a background thread and return its manager. Register handlers first with on(event, fn). Order/pnl/position events flow automatically; pass `symbols` to also receive quote_update events. WARNING: handlers run on the background thread — keep them short and do NOT call blocking sync SDK methods inside them. Call mgr.stop() (or stop_streams()) to tear down. """ self._stream_mgr = BackgroundStreamManager(self, symbols).start() return self._stream_mgr def stop_streams(self) -> None: """Stop the background event stream started by start_streams().""" mgr = getattr(self, "_stream_mgr", None) if mgr is not None: mgr.stop() self._stream_mgr = None # ==================== Private Methods ==================== def _raise_with_detail(self, response: requests.Response): """Raise exception with server error detail if available.""" try: detail = response.json().get("detail", response.text) except Exception: detail = response.text raise requests.HTTPError(f"{response.status_code}: {detail}", response=response) def _request(self, method: str, endpoint: str, *, params: Optional[Dict] = None, data: Optional[Dict] = None) -> Any: url = f"{self.base_url}{endpoint}" last_exc = None for attempt in range(_REQUEST_RETRY_ATTEMPTS): try: response = self.session.request( method, url, params=params, json=data, headers=self._headers(), timeout=_REQUEST_TIMEOUT_SECONDS, ) if response.status_code in _RETRYABLE_HTTP_STATUSES: last_exc = response.text if attempt < _REQUEST_RETRY_ATTEMPTS - 1: time.sleep(_REQUEST_RETRY_BASE_DELAY_SECONDS * (2 ** attempt)) continue self._raise_with_detail(response) if not response.ok: self._raise_with_detail(response) return response.json() except (requests.ConnectionError, requests.Timeout) as exc: last_exc = exc if attempt < _REQUEST_RETRY_ATTEMPTS - 1: time.sleep(_REQUEST_RETRY_BASE_DELAY_SECONDS * (2 ** attempt)) continue raise requests.ConnectionError(f"{method} {endpoint} failed after {_REQUEST_RETRY_ATTEMPTS} attempts: {last_exc}") def _get(self, endpoint: str, params: Optional[Dict] = None) -> Any: """Make GET request with retry on transient errors.""" return self._request("GET", endpoint, params=params) def _post(self, endpoint: str, data: Dict) -> Any: """Make POST request with retry on transient errors.""" return self._request("POST", endpoint, data=data) def _put(self, endpoint: str, data: Dict) -> Any: """Make PUT request.""" return self._request("PUT", endpoint, data=data) def _delete(self, endpoint: str) -> Any: """Make DELETE request.""" return self._request("DELETE", endpoint) def _headers(self) -> Dict[str, str]: """Attach strategy ID for routing.""" if self.strategy_id: return {"X-Strategy-Id": self.strategy_id} return {} # ==================== Async Client ==================== class AsyncTradingClient(EventEmitter): """ Async version of the Trading SDK Client. For use in async environments (e.g., with asyncio strategies). Supports the event API: client.on("fill", fn); await client.start_streams(); ... await client.stop_streams() """ _STRATEGY_ID_RE = re.compile(r'^AQC-[PLF][A-Z0-9]{6}$') def __init__( self, strategy_id: str, base_url: str = "https://engine.alpha-techlab.com" ): sid = strategy_id.upper() if strategy_id else "" if not self._STRATEGY_ID_RE.match(sid): raise ValueError( f"Invalid strategy_id '{strategy_id}'. " "Expected format: AQC-P/L/F + 6 alphanumeric chars (e.g., AQC-P1A2B3C4)." ) self.strategy_id = sid self.base_url = base_url.rstrip("/") self._session: Optional[aiohttp.ClientSession] = None self._timeout = aiohttp.ClientTimeout( total=_REQUEST_TIMEOUT_SECONDS, connect=_REQUEST_CONNECT_TIMEOUT_SECONDS, sock_connect=_REQUEST_CONNECT_TIMEOUT_SECONDS, sock_read=_REQUEST_TIMEOUT_SECONDS, ) self._position_cache: Dict[str, Position] = {} self._position_cache_time: float = 0.0 self._position_cache_ttl: float = 1.0 async def _get_session(self) -> aiohttp.ClientSession: if self._session is None or self._session.closed: ssl_ctx = ssl.create_default_context(cafile=certifi.where()) self._session = aiohttp.ClientSession( connector=aiohttp.TCPConnector(ssl=ssl_ctx), timeout=self._timeout, ) return self._session async def close(self): if self._session and not self._session.closed: await self._session.close() async def _request(self, method: str, endpoint: str, *, params: Optional[Dict] = None, data: Optional[Dict] = None) -> Any: session = await self._get_session() url = f"{self.base_url}{endpoint}" last_exc = None for attempt in range(_REQUEST_RETRY_ATTEMPTS): try: async with session.request(method, url, params=params, json=data, headers=self._headers()) as response: if response.status in _RETRYABLE_HTTP_STATUSES: last_exc = await response.text() if attempt < _REQUEST_RETRY_ATTEMPTS - 1: await asyncio.sleep(_REQUEST_RETRY_BASE_DELAY_SECONDS * (2 ** attempt)) continue response.raise_for_status() response.raise_for_status() return await response.json() except aiohttp.ClientResponseError as exc: last_exc = exc if exc.status in _RETRYABLE_HTTP_STATUSES and attempt < _REQUEST_RETRY_ATTEMPTS - 1: await asyncio.sleep(_REQUEST_RETRY_BASE_DELAY_SECONDS * (2 ** attempt)) continue raise except (aiohttp.ClientConnectionError, aiohttp.ServerTimeoutError, asyncio.TimeoutError) as exc: last_exc = exc if attempt < _REQUEST_RETRY_ATTEMPTS - 1: await asyncio.sleep(_REQUEST_RETRY_BASE_DELAY_SECONDS * (2 ** attempt)) continue raise aiohttp.ClientError(f"{method} {endpoint} failed after {_REQUEST_RETRY_ATTEMPTS} attempts: {last_exc}") async def _get(self, endpoint: str, params: Optional[Dict] = None) -> Any: return await self._request("GET", endpoint, params=params) async def _post(self, endpoint: str, data: Dict) -> Any: return await self._request("POST", endpoint, data=data) async def _delete(self, endpoint: str) -> Any: return await self._request("DELETE", endpoint) async def _put(self, endpoint: str, data: Dict) -> Any: return await self._request("PUT", endpoint, data=data) def _headers(self) -> Dict[str, str]: """Attach strategy ID for routing.""" if self.strategy_id: return {"X-Strategy-Id": self.strategy_id} return {} @staticmethod def _normalize_account_value_key(key: Any) -> str: return "".join(ch for ch in str(key or "").lower() if ch.isalnum()) def _mode(self) -> str: strategy_id = str(self.strategy_id or "").upper() if strategy_id.startswith("AQC-L"): return "live" if strategy_id.startswith("AQC-F"): return "fund" return "paper" def _flatten_account_values(self, payload: Dict[str, Any]) -> Dict[str, Any]: flattened: Dict[str, Any] = {} if not isinstance(payload, dict): return flattened for key, value in payload.items(): flattened[key] = value normalized = self._normalize_account_value_key(key) if normalized and normalized not in flattened: flattened[normalized] = value return flattened def _order_matches_strategy(self, order: Dict[str, Any]) -> bool: if not self.strategy_id: return True strategy_id = str(self.strategy_id) candidates = ( order.get("order_ref"), order.get("orderRef"), order.get("cOID"), order.get("customer_order_id"), order.get("customerOrderId"), ) return any(strategy_id in str(candidate or "") for candidate in candidates) # Mirror all methods from TradingClient but async async def get_account(self) -> Account: response = await self._get("/account") return Account( account_id=response.get("account_id", ""), net_liquidation=response.get("net_liquidation"), available_funds=response.get("available_funds"), buying_power=response.get("buying_power") ) async def get_account_values(self) -> Dict[str, Any]: response = await self._get("/account/values") return self._flatten_account_values(response) async def get_account_value(self, key: str, default: Any = None) -> Any: values = await self.get_account_values() normalized = self._normalize_account_value_key(key) return values.get(key, values.get(normalized, default)) async def get_positions(self, force_refresh: bool = False) -> List[Position]: if not force_refresh and self._position_cache and (time.time() - self._position_cache_time) < self._position_cache_ttl: return list(self._position_cache.values()) response = await self._get("/account/positions") positions = [ Position( conid=pos.get("conid", 0), symbol=pos.get("symbol", ""), position=pos.get("position", 0), avg_cost=pos.get("avg_cost", 0), market_value=pos.get("market_value"), unrealized_pnl=pos.get("unrealized_pnl"), asset_class=pos.get("asset_class") ) for pos in response ] self._position_cache = {pos.symbol.upper(): pos for pos in positions} self._position_cache_time = time.time() return positions async def get_account_summary(self) -> "Account": return await self.get_account() async def get_account_id(self) -> str: return (await self.get_account()).account_id async def get_position(self, symbol: str, force_refresh: bool = False) -> Optional[Position]: if force_refresh or not self._position_cache or (time.time() - self._position_cache_time) >= self._position_cache_ttl: await self.get_positions(force_refresh=force_refresh) return self._position_cache.get(symbol.upper()) async def has_position(self, symbol: str, force_refresh: bool = False) -> bool: pos = await self.get_position(symbol, force_refresh=force_refresh) return pos is not None and pos.position != 0 async def get_open_position(self, symbol: str, sec_type: str = "STK", force_refresh: bool = False) -> float: pos = await self.get_position(symbol, force_refresh=force_refresh) if pos and (sec_type == "STK" or pos.asset_class == sec_type): return pos.position return 0.0 def invalidate_position_cache(self) -> None: self._position_cache.clear() self._position_cache_time = 0.0 async def close_position(self, symbol: str) -> "OrderResult": pos = await self.get_position(symbol) if not pos or pos.position == 0: return OrderResult(success=True, message="No position to close") side = "SELL" if pos.position > 0 else "BUY" quantity = round(abs(pos.position)) if quantity == 0: return OrderResult(success=True, message="Position too small to close") # Pass conid directly so we close the exact contract held (critical for futures, # options, and any non-STK asset where symbol alone is ambiguous). return await self.place_order( symbol, quantity, side, "MKT", conid=pos.conid or None, sec_type=pos.asset_class, ) async def get_orders(self) -> List[Dict]: response = await self._get("/account/orders") return response.get("orders", []) async def get_open_trades(self) -> List[OpenTrade]: working_statuses = {"submitted", "presubmitted", "pendingsubmit"} open_trades: List[OpenTrade] = [] for order in await self.get_orders(): status = str(order.get("status") or "").lower() if status not in working_statuses: continue if not self._order_matches_strategy(order): continue open_trades.append(TradingClient._parse_open_trade(order)) return open_trades async def get_open_orders(self) -> List[Dict]: working_statuses = {"submitted", "presubmitted", "pendingsubmit"} return [o for o in await self.get_orders() if str(o.get("status") or "").lower() in working_statuses] async def get_trades(self) -> List[Dict]: return await self._get("/account/trades") async def get_quote( self, symbol: str, sec_type: Optional[str] = None, exchange: Optional[str] = None, currency: Optional[str] = None, primary_exchange: Optional[str] = None, expiry: Optional[str] = None, local_symbol: Optional[str] = None, ) -> Quote: params: Dict[str, str] = {} if sec_type: params["sec_type"] = sec_type if exchange: params["exchange"] = exchange if currency: params["currency"] = currency if primary_exchange: params["primary_exchange"] = primary_exchange if expiry: params["expiry"] = expiry if local_symbol: params["local_symbol"] = local_symbol response = await self._get(f"/market/quote/{symbol}", params=params or None) return Quote( conid=response.get("conid", 0), symbol=response.get("symbol", symbol), last=response.get("last"), bid=response.get("bid"), ask=response.get("ask"), bid_size=response.get("bid_size"), ask_size=response.get("ask_size"), volume=response.get("volume"), change=response.get("change"), change_pct=response.get("change_pct") ) async def get_price( self, symbol: str, sec_type: Optional[str] = None, exchange: Optional[str] = None, currency: Optional[str] = None, primary_exchange: Optional[str] = None, expiry: Optional[str] = None, local_symbol: Optional[str] = None, ) -> Optional[float]: quote = await self.get_quote( symbol, sec_type=sec_type, exchange=exchange, currency=currency, primary_exchange=primary_exchange, expiry=expiry, local_symbol=local_symbol, ) return quote.last or quote.bid or quote.ask async def get_dividend_yield(self, symbol: str) -> Dict: """ Get dividend yield payload for an equity symbol. :param symbol: Underlying symbol :return: Raw dividend-yield payload from execution_api """ return await self._get(f"/market/dividend-yield/{symbol}") async def get_tickers(self, *symbols: str) -> List[Quote]: if not symbols: return [] results: Dict[str, Quote] = {} try: response = await self._post("/market/quotes", {"symbols": list(symbols)}) for q in response.get("quotes", []): sym = str(q.get("symbol", "")).upper() if sym: results[sym] = Quote( conid=q.get("conid", 0), symbol=sym, last=q.get("last"), bid=q.get("bid"), ask=q.get("ask"), bid_size=q.get("bid_size"), ask_size=q.get("ask_size"), volume=q.get("volume"), change=q.get("change"), change_pct=q.get("change_pct"), ) except Exception: pass # Fill in any symbols the batch missed missing = [s for s in symbols if s.upper() not in results] for sym in missing: try: results[sym.upper()] = await self.get_quote(sym) except Exception: pass # Return in original symbol order return [results[s.upper()] for s in symbols if s.upper() in results] async def get_history( self, symbol: str, period: str = "1d", bar_size: str = "1min", sec_type: Optional[str] = None, exchange: Optional[str] = None, currency: Optional[str] = None, primary_exchange: Optional[str] = None, expiry: Optional[str] = None, local_symbol: Optional[str] = None, ) -> List[Bar]: params: Dict[str, str] = {"period": period, "bar": bar_size} if sec_type: params["sec_type"] = sec_type if exchange: params["exchange"] = exchange if currency: params["currency"] = currency if primary_exchange: params["primary_exchange"] = primary_exchange if expiry: params["expiry"] = expiry if local_symbol: params["local_symbol"] = local_symbol response = await self._get(f"/market/history/{symbol}", params=params) return [ Bar( timestamp=bar.get("timestamp", 0), open=bar.get("open", 0), high=bar.get("high", 0), low=bar.get("low", 0), close=bar.get("close", 0), volume=bar.get("volume"), ) for bar in response.get("bars", []) ] async def get_historical_data(self, symbol: str, duration: str = "1 D", bar_size: str = "30 mins") -> "pd.DataFrame": period_map = { "1 D": "1d", "1 d": "1d", "1D": "1d", "2 D": "2d", "2 d": "2d", "2D": "2d", "3 D": "3d", "3 d": "3d", "3D": "3d", "4 D": "4d", "4 d": "4d", "4D": "4d", "5 D": "5d", "5 d": "5d", "5D": "5d", "7 D": "7d", "7 d": "7d", "7D": "7d", "10 D": "10d", "10 d": "10d", "10D": "10d", "14 D": "14d", "14 d": "14d", "14D": "14d", "1 W": "1w", "1 w": "1w", "1W": "1w", "2 W": "2w", "2 w": "2w", "2W": "2w", "3 W": "3w", "3 w": "3w", "3W": "3w", "4 W": "4w", "4 w": "4w", "4W": "4w", "1 M": "1m", "1 m": "1m", "1M": "1m", "2 M": "2m", "2 m": "2m", "2M": "2m", "3 M": "3m", "3 m": "3m", "3M": "3m", "6 M": "6m", "6 m": "6m", "6M": "6m", "12 M": "1y", "12 m": "1y", "12M": "1y", "1 Y": "1y", "1 y": "1y", "1Y": "1y", "2 Y": "2y", "2 y": "2y", "2Y": "2y", } bar_map = { "1 min": "1min", "1 mins": "1min", "1min": "1min", "5 mins": "5min", "5 min": "5min", "5min": "5min", "15 mins": "15min", "15 min": "15min", "15min": "15min", "30 mins": "30min", "30 min": "30min", "30min": "30min", "1 hour": "1h", "1 h": "1h", "1h": "1h", "1 day": "1d", "1 d": "1d", "1d": "1d", } period = period_map.get(duration, "1d") bar = bar_map.get(bar_size, "1min") bars = await self.get_history(symbol, period, bar) if not bars: return pd.DataFrame(columns=["date", "open", "high", "low", "close", "volume"]) return pd.DataFrame([ { "date": datetime.fromtimestamp(b.timestamp / 1000), "open": b.open, "high": b.high, "low": b.low, "close": b.close, "volume": b.volume, } for b in bars ]) async def get_historical_bars(self, symbol: str, duration: str = "1 D", bar_size: str = "30 mins") -> List[Bar]: bar_map = { "1 min": "1min", "1 mins": "1min", "1min": "1min", "5 mins": "5min", "5 min": "5min", "5min": "5min", "15 mins": "15min", "15 min": "15min", "15min": "15min", "30 mins": "30min", "30 min": "30min", "30min": "30min", "1 hour": "1h", "1 h": "1h", "1h": "1h", "1 day": "1d", "1 d": "1d", "1d": "1d", } period_map = { "1 D": "1d", "1 d": "1d", "1D": "1d", "2 D": "2d", "2 d": "2d", "2D": "2d", "3 D": "3d", "3 d": "3d", "3D": "3d", "4 D": "4d", "4 d": "4d", "4D": "4d", "5 D": "5d", "5 d": "5d", "5D": "5d", "7 D": "7d", "7 d": "7d", "7D": "7d", "10 D": "10d", "10 d": "10d", "10D": "10d", "14 D": "14d", "14 d": "14d", "14D": "14d", "1 W": "1w", "1 w": "1w", "1W": "1w", "2 W": "2w", "2 w": "2w", "2W": "2w", "3 W": "3w", "3 w": "3w", "3W": "3w", "4 W": "4w", "4 w": "4w", "4W": "4w", "1 M": "1m", "1 m": "1m", "1M": "1m", "2 M": "2m", "2 m": "2m", "2M": "2m", "3 M": "3m", "3 m": "3m", "3M": "3m", "6 M": "6m", "6 m": "6m", "6M": "6m", "12 M": "1y", "12 m": "1y", "12M": "1y", "1 Y": "1y", "1 y": "1y", "1Y": "1y", "2 Y": "2y", "2 y": "2y", "2Y": "2y", } period = period_map.get(duration, "1d") bar = bar_map.get(bar_size, "30min") return await self.get_history(symbol, period, bar) async def get_conid( self, symbol: str, sec_type: Optional[str] = None, country: Optional[str] = None, currency: Optional[str] = None, exchange: Optional[str] = None, primary_exchange: Optional[str] = None, expiry: Optional[str] = None, local_symbol: Optional[str] = None, ) -> Optional[int]: params = {} if sec_type: params["sec_type"] = sec_type if country: params["country"] = country if currency: params["currency"] = currency if exchange: params["exchange"] = exchange if primary_exchange: params["primary_exchange"] = primary_exchange if expiry: params["expiry"] = expiry if local_symbol: params["local_symbol"] = local_symbol response = await self._get(f"/market/conid/{symbol}", params=params if params else None) conid = response.get("conid") return int(conid) if conid else None async def get_index_conid(self, symbol: str) -> Optional[int]: return await self.get_conid(symbol, sec_type="IND") async def get_option_contracts( self, symbol: str, expiry: str, right: str, strikes: List[float], trading_class: Optional[str] = None ) -> List[Dict]: payload = { "symbol": symbol, "expiry": expiry, "right": right, "strikes": strikes } if trading_class: payload["trading_class"] = trading_class try: response = await self._post("/options/contracts", payload) except aiohttp.ClientResponseError as exc: if exc.status == 404: return [] raise return response.get("contracts", []) async def get_option_quotes(self, conids: List[int]) -> List[Dict]: response = await self._post("/options/quotes", {"conids": conids}) if isinstance(response, list): return response return response.get("quotes", []) async def get_option_quote(self, conid: int) -> Optional[Dict]: quotes = await self.get_option_quotes([conid]) return quotes[0] if quotes else None async def get_options_chain(self, symbol: str, month: Optional[str] = None) -> Dict: params: Dict[str, str] = {} if month: params["month"] = month return await self._get(f"/options/chain/{symbol}", params=params or None) async def get_strikes(self, symbol: str, month: str) -> Dict: return await self._get(f"/options/strikes/{symbol}", params={"month": month}) async def get_option_contract( self, symbol: str, expiry: str, strike: float, right: str, trading_class: Optional[str] = None, ) -> Union[Dict, List[Dict]]: payload: Dict[str, Any] = {"symbol": symbol, "expiry": expiry, "strike": strike, "right": right} if trading_class: payload["trading_class"] = trading_class data = await self._post("/options/contract", payload) if isinstance(data, list): return data[0] if len(data) == 1 else data return data async def get_option_greeks_batch(self, conids: List[int]) -> List[Dict]: response = await self._post("/options/greeks", {"conids": conids}) return response.get("greeks", []) async def stream_quotes( self, symbols: List[str], callback: Callable[[Dict], None], on_connected: Optional[Callable[[], None]] = None, ): """Stream real-time quotes via WebSocket.""" ws_url = self.base_url.replace("http://", "ws://").replace("https://", "wss://") ws_url = f"{ws_url}/ws/market" async with aiohttp.ClientSession(connector=aiohttp.TCPConnector(ssl=ssl.create_default_context(cafile=certifi.where()))) as session: async with session.ws_connect(ws_url, headers=self._headers()) as ws: if on_connected: on_connected() await ws.send_str(json.dumps({"action": "subscribe", "symbols": symbols, "strategy_id": self.strategy_id})) async for msg in ws: if msg.type == aiohttp.WSMsgType.TEXT: data = json.loads(msg.data) if data.get("type") == "market_data": callback(data.get("data", {})) elif data.get("type") == "error": _raise_ws_stream_error(data) elif msg.type == aiohttp.WSMsgType.ERROR: break async def stream_order_updates( self, callback: Callable[[Dict], None], on_connected: Optional[Callable[[], None]] = None ): """ Stream order updates via WebSocket. """ ws_url = self.base_url.replace("http://", "ws://").replace("https://", "wss://") ws_url = f"{ws_url}/ws/market" async with aiohttp.ClientSession(connector=aiohttp.TCPConnector(ssl=ssl.create_default_context(cafile=certifi.where()))) as session: async with session.ws_connect(ws_url, headers=self._headers()) as ws: if on_connected: on_connected() async for msg in ws: if msg.type == aiohttp.WSMsgType.TEXT: data = json.loads(msg.data) if data.get("type") == "order_update": callback(data.get("data", {})) elif data.get("type") == "error": _raise_ws_stream_error(data) elif msg.type == aiohttp.WSMsgType.ERROR: break async def _stream_typed(self, msg_type: str, callback, on_connected=None): """Listen on /ws/market and invoke callback for messages of a given type.""" ws_url = self.base_url.replace("http://", "ws://").replace("https://", "wss://") ws_url = f"{ws_url}/ws/market" async with aiohttp.ClientSession(connector=aiohttp.TCPConnector(ssl=ssl.create_default_context(cafile=certifi.where()))) as session: async with session.ws_connect(ws_url, headers=self._headers()) as ws: if on_connected: on_connected() async for msg in ws: if msg.type == aiohttp.WSMsgType.TEXT: data = json.loads(msg.data) if data.get("type") == msg_type: callback(data.get("data", {})) elif data.get("type") == "error": _raise_ws_stream_error(data) elif msg.type == aiohttp.WSMsgType.ERROR: break async def stream_pnl(self, callback: Callable[[Dict], None], on_connected: Optional[Callable[[], None]] = None): """Stream portfolio-level P&L updates via WebSocket.""" await self._stream_typed("pnl_update", callback, on_connected) async def stream_position_updates(self, callback: Callable[[Dict], None], on_connected: Optional[Callable[[], None]] = None): """Stream position/ledger updates (per-position quantity & balance changes).""" await self._stream_typed("ledger_update", callback, on_connected) async def stream_trades(self, callback: Callable[[Dict], None], on_connected: Optional[Callable[[], None]] = None): """Stream trade executions (fills + commission reports) via WebSocket.""" await self._stream_typed("trade_update", callback, on_connected) async def start_streams(self, symbols: Optional[List[str]] = None) -> None: """ Start the unified event stream as a background asyncio task. Register handlers first with on(event, fn). Order/pnl/position events flow automatically; pass `symbols` to also receive quote_update events. Handlers run in this event loop, so awaiting SDK calls inside them is safe. """ task = getattr(self, "_stream_task", None) if task is not None and not task.done(): return self._stream_task = asyncio.create_task( run_event_streams(self, symbols, should_stop=lambda: False) ) async def stop_streams(self) -> None: """Stop the event stream started by start_streams().""" task = getattr(self, "_stream_task", None) if task is not None and not task.done(): task.cancel() try: await task except asyncio.CancelledError: pass self._stream_task = None async def place_order( self, symbol: str, quantity: float, side: str, order_type: str = "MKT", price: Optional[float] = None, aux_price: Optional[float] = None, conid: Optional[int] = None, force: bool = True, tif: Optional[str] = None, exchange: Optional[str] = None, country: Optional[str] = None, currency: Optional[str] = None, primary_exchange: Optional[str] = None, sec_type: Optional[str] = None, expiry: Optional[str] = None, local_symbol: Optional[str] = None, order_params: Optional[Dict[str, Any]] = None, ) -> OrderResult: payload = { "symbol": symbol, "quantity": quantity, "side": side.upper(), "order_type": order_type.upper() } if price is not None: payload["price"] = price if aux_price is not None: payload["aux_price"] = aux_price if conid: payload["conid"] = conid if force: payload["force"] = True if tif: payload["tif"] = tif if exchange: payload["exchange"] = exchange if country: payload["country"] = country if currency: payload["currency"] = currency if primary_exchange: payload["primary_exchange"] = primary_exchange if sec_type: payload["sec_type"] = sec_type if expiry: payload["expiry"] = expiry if local_symbol: payload["local_symbol"] = local_symbol if order_params: payload["order_params"] = dict(order_params) # Include strategy_id as customer order reference for tracking if self.strategy_id: payload["cOID"] = f"{self.strategy_id}-{int(time.time() * 1000)}" payload["customer_order_id"] = self.strategy_id try: response = await self._post("/orders", payload) except Exception as e: return OrderResult(success=False, message=str(e)) result = response.get("result") if isinstance(response, dict) else None def _get_order_id(obj): if isinstance(obj, dict): # Prefer order_id/orderId over id (id is often reply UUID, not order ID) oid = obj.get("order_id") or obj.get("orderId") if oid: return oid # Only use 'id' if it looks like a numeric order ID, not a UUID raw_id = obj.get("id") if raw_id and str(raw_id).isdigit(): return raw_id # Check for nested 'result' (IBKR sometimes returns {"result": [...], "status": "success"}) nested_result = obj.get("result") if nested_result: return _get_order_id(nested_result) return None if isinstance(obj, list): for item in obj: if isinstance(item, dict): oid = _get_order_id(item) if oid: return oid return None def _get_message(resp, res): if isinstance(resp, dict): msg = resp.get("message") or resp.get("detail") if msg: return msg if isinstance(res, dict): msg = res.get("message") or res.get("detail") if msg: return msg if isinstance(res, list) and res: first = res[0] if isinstance(first, dict): msg = first.get("message") if isinstance(msg, list): return " ".join(str(m) for m in msg) if msg: return msg return None def _get_error(resp, res): if isinstance(resp, dict): err = resp.get("error") if err: return err if isinstance(res, dict): err = res.get("error") if err: return err return None order_id = _get_order_id(result) message = _get_message(response, result) error = _get_error(response, result) if error: return OrderResult(success=False, order_id=None, message=str(error), raw=response) return OrderResult( success=isinstance(response, dict) and response.get("status") == "success" and order_id is not None, order_id=str(order_id) if order_id else None, message=str(message) if message else None, raw=response ) async def place_contract_order( self, contract: Union[ContractRef, Dict[str, Any]], side: str, quantity: float, order_type: str = "MKT", price: Optional[float] = None, aux_price: Optional[float] = None, force: bool = True, tif: Optional[str] = None, exchange: Optional[str] = None, ) -> OrderResult: if isinstance(contract, ContractRef): conid = contract.conid symbol = contract.symbol exchange_value = exchange or contract.exchange elif isinstance(contract, dict): conid = contract.get("conid") symbol = contract.get("symbol") or contract.get("ticker") exchange_value = exchange or contract.get("exchange") or contract.get("listingExchange") or "SMART" else: raise TypeError("contract must be a ContractRef or dict") if not conid or not symbol: raise ValueError("contract must include both conid and symbol") return await self.place_order( symbol=str(symbol), quantity=quantity, side=side, order_type=order_type, price=price, aux_price=aux_price, conid=int(conid), force=force, tif=tif, exchange=exchange_value, ) async def place_combo_order( self, legs: List[ComboLeg], quantity: float, side: str, order_type: str = "MKT", price: Optional[float] = None, force: bool = True, tif: str = "DAY", delta_neutral: Optional[Dict[str, Any]] = None, ) -> OrderResult: """ Place a combo (BAG) order. :param delta_neutral: Optional {conid, delta, price} hedge contract for delta-neutral combos (passed through to IBKR as deltaNeutralContract). :return: OrderResult with success, order_id, message, and raw response """ payload = { "legs": [ { "conid": leg.conid, "ratio": leg.ratio, "action": leg.action, "exchange": leg.exchange } for leg in legs ], "quantity": quantity, "side": side.upper(), "order_type": order_type.upper(), "tif": tif } if price is not None: payload["price"] = price if delta_neutral: payload["delta_neutral"] = dict(delta_neutral) if force: payload["force"] = True if self.strategy_id: payload["cOID"] = f"{self.strategy_id}-{int(time.time() * 1000)}" payload["customer_order_id"] = self.strategy_id try: response = await self._post("/orders/combo", payload) except Exception as e: return OrderResult(success=False, message=str(e)) # Extract order_id from response order_id = None if isinstance(response, dict): result = response.get("result") if isinstance(result, list) and result: order_id = result[0].get("order_id") or result[0].get("orderId") elif isinstance(result, dict): order_id = result.get("order_id") or result.get("orderId") return OrderResult( success=isinstance(response, dict) and response.get("status") == "success" and order_id is not None, order_id=str(order_id) if order_id else None, message=response.get("message") if isinstance(response, dict) else None, raw=response ) async def buy( self, symbol: str, quantity: float, order_type: str = "MKT", price: Optional[float] = None, force: bool = True, sec_type: Optional[str] = None, exchange: Optional[str] = None, expiry: Optional[str] = None, local_symbol: Optional[str] = None, conid: Optional[int] = None, ) -> OrderResult: return await self.place_order( symbol, quantity, "BUY", order_type, price, force=force, sec_type=sec_type, exchange=exchange, expiry=expiry, local_symbol=local_symbol, conid=conid, ) async def sell( self, symbol: str, quantity: float, order_type: str = "MKT", price: Optional[float] = None, force: bool = True, sec_type: Optional[str] = None, exchange: Optional[str] = None, expiry: Optional[str] = None, local_symbol: Optional[str] = None, conid: Optional[int] = None, ) -> OrderResult: return await self.place_order( symbol, quantity, "SELL", order_type, price, force=force, sec_type=sec_type, exchange=exchange, expiry=expiry, local_symbol=local_symbol, conid=conid, ) async def buy_market( self, symbol: str, quantity: float, conid: Optional[int] = None, tif: Optional[str] = None, exchange: Optional[str] = None, country: Optional[str] = None, currency: Optional[str] = None, primary_exchange: Optional[str] = None, sec_type: Optional[str] = None, ) -> OrderResult: return await self.place_order( symbol, quantity, "BUY", "MKT", conid=conid, tif=tif, exchange=exchange, country=country, currency=currency, primary_exchange=primary_exchange, sec_type=sec_type, ) async def sell_market( self, symbol: str, quantity: float, conid: Optional[int] = None, tif: Optional[str] = None, exchange: Optional[str] = None, country: Optional[str] = None, currency: Optional[str] = None, primary_exchange: Optional[str] = None, sec_type: Optional[str] = None, ) -> OrderResult: return await self.place_order( symbol, quantity, "SELL", "MKT", conid=conid, tif=tif, exchange=exchange, country=country, currency=currency, primary_exchange=primary_exchange, sec_type=sec_type, ) async def buy_limit( self, symbol: str, quantity: float, price: float, conid: Optional[int] = None, tif: Optional[str] = None, exchange: Optional[str] = None, country: Optional[str] = None, currency: Optional[str] = None, primary_exchange: Optional[str] = None, ) -> OrderResult: return await self.place_order( symbol, quantity, "BUY", "LMT", price, conid=conid, tif=tif, exchange=exchange, country=country, currency=currency, primary_exchange=primary_exchange, ) async def sell_limit( self, symbol: str, quantity: float, price: float, conid: Optional[int] = None, tif: Optional[str] = None, exchange: Optional[str] = None, country: Optional[str] = None, currency: Optional[str] = None, primary_exchange: Optional[str] = None, ) -> OrderResult: return await self.place_order( symbol, quantity, "SELL", "LMT", price, conid=conid, tif=tif, exchange=exchange, country=country, currency=currency, primary_exchange=primary_exchange, ) async def buy_stop( self, symbol: str, quantity: float, stop_price: float, conid: Optional[int] = None, tif: Optional[str] = None, exchange: Optional[str] = None, ) -> OrderResult: """Place a stop buy order.""" return await self.place_order(symbol, quantity, "BUY", "STP", aux_price=stop_price, conid=conid, tif=tif, exchange=exchange) async def sell_stop( self, symbol: str, quantity: float, stop_price: float, conid: Optional[int] = None, tif: Optional[str] = None, exchange: Optional[str] = None, ) -> OrderResult: """Place a stop sell order.""" return await self.place_order(symbol, quantity, "SELL", "STP", aux_price=stop_price, conid=conid, tif=tif, exchange=exchange) async def buy_stop_limit( self, symbol: str, quantity: float, stop_price: float, limit_price: float, conid: Optional[int] = None, tif: Optional[str] = None, exchange: Optional[str] = None, ) -> OrderResult: """Place a stop-limit buy order.""" return await self.place_order(symbol, quantity, "BUY", "STP LMT", price=limit_price, aux_price=stop_price, conid=conid, tif=tif, exchange=exchange) async def sell_stop_limit( self, symbol: str, quantity: float, stop_price: float, limit_price: float, conid: Optional[int] = None, tif: Optional[str] = None, exchange: Optional[str] = None, ) -> OrderResult: """Place a stop-limit sell order.""" return await self.place_order(symbol, quantity, "SELL", "STP LMT", price=limit_price, aux_price=stop_price, conid=conid, tif=tif, exchange=exchange) async def buy_trail( self, symbol: str, quantity: float, trail_amount: Optional[float] = None, trail_percent: Optional[float] = None, conid: Optional[int] = None, force: bool = True, ) -> OrderResult: """Place a trailing stop buy order.""" return await self.place_trailing_stop( symbol=symbol, quantity=quantity, side="BUY", trail_amount=trail_amount, trail_percent=trail_percent, conid=conid, force=force, ) async def sell_trail( self, symbol: str, quantity: float, trail_amount: Optional[float] = None, trail_percent: Optional[float] = None, conid: Optional[int] = None, force: bool = True, ) -> OrderResult: """Place a trailing stop sell order.""" return await self.place_trailing_stop( symbol=symbol, quantity=quantity, side="SELL", trail_amount=trail_amount, trail_percent=trail_percent, conid=conid, force=force, ) async def cancel_order(self, order_id: Union[str, int]) -> bool: """Cancel an order. Raises on network/server errors.""" response = await self._delete(f"/orders/{order_id}") return response.get("status") == "success" async def cancel_all_orders(self) -> int: try: response = await self._delete("/orders") return response.get("cancelled_count", 0) except Exception: orders = await self.get_orders() cancelled = 0 for order in orders: order_id = order.get("orderId") or order.get("order_id") if order_id: try: if await self.cancel_order(order_id): cancelled += 1 except Exception: pass return cancelled async def account_kill_switch(self, mode: Optional[str] = None) -> Dict: """ EMERGENCY: Cancel ALL orders and close ALL positions for the account. :param mode: "paper", "live", or "fund". If omitted, inferred from strategy_id. :return: Results dictionary """ endpoint = f"/kill/account?mode={mode}" if mode else "/kill/account" result = await self._post(endpoint, {}) self.invalidate_position_cache() return result async def flatten_all(self, mode: Optional[str] = None) -> Dict: """EMERGENCY: Alias of account_kill_switch().""" endpoint = f"/orders/flatten?mode={mode}" if mode else "/orders/flatten" result = await self._post(endpoint, {}) self.invalidate_position_cache() return result async def get_status(self) -> Dict: return await self._get("/status") async def is_connected(self) -> bool: try: status = await self.get_status() mode = self._mode() broker_ready = status.get("broker_ready") if isinstance(broker_ready, dict): return bool(broker_ready.get(mode, False)) authenticated = status.get("ibkr_authenticated") connected = status.get("ibkr_connected") if isinstance(authenticated, dict) and isinstance(connected, dict): return bool(authenticated.get(mode, False) and connected.get(mode, False)) return bool(authenticated) except Exception: return False async def get_connection_status(self) -> Dict[str, Any]: status = await self.get_status() mode = self._mode() return { "mode": mode, "engine_running": status.get("status") == "running", "ibkr_connected": bool((status.get("ibkr_connected") or {}).get(mode, False)), "ibkr_authenticated": bool((status.get("ibkr_authenticated") or {}).get(mode, False)), "account_selected": bool((status.get("account_selected") or {}).get(mode, False)), "broker_ready": bool((status.get("broker_ready") or {}).get(mode, False)), "timestamp": status.get("timestamp"), } async def get_market_time(self) -> MarketTime: response = await self._get("/time") return MarketTime( eastern_time=response.get("eastern_time", ""), is_weekday=response.get("is_weekday", False), is_market_hours=response.get("is_market_hours", False), market_open=response.get("market_open", ""), market_close=response.get("market_close", ""), ) async def is_market_open(self) -> bool: return (await self.get_market_time()).is_market_hours def get_eastern_time(self) -> datetime: eastern = pytz.timezone("US/Eastern") return datetime.now(eastern) def is_within_trade_window(self, start: dtime, end: dtime) -> bool: now = self.get_eastern_time().time() return start <= now <= end def is_eod(self, eod_time: dtime = dtime(15, 30)) -> bool: now = self.get_eastern_time().time() return now >= eod_time # ==================== Order Lifecycle Methods ==================== async def get_order_status(self, order_id: Union[str, int]) -> Dict: """Get detailed status of an order including fills.""" return await self._get(f"/orders/{order_id}") async def wait_for_fill( self, order_id: Union[str, int], timeout: int = 60, poll_interval: float = 0.5 ) -> Dict: """Wait for an order to fill (or timeout).""" return await self._get(f"/orders/{order_id}/wait", params={ "timeout": timeout, "poll_interval": poll_interval }) async def wait_for_submitted( self, order_id: Union[str, int], timeout: int = 30, poll_interval: float = 0.3, ) -> bool: start_time = time.time() submitted_statuses = {"submitted", "presubmitted", "filled", "partialfilled"} while time.time() - start_time < timeout: try: status_response = await self.get_order_status(order_id) status = status_response.get("status", "").lower() if status in submitted_statuses: return True if status in {"cancelled", "canceled", "rejected", "error", "inactive"}: return False except Exception: pass await asyncio.sleep(poll_interval) return False async def modify_order( self, order_id: Union[str, int], price: Optional[float] = None, aux_price: Optional[float] = None, quantity: Optional[float] = None ) -> Dict: """Modify an existing order.""" payload = {} if price is not None: payload["price"] = price if aux_price is not None: payload["aux_price"] = aux_price if quantity is not None: payload["quantity"] = quantity return await self._put(f"/orders/{order_id}", payload) # ==================== Advanced Order Types ==================== async def place_bracket_order( self, symbol: str, quantity: float, side: str, entry_price: float, stop_loss: float, take_profit: float, entry_type: str = "LMT", conid: Optional[int] = None, force: bool = False ) -> Dict: """Place a bracket order (entry + stop loss + take profit).""" payload = { "symbol": symbol, "quantity": quantity, "side": side.upper(), "entry_price": entry_price, "entry_type": entry_type, "stop_loss": stop_loss, "take_profit": take_profit } if conid: payload["conid"] = conid if force: payload["force"] = True if self.strategy_id: payload["cOID"] = f"{self.strategy_id}-{int(time.time() * 1000)}" payload["customer_order_id"] = self.strategy_id return await self._post("/orders/bracket", payload) async def place_trailing_stop( self, symbol: str, quantity: float, side: str, trail_amount: Optional[float] = None, trail_percent: Optional[float] = None, limit_offset: Optional[float] = None, conid: Optional[int] = None, force: bool = False ) -> OrderResult: """Place a trailing stop order.""" payload = { "symbol": symbol, "quantity": quantity, "side": side.upper() } if trail_amount is not None: payload["trail_amount"] = trail_amount if trail_percent is not None: payload["trail_percent"] = trail_percent if limit_offset is not None: payload["limit_offset"] = limit_offset if conid: payload["conid"] = conid if force: payload["force"] = True if self.strategy_id: payload["cOID"] = f"{self.strategy_id}-{int(time.time() * 1000)}" payload["customer_order_id"] = self.strategy_id try: response = await self._post("/orders/trailing", payload) return OrderResult( success=response.get("status") == "success", order_id=response.get("order_id"), message=response.get("message"), raw=response ) except Exception as e: return OrderResult(success=False, message=str(e)) async def place_adaptive_order( self, symbol: str, quantity: float, side: str, priority: str = "Normal", order_type: str = "MKT", price: Optional[float] = None, conid: Optional[int] = None, force: bool = False ) -> OrderResult: """Place an IBKR adaptive algo order.""" payload = { "symbol": symbol, "quantity": quantity, "side": side.upper(), "priority": priority, "order_type": order_type, } if price is not None: payload["max_price"] = price if conid: payload["conid"] = conid if force: payload["force"] = True if self.strategy_id: payload["cOID"] = f"{self.strategy_id}-{int(time.time() * 1000)}" payload["customer_order_id"] = self.strategy_id try: response = await self._post("/orders/algo/adaptive", payload) return OrderResult( success=response.get("status") == "success", order_id=response.get("order_id"), message=response.get("message"), raw=response ) except Exception as e: return OrderResult(success=False, message=str(e)) async def place_algo_order( self, symbol: str, quantity: float, side: str, algo_strategy: str, algo_params: Optional[Dict[str, Any]] = None, order_type: str = "MKT", price: Optional[float] = None, tif: Optional[str] = "DAY", conid: Optional[int] = None, force: bool = False, ) -> OrderResult: """Place a generic IBKR algo order (TWAP, VWAP, ArrivalPrice, etc.).""" payload = { "symbol": symbol, "quantity": quantity, "side": side.upper(), "algo_strategy": algo_strategy, "algo_params": algo_params or {}, "order_type": order_type.upper(), "tif": tif or "DAY", } if price is not None: payload["price"] = price if conid: payload["conid"] = conid if force: payload["force"] = True if self.strategy_id: payload["cOID"] = f"{self.strategy_id}-{int(time.time() * 1000)}" payload["customer_order_id"] = self.strategy_id try: response = await self._post("/orders/algo", payload) return OrderResult( success=response.get("status") in ("success", "confirmed"), order_id=response.get("order_id"), message=response.get("message"), raw=response, ) except Exception as e: return OrderResult(success=False, message=str(e)) async def place_oca_order( self, group_name: str, orders: List[Dict[str, Any]], oca_type: int = 1, force: bool = False, ) -> OrderResult: """Place a group of orders linked by OCA (One-Cancels-All).""" if not orders or len(orders) < 2: return OrderResult(success=False, message="OCA group requires at least 2 legs") payload: Dict[str, Any] = { "group_name": group_name, "oca_type": oca_type, "orders": [dict(leg) for leg in orders], "force": force, } if self.strategy_id: payload["cOID"] = f"{self.strategy_id}-{int(time.time() * 1000)}" payload["customer_order_id"] = self.strategy_id try: response = await self._post("/orders/oca", payload) return OrderResult( success=response.get("status") in ("success", "confirmed"), order_id=response.get("order_id"), message=response.get("message"), raw=response, ) except Exception as e: return OrderResult(success=False, message=str(e)) async def place_midprice_order( self, symbol: str, quantity: float, side: str, price: Optional[float] = None, conid: Optional[int] = None, force: bool = True, sec_type: Optional[str] = None, exchange: Optional[str] = None, ) -> OrderResult: """Place a MidPrice order — IBKR pegs to the NBBO midpoint.""" return await self.place_order(symbol, quantity, side, order_type="MIDPRICE", price=price, conid=conid, force=force, sec_type=sec_type, exchange=exchange) async def place_relative_order( self, symbol: str, quantity: float, side: str, offset: float, limit_cap: Optional[float] = None, conid: Optional[int] = None, force: bool = True, sec_type: Optional[str] = None, exchange: Optional[str] = None, ) -> OrderResult: """Place a Relative (pegged-to-primary) order with a price offset.""" return await self.place_order(symbol, quantity, side, order_type="REL", price=limit_cap, aux_price=offset, conid=conid, force=force, sec_type=sec_type, exchange=exchange) async def get_order_impact( self, symbol: str, quantity: float, side: str, order_type: str = "MKT", price: Optional[float] = None, conid: Optional[int] = None ) -> Dict: """Get what-if margin impact for a hypothetical order.""" payload = { "symbol": symbol, "quantity": quantity, "side": side.upper(), "order_type": order_type.upper() } if price is not None: payload["price"] = price if conid: payload["conid"] = conid return await self._post("/orders/impact", payload) # ==================== Options Structure Orders ==================== async def place_vertical_spread( self, symbol: str, expiry: str, long_strike: float, short_strike: float, right: str, quantity: float, trading_class: Optional[str] = None, order_type: str = "MKT", price: Optional[float] = None, force: bool = False ) -> Dict: """Place a vertical spread (bull/bear spread).""" payload = { "symbol": symbol, "expiry": expiry, "long_strike": long_strike, "short_strike": short_strike, "right": right.upper(), "quantity": quantity, "order_type": order_type.upper() } if trading_class: payload["trading_class"] = trading_class if price is not None: payload["price"] = price if force: payload["force"] = True if self.strategy_id: payload["cOID"] = f"{self.strategy_id}-{int(time.time() * 1000)}" payload["customer_order_id"] = self.strategy_id return await self._post("/orders/vertical-spread", payload) async def place_iron_condor( self, symbol: str, expiry: str, put_long_strike: float, put_short_strike: float, call_short_strike: float, call_long_strike: float, quantity: float, trading_class: Optional[str] = None, order_type: str = "MKT", price: Optional[float] = None, force: bool = False ) -> Dict: """Place an iron condor (sell put spread + sell call spread).""" payload = { "symbol": symbol, "expiry": expiry, "put_long_strike": put_long_strike, "put_short_strike": put_short_strike, "call_short_strike": call_short_strike, "call_long_strike": call_long_strike, "quantity": quantity, "order_type": order_type.upper() } if trading_class: payload["trading_class"] = trading_class if price is not None: payload["price"] = price if force: payload["force"] = True if self.strategy_id: payload["cOID"] = f"{self.strategy_id}-{int(time.time() * 1000)}" payload["customer_order_id"] = self.strategy_id return await self._post("/orders/iron-condor", payload) # ==================== Options Selection Methods ==================== async def get_option_expirations( self, symbol: str, min_dte: Optional[int] = None, max_dte: Optional[int] = None, ) -> List[str]: """Get available expiration dates for a symbol, optionally filtered by DTE.""" params = {} if min_dte is not None: params["min_dte"] = int(min_dte) if max_dte is not None: params["max_dte"] = int(max_dte) response = await self._get(f"/options/expirations/{symbol}", params=params or None) return response.get("expirations", []) async def get_option_by_delta( self, symbol: str, expiry: str, target_delta: float, right: str, trading_class: Optional[str] = None ) -> Optional[Dict]: """Find option contract by target delta.""" payload = { "symbol": symbol, "expiry": expiry, "target_delta": target_delta, "right": right.upper() } if trading_class: payload["trading_class"] = trading_class # Server returns contract directly, not wrapped return await self._post("/options/by-delta", payload) async def get_atm_strike(self, symbol: str, expiry: str) -> Optional[float]: """Get the at-the-money strike price for a symbol.""" response = await self._get(f"/options/atm/{symbol}", params={"expiry": expiry}) return response.get("atm_strike") async def get_option_oi(self, symbol: str, expiry: str) -> Dict: """Get open interest for options.""" return await self._get(f"/options/oi/{symbol}", params={"expiry": expiry}) async def get_option_volume(self, symbol: str, expiry: str) -> Dict: """Get volume for options.""" return await self._get(f"/options/volume/{symbol}", params={"expiry": expiry}) async def get_greeks(self, conid: int) -> Greeks: """Get Greeks for an option contract.""" response = await self._get(f"/options/greeks/{conid}") return Greeks( conid=response.get("conid", conid), delta=response.get("delta"), gamma=response.get("gamma"), theta=response.get("theta"), vega=response.get("vega"), iv=response.get("iv"), ) # ==================== Deprecated Aliases (Backward Compatibility) ==================== async def strategy_kill_switch(self) -> Dict: """Deprecated: use flatten_all() or account_kill_switch() instead.""" warnings.warn( "strategy_kill_switch() is deprecated and will be removed in a future version. " "Use flatten_all() instead.", DeprecationWarning, stacklevel=2, ) return await self.flatten_all() async def get_option_greeks(self, conid: int) -> Greeks: """Alias for get_greeks().""" return await self.get_greeks(conid) async def get_expirations( self, symbol: str, min_dte: Optional[int] = None, max_dte: Optional[int] = None, ) -> List[str]: """Alias for get_option_expirations().""" return await self.get_option_expirations(symbol, min_dte=min_dte, max_dte=max_dte) # ==================== P&L Methods ==================== async def get_pnl(self) -> Dict: """Get portfolio-level P&L (daily, realized, unrealized).""" return await self._get("/account/pnl") async def get_position_pnl(self, symbol: str) -> Dict: """Get P&L for a specific position.""" return await self._get(f"/positions/{symbol}/pnl") async def get_margin(self) -> Dict: """Get account margin information.""" return await self._get("/account/margin") # ==================== Market Data Methods (Extended) ==================== async def get_market_depth(self, conid: int, rows: int = 5) -> Dict: """Get market depth (Level 2 data) for a contract.""" return await self._get(f"/market/depth/{conid}", params={"rows": rows}) async def get_last_trade(self, conid: int) -> Dict: """Get last trade details for a contract.""" return await self._get(f"/market/last-trade/{conid}") async def get_ticks( self, conid: int, start: str, end: str, tick_type: str = "TRADES" ) -> Dict: """Get historical tick data.""" return await self._get(f"/market/ticks/{conid}", params={ "start": start, "end": end, "tick_type": tick_type }) async def get_halt_status(self, conid: int) -> Dict: """Check if a contract is halted.""" return await self._get(f"/market/halt/{conid}") async def is_halted(self, conid: int) -> bool: """Check if a contract is currently halted.""" response = await self.get_halt_status(conid) return response.get("is_halted", False) async def get_trading_hours(self, symbol: str) -> Dict: """Get trading hours for a symbol.""" return await self._get(f"/market/hours/{symbol}") async def is_market_open_for(self, symbol: str) -> bool: """Check if market is currently open for a specific symbol.""" response = await self._get(f"/market/is-open/{symbol}") return response.get("is_open", False) # ==================== Contract Methods ==================== async def search_contracts( self, query: str, sec_type: Optional[str] = None, exchange: Optional[str] = None, country: Optional[str] = None, currency: Optional[str] = None, primary_exchange: Optional[str] = None, expiry: Optional[str] = None, local_symbol: Optional[str] = None, limit: int = 10 ) -> List[Dict]: """Search for contracts by name or symbol.""" params = {"query": query, "limit": limit} if sec_type: params["sec_type"] = sec_type if exchange: params["exchange"] = exchange if country: params["country"] = country if currency: params["currency"] = currency if primary_exchange: params["primary_exchange"] = primary_exchange if expiry: params["expiry"] = expiry if local_symbol: params["local_symbol"] = local_symbol response = await self._get("/contracts/search", params=params) return response.get("contracts", []) async def get_contract_details(self, conid: int) -> Dict: """Get full contract details.""" return await self._get(f"/contracts/{conid}") async def qualify_contract( self, symbol: str, sec_type: str = "STK", exchange: str = "SMART", currency: str = "USD", country: Optional[str] = None, primary_exchange: Optional[str] = None, expiry: Optional[str] = None, local_symbol: Optional[str] = None, ) -> ContractRef: requested_sec_type = str(sec_type or "").upper() effective_exchange = exchange effective_currency = currency if ( requested_sec_type == "IND" and str(exchange or "").upper() == "SMART" and str(currency or "").upper() == "USD" and not country and not primary_exchange ): effective_exchange = None effective_currency = None conid = await self.get_conid( symbol, sec_type=sec_type, country=country, currency=effective_currency, exchange=effective_exchange, primary_exchange=primary_exchange, expiry=expiry, local_symbol=local_symbol, ) if not conid: raise ValueError(f"Could not qualify contract for {symbol} ({sec_type})") details = await self.get_contract_details(conid) secdef = details.get("secdef", [{}]) info = secdef[0] if secdef else {} return ContractRef( conid=int(info.get("conid") or conid), symbol=info.get("ticker") or symbol, sec_type=info.get("assetClass") or sec_type, exchange=info.get("exchange") or exchange, currency=info.get("currency") or currency, listing_exchange=info.get("listingExchange"), country=info.get("countryCode") or country, trading_class=info.get("tradingClass"), local_symbol=info.get("localSymbol"), expiry=info.get("lastTradingDay") or info.get("lastTradingDayOrContractMonth") or expiry, raw=details, ) async def qualify_futures_contract( self, symbol: str, expiry: Optional[str] = None, exchange: Optional[str] = None, local_symbol: Optional[str] = None, currency: Optional[str] = None, ) -> ContractRef: return await self.qualify_contract( symbol, sec_type="FUT", exchange=exchange or "CME", currency=currency or "USD", expiry=expiry, local_symbol=local_symbol, ) async def qualify_crypto_contract(self, symbol: str, exchange: str = "PAXOS", currency: str = "USD") -> ContractRef: """Resolve a crypto contract (e.g. BTC, ETH) into a contract reference.""" return await self.qualify_contract(symbol, sec_type="CRYPTO", exchange=exchange, currency=currency) async def qualify_cfd_contract(self, symbol: str, exchange: str = "SMART", currency: str = "USD") -> ContractRef: """Resolve a CFD contract into a contract reference.""" return await self.qualify_contract(symbol, sec_type="CFD", exchange=exchange, currency=currency) async def qualify_bond_contract(self, symbol: str, exchange: str = "SMART", currency: str = "USD") -> ContractRef: """Resolve a bond contract into a contract reference.""" return await self.qualify_contract(symbol, sec_type="BOND", exchange=exchange, currency=currency) async def qualify_mutual_fund_contract(self, symbol: str, exchange: str = "FUNDSERV", currency: str = "USD") -> ContractRef: """Resolve a mutual-fund contract into a contract reference.""" return await self.qualify_contract(symbol, sec_type="FUND", exchange=exchange, currency=currency) async def qualify_warrant_contract(self, symbol: str, exchange: str = "SMART", currency: str = "USD") -> ContractRef: """Resolve a warrant contract into a contract reference.""" return await self.qualify_contract(symbol, sec_type="WAR", exchange=exchange, currency=currency) async def qualify_commodity_contract(self, symbol: str, exchange: str = "SMART", currency: str = "USD") -> ContractRef: """Resolve a commodity (metals etc.) contract into a contract reference.""" return await self.qualify_contract(symbol, sec_type="CMDTY", exchange=exchange, currency=currency) async def qualify_contfuture_contract(self, symbol: str, exchange: str = "CME", currency: str = "USD") -> ContractRef: """Resolve a continuous-futures contract into a contract reference.""" return await self.qualify_contract(symbol, sec_type="CONTFUT", exchange=exchange, currency=currency) # ==================== Option Calc / Market Structure ==================== async def compute_iv_given_price(self, conid: int, option_price: float, underlying_price: float, risk_free_rate: float = 0.04, dividend_yield: float = 0.0) -> Dict: """What-if: solve implied volatility for a hypothetical option price.""" return await self._post("/options/calculate", { "conid": conid, "option_price": option_price, "underlying_price": underlying_price, "risk_free_rate": risk_free_rate, "dividend_yield": dividend_yield, }) async def compute_price_given_iv(self, conid: int, implied_vol: float, underlying_price: float, risk_free_rate: float = 0.04, dividend_yield: float = 0.0) -> Dict: """What-if: theoretical option price for a hypothetical IV.""" return await self._post("/options/calculate", { "conid": conid, "implied_vol": implied_vol, "underlying_price": underlying_price, "risk_free_rate": risk_free_rate, "dividend_yield": dividend_yield, }) async def get_contract_schedule(self, symbol: str, asset_class: str = "STK", exchange: Optional[str] = None) -> Dict: """Trading schedule for a symbol (IBKR /trsrv/secdef/schedule).""" params = {"asset_class": asset_class} if exchange: params["exchange"] = exchange return await self._get(f"/market/schedule/{symbol}", params=params) async def get_market_rule(self, conid: int, is_buy: bool = True) -> Dict: """Order rules / tick increments for a contract (IBKR /iserver/contract/rules).""" return await self._post("/market/rules", {"conid": conid, "is_buy": is_buy}) # ==================== Calendar Methods ==================== async def get_trading_calendar(self, days: int = 30) -> Dict: """Get trading calendar with holidays and early closes.""" return await self._get("/market/calendar", params={"days": days}) async def get_next_market_open(self, exchange: str = "NYSE") -> Dict: """Get the next market open time.""" return await self._get("/market/next-open", params={"exchange": exchange}) async def get_next_market_close(self, exchange: str = "NYSE") -> Dict: """Get the next market close time.""" return await self._get("/market/next-close", params={"exchange": exchange}) async def count_trading_days(self, start: str, end: str) -> int: """Count trading days between two dates.""" response = await self._get("/market/trading-days", params={ "start": start, "end": end }) return response.get("trading_days", 0)