Port EMS to typed messaging + bidir streaming

This moves the entire clearing system to use typed messages using
`pydantic.BaseModel` such that the streamed request-response order
submission protocols can be explicitly viewed in terms of message
schema, flow, and sequencing. Using the explicit message formats we can
now dig into simplifying and normalizing across broker provider apis to
get the best uniformity and simplicity.

The order submission sequence is now fully async: an order request is
expected to be explicitly acked with a new message and if cancellation
is requested by the client before the ack arrives, the cancel message is
stashed and then later sent immediately on receipt of the order
submission's ack from the backend broker. Backend brokers are now
controlled using a 2-way request-response streaming dialogue which is
fully api agnostic of the clearing system's core processing; This
leverages the new bi-directional streaming apis from `tractor`.  The
clearing core (emsd) was also simplified by moving the paper engine to
it's own sub-actor and making it api-symmetric with expected `brokerd`
endpoints.

A couple of the ems status messages were changed/added:
'dark_executed' -> 'dark_triggered'
added 'alert_triggered'

More cleaning of old code to come!
ems_to_bidir_streaming
Tyler Goodlet 2021-06-08 12:14:45 -04:00
parent 0dabc6ad26
commit 6e58f31fd8
3 changed files with 653 additions and 348 deletions

View File

@ -19,34 +19,23 @@ Orders and execution client API.
""" """
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Dict, Tuple, List from typing import Dict
from pprint import pformat from pprint import pformat
from dataclasses import dataclass, field from dataclasses import dataclass, field
import trio import trio
import tractor import tractor
# import msgspec
from ..data._source import Symbol from ..data._source import Symbol
from ..log import get_logger from ..log import get_logger
from ._ems import _emsd_main from ._ems import _emsd_main
from .._daemon import maybe_open_emsd from .._daemon import maybe_open_emsd
from ._messages import Order, Cancel
log = get_logger(__name__) log = get_logger(__name__)
# TODO: some kinda validation like this
# class Order(msgspec.Struct):
# action: str
# price: float
# size: float
# symbol: str
# brokers: List[str]
# oid: str
# exec_mode: str
@dataclass @dataclass
class OrderBook: class OrderBook:
"""Buy-side (client-side ?) order book ctl and tracking. """Buy-side (client-side ?) order book ctl and tracking.
@ -64,31 +53,34 @@ class OrderBook:
_to_ems: trio.abc.SendChannel _to_ems: trio.abc.SendChannel
_from_order_book: trio.abc.ReceiveChannel _from_order_book: trio.abc.ReceiveChannel
_sent_orders: Dict[str, dict] = field(default_factory=dict) _sent_orders: Dict[str, Order] = field(default_factory=dict)
_ready_to_receive: trio.Event = trio.Event() _ready_to_receive: trio.Event = trio.Event()
def send( def send(
self, self,
uuid: str, uuid: str,
symbol: str, symbol: str,
brokers: List[str], brokers: list[str],
price: float, price: float,
size: float, size: float,
action: str, action: str,
exec_mode: str, exec_mode: str,
) -> dict: ) -> dict:
cmd = { msg = Order(
'action': action, action=action,
'price': price, price=price,
'size': size, size=size,
'symbol': symbol, symbol=symbol,
'brokers': brokers, brokers=brokers,
'oid': uuid, oid=uuid,
'exec_mode': exec_mode, # dark or live exec_mode=exec_mode, # dark or live
} )
self._sent_orders[uuid] = cmd
self._to_ems.send_nowait(cmd) self._sent_orders[uuid] = msg
return cmd self._to_ems.send_nowait(msg.dict())
return msg
def update( def update(
self, self,
@ -98,28 +90,27 @@ class OrderBook:
cmd = self._sent_orders[uuid] cmd = self._sent_orders[uuid]
msg = cmd.dict() msg = cmd.dict()
msg.update(data) msg.update(data)
self._sent_orders[uuid] = OrderMsg(**msg) self._sent_orders[uuid] = Order(**msg)
self._to_ems.send_nowait(msg) self._to_ems.send_nowait(msg)
return cmd return cmd
def cancel(self, uuid: str) -> bool: def cancel(self, uuid: str) -> bool:
"""Cancel an order (or alert) from the EMS. """Cancel an order (or alert) in the EMS.
""" """
cmd = self._sent_orders[uuid] cmd = self._sent_orders[uuid]
msg = { msg = Cancel(
'action': 'cancel', oid=uuid,
'oid': uuid, symbol=cmd.symbol,
'symbol': cmd['symbol'], )
} self._to_ems.send_nowait(msg.dict())
self._to_ems.send_nowait(msg)
_orders: OrderBook = None _orders: OrderBook = None
def get_orders( def get_orders(
emsd_uid: Tuple[str, str] = None emsd_uid: tuple[str, str] = None
) -> OrderBook: ) -> OrderBook:
"""" """"
OrderBook singleton factory per actor. OrderBook singleton factory per actor.
@ -139,7 +130,10 @@ def get_orders(
return _orders return _orders
# TODO: we can get rid of this relay loop once we move
# order_mode inputs to async code!
async def relay_order_cmds_from_sync_code( async def relay_order_cmds_from_sync_code(
symbol_key: str, symbol_key: str,
to_ems_stream: tractor.MsgStream, to_ems_stream: tractor.MsgStream,
@ -184,7 +178,8 @@ async def relay_order_cmds_from_sync_code(
async def open_ems( async def open_ems(
broker: str, broker: str,
symbol: Symbol, symbol: Symbol,
) -> None:
) -> (OrderBook, tractor.MsgStream, dict):
"""Spawn an EMS daemon and begin sending orders and receiving """Spawn an EMS daemon and begin sending orders and receiving
alerts. alerts.
@ -232,9 +227,9 @@ async def open_ems(
broker=broker, broker=broker,
symbol=symbol.key, symbol=symbol.key,
# TODO: ``first`` here should be the active orders/execs # TODO: ``first`` here should be the active orders/execs
# persistent on the ems so that loca UI's can be populated. # persistent on the ems so that loca UI's can be populated.
) as (ctx, first), ) as (ctx, positions),
# open 2-way trade command stream # open 2-way trade command stream
ctx.open_stream() as trades_stream, ctx.open_stream() as trades_stream,
@ -246,4 +241,4 @@ async def open_ems(
trades_stream trades_stream
) )
yield book, trades_stream yield book, trades_stream, positions

File diff suppressed because it is too large Load Diff

View File

@ -127,9 +127,9 @@ class OrderMode:
""" """
line = self.lines.commit_line(uuid) line = self.lines.commit_line(uuid)
req_msg = self.book._sent_orders.get(uuid) # req_msg = self.book._sent_orders.get(uuid)
if req_msg: # if req_msg:
req_msg.ack_time_ns = time.time_ns() # req_msg.ack_time_ns = time.time_ns()
return line return line
@ -317,10 +317,14 @@ async def start_order_mode(
# spawn EMS actor-service # spawn EMS actor-service
async with ( async with (
open_ems(brokername, symbol) as (book, trades_stream), open_ems(brokername, symbol) as (book, trades_stream, positions),
open_order_mode(symbol, chart, book) as order_mode open_order_mode(symbol, chart, book) as order_mode
): ):
# update any exising positions
for sym, msg in positions.items():
order_mode.on_position_update(msg)
def get_index(time: float): def get_index(time: float):
# XXX: not sure why the time is so off here # XXX: not sure why the time is so off here
@ -343,16 +347,15 @@ async def start_order_mode(
fmsg = pformat(msg) fmsg = pformat(msg)
log.info(f'Received order msg:\n{fmsg}') log.info(f'Received order msg:\n{fmsg}')
resp = msg['resp'] name = msg['name']
if name in (
if resp in (
'position', 'position',
): ):
# show line label once order is live # show line label once order is live
order_mode.on_position_update(msg) order_mode.on_position_update(msg)
continue continue
# delete the line from view resp = msg['resp']
oid = msg['oid'] oid = msg['oid']
# response to 'action' request (buy/sell) # response to 'action' request (buy/sell)
@ -375,21 +378,21 @@ async def start_order_mode(
order_mode.on_cancel(oid) order_mode.on_cancel(oid)
elif resp in ( elif resp in (
'dark_executed' 'dark_triggered'
): ):
log.info(f'Dark order triggered for {fmsg}') log.info(f'Dark order triggered for {fmsg}')
# for alerts add a triangle and remove the elif resp in (
# level line 'alert_triggered'
if msg['cmd']['action'] == 'alert': ):
# should only be one "fill" for an alert
# should only be one "fill" for an alert # add a triangle and remove the level line
order_mode.on_fill( order_mode.on_fill(
oid, oid,
price=msg['trigger_price'], price=msg['trigger_price'],
arrow_index=get_index(time.time()) arrow_index=get_index(time.time())
) )
await order_mode.on_exec(oid, msg) await order_mode.on_exec(oid, msg)
# response to completed 'action' request for buy/sell # response to completed 'action' request for buy/sell
elif resp in ( elif resp in (
@ -400,12 +403,15 @@ async def start_order_mode(
# each clearing tick is responded individually # each clearing tick is responded individually
elif resp in ('broker_filled',): elif resp in ('broker_filled',):
action = msg['action'] action = book._sent_orders[oid].action
details = msg['brokerd_msg']
# TODO: some kinda progress system # TODO: some kinda progress system
order_mode.on_fill( order_mode.on_fill(
oid, oid,
price=msg['price'], price=details['price'],
arrow_index=get_index(msg['broker_time']),
pointing='up' if action == 'buy' else 'down', pointing='up' if action == 'buy' else 'down',
# TODO: put the actual exchange timestamp
arrow_index=get_index(details['broker_time']),
) )