Add pub-sub broadcasting
Establishes a more formalized subscription based fan out pattern to ems clients who subscribe for order flow for a particular symbol (the fqsn is the default subscription key for now). Make `Router.client_broadcast()` take a `sub_key: str` value which determines the set of clients to forward a message to and drop all such manually defined broadcast loops from task (func) code. Also add `.get_subs()` which (hackily) allows getting the set of clients for a given sub key where any stream that is detected as "closed" is discarded in the output. Further we simplify to `Router.dialogs: defaultdict[str, set[tractor.MsgStream]]` and `.subscriptions` as maps to sets of streams for much easier broadcast management/logic using set operations inside `.client_broadcast()`.multi_client_order_mgt
parent
909e068121
commit
4877af9bc3
|
@ -21,7 +21,7 @@ In da suit parlances: "Execution management systems"
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
from collections import (
|
from collections import (
|
||||||
defaultdict,
|
defaultdict,
|
||||||
ChainMap,
|
# ChainMap,
|
||||||
)
|
)
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from math import isnan
|
from math import isnan
|
||||||
|
@ -286,16 +286,10 @@ async def clear_dark_triggers(
|
||||||
book._active[oid] = status
|
book._active[oid] = status
|
||||||
|
|
||||||
# send response to client-side
|
# send response to client-side
|
||||||
for client_stream in router.dialogs[oid]:
|
await router.client_broadcast(
|
||||||
try:
|
fqsn,
|
||||||
await client_stream.send(status)
|
status,
|
||||||
except (
|
|
||||||
trio.ClosedResourceError,
|
|
||||||
):
|
|
||||||
log.warning(
|
|
||||||
f'{client_stream} stream broke?'
|
|
||||||
)
|
)
|
||||||
break
|
|
||||||
|
|
||||||
else: # condition scan loop complete
|
else: # condition scan loop complete
|
||||||
log.debug(f'execs are {execs}')
|
log.debug(f'execs are {execs}')
|
||||||
|
@ -342,21 +336,24 @@ 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()
|
||||||
|
|
||||||
fqsn2dialogs: defaultdict[
|
# sets of clients mapped from subscription keys
|
||||||
str, # fqsn
|
subscribers: defaultdict[
|
||||||
list[str], # oids
|
str, # sub key, default fqsn
|
||||||
] = defaultdict(list)
|
set[tractor.MsgStream], # unique client streams
|
||||||
|
] = defaultdict(set)
|
||||||
|
|
||||||
|
# sets of clients dynamically registered for specific
|
||||||
|
# order flows based on subscription config.
|
||||||
dialogs: defaultdict[
|
dialogs: defaultdict[
|
||||||
str, # ems uuid (oid)
|
str, # ems uuid (oid)
|
||||||
list[tractor.MsgStream] # client side msg stream
|
set[tractor.MsgStream] # client side msg stream
|
||||||
] = defaultdict(list)
|
] = defaultdict(set)
|
||||||
|
|
||||||
# mapping of ems dialog ids to msg flow history
|
# TODO: mapping of ems dialog ids to msg flow history
|
||||||
msgflows: defaultdict[
|
# msgflows: defaultdict[
|
||||||
str,
|
# str,
|
||||||
ChainMap[dict[str, dict]],
|
# ChainMap[dict[str, dict]],
|
||||||
] = defaultdict(ChainMap)
|
# ] = defaultdict(ChainMap)
|
||||||
|
|
||||||
# brokername to trades-dialogs streams with ``brokerd`` actors
|
# brokername to trades-dialogs streams with ``brokerd`` actors
|
||||||
relays: dict[
|
relays: dict[
|
||||||
|
@ -372,6 +369,20 @@ class Router(Struct):
|
||||||
|
|
||||||
return self.books.setdefault(brokername, _DarkBook(brokername))
|
return self.books.setdefault(brokername, _DarkBook(brokername))
|
||||||
|
|
||||||
|
def get_subs(
|
||||||
|
self,
|
||||||
|
oid: str,
|
||||||
|
|
||||||
|
) -> set[tractor.MsgStream]:
|
||||||
|
'''
|
||||||
|
Deliver list of non-closed subscriber client msg streams.
|
||||||
|
|
||||||
|
'''
|
||||||
|
return set(
|
||||||
|
stream for stream in self.dialogs[oid]
|
||||||
|
if not stream._closed
|
||||||
|
)
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def maybe_open_brokerd_trades_dialogue(
|
async def maybe_open_brokerd_trades_dialogue(
|
||||||
self,
|
self,
|
||||||
|
@ -431,20 +442,27 @@ class Router(Struct):
|
||||||
|
|
||||||
async def client_broadcast(
|
async def client_broadcast(
|
||||||
self,
|
self,
|
||||||
|
sub_key: str,
|
||||||
msg: dict,
|
msg: dict,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
for client_stream in self.clients.copy():
|
to_remove: set[tractor.MsgStream] = set()
|
||||||
|
subs = self.subscribers[sub_key]
|
||||||
|
for client_stream in subs:
|
||||||
try:
|
try:
|
||||||
await client_stream.send(msg)
|
await client_stream.send(msg)
|
||||||
except (
|
except (
|
||||||
trio.ClosedResourceError,
|
trio.ClosedResourceError,
|
||||||
trio.BrokenResourceError,
|
trio.BrokenResourceError,
|
||||||
):
|
):
|
||||||
|
to_remove.add(client_stream)
|
||||||
self.clients.remove(client_stream)
|
self.clients.remove(client_stream)
|
||||||
log.warning(
|
log.warning(
|
||||||
f'client for {client_stream} was already closed?')
|
f'client for {client_stream} was already closed?')
|
||||||
|
|
||||||
|
if to_remove:
|
||||||
|
subs.difference_update(to_remove)
|
||||||
|
|
||||||
|
|
||||||
_router: Router = None
|
_router: Router = None
|
||||||
|
|
||||||
|
@ -558,7 +576,7 @@ async def open_brokerd_trades_dialog(
|
||||||
consumers=1,
|
consumers=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
_router.relays[broker] = relay
|
router.relays[broker] = relay
|
||||||
|
|
||||||
# the ems scan loop may be cancelled by the client but we
|
# the ems scan loop may be cancelled by the client but we
|
||||||
# want to keep the ``brokerd`` dialogue up regardless
|
# want to keep the ``brokerd`` dialogue up regardless
|
||||||
|
@ -572,7 +590,7 @@ async def open_brokerd_trades_dialog(
|
||||||
finally:
|
finally:
|
||||||
# parent context must have been closed remove from cache so
|
# parent context must have been closed remove from cache so
|
||||||
# next client will respawn if needed
|
# next client will respawn if needed
|
||||||
relay = _router.relays.pop(broker, None)
|
relay = router.relays.pop(broker, None)
|
||||||
if not relay:
|
if not relay:
|
||||||
log.warning(f'Relay for {broker} was already removed!?')
|
log.warning(f'Relay for {broker} was already removed!?')
|
||||||
|
|
||||||
|
@ -627,7 +645,6 @@ async def translate_and_relay_brokerd_events(
|
||||||
'''
|
'''
|
||||||
book: _DarkBook = router.get_dark_book(broker)
|
book: _DarkBook = router.get_dark_book(broker)
|
||||||
relay: TradesRelay = router.relays[broker]
|
relay: TradesRelay = router.relays[broker]
|
||||||
|
|
||||||
assert relay.brokerd_stream == brokerd_trades_stream
|
assert relay.brokerd_stream == brokerd_trades_stream
|
||||||
|
|
||||||
brokerd_msg: dict[str, Any]
|
brokerd_msg: dict[str, Any]
|
||||||
|
@ -660,7 +677,7 @@ 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
|
||||||
await router.client_broadcast(pos_msg)
|
await router.client_broadcast(sym, pos_msg)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# BrokerdOrderAck
|
# BrokerdOrderAck
|
||||||
|
@ -727,21 +744,18 @@ 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_streams = router.dialogs[oid]
|
|
||||||
|
|
||||||
status_msg.resp = 'error'
|
status_msg.resp = 'error'
|
||||||
status_msg.brokerd_msg = msg
|
status_msg.brokerd_msg = msg
|
||||||
book._active[oid] = status_msg
|
book._active[oid] = status_msg
|
||||||
|
|
||||||
for stream in ems_client_order_streams:
|
await router.client_broadcast(sym, status_msg)
|
||||||
await 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
|
||||||
|
|
||||||
} if (
|
} if (
|
||||||
(oid := book._ems2brokerd_ids.inverse.get(reqid))
|
(oid := book._ems2brokerd_ids.inverse.get(reqid))
|
||||||
and status in (
|
and status in (
|
||||||
|
@ -755,7 +769,7 @@ async def translate_and_relay_brokerd_events(
|
||||||
# TODO: maybe pack this into a composite type that
|
# TODO: maybe pack this into a composite type that
|
||||||
# contains both the IPC stream as well the
|
# contains both the IPC stream as well the
|
||||||
# msg-chain/dialog.
|
# msg-chain/dialog.
|
||||||
ems_client_order_streams = router.dialogs[oid]
|
ems_client_order_streams = router.get_subs(oid)
|
||||||
status_msg = book._active.get(oid)
|
status_msg = book._active.get(oid)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
@ -783,8 +797,10 @@ async def translate_and_relay_brokerd_events(
|
||||||
status_msg.brokerd_msg = msg
|
status_msg.brokerd_msg = msg
|
||||||
status_msg.src = msg.broker_details['name']
|
status_msg.src = msg.broker_details['name']
|
||||||
|
|
||||||
for stream in ems_client_order_streams:
|
await router.client_broadcast(
|
||||||
await stream.send(status_msg)
|
status_msg.req.symbol,
|
||||||
|
status_msg,
|
||||||
|
)
|
||||||
|
|
||||||
if status == 'closed':
|
if status == 'closed':
|
||||||
log.info(f'Execution for {oid} is complete!')
|
log.info(f'Execution for {oid} is complete!')
|
||||||
|
@ -818,8 +834,6 @@ async def translate_and_relay_brokerd_events(
|
||||||
msg = BrokerdFill(**brokerd_msg)
|
msg = BrokerdFill(**brokerd_msg)
|
||||||
log.info(f'Fill for {oid} cleared with:\n{fmsg}')
|
log.info(f'Fill for {oid} cleared with:\n{fmsg}')
|
||||||
|
|
||||||
ems_client_order_streams = router.dialogs[oid]
|
|
||||||
|
|
||||||
# XXX: bleh, a fill can come after 'closed' from `ib`?
|
# XXX: bleh, a fill can come after 'closed' from `ib`?
|
||||||
# only send a late fill event we haven't already closed
|
# only send a late fill event we haven't already closed
|
||||||
# out the dialog status locally.
|
# out the dialog status locally.
|
||||||
|
@ -829,9 +843,10 @@ async def translate_and_relay_brokerd_events(
|
||||||
status_msg.reqid = reqid
|
status_msg.reqid = reqid
|
||||||
status_msg.brokerd_msg = msg
|
status_msg.brokerd_msg = msg
|
||||||
|
|
||||||
for stream in ems_client_order_streams:
|
await router.client_broadcast(
|
||||||
await stream.send(status_msg)
|
status_msg.req.symbol,
|
||||||
# await ems_client_order_stream.send(status_msg)
|
status_msg,
|
||||||
|
)
|
||||||
|
|
||||||
# ``Status`` containing an embedded order msg which
|
# ``Status`` containing an embedded order msg which
|
||||||
# should be loaded as a "pre-existing open order" from the
|
# should be loaded as a "pre-existing open order" from the
|
||||||
|
@ -883,7 +898,10 @@ 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
|
||||||
await router.client_broadcast(status_msg)
|
await router.client_broadcast(
|
||||||
|
order.symbol,
|
||||||
|
status_msg,
|
||||||
|
)
|
||||||
|
|
||||||
# don't fall through
|
# don't fall through
|
||||||
continue
|
continue
|
||||||
|
@ -937,15 +955,21 @@ async def process_client_order_cmds(
|
||||||
client_order_stream: tractor.MsgStream,
|
client_order_stream: tractor.MsgStream,
|
||||||
brokerd_order_stream: tractor.MsgStream,
|
brokerd_order_stream: tractor.MsgStream,
|
||||||
|
|
||||||
symbol: str,
|
fqsn: str,
|
||||||
feed: Feed,
|
feed: Feed,
|
||||||
dark_book: _DarkBook,
|
dark_book: _DarkBook,
|
||||||
router: Router,
|
router: Router,
|
||||||
|
|
||||||
) -> None:
|
) -> None:
|
||||||
|
'''
|
||||||
|
Client-dialog request loop: accept order requests and deliver
|
||||||
|
initial status msg responses to subscribed clients.
|
||||||
|
|
||||||
client_dialogs = router.dialogs
|
This task-loop handles both management of dark triggered orders and
|
||||||
|
alerts by inserting them into the "dark book"-table as well as
|
||||||
|
submitting live orders immediately if requested by the client.
|
||||||
|
|
||||||
|
'''
|
||||||
# cmd: dict
|
# cmd: dict
|
||||||
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)}')
|
||||||
|
@ -953,15 +977,17 @@ async def process_client_order_cmds(
|
||||||
# CAWT DAMN we need struct support!
|
# CAWT DAMN we need struct support!
|
||||||
oid = str(cmd['oid'])
|
oid = str(cmd['oid'])
|
||||||
|
|
||||||
# register this stream as an active dialogue for this order id
|
# register this stream as an active order dialog (msg flow) for
|
||||||
# such that translated message from the brokerd backend can be
|
# this order id such that translated message from the brokerd
|
||||||
# routed (relayed) to **just** that client stream (and in theory
|
# backend can be routed and relayed to subscribed clients.
|
||||||
# others who are registered for such order affiliated msgs).
|
subs = router.dialogs[oid]
|
||||||
subs = client_dialogs[oid]
|
|
||||||
if client_order_stream not in subs:
|
# add all subscribed clients for this fqsn (should eventually be
|
||||||
subs.append(client_order_stream)
|
# a more generalize subscription system) to received order msg
|
||||||
|
# updates (and thus show stuff in the UI).
|
||||||
|
subs.add(client_order_stream)
|
||||||
|
subs.update(router.subscribers[fqsn])
|
||||||
|
|
||||||
router.fqsn2dialogs[symbol].append(oid)
|
|
||||||
reqid = dark_book._ems2brokerd_ids.inverse.get(oid)
|
reqid = dark_book._ems2brokerd_ids.inverse.get(oid)
|
||||||
|
|
||||||
# any dark/live status which is current
|
# any dark/live status which is current
|
||||||
|
@ -973,7 +999,7 @@ async def process_client_order_cmds(
|
||||||
'action': 'cancel',
|
'action': 'cancel',
|
||||||
'oid': oid,
|
'oid': oid,
|
||||||
} if (
|
} if (
|
||||||
(status := dark_book._active.get(oid))
|
status
|
||||||
and status.resp in ('open', 'pending')
|
and status.resp in ('open', 'pending')
|
||||||
):
|
):
|
||||||
reqid = status.reqid
|
reqid = status.reqid
|
||||||
|
@ -1009,11 +1035,11 @@ async def process_client_order_cmds(
|
||||||
'action': 'cancel',
|
'action': 'cancel',
|
||||||
'oid': oid,
|
'oid': oid,
|
||||||
} if (
|
} if (
|
||||||
status and status.resp == 'dark_open'
|
status
|
||||||
# or status and status.req
|
and status.resp == 'dark_open'
|
||||||
):
|
):
|
||||||
# remove from dark book clearing
|
# remove from dark book clearing
|
||||||
entry = dark_book.orders[symbol].pop(oid, None)
|
entry = dark_book.orders[fqsn].pop(oid, None)
|
||||||
if entry:
|
if entry:
|
||||||
(
|
(
|
||||||
pred,
|
pred,
|
||||||
|
@ -1028,14 +1054,18 @@ async def process_client_order_cmds(
|
||||||
status.resp = 'canceled'
|
status.resp = 'canceled'
|
||||||
status.req = cmd
|
status.req = cmd
|
||||||
|
|
||||||
await client_order_stream.send(status)
|
await router.client_broadcast(
|
||||||
|
fqsn,
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
|
||||||
# de-register this order dialogue from all clients
|
# de-register this order dialogue from all clients
|
||||||
router.dialogs[oid].clear()
|
router.dialogs[oid].clear()
|
||||||
router.dialogs.pop(oid)
|
router.dialogs.pop(oid)
|
||||||
dark_book._active.pop(oid)
|
dark_book._active.pop(oid)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
log.exception(f'No dark order for {symbol}?')
|
log.exception(f'No dark order for {fqsn}?')
|
||||||
|
|
||||||
# TODO: eventually we should be receiving
|
# TODO: eventually we should be receiving
|
||||||
# this struct on the wire unpacked in a scoped protocol
|
# this struct on the wire unpacked in a scoped protocol
|
||||||
|
@ -1194,7 +1224,12 @@ async def process_client_order_cmds(
|
||||||
src='dark',
|
src='dark',
|
||||||
)
|
)
|
||||||
dark_book._active[oid] = status
|
dark_book._active[oid] = status
|
||||||
await client_order_stream.send(status)
|
|
||||||
|
# broadcast status to all subscribed clients
|
||||||
|
await router.client_broadcast(
|
||||||
|
fqsn,
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@tractor.context
|
@tractor.context
|
||||||
|
@ -1220,20 +1255,26 @@ async def _emsd_main(
|
||||||
received in a stream from that client actor and then responses are
|
received in a stream from that client actor and then responses are
|
||||||
streamed back up to the original calling task in the same client.
|
streamed back up to the original calling task in the same client.
|
||||||
|
|
||||||
The primary ``emsd`` task tree is:
|
The primary ``emsd`` task trees are:
|
||||||
|
|
||||||
- ``_emsd_main()``:
|
- ``_setup_persistent_emsd()``:
|
||||||
sets up brokerd feed, order feed with ems client, trades dialogue with
|
is the ``emsd`` actor's primary root task which sets up an
|
||||||
brokderd trading api.
|
actor-global ``Router`` instance and starts a relay loop task
|
||||||
|
|
which lives until the backend broker is shutdown or the ems is
|
||||||
- ``clear_dark_triggers()``:
|
terminated.
|
||||||
run (dark order) conditions on inputs and trigger brokerd "live"
|
|
||||||
order submissions.
|
|
||||||
|
|
|
|
||||||
- (maybe) ``translate_and_relay_brokerd_events()``:
|
- (maybe) ``translate_and_relay_brokerd_events()``:
|
||||||
accept normalized trades responses from brokerd, process and
|
accept normalized trades responses from brokerd, process and
|
||||||
relay to ems client(s); this is a effectively a "trade event
|
relay to ems client(s); this is a effectively a "trade event
|
||||||
reponse" proxy-broker.
|
reponse" proxy-broker.
|
||||||
|
|
||||||
|
- ``_emsd_main()``:
|
||||||
|
attaches a brokerd real-time quote feed and trades dialogue with
|
||||||
|
brokderd trading api for every connecting client.
|
||||||
|
|
|
||||||
|
- ``clear_dark_triggers()``:
|
||||||
|
run (dark order) conditions on inputs and trigger brokerd "live"
|
||||||
|
order submissions.
|
||||||
|
|
|
|
||||||
- ``process_client_order_cmds()``:
|
- ``process_client_order_cmds()``:
|
||||||
accepts order cmds from requesting clients, registers dark orders and
|
accepts order cmds from requesting clients, registers dark orders and
|
||||||
|
@ -1301,8 +1342,12 @@ async def _emsd_main(
|
||||||
# brokerd-side relay task to ensure the client is
|
# brokerd-side relay task to ensure the client is
|
||||||
# delivered all exisiting open orders on startup.
|
# delivered all exisiting open orders on startup.
|
||||||
_router.clients.add(client_stream)
|
_router.clients.add(client_stream)
|
||||||
for oid in _router.fqsn2dialogs[fqsn]:
|
|
||||||
_router.dialogs[oid].append(client_stream)
|
# TODO: instead of by fqsn we need a subscription
|
||||||
|
# system/schema here to limit what each new client is
|
||||||
|
# allowed to see in terms of broadcasted order flow
|
||||||
|
# updates per dialog.
|
||||||
|
_router.subscribers[fqsn].add(client_stream)
|
||||||
|
|
||||||
# trigger scan and exec loop
|
# trigger scan and exec loop
|
||||||
n.start_soon(
|
n.start_soon(
|
||||||
|
@ -1337,6 +1382,7 @@ async def _emsd_main(
|
||||||
' was already dropped?'
|
' was already dropped?'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_router.subscribers[fqsn].remove(client_stream)
|
||||||
dialogs = _router.dialogs
|
dialogs = _router.dialogs
|
||||||
for oid, client_streams in dialogs.items():
|
for oid, client_streams in dialogs.items():
|
||||||
if client_stream in client_streams:
|
if client_stream in client_streams:
|
||||||
|
|
Loading…
Reference in New Issue