Skip to content

Commit

Permalink
Fix message sending with unqualified contracts
Browse files Browse the repository at this point in the history
Error introduced during refactor of comparing against tuples to now
comparing against sets. Unqualified contracts aren't hashable so they
can't compare against sets, but they could compare against the previous
tuples without errors.

Fixed by refactoring the entire message sending logic to be faster and
use direct comparison of matching types instead of walking a 5-7 stage
if-else tree.

Also extended error message for contract hash errors (still needs some
improvements, contracts should probably hash by conId+exchange+currency
instead of only conId).

Fixes mattsta#4
Closes mattsta#5
  • Loading branch information
mattsta committed Mar 20, 2024
1 parent 0ae4345 commit 20badd6
Show file tree
Hide file tree
Showing 6 changed files with 140 additions and 42 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ html
doctrees
ib_async.egg-info
poetry.lock
*.csv
*.json
11 changes: 9 additions & 2 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,15 @@
1.0
---

Version 1.0.0
^^^^^^^^^^^^^
Version 1.0.1 (2024-03-20)
^^^^^^^^^^^^^^^^^^^^^^^^^^

* Fixed :issue:`4`: Messaging sending bug for unresolved contracts due to cleanup in 1.0.0

Solved this messaging sending bug by refactoring message parsing logic to be more stable. Also added a test case verifying it works as expected now.

Version 1.0.0 (2024-03-18)
^^^^^^^^^^^^^^^^^^^^^^^^^^

This is the first version under new management after the unexpected passing of `Ewald de Wit <https://github.com/erdewit/ib_insync>`_ on March 11, 2024. We wish to maintain his legacy while continuing to improve the project going forward. We are resetting the project name, development practices, modernization levels, and project structure to hopefully grow more contributors over time.

Expand Down
105 changes: 67 additions & 38 deletions ib_async/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,53 +244,82 @@ def disconnect(self):
self.reset()

def send(self, *fields, makeEmpty=True):
"""Serialize and send the given fields using the IB socket protocol."""
"""Serialize and send the given fields using the IB socket protocol.
if 'makeEmpty' is True (default), then the IBKR values representing "no value"
become the empty string."""
if not self.isConnected():
raise ConnectionError("Not connected")

# fmt: off
FORMAT_HANDLERS = {
# Contracts are formatted in IBKR null delimiter format
Contract: lambda c: "\0".join([
str(f)
for f in (
c.conId,
c.symbol,
c.secType,
c.lastTradeDateOrContractMonth,
c.strike,
c.right,
c.multiplier,
c.exchange,
c.primaryExchange,
c.currency,
c.localSymbol,
c.tradingClass,
)
]),

# Float conversion has 3 stages:
# - Convert 'IBKR unset' double to empty (if requested)
# - Convert infinity to 'Infinite' string (if appropriate)
# - else, convert float to string normally
float: lambda f: ""
if (makeEmpty and f == UNSET_DOUBLE)
else ("Infinite" if (f == math.inf) else str(f)),

# Int conversion has 2 stages:
# - Convert 'IBKR unset' to empty (if requested)
# - else, convert int to string normally
int: lambda f: "" if makeEmpty and f == UNSET_INTEGER else str(f),

# None is always just an empty string.
# (due to a quirk of Python, 'type(None)' is how you properly generate the NoneType value)
type(None): lambda _: "",

# Strings are always strings
str: lambda s: s,

# Bools become strings "1" or "0"
bool: lambda b: "1" if b else "0",

# Lists of tags become semicolon-appended KV pairs
list: lambda l: "".join([f"{v.tag}={v.value};" for v in l]),
}
# fmt: on

# start of new message
msg = io.StringIO()
empty = {None, UNSET_INTEGER, UNSET_DOUBLE} if makeEmpty else {None}

for field in fields:
typ = type(field)
if field in empty:
s = ""
elif typ is str:
s = field
elif typ is int:
s = str(field)
elif typ is float:
s = "Infinite" if field == math.inf else str(field)
elif typ is bool:
s = "1" if field else "0"
elif typ is list:
# list of TagValue
s = "".join(f"{v.tag}={v.value};" for v in field)
elif isinstance(field, Contract):
c = field
s = "\0".join(
str(f)
for f in (
c.conId,
c.symbol,
c.secType,
c.lastTradeDateOrContractMonth,
c.strike,
c.right,
c.multiplier,
c.exchange,
c.primaryExchange,
c.currency,
c.localSymbol,
c.tradingClass,
)
)
else:
s = str(field)
# Fetch type converter for this field (falls back to 'str(field)' as a default for unmatched types)
# (extra `isinstance()` wrapper needed here because Contract subclasses are their own type, but we want
# to only match against the Contract parent class for formatting operations)
convert = FORMAT_HANDLERS.get(
Contract if isinstance(field, Contract) else type(field), str
)

# Convert field to IBKR protocol string part
s = convert(field)

# Append converted IBKR protocol string to message buffer
msg.write(s)
msg.write("\0")

self.sendMsg(msg.getvalue())
generated = msg.getvalue()
self.sendMsg(generated)

def sendMsg(self, msg: str):
loop = getLoop()
Expand Down
4 changes: 3 additions & 1 deletion ib_async/contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,9 @@ def __eq__(self, other):

def __hash__(self):
if not self.isHashable():
raise ValueError(f"Contract {self} can't be hashed")
raise ValueError(
f"Contract {self} can't be hashed because no 'conId' value exists. Resolve contract to populate 'conId'."
)

if self.secType == "CONTFUT":
# CONTFUT gets the same conId as the front contract, invert it here
Expand Down
7 changes: 6 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "ib_async"
version = "1.0.0"
version = "1.0.1"
description = "Python sync/async framework for Interactive Brokers API"
authors = ["Ewald de Wit"]
maintainers = ["Matt Stancliff <matt@matt.sh>"]
Expand Down Expand Up @@ -35,6 +35,7 @@ optional = true
[tool.poetry.group.dev.dependencies]
pytest = ">=8.0"
pytest-asyncio = ">=0.23"
pandas = "^2.2.1"


[tool.poetry.group.docs]
Expand All @@ -51,6 +52,10 @@ ignore_missing_imports = true
check_untyped_defs = true


[tool.pytest.ini_options]
asyncio_mode="auto"


[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
53 changes: 53 additions & 0 deletions tests/test_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
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."""
ib = ib_async.IB()
ib.connect("127.0.0.1", 4001, clientId=90, readonly=True)

symbols = ["AMZN", "TSLA"]

# Method to get OHLCV
def get_OHLCV(
symbol,
endDateTime="",
durationStr="1 D",
barSizeSetting="1 hour",
whatToShow="TRADES",
useRTH=False,
formatDate=1,
):
bars = ib.reqHistoricalData(
symbol,
endDateTime,
durationStr,
barSizeSetting,
whatToShow,
useRTH,
formatDate,
)
df = util.df(bars)
df["date"] = df["date"].dt.tz_convert("America/New_York")
df = df.drop(columns=["average", "barCount"])
# df.set_index("date", inplace=True)

print("\n", df)

# df = df.iloc[::-1]
# df.to_csv("{}.csv".format(symbol.symbol))
# df = pd.read_csv("{}.csv".format(symbol.symbol))
df.columns = ["date", "open", "high", "low", "close", "volume"]

df["date"] = pd.to_datetime(df["date"])
df.set_index("date", inplace=True)

print(f"Data for {symbol.symbol} downloaded OK with OLD")
return df

for symbol_str in symbols:
symbol = Stock(symbol_str, "SMART", "USD")
df = get_OHLCV(symbol)

0 comments on commit 20badd6

Please sign in to comment.