commit
cf5b0bf9c6
|
@ -60,6 +60,8 @@ from piker.pp import (
|
||||||
)
|
)
|
||||||
from piker.log import get_console_log
|
from piker.log import get_console_log
|
||||||
from piker.clearing._messages import (
|
from piker.clearing._messages import (
|
||||||
|
Order,
|
||||||
|
Status,
|
||||||
BrokerdOrder,
|
BrokerdOrder,
|
||||||
BrokerdOrderAck,
|
BrokerdOrderAck,
|
||||||
BrokerdStatus,
|
BrokerdStatus,
|
||||||
|
@ -122,11 +124,13 @@ async def handle_order_requests(
|
||||||
f'An IB account number for name {account} is not found?\n'
|
f'An IB account number for name {account} is not found?\n'
|
||||||
'Make sure you have all TWS and GW instances running.'
|
'Make sure you have all TWS and GW instances running.'
|
||||||
)
|
)
|
||||||
await ems_order_stream.send(BrokerdError(
|
await ems_order_stream.send(
|
||||||
|
BrokerdError(
|
||||||
oid=request_msg['oid'],
|
oid=request_msg['oid'],
|
||||||
symbol=request_msg['symbol'],
|
symbol=request_msg['symbol'],
|
||||||
reason=f'No account found: `{account}` ?',
|
reason=f'No account found: `{account}` ?',
|
||||||
))
|
)
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
client = _accounts2clients.get(account)
|
client = _accounts2clients.get(account)
|
||||||
|
@ -146,6 +150,14 @@ async def handle_order_requests(
|
||||||
# validate
|
# validate
|
||||||
order = BrokerdOrder(**request_msg)
|
order = BrokerdOrder(**request_msg)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
if reqid is not None:
|
||||||
|
reqid = int(reqid)
|
||||||
|
|
||||||
# call our client api to submit the order
|
# call our client api to submit the order
|
||||||
reqid = client.submit_limit(
|
reqid = client.submit_limit(
|
||||||
oid=order.oid,
|
oid=order.oid,
|
||||||
|
@ -154,12 +166,7 @@ async def handle_order_requests(
|
||||||
action=order.action,
|
action=order.action,
|
||||||
size=order.size,
|
size=order.size,
|
||||||
account=acct_number,
|
account=acct_number,
|
||||||
|
reqid=reqid,
|
||||||
# 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,
|
|
||||||
)
|
)
|
||||||
if reqid is None:
|
if reqid is None:
|
||||||
await ems_order_stream.send(BrokerdError(
|
await ems_order_stream.send(BrokerdError(
|
||||||
|
@ -181,7 +188,7 @@ async def handle_order_requests(
|
||||||
|
|
||||||
elif action == 'cancel':
|
elif action == 'cancel':
|
||||||
msg = BrokerdCancel(**request_msg)
|
msg = BrokerdCancel(**request_msg)
|
||||||
client.submit_cancel(reqid=msg.reqid)
|
client.submit_cancel(reqid=int(msg.reqid))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
log.error(f'Unknown order command: {request_msg}')
|
log.error(f'Unknown order command: {request_msg}')
|
||||||
|
@ -451,7 +458,6 @@ async def trades_dialogue(
|
||||||
# we might also want to delegate a specific actor for
|
# we might also want to delegate a specific actor for
|
||||||
# ledger writing / reading for speed?
|
# ledger writing / reading for speed?
|
||||||
async with (
|
async with (
|
||||||
# trio.open_nursery() as nurse,
|
|
||||||
open_client_proxies() as (proxies, aioclients),
|
open_client_proxies() as (proxies, aioclients),
|
||||||
):
|
):
|
||||||
# Open a trade ledgers stack for appending trade records over
|
# Open a trade ledgers stack for appending trade records over
|
||||||
|
@ -459,6 +465,7 @@ async def trades_dialogue(
|
||||||
# TODO: we probably want to generalize this into a "ledgers" api..
|
# TODO: we probably want to generalize this into a "ledgers" api..
|
||||||
ledgers: dict[str, dict] = {}
|
ledgers: dict[str, dict] = {}
|
||||||
tables: dict[str, PpTable] = {}
|
tables: dict[str, PpTable] = {}
|
||||||
|
order_msgs: list[Status] = []
|
||||||
with (
|
with (
|
||||||
ExitStack() as lstack,
|
ExitStack() as lstack,
|
||||||
):
|
):
|
||||||
|
@ -480,6 +487,49 @@ async def trades_dialogue(
|
||||||
|
|
||||||
for account, proxy in proxies.items():
|
for account, proxy in proxies.items():
|
||||||
client = aioclients[account]
|
client = aioclients[account]
|
||||||
|
trades: list[Trade] = client.ib.openTrades()
|
||||||
|
for trade in trades:
|
||||||
|
order = trade.order
|
||||||
|
quant = trade.order.totalQuantity
|
||||||
|
action = order.action.lower()
|
||||||
|
size = {
|
||||||
|
'sell': -1,
|
||||||
|
'buy': 1,
|
||||||
|
}[action] * quant
|
||||||
|
con = trade.contract
|
||||||
|
|
||||||
|
# TODO: in the case of the SMART venue (aka ib's
|
||||||
|
# router-clearing sys) we probably should handle
|
||||||
|
# showing such orders overtop of the fqsn for the
|
||||||
|
# primary exchange, how to map this easily is going
|
||||||
|
# to be a bit tricky though?
|
||||||
|
deats = await proxy.con_deats(contracts=[con])
|
||||||
|
fqsn = list(deats)[0]
|
||||||
|
|
||||||
|
reqid = order.orderId
|
||||||
|
|
||||||
|
# TODO: maybe embed a ``BrokerdOrder`` instead
|
||||||
|
# since then we can directly load it on the client
|
||||||
|
# side in the order mode loop?
|
||||||
|
msg = Status(
|
||||||
|
time_ns=time.time_ns(),
|
||||||
|
resp='open',
|
||||||
|
oid=str(reqid),
|
||||||
|
reqid=reqid,
|
||||||
|
|
||||||
|
# embedded order info
|
||||||
|
req=Order(
|
||||||
|
action=action,
|
||||||
|
exec_mode='live',
|
||||||
|
oid=str(reqid),
|
||||||
|
symbol=fqsn,
|
||||||
|
account=accounts_def.inverse[order.account],
|
||||||
|
price=order.lmtPrice,
|
||||||
|
size=size,
|
||||||
|
),
|
||||||
|
src='ib',
|
||||||
|
)
|
||||||
|
order_msgs.append(msg)
|
||||||
|
|
||||||
# process pp value reported from ib's system. we only use these
|
# process pp value reported from ib's system. we only use these
|
||||||
# to cross-check sizing since average pricing on their end uses
|
# to cross-check sizing since average pricing on their end uses
|
||||||
|
@ -615,6 +665,9 @@ async def trades_dialogue(
|
||||||
ctx.open_stream() as ems_stream,
|
ctx.open_stream() as ems_stream,
|
||||||
trio.open_nursery() as n,
|
trio.open_nursery() as n,
|
||||||
):
|
):
|
||||||
|
# relay existing open orders to ems
|
||||||
|
for msg in order_msgs:
|
||||||
|
await ems_stream.send(msg)
|
||||||
|
|
||||||
for client in set(aioclients.values()):
|
for client in set(aioclients.values()):
|
||||||
trade_event_stream = await n.start(
|
trade_event_stream = await n.start(
|
||||||
|
@ -633,6 +686,7 @@ async def trades_dialogue(
|
||||||
# allocate event relay tasks for each client connection
|
# allocate event relay tasks for each client connection
|
||||||
n.start_soon(
|
n.start_soon(
|
||||||
deliver_trade_events,
|
deliver_trade_events,
|
||||||
|
n,
|
||||||
trade_event_stream,
|
trade_event_stream,
|
||||||
ems_stream,
|
ems_stream,
|
||||||
accounts_def,
|
accounts_def,
|
||||||
|
@ -726,6 +780,7 @@ _statuses: dict[str, str] = {
|
||||||
|
|
||||||
async def deliver_trade_events(
|
async def deliver_trade_events(
|
||||||
|
|
||||||
|
nurse: trio.Nursery,
|
||||||
trade_event_stream: trio.MemoryReceiveChannel,
|
trade_event_stream: trio.MemoryReceiveChannel,
|
||||||
ems_stream: tractor.MsgStream,
|
ems_stream: tractor.MsgStream,
|
||||||
accounts_def: dict[str, str], # eg. `'ib.main'` -> `'DU999999'`
|
accounts_def: dict[str, str], # eg. `'ib.main'` -> `'DU999999'`
|
||||||
|
@ -750,8 +805,9 @@ async def deliver_trade_events(
|
||||||
log.info(f'ib sending {event_name}:\n{pformat(item)}')
|
log.info(f'ib sending {event_name}:\n{pformat(item)}')
|
||||||
|
|
||||||
match event_name:
|
match event_name:
|
||||||
# TODO: templating the ib statuses in comparison with other
|
# NOTE: we remap statuses to the ems set via the
|
||||||
# brokers is likely the way to go:
|
# ``_statuses: dict`` above.
|
||||||
|
|
||||||
# https://interactivebrokers.github.io/tws-api/interfaceIBApi_1_1EWrapper.html#a17f2a02d6449710b6394d0266a353313
|
# https://interactivebrokers.github.io/tws-api/interfaceIBApi_1_1EWrapper.html#a17f2a02d6449710b6394d0266a353313
|
||||||
# short list:
|
# short list:
|
||||||
# - PendingSubmit
|
# - PendingSubmit
|
||||||
|
@ -781,28 +837,90 @@ async def deliver_trade_events(
|
||||||
# unwrap needed data from ib_insync internal types
|
# unwrap needed data from ib_insync internal types
|
||||||
trade: Trade = item
|
trade: Trade = item
|
||||||
status: OrderStatus = trade.orderStatus
|
status: OrderStatus = trade.orderStatus
|
||||||
|
ib_status_key = status.status.lower()
|
||||||
|
|
||||||
|
# TODO: try out cancelling inactive orders after delay:
|
||||||
|
# https://github.com/erdewit/ib_insync/issues/363
|
||||||
|
# acctid = accounts_def.inverse[trade.order.account]
|
||||||
|
|
||||||
|
# # double check there is no error when
|
||||||
|
# # cancelling.. gawwwd
|
||||||
|
# if ib_status_key == 'cancelled':
|
||||||
|
# last_log = trade.log[-1]
|
||||||
|
# if (
|
||||||
|
# last_log.message
|
||||||
|
# and 'Error' not in last_log.message
|
||||||
|
# ):
|
||||||
|
# ib_status_key = trade.log[-2].status
|
||||||
|
|
||||||
|
# elif ib_status_key == 'inactive':
|
||||||
|
|
||||||
|
# async def sched_cancel():
|
||||||
|
# log.warning(
|
||||||
|
# 'OH GAWD an inactive order.scheduling a cancel\n'
|
||||||
|
# f'{pformat(item)}'
|
||||||
|
# )
|
||||||
|
# proxy = proxies[acctid]
|
||||||
|
# await proxy.submit_cancel(reqid=trade.order.orderId)
|
||||||
|
# await trio.sleep(1)
|
||||||
|
# nurse.start_soon(sched_cancel)
|
||||||
|
|
||||||
|
# nurse.start_soon(sched_cancel)
|
||||||
|
|
||||||
|
status_key = (
|
||||||
|
_statuses.get(ib_status_key.lower())
|
||||||
|
or ib_status_key.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
remaining = status.remaining
|
||||||
|
if (
|
||||||
|
status_key == 'filled'
|
||||||
|
):
|
||||||
|
fill: Fill = trade.fills[-1]
|
||||||
|
execu: Execution = fill.execution
|
||||||
|
# execdict = asdict(execu)
|
||||||
|
# execdict.pop('acctNumber')
|
||||||
|
|
||||||
|
fill_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=execdict,
|
||||||
|
# XXX: required by order mode currently
|
||||||
|
broker_time=execu.time,
|
||||||
|
)
|
||||||
|
await ems_stream.send(fill_msg)
|
||||||
|
|
||||||
|
if remaining == 0:
|
||||||
|
# emit a closed status on filled statuses where
|
||||||
|
# all units were cleared.
|
||||||
|
status_key = 'closed'
|
||||||
|
|
||||||
# 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 = BrokerdStatus(
|
msg = BrokerdStatus(
|
||||||
|
|
||||||
reqid=trade.order.orderId,
|
reqid=trade.order.orderId,
|
||||||
time_ns=time.time_ns(), # cuz why not
|
time_ns=time.time_ns(), # cuz why not
|
||||||
account=accounts_def.inverse[trade.order.account],
|
account=accounts_def.inverse[trade.order.account],
|
||||||
|
|
||||||
# everyone doin camel case..
|
# everyone doin camel case..
|
||||||
status=status.status.lower(), # force lower case
|
status=status_key, # force lower case
|
||||||
|
|
||||||
filled=status.filled,
|
filled=status.filled,
|
||||||
reason=status.whyHeld,
|
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=remaining,
|
||||||
|
|
||||||
broker_details={'name': 'ib'},
|
broker_details={'name': 'ib'},
|
||||||
)
|
)
|
||||||
await ems_stream.send(msg)
|
await ems_stream.send(msg)
|
||||||
|
continue
|
||||||
|
|
||||||
case 'fill':
|
case 'fill':
|
||||||
|
|
||||||
|
@ -818,8 +936,6 @@ async def deliver_trade_events(
|
||||||
# https://www.python.org/dev/peps/pep-0526/#global-and-local-variable-annotations
|
# https://www.python.org/dev/peps/pep-0526/#global-and-local-variable-annotations
|
||||||
trade: Trade
|
trade: Trade
|
||||||
fill: Fill
|
fill: Fill
|
||||||
|
|
||||||
# TODO: maybe we can use matching to better handle these cases.
|
|
||||||
trade, fill = item
|
trade, fill = item
|
||||||
execu: Execution = fill.execution
|
execu: Execution = fill.execution
|
||||||
execid = execu.execId
|
execid = execu.execId
|
||||||
|
@ -848,22 +964,6 @@ async def deliver_trade_events(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
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=trade_entry,
|
|
||||||
# XXX: required by order mode currently
|
|
||||||
broker_time=trade_entry['broker_time'],
|
|
||||||
|
|
||||||
)
|
|
||||||
await ems_stream.send(msg)
|
|
||||||
|
|
||||||
# 2 cases:
|
# 2 cases:
|
||||||
# - fill comes first or
|
# - fill comes first or
|
||||||
# - comms report comes first
|
# - comms report comes first
|
||||||
|
@ -933,17 +1033,25 @@ async def deliver_trade_events(
|
||||||
if err['reqid'] == -1:
|
if err['reqid'] == -1:
|
||||||
log.error(f'TWS external order error:\n{pformat(err)}')
|
log.error(f'TWS external order error:\n{pformat(err)}')
|
||||||
|
|
||||||
# TODO: what schema for this msg if we're going to make it
|
# TODO: we don't want to relay data feed / lookup errors
|
||||||
# portable across all backends?
|
# so we need some further filtering logic here..
|
||||||
# msg = BrokerdError(**err)
|
# for most cases the 'status' block above should take
|
||||||
|
# care of this.
|
||||||
|
# await ems_stream.send(BrokerdStatus(
|
||||||
|
# status='error',
|
||||||
|
# reqid=err['reqid'],
|
||||||
|
# reason=err['reason'],
|
||||||
|
# time_ns=time.time_ns(),
|
||||||
|
# account=accounts_def.inverse[trade.order.account],
|
||||||
|
# broker_details={'name': 'ib'},
|
||||||
|
# ))
|
||||||
|
|
||||||
case 'position':
|
case 'position':
|
||||||
|
|
||||||
cid, msg = pack_position(item)
|
cid, msg = pack_position(item)
|
||||||
log.info(f'New IB position msg: {msg}')
|
log.info(f'New IB position msg: {msg}')
|
||||||
# acctid = msg.account = accounts_def.inverse[msg.account]
|
|
||||||
# cuck ib and it's shitty fifo sys for pps!
|
# cuck ib and it's shitty fifo sys for pps!
|
||||||
# await ems_stream.send(msg)
|
continue
|
||||||
|
|
||||||
case 'event':
|
case 'event':
|
||||||
|
|
||||||
|
|
|
@ -31,6 +31,7 @@ import time
|
||||||
from typing import (
|
from typing import (
|
||||||
Any,
|
Any,
|
||||||
AsyncIterator,
|
AsyncIterator,
|
||||||
|
Iterable,
|
||||||
Union,
|
Union,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -39,7 +40,6 @@ from bidict import bidict
|
||||||
import pendulum
|
import pendulum
|
||||||
import trio
|
import trio
|
||||||
import tractor
|
import tractor
|
||||||
import wsproto
|
|
||||||
|
|
||||||
from piker.pp import (
|
from piker.pp import (
|
||||||
Position,
|
Position,
|
||||||
|
@ -49,6 +49,8 @@ from piker.pp import (
|
||||||
open_pps,
|
open_pps,
|
||||||
)
|
)
|
||||||
from piker.clearing._messages import (
|
from piker.clearing._messages import (
|
||||||
|
Order,
|
||||||
|
Status,
|
||||||
BrokerdCancel,
|
BrokerdCancel,
|
||||||
BrokerdError,
|
BrokerdError,
|
||||||
BrokerdFill,
|
BrokerdFill,
|
||||||
|
@ -85,6 +87,33 @@ class TooFastEdit(Exception):
|
||||||
'Edit requests faster then api submissions'
|
'Edit requests faster then api submissions'
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: make this wrap the `Client` and `ws` instances
|
||||||
|
# and give it methods to submit cancel vs. add vs. edit
|
||||||
|
# requests?
|
||||||
|
class BrokerClient:
|
||||||
|
'''
|
||||||
|
Actor global, client-unique order manager API.
|
||||||
|
|
||||||
|
For now provides unique ``brokerd`` defined "request ids"
|
||||||
|
and "user reference" values to track ``kraken`` ws api order
|
||||||
|
dialogs.
|
||||||
|
|
||||||
|
'''
|
||||||
|
counter: Iterable = count(1)
|
||||||
|
_table: set[int] = set()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def new_reqid(cls) -> int:
|
||||||
|
for reqid in cls.counter:
|
||||||
|
if reqid not in cls._table:
|
||||||
|
cls._table.add(reqid)
|
||||||
|
return reqid
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def add_reqid(cls, reqid: int) -> None:
|
||||||
|
cls._table.add(reqid)
|
||||||
|
|
||||||
|
|
||||||
async def handle_order_requests(
|
async def handle_order_requests(
|
||||||
|
|
||||||
ws: NoBsWs,
|
ws: NoBsWs,
|
||||||
|
@ -104,7 +133,6 @@ async def handle_order_requests(
|
||||||
# XXX: UGH, let's unify this.. with ``msgspec``.
|
# XXX: UGH, let's unify this.. with ``msgspec``.
|
||||||
msg: dict[str, Any]
|
msg: dict[str, Any]
|
||||||
order: BrokerdOrder
|
order: BrokerdOrder
|
||||||
counter = count(1)
|
|
||||||
|
|
||||||
async for msg in ems_order_stream:
|
async for msg in ems_order_stream:
|
||||||
log.info(f'Rx order msg:\n{pformat(msg)}')
|
log.info(f'Rx order msg:\n{pformat(msg)}')
|
||||||
|
@ -126,7 +154,7 @@ async def handle_order_requests(
|
||||||
oid=msg['oid'],
|
oid=msg['oid'],
|
||||||
symbol=msg['symbol'],
|
symbol=msg['symbol'],
|
||||||
reason=(
|
reason=(
|
||||||
f'TooFastEdit reqid:{reqid}, could not cancelling..'
|
f'Edit too fast:{reqid}, cancelling..'
|
||||||
),
|
),
|
||||||
|
|
||||||
)
|
)
|
||||||
|
@ -177,7 +205,8 @@ async def handle_order_requests(
|
||||||
|
|
||||||
else:
|
else:
|
||||||
ep = 'addOrder'
|
ep = 'addOrder'
|
||||||
reqid = next(counter)
|
|
||||||
|
reqid = BrokerClient.new_reqid()
|
||||||
ids[order.oid] = reqid
|
ids[order.oid] = reqid
|
||||||
log.debug(
|
log.debug(
|
||||||
f"Adding order {reqid}\n"
|
f"Adding order {reqid}\n"
|
||||||
|
@ -249,7 +278,7 @@ async def handle_order_requests(
|
||||||
|
|
||||||
@acm
|
@acm
|
||||||
async def subscribe(
|
async def subscribe(
|
||||||
ws: wsproto.WSConnection,
|
ws: NoBsWs,
|
||||||
token: str,
|
token: str,
|
||||||
subs: list[tuple[str, dict]] = [
|
subs: list[tuple[str, dict]] = [
|
||||||
('ownTrades', {
|
('ownTrades', {
|
||||||
|
@ -632,8 +661,6 @@ async def handle_order_updates(
|
||||||
# to do all fill/status/pp updates in that sub and just use
|
# to do all fill/status/pp updates in that sub and just use
|
||||||
# this one for ledger syncs?
|
# this one for ledger syncs?
|
||||||
|
|
||||||
# XXX: ASK SUPPORT ABOUT THIS!
|
|
||||||
|
|
||||||
# For eg. we could take the "last 50 trades" and do a diff
|
# For eg. we could take the "last 50 trades" and do a diff
|
||||||
# with the ledger and then only do a re-sync if something
|
# with the ledger and then only do a re-sync if something
|
||||||
# seems amiss?
|
# seems amiss?
|
||||||
|
@ -696,7 +723,6 @@ async def handle_order_updates(
|
||||||
status_msg = BrokerdStatus(
|
status_msg = BrokerdStatus(
|
||||||
reqid=reqid,
|
reqid=reqid,
|
||||||
time_ns=time.time_ns(),
|
time_ns=time.time_ns(),
|
||||||
|
|
||||||
account=acc_name,
|
account=acc_name,
|
||||||
status='filled',
|
status='filled',
|
||||||
filled=size,
|
filled=size,
|
||||||
|
@ -741,7 +767,6 @@ async def handle_order_updates(
|
||||||
f'{pformat(order_msg)}'
|
f'{pformat(order_msg)}'
|
||||||
)
|
)
|
||||||
txid, update_msg = list(order_msg.items())[0]
|
txid, update_msg = list(order_msg.items())[0]
|
||||||
match update_msg:
|
|
||||||
|
|
||||||
# XXX: eg. of full msg schema:
|
# XXX: eg. of full msg schema:
|
||||||
# {'avg_price': _,
|
# {'avg_price': _,
|
||||||
|
@ -768,6 +793,66 @@ async def handle_order_updates(
|
||||||
# 'timeinforce': 'GTC',
|
# 'timeinforce': 'GTC',
|
||||||
# 'vol': submit_vlm, # '13.34400854',
|
# 'vol': submit_vlm, # '13.34400854',
|
||||||
# 'vol_exec': exec_vlm} # 0.0000
|
# 'vol_exec': exec_vlm} # 0.0000
|
||||||
|
match update_msg:
|
||||||
|
|
||||||
|
# EMS-unknown live order that needs to be
|
||||||
|
# delivered and loaded on the client-side.
|
||||||
|
case {
|
||||||
|
'userref': reqid,
|
||||||
|
'descr': {
|
||||||
|
'pair': pair,
|
||||||
|
'price': price,
|
||||||
|
'type': action,
|
||||||
|
},
|
||||||
|
'vol': vol,
|
||||||
|
|
||||||
|
# during a fill this field is **not**
|
||||||
|
# provided! but, it is always avail on
|
||||||
|
# actual status updates.. see case above.
|
||||||
|
'status': status,
|
||||||
|
**rest,
|
||||||
|
} if (
|
||||||
|
ids.inverse.get(reqid) is None
|
||||||
|
):
|
||||||
|
# parse out existing live order
|
||||||
|
fqsn = pair.replace('/', '').lower()
|
||||||
|
price = float(price)
|
||||||
|
size = float(vol)
|
||||||
|
|
||||||
|
# register the userref value from
|
||||||
|
# kraken (usually an `int` staring
|
||||||
|
# at 1?) as our reqid.
|
||||||
|
reqids2txids[reqid] = txid
|
||||||
|
oid = str(reqid)
|
||||||
|
ids[oid] = reqid # NOTE!: str -> int
|
||||||
|
|
||||||
|
# ensure wtv reqid they give us we don't re-use on
|
||||||
|
# new order submissions to this actor's client.
|
||||||
|
BrokerClient.add_reqid(reqid)
|
||||||
|
|
||||||
|
# fill out ``Status`` + boxed ``Order``
|
||||||
|
status_msg = Status(
|
||||||
|
time_ns=time.time_ns(),
|
||||||
|
resp='open',
|
||||||
|
oid=oid,
|
||||||
|
reqid=reqid,
|
||||||
|
|
||||||
|
# embedded order info
|
||||||
|
req=Order(
|
||||||
|
action=action,
|
||||||
|
exec_mode='live',
|
||||||
|
oid=oid,
|
||||||
|
symbol=fqsn,
|
||||||
|
account=acc_name,
|
||||||
|
price=price,
|
||||||
|
size=size,
|
||||||
|
),
|
||||||
|
src='kraken',
|
||||||
|
)
|
||||||
|
apiflows[reqid].maps.append(status_msg)
|
||||||
|
await ems_stream.send(status_msg)
|
||||||
|
continue
|
||||||
|
|
||||||
case {
|
case {
|
||||||
'userref': reqid,
|
'userref': reqid,
|
||||||
|
|
||||||
|
@ -821,10 +906,6 @@ async def handle_order_updates(
|
||||||
)
|
)
|
||||||
|
|
||||||
oid = ids.inverse.get(reqid)
|
oid = ids.inverse.get(reqid)
|
||||||
|
|
||||||
if (
|
|
||||||
status == 'open'
|
|
||||||
and (
|
|
||||||
# XXX: too fast edit handled by the
|
# XXX: too fast edit handled by the
|
||||||
# request handler task: this
|
# request handler task: this
|
||||||
# scenario occurs when ems side
|
# scenario occurs when ems side
|
||||||
|
@ -850,17 +931,13 @@ async def handle_order_updates(
|
||||||
# coming in too quickly on the other
|
# coming in too quickly on the other
|
||||||
# side of the ems, aka the client
|
# side of the ems, aka the client
|
||||||
# <-> ems dialog.
|
# <-> ems dialog.
|
||||||
(toofast := isinstance(
|
if (
|
||||||
|
status == 'open'
|
||||||
|
and isinstance(
|
||||||
reqids2txids.get(reqid),
|
reqids2txids.get(reqid),
|
||||||
TooFastEdit
|
TooFastEdit
|
||||||
))
|
|
||||||
|
|
||||||
# pre-existing open order NOT from
|
|
||||||
# this EMS session.
|
|
||||||
or (noid := oid is None)
|
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
if toofast:
|
|
||||||
# TODO: don't even allow this case
|
# TODO: don't even allow this case
|
||||||
# by not moving the client side line
|
# by not moving the client side line
|
||||||
# until an edit confirmation
|
# until an edit confirmation
|
||||||
|
@ -870,17 +947,6 @@ async def handle_order_updates(
|
||||||
f'{update_msg}\n'
|
f'{update_msg}\n'
|
||||||
'Cancelling order for now!..'
|
'Cancelling order for now!..'
|
||||||
)
|
)
|
||||||
|
|
||||||
elif noid: # a non-ems-active order
|
|
||||||
# TODO: handle these and relay them
|
|
||||||
# through the EMS to the client / UI
|
|
||||||
# side!
|
|
||||||
log.cancel(
|
|
||||||
f'Rx unknown active order {txid}:\n'
|
|
||||||
f'{update_msg}\n'
|
|
||||||
'Cancelling order for now!..'
|
|
||||||
)
|
|
||||||
|
|
||||||
# call ws api to cancel:
|
# call ws api to cancel:
|
||||||
# https://docs.kraken.com/websockets/#message-cancelOrder
|
# https://docs.kraken.com/websockets/#message-cancelOrder
|
||||||
await ws.send_msg({
|
await ws.send_msg({
|
||||||
|
@ -891,18 +957,6 @@ async def handle_order_updates(
|
||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# remap statuses to ems set.
|
|
||||||
ems_status = {
|
|
||||||
'open': 'submitted',
|
|
||||||
'closed': 'filled',
|
|
||||||
'canceled': 'cancelled',
|
|
||||||
# do we even need to forward
|
|
||||||
# this state to the ems?
|
|
||||||
'pending': 'pending',
|
|
||||||
}[status]
|
|
||||||
# TODO: i like the open / closed semantics
|
|
||||||
# more we should consider them for internals
|
|
||||||
|
|
||||||
# send BrokerdStatus messages for all
|
# send BrokerdStatus messages for all
|
||||||
# order state updates
|
# order state updates
|
||||||
resp = BrokerdStatus(
|
resp = BrokerdStatus(
|
||||||
|
@ -912,7 +966,7 @@ async def handle_order_updates(
|
||||||
account=f'kraken.{acctid}',
|
account=f'kraken.{acctid}',
|
||||||
|
|
||||||
# everyone doin camel case..
|
# everyone doin camel case..
|
||||||
status=ems_status, # force lower case
|
status=status, # force lower case
|
||||||
|
|
||||||
filled=vlm,
|
filled=vlm,
|
||||||
reason='', # why held?
|
reason='', # why held?
|
||||||
|
|
|
@ -34,7 +34,6 @@ import pendulum
|
||||||
from trio_typing import TaskStatus
|
from trio_typing import TaskStatus
|
||||||
import tractor
|
import tractor
|
||||||
import trio
|
import trio
|
||||||
import wsproto
|
|
||||||
|
|
||||||
from piker._cacheables import open_cached_client
|
from piker._cacheables import open_cached_client
|
||||||
from piker.brokers._util import (
|
from piker.brokers._util import (
|
||||||
|
@ -243,22 +242,6 @@ def normalize(
|
||||||
return topic, quote
|
return topic, quote
|
||||||
|
|
||||||
|
|
||||||
def make_sub(pairs: list[str], data: dict[str, Any]) -> dict[str, str]:
|
|
||||||
'''
|
|
||||||
Create a request subscription packet dict.
|
|
||||||
|
|
||||||
https://docs.kraken.com/websockets/#message-subscribe
|
|
||||||
|
|
||||||
'''
|
|
||||||
# eg. specific logic for this in kraken's sync client:
|
|
||||||
# https://github.com/krakenfx/kraken-wsclient-py/blob/master/kraken_wsclient_py/kraken_wsclient_py.py#L188
|
|
||||||
return {
|
|
||||||
'pair': pairs,
|
|
||||||
'event': 'subscribe',
|
|
||||||
'subscription': data,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@acm
|
@acm
|
||||||
async def open_history_client(
|
async def open_history_client(
|
||||||
symbol: str,
|
symbol: str,
|
||||||
|
@ -381,15 +364,20 @@ async def stream_quotes(
|
||||||
}
|
}
|
||||||
|
|
||||||
@acm
|
@acm
|
||||||
async def subscribe(ws: wsproto.WSConnection):
|
async def subscribe(ws: NoBsWs):
|
||||||
|
|
||||||
# XXX: setup subs
|
# XXX: setup subs
|
||||||
# https://docs.kraken.com/websockets/#message-subscribe
|
# https://docs.kraken.com/websockets/#message-subscribe
|
||||||
# specific logic for this in kraken's shitty sync client:
|
# specific logic for this in kraken's sync client:
|
||||||
# https://github.com/krakenfx/kraken-wsclient-py/blob/master/kraken_wsclient_py/kraken_wsclient_py.py#L188
|
# https://github.com/krakenfx/kraken-wsclient-py/blob/master/kraken_wsclient_py/kraken_wsclient_py.py#L188
|
||||||
ohlc_sub = make_sub(
|
ohlc_sub = {
|
||||||
list(ws_pairs.values()),
|
'event': 'subscribe',
|
||||||
{'name': 'ohlc', 'interval': 1}
|
'pair': list(ws_pairs.values()),
|
||||||
)
|
'subscription': {
|
||||||
|
'name': 'ohlc',
|
||||||
|
'interval': 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
# TODO: we want to eventually allow unsubs which should
|
# TODO: we want to eventually allow unsubs which should
|
||||||
# be completely fine to request from a separate task
|
# be completely fine to request from a separate task
|
||||||
|
@ -398,10 +386,14 @@ async def stream_quotes(
|
||||||
await ws.send_msg(ohlc_sub)
|
await ws.send_msg(ohlc_sub)
|
||||||
|
|
||||||
# trade data (aka L1)
|
# trade data (aka L1)
|
||||||
l1_sub = make_sub(
|
l1_sub = {
|
||||||
list(ws_pairs.values()),
|
'event': 'subscribe',
|
||||||
{'name': 'spread'} # 'depth': 10}
|
'pair': list(ws_pairs.values()),
|
||||||
)
|
'subscription': {
|
||||||
|
'name': 'spread',
|
||||||
|
# 'depth': 10}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
# pull a first quote and deliver
|
# pull a first quote and deliver
|
||||||
await ws.send_msg(l1_sub)
|
await ws.send_msg(l1_sub)
|
||||||
|
|
|
@ -83,7 +83,13 @@ class OrderBook:
|
||||||
"""Cancel an order (or alert) in the EMS.
|
"""Cancel an order (or alert) in the EMS.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
cmd = self._sent_orders[uuid]
|
cmd = self._sent_orders.get(uuid)
|
||||||
|
if not cmd:
|
||||||
|
log.error(
|
||||||
|
f'Unknown order {uuid}!?\n'
|
||||||
|
f'Maybe there is a stale entry or line?\n'
|
||||||
|
f'You should report this as a bug!'
|
||||||
|
)
|
||||||
msg = Cancel(
|
msg = Cancel(
|
||||||
oid=uuid,
|
oid=uuid,
|
||||||
symbol=cmd.symbol,
|
symbol=cmd.symbol,
|
||||||
|
@ -149,10 +155,17 @@ async def relay_order_cmds_from_sync_code(
|
||||||
book = get_orders()
|
book = get_orders()
|
||||||
async with book._from_order_book.subscribe() as orders_stream:
|
async with book._from_order_book.subscribe() as orders_stream:
|
||||||
async for cmd in orders_stream:
|
async for cmd in orders_stream:
|
||||||
if cmd.symbol == symbol_key:
|
sym = cmd.symbol
|
||||||
log.info(f'Send order cmd:\n{pformat(cmd)}')
|
msg = pformat(cmd)
|
||||||
|
if sym == symbol_key:
|
||||||
|
log.info(f'Send order cmd:\n{msg}')
|
||||||
# send msg over IPC / wire
|
# send msg over IPC / wire
|
||||||
await to_ems_stream.send(cmd)
|
await to_ems_stream.send(cmd)
|
||||||
|
else:
|
||||||
|
log.warning(
|
||||||
|
f'Ignoring unmatched order cmd for {sym} != {symbol_key}:'
|
||||||
|
f'\n{msg}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@acm
|
@acm
|
||||||
|
@ -220,11 +233,19 @@ async def open_ems(
|
||||||
fqsn=fqsn,
|
fqsn=fqsn,
|
||||||
exec_mode=mode,
|
exec_mode=mode,
|
||||||
|
|
||||||
) as (ctx, (positions, accounts)),
|
) as (
|
||||||
|
ctx,
|
||||||
|
(
|
||||||
|
positions,
|
||||||
|
accounts,
|
||||||
|
dialogs,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
# open 2-way trade command stream
|
# open 2-way trade command stream
|
||||||
ctx.open_stream() as trades_stream,
|
ctx.open_stream() as trades_stream,
|
||||||
):
|
):
|
||||||
|
# start sync code order msg delivery task
|
||||||
async with trio.open_nursery() as n:
|
async with trio.open_nursery() as n:
|
||||||
n.start_soon(
|
n.start_soon(
|
||||||
relay_order_cmds_from_sync_code,
|
relay_order_cmds_from_sync_code,
|
||||||
|
@ -232,4 +253,10 @@ async def open_ems(
|
||||||
trades_stream
|
trades_stream
|
||||||
)
|
)
|
||||||
|
|
||||||
yield book, trades_stream, positions, accounts
|
yield (
|
||||||
|
book,
|
||||||
|
trades_stream,
|
||||||
|
positions,
|
||||||
|
accounts,
|
||||||
|
dialogs,
|
||||||
|
)
|
||||||
|
|
|
@ -18,8 +18,8 @@
|
||||||
In da suit parlances: "Execution management systems"
|
In da suit parlances: "Execution management systems"
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
from collections import defaultdict, ChainMap
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from math import isnan
|
from math import isnan
|
||||||
from pprint import pformat
|
from pprint import pformat
|
||||||
import time
|
import time
|
||||||
|
@ -27,6 +27,7 @@ from typing import (
|
||||||
AsyncIterator,
|
AsyncIterator,
|
||||||
Any,
|
Any,
|
||||||
Callable,
|
Callable,
|
||||||
|
Optional,
|
||||||
)
|
)
|
||||||
|
|
||||||
from bidict import bidict
|
from bidict import bidict
|
||||||
|
@ -41,9 +42,16 @@ from ..data.types import Struct
|
||||||
from .._daemon import maybe_spawn_brokerd
|
from .._daemon import maybe_spawn_brokerd
|
||||||
from . import _paper_engine as paper
|
from . import _paper_engine as paper
|
||||||
from ._messages import (
|
from ._messages import (
|
||||||
Status, Order,
|
Order,
|
||||||
BrokerdCancel, BrokerdOrder, BrokerdOrderAck, BrokerdStatus,
|
Status,
|
||||||
BrokerdFill, BrokerdError, BrokerdPosition,
|
# Cancel,
|
||||||
|
BrokerdCancel,
|
||||||
|
BrokerdOrder,
|
||||||
|
# BrokerdOrderAck,
|
||||||
|
BrokerdStatus,
|
||||||
|
BrokerdFill,
|
||||||
|
BrokerdError,
|
||||||
|
BrokerdPosition,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -90,8 +98,7 @@ def mk_check(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
class _DarkBook(Struct):
|
||||||
class _DarkBook:
|
|
||||||
'''
|
'''
|
||||||
EMS-trigger execution book.
|
EMS-trigger execution book.
|
||||||
|
|
||||||
|
@ -116,17 +123,24 @@ class _DarkBook:
|
||||||
dict, # cmd / msg type
|
dict, # cmd / msg type
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
] = field(default_factory=dict)
|
] = {}
|
||||||
|
|
||||||
# tracks most recent values per symbol each from data feed
|
# tracks most recent values per symbol each from data feed
|
||||||
lasts: dict[
|
lasts: dict[
|
||||||
str,
|
str,
|
||||||
float,
|
float,
|
||||||
] = field(default_factory=dict)
|
] = {}
|
||||||
|
|
||||||
# mapping of piker ems order ids to current brokerd order flow message
|
# _ems_entries: dict[str, str] = {}
|
||||||
_ems_entries: dict[str, str] = field(default_factory=dict)
|
_active: dict = {}
|
||||||
_ems2brokerd_ids: dict[str, str] = field(default_factory=bidict)
|
|
||||||
|
# mapping of ems dialog ids to msg flow history
|
||||||
|
_msgflows: defaultdict[
|
||||||
|
int,
|
||||||
|
ChainMap[dict[str, dict]],
|
||||||
|
] = defaultdict(ChainMap)
|
||||||
|
|
||||||
|
_ems2brokerd_ids: dict[str, str] = bidict()
|
||||||
|
|
||||||
|
|
||||||
# XXX: this is in place to prevent accidental positions that are too
|
# XXX: this is in place to prevent accidental positions that are too
|
||||||
|
@ -181,6 +195,7 @@ async def clear_dark_triggers(
|
||||||
for oid, (
|
for oid, (
|
||||||
pred,
|
pred,
|
||||||
tf,
|
tf,
|
||||||
|
# TODO: send this msg instead?
|
||||||
cmd,
|
cmd,
|
||||||
percent_away,
|
percent_away,
|
||||||
abs_diff_away
|
abs_diff_away
|
||||||
|
@ -188,9 +203,9 @@ async def clear_dark_triggers(
|
||||||
tuple(execs.items())
|
tuple(execs.items())
|
||||||
):
|
):
|
||||||
if (
|
if (
|
||||||
not pred or
|
not pred
|
||||||
ttype not in tf or
|
or ttype not in tf
|
||||||
not pred(price)
|
or not pred(price)
|
||||||
):
|
):
|
||||||
# log.runtime(
|
# log.runtime(
|
||||||
# f'skipping quote for {sym} '
|
# f'skipping quote for {sym} '
|
||||||
|
@ -200,30 +215,29 @@ async def clear_dark_triggers(
|
||||||
# majority of iterations will be non-matches
|
# majority of iterations will be non-matches
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
brokerd_msg: Optional[BrokerdOrder] = None
|
||||||
match cmd:
|
match cmd:
|
||||||
# alert: nothing to do but relay a status
|
# alert: nothing to do but relay a status
|
||||||
# back to the requesting ems client
|
# back to the requesting ems client
|
||||||
case {
|
case Order(action='alert'):
|
||||||
'action': 'alert',
|
resp = 'triggered'
|
||||||
}:
|
|
||||||
resp = 'alert_triggered'
|
|
||||||
|
|
||||||
# executable order submission
|
# executable order submission
|
||||||
case {
|
case Order(
|
||||||
'action': action,
|
action=action,
|
||||||
'symbol': symbol,
|
symbol=symbol,
|
||||||
'account': account,
|
account=account,
|
||||||
'size': size,
|
size=size,
|
||||||
}:
|
):
|
||||||
bfqsn: str = symbol.replace(f'.{broker}', '')
|
bfqsn: str = symbol.replace(f'.{broker}', '')
|
||||||
submit_price = price + abs_diff_away
|
submit_price = price + abs_diff_away
|
||||||
resp = 'dark_triggered' # hidden on client-side
|
resp = 'triggered' # hidden on client-side
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
f'Dark order triggered for price {price}\n'
|
f'Dark order triggered for price {price}\n'
|
||||||
f'Submitting order @ price {submit_price}')
|
f'Submitting order @ price {submit_price}')
|
||||||
|
|
||||||
live_req = BrokerdOrder(
|
brokerd_msg = BrokerdOrder(
|
||||||
action=action,
|
action=action,
|
||||||
oid=oid,
|
oid=oid,
|
||||||
account=account,
|
account=account,
|
||||||
|
@ -232,26 +246,22 @@ async def clear_dark_triggers(
|
||||||
price=submit_price,
|
price=submit_price,
|
||||||
size=size,
|
size=size,
|
||||||
)
|
)
|
||||||
await brokerd_orders_stream.send(live_req)
|
|
||||||
|
|
||||||
# mark this entry as having sent an order
|
await brokerd_orders_stream.send(brokerd_msg)
|
||||||
# request. the entry will be replaced once the
|
|
||||||
# target broker replies back with
|
# book._ems_entries[oid] = live_req
|
||||||
# a ``BrokerdOrderAck`` msg including the
|
# book._msgflows[oid].maps.insert(0, live_req)
|
||||||
# allocated unique ``BrokerdOrderAck.reqid`` key
|
|
||||||
# generated by the broker's own systems.
|
|
||||||
book._ems_entries[oid] = live_req
|
|
||||||
|
|
||||||
case _:
|
case _:
|
||||||
raise ValueError(f'Invalid dark book entry: {cmd}')
|
raise ValueError(f'Invalid dark book entry: {cmd}')
|
||||||
|
|
||||||
# fallthrough logic
|
# fallthrough logic
|
||||||
resp = Status(
|
status = Status(
|
||||||
oid=oid, # ems dialog id
|
oid=oid, # ems dialog id
|
||||||
time_ns=time.time_ns(),
|
time_ns=time.time_ns(),
|
||||||
resp=resp,
|
resp=resp,
|
||||||
trigger_price=price,
|
req=cmd,
|
||||||
brokerd_msg=cmd,
|
brokerd_msg=brokerd_msg,
|
||||||
)
|
)
|
||||||
|
|
||||||
# remove exec-condition from set
|
# remove exec-condition from set
|
||||||
|
@ -262,9 +272,24 @@ async def clear_dark_triggers(
|
||||||
f'pred for {oid} was already removed!?'
|
f'pred for {oid} was already removed!?'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# update actives
|
||||||
|
# mark this entry as having sent an order
|
||||||
|
# request. the entry will be replaced once the
|
||||||
|
# target broker replies back with
|
||||||
|
# a ``BrokerdOrderAck`` msg including the
|
||||||
|
# allocated unique ``BrokerdOrderAck.reqid`` key
|
||||||
|
# generated by the broker's own systems.
|
||||||
|
if cmd.action == 'alert':
|
||||||
|
# don't register the alert status (so it won't
|
||||||
|
# be reloaded by clients) since it's now
|
||||||
|
# complete / closed.
|
||||||
|
book._active.pop(oid)
|
||||||
|
else:
|
||||||
|
book._active[oid] = status
|
||||||
|
|
||||||
# send response to client-side
|
# send response to client-side
|
||||||
try:
|
try:
|
||||||
await ems_client_order_stream.send(resp)
|
await ems_client_order_stream.send(status)
|
||||||
except (
|
except (
|
||||||
trio.ClosedResourceError,
|
trio.ClosedResourceError,
|
||||||
):
|
):
|
||||||
|
@ -281,8 +306,7 @@ async def clear_dark_triggers(
|
||||||
# print(f'execs scan took: {time.time() - start}')
|
# print(f'execs scan took: {time.time() - start}')
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
class TradesRelay(Struct):
|
||||||
class TradesRelay:
|
|
||||||
|
|
||||||
# for now we keep only a single connection open with
|
# for now we keep only a single connection open with
|
||||||
# each ``brokerd`` for simplicity.
|
# each ``brokerd`` for simplicity.
|
||||||
|
@ -318,7 +342,10 @@ class Router(Struct):
|
||||||
|
|
||||||
# order id to client stream map
|
# order id to client stream map
|
||||||
clients: set[tractor.MsgStream] = set()
|
clients: set[tractor.MsgStream] = set()
|
||||||
dialogues: dict[str, list[tractor.MsgStream]] = {}
|
dialogues: dict[
|
||||||
|
str,
|
||||||
|
list[tractor.MsgStream]
|
||||||
|
] = {}
|
||||||
|
|
||||||
# brokername to trades-dialogues streams with ``brokerd`` actors
|
# brokername to trades-dialogues streams with ``brokerd`` actors
|
||||||
relays: dict[str, TradesRelay] = {}
|
relays: dict[str, TradesRelay] = {}
|
||||||
|
@ -341,11 +368,12 @@ class Router(Struct):
|
||||||
loglevel: str,
|
loglevel: str,
|
||||||
|
|
||||||
) -> tuple[dict, tractor.MsgStream]:
|
) -> tuple[dict, tractor.MsgStream]:
|
||||||
'''Open and yield ``brokerd`` trades dialogue context-stream if none
|
'''
|
||||||
already exists.
|
Open and yield ``brokerd`` trades dialogue context-stream if
|
||||||
|
none already exists.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
relay = self.relays.get(feed.mod.name)
|
relay: TradesRelay = self.relays.get(feed.mod.name)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
relay is None
|
relay is None
|
||||||
|
@ -381,6 +409,22 @@ class Router(Struct):
|
||||||
|
|
||||||
relay.consumers -= 1
|
relay.consumers -= 1
|
||||||
|
|
||||||
|
async def client_broadcast(
|
||||||
|
self,
|
||||||
|
msg: dict,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
for client_stream in self.clients.copy():
|
||||||
|
try:
|
||||||
|
await client_stream.send(msg)
|
||||||
|
except(
|
||||||
|
trio.ClosedResourceError,
|
||||||
|
trio.BrokenResourceError,
|
||||||
|
):
|
||||||
|
self.clients.remove(client_stream)
|
||||||
|
log.warning(
|
||||||
|
f'client for {client_stream} was already closed?')
|
||||||
|
|
||||||
|
|
||||||
_router: Router = None
|
_router: Router = None
|
||||||
|
|
||||||
|
@ -452,7 +496,6 @@ async def open_brokerd_trades_dialogue(
|
||||||
async with (
|
async with (
|
||||||
open_trades_endpoint as (brokerd_ctx, (positions, accounts,)),
|
open_trades_endpoint as (brokerd_ctx, (positions, accounts,)),
|
||||||
brokerd_ctx.open_stream() as brokerd_trades_stream,
|
brokerd_ctx.open_stream() as brokerd_trades_stream,
|
||||||
|
|
||||||
):
|
):
|
||||||
# XXX: really we only want one stream per `emsd` actor
|
# XXX: really we only want one stream per `emsd` actor
|
||||||
# to relay global `brokerd` order events unless we're
|
# to relay global `brokerd` order events unless we're
|
||||||
|
@ -502,14 +545,9 @@ async def open_brokerd_trades_dialogue(
|
||||||
|
|
||||||
task_status.started(relay)
|
task_status.started(relay)
|
||||||
|
|
||||||
await translate_and_relay_brokerd_events(
|
|
||||||
broker,
|
|
||||||
brokerd_trades_stream,
|
|
||||||
_router,
|
|
||||||
)
|
|
||||||
|
|
||||||
# this context should block here indefinitely until
|
# this context should block here indefinitely until
|
||||||
# the ``brokerd`` task either dies or is cancelled
|
# the ``brokerd`` task either dies or is cancelled
|
||||||
|
await trio.sleep_forever()
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# parent context must have been closed
|
# parent context must have been closed
|
||||||
|
@ -561,15 +599,14 @@ async def translate_and_relay_brokerd_events(
|
||||||
|
|
||||||
broker ems
|
broker ems
|
||||||
'error' -> log it locally (for now)
|
'error' -> log it locally (for now)
|
||||||
'status' -> relabel as 'broker_<status>', if complete send 'executed'
|
('status' | 'fill'} -> relayed through see ``Status`` msg type.
|
||||||
'fill' -> 'broker_filled'
|
|
||||||
|
|
||||||
Currently handled status values from IB:
|
Currently handled status values from IB:
|
||||||
{'presubmitted', 'submitted', 'cancelled', 'inactive'}
|
{'presubmitted', 'submitted', 'cancelled', 'inactive'}
|
||||||
|
|
||||||
'''
|
'''
|
||||||
book = router.get_dark_book(broker)
|
book: _DarkBook = router.get_dark_book(broker)
|
||||||
relay = router.relays[broker]
|
relay: TradesRelay = router.relays[broker]
|
||||||
|
|
||||||
assert relay.brokerd_dialogue == brokerd_trades_stream
|
assert relay.brokerd_dialogue == brokerd_trades_stream
|
||||||
|
|
||||||
|
@ -601,30 +638,16 @@ async def translate_and_relay_brokerd_events(
|
||||||
|
|
||||||
# fan-out-relay position msgs immediately by
|
# fan-out-relay position msgs immediately by
|
||||||
# broadcasting updates on all client streams
|
# broadcasting updates on all client streams
|
||||||
for client_stream in router.clients.copy():
|
await router.client_broadcast(pos_msg)
|
||||||
try:
|
|
||||||
await client_stream.send(pos_msg)
|
|
||||||
except(
|
|
||||||
trio.ClosedResourceError,
|
|
||||||
trio.BrokenResourceError,
|
|
||||||
):
|
|
||||||
router.clients.remove(client_stream)
|
|
||||||
log.warning(
|
|
||||||
f'client for {client_stream} was already closed?')
|
|
||||||
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# BrokerdOrderAck
|
# BrokerdOrderAck
|
||||||
|
# initial response to brokerd order request
|
||||||
case {
|
case {
|
||||||
'name': 'ack',
|
'name': 'ack',
|
||||||
'reqid': reqid, # brokerd generated order-request id
|
'reqid': reqid, # brokerd generated order-request id
|
||||||
'oid': oid, # ems order-dialog id
|
'oid': oid, # ems order-dialog id
|
||||||
} if (
|
}:
|
||||||
entry := book._ems_entries.get(oid)
|
|
||||||
):
|
|
||||||
# initial response to brokerd order request
|
|
||||||
# if name == 'ack':
|
|
||||||
|
|
||||||
# register the brokerd request id (that was generated
|
# register the brokerd request id (that was generated
|
||||||
# / created internally by the broker backend) with our
|
# / created internally by the broker backend) with our
|
||||||
# local ems order id for reverse lookup later.
|
# local ems order id for reverse lookup later.
|
||||||
|
@ -639,23 +662,23 @@ async def translate_and_relay_brokerd_events(
|
||||||
# new order which has not yet be registered into the
|
# new order which has not yet be registered into the
|
||||||
# local ems book, insert it now and handle 2 cases:
|
# local ems book, insert it now and handle 2 cases:
|
||||||
|
|
||||||
# - the order has previously been requested to be
|
# 1. the order has previously been requested to be
|
||||||
# cancelled by the ems controlling client before we
|
# cancelled by the ems controlling client before we
|
||||||
# received this ack, in which case we relay that cancel
|
# received this ack, in which case we relay that cancel
|
||||||
# signal **asap** to the backend broker
|
# signal **asap** to the backend broker
|
||||||
action = getattr(entry, 'action', None)
|
status_msg = book._active[oid]
|
||||||
if action and action == 'cancel':
|
req = status_msg.req
|
||||||
|
if req and req.action == 'cancel':
|
||||||
# assign newly providerd broker backend request id
|
# assign newly providerd broker backend request id
|
||||||
entry.reqid = reqid
|
# and tell broker to cancel immediately
|
||||||
|
status_msg.reqid = reqid
|
||||||
|
await brokerd_trades_stream.send(req)
|
||||||
|
|
||||||
# tell broker to cancel immediately
|
# 2. the order is now active and will be mirrored in
|
||||||
await brokerd_trades_stream.send(entry)
|
|
||||||
|
|
||||||
# - the order is now active and will be mirrored in
|
|
||||||
# our book -> registered as live flow
|
# our book -> registered as live flow
|
||||||
else:
|
else:
|
||||||
# update the flow with the ack msg
|
# TODO: should we relay this ack state?
|
||||||
book._ems_entries[oid] = BrokerdOrderAck(**brokerd_msg)
|
status_msg.resp = 'pending'
|
||||||
|
|
||||||
# no msg to client necessary
|
# no msg to client necessary
|
||||||
continue
|
continue
|
||||||
|
@ -666,11 +689,9 @@ async def translate_and_relay_brokerd_events(
|
||||||
'oid': oid, # ems order-dialog id
|
'oid': oid, # ems order-dialog id
|
||||||
'reqid': reqid, # brokerd generated order-request id
|
'reqid': reqid, # brokerd generated order-request id
|
||||||
'symbol': sym,
|
'symbol': sym,
|
||||||
'broker_details': details,
|
} if status_msg := book._active.get(oid):
|
||||||
# 'reason': reason,
|
|
||||||
}:
|
|
||||||
msg = BrokerdError(**brokerd_msg)
|
msg = BrokerdError(**brokerd_msg)
|
||||||
resp = 'broker_errored'
|
|
||||||
log.error(pformat(msg)) # XXX make one when it's blank?
|
log.error(pformat(msg)) # XXX make one when it's blank?
|
||||||
|
|
||||||
# TODO: figure out how this will interact with EMS clients
|
# TODO: figure out how this will interact with EMS clients
|
||||||
|
@ -680,43 +701,61 @@ async def translate_and_relay_brokerd_events(
|
||||||
# some unexpected failure - something we need to think more
|
# some unexpected failure - something we need to think more
|
||||||
# about. In most default situations, with composed orders
|
# about. In most default situations, with composed orders
|
||||||
# (ex. brackets), most brokers seem to use a oca policy.
|
# (ex. brackets), most brokers seem to use a oca policy.
|
||||||
|
ems_client_order_stream = router.dialogues[oid]
|
||||||
|
status_msg.resp = 'error'
|
||||||
|
status_msg.brokerd_msg = msg
|
||||||
|
book._active[oid] = status_msg
|
||||||
|
await ems_client_order_stream.send(status_msg)
|
||||||
|
|
||||||
# BrokerdStatus
|
# BrokerdStatus
|
||||||
case {
|
case {
|
||||||
'name': 'status',
|
'name': 'status',
|
||||||
'status': status,
|
'status': status,
|
||||||
'reqid': reqid, # brokerd generated order-request id
|
'reqid': reqid, # brokerd generated order-request id
|
||||||
# TODO: feels like the wrong msg for this field?
|
|
||||||
'remaining': remaining,
|
|
||||||
|
|
||||||
} if (
|
} if (
|
||||||
oid := book._ems2brokerd_ids.inverse.get(reqid)
|
(oid := book._ems2brokerd_ids.inverse.get(reqid))
|
||||||
|
and status in (
|
||||||
|
'canceled',
|
||||||
|
'open',
|
||||||
|
'closed',
|
||||||
|
)
|
||||||
):
|
):
|
||||||
msg = BrokerdStatus(**brokerd_msg)
|
msg = BrokerdStatus(**brokerd_msg)
|
||||||
|
|
||||||
# TODO: should we flatten out these cases and/or should
|
# TODO: maybe pack this into a composite type that
|
||||||
# they maybe even eventually be separate messages?
|
# contains both the IPC stream as well the
|
||||||
if status == 'cancelled':
|
# msg-chain/dialog.
|
||||||
log.info(f'Cancellation for {oid} is complete!')
|
ems_client_order_stream = router.dialogues[oid]
|
||||||
|
status_msg = book._active[oid]
|
||||||
|
status_msg.resp = status
|
||||||
|
|
||||||
if status == 'filled':
|
# retrieve existing live flow
|
||||||
# conditional execution is fully complete, no more
|
old_reqid = status_msg.reqid
|
||||||
# fills for the noted order
|
if old_reqid and old_reqid != reqid:
|
||||||
if not remaining:
|
log.warning(
|
||||||
|
f'Brokerd order id change for {oid}:\n'
|
||||||
|
f'{old_reqid}:{type(old_reqid)} ->'
|
||||||
|
f' {reqid}{type(reqid)}'
|
||||||
|
)
|
||||||
|
|
||||||
resp = 'broker_executed'
|
status_msg.reqid = reqid # THIS LINE IS CRITICAL!
|
||||||
|
status_msg.brokerd_msg = msg
|
||||||
|
status_msg.src = msg.broker_details['name']
|
||||||
|
await ems_client_order_stream.send(status_msg)
|
||||||
|
|
||||||
# be sure to pop this stream from our dialogue set
|
if status == 'closed':
|
||||||
# since the order dialogue should be done.
|
|
||||||
log.info(f'Execution for {oid} is complete!')
|
log.info(f'Execution for {oid} is complete!')
|
||||||
|
status_msg = book._active.pop(oid)
|
||||||
|
|
||||||
|
elif status == 'canceled':
|
||||||
|
log.cancel(f'Cancellation for {oid} is complete!')
|
||||||
|
status_msg = book._active.pop(oid)
|
||||||
|
|
||||||
|
else: # open
|
||||||
|
# relayed from backend but probably not handled so
|
||||||
# just log it
|
# just log it
|
||||||
else:
|
log.info(f'{broker} opened order {msg}')
|
||||||
log.info(f'{broker} filled {msg}')
|
|
||||||
|
|
||||||
else:
|
|
||||||
# one of {submitted, cancelled}
|
|
||||||
resp = 'broker_' + msg.status
|
|
||||||
|
|
||||||
# BrokerdFill
|
# BrokerdFill
|
||||||
case {
|
case {
|
||||||
|
@ -728,82 +767,112 @@ async def translate_and_relay_brokerd_events(
|
||||||
):
|
):
|
||||||
# proxy through the "fill" result(s)
|
# proxy through the "fill" result(s)
|
||||||
msg = BrokerdFill(**brokerd_msg)
|
msg = BrokerdFill(**brokerd_msg)
|
||||||
resp = 'broker_filled'
|
log.info(f'Fill for {oid} cleared with:\n{pformat(msg)}')
|
||||||
log.info(f'\nFill for {oid} cleared with:\n{pformat(resp)}')
|
|
||||||
|
|
||||||
# unknown valid message case?
|
ems_client_order_stream = router.dialogues[oid]
|
||||||
# case {
|
|
||||||
# 'name': name,
|
|
||||||
# 'symbol': sym,
|
|
||||||
# 'reqid': reqid, # brokerd generated order-request id
|
|
||||||
# # 'oid': oid, # ems order-dialog id
|
|
||||||
# 'broker_details': details,
|
|
||||||
|
|
||||||
# } if (
|
# XXX: bleh, a fill can come after 'closed' from `ib`?
|
||||||
# book._ems2brokerd_ids.inverse.get(reqid) is None
|
# only send a late fill event we haven't already closed
|
||||||
# ):
|
# out the dialog status locally.
|
||||||
# # TODO: pretty sure we can drop this now?
|
status_msg = book._active.get(oid)
|
||||||
|
if status_msg:
|
||||||
|
status_msg.resp = 'fill'
|
||||||
|
status_msg.reqid = reqid
|
||||||
|
status_msg.brokerd_msg = msg
|
||||||
|
await ems_client_order_stream.send(status_msg)
|
||||||
|
|
||||||
# # XXX: paper clearing special cases
|
# ``Status`` containing an embedded order msg which
|
||||||
# # paper engine race case: ``Client.submit_limit()`` hasn't
|
# should be loaded as a "pre-existing open order" from the
|
||||||
# # returned yet and provided an output reqid to register
|
# brokerd backend.
|
||||||
# # locally, so we need to retreive the oid that was already
|
case {
|
||||||
# # packed at submission since we already know it ahead of
|
'name': 'status',
|
||||||
# # time
|
'resp': status,
|
||||||
# paper = details.get('paper_info')
|
'reqid': reqid, # brokerd generated order-request id
|
||||||
# ext = details.get('external')
|
}:
|
||||||
|
if (
|
||||||
|
status != 'open'
|
||||||
|
):
|
||||||
|
# TODO: check for an oid we might know since it was
|
||||||
|
# registered from a previous order/status load?
|
||||||
|
log.error(
|
||||||
|
f'Unknown/transient status msg:\n'
|
||||||
|
f'{pformat(brokerd_msg)}\n'
|
||||||
|
'Unable to relay message to client side!?'
|
||||||
|
)
|
||||||
|
|
||||||
# if paper:
|
# TODO: we probably want some kind of "tagging" system
|
||||||
# # paperboi keeps the ems id up front
|
# for external order submissions like this eventually
|
||||||
# oid = paper['oid']
|
# to be able to more formally handle multi-player
|
||||||
|
# trading...
|
||||||
|
else:
|
||||||
|
# existing open backend order which we broadcast to
|
||||||
|
# all currently connected clients.
|
||||||
|
log.info(
|
||||||
|
f'Relaying existing open order:\n {brokerd_msg}'
|
||||||
|
)
|
||||||
|
|
||||||
# elif ext:
|
# use backend request id as our ems id though this
|
||||||
# # may be an order msg specified as "external" to the
|
# may end up with collisions?
|
||||||
# # piker ems flow (i.e. generated by some other
|
status_msg = Status(**brokerd_msg)
|
||||||
# # external broker backend client (like tws for ib)
|
order = Order(**status_msg.req)
|
||||||
# log.error(f"External trade event {name}@{ext}")
|
assert order.price and order.size
|
||||||
|
status_msg.req = order
|
||||||
|
|
||||||
# else:
|
assert status_msg.src # source tag?
|
||||||
# # something is out of order, we don't have an oid for
|
oid = str(status_msg.reqid)
|
||||||
# # this broker-side message.
|
|
||||||
# log.error(
|
|
||||||
# f'Unknown oid: {oid} for msg {name}:\n'
|
|
||||||
# f'{pformat(brokerd_msg)}\n'
|
|
||||||
# 'Unable to relay message to client side!?'
|
|
||||||
# )
|
|
||||||
|
|
||||||
# continue
|
# attempt to avoid collisions
|
||||||
|
status_msg.reqid = oid
|
||||||
|
assert status_msg.resp == 'open'
|
||||||
|
|
||||||
|
# register this existing broker-side dialog
|
||||||
|
book._ems2brokerd_ids[oid] = reqid
|
||||||
|
book._active[oid] = status_msg
|
||||||
|
|
||||||
|
# fan-out-relay position msgs immediately by
|
||||||
|
# broadcasting updates on all client streams
|
||||||
|
await router.client_broadcast(status_msg)
|
||||||
|
|
||||||
|
# don't fall through
|
||||||
|
continue
|
||||||
|
|
||||||
|
# brokerd error
|
||||||
|
case {
|
||||||
|
'name': 'status',
|
||||||
|
'status': 'error',
|
||||||
|
}:
|
||||||
|
log.error(f'Broker error:\n{pformat(brokerd_msg)}')
|
||||||
|
# XXX: we presume the brokerd cancels its own order
|
||||||
|
|
||||||
|
# TOO FAST ``BrokerdStatus`` that arrives
|
||||||
|
# before the ``BrokerdAck``.
|
||||||
|
case {
|
||||||
|
# XXX: sometimes there is a race with the backend (like
|
||||||
|
# `ib` where the pending stauts will be related before
|
||||||
|
# the ack, in which case we just ignore the faster
|
||||||
|
# pending msg and wait for our expected ack to arrive
|
||||||
|
# later (i.e. the first block below should enter).
|
||||||
|
'name': 'status',
|
||||||
|
'status': status,
|
||||||
|
'reqid': reqid,
|
||||||
|
}:
|
||||||
|
oid = book._ems2brokerd_ids.inverse.get(reqid)
|
||||||
|
msg = f'Unhandled broker status for dialog {reqid}:\n'
|
||||||
|
if oid:
|
||||||
|
status_msg = book._active[oid]
|
||||||
|
msg += (
|
||||||
|
f'last status msg: {pformat(status_msg)}\n\n'
|
||||||
|
f'this msg:{pformat(brokerd_msg)}\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
log.warning(msg)
|
||||||
|
|
||||||
case _:
|
case _:
|
||||||
raise ValueError(f'Brokerd message {brokerd_msg} is invalid')
|
raise ValueError(f'Brokerd message {brokerd_msg} is invalid')
|
||||||
|
|
||||||
# retrieve existing live flow
|
# XXX: ugh sometimes we don't access it?
|
||||||
entry = book._ems_entries[oid]
|
if status_msg:
|
||||||
assert entry.oid == oid
|
del status_msg
|
||||||
|
|
||||||
old_reqid = entry.reqid
|
|
||||||
if old_reqid and old_reqid != reqid:
|
|
||||||
log.warning(
|
|
||||||
f'Brokerd order id change for {oid}:\n'
|
|
||||||
f'{old_reqid} -> {reqid}'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create and relay response status message
|
|
||||||
# to requesting EMS client
|
|
||||||
try:
|
|
||||||
ems_client_order_stream = router.dialogues[oid]
|
|
||||||
await ems_client_order_stream.send(
|
|
||||||
Status(
|
|
||||||
oid=oid,
|
|
||||||
resp=resp,
|
|
||||||
time_ns=time.time_ns(),
|
|
||||||
broker_reqid=reqid,
|
|
||||||
brokerd_msg=msg,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except KeyError:
|
|
||||||
log.error(
|
|
||||||
f'Received `brokerd` msg for unknown client with oid: {oid}')
|
|
||||||
|
|
||||||
# TODO: do we want this to keep things cleaned up?
|
# TODO: do we want this to keep things cleaned up?
|
||||||
# it might require a special status from brokerd to affirm the
|
# it might require a special status from brokerd to affirm the
|
||||||
|
@ -829,27 +898,36 @@ async def process_client_order_cmds(
|
||||||
async for cmd in client_order_stream:
|
async for cmd in client_order_stream:
|
||||||
log.info(f'Received order cmd:\n{pformat(cmd)}')
|
log.info(f'Received order cmd:\n{pformat(cmd)}')
|
||||||
|
|
||||||
oid = cmd['oid']
|
# CAWT DAMN we need struct support!
|
||||||
|
oid = str(cmd['oid'])
|
||||||
|
|
||||||
# register this stream as an active dialogue for this order id
|
# register this stream as an active dialogue for this order id
|
||||||
# such that translated message from the brokerd backend can be
|
# such that translated message from the brokerd backend can be
|
||||||
# routed (relayed) to **just** that client stream (and in theory
|
# routed (relayed) to **just** that client stream (and in theory
|
||||||
# others who are registered for such order affiliated msgs).
|
# others who are registered for such order affiliated msgs).
|
||||||
client_dialogues[oid] = client_order_stream
|
client_dialogues[oid] = client_order_stream
|
||||||
reqid = dark_book._ems2brokerd_ids.inverse.get(oid)
|
reqid = dark_book._ems2brokerd_ids.inverse.get(oid)
|
||||||
live_entry = dark_book._ems_entries.get(oid)
|
|
||||||
|
# any dark/live status which is current
|
||||||
|
status = dark_book._active.get(oid)
|
||||||
|
|
||||||
match cmd:
|
match cmd:
|
||||||
# existing live-broker order cancel
|
# existing live-broker order cancel
|
||||||
case {
|
case {
|
||||||
'action': 'cancel',
|
'action': 'cancel',
|
||||||
'oid': oid,
|
'oid': oid,
|
||||||
} if live_entry:
|
} if (
|
||||||
reqid = live_entry.reqid
|
(status := dark_book._active.get(oid))
|
||||||
msg = BrokerdCancel(
|
and status.resp in ('open', 'pending')
|
||||||
|
):
|
||||||
|
reqid = status.reqid
|
||||||
|
order = status.req
|
||||||
|
to_brokerd_msg = BrokerdCancel(
|
||||||
oid=oid,
|
oid=oid,
|
||||||
reqid=reqid,
|
reqid=reqid,
|
||||||
time_ns=time.time_ns(),
|
time_ns=time.time_ns(),
|
||||||
account=live_entry.account,
|
# account=live_entry.account,
|
||||||
|
account=order.account,
|
||||||
)
|
)
|
||||||
|
|
||||||
# NOTE: cancel response will be relayed back in messages
|
# NOTE: cancel response will be relayed back in messages
|
||||||
|
@ -859,39 +937,53 @@ async def process_client_order_cmds(
|
||||||
log.info(
|
log.info(
|
||||||
f'Submitting cancel for live order {reqid}'
|
f'Submitting cancel for live order {reqid}'
|
||||||
)
|
)
|
||||||
await brokerd_order_stream.send(msg)
|
await brokerd_order_stream.send(to_brokerd_msg)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# this might be a cancel for an order that hasn't been
|
# this might be a cancel for an order that hasn't been
|
||||||
# acked yet by a brokerd, so register a cancel for when
|
# acked yet by a brokerd, so register a cancel for when
|
||||||
# the order ack does show up later such that the brokerd
|
# the order ack does show up later such that the brokerd
|
||||||
# order request can be cancelled at that time.
|
# order request can be cancelled at that time.
|
||||||
dark_book._ems_entries[oid] = msg
|
# dark_book._ems_entries[oid] = msg
|
||||||
|
# special case for now..
|
||||||
|
status.req = to_brokerd_msg
|
||||||
|
|
||||||
# dark trigger cancel
|
# dark trigger cancel
|
||||||
case {
|
case {
|
||||||
'action': 'cancel',
|
'action': 'cancel',
|
||||||
'oid': oid,
|
'oid': oid,
|
||||||
} if not live_entry:
|
} if (
|
||||||
try:
|
status and status.resp == 'dark_open'
|
||||||
|
# or status and status.req
|
||||||
|
):
|
||||||
# remove from dark book clearing
|
# remove from dark book clearing
|
||||||
dark_book.orders[symbol].pop(oid, None)
|
entry = dark_book.orders[symbol].pop(oid, None)
|
||||||
|
if entry:
|
||||||
|
(
|
||||||
|
pred,
|
||||||
|
tickfilter,
|
||||||
|
cmd,
|
||||||
|
percent_away,
|
||||||
|
abs_diff_away
|
||||||
|
) = entry
|
||||||
|
|
||||||
# tell client side that we've cancelled the
|
# tell client side that we've cancelled the
|
||||||
# dark-trigger order
|
# dark-trigger order
|
||||||
await client_order_stream.send(
|
status.resp = 'canceled'
|
||||||
Status(
|
status.req = cmd
|
||||||
resp='dark_cancelled',
|
|
||||||
oid=oid,
|
await client_order_stream.send(status)
|
||||||
time_ns=time.time_ns(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# de-register this client dialogue
|
# de-register this client dialogue
|
||||||
router.dialogues.pop(oid)
|
router.dialogues.pop(oid)
|
||||||
|
dark_book._active.pop(oid)
|
||||||
|
|
||||||
except KeyError:
|
else:
|
||||||
log.exception(f'No dark order for {symbol}?')
|
log.exception(f'No dark order for {symbol}?')
|
||||||
|
|
||||||
|
# TODO: eventually we should be receiving
|
||||||
|
# this struct on the wire unpacked in a scoped protocol
|
||||||
|
# setup with ``tractor``.
|
||||||
|
|
||||||
# live order submission
|
# live order submission
|
||||||
case {
|
case {
|
||||||
'oid': oid,
|
'oid': oid,
|
||||||
|
@ -899,11 +991,9 @@ async def process_client_order_cmds(
|
||||||
'price': trigger_price,
|
'price': trigger_price,
|
||||||
'size': size,
|
'size': size,
|
||||||
'action': ('buy' | 'sell') as action,
|
'action': ('buy' | 'sell') as action,
|
||||||
'exec_mode': 'live',
|
'exec_mode': ('live' | 'paper'),
|
||||||
}:
|
}:
|
||||||
# TODO: eventually we should be receiving
|
# TODO: relay this order msg directly?
|
||||||
# this struct on the wire unpacked in a scoped protocol
|
|
||||||
# setup with ``tractor``.
|
|
||||||
req = Order(**cmd)
|
req = Order(**cmd)
|
||||||
broker = req.brokers[0]
|
broker = req.brokers[0]
|
||||||
|
|
||||||
|
@ -912,13 +1002,13 @@ async def process_client_order_cmds(
|
||||||
# aren't expectig their own name, but should they?
|
# aren't expectig their own name, but should they?
|
||||||
sym = fqsn.replace(f'.{broker}', '')
|
sym = fqsn.replace(f'.{broker}', '')
|
||||||
|
|
||||||
if live_entry is not None:
|
if status is not None:
|
||||||
# sanity check on emsd id
|
|
||||||
assert live_entry.oid == oid
|
|
||||||
reqid = live_entry.reqid
|
|
||||||
# if we already had a broker order id then
|
# if we already had a broker order id then
|
||||||
# this is likely an order update commmand.
|
# this is likely an order update commmand.
|
||||||
log.info(f"Modifying live {broker} order: {reqid}")
|
log.info(f"Modifying live {broker} order: {reqid}")
|
||||||
|
reqid = status.reqid
|
||||||
|
status.req = req
|
||||||
|
status.resp = 'pending'
|
||||||
|
|
||||||
msg = BrokerdOrder(
|
msg = BrokerdOrder(
|
||||||
oid=oid, # no ib support for oids...
|
oid=oid, # no ib support for oids...
|
||||||
|
@ -935,6 +1025,18 @@ async def process_client_order_cmds(
|
||||||
account=req.account,
|
account=req.account,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if status is None:
|
||||||
|
status = Status(
|
||||||
|
oid=oid,
|
||||||
|
reqid=reqid,
|
||||||
|
resp='pending',
|
||||||
|
time_ns=time.time_ns(),
|
||||||
|
brokerd_msg=msg,
|
||||||
|
req=req,
|
||||||
|
)
|
||||||
|
|
||||||
|
dark_book._active[oid] = status
|
||||||
|
|
||||||
# send request to backend
|
# send request to backend
|
||||||
# XXX: the trades data broker response loop
|
# XXX: the trades data broker response loop
|
||||||
# (``translate_and_relay_brokerd_events()`` above) will
|
# (``translate_and_relay_brokerd_events()`` above) will
|
||||||
|
@ -950,7 +1052,7 @@ async def process_client_order_cmds(
|
||||||
# client, before that ack, when the ack does arrive we
|
# client, before that ack, when the ack does arrive we
|
||||||
# immediately take the reqid from the broker and cancel
|
# immediately take the reqid from the broker and cancel
|
||||||
# that live order asap.
|
# that live order asap.
|
||||||
dark_book._ems_entries[oid] = msg
|
# dark_book._msgflows[oid].maps.insert(0, msg.to_dict())
|
||||||
|
|
||||||
# dark-order / alert submission
|
# dark-order / alert submission
|
||||||
case {
|
case {
|
||||||
|
@ -966,9 +1068,11 @@ async def process_client_order_cmds(
|
||||||
# submit order to local EMS book and scan loop,
|
# submit order to local EMS book and scan loop,
|
||||||
# effectively a local clearing engine, which
|
# effectively a local clearing engine, which
|
||||||
# scans for conditions and triggers matching executions
|
# scans for conditions and triggers matching executions
|
||||||
exec_mode in ('dark', 'paper')
|
exec_mode in ('dark',)
|
||||||
or action == 'alert'
|
or action == 'alert'
|
||||||
):
|
):
|
||||||
|
req = Order(**cmd)
|
||||||
|
|
||||||
# Auto-gen scanner predicate:
|
# Auto-gen scanner predicate:
|
||||||
# we automatically figure out what the alert check
|
# we automatically figure out what the alert check
|
||||||
# condition should be based on the current first
|
# condition should be based on the current first
|
||||||
|
@ -1015,23 +1119,25 @@ async def process_client_order_cmds(
|
||||||
)[oid] = (
|
)[oid] = (
|
||||||
pred,
|
pred,
|
||||||
tickfilter,
|
tickfilter,
|
||||||
cmd,
|
req,
|
||||||
percent_away,
|
percent_away,
|
||||||
abs_diff_away
|
abs_diff_away
|
||||||
)
|
)
|
||||||
resp = 'dark_submitted'
|
resp = 'dark_open'
|
||||||
|
|
||||||
# alerts have special msgs to distinguish
|
# alerts have special msgs to distinguish
|
||||||
if action == 'alert':
|
# if action == 'alert':
|
||||||
resp = 'alert_submitted'
|
# resp = 'open'
|
||||||
|
|
||||||
await client_order_stream.send(
|
status = Status(
|
||||||
Status(
|
|
||||||
resp=resp,
|
resp=resp,
|
||||||
oid=oid,
|
oid=oid,
|
||||||
time_ns=time.time_ns(),
|
time_ns=time.time_ns(),
|
||||||
|
req=req,
|
||||||
|
src='dark',
|
||||||
)
|
)
|
||||||
)
|
dark_book._active[oid] = status
|
||||||
|
await client_order_stream.send(status)
|
||||||
|
|
||||||
|
|
||||||
@tractor.context
|
@tractor.context
|
||||||
|
@ -1099,10 +1205,9 @@ async def _emsd_main(
|
||||||
):
|
):
|
||||||
|
|
||||||
# XXX: this should be initial price quote from target provider
|
# XXX: this should be initial price quote from target provider
|
||||||
first_quote = feed.first_quotes[fqsn]
|
first_quote: dict = feed.first_quotes[fqsn]
|
||||||
|
book: _DarkBook = _router.get_dark_book(broker)
|
||||||
book = _router.get_dark_book(broker)
|
book.lasts[fqsn]: float = first_quote['last']
|
||||||
book.lasts[fqsn] = first_quote['last']
|
|
||||||
|
|
||||||
# open a stream with the brokerd backend for order
|
# open a stream with the brokerd backend for order
|
||||||
# flow dialogue
|
# flow dialogue
|
||||||
|
@ -1129,12 +1234,25 @@ async def _emsd_main(
|
||||||
await ems_ctx.started((
|
await ems_ctx.started((
|
||||||
relay.positions,
|
relay.positions,
|
||||||
list(relay.accounts),
|
list(relay.accounts),
|
||||||
|
book._active,
|
||||||
))
|
))
|
||||||
|
|
||||||
# establish 2-way stream with requesting order-client and
|
# establish 2-way stream with requesting order-client and
|
||||||
# begin handling inbound order requests and updates
|
# begin handling inbound order requests and updates
|
||||||
async with ems_ctx.open_stream() as ems_client_order_stream:
|
async with ems_ctx.open_stream() as ems_client_order_stream:
|
||||||
|
|
||||||
|
# register the client side before startingn the
|
||||||
|
# brokerd-side relay task to ensure the client is
|
||||||
|
# delivered all exisiting open orders on startup.
|
||||||
|
_router.clients.add(ems_client_order_stream)
|
||||||
|
|
||||||
|
n.start_soon(
|
||||||
|
translate_and_relay_brokerd_events,
|
||||||
|
broker,
|
||||||
|
brokerd_stream,
|
||||||
|
_router,
|
||||||
|
)
|
||||||
|
|
||||||
# trigger scan and exec loop
|
# trigger scan and exec loop
|
||||||
n.start_soon(
|
n.start_soon(
|
||||||
clear_dark_triggers,
|
clear_dark_triggers,
|
||||||
|
@ -1149,7 +1267,6 @@ async def _emsd_main(
|
||||||
|
|
||||||
# start inbound (from attached client) order request processing
|
# start inbound (from attached client) order request processing
|
||||||
try:
|
try:
|
||||||
_router.clients.add(ems_client_order_stream)
|
|
||||||
|
|
||||||
# main entrypoint, run here until cancelled.
|
# main entrypoint, run here until cancelled.
|
||||||
await process_client_order_cmds(
|
await process_client_order_cmds(
|
||||||
|
|
|
@ -18,24 +18,92 @@
|
||||||
Clearing sub-system message and protocols.
|
Clearing sub-system message and protocols.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
from typing import Optional, Union
|
# from collections import (
|
||||||
|
# ChainMap,
|
||||||
|
# deque,
|
||||||
|
# )
|
||||||
|
from typing import (
|
||||||
|
Optional,
|
||||||
|
Literal,
|
||||||
|
)
|
||||||
|
|
||||||
from ..data._source import Symbol
|
from ..data._source import Symbol
|
||||||
from ..data.types import Struct
|
from ..data.types import Struct
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: a composite for tracking msg flow on 2-legged
|
||||||
|
# dialogs.
|
||||||
|
# class Dialog(ChainMap):
|
||||||
|
# '''
|
||||||
|
# Msg collection abstraction to easily track the state changes of
|
||||||
|
# a msg flow in one high level, query-able and immutable construct.
|
||||||
|
|
||||||
|
# The main use case is to query data from a (long-running)
|
||||||
|
# msg-transaction-sequence
|
||||||
|
|
||||||
|
|
||||||
|
# '''
|
||||||
|
# def update(
|
||||||
|
# self,
|
||||||
|
# msg,
|
||||||
|
# ) -> None:
|
||||||
|
# self.maps.insert(0, msg.to_dict())
|
||||||
|
|
||||||
|
# def flatten(self) -> dict:
|
||||||
|
# return dict(self)
|
||||||
|
|
||||||
|
|
||||||
# TODO: ``msgspec`` stuff worth paying attention to:
|
# TODO: ``msgspec`` stuff worth paying attention to:
|
||||||
# - schema evolution: https://jcristharif.com/msgspec/usage.html#schema-evolution
|
# - schema evolution:
|
||||||
|
# https://jcristharif.com/msgspec/usage.html#schema-evolution
|
||||||
|
# - for eg. ``BrokerdStatus``, instead just have separate messages?
|
||||||
# - use literals for a common msg determined by diff keys?
|
# - use literals for a common msg determined by diff keys?
|
||||||
# - https://jcristharif.com/msgspec/usage.html#literal
|
# - https://jcristharif.com/msgspec/usage.html#literal
|
||||||
# - for eg. ``BrokerdStatus``, instead just have separate messages?
|
|
||||||
|
|
||||||
# --------------
|
# --------------
|
||||||
# Client -> emsd
|
# Client -> emsd
|
||||||
# --------------
|
# --------------
|
||||||
|
|
||||||
|
class Order(Struct):
|
||||||
|
|
||||||
|
# TODO: ideally we can combine these 2 fields into
|
||||||
|
# 1 and just use the size polarity to determine a buy/sell.
|
||||||
|
# i would like to see this become more like
|
||||||
|
# https://jcristharif.com/msgspec/usage.html#literal
|
||||||
|
# action: Literal[
|
||||||
|
# 'live',
|
||||||
|
# 'dark',
|
||||||
|
# 'alert',
|
||||||
|
# ]
|
||||||
|
|
||||||
|
action: Literal[
|
||||||
|
'buy',
|
||||||
|
'sell',
|
||||||
|
'alert',
|
||||||
|
]
|
||||||
|
# determines whether the create execution
|
||||||
|
# will be submitted to the ems or directly to
|
||||||
|
# the backend broker
|
||||||
|
exec_mode: Literal[
|
||||||
|
'dark',
|
||||||
|
'live',
|
||||||
|
# 'paper', no right?
|
||||||
|
]
|
||||||
|
|
||||||
|
# internal ``emdsd`` unique "order id"
|
||||||
|
oid: str # uuid4
|
||||||
|
symbol: str | Symbol
|
||||||
|
account: str # should we set a default as '' ?
|
||||||
|
|
||||||
|
price: float
|
||||||
|
size: float # -ve is "sell", +ve is "buy"
|
||||||
|
|
||||||
|
brokers: Optional[list[str]] = []
|
||||||
|
|
||||||
|
|
||||||
class Cancel(Struct):
|
class Cancel(Struct):
|
||||||
'''Cancel msg for removing a dark (ems triggered) or
|
'''
|
||||||
|
Cancel msg for removing a dark (ems triggered) or
|
||||||
broker-submitted (live) trigger/order.
|
broker-submitted (live) trigger/order.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
@ -44,32 +112,6 @@ class Cancel(Struct):
|
||||||
symbol: str
|
symbol: str
|
||||||
|
|
||||||
|
|
||||||
class Order(Struct):
|
|
||||||
|
|
||||||
# TODO: use ``msgspec.Literal``
|
|
||||||
# https://jcristharif.com/msgspec/usage.html#literal
|
|
||||||
action: str # {'buy', 'sell', 'alert'}
|
|
||||||
# internal ``emdsd`` unique "order id"
|
|
||||||
oid: str # uuid4
|
|
||||||
symbol: Union[str, Symbol]
|
|
||||||
account: str # should we set a default as '' ?
|
|
||||||
|
|
||||||
price: float
|
|
||||||
# TODO: could we drop the ``.action`` field above and instead just
|
|
||||||
# use +/- values here? Would make the msg smaller at the sake of a
|
|
||||||
# teensie fp precision?
|
|
||||||
size: float
|
|
||||||
brokers: list[str]
|
|
||||||
|
|
||||||
# Assigned once initial ack is received
|
|
||||||
# ack_time_ns: Optional[int] = None
|
|
||||||
|
|
||||||
# determines whether the create execution
|
|
||||||
# will be submitted to the ems or directly to
|
|
||||||
# the backend broker
|
|
||||||
exec_mode: str # {'dark', 'live', 'paper'}
|
|
||||||
|
|
||||||
|
|
||||||
# --------------
|
# --------------
|
||||||
# Client <- emsd
|
# Client <- emsd
|
||||||
# --------------
|
# --------------
|
||||||
|
@ -79,37 +121,39 @@ class Order(Struct):
|
||||||
class Status(Struct):
|
class Status(Struct):
|
||||||
|
|
||||||
name: str = 'status'
|
name: str = 'status'
|
||||||
oid: str # uuid4
|
|
||||||
time_ns: int
|
time_ns: int
|
||||||
|
oid: str # uuid4 ems-order dialog id
|
||||||
|
|
||||||
# {
|
resp: Literal[
|
||||||
# 'dark_submitted',
|
'pending', # acked by broker but not yet open
|
||||||
# 'dark_cancelled',
|
'open',
|
||||||
# 'dark_triggered',
|
'dark_open', # dark/algo triggered order is open in ems clearing loop
|
||||||
|
'triggered', # above triggered order sent to brokerd, or an alert closed
|
||||||
# 'broker_submitted',
|
'closed', # fully cleared all size/units
|
||||||
# 'broker_cancelled',
|
'fill', # partial execution
|
||||||
# 'broker_executed',
|
'canceled',
|
||||||
# 'broker_filled',
|
'error',
|
||||||
# 'broker_errored',
|
]
|
||||||
|
|
||||||
# 'alert_submitted',
|
|
||||||
# 'alert_triggered',
|
|
||||||
|
|
||||||
# }
|
|
||||||
resp: str # "response", see above
|
|
||||||
|
|
||||||
# trigger info
|
|
||||||
trigger_price: Optional[float] = None
|
|
||||||
# price: float
|
|
||||||
|
|
||||||
# broker: Optional[str] = None
|
|
||||||
|
|
||||||
# this maps normally to the ``BrokerdOrder.reqid`` below, an id
|
# this maps normally to the ``BrokerdOrder.reqid`` below, an id
|
||||||
# normally allocated internally by the backend broker routing system
|
# normally allocated internally by the backend broker routing system
|
||||||
broker_reqid: Optional[Union[int, str]] = None
|
reqid: Optional[int | str] = None
|
||||||
|
|
||||||
# for relaying backend msg data "through" the ems layer
|
# the (last) source order/request msg if provided
|
||||||
|
# (eg. the Order/Cancel which causes this msg) and
|
||||||
|
# acts as a back-reference to the corresponding
|
||||||
|
# request message which was the source of this msg.
|
||||||
|
req: Optional[Order | Cancel] = None
|
||||||
|
|
||||||
|
# XXX: better design/name here?
|
||||||
|
# flag that can be set to indicate a message for an order
|
||||||
|
# event that wasn't originated by piker's emsd (eg. some external
|
||||||
|
# trading system which does it's own order control but that you
|
||||||
|
# might want to "track" using piker UIs/systems).
|
||||||
|
src: Optional[str] = None
|
||||||
|
|
||||||
|
# for relaying a boxed brokerd-dialog-side msg data "through" the
|
||||||
|
# ems layer to clients.
|
||||||
brokerd_msg: dict = {}
|
brokerd_msg: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
@ -131,25 +175,28 @@ class BrokerdCancel(Struct):
|
||||||
# for setting a unique order id then this value will be relayed back
|
# for setting a unique order id then this value will be relayed back
|
||||||
# on the emsd order request stream as the ``BrokerdOrderAck.reqid``
|
# on the emsd order request stream as the ``BrokerdOrderAck.reqid``
|
||||||
# field
|
# field
|
||||||
reqid: Optional[Union[int, str]] = None
|
reqid: Optional[int | str] = None
|
||||||
|
|
||||||
|
|
||||||
class BrokerdOrder(Struct):
|
class BrokerdOrder(Struct):
|
||||||
|
|
||||||
action: str # {buy, sell}
|
|
||||||
oid: str
|
oid: str
|
||||||
account: str
|
account: str
|
||||||
time_ns: int
|
time_ns: int
|
||||||
|
|
||||||
|
# TODO: if we instead rely on a +ve/-ve size to determine
|
||||||
|
# the action we more or less don't need this field right?
|
||||||
|
action: str = '' # {buy, sell}
|
||||||
|
|
||||||
# "broker request id": broker specific/internal order id if this is
|
# "broker request id": broker specific/internal order id if this is
|
||||||
# None, creates a new order otherwise if the id is valid the backend
|
# None, creates a new order otherwise if the id is valid the backend
|
||||||
# api must modify the existing matching order. If the broker allows
|
# api must modify the existing matching order. If the broker allows
|
||||||
# for setting a unique order id then this value will be relayed back
|
# for setting a unique order id then this value will be relayed back
|
||||||
# on the emsd order request stream as the ``BrokerdOrderAck.reqid``
|
# on the emsd order request stream as the ``BrokerdOrderAck.reqid``
|
||||||
# field
|
# field
|
||||||
reqid: Optional[Union[int, str]] = None
|
reqid: Optional[int | str] = None
|
||||||
|
|
||||||
symbol: str # symbol.<providername> ?
|
symbol: str # fqsn
|
||||||
price: float
|
price: float
|
||||||
size: float
|
size: float
|
||||||
|
|
||||||
|
@ -170,7 +217,7 @@ class BrokerdOrderAck(Struct):
|
||||||
name: str = 'ack'
|
name: str = 'ack'
|
||||||
|
|
||||||
# defined and provided by backend
|
# defined and provided by backend
|
||||||
reqid: Union[int, str]
|
reqid: int | str
|
||||||
|
|
||||||
# emsd id originally sent in matching request msg
|
# emsd id originally sent in matching request msg
|
||||||
oid: str
|
oid: str
|
||||||
|
@ -180,30 +227,22 @@ class BrokerdOrderAck(Struct):
|
||||||
class BrokerdStatus(Struct):
|
class BrokerdStatus(Struct):
|
||||||
|
|
||||||
name: str = 'status'
|
name: str = 'status'
|
||||||
reqid: Union[int, str]
|
reqid: int | str
|
||||||
time_ns: int
|
time_ns: int
|
||||||
|
status: Literal[
|
||||||
|
'open',
|
||||||
|
'canceled',
|
||||||
|
'fill',
|
||||||
|
'pending',
|
||||||
|
'error',
|
||||||
|
]
|
||||||
|
|
||||||
# XXX: should be best effort set for every update
|
account: str
|
||||||
account: str = ''
|
|
||||||
|
|
||||||
# TODO: instead (ack, pending, open, fill, clos(ed), cancelled)
|
|
||||||
# {
|
|
||||||
# 'submitted',
|
|
||||||
# 'cancelled',
|
|
||||||
# 'filled',
|
|
||||||
# }
|
|
||||||
status: str
|
|
||||||
|
|
||||||
filled: float = 0.0
|
filled: float = 0.0
|
||||||
reason: str = ''
|
reason: str = ''
|
||||||
remaining: float = 0.0
|
remaining: float = 0.0
|
||||||
|
|
||||||
# XXX: better design/name here?
|
# external: bool = False
|
||||||
# flag that can be set to indicate a message for an order
|
|
||||||
# event that wasn't originated by piker's emsd (eg. some external
|
|
||||||
# trading system which does it's own order control but that you
|
|
||||||
# might want to "track" using piker UIs/systems).
|
|
||||||
external: bool = False
|
|
||||||
|
|
||||||
# XXX: not required schema as of yet
|
# XXX: not required schema as of yet
|
||||||
broker_details: dict = {
|
broker_details: dict = {
|
||||||
|
@ -218,7 +257,7 @@ class BrokerdFill(Struct):
|
||||||
|
|
||||||
'''
|
'''
|
||||||
name: str = 'fill'
|
name: str = 'fill'
|
||||||
reqid: Union[int, str]
|
reqid: int | str
|
||||||
time_ns: int
|
time_ns: int
|
||||||
|
|
||||||
# order exeuction related
|
# order exeuction related
|
||||||
|
@ -248,7 +287,7 @@ class BrokerdError(Struct):
|
||||||
|
|
||||||
# if no brokerd order request was actually submitted (eg. we errored
|
# if no brokerd order request was actually submitted (eg. we errored
|
||||||
# at the ``pikerd`` layer) then there will be ``reqid`` allocated.
|
# at the ``pikerd`` layer) then there will be ``reqid`` allocated.
|
||||||
reqid: Optional[Union[int, str]] = None
|
reqid: Optional[int | str] = None
|
||||||
|
|
||||||
symbol: str
|
symbol: str
|
||||||
reason: str
|
reason: str
|
||||||
|
|
|
@ -33,10 +33,10 @@ from bidict import bidict
|
||||||
import pendulum
|
import pendulum
|
||||||
import trio
|
import trio
|
||||||
import tractor
|
import tractor
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from .. import data
|
from .. import data
|
||||||
from ..data._source import Symbol
|
from ..data._source import Symbol
|
||||||
|
from ..data.types import Struct
|
||||||
from ..pp import (
|
from ..pp import (
|
||||||
Position,
|
Position,
|
||||||
Transaction,
|
Transaction,
|
||||||
|
@ -45,16 +45,20 @@ from ..data._normalize import iterticks
|
||||||
from ..data._source import unpack_fqsn
|
from ..data._source import unpack_fqsn
|
||||||
from ..log import get_logger
|
from ..log import get_logger
|
||||||
from ._messages import (
|
from ._messages import (
|
||||||
BrokerdCancel, BrokerdOrder, BrokerdOrderAck, BrokerdStatus,
|
BrokerdCancel,
|
||||||
BrokerdFill, BrokerdPosition, BrokerdError
|
BrokerdOrder,
|
||||||
|
BrokerdOrderAck,
|
||||||
|
BrokerdStatus,
|
||||||
|
BrokerdFill,
|
||||||
|
BrokerdPosition,
|
||||||
|
BrokerdError,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
class PaperBoi(Struct):
|
||||||
class PaperBoi:
|
|
||||||
"""
|
"""
|
||||||
Emulates a broker order client providing the same API and
|
Emulates a broker order client providing the same API and
|
||||||
delivering an order-event response stream but with methods for
|
delivering an order-event response stream but with methods for
|
||||||
|
@ -68,8 +72,8 @@ class PaperBoi:
|
||||||
|
|
||||||
# map of paper "live" orders which be used
|
# map of paper "live" orders which be used
|
||||||
# to simulate fills based on paper engine settings
|
# to simulate fills based on paper engine settings
|
||||||
_buys: bidict
|
_buys: dict
|
||||||
_sells: bidict
|
_sells: dict
|
||||||
_reqids: bidict
|
_reqids: bidict
|
||||||
_positions: dict[str, Position]
|
_positions: dict[str, Position]
|
||||||
_trade_ledger: dict[str, Any]
|
_trade_ledger: dict[str, Any]
|
||||||
|
@ -94,6 +98,10 @@ class PaperBoi:
|
||||||
'''
|
'''
|
||||||
is_modify: bool = False
|
is_modify: bool = False
|
||||||
|
|
||||||
|
if action == 'alert':
|
||||||
|
# bypass all fill simulation
|
||||||
|
return reqid
|
||||||
|
|
||||||
entry = self._reqids.get(reqid)
|
entry = self._reqids.get(reqid)
|
||||||
if entry:
|
if entry:
|
||||||
# order is already existing, this is a modify
|
# order is already existing, this is a modify
|
||||||
|
@ -104,10 +112,6 @@ class PaperBoi:
|
||||||
# register order internally
|
# register order internally
|
||||||
self._reqids[reqid] = (oid, symbol, action, price)
|
self._reqids[reqid] = (oid, symbol, action, price)
|
||||||
|
|
||||||
if action == 'alert':
|
|
||||||
# bypass all fill simulation
|
|
||||||
return reqid
|
|
||||||
|
|
||||||
# TODO: net latency model
|
# TODO: net latency model
|
||||||
# we checkpoint here quickly particulalry
|
# we checkpoint here quickly particulalry
|
||||||
# for dark orders since we want the dark_executed
|
# for dark orders since we want the dark_executed
|
||||||
|
@ -119,7 +123,9 @@ class PaperBoi:
|
||||||
size = -size
|
size = -size
|
||||||
|
|
||||||
msg = BrokerdStatus(
|
msg = BrokerdStatus(
|
||||||
status='submitted',
|
status='open',
|
||||||
|
# account=f'paper_{self.broker}',
|
||||||
|
account='paper',
|
||||||
reqid=reqid,
|
reqid=reqid,
|
||||||
time_ns=time.time_ns(),
|
time_ns=time.time_ns(),
|
||||||
filled=0.0,
|
filled=0.0,
|
||||||
|
@ -136,7 +142,14 @@ class PaperBoi:
|
||||||
) or (
|
) or (
|
||||||
action == 'sell' and (clear_price := self.last_bid[0]) >= price
|
action == 'sell' and (clear_price := self.last_bid[0]) >= price
|
||||||
):
|
):
|
||||||
await self.fake_fill(symbol, clear_price, size, action, reqid, oid)
|
await self.fake_fill(
|
||||||
|
symbol,
|
||||||
|
clear_price,
|
||||||
|
size,
|
||||||
|
action,
|
||||||
|
reqid,
|
||||||
|
oid,
|
||||||
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# register this submissions as a paper live order
|
# register this submissions as a paper live order
|
||||||
|
@ -178,7 +191,9 @@ class PaperBoi:
|
||||||
await trio.sleep(0.05)
|
await trio.sleep(0.05)
|
||||||
|
|
||||||
msg = BrokerdStatus(
|
msg = BrokerdStatus(
|
||||||
status='cancelled',
|
status='canceled',
|
||||||
|
# account=f'paper_{self.broker}',
|
||||||
|
account='paper',
|
||||||
reqid=reqid,
|
reqid=reqid,
|
||||||
time_ns=time.time_ns(),
|
time_ns=time.time_ns(),
|
||||||
broker_details={'name': 'paperboi'},
|
broker_details={'name': 'paperboi'},
|
||||||
|
@ -230,25 +245,14 @@ class PaperBoi:
|
||||||
self._trade_ledger.update(fill_msg.to_dict())
|
self._trade_ledger.update(fill_msg.to_dict())
|
||||||
|
|
||||||
if order_complete:
|
if order_complete:
|
||||||
|
|
||||||
msg = BrokerdStatus(
|
msg = BrokerdStatus(
|
||||||
|
|
||||||
reqid=reqid,
|
reqid=reqid,
|
||||||
time_ns=time.time_ns(),
|
time_ns=time.time_ns(),
|
||||||
|
# account=f'paper_{self.broker}',
|
||||||
status='filled',
|
account='paper',
|
||||||
|
status='closed',
|
||||||
filled=size,
|
filled=size,
|
||||||
remaining=0 if order_complete else remaining,
|
remaining=0 if order_complete else remaining,
|
||||||
|
|
||||||
broker_details={
|
|
||||||
'paper_info': {
|
|
||||||
'oid': oid,
|
|
||||||
},
|
|
||||||
'action': action,
|
|
||||||
'size': size,
|
|
||||||
'price': price,
|
|
||||||
'name': self.broker,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
await self.ems_trades_stream.send(msg)
|
await self.ems_trades_stream.send(msg)
|
||||||
|
|
||||||
|
@ -257,7 +261,10 @@ class PaperBoi:
|
||||||
pp = self._positions.setdefault(
|
pp = self._positions.setdefault(
|
||||||
token,
|
token,
|
||||||
Position(
|
Position(
|
||||||
Symbol(key=symbol),
|
Symbol(
|
||||||
|
key=symbol,
|
||||||
|
broker_info={self.broker: {}},
|
||||||
|
),
|
||||||
size=size,
|
size=size,
|
||||||
ppu=price,
|
ppu=price,
|
||||||
bsuid=symbol,
|
bsuid=symbol,
|
||||||
|
@ -390,56 +397,41 @@ async def handle_order_requests(
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
# order_request: dict
|
request_msg: dict
|
||||||
async for request_msg in ems_order_stream:
|
async for request_msg in ems_order_stream:
|
||||||
|
match request_msg:
|
||||||
action = request_msg['action']
|
case {'action': ('buy' | 'sell')}:
|
||||||
|
order = BrokerdOrder(**request_msg)
|
||||||
if action in {'buy', 'sell'}:
|
account = order.account
|
||||||
|
|
||||||
account = request_msg['account']
|
|
||||||
if account != 'paper':
|
if account != 'paper':
|
||||||
log.error(
|
log.error(
|
||||||
'This is a paper account,'
|
'This is a paper account,'
|
||||||
' only a `paper` selection is valid'
|
' only a `paper` selection is valid'
|
||||||
)
|
)
|
||||||
await ems_order_stream.send(BrokerdError(
|
await ems_order_stream.send(BrokerdError(
|
||||||
oid=request_msg['oid'],
|
oid=order.oid,
|
||||||
symbol=request_msg['symbol'],
|
symbol=order.symbol,
|
||||||
reason=f'Paper only. No account found: `{account}` ?',
|
reason=f'Paper only. No account found: `{account}` ?',
|
||||||
))
|
))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# validate
|
reqid = order.reqid or str(uuid.uuid4())
|
||||||
order = BrokerdOrder(**request_msg)
|
|
||||||
|
|
||||||
if order.reqid is None:
|
|
||||||
reqid = str(uuid.uuid4())
|
|
||||||
else:
|
|
||||||
reqid = order.reqid
|
|
||||||
|
|
||||||
# deliver ack that order has been submitted to broker routing
|
# deliver ack that order has been submitted to broker routing
|
||||||
await ems_order_stream.send(
|
await ems_order_stream.send(
|
||||||
BrokerdOrderAck(
|
BrokerdOrderAck(
|
||||||
|
|
||||||
# ems order request id
|
|
||||||
oid=order.oid,
|
oid=order.oid,
|
||||||
|
|
||||||
# broker specific request id
|
|
||||||
reqid=reqid,
|
reqid=reqid,
|
||||||
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# call our client api to submit the order
|
# call our client api to submit the order
|
||||||
reqid = await client.submit_limit(
|
reqid = await client.submit_limit(
|
||||||
|
|
||||||
oid=order.oid,
|
oid=order.oid,
|
||||||
symbol=order.symbol,
|
symbol=order.symbol,
|
||||||
price=order.price,
|
price=order.price,
|
||||||
action=order.action,
|
action=order.action,
|
||||||
size=order.size,
|
size=order.size,
|
||||||
|
|
||||||
# XXX: by default 0 tells ``ib_insync`` methods that
|
# XXX: by default 0 tells ``ib_insync`` methods that
|
||||||
# there is no existing order so ask the client to create
|
# there is no existing order so ask the client to create
|
||||||
# a new one (which it seems to do by allocating an int
|
# a new one (which it seems to do by allocating an int
|
||||||
|
@ -447,17 +439,35 @@ async def handle_order_requests(
|
||||||
reqid=reqid,
|
reqid=reqid,
|
||||||
)
|
)
|
||||||
|
|
||||||
elif action == 'cancel':
|
# elif action == 'cancel':
|
||||||
|
case {'action': 'cancel'}:
|
||||||
msg = BrokerdCancel(**request_msg)
|
msg = BrokerdCancel(**request_msg)
|
||||||
|
|
||||||
await client.submit_cancel(
|
await client.submit_cancel(
|
||||||
reqid=msg.reqid
|
reqid=msg.reqid
|
||||||
)
|
)
|
||||||
|
|
||||||
else:
|
case _:
|
||||||
log.error(f'Unknown order command: {request_msg}')
|
log.error(f'Unknown order command: {request_msg}')
|
||||||
|
|
||||||
|
|
||||||
|
_reqids: bidict[str, tuple] = {}
|
||||||
|
_buys: dict[
|
||||||
|
str,
|
||||||
|
dict[
|
||||||
|
tuple[str, float],
|
||||||
|
tuple[float, str, str],
|
||||||
|
]
|
||||||
|
] = {}
|
||||||
|
_sells: dict[
|
||||||
|
str,
|
||||||
|
dict[
|
||||||
|
tuple[str, float],
|
||||||
|
tuple[float, str, str],
|
||||||
|
]
|
||||||
|
] = {}
|
||||||
|
_positions: dict[str, Position] = {}
|
||||||
|
|
||||||
|
|
||||||
@tractor.context
|
@tractor.context
|
||||||
async def trades_dialogue(
|
async def trades_dialogue(
|
||||||
|
|
||||||
|
@ -467,6 +477,7 @@ async def trades_dialogue(
|
||||||
loglevel: str = None,
|
loglevel: str = None,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
tractor.log.get_console_log(loglevel)
|
tractor.log.get_console_log(loglevel)
|
||||||
|
|
||||||
async with (
|
async with (
|
||||||
|
@ -476,10 +487,22 @@ async def trades_dialogue(
|
||||||
) as feed,
|
) as feed,
|
||||||
|
|
||||||
):
|
):
|
||||||
|
pp_msgs: list[BrokerdPosition] = []
|
||||||
|
pos: Position
|
||||||
|
token: str # f'{symbol}.{self.broker}'
|
||||||
|
for token, pos in _positions.items():
|
||||||
|
pp_msgs.append(BrokerdPosition(
|
||||||
|
broker=broker,
|
||||||
|
account='paper',
|
||||||
|
symbol=pos.symbol.front_fqsn(),
|
||||||
|
size=pos.size,
|
||||||
|
avg_price=pos.ppu,
|
||||||
|
))
|
||||||
|
|
||||||
# TODO: load paper positions per broker from .toml config file
|
# TODO: load paper positions per broker from .toml config file
|
||||||
# and pass as symbol to position data mapping: ``dict[str, dict]``
|
# and pass as symbol to position data mapping: ``dict[str, dict]``
|
||||||
# await ctx.started(all_positions)
|
# await ctx.started(all_positions)
|
||||||
await ctx.started(({}, ['paper']))
|
await ctx.started((pp_msgs, ['paper']))
|
||||||
|
|
||||||
async with (
|
async with (
|
||||||
ctx.open_stream() as ems_stream,
|
ctx.open_stream() as ems_stream,
|
||||||
|
@ -488,13 +511,13 @@ async def trades_dialogue(
|
||||||
client = PaperBoi(
|
client = PaperBoi(
|
||||||
broker,
|
broker,
|
||||||
ems_stream,
|
ems_stream,
|
||||||
_buys={},
|
_buys=_buys,
|
||||||
_sells={},
|
_sells=_sells,
|
||||||
|
|
||||||
_reqids={},
|
_reqids=_reqids,
|
||||||
|
|
||||||
# TODO: load paper positions from ``positions.toml``
|
# TODO: load paper positions from ``positions.toml``
|
||||||
_positions={},
|
_positions=_positions,
|
||||||
|
|
||||||
# TODO: load postions from ledger file
|
# TODO: load postions from ledger file
|
||||||
_trade_ledger={},
|
_trade_ledger={},
|
||||||
|
|
|
@ -18,6 +18,7 @@
|
||||||
Built-in (extension) types.
|
Built-in (extension) types.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
import sys
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from pprint import pformat
|
from pprint import pformat
|
||||||
|
|
||||||
|
@ -42,8 +43,16 @@ class Struct(
|
||||||
}
|
}
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
|
# only turn on pprint when we detect a python REPL
|
||||||
|
# at runtime B)
|
||||||
|
if (
|
||||||
|
hasattr(sys, 'ps1')
|
||||||
|
# TODO: check if we're in pdb
|
||||||
|
):
|
||||||
return f'Struct({pformat(self.to_dict())})'
|
return f'Struct({pformat(self.to_dict())})'
|
||||||
|
|
||||||
|
return super().__repr__()
|
||||||
|
|
||||||
def copy(
|
def copy(
|
||||||
self,
|
self,
|
||||||
update: Optional[dict] = None,
|
update: Optional[dict] = None,
|
||||||
|
|
|
@ -140,9 +140,9 @@ class LineEditor:
|
||||||
|
|
||||||
) -> LevelLine:
|
) -> LevelLine:
|
||||||
|
|
||||||
staged_line = self._active_staged_line
|
# staged_line = self._active_staged_line
|
||||||
if not staged_line:
|
# if not staged_line:
|
||||||
raise RuntimeError("No line is currently staged!?")
|
# raise RuntimeError("No line is currently staged!?")
|
||||||
|
|
||||||
# for now, until submission reponse arrives
|
# for now, until submission reponse arrives
|
||||||
line.hide_labels()
|
line.hide_labels()
|
||||||
|
|
|
@ -221,6 +221,7 @@ async def handle_viewmode_kb_inputs(
|
||||||
# TODO: show pp config mini-params in status bar widget
|
# TODO: show pp config mini-params in status bar widget
|
||||||
# mode.pp_config.show()
|
# mode.pp_config.show()
|
||||||
|
|
||||||
|
trigger_type: str = 'dark'
|
||||||
if (
|
if (
|
||||||
# 's' for "submit" to activate "live" order
|
# 's' for "submit" to activate "live" order
|
||||||
Qt.Key_S in pressed or
|
Qt.Key_S in pressed or
|
||||||
|
@ -228,9 +229,6 @@ async def handle_viewmode_kb_inputs(
|
||||||
):
|
):
|
||||||
trigger_type: str = 'live'
|
trigger_type: str = 'live'
|
||||||
|
|
||||||
else:
|
|
||||||
trigger_type: str = 'dark'
|
|
||||||
|
|
||||||
# order mode trigger "actions"
|
# order mode trigger "actions"
|
||||||
if Qt.Key_D in pressed: # for "damp eet"
|
if Qt.Key_D in pressed: # for "damp eet"
|
||||||
action = 'sell'
|
action = 'sell'
|
||||||
|
@ -397,8 +395,11 @@ class ChartView(ViewBox):
|
||||||
|
|
||||||
'''
|
'''
|
||||||
if self._ic is None:
|
if self._ic is None:
|
||||||
|
try:
|
||||||
self.chart.pause_all_feeds()
|
self.chart.pause_all_feeds()
|
||||||
self._ic = trio.Event()
|
self._ic = trio.Event()
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
def signal_ic(
|
def signal_ic(
|
||||||
self,
|
self,
|
||||||
|
@ -411,9 +412,12 @@ class ChartView(ViewBox):
|
||||||
|
|
||||||
'''
|
'''
|
||||||
if self._ic:
|
if self._ic:
|
||||||
|
try:
|
||||||
self._ic.set()
|
self._ic.set()
|
||||||
self._ic = None
|
self._ic = None
|
||||||
self.chart.resume_all_feeds()
|
self.chart.resume_all_feeds()
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def open_async_input_handler(
|
async def open_async_input_handler(
|
||||||
|
@ -669,7 +673,10 @@ class ChartView(ViewBox):
|
||||||
# XXX: WHY
|
# XXX: WHY
|
||||||
ev.accept()
|
ev.accept()
|
||||||
|
|
||||||
|
try:
|
||||||
self.start_ic()
|
self.start_ic()
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
# if self._ic is None:
|
# if self._ic is None:
|
||||||
# self.chart.pause_all_feeds()
|
# self.chart.pause_all_feeds()
|
||||||
# self._ic = trio.Event()
|
# self._ic = trio.Event()
|
||||||
|
|
|
@ -421,6 +421,10 @@ class LevelLine(pg.InfiniteLine):
|
||||||
|
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
@property
|
||||||
|
def marker(self) -> LevelMarker:
|
||||||
|
return self._marker
|
||||||
|
|
||||||
def hoverEvent(self, ev):
|
def hoverEvent(self, ev):
|
||||||
'''
|
'''
|
||||||
Mouse hover callback.
|
Mouse hover callback.
|
||||||
|
|
|
@ -49,16 +49,21 @@ from ._position import (
|
||||||
SettingsPane,
|
SettingsPane,
|
||||||
)
|
)
|
||||||
from ._forms import FieldsForm
|
from ._forms import FieldsForm
|
||||||
# from ._label import FormatLabel
|
|
||||||
from ._window import MultiStatus
|
from ._window import MultiStatus
|
||||||
from ..clearing._messages import Order, BrokerdPosition
|
from ..clearing._messages import (
|
||||||
|
Order,
|
||||||
|
Status,
|
||||||
|
# BrokerdOrder,
|
||||||
|
# BrokerdStatus,
|
||||||
|
BrokerdPosition,
|
||||||
|
)
|
||||||
from ._forms import open_form_input_handling
|
from ._forms import open_form_input_handling
|
||||||
|
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class OrderDialog(Struct):
|
class Dialog(Struct):
|
||||||
'''
|
'''
|
||||||
Trade dialogue meta-data describing the lifetime
|
Trade dialogue meta-data describing the lifetime
|
||||||
of an order submission to ``emsd`` from a chart.
|
of an order submission to ``emsd`` from a chart.
|
||||||
|
@ -74,38 +79,6 @@ class OrderDialog(Struct):
|
||||||
fills: Dict[str, Any] = {}
|
fills: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
def on_level_change_update_next_order_info(
|
|
||||||
|
|
||||||
level: float,
|
|
||||||
|
|
||||||
# these are all ``partial``-ed in at callback assignment time.
|
|
||||||
line: LevelLine,
|
|
||||||
order: Order,
|
|
||||||
tracker: PositionTracker,
|
|
||||||
|
|
||||||
) -> None:
|
|
||||||
'''
|
|
||||||
A callback applied for each level change to the line
|
|
||||||
which will recompute the order size based on allocator
|
|
||||||
settings. this is assigned inside
|
|
||||||
``OrderMode.line_from_order()``
|
|
||||||
|
|
||||||
'''
|
|
||||||
# NOTE: the ``Order.account`` is set at order stage time
|
|
||||||
# inside ``OrderMode.line_from_order()``.
|
|
||||||
order_info = tracker.alloc.next_order_info(
|
|
||||||
startup_pp=tracker.startup_pp,
|
|
||||||
live_pp=tracker.live_pp,
|
|
||||||
price=level,
|
|
||||||
action=order.action,
|
|
||||||
)
|
|
||||||
line.update_labels(order_info)
|
|
||||||
|
|
||||||
# update bound-in staged order
|
|
||||||
order.price = level
|
|
||||||
order.size = order_info['size']
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class OrderMode:
|
class OrderMode:
|
||||||
'''
|
'''
|
||||||
|
@ -141,7 +114,7 @@ class OrderMode:
|
||||||
current_pp: Optional[PositionTracker] = None
|
current_pp: Optional[PositionTracker] = None
|
||||||
active: bool = False
|
active: bool = False
|
||||||
name: str = 'order'
|
name: str = 'order'
|
||||||
dialogs: dict[str, OrderDialog] = field(default_factory=dict)
|
dialogs: dict[str, Dialog] = field(default_factory=dict)
|
||||||
|
|
||||||
_colors = {
|
_colors = {
|
||||||
'alert': 'alert_yellow',
|
'alert': 'alert_yellow',
|
||||||
|
@ -150,12 +123,45 @@ class OrderMode:
|
||||||
}
|
}
|
||||||
_staged_order: Optional[Order] = None
|
_staged_order: Optional[Order] = None
|
||||||
|
|
||||||
|
def on_level_change_update_next_order_info(
|
||||||
|
self,
|
||||||
|
level: float,
|
||||||
|
|
||||||
|
# these are all ``partial``-ed in at callback assignment time.
|
||||||
|
line: LevelLine,
|
||||||
|
order: Order,
|
||||||
|
tracker: PositionTracker,
|
||||||
|
|
||||||
|
) -> None:
|
||||||
|
'''
|
||||||
|
A callback applied for each level change to the line
|
||||||
|
which will recompute the order size based on allocator
|
||||||
|
settings. this is assigned inside
|
||||||
|
``OrderMode.line_from_order()``
|
||||||
|
|
||||||
|
'''
|
||||||
|
# NOTE: the ``Order.account`` is set at order stage time inside
|
||||||
|
# ``OrderMode.line_from_order()`` or is inside ``Order`` msg
|
||||||
|
# field for loaded orders.
|
||||||
|
order_info = tracker.alloc.next_order_info(
|
||||||
|
startup_pp=tracker.startup_pp,
|
||||||
|
live_pp=tracker.live_pp,
|
||||||
|
price=level,
|
||||||
|
action=order.action,
|
||||||
|
)
|
||||||
|
line.update_labels(order_info)
|
||||||
|
|
||||||
|
# update bound-in staged order
|
||||||
|
order.price = level
|
||||||
|
order.size = order_info['size']
|
||||||
|
|
||||||
|
# when an order is changed we flip the settings side-pane to
|
||||||
|
# reflect the corresponding account and pos info.
|
||||||
|
self.pane.on_ui_settings_change('account', order.account)
|
||||||
|
|
||||||
def line_from_order(
|
def line_from_order(
|
||||||
self,
|
self,
|
||||||
|
|
||||||
order: Order,
|
order: Order,
|
||||||
symbol: Symbol,
|
|
||||||
|
|
||||||
**line_kwargs,
|
**line_kwargs,
|
||||||
|
|
||||||
) -> LevelLine:
|
) -> LevelLine:
|
||||||
|
@ -173,8 +179,8 @@ class OrderMode:
|
||||||
color=self._colors[order.action],
|
color=self._colors[order.action],
|
||||||
|
|
||||||
dotted=True if (
|
dotted=True if (
|
||||||
order.exec_mode == 'dark' and
|
order.exec_mode == 'dark'
|
||||||
order.action != 'alert'
|
and order.action != 'alert'
|
||||||
) else False,
|
) else False,
|
||||||
|
|
||||||
**line_kwargs,
|
**line_kwargs,
|
||||||
|
@ -184,10 +190,12 @@ class OrderMode:
|
||||||
# immediately
|
# immediately
|
||||||
if order.action != 'alert':
|
if order.action != 'alert':
|
||||||
line._on_level_change = partial(
|
line._on_level_change = partial(
|
||||||
on_level_change_update_next_order_info,
|
self.on_level_change_update_next_order_info,
|
||||||
line=line,
|
line=line,
|
||||||
order=order,
|
order=order,
|
||||||
tracker=self.current_pp,
|
# use the corresponding position tracker for the
|
||||||
|
# order's account.
|
||||||
|
tracker=self.trackers[order.account],
|
||||||
)
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
@ -236,8 +244,6 @@ class OrderMode:
|
||||||
|
|
||||||
line = self.line_from_order(
|
line = self.line_from_order(
|
||||||
order,
|
order,
|
||||||
symbol,
|
|
||||||
|
|
||||||
show_markers=True,
|
show_markers=True,
|
||||||
# just for the stage line to avoid
|
# just for the stage line to avoid
|
||||||
# flickering while moving the cursor
|
# flickering while moving the cursor
|
||||||
|
@ -249,7 +255,6 @@ class OrderMode:
|
||||||
# prevent flickering of marker while moving/tracking cursor
|
# prevent flickering of marker while moving/tracking cursor
|
||||||
only_show_markers_on_hover=False,
|
only_show_markers_on_hover=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
line = self.lines.stage_line(line)
|
line = self.lines.stage_line(line)
|
||||||
|
|
||||||
# hide crosshair y-line and label
|
# hide crosshair y-line and label
|
||||||
|
@ -262,25 +267,26 @@ class OrderMode:
|
||||||
|
|
||||||
def submit_order(
|
def submit_order(
|
||||||
self,
|
self,
|
||||||
|
send_msg: bool = True,
|
||||||
|
order: Optional[Order] = None,
|
||||||
|
|
||||||
) -> OrderDialog:
|
) -> Dialog:
|
||||||
'''
|
'''
|
||||||
Send execution order to EMS return a level line to
|
Send execution order to EMS return a level line to
|
||||||
represent the order on a chart.
|
represent the order on a chart.
|
||||||
|
|
||||||
'''
|
'''
|
||||||
|
if not order:
|
||||||
staged = self._staged_order
|
staged = self._staged_order
|
||||||
symbol: Symbol = staged.symbol
|
# apply order fields for ems
|
||||||
oid = str(uuid.uuid4())
|
oid = str(uuid.uuid4())
|
||||||
|
|
||||||
# format order data for ems
|
|
||||||
order = staged.copy()
|
order = staged.copy()
|
||||||
order.oid = oid
|
order.oid = oid
|
||||||
order.symbol = symbol.front_fqsn()
|
|
||||||
|
order.symbol = order.symbol.front_fqsn()
|
||||||
|
|
||||||
line = self.line_from_order(
|
line = self.line_from_order(
|
||||||
order,
|
order,
|
||||||
symbol,
|
|
||||||
|
|
||||||
show_markers=True,
|
show_markers=True,
|
||||||
only_show_markers_on_hover=True,
|
only_show_markers_on_hover=True,
|
||||||
|
@ -298,17 +304,17 @@ class OrderMode:
|
||||||
# color once the submission ack arrives.
|
# color once the submission ack arrives.
|
||||||
self.lines.submit_line(
|
self.lines.submit_line(
|
||||||
line=line,
|
line=line,
|
||||||
uuid=oid,
|
uuid=order.oid,
|
||||||
)
|
)
|
||||||
|
|
||||||
dialog = OrderDialog(
|
dialog = Dialog(
|
||||||
uuid=oid,
|
uuid=order.oid,
|
||||||
order=order,
|
order=order,
|
||||||
symbol=symbol,
|
symbol=order.symbol,
|
||||||
line=line,
|
line=line,
|
||||||
last_status_close=self.multistatus.open_status(
|
last_status_close=self.multistatus.open_status(
|
||||||
f'submitting {self._trigger_type}-{order.action}',
|
f'submitting {order.exec_mode}-{order.action}',
|
||||||
final_msg=f'submitted {self._trigger_type}-{order.action}',
|
final_msg=f'submitted {order.exec_mode}-{order.action}',
|
||||||
clear_on_next=True,
|
clear_on_next=True,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -318,14 +324,21 @@ class OrderMode:
|
||||||
|
|
||||||
# enter submission which will be popped once a response
|
# enter submission which will be popped once a response
|
||||||
# from the EMS is received to move the order to a different# status
|
# from the EMS is received to move the order to a different# status
|
||||||
self.dialogs[oid] = dialog
|
self.dialogs[order.oid] = dialog
|
||||||
|
|
||||||
# hook up mouse drag handlers
|
# hook up mouse drag handlers
|
||||||
line._on_drag_start = self.order_line_modify_start
|
line._on_drag_start = self.order_line_modify_start
|
||||||
line._on_drag_end = self.order_line_modify_complete
|
line._on_drag_end = self.order_line_modify_complete
|
||||||
|
|
||||||
# send order cmd to ems
|
# send order cmd to ems
|
||||||
|
if send_msg:
|
||||||
self.book.send(order)
|
self.book.send(order)
|
||||||
|
else:
|
||||||
|
# just register for control over this order
|
||||||
|
# TODO: some kind of mini-perms system here based on
|
||||||
|
# an out-of-band tagging/auth sub-sys for multiplayer
|
||||||
|
# order control?
|
||||||
|
self.book._sent_orders[order.oid] = order
|
||||||
|
|
||||||
return dialog
|
return dialog
|
||||||
|
|
||||||
|
@ -363,7 +376,7 @@ class OrderMode:
|
||||||
self,
|
self,
|
||||||
uuid: str
|
uuid: str
|
||||||
|
|
||||||
) -> OrderDialog:
|
) -> Dialog:
|
||||||
'''
|
'''
|
||||||
Order submitted status event handler.
|
Order submitted status event handler.
|
||||||
|
|
||||||
|
@ -418,7 +431,7 @@ class OrderMode:
|
||||||
self,
|
self,
|
||||||
|
|
||||||
uuid: str,
|
uuid: str,
|
||||||
msg: Dict[str, Any],
|
msg: Status,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
|
@ -442,7 +455,7 @@ class OrderMode:
|
||||||
|
|
||||||
# TODO: add in standard fill/exec info that maybe we
|
# TODO: add in standard fill/exec info that maybe we
|
||||||
# pack in a broker independent way?
|
# pack in a broker independent way?
|
||||||
f'{msg["resp"]}: {msg["trigger_price"]}',
|
f'{msg.resp}: {msg.req.price}',
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
log.runtime(result)
|
log.runtime(result)
|
||||||
|
@ -502,7 +515,7 @@ class OrderMode:
|
||||||
oid = dialog.uuid
|
oid = dialog.uuid
|
||||||
|
|
||||||
cancel_status_close = self.multistatus.open_status(
|
cancel_status_close = self.multistatus.open_status(
|
||||||
f'cancelling order {oid[:6]}',
|
f'cancelling order {oid}',
|
||||||
group_key=key,
|
group_key=key,
|
||||||
)
|
)
|
||||||
dialog.last_status_close = cancel_status_close
|
dialog.last_status_close = cancel_status_close
|
||||||
|
@ -512,6 +525,44 @@ class OrderMode:
|
||||||
|
|
||||||
return ids
|
return ids
|
||||||
|
|
||||||
|
def load_unknown_dialog_from_msg(
|
||||||
|
self,
|
||||||
|
msg: Status,
|
||||||
|
|
||||||
|
) -> Dialog:
|
||||||
|
# NOTE: the `.order` attr **must** be set with the
|
||||||
|
# equivalent order msg in order to be loaded.
|
||||||
|
order = msg.req
|
||||||
|
oid = str(msg.oid)
|
||||||
|
symbol = order.symbol
|
||||||
|
|
||||||
|
# TODO: MEGA UGGG ZONEEEE!
|
||||||
|
src = msg.src
|
||||||
|
if (
|
||||||
|
src
|
||||||
|
and src not in ('dark', 'paperboi')
|
||||||
|
and src not in symbol
|
||||||
|
):
|
||||||
|
fqsn = symbol + '.' + src
|
||||||
|
brokername = src
|
||||||
|
else:
|
||||||
|
fqsn = symbol
|
||||||
|
*head, brokername = fqsn.rsplit('.')
|
||||||
|
|
||||||
|
# fill out complex fields
|
||||||
|
order.oid = str(order.oid)
|
||||||
|
order.brokers = [brokername]
|
||||||
|
order.symbol = Symbol.from_fqsn(
|
||||||
|
fqsn=fqsn,
|
||||||
|
info={},
|
||||||
|
)
|
||||||
|
dialog = self.submit_order(
|
||||||
|
send_msg=False,
|
||||||
|
order=order,
|
||||||
|
)
|
||||||
|
assert self.dialogs[oid] == dialog
|
||||||
|
return dialog
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def open_order_mode(
|
async def open_order_mode(
|
||||||
|
@ -549,6 +600,7 @@ async def open_order_mode(
|
||||||
trades_stream,
|
trades_stream,
|
||||||
position_msgs,
|
position_msgs,
|
||||||
brokerd_accounts,
|
brokerd_accounts,
|
||||||
|
ems_dialog_msgs,
|
||||||
),
|
),
|
||||||
trio.open_nursery() as tn,
|
trio.open_nursery() as tn,
|
||||||
|
|
||||||
|
@ -596,10 +648,10 @@ async def open_order_mode(
|
||||||
|
|
||||||
sym = msg['symbol']
|
sym = msg['symbol']
|
||||||
if (
|
if (
|
||||||
sym == symkey or
|
(sym == symkey) or (
|
||||||
# mega-UGH, i think we need to fix the FQSN stuff sooner
|
# mega-UGH, i think we need to fix the FQSN
|
||||||
# then later..
|
# stuff sooner then later..
|
||||||
sym == symkey.removesuffix(f'.{broker}')
|
sym == symkey.removesuffix(f'.{broker}'))
|
||||||
):
|
):
|
||||||
pps_by_account[acctid] = msg
|
pps_by_account[acctid] = msg
|
||||||
|
|
||||||
|
@ -703,7 +755,7 @@ async def open_order_mode(
|
||||||
order_pane.order_mode = mode
|
order_pane.order_mode = mode
|
||||||
|
|
||||||
# select a pp to track
|
# select a pp to track
|
||||||
tracker = trackers[pp_account]
|
tracker: PositionTracker = trackers[pp_account]
|
||||||
mode.current_pp = tracker
|
mode.current_pp = tracker
|
||||||
tracker.show()
|
tracker.show()
|
||||||
tracker.hide_info()
|
tracker.hide_info()
|
||||||
|
@ -755,38 +807,61 @@ async def open_order_mode(
|
||||||
# to handle input since the ems connection is ready
|
# to handle input since the ems connection is ready
|
||||||
started.set()
|
started.set()
|
||||||
|
|
||||||
|
for oid, msg in ems_dialog_msgs.items():
|
||||||
|
|
||||||
|
# HACK ALERT: ensure a resp field is filled out since
|
||||||
|
# techincally the call below expects a ``Status``. TODO:
|
||||||
|
# parse into proper ``Status`` equivalents ems-side?
|
||||||
|
# msg.setdefault('resp', msg['broker_details']['resp'])
|
||||||
|
# msg.setdefault('oid', msg['broker_details']['oid'])
|
||||||
|
msg['brokerd_msg'] = msg
|
||||||
|
|
||||||
|
await process_trade_msg(
|
||||||
|
mode,
|
||||||
|
book,
|
||||||
|
msg,
|
||||||
|
)
|
||||||
|
|
||||||
tn.start_soon(
|
tn.start_soon(
|
||||||
process_trades_and_update_ui,
|
process_trades_and_update_ui,
|
||||||
tn,
|
|
||||||
feed,
|
|
||||||
mode,
|
|
||||||
trades_stream,
|
trades_stream,
|
||||||
|
mode,
|
||||||
book,
|
book,
|
||||||
)
|
)
|
||||||
|
|
||||||
yield mode
|
yield mode
|
||||||
|
|
||||||
|
|
||||||
async def process_trades_and_update_ui(
|
async def process_trades_and_update_ui(
|
||||||
|
|
||||||
n: trio.Nursery,
|
|
||||||
feed: Feed,
|
|
||||||
mode: OrderMode,
|
|
||||||
trades_stream: tractor.MsgStream,
|
trades_stream: tractor.MsgStream,
|
||||||
|
mode: OrderMode,
|
||||||
book: OrderBook,
|
book: OrderBook,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
get_index = mode.chart.get_index
|
|
||||||
global _pnl_tasks
|
|
||||||
|
|
||||||
# this is where we receive **back** messages
|
# this is where we receive **back** messages
|
||||||
# about executions **from** the EMS actor
|
# about executions **from** the EMS actor
|
||||||
async for msg in trades_stream:
|
async for msg in trades_stream:
|
||||||
|
await process_trade_msg(
|
||||||
|
mode,
|
||||||
|
book,
|
||||||
|
msg,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def process_trade_msg(
|
||||||
|
mode: OrderMode,
|
||||||
|
book: OrderBook,
|
||||||
|
msg: dict,
|
||||||
|
|
||||||
|
) -> tuple[Dialog, Status]:
|
||||||
|
|
||||||
|
get_index = mode.chart.get_index
|
||||||
fmsg = pformat(msg)
|
fmsg = pformat(msg)
|
||||||
log.info(f'Received order msg:\n{fmsg}')
|
log.debug(f'Received order msg:\n{fmsg}')
|
||||||
|
|
||||||
name = msg['name']
|
name = msg['name']
|
||||||
|
|
||||||
if name in (
|
if name in (
|
||||||
'position',
|
'position',
|
||||||
):
|
):
|
||||||
|
@ -811,95 +886,107 @@ async def process_trades_and_update_ui(
|
||||||
|
|
||||||
# short circuit to next msg to avoid
|
# short circuit to next msg to avoid
|
||||||
# unnecessary msg content lookups
|
# unnecessary msg content lookups
|
||||||
continue
|
return
|
||||||
|
|
||||||
resp = msg['resp']
|
msg = Status(**msg)
|
||||||
oid = msg['oid']
|
resp = msg.resp
|
||||||
|
oid = msg.oid
|
||||||
|
dialog: Dialog = mode.dialogs.get(oid)
|
||||||
|
|
||||||
dialog = mode.dialogs.get(oid)
|
match msg:
|
||||||
if dialog is None:
|
case Status(resp='dark_open' | 'open'):
|
||||||
log.warning(f'received msg for untracked dialog:\n{fmsg}')
|
|
||||||
|
|
||||||
# TODO: enable pure tracking / mirroring of dialogs
|
|
||||||
# is desired.
|
|
||||||
continue
|
|
||||||
|
|
||||||
# record message to dialog tracking
|
|
||||||
dialog.msgs[oid] = msg
|
|
||||||
|
|
||||||
# response to 'action' request (buy/sell)
|
|
||||||
if resp in (
|
|
||||||
'dark_submitted',
|
|
||||||
'broker_submitted'
|
|
||||||
):
|
|
||||||
|
|
||||||
|
if dialog is not None:
|
||||||
# show line label once order is live
|
# show line label once order is live
|
||||||
mode.on_submit(oid)
|
mode.on_submit(oid)
|
||||||
|
|
||||||
# resp to 'cancel' request or error condition
|
else:
|
||||||
# for action request
|
log.warning(
|
||||||
elif resp in (
|
f'received msg for untracked dialog:\n{fmsg}'
|
||||||
'broker_inactive',
|
)
|
||||||
'broker_errored',
|
assert msg.resp in ('open', 'dark_open'), f'Unknown msg: {msg}'
|
||||||
|
|
||||||
|
sym = mode.chart.linked.symbol
|
||||||
|
fqsn = sym.front_fqsn()
|
||||||
|
order = Order(**msg.req)
|
||||||
|
if (
|
||||||
|
((order.symbol + f'.{msg.src}') == fqsn)
|
||||||
|
|
||||||
|
# a existing dark order for the same symbol
|
||||||
|
or (
|
||||||
|
order.symbol == fqsn
|
||||||
|
and (
|
||||||
|
msg.src in ('dark', 'paperboi')
|
||||||
|
or (msg.src in fqsn)
|
||||||
|
|
||||||
|
)
|
||||||
|
)
|
||||||
):
|
):
|
||||||
|
msg.req = order
|
||||||
|
dialog = mode.load_unknown_dialog_from_msg(msg)
|
||||||
|
mode.on_submit(oid)
|
||||||
|
# return dialog, msg
|
||||||
|
|
||||||
|
case Status(resp='error'):
|
||||||
# delete level line from view
|
# delete level line from view
|
||||||
mode.on_cancel(oid)
|
mode.on_cancel(oid)
|
||||||
broker_msg = msg['brokerd_msg']
|
broker_msg = msg.brokerd_msg
|
||||||
log.error(
|
log.error(
|
||||||
f'Order {oid}->{resp} with:\n{pformat(broker_msg)}'
|
f'Order {oid}->{resp} with:\n{pformat(broker_msg)}'
|
||||||
)
|
)
|
||||||
|
|
||||||
elif resp in (
|
case Status(resp='canceled'):
|
||||||
'broker_cancelled',
|
|
||||||
'dark_cancelled'
|
|
||||||
):
|
|
||||||
# delete level line from view
|
# delete level line from view
|
||||||
mode.on_cancel(oid)
|
mode.on_cancel(oid)
|
||||||
broker_msg = msg['brokerd_msg']
|
req = Order(**msg.req)
|
||||||
log.cancel(
|
log.cancel(f'Canceled {req.action}:{oid}')
|
||||||
f'Order {oid}->{resp} with:\n{pformat(broker_msg)}'
|
|
||||||
)
|
|
||||||
|
|
||||||
elif resp in (
|
case Status(
|
||||||
'dark_triggered'
|
resp='triggered',
|
||||||
|
# req=Order(exec_mode='dark') # TODO:
|
||||||
|
req={'exec_mode': 'dark'},
|
||||||
):
|
):
|
||||||
|
# TODO: UX for a "pending" clear/live order
|
||||||
log.info(f'Dark order triggered for {fmsg}')
|
log.info(f'Dark order triggered for {fmsg}')
|
||||||
|
|
||||||
elif resp in (
|
case Status(
|
||||||
'alert_triggered'
|
resp='triggered',
|
||||||
|
# req=Order(exec_mode='live', action='alert') as req, # TODO
|
||||||
|
req={'exec_mode': 'live', 'action': 'alert'} as req,
|
||||||
):
|
):
|
||||||
# should only be one "fill" for an alert
|
# should only be one "fill" for an alert
|
||||||
# add a triangle and remove the level line
|
# add a triangle and remove the level line
|
||||||
|
req = Order(**req)
|
||||||
mode.on_fill(
|
mode.on_fill(
|
||||||
oid,
|
oid,
|
||||||
price=msg['trigger_price'],
|
price=req.price,
|
||||||
arrow_index=get_index(time.time()),
|
arrow_index=get_index(time.time()),
|
||||||
)
|
)
|
||||||
mode.lines.remove_line(uuid=oid)
|
mode.lines.remove_line(uuid=oid)
|
||||||
|
msg.req = req
|
||||||
await mode.on_exec(oid, msg)
|
await mode.on_exec(oid, msg)
|
||||||
|
|
||||||
# response to completed 'action' request for buy/sell
|
# response to completed 'dialog' for order request
|
||||||
elif resp in (
|
case Status(
|
||||||
'broker_executed',
|
resp='closed',
|
||||||
|
# req=Order() as req, # TODO
|
||||||
|
req=req,
|
||||||
):
|
):
|
||||||
# right now this is just triggering a system alert
|
msg.req = Order(**req)
|
||||||
await mode.on_exec(oid, msg)
|
await mode.on_exec(oid, msg)
|
||||||
|
|
||||||
if msg['brokerd_msg']['remaining'] == 0:
|
|
||||||
mode.lines.remove_line(uuid=oid)
|
mode.lines.remove_line(uuid=oid)
|
||||||
|
|
||||||
# each clearing tick is responded individually
|
# each clearing tick is responded individually
|
||||||
elif resp in (
|
case Status(resp='fill'):
|
||||||
'broker_filled',
|
|
||||||
):
|
|
||||||
|
|
||||||
|
# handle out-of-piker fills reporting?
|
||||||
known_order = book._sent_orders.get(oid)
|
known_order = book._sent_orders.get(oid)
|
||||||
if not known_order:
|
if not known_order:
|
||||||
log.warning(f'order {oid} is unknown')
|
log.warning(f'order {oid} is unknown')
|
||||||
continue
|
return
|
||||||
|
|
||||||
action = known_order.action
|
action = known_order.action
|
||||||
details = msg['brokerd_msg']
|
details = msg.brokerd_msg
|
||||||
|
|
||||||
# TODO: some kinda progress system
|
# TODO: some kinda progress system
|
||||||
mode.on_fill(
|
mode.on_fill(
|
||||||
|
@ -914,3 +1001,9 @@ async def process_trades_and_update_ui(
|
||||||
# TODO: how should we look this up?
|
# TODO: how should we look this up?
|
||||||
# tracker = mode.trackers[msg['account']]
|
# tracker = mode.trackers[msg['account']]
|
||||||
# tracker.live_pp.fills.append(msg)
|
# tracker.live_pp.fills.append(msg)
|
||||||
|
|
||||||
|
# record message to dialog tracking
|
||||||
|
if dialog:
|
||||||
|
dialog.msgs[oid] = msg
|
||||||
|
|
||||||
|
return dialog, msg
|
||||||
|
|
Loading…
Reference in New Issue