Skip to content

Commit 5574f3e

Browse files
committed
Implement ability to customize default values
This is a fairly big change for usability, but it shouldn't change behavior unless you opt-in to new values yourself. IBKR uses a strictly typed API where integer fields can only send integers and float fields can only send floats and string fields can only send strings. This is why, in some places, IBKR uses FLOAT_MAX of "1.7976931348623157e+308" to mean "no data available" while in other places IBKR uses "price=-1, size=0" to mean "no data available," and historically ib_insync has also uses `nan` to mean "field is not initialized with API data yet." So there are potentially 4 different ways to check if data is not actually real data. This causes more problems when people are being lazy (and who isn't?) when just using price data, but price=-1 when price is missing (and many negative prices _are_ valid when using credit spreads), so it can cause actual problems. Now we have a way to request different defaults. The `IB()` constructor now takes an optional argument as: ```python ib = IB(IBDefaults( emptyPrice=None, emptySize=None, unset=None, timezone=pytz.timezone("US/Eastern") ) ``` Providing an `IBDefaults()` object will overwrite the IBKR defaults of `emptyPrice=-1, emptySize=0, unset=nan, timezone=datetime.timezone.utc` with your own defaults. You can technically set any value there, but it's your own fault if you set `emptySize=3` or something awful on your own. You can be even more explicit and define your _own_ empty objects or enums to represent empty data too. Just make sure if you do use things like `None` for price, you run your checks as `price is not None` because for certain spread opportunities, prices _can_ be valid limit prices to execute (so `if price:` would fail a valid price of `0`). This is currently still undergoing more testing, but it works so far for me (and it's nicer having timestamps in my own exchange timezone instead of utc when printing tickers and order event logs everywhere). We may add another couple default types to allow removing `UNSET_DOUBLE` from showing up in user API output anywhere too.
1 parent fb676fb commit 5574f3e

File tree

8 files changed

+243
-91
lines changed

8 files changed

+243
-91
lines changed

ib_async/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
HistoricalTick,
5555
HistoricalTickBidAsk,
5656
HistoricalTickLast,
57+
IBDefaults,
5758
MktDepthData,
5859
NewsArticle,
5960
NewsBulletin,

ib_async/decoder.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -465,7 +465,7 @@ def execDetails(self, fields):
465465
if tz:
466466
time = time.replace(tzinfo=ZoneInfo(str(tz)))
467467

468-
ex.time = time.astimezone(timezone.utc)
468+
ex.time = time.astimezone(self.wrapper.defaultTimezone)
469469
self.wrapper.execDetails(int(reqId), c, ex)
470470

471471
def historicalData(self, fields):
@@ -779,7 +779,7 @@ def historicalTicks(self, fields):
779779
get()
780780
price = float(get())
781781
size = float(get())
782-
dt = datetime.fromtimestamp(time, timezone.utc)
782+
dt = datetime.fromtimestamp(time, self.wrapper.defaultTimezone)
783783
ticks.append(HistoricalTick(dt, price, size))
784784

785785
done = bool(int(get()))
@@ -800,7 +800,7 @@ def historicalTicksBidAsk(self, fields):
800800
priceAsk = float(get())
801801
sizeBid = float(get())
802802
sizeAsk = float(get())
803-
dt = datetime.fromtimestamp(time, timezone.utc)
803+
dt = datetime.fromtimestamp(time, self.wrapper.defaultTimezone)
804804
ticks.append(
805805
HistoricalTickBidAsk(dt, attrib, priceBid, priceAsk, sizeBid, sizeAsk)
806806
)
@@ -821,7 +821,7 @@ def historicalTicksLast(self, fields):
821821
size = float(get())
822822
exchange = get()
823823
specialConditions = get()
824-
dt = datetime.fromtimestamp(time, timezone.utc)
824+
dt = datetime.fromtimestamp(time, self.wrapper.defaultTimezone)
825825
ticks.append(
826826
HistoricalTickLast(dt, attrib, price, size, exchange, specialConditions)
827827
)
@@ -914,6 +914,7 @@ def openOrder(self, fields):
914914
o.faPercentage,
915915
*fields,
916916
) = fields
917+
917918
if self.serverVersion < 177:
918919
o.faProfile, *fields = fields
919920

ib_async/ib.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
HistogramData,
2424
HistoricalNews,
2525
HistoricalSchedule,
26+
IBDefaults,
2627
NewsArticle,
2728
NewsBulletin,
2829
NewsProvider,
@@ -271,9 +272,9 @@ class IB:
271272
MaxSyncedSubAccounts: int = 50
272273
TimezoneTWS: str = ""
273274

274-
def __init__(self):
275+
def __init__(self, defaults: IBDefaults = IBDefaults()):
275276
self._createEvents()
276-
self.wrapper = Wrapper(self)
277+
self.wrapper = Wrapper(self, defaults=defaults)
277278
self.client = Client(self.wrapper)
278279
self.errorEvent += self._onError
279280
self.client.apiEnd += self.disconnectedEvent
@@ -779,7 +780,7 @@ def placeOrder(self, contract: Contract, order: Order) -> Trade:
779780
"""
780781
orderId = order.orderId or self.client.getReqId()
781782
self.client.placeOrder(orderId, contract, order)
782-
now = datetime.datetime.now(datetime.timezone.utc)
783+
now = datetime.datetime.now(self.wrapper.defaultTimezone)
783784
key = self.wrapper.orderKey(self.wrapper.clientId, orderId, order.permId)
784785
trade = self.wrapper.trades.get(key)
785786
if trade:
@@ -814,7 +815,7 @@ def cancelOrder(
814815
manualCancelOrderTime: For audit trail.
815816
"""
816817
self.client.cancelOrder(order.orderId, manualCancelOrderTime)
817-
now = datetime.datetime.now(datetime.timezone.utc)
818+
now = datetime.datetime.now(self.wrapper.defaultTimezone)
818819
key = self.wrapper.orderKey(order.clientId, order.orderId, order.permId)
819820
trade = self.wrapper.trades.get(key)
820821
if trade:

ib_async/objects.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
"""Object hierarchy."""
22

33
from dataclasses import dataclass, field
4-
from datetime import date as date_, datetime
5-
from typing import List, NamedTuple, Optional, Union
4+
from datetime import date as date_, datetime, timezone
5+
from typing import Any, List, NamedTuple, Optional, Union
66

77
from eventkit import Event
88

@@ -488,3 +488,18 @@ class FundamentalRatios(DynamicObject):
488488
"""
489489

490490
pass
491+
492+
493+
@dataclass
494+
class IBDefaults:
495+
"""A simple way to provide default values when populating API data."""
496+
497+
# optionally replace IBKR using -1 price and 0 size when quotes don't exist
498+
emptyPrice: Any = -1
499+
emptySize: Any = 0
500+
501+
# optionally replace ib_async default for all instance variable values before popualted from API updates
502+
unset: Any = nan
503+
504+
# optionally change the timezone used for log history events in objects (no impact on orders or data processing)
505+
timezone: timezone = timezone.utc

ib_async/order.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,10 @@ def __repr__(self):
163163
attrs = dataclassNonDefaults(self)
164164
if self.__class__ is not Order:
165165
attrs.pop("orderType", None)
166+
166167
if not self.softDollarTier:
167168
attrs.pop("softDollarTier")
169+
168170
clsName = self.__class__.__qualname__
169171
kwargs = ", ".join(f"{k}={v!r}" for k, v in attrs.items())
170172
return f"{clsName}({kwargs})"

ib_async/ticker.py

Lines changed: 85 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from dataclasses import dataclass, field
44
from datetime import datetime
5-
from typing import ClassVar, List, Optional, Union
5+
from typing import Any, ClassVar, List, Optional, Union
66

77
from eventkit import Event, Op
88

@@ -11,6 +11,7 @@
1111
DOMLevel,
1212
Dividends,
1313
FundamentalRatios,
14+
IBDefaults,
1415
MktDepthData,
1516
OptionComputation,
1617
TickByTickAllLast,
@@ -48,8 +49,8 @@ class Ticker:
4849

4950
events: ClassVar = ("updateEvent",)
5051

51-
contract: Optional[Contract] = None
52-
time: Optional[datetime] = None
52+
contract: Contract | None = None
53+
time: datetime | None = None
5354
marketDataType: int = 1
5455
minTick: float = nan
5556
bid: float = nan
@@ -129,8 +130,72 @@ class Ticker:
129130
bboExchange: str = ""
130131
snapshotPermissions: int = 0
131132

133+
defaults: IBDefaults = field(default_factory=IBDefaults)
134+
created: bool = False
135+
132136
def __post_init__(self):
133-
self.updateEvent = TickerUpdateEvent("updateEvent")
137+
# when copying a dataclass, the __post_init__ runs again, so we
138+
# want to make sure if this was _already_ created, we don't overwrite
139+
# everything with _another_ post_init clear.
140+
if not self.created:
141+
self.updateEvent = TickerUpdateEvent("updateEvent")
142+
self.minTick = self.defaults.unset
143+
self.bid = self.defaults.unset
144+
self.bidSize = self.defaults.unset
145+
self.ask = self.defaults.unset
146+
self.askSize = self.defaults.unset
147+
self.last = self.defaults.unset
148+
self.lastSize = self.defaults.unset
149+
self.prevBid = self.defaults.unset
150+
self.prevBidSize = self.defaults.unset
151+
self.prevAsk = self.defaults.unset
152+
self.prevAskSize = self.defaults.unset
153+
self.prevLast = self.defaults.unset
154+
self.prevLastSize = self.defaults.unset
155+
self.volume = self.defaults.unset
156+
self.open = self.defaults.unset
157+
self.high = self.defaults.unset
158+
self.low = self.defaults.unset
159+
self.close = self.defaults.unset
160+
self.vwap = self.defaults.unset
161+
self.low13week = self.defaults.unset
162+
self.high13week = self.defaults.unset
163+
self.low26week = self.defaults.unset
164+
self.high26week = self.defaults.unset
165+
self.low52week = self.defaults.unset
166+
self.high52week = self.defaults.unset
167+
self.bidYield = self.defaults.unset
168+
self.askYield = self.defaults.unset
169+
self.lastYield = self.defaults.unset
170+
self.markPrice = self.defaults.unset
171+
self.halted = self.defaults.unset
172+
self.rtHistVolatility = self.defaults.unset
173+
self.rtVolume = self.defaults.unset
174+
self.rtTradeVolume = self.defaults.unset
175+
self.avVolume = self.defaults.unset
176+
self.tradeCount = self.defaults.unset
177+
self.tradeRate = self.defaults.unset
178+
self.volumeRate = self.defaults.unset
179+
self.volumeRate3Min = self.defaults.unset
180+
self.volumeRate5Min = self.defaults.unset
181+
self.volumeRate10Min = self.defaults.unset
182+
self.shortable = self.defaults.unset
183+
self.shortableShares = self.defaults.unset
184+
self.indexFuturePremium = self.defaults.unset
185+
self.futuresOpenInterest = self.defaults.unset
186+
self.putOpenInterest = self.defaults.unset
187+
self.callOpenInterest = self.defaults.unset
188+
self.putVolume = self.defaults.unset
189+
self.callVolume = self.defaults.unset
190+
self.avOptionVolume = self.defaults.unset
191+
self.histVolatility = self.defaults.unset
192+
self.impliedVolatility = self.defaults.unset
193+
self.auctionVolume = self.defaults.unset
194+
self.auctionPrice = self.defaults.unset
195+
self.auctionImbalance = self.defaults.unset
196+
self.regulatoryImbalance = self.defaults.unset
197+
198+
self.created = True
134199

135200
def __eq__(self, other):
136201
return self is other
@@ -141,23 +206,29 @@ def __hash__(self):
141206
__repr__ = dataclassRepr
142207
__str__ = dataclassRepr
143208

209+
def isUnset(self, value) -> bool:
210+
# if default value is nan and value is nan, it is unset.
211+
# else, if value matches default value, it is unset.
212+
dev = self.defaults.unset
213+
return (dev != dev and value != value) or (value == dev)
214+
144215
def hasBidAsk(self) -> bool:
145216
"""See if this ticker has a valid bid and ask."""
146217
return (
147218
self.bid != -1
148-
and not isNan(self.bid)
219+
and not self.isUnset(self.bid)
149220
and self.bidSize > 0
150221
and self.ask != -1
151-
and not isNan(self.ask)
222+
and not self.isUnset(self.ask)
152223
and self.askSize > 0
153224
)
154225

155226
def midpoint(self) -> float:
156227
"""
157-
Return average of bid and ask, or NaN if no valid bid and ask
228+
Return average of bid and ask, or defaults.unset if no valid bid and ask
158229
are available.
159230
"""
160-
return (self.bid + self.ask) * 0.5 if self.hasBidAsk() else nan
231+
return (self.bid + self.ask) * 0.5 if self.hasBidAsk() else self.defaults.unset
161232

162233
def marketPrice(self) -> float:
163234
"""
@@ -173,6 +244,7 @@ def marketPrice(self) -> float:
173244
price = self.midpoint()
174245
else:
175246
price = self.last
247+
176248
return price
177249

178250

@@ -302,8 +374,10 @@ def on_source(self, time, price, size):
302374
if not self.bars:
303375
return
304376
bar = self.bars[-1]
377+
305378
if isNan(bar.open):
306379
bar.open = bar.high = bar.low = price
380+
307381
bar.high = max(bar.high, price)
308382
bar.low = min(bar.low, price)
309383
bar.close = price
@@ -314,10 +388,12 @@ def on_source(self, time, price, size):
314388
def _on_timer(self, time):
315389
if self.bars:
316390
bar = self.bars[-1]
317-
if isNan(bar.close) and len(self.bars) > 1:
391+
if self.isUnset(bar.close) and len(self.bars) > 1:
318392
bar.open = bar.high = bar.low = bar.close = self.bars[-2].close
393+
319394
self.bars.updateEvent.emit(self.bars, True)
320395
self.emit(bar)
396+
321397
self.bars.append(Bar(time))
322398

323399
def _on_timer_done(self, timer):

ib_async/util.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,8 @@ def dataclassNonDefaults(obj) -> dict:
101101
return {
102102
field.name: value
103103
for field, value in zip(fields(obj), values)
104-
if value != field.default
104+
if value is not None
105+
and value != field.default
105106
and value == value
106107
and not (
107108
(isinstance(value, list) and value == [])

0 commit comments

Comments
 (0)