From f9c5e577965cc01af7aa64d5e7f48c148b45d751 Mon Sep 17 00:00:00 2001 From: Matt Stancliff Date: Wed, 5 Nov 2025 20:22:13 -0500 Subject: [PATCH 1/5] Implement missing tick types Also refactors some handling to use the more modern O(1) setting patterns. Adds missing tick types present in the current IBKR ibapi client we hadn't updated internally in a while. Currently untested, but looks good so far. Some specific data types or data extraction formats may need to be adjusted when we test against live data. Fixes #182 Fixes #182 --- ib_async/objects.py | 31 ++++++++ ib_async/ticker.py | 47 ++++++++++++ ib_async/wrapper.py | 179 ++++++++++++++++++++++++++++++++------------ 3 files changed, 208 insertions(+), 49 deletions(-) diff --git a/ib_async/objects.py b/ib_async/objects.py index 980f7a3..d7ecd79 100644 --- a/ib_async/objects.py +++ b/ib_async/objects.py @@ -323,6 +323,37 @@ class Fill(NamedTuple): time: datetime +@dataclass(slots=True, frozen=True) +class EfpData: + """ + Exchange for Physical (EFP) futures data. + + EFP allows trading a position in a single stock for a position + in the corresponding single stock future. + """ + + # Annualized basis points (financing rate comparable to broker rates) + basisPoints: float + + # Basis points formatted as percentage string + formattedBasisPoints: str + + # The implied Futures price + impliedFuture: float + + # Number of days until the future's last trade date + holdDays: int + + # Expiration date of the single stock future + futureLastTradeDate: str + + # Dividend impact on the annualized basis points interest rate + dividendImpact: float + + # Expected dividends until future expiration + dividendsToLastTradeDate: float + + @dataclass(slots=True, frozen=True) class OptionComputation: tickAttrib: int diff --git a/ib_async/ticker.py b/ib_async/ticker.py index 9771c89..cc98560 100644 --- a/ib_async/ticker.py +++ b/ib_async/ticker.py @@ -10,6 +10,7 @@ from ib_async.objects import ( Dividends, DOMLevel, + EfpData, FundamentalRatios, IBDefaults, MktDepthData, @@ -109,6 +110,28 @@ class Ticker: avOptionVolume: float = nan histVolatility: float = nan impliedVolatility: float = nan + openInterest: float = nan + lastRthTrade: float = nan + lastRegTime: str = "" + optionBidExch: str = "" + optionAskExch: str = "" + bondFactorMultiplier: float = nan + creditmanMarkPrice: float = nan + creditmanSlowMarkPrice: float = nan + delayedLastTimestamp: datetime | None = None + delayedHalted: float = nan + reutersMutualFunds: str = "" + etfNavClose: float = nan + etfNavPriorClose: float = nan + etfNavBid: float = nan + etfNavAsk: float = nan + etfNavLast: float = nan + etfFrozenNavLast: float = nan + etfNavHigh: float = nan + etfNavLow: float = nan + socialMarketAnalytics: str = "" + estimatedIpoMidpoint: float = nan + finalIpoLast: float = nan dividends: Optional[Dividends] = None fundamentalRatios: Optional[FundamentalRatios] = None ticks: list[TickData] = field(default_factory=list) @@ -124,6 +147,14 @@ class Ticker: askGreeks: Optional[OptionComputation] = None lastGreeks: Optional[OptionComputation] = None modelGreeks: Optional[OptionComputation] = None + custGreeks: Optional[OptionComputation] = None + bidEfp: Optional[EfpData] = None + askEfp: Optional[EfpData] = None + lastEfp: Optional[EfpData] = None + openEfp: Optional[EfpData] = None + highEfp: Optional[EfpData] = None + lowEfp: Optional[EfpData] = None + closeEfp: Optional[EfpData] = None auctionVolume: float = nan auctionPrice: float = nan auctionImbalance: float = nan @@ -195,6 +226,22 @@ def __post_init__(self): self.auctionPrice = self.defaults.unset self.auctionImbalance = self.defaults.unset self.regulatoryImbalance = self.defaults.unset + self.openInterest = self.defaults.unset + self.lastRthTrade = self.defaults.unset + self.bondFactorMultiplier = self.defaults.unset + self.creditmanMarkPrice = self.defaults.unset + self.creditmanSlowMarkPrice = self.defaults.unset + self.delayedHalted = self.defaults.unset + self.etfNavClose = self.defaults.unset + self.etfNavPriorClose = self.defaults.unset + self.etfNavBid = self.defaults.unset + self.etfNavAsk = self.defaults.unset + self.etfNavLast = self.defaults.unset + self.etfFrozenNavLast = self.defaults.unset + self.etfNavHigh = self.defaults.unset + self.etfNavLow = self.defaults.unset + self.estimatedIpoMidpoint = self.defaults.unset + self.finalIpoLast = self.defaults.unset self.created = True diff --git a/ib_async/wrapper.py b/ib_async/wrapper.py index 1ce3b01..6b0a330 100644 --- a/ib_async/wrapper.py +++ b/ib_async/wrapper.py @@ -25,6 +25,7 @@ DepthMktDataDescription, Dividends, DOMLevel, + EfpData, Execution, FamilyCode, Fill, @@ -101,6 +102,19 @@ 51: "askYield", 104: "askYield", 52: "lastYield", + 57: "lastRthTrade", + 78: "creditmanMarkPrice", + 79: "creditmanSlowMarkPrice", + 92: "etfNavClose", + 93: "etfNavPriorClose", + 94: "etfNavBid", + 95: "etfNavAsk", + 96: "etfNavLast", + 97: "etfFrozenNavLast", + 98: "etfNavHigh", + 99: "etfNavLow", + 101: "estimatedIpoMidpoint", + 102: "finalIpoLast", } @@ -111,6 +125,7 @@ 64: "volumeRate5Min", 65: "volumeRate10Min", 21: "avVolume", + 22: "openInterest", 27: "callOpenInterest", 28: "putOpenInterest", 29: "callVolume", @@ -133,6 +148,8 @@ 55: "tradeRate", 56: "volumeRate", 58: "rtHistVolatility", + 60: "bondFactorMultiplier", + 90: "delayedHalted", } GREEKS_TICK_MAP: Final[TickDict] = { @@ -144,6 +161,38 @@ 82: "lastGreeks", 13: "modelGreeks", 83: "modelGreeks", + 53: "custGreeks", +} + +EFP_TICK_MAP: Final[TickDict] = { + 38: "bidEfp", + 39: "askEfp", + 40: "lastEfp", + 41: "openEfp", + 42: "highEfp", + 43: "lowEfp", + 44: "closeEfp", +} + +STRING_TICK_MAP: Final[TickDict] = { + 25: "optionBidExch", + 26: "optionAskExch", + 32: "bidExchange", + 33: "askExchange", + 84: "lastExchange", + 85: "lastRegTime", + 91: "reutersMutualFunds", + 100: "socialMarketAnalytics", +} + +TIMESTAMP_TICK_MAP: Final[TickDict] = { + 45: "lastTimestamp", + 88: "delayedLastTimestamp", +} + +RT_VOLUME_TICK_MAP: Final[TickDict] = { + 48: "rtVolume", + 77: "rtTradeVolume", } @@ -418,6 +467,50 @@ def _setTimer(self, delay: float = 0): self.setTimeout(0) self.ib.timeoutEvent.emit(diff) + # Helper methods for tick processing + + def _processTimestampTick(self, ticker: Ticker, fieldName: str, value: str): + """Convert timestamp string to datetime and set on ticker field.""" + timestamp = int(value) + # Only populate if timestamp isn't '0' (we don't want to report "last trade: 20,000 days ago") + if timestamp: + setattr( + ticker, + fieldName, + datetime.fromtimestamp(timestamp, self.defaultTimezone), + ) + + def _processRtVolumeTick( + self, ticker: Ticker, tickType: int, value: str + ) -> tuple[float, float] | None: + """ + Parse RT Volume or RT Trade Volume tick. + + Format: price;size;ms since epoch;total volume;VWAP;single trade + Example: 701.28;1;1348075471534;67854;701.46918464;true + + Returns (price, size) tuple if valid, None otherwise. + """ + priceStr, sizeStr, rtTime, volume, vwap, _ = value.split(";") + + if volume: + # Set volume field based on tick type + volumeField = RT_VOLUME_TICK_MAP[tickType] + setattr(ticker, volumeField, float(volume)) + + if vwap: + ticker.vwap = float(vwap) + + if rtTime: + ticker.rtTime = datetime.fromtimestamp( + int(rtTime) / 1000, self.defaultTimezone + ) + + if priceStr == "": + return None + + return (float(priceStr), float(sizeStr)) + # wrapper methods def connectAck(self): @@ -1107,20 +1200,12 @@ def tickString(self, reqId: int, tickType: int, value: str): return try: - if tickType == 32: - ticker.bidExchange = value - elif tickType == 33: - ticker.askExchange = value - elif tickType == 84: - ticker.lastExchange = value - elif tickType == 45: - timestamp = int(value) - - # only populate if timestamp isn't '0' (we don't want to report "last trade: 20,000 days ago") - if timestamp: - ticker.lastTimestamp = datetime.fromtimestamp( - timestamp, self.defaultTimezone - ) + # Simple string assignments (O(1) dict lookup) + if tickType in STRING_TICK_MAP: + setattr(ticker, STRING_TICK_MAP[tickType], value) + elif tickType in TIMESTAMP_TICK_MAP: + # Timestamp conversion (O(1) dict lookup) + self._processTimestampTick(ticker, TIMESTAMP_TICK_MAP[tickType], value) elif tickType == 47: # https://web.archive.org/web/20200725010343/https://interactivebrokers.github.io/tws-api/fundamental_ratios_tags.html d = dict( @@ -1135,40 +1220,17 @@ def tickString(self, reqId: int, tickType: int, value: str): d[k] = float(v) # type: ignore d[k] = int(v) # type: ignore ticker.fundamentalRatios = FundamentalRatios(**d) - elif tickType in {48, 77}: - # RT Volume or RT Trade Volume string format: - # price;size;ms since epoch;total volume;VWAP;single trade - # example: - # 701.28;1;1348075471534;67854;701.46918464;true - priceStr, sizeStr, rtTime, volume, vwap, _ = value.split(";") - if volume: - if tickType == 48: - ticker.rtVolume = float(volume) - elif tickType == 77: - ticker.rtTradeVolume = float(volume) - - if vwap: - ticker.vwap = float(vwap) - - if rtTime: - ticker.rtTime = datetime.fromtimestamp( - int(rtTime) / 1000, self.defaultTimezone - ) - - if priceStr == "": - return - - price = float(priceStr) - size = float(sizeStr) - - ticker.prevLast = ticker.last - ticker.prevLastSize = ticker.lastSize - - ticker.last = price - ticker.lastSize = size - - tick = TickData(self.lastTime, tickType, price, size) - ticker.ticks.append(tick) + elif tickType in RT_VOLUME_TICK_MAP: + # RT Volume or RT Trade Volume (O(1) dict lookup + helper) + result = self._processRtVolumeTick(ticker, tickType, value) + if result: + price, size = result + ticker.prevLast = ticker.last + ticker.prevLastSize = ticker.lastSize + ticker.last = price + ticker.lastSize = size + tick = TickData(self.lastTime, tickType, price, size) + ticker.ticks.append(tick) elif tickType == 59: # Dividend tick: # https://interactivebrokers.github.io/tws-api/tick_types.html#ib_dividends @@ -1467,7 +1529,26 @@ def tickEFP( dividendImpact: float, dividendsToLastTradeDate: float, ): - pass + ticker = self.reqId2Ticker.get(reqId) + if not ticker: + return + + # Create EFP data object with all available information + # Note: totalDividends parameter is actually the implied future price per IBKR docs + efpData = EfpData( + basisPoints=basisPoints, + formattedBasisPoints=formattedBasisPoints, + impliedFuture=totalDividends, + holdDays=holdDays, + futureLastTradeDate=futureLastTradeDate, + dividendImpact=dividendImpact, + dividendsToLastTradeDate=dividendsToLastTradeDate, + ) + + # Store in appropriate field based on tick type (O(1) dict lookup) + if tickType in EFP_TICK_MAP: + setattr(ticker, EFP_TICK_MAP[tickType], efpData) + self.pendingTickers.add(ticker) def historicalSchedule( self, From 2e9bcdf66dd8314b9ef0d6c411d9bab6830fdfed Mon Sep 17 00:00:00 2001 From: Matt Stancliff Date: Wed, 5 Nov 2025 20:24:22 -0500 Subject: [PATCH 2/5] Add explicit `tzdata` dependency Also removes obsolete import guard since Python 3.9+ guarantees zoneinfo exists. Fixes #188 --- ib_async/util.py | 5 +---- pyproject.toml | 1 + 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/ib_async/util.py b/ib_async/util.py index 8462c51..41c3361 100644 --- a/ib_async/util.py +++ b/ib_async/util.py @@ -24,10 +24,7 @@ import eventkit as ev -try: - from zoneinfo import ZoneInfo -except ImportError: - from backports.zoneinfo import ZoneInfo # type: ignore +from zoneinfo import ZoneInfo globalErrorEvent = ev.Event() diff --git a/pyproject.toml b/pyproject.toml index e22bd8f..c3f3392 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ python = ">=3.10" aeventkit = "^2.1.0" # aeventkit = { path = "../eventkit", develop = true } nest_asyncio = "*" +tzdata = "^2025.2" [tool.poetry.urls] "Bug Tracker" = "https://github.com/ib-api-reloaded/ib_async/issues" From 049e86fd64172613f4234ecfa9d5e3d1e624c474 Mon Sep 17 00:00:00 2001 From: Matt Stancliff Date: Wed, 5 Nov 2025 21:03:29 -0500 Subject: [PATCH 3/5] Improve event loop fetching This wasn't a global problem and only triggered edge cases for people developing with too much (unnecessary?) complexity, but now we don't cache the event loop and instead request the live event loop each time. This is technically about 10x slower than using a cached event loop reader, but the difference is just +20ns slower if we ask python to lookup the active event loop each time vs using it cached on first access forever. Fixes #160 Fixes #186 Fixes #159 --- ib_async/connection.py | 5 ++--- ib_async/util.py | 38 ++++++++++++++++++++++++++++++++++---- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/ib_async/connection.py b/ib_async/connection.py index baebffd..15f1063 100644 --- a/ib_async/connection.py +++ b/ib_async/connection.py @@ -4,8 +4,6 @@ from eventkit import Event -from ib_async.util import getLoop - class Connection(asyncio.Protocol): """ @@ -35,7 +33,8 @@ async def connectAsync(self, host, port): self.disconnect() await self.disconnected self.reset() - loop = getLoop() + # Use get_running_loop() directly for optimal performance in async context + loop = asyncio.get_running_loop() self.transport, _ = await loop.create_connection(lambda: self, host, port) def disconnect(self): diff --git a/ib_async/util.py b/ib_async/util.py index 41c3361..02f1b48 100644 --- a/ib_async/util.py +++ b/ib_async/util.py @@ -361,7 +361,8 @@ def run(*awaitables: Awaitable, timeout: Optional[float] = None): if timeout: future = asyncio.wait_for(future, timeout) - task = asyncio.ensure_future(future) + # Pass loop explicitly to avoid deprecation warnings in Python 3.10+ + task = asyncio.ensure_future(future, loop=loop) def onError(_): task.cancel() @@ -492,13 +493,42 @@ def patchAsyncio(): nest_asyncio.apply() -@functools.cache def getLoop(): - """Get asyncio event loop or create one if it doesn't exist.""" + """ + Get asyncio event loop with smart fallback handling. + + This function is designed for use in synchronous contexts or when the + execution context is unknown. It will: + 1. Try to get the currently running event loop (if in async context) + 2. Fall back to getting the current thread's event loop via policy + 3. Create a new event loop if none exists or if the existing one is closed + + For performance-critical async code paths, prefer using + asyncio.get_running_loop() directly instead of this function. + + Note: This function does NOT cache the loop to avoid stale loop bugs + when loops are closed and recreated (e.g., in testing, Jupyter notebooks). + """ try: - # https://docs.python.org/3/library/asyncio-eventloop.html#asyncio.get_running_loop + # Fast path: we're in an async context (coroutine or callback) loop = asyncio.get_running_loop() + return loop except RuntimeError: + pass + + # We're in a sync context or no loop is running + # Use the event loop policy to get the loop for this thread + # This avoids deprecation warnings from get_event_loop() in Python 3.10+ + try: + loop = asyncio.get_event_loop_policy().get_event_loop() + except RuntimeError: + # No event loop exists for this thread, create one + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + return loop + + # Check if the loop we got is closed - if so, create a new one + if loop.is_closed(): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) From 8f7a677e5e3cc7b2011cf249b01eb61c76339331 Mon Sep 17 00:00:00 2001 From: Matt Stancliff Date: Wed, 5 Nov 2025 21:10:34 -0500 Subject: [PATCH 4/5] Use more modern python syntax We are currently Python 3.10 minimum, so use Python 3.10 syntax everywhere. --- examples/qt_ticker_table.py | 2 +- ib_async/__init__.py | 2 +- ib_async/client.py | 15 ++++----- ib_async/contract.py | 18 +++++----- ib_async/decoder.py | 2 +- ib_async/ib.py | 56 ++++++++++++++++---------------- ib_async/objects.py | 37 +++++++++++---------- ib_async/order.py | 3 +- ib_async/ticker.py | 40 +++++++++++------------ ib_async/util.py | 28 +++++----------- ib_async/wrapper.py | 15 ++++----- notebooks/bar_data.ipynb | 2 +- notebooks/contract_details.ipynb | 1 - notebooks/market_depth.ipynb | 2 +- notebooks/tick_data.ipynb | 2 +- pyproject.toml | 12 +++++++ tests/test_contract.py | 6 ++-- 17 files changed, 120 insertions(+), 123 deletions(-) diff --git a/examples/qt_ticker_table.py b/examples/qt_ticker_table.py index eb51c6b..de42fc7 100644 --- a/examples/qt_ticker_table.py +++ b/examples/qt_ticker_table.py @@ -1,6 +1,6 @@ import PyQt5.QtWidgets as qt -# import PySide6.QtWidgets as qt +# import PySide6.QtWidgets as qt from ib_async import IB, util from ib_async.contract import * # noqa diff --git a/ib_async/__init__.py b/ib_async/__init__.py index 8e21627..8e3b9c9 100644 --- a/ib_async/__init__.py +++ b/ib_async/__init__.py @@ -8,9 +8,9 @@ from . import util from .client import Client from .contract import ( + CFD, Bag, Bond, - CFD, ComboLeg, Commodity, ContFuture, diff --git a/ib_async/client.py b/ib_async/client.py index b60f1cc..fa217ee 100644 --- a/ib_async/client.py +++ b/ib_async/client.py @@ -7,7 +7,8 @@ import struct import time from collections import deque -from typing import Any, Callable, Deque, List, Optional +from collections.abc import Callable +from typing import Any from eventkit import Event @@ -15,7 +16,7 @@ from .contract import Contract from .decoder import Decoder from .objects import ConnectionStats, WshEventData -from .util import dataclassAsTuple, getLoop, run, UNSET_DOUBLE, UNSET_INTEGER +from .util import UNSET_DOUBLE, UNSET_INTEGER, dataclassAsTuple, getLoop, run class Client: @@ -127,8 +128,8 @@ def reset(self): self._numBytesRecv = 0 self._numMsgRecv = 0 self._isThrottling = False - self._msgQ: Deque[str] = deque() - self._timeQ: Deque[float] = deque() + self._msgQ: deque[str] = deque() + self._timeQ: deque[float] = deque() def serverVersion(self) -> int: return self._serverVersion @@ -171,7 +172,7 @@ def updateReqId(self, minReqId): """Update the next reqId to be at least ``minReqId``.""" self._reqIdSeq = max(self._reqIdSeq, minReqId) - def getAccounts(self) -> List[str]: + def getAccounts(self) -> list[str]: """Get the list of account names that are under management.""" if not self.isReady(): raise ConnectionError("Not connected") @@ -187,9 +188,7 @@ def setConnectOptions(self, connectOptions: str): """ self.connectOptions = connectOptions.encode() - def connect( - self, host: str, port: int, clientId: int, timeout: Optional[float] = 2.0 - ): + def connect(self, host: str, port: int, clientId: int, timeout: float | None = 2.0): """ Connect to a running TWS or IB gateway application. diff --git a/ib_async/contract.py b/ib_async/contract.py index 3c40d11..d674650 100644 --- a/ib_async/contract.py +++ b/ib_async/contract.py @@ -2,7 +2,7 @@ import datetime as dt from dataclasses import dataclass, field -from typing import List, NamedTuple, Optional +from typing import NamedTuple, Optional import ib_async.util as util @@ -100,7 +100,7 @@ class Contract: description: str = "" issuerId: str = "" comboLegsDescrip: str = "" - comboLegs: List["ComboLeg"] = field(default_factory=list) + comboLegs: list["ComboLeg"] = field(default_factory=list) deltaNeutralContract: Optional["DeltaNeutralContract"] = None @staticmethod @@ -550,7 +550,7 @@ class TradingSession(NamedTuple): @dataclass class ContractDetails: - contract: Optional[Contract] = None + contract: Contract | None = None marketName: str = "" minTick: float = 0.0 orderTypes: str = "" @@ -572,7 +572,7 @@ class ContractDetails: underSymbol: str = "" underSecType: str = "" marketRuleIds: str = "" - secIdList: List[TagValue] = field(default_factory=list) + secIdList: list[TagValue] = field(default_factory=list) realExpirationDate: str = "" lastTradeTime: str = "" stockType: str = "" @@ -596,13 +596,13 @@ class ContractDetails: nextOptionPartial: bool = False notes: str = "" - def tradingSessions(self) -> List[TradingSession]: + def tradingSessions(self) -> list[TradingSession]: return self._parseSessions(self.tradingHours) - def liquidSessions(self) -> List[TradingSession]: + def liquidSessions(self) -> list[TradingSession]: return self._parseSessions(self.liquidHours) - def _parseSessions(self, s: str) -> List[TradingSession]: + def _parseSessions(self, s: str) -> list[TradingSession]: """Parse the IBKR session date range text format into native Python objects. Note: The IBKR date range format looks like: @@ -637,8 +637,8 @@ def _parseSessions(self, s: str) -> List[TradingSession]: @dataclass class ContractDescription: - contract: Optional[Contract] = None - derivativeSecTypes: List[str] = field(default_factory=list) + contract: Contract | None = None + derivativeSecTypes: list[str] = field(default_factory=list) @dataclass diff --git a/ib_async/decoder.py b/ib_async/decoder.py index 0b84391..8978464 100644 --- a/ib_async/decoder.py +++ b/ib_async/decoder.py @@ -32,7 +32,7 @@ TickAttribLast, ) from .order import Order, OrderComboLeg, OrderCondition, OrderState -from .util import parseIBDatetime, UNSET_DOUBLE, ZoneInfo +from .util import UNSET_DOUBLE, ZoneInfo, parseIBDatetime from .wrapper import Wrapper diff --git a/ib_async/ib.py b/ib_async/ib.py index 4f73160..e16985d 100644 --- a/ib_async/ib.py +++ b/ib_async/ib.py @@ -5,8 +5,9 @@ import datetime import logging import time -from enum import auto, Flag -from typing import Any, Awaitable, Iterator, List, Optional, Union +from collections.abc import Awaitable, Iterator +from enum import Flag, auto +from typing import Any from eventkit import Event @@ -48,7 +49,6 @@ LimitOrder, Order, OrderState, - OrderStateNumeric, OrderStatus, StopOrder, Trade, @@ -623,7 +623,7 @@ def executions(self) -> list[Execution]: """List of all executions from this session.""" return list(fill.execution for fill in self.wrapper.fills.values()) - def ticker(self, contract: Contract) -> Optional[Ticker]: + def ticker(self, contract: Contract) -> Ticker | None: """ Get ticker of the given contract. It must have been requested before with reqMktData with the same contract object. The ticker may not be @@ -642,7 +642,7 @@ def pendingTickers(self) -> list[Ticker]: """Get a list of all tickers that have pending ticks or domTicks.""" return list(self.wrapper.pendingTickers) - def realtimeBars(self) -> list[Union[BarDataList, RealTimeBarList]]: + def realtimeBars(self) -> list[BarDataList | RealTimeBarList]: """ Get a list of all live updated bars. These can be 5 second realtime bars or live updated historical bars. @@ -815,7 +815,7 @@ def placeOrder(self, contract: Contract, order: Order) -> Trade: def cancelOrder( self, order: Order, manualCancelOrderTime: str = "" - ) -> Optional[Trade]: + ) -> Trade | None: """ Cancel the order and return the Trade it belongs to. @@ -957,7 +957,7 @@ def reqCompletedOrders(self, apiOnly: bool) -> list[Trade]: """ return self._run(self.reqCompletedOrdersAsync(apiOnly)) - def reqExecutions(self, execFilter: Optional[ExecutionFilter] = None) -> list[Fill]: + def reqExecutions(self, execFilter: ExecutionFilter | None = None) -> list[Fill]: """ It is recommended to use :meth:`.fills` or :meth:`.executions` instead. @@ -1167,7 +1167,7 @@ def cancelRealTimeBars(self, bars: RealTimeBarList): def reqHistoricalData( self, contract: Contract, - endDateTime: Union[datetime.datetime, datetime.date, str, None], + endDateTime: datetime.datetime | datetime.date | str | None, durationStr: str, barSizeSetting: str, whatToShow: str, @@ -1250,7 +1250,7 @@ def reqHistoricalSchedule( self, contract: Contract, numDays: int, - endDateTime: Union[datetime.datetime, datetime.date, str, None] = "", + endDateTime: datetime.datetime | datetime.date | str | None = "", useRTH: bool = True, ) -> HistoricalSchedule: """ @@ -1275,14 +1275,14 @@ def reqHistoricalSchedule( def reqHistoricalTicks( self, contract: Contract, - startDateTime: Union[str, datetime.date], - endDateTime: Union[str, datetime.date], + startDateTime: str | datetime.date, + endDateTime: str | datetime.date, numberOfTicks: int, whatToShow: str, useRth: bool, ignoreSize: bool = False, miscOptions: list[TagValue] = [], - ) -> List: + ) -> list: """ Request historical ticks. The time resolution of the ticks is one second. @@ -1837,8 +1837,8 @@ def reqHistoricalNews( self, conId: int, providerCodes: str, - startDateTime: Union[str, datetime.date], - endDateTime: Union[str, datetime.date], + startDateTime: str | datetime.date, + endDateTime: str | datetime.date, totalResults: int, historicalNewsOptions: list[TagValue] = [], ) -> HistoricalNews: @@ -2029,7 +2029,7 @@ async def connectAsync( host: str = "127.0.0.1", port: int = 7497, clientId: int = 1, - timeout: Optional[float] = 4, + timeout: float | None = 4, readonly: bool = False, account: str = "", raiseSyncErrors: bool = False, @@ -2276,7 +2276,7 @@ def reqCompletedOrdersAsync(self, apiOnly: bool) -> Awaitable[list[Trade]]: return future def reqExecutionsAsync( - self, execFilter: Optional[ExecutionFilter] = None + self, execFilter: ExecutionFilter | None = None ) -> Awaitable[list[Fill]]: execFilter = execFilter or ExecutionFilter() reqId = self.client.getReqId() @@ -2299,7 +2299,7 @@ def reqContractDetailsAsync( async def reqMatchingSymbolsAsync( self, pattern: str - ) -> Optional[list[ContractDescription]]: + ) -> list[ContractDescription] | None: reqId = self.client.getReqId() future = self.wrapper.startReq(reqId) self.client.reqMatchingSymbols(reqId, pattern) @@ -2312,7 +2312,7 @@ async def reqMatchingSymbolsAsync( async def reqMarketRuleAsync( self, marketRuleId: int - ) -> Optional[list[PriceIncrement]]: + ) -> list[PriceIncrement] | None: future = self.wrapper.startReq(f"marketRule-{marketRuleId}") try: self.client.reqMarketRule(marketRuleId) @@ -2325,7 +2325,7 @@ async def reqMarketRuleAsync( async def reqHistoricalDataAsync( self, contract: Contract, - endDateTime: Union[datetime.datetime, datetime.date, str, None], + endDateTime: datetime.datetime | datetime.date | str | None, durationStr: str, barSizeSetting: str, whatToShow: str, @@ -2377,7 +2377,7 @@ def reqHistoricalScheduleAsync( self, contract: Contract, numDays: int, - endDateTime: Union[datetime.datetime, datetime.date, str, None] = "", + endDateTime: datetime.datetime | datetime.date | str | None = "", useRTH: bool = True, ) -> Awaitable[HistoricalSchedule]: reqId = self.client.getReqId() @@ -2401,14 +2401,14 @@ def reqHistoricalScheduleAsync( def reqHistoricalTicksAsync( self, contract: Contract, - startDateTime: Union[str, datetime.date], - endDateTime: Union[str, datetime.date], + startDateTime: str | datetime.date, + endDateTime: str | datetime.date, numberOfTicks: int, whatToShow: str, useRth: bool, ignoreSize: bool = False, miscOptions: list[TagValue] = [], - ) -> Awaitable[List]: + ) -> Awaitable[list]: reqId = self.client.getReqId() future = self.wrapper.startReq(reqId, contract) start = util.formatIBDatetime(startDateTime) @@ -2502,7 +2502,7 @@ async def calculateImpliedVolatilityAsync( optionPrice: float, underPrice: float, implVolOptions: list[TagValue] = [], - ) -> Optional[OptionComputation]: + ) -> OptionComputation | None: reqId = self.client.getReqId() future = self.wrapper.startReq(reqId, contract) self.client.calculateImpliedVolatility( @@ -2523,7 +2523,7 @@ async def calculateOptionPriceAsync( volatility: float, underPrice: float, optPrcOptions: list[TagValue] = [], - ) -> Optional[OptionComputation]: + ) -> OptionComputation | None: reqId = self.client.getReqId() future = self.wrapper.startReq(reqId, contract) self.client.calculateOptionPrice( @@ -2571,11 +2571,11 @@ async def reqHistoricalNewsAsync( self, conId: int, providerCodes: str, - startDateTime: Union[str, datetime.date], - endDateTime: Union[str, datetime.date], + startDateTime: str | datetime.date, + endDateTime: str | datetime.date, totalResults: int, historicalNewsOptions: list[TagValue] = [], - ) -> Optional[HistoricalNews]: + ) -> HistoricalNews | None: reqId = self.client.getReqId() future = self.wrapper.startReq(reqId) diff --git a/ib_async/objects.py b/ib_async/objects.py index d7ecd79..f036034 100644 --- a/ib_async/objects.py +++ b/ib_async/objects.py @@ -3,8 +3,9 @@ from __future__ import annotations from dataclasses import dataclass, field -from datetime import date as date_, datetime, timezone, tzinfo -from typing import Any, List, NamedTuple, Optional, Union +from datetime import date as date_ +from datetime import datetime, timezone, tzinfo +from typing import Any, NamedTuple from eventkit import Event @@ -95,7 +96,7 @@ class ExecutionFilter: @dataclass class BarData: - date: Union[date_, datetime] = EPOCH + date: date_ | datetime = EPOCH open: float = 0.0 high: float = 0.0 low: float = 0.0 @@ -199,7 +200,7 @@ class HistoricalSchedule: startDateTime: str = "" endDateTime: str = "" timeZone: str = "" - sessions: List[HistoricalSession] = field(default_factory=list) + sessions: list[HistoricalSession] = field(default_factory=list) @dataclass @@ -418,16 +419,16 @@ class OptionChain: underlyingConId: int tradingClass: str multiplier: str - expirations: List[str] - strikes: List[float] + expirations: list[str] + strikes: list[float] @dataclass(slots=True, frozen=True) class Dividends: - past12Months: Optional[float] - next12Months: Optional[float] - nextDate: Optional[date_] - nextAmount: Optional[float] + past12Months: float | None + next12Months: float | None + nextDate: date_ | None + nextAmount: float | None @dataclass(slots=True, frozen=True) @@ -484,7 +485,7 @@ class ConnectionStats: numMsgSent: int -class BarDataList(List[BarData]): +class BarDataList(list[BarData]): """ List of :class:`.BarData` that also stores all request parameters. @@ -496,14 +497,14 @@ class BarDataList(List[BarData]): reqId: int contract: Contract - endDateTime: Union[datetime, date_, str, None] + endDateTime: datetime | date_ | str | None durationStr: str barSizeSetting: str whatToShow: str useRTH: bool formatDate: int keepUpToDate: bool - chartOptions: List[TagValue] + chartOptions: list[TagValue] def __init__(self, *args): super().__init__(*args) @@ -513,7 +514,7 @@ def __eq__(self, other) -> bool: return self is other -class RealTimeBarList(List[RealTimeBar]): +class RealTimeBarList(list[RealTimeBar]): """ List of :class:`.RealTimeBar` that also stores all request parameters. @@ -528,7 +529,7 @@ class RealTimeBarList(List[RealTimeBar]): barSize: int whatToShow: str useRTH: bool - realTimeBarsOptions: List[TagValue] + realTimeBarsOptions: list[TagValue] def __init__(self, *args): super().__init__(*args) @@ -538,7 +539,7 @@ def __eq__(self, other) -> bool: return self is other -class ScanDataList(List[ScanData]): +class ScanDataList(list[ScanData]): """ List of :class:`.ScanData` that also stores all request parameters. @@ -548,8 +549,8 @@ class ScanDataList(List[ScanData]): reqId: int subscription: ScannerSubscription - scannerSubscriptionOptions: List[TagValue] - scannerSubscriptionFilterOptions: List[TagValue] + scannerSubscriptionOptions: list[TagValue] + scannerSubscriptionFilterOptions: list[TagValue] def __init__(self, *args): super().__init__(*args) diff --git a/ib_async/order.py b/ib_async/order.py index 33b866a..4cf726c 100644 --- a/ib_async/order.py +++ b/ib_async/order.py @@ -3,7 +3,6 @@ from __future__ import annotations import dataclasses - from dataclasses import dataclass, field from decimal import Decimal from typing import ClassVar, NamedTuple @@ -12,7 +11,7 @@ from .contract import Contract, TagValue from .objects import Fill, SoftDollarTier, TradeLogEntry -from .util import dataclassNonDefaults, UNSET_DOUBLE, UNSET_INTEGER +from .util import UNSET_DOUBLE, UNSET_INTEGER, dataclassNonDefaults @dataclass diff --git a/ib_async/ticker.py b/ib_async/ticker.py index cc98560..50e704e 100644 --- a/ib_async/ticker.py +++ b/ib_async/ticker.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, field from datetime import datetime -from typing import ClassVar, Optional, Union +from typing import ClassVar from eventkit import Event, Op @@ -91,7 +91,7 @@ class Ticker: rtHistVolatility: float = nan rtVolume: float = nan rtTradeVolume: float = nan - rtTime: Optional[datetime] = None + rtTime: datetime | None = None avVolume: float = nan tradeCount: float = nan tradeRate: float = nan @@ -132,29 +132,29 @@ class Ticker: socialMarketAnalytics: str = "" estimatedIpoMidpoint: float = nan finalIpoLast: float = nan - dividends: Optional[Dividends] = None - fundamentalRatios: Optional[FundamentalRatios] = None + dividends: Dividends | None = None + fundamentalRatios: FundamentalRatios | None = None ticks: list[TickData] = field(default_factory=list) - tickByTicks: list[ - Union[TickByTickAllLast, TickByTickBidAsk, TickByTickMidPoint] - ] = field(default_factory=list) + tickByTicks: list[TickByTickAllLast | TickByTickBidAsk | TickByTickMidPoint] = ( + field(default_factory=list) + ) domBids: list[DOMLevel] = field(default_factory=list) domBidsDict: dict[int, DOMLevel] = field(default_factory=dict) domAsks: list[DOMLevel] = field(default_factory=list) domAsksDict: dict[int, DOMLevel] = field(default_factory=dict) domTicks: list[MktDepthData] = field(default_factory=list) - bidGreeks: Optional[OptionComputation] = None - askGreeks: Optional[OptionComputation] = None - lastGreeks: Optional[OptionComputation] = None - modelGreeks: Optional[OptionComputation] = None - custGreeks: Optional[OptionComputation] = None - bidEfp: Optional[EfpData] = None - askEfp: Optional[EfpData] = None - lastEfp: Optional[EfpData] = None - openEfp: Optional[EfpData] = None - highEfp: Optional[EfpData] = None - lowEfp: Optional[EfpData] = None - closeEfp: Optional[EfpData] = None + bidGreeks: OptionComputation | None = None + askGreeks: OptionComputation | None = None + lastGreeks: OptionComputation | None = None + modelGreeks: OptionComputation | None = None + custGreeks: OptionComputation | None = None + bidEfp: EfpData | None = None + askEfp: EfpData | None = None + lastEfp: EfpData | None = None + openEfp: EfpData | None = None + highEfp: EfpData | None = None + lowEfp: EfpData | None = None + closeEfp: EfpData | None = None auctionVolume: float = nan auctionPrice: float = nan auctionImbalance: float = nan @@ -385,7 +385,7 @@ def on_source(self, ticker): @dataclass class Bar: - time: Optional[datetime] + time: datetime | None open: float = nan high: float = nan low: float = nan diff --git a/ib_async/util.py b/ib_async/util.py index 02f1b48..25dc0d9 100644 --- a/ib_async/util.py +++ b/ib_async/util.py @@ -2,30 +2,21 @@ import asyncio import datetime as dt -import functools import logging import math import signal import sys import time +from collections.abc import AsyncIterator, Awaitable, Callable, Iterator from dataclasses import fields, is_dataclass from typing import ( Any, - AsyncIterator, - Awaitable, - Callable, Final, - Iterator, - List, - Optional, TypeAlias, - Union, ) - -import eventkit as ev - from zoneinfo import ZoneInfo +import eventkit as ev globalErrorEvent = ev.Event() """ @@ -39,7 +30,7 @@ Time_t: TypeAlias = dt.time | dt.datetime -def df(objs, labels: Optional[List[str]] = None): +def df(objs, labels: list[str] | None = None): """ Create pandas DataFrame from the sequence of same-type objects. @@ -296,7 +287,7 @@ def formatSI(n: float) -> str: log = int(math.floor(math.log10(n))) i, j = divmod(log, 3) for _try in range(2): - templ = "%.{}f".format(2 - j) + templ = f"%.{2 - j}f" val = templ % (n * 10 ** (-3 * i)) if val != "1000": break @@ -322,7 +313,7 @@ def __exit__(self, *_args): print(self.title + " took " + formatSI(time.time() - self.t0) + "s") -def run(*awaitables: Awaitable, timeout: Optional[float] = None): +def run(*awaitables: Awaitable, timeout: float | None = None): """ By default run the event loop forever. @@ -340,10 +331,7 @@ def run(*awaitables: Awaitable, timeout: Optional[float] = None): loop.run_forever() result = None - if sys.version_info >= (3, 7): - all_tasks = asyncio.all_tasks(loop) # type: ignore - else: - all_tasks = asyncio.Task.all_tasks() # type: ignore + all_tasks = asyncio.all_tasks(loop) # type: ignore if all_tasks: # cancel pending tasks @@ -583,7 +571,7 @@ def qt_step(): qt_step() -def formatIBDatetime(t: Union[dt.date, dt.datetime, str, None]) -> str: +def formatIBDatetime(t: dt.date | dt.datetime | str | None) -> str: """Format date or datetime to string that IB uses.""" if not t: s = "" @@ -602,7 +590,7 @@ def formatIBDatetime(t: Union[dt.date, dt.datetime, str, None]) -> str: return s -def parseIBDatetime(s: str) -> Union[dt.date, dt.datetime]: +def parseIBDatetime(s: str) -> dt.date | dt.datetime: """Parse string in IB date or datetime format to datetime.""" if len(s) == 8: # YYYYmmdd diff --git a/ib_async/wrapper.py b/ib_async/wrapper.py index 6b0a330..5e87919 100644 --- a/ib_async/wrapper.py +++ b/ib_async/wrapper.py @@ -3,12 +3,11 @@ import asyncio import logging import time - from collections import defaultdict from contextlib import suppress from dataclasses import dataclass, field from datetime import datetime -from typing import Any, cast, Final, Optional, TYPE_CHECKING, TypeAlias, Union +from typing import TYPE_CHECKING, Any, Final, TypeAlias, cast from ib_async.contract import ( Contract, @@ -64,13 +63,13 @@ from ib_async.order import Order, OrderState, OrderStatus, Trade from ib_async.ticker import Ticker from ib_async.util import ( + UNSET_DOUBLE, + UNSET_INTEGER, dataclassAsDict, dataclassUpdate, getLoop, globalErrorEvent, parseIBDatetime, - UNSET_DOUBLE, - UNSET_INTEGER, ) if TYPE_CHECKING: @@ -256,7 +255,7 @@ class Wrapper: reqId2Ticker: dict[int, Ticker] = field(init=False) """ reqId -> Ticker """ - ticker2ReqId: dict[Union[int, str], dict[Ticker, int]] = field(init=False) + ticker2ReqId: dict[int | str, dict[Ticker, int]] = field(init=False) """ tickType -> Ticker -> reqId """ reqId2Subscriber: dict[int, Any] = field(init=False) @@ -399,7 +398,7 @@ def _endReq(self, key, result=None, success=True): else: future.set_exception(result) - def startTicker(self, reqId: int, contract: Contract, tickType: Union[int, str]): + def startTicker(self, reqId: int, contract: Contract, tickType: int | str): """ Start a tick request that has the reqId associated with the contract. Return the ticker. @@ -414,7 +413,7 @@ def startTicker(self, reqId: int, contract: Contract, tickType: Union[int, str]) self.ticker2ReqId[tickType][ticker] = reqId return ticker - def endTicker(self, ticker: Ticker, tickType: Union[int, str]): + def endTicker(self, ticker: Ticker, tickType: int | str): reqId = self.ticker2ReqId[tickType].pop(ticker, 0) self._reqId2Contract.pop(reqId, None) return reqId @@ -750,7 +749,7 @@ def orderStatus( key = self.orderKey(clientId, orderId, permId) trade = self.trades.get(key) if trade: - msg: Optional[str] + msg: str | None oldStatus = trade.orderStatus.status new = dict( status=status, diff --git a/notebooks/bar_data.ipynb b/notebooks/bar_data.ipynb index 426effd..f5019f2 100644 --- a/notebooks/bar_data.ipynb +++ b/notebooks/bar_data.ipynb @@ -497,8 +497,8 @@ } ], "source": [ - "from IPython.display import display, clear_output\n", "import matplotlib.pyplot as plt\n", + "from IPython.display import clear_output, display\n", "\n", "\n", "def onBarUpdate(bars, hasNewBar):\n", diff --git a/notebooks/contract_details.ipynb b/notebooks/contract_details.ipynb index e4b7b3f..4b0de58 100644 --- a/notebooks/contract_details.ipynb +++ b/notebooks/contract_details.ipynb @@ -29,7 +29,6 @@ "\n", "util.startLoop()\n", "\n", - "import logging\n", "# util.logToConsole(logging.DEBUG)\n", "\n", "ib = IB()\n", diff --git a/notebooks/market_depth.ipynb b/notebooks/market_depth.ipynb index 47e5816..77076f2 100644 --- a/notebooks/market_depth.ipynb +++ b/notebooks/market_depth.ipynb @@ -176,8 +176,8 @@ } ], "source": [ - "from IPython.display import display, clear_output\n", "import pandas as pd\n", + "from IPython.display import clear_output, display\n", "\n", "df = pd.DataFrame(index=range(5), columns=\"bidSize bidPrice askPrice askSize\".split())\n", "\n", diff --git a/notebooks/tick_data.ipynb b/notebooks/tick_data.ipynb index cc8c89e..6c1ed3b 100644 --- a/notebooks/tick_data.ipynb +++ b/notebooks/tick_data.ipynb @@ -256,8 +256,8 @@ } ], "source": [ - "from IPython.display import display, clear_output\n", "import pandas as pd\n", + "from IPython.display import clear_output, display\n", "\n", "df = pd.DataFrame(\n", " index=[c.pair() for c in contracts],\n", diff --git a/pyproject.toml b/pyproject.toml index c3f3392..a7908f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,3 +64,15 @@ asyncio_mode="auto" [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" + +[tool.ruff] +target-version = "py310" + +[tool.ruff.lint] +extend-select = [ + # convert legacy python syntax to modern syntax + "UP", + # isort imports + "I", +] +ignore = ["E402"] # Ignore Module level import not at top of file diff --git a/tests/test_contract.py b/tests/test_contract.py index 8c9bf1c..fa25221 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -1,8 +1,8 @@ +import pandas as pd + import ib_async from ib_async import * -import pandas as pd - def test_contract_format_data_pd(): """Simple smoketest to verify everything still works minimally.""" @@ -50,4 +50,4 @@ def get_OHLCV( for symbol_str in symbols: symbol = Stock(symbol_str, "SMART", "USD") - df = get_OHLCV(symbol) + get_OHLCV(symbol) From b4a532ec33868404cc9617bbbb566d3785d1d548 Mon Sep 17 00:00:00 2001 From: Matt Stancliff Date: Wed, 5 Nov 2025 21:25:40 -0500 Subject: [PATCH 5/5] Improve ruff check warnings Lots of warnings are just from notebooks which we can ignore due to import issues. Others are smaller things we fixed in-place. --- pyproject.toml | 5 +++++ tests/test_contract.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a7908f7..b44245f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,11 @@ build-backend = "poetry.core.masonry.api" [tool.ruff] target-version = "py310" +exclude = [ + "notebooks/", + "upstream_api_architecture/", + "examples/", +] [tool.ruff.lint] extend-select = [ diff --git a/tests/test_contract.py b/tests/test_contract.py index fa25221..128fceb 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -1,7 +1,7 @@ import pandas as pd import ib_async -from ib_async import * +from ib_async import Stock, util def test_contract_format_data_pd():