Port ib orders to new msgs and bidir streaming api
parent
6e58f31fd8
commit
db92683ede
|
@ -25,7 +25,7 @@ from contextlib import asynccontextmanager
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from typing import List, Dict, Any, Tuple, Optional, AsyncIterator, Callable
|
from typing import List, Dict, Any, Tuple, Optional, AsyncIterator
|
||||||
import asyncio
|
import asyncio
|
||||||
from pprint import pformat
|
from pprint import pformat
|
||||||
import inspect
|
import inspect
|
||||||
|
@ -39,7 +39,8 @@ import tractor
|
||||||
from async_generator import aclosing
|
from async_generator import aclosing
|
||||||
from ib_insync.wrapper import RequestError
|
from ib_insync.wrapper import RequestError
|
||||||
from ib_insync.contract import Contract, ContractDetails, Option
|
from ib_insync.contract import Contract, ContractDetails, Option
|
||||||
from ib_insync.order import Order
|
from ib_insync.order import Order, Trade, OrderStatus
|
||||||
|
from ib_insync.objects import Fill, Execution
|
||||||
from ib_insync.ticker import Ticker
|
from ib_insync.ticker import Ticker
|
||||||
from ib_insync.objects import Position
|
from ib_insync.objects import Position
|
||||||
import ib_insync as ibis
|
import ib_insync as ibis
|
||||||
|
@ -53,6 +54,12 @@ from .._daemon import maybe_spawn_brokerd
|
||||||
from ..data._source import from_df
|
from ..data._source import from_df
|
||||||
from ..data._sharedmem import ShmArray
|
from ..data._sharedmem import ShmArray
|
||||||
from ._util import SymbolNotFound, NoData
|
from ._util import SymbolNotFound, NoData
|
||||||
|
from ..clearing._messages import (
|
||||||
|
BrokerdOrder, BrokerdOrderAck, BrokerdStatus,
|
||||||
|
BrokerdPosition, BrokerdCancel,
|
||||||
|
BrokerdFill,
|
||||||
|
# BrokerdError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
@ -472,7 +479,7 @@ class Client:
|
||||||
# XXX: by default 0 tells ``ib_insync`` methods that there is no
|
# XXX: by default 0 tells ``ib_insync`` methods that there is no
|
||||||
# existing order so ask the client to create a new one (which it
|
# existing order so ask the client to create a new one (which it
|
||||||
# seems to do by allocating an int counter - collision prone..)
|
# seems to do by allocating an int counter - collision prone..)
|
||||||
brid: int = None,
|
reqid: int = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Place an order and return integer request id provided by client.
|
"""Place an order and return integer request id provided by client.
|
||||||
|
|
||||||
|
@ -488,7 +495,7 @@ class Client:
|
||||||
trade = self.ib.placeOrder(
|
trade = self.ib.placeOrder(
|
||||||
contract,
|
contract,
|
||||||
Order(
|
Order(
|
||||||
orderId=brid or 0, # stupid api devs..
|
orderId=reqid or 0, # stupid api devs..
|
||||||
action=action.upper(), # BUY/SELL
|
action=action.upper(), # BUY/SELL
|
||||||
orderType='LMT',
|
orderType='LMT',
|
||||||
lmtPrice=price,
|
lmtPrice=price,
|
||||||
|
@ -582,6 +589,7 @@ class Client:
|
||||||
self,
|
self,
|
||||||
to_trio: trio.abc.SendChannel,
|
to_trio: trio.abc.SendChannel,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
# connect error msgs
|
# connect error msgs
|
||||||
def push_err(
|
def push_err(
|
||||||
reqId: int,
|
reqId: int,
|
||||||
|
@ -589,13 +597,16 @@ class Client:
|
||||||
errorString: str,
|
errorString: str,
|
||||||
contract: Contract,
|
contract: Contract,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
log.error(errorString)
|
log.error(errorString)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
to_trio.send_nowait((
|
to_trio.send_nowait((
|
||||||
'error',
|
'error',
|
||||||
|
|
||||||
# error "object"
|
# error "object"
|
||||||
{'reqid': reqId,
|
{'reqid': reqId,
|
||||||
'message': errorString,
|
'reason': errorString,
|
||||||
'contract': contract}
|
'contract': contract}
|
||||||
))
|
))
|
||||||
except trio.BrokenResourceError:
|
except trio.BrokenResourceError:
|
||||||
|
@ -635,6 +646,8 @@ async def _aio_get_client(
|
||||||
"""Return an ``ib_insync.IB`` instance wrapped in our client API.
|
"""Return an ``ib_insync.IB`` instance wrapped in our client API.
|
||||||
|
|
||||||
Client instances are cached for later use.
|
Client instances are cached for later use.
|
||||||
|
|
||||||
|
TODO: consider doing this with a ctx mngr eventually?
|
||||||
"""
|
"""
|
||||||
# first check cache for existing client
|
# first check cache for existing client
|
||||||
|
|
||||||
|
@ -848,7 +861,7 @@ async def get_bars(
|
||||||
end_dt: str = "",
|
end_dt: str = "",
|
||||||
) -> (dict, np.ndarray):
|
) -> (dict, np.ndarray):
|
||||||
|
|
||||||
_err = None
|
_err: Optional[Exception] = None
|
||||||
|
|
||||||
fails = 0
|
fails = 0
|
||||||
for _ in range(2):
|
for _ in range(2):
|
||||||
|
@ -885,12 +898,12 @@ async def get_bars(
|
||||||
raise NoData(f'Symbol: {sym}')
|
raise NoData(f'Symbol: {sym}')
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
log.exception(
|
log.exception(
|
||||||
"Data query rate reached: Press `ctrl-alt-f`"
|
"Data query rate reached: Press `ctrl-alt-f`"
|
||||||
"in TWS"
|
"in TWS"
|
||||||
)
|
)
|
||||||
|
print(_err)
|
||||||
|
|
||||||
# TODO: should probably create some alert on screen
|
# TODO: should probably create some alert on screen
|
||||||
# and then somehow get that to trigger an event here
|
# and then somehow get that to trigger an event here
|
||||||
|
@ -937,7 +950,7 @@ async def backfill_bars(
|
||||||
if fails is None or fails > 1:
|
if fails is None or fails > 1:
|
||||||
break
|
break
|
||||||
|
|
||||||
if out is (None, None):
|
if out == (None, None):
|
||||||
# could be trying to retreive bars over weekend
|
# could be trying to retreive bars over weekend
|
||||||
# TODO: add logic here to handle tradable hours and only grab
|
# TODO: add logic here to handle tradable hours and only grab
|
||||||
# valid bars in the range
|
# valid bars in the range
|
||||||
|
@ -1188,114 +1201,217 @@ def pack_position(pos: Position) -> Dict[str, Any]:
|
||||||
else:
|
else:
|
||||||
symbol = con.symbol
|
symbol = con.symbol
|
||||||
|
|
||||||
return {
|
return BrokerdPosition(
|
||||||
'broker': 'ib',
|
broker='ib',
|
||||||
'account': pos.account,
|
account=pos.account,
|
||||||
'symbol': symbol,
|
symbol=symbol,
|
||||||
'currency': con.currency,
|
currency=con.currency,
|
||||||
'size': float(pos.position),
|
size=float(pos.position),
|
||||||
'avg_price': float(pos.avgCost) / float(con.multiplier or 1.0),
|
avg_price=float(pos.avgCost) / float(con.multiplier or 1.0),
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@tractor.msg.pub(
|
|
||||||
send_on_connect={'local_trades': 'start'}
|
|
||||||
)
|
)
|
||||||
async def stream_trades(
|
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_order_requests(
|
||||||
|
|
||||||
|
ems_order_stream: tractor.MsgStream,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
|
||||||
|
# request_msg: dict
|
||||||
|
async for request_msg in ems_order_stream:
|
||||||
|
log.info(f'Received order request {request_msg}')
|
||||||
|
|
||||||
|
action = request_msg['action']
|
||||||
|
|
||||||
|
if action in {'buy', 'sell'}:
|
||||||
|
# validate
|
||||||
|
order = BrokerdOrder(**request_msg)
|
||||||
|
|
||||||
|
# call our client api to submit the order
|
||||||
|
reqid = await _trio_run_client_method(
|
||||||
|
|
||||||
|
method='submit_limit',
|
||||||
|
oid=order.oid,
|
||||||
|
symbol=order.symbol,
|
||||||
|
price=order.price,
|
||||||
|
action=order.action,
|
||||||
|
size=order.size,
|
||||||
|
|
||||||
|
# XXX: by default 0 tells ``ib_insync`` methods that
|
||||||
|
# there is no existing order so ask the client to create
|
||||||
|
# a new one (which it seems to do by allocating an int
|
||||||
|
# counter - collision prone..)
|
||||||
|
reqid=order.reqid,
|
||||||
|
)
|
||||||
|
|
||||||
|
# deliver ack that order has been submitted to broker routing
|
||||||
|
await ems_order_stream.send(
|
||||||
|
BrokerdOrderAck(
|
||||||
|
# ems order request id
|
||||||
|
oid=order.oid,
|
||||||
|
# broker specific request id
|
||||||
|
reqid=reqid,
|
||||||
|
time_ns=time.time_ns(),
|
||||||
|
).dict()
|
||||||
|
)
|
||||||
|
|
||||||
|
elif action == 'cancel':
|
||||||
|
msg = BrokerdCancel(**request_msg)
|
||||||
|
|
||||||
|
await _trio_run_client_method(
|
||||||
|
method='submit_cancel',
|
||||||
|
reqid=msg.reqid
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
log.error(f'Unknown order command: {request_msg}')
|
||||||
|
|
||||||
|
|
||||||
|
@tractor.context
|
||||||
|
async def trades_dialogue(
|
||||||
|
|
||||||
|
ctx: tractor.Context,
|
||||||
loglevel: str = None,
|
loglevel: str = None,
|
||||||
get_topics: Callable = None,
|
|
||||||
|
|
||||||
) -> AsyncIterator[Dict[str, Any]]:
|
) -> AsyncIterator[Dict[str, Any]]:
|
||||||
|
|
||||||
# XXX: required to propagate ``tractor`` loglevel to piker logging
|
# XXX: required to propagate ``tractor`` loglevel to piker logging
|
||||||
get_console_log(loglevel or tractor.current_actor().loglevel)
|
get_console_log(loglevel or tractor.current_actor().loglevel)
|
||||||
|
|
||||||
stream = await _trio_run_client_method(
|
ib_trade_events_stream = await _trio_run_client_method(
|
||||||
method='recv_trade_updates',
|
method='recv_trade_updates',
|
||||||
)
|
)
|
||||||
|
|
||||||
# deliver positions to subscriber before anything else
|
# deliver positions to subscriber before anything else
|
||||||
positions = await _trio_run_client_method(method='positions')
|
positions = await _trio_run_client_method(method='positions')
|
||||||
|
|
||||||
|
all_positions = {}
|
||||||
|
|
||||||
for pos in positions:
|
for pos in positions:
|
||||||
yield {'local_trades': ('position', pack_position(pos))}
|
msg = pack_position(pos)
|
||||||
|
all_positions[msg.symbol] = msg.dict()
|
||||||
|
|
||||||
|
await ctx.started(all_positions)
|
||||||
|
|
||||||
action_map = {'BOT': 'buy', 'SLD': 'sell'}
|
action_map = {'BOT': 'buy', 'SLD': 'sell'}
|
||||||
|
|
||||||
async for event_name, item in stream:
|
async with (
|
||||||
|
ctx.open_stream() as ems_stream,
|
||||||
|
trio.open_nursery() as n,
|
||||||
|
):
|
||||||
|
# start order request handler **before** local trades event loop
|
||||||
|
n.start_soon(handle_order_requests, ems_stream)
|
||||||
|
|
||||||
|
async for event_name, item in ib_trade_events_stream:
|
||||||
|
|
||||||
# XXX: begin normalization of nonsense ib_insync internal
|
# XXX: begin normalization of nonsense ib_insync internal
|
||||||
# object-state tracking representations...
|
# object-state tracking representations...
|
||||||
|
|
||||||
if event_name == 'status':
|
if event_name == 'status':
|
||||||
|
|
||||||
# unwrap needed data from ib_insync internal objects
|
# unwrap needed data from ib_insync internal types
|
||||||
trade = item
|
trade: Trade = item
|
||||||
status = trade.orderStatus
|
status: OrderStatus = trade.orderStatus
|
||||||
|
|
||||||
# skip duplicate filled updates - we get the deats
|
# skip duplicate filled updates - we get the deats
|
||||||
# from the execution details event
|
# from the execution details event
|
||||||
msg = {
|
msg = BrokerdStatus(
|
||||||
'reqid': trade.order.orderId,
|
|
||||||
'status': status.status,
|
|
||||||
'filled': status.filled,
|
|
||||||
'reason': status.whyHeld,
|
|
||||||
|
|
||||||
|
reqid=trade.order.orderId,
|
||||||
|
time_ns=time.time_ns(), # cuz why not
|
||||||
|
status=status.status.lower(), # force lower case
|
||||||
|
|
||||||
|
filled=status.filled,
|
||||||
|
reason=status.whyHeld,
|
||||||
# this seems to not be necessarily up to date in the
|
# this seems to not be necessarily up to date in the
|
||||||
# execDetails event.. so we have to send it here I guess?
|
# execDetails event.. so we have to send it here I guess?
|
||||||
'remaining': status.remaining,
|
remaining=status.remaining,
|
||||||
}
|
|
||||||
|
broker_details={'name': 'ib'},
|
||||||
|
)
|
||||||
|
|
||||||
elif event_name == 'fill':
|
elif event_name == 'fill':
|
||||||
|
|
||||||
|
# for wtv reason this is a separate event type
|
||||||
|
# from IB, not sure why it's needed other then for extra
|
||||||
|
# complexity and over-engineering :eyeroll:.
|
||||||
|
# we may just end up dropping these events (or
|
||||||
|
# translating them to ``Status`` msgs) if we can
|
||||||
|
# show the equivalent status events are no more latent.
|
||||||
|
|
||||||
|
# unpack ib_insync types
|
||||||
|
# pep-0526 style:
|
||||||
|
# https://www.python.org/dev/peps/pep-0526/#global-and-local-variable-annotations
|
||||||
|
trade: Trade
|
||||||
|
fill: Fill
|
||||||
trade, fill = item
|
trade, fill = item
|
||||||
execu = fill.execution
|
execu: Execution = fill.execution
|
||||||
|
|
||||||
msg = {
|
# TODO: normalize out commissions details?
|
||||||
'reqid': execu.orderId,
|
details = {
|
||||||
'execid': execu.execId,
|
'contract': asdict(fill.contract),
|
||||||
|
'execution': asdict(fill.execution),
|
||||||
# supposedly IB server fill time
|
'commissions': asdict(fill.commissionReport),
|
||||||
'broker_time': execu.time, # converted to float by us
|
|
||||||
# ns from main TCP handler by us inside ``ib_insync`` override
|
|
||||||
'time': fill.time,
|
|
||||||
'time_ns': time.time_ns(), # cuz why not
|
|
||||||
'action': action_map[execu.side],
|
|
||||||
'size': execu.shares,
|
|
||||||
'price': execu.price,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# supposedly IB server fill time
|
||||||
|
details['broker_time'] = execu.time
|
||||||
|
details['name'] = 'ib'
|
||||||
|
|
||||||
|
msg = BrokerdFill(
|
||||||
|
# should match the value returned from `.submit_limit()`
|
||||||
|
reqid=execu.orderId,
|
||||||
|
time_ns=time.time_ns(), # cuz why not
|
||||||
|
|
||||||
|
action=action_map[execu.side],
|
||||||
|
size=execu.shares,
|
||||||
|
price=execu.price,
|
||||||
|
|
||||||
|
broker_details=details,
|
||||||
|
# XXX: required by order mode currently
|
||||||
|
broker_time=details['execution']['time'],
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
elif event_name == 'error':
|
elif event_name == 'error':
|
||||||
msg = item
|
|
||||||
|
err: dict = item
|
||||||
|
|
||||||
# f$#$% gawd dammit insync..
|
# f$#$% gawd dammit insync..
|
||||||
con = msg['contract']
|
con = err['contract']
|
||||||
if isinstance(con, Contract):
|
if isinstance(con, Contract):
|
||||||
msg['contract'] = asdict(con)
|
err['contract'] = asdict(con)
|
||||||
|
|
||||||
if msg['reqid'] == -1:
|
if err['reqid'] == -1:
|
||||||
log.error(pformat(msg))
|
log.error(f'TWS external order error:\n{pformat(err)}')
|
||||||
|
|
||||||
# don't forward, it's pointless..
|
# don't forward for now, it's unecessary.. but if we wanted to,
|
||||||
|
# msg = BrokerdError(**err)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
elif event_name == 'position':
|
elif event_name == 'position':
|
||||||
msg = pack_position(item)
|
msg = pack_position(item)
|
||||||
|
# msg = BrokerdPosition(**item)
|
||||||
|
|
||||||
|
# if msg.get('reqid', 0) < -1:
|
||||||
|
if getattr(msg, 'reqid', 0) < -1:
|
||||||
|
|
||||||
if msg.get('reqid', 0) < -1:
|
|
||||||
# it's a trade event generated by TWS usage.
|
# it's a trade event generated by TWS usage.
|
||||||
log.warning(f"TWS triggered trade:\n{pformat(msg)}")
|
log.warning(f"TWS triggered trade:\n{pformat(msg)}")
|
||||||
|
|
||||||
msg['reqid'] = 'tws-' + str(-1 * msg['reqid'])
|
msg['reqid'] = 'tws-' + str(-1 * msg['reqid'])
|
||||||
|
|
||||||
# mark msg as from "external system"
|
# mark msg as from "external system"
|
||||||
# TODO: probably something better then this..
|
# TODO: probably something better then this.. and start
|
||||||
|
# considering multiplayer/group trades tracking
|
||||||
msg['external'] = True
|
msg['external'] = True
|
||||||
|
|
||||||
yield {'remote_trades': (event_name, msg)}
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
yield {'local_trades': (event_name, msg)}
|
# XXX: we always serialize to a dict for msgpack
|
||||||
|
# translations, ideally we can move to an msgspec (or other)
|
||||||
|
# encoder # that can be enabled in ``tractor`` ahead of
|
||||||
|
# time so we can pass through the message types directly.
|
||||||
|
await ems_stream.send(msg.dict())
|
||||||
|
|
||||||
|
|
||||||
@tractor.context
|
@tractor.context
|
||||||
|
|
Loading…
Reference in New Issue