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 typing import Dict, Tuple, List
from typing import Dict
from pprint import pformat
from dataclasses import dataclass, field
import trio
import tractor
# import msgspec
from ..data._source import Symbol
from ..log import get_logger
from ._ems import _emsd_main
from .._daemon import maybe_open_emsd
from ._messages import Order, Cancel
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
class OrderBook:
"""Buy-side (client-side ?) order book ctl and tracking.
@ -64,31 +53,34 @@ class OrderBook:
_to_ems: trio.abc.SendChannel
_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()
def send(
self,
uuid: str,
symbol: str,
brokers: List[str],
brokers: list[str],
price: float,
size: float,
action: str,
exec_mode: str,
) -> dict:
cmd = {
'action': action,
'price': price,
'size': size,
'symbol': symbol,
'brokers': brokers,
'oid': uuid,
'exec_mode': exec_mode, # dark or live
}
self._sent_orders[uuid] = cmd
self._to_ems.send_nowait(cmd)
return cmd
msg = Order(
action=action,
price=price,
size=size,
symbol=symbol,
brokers=brokers,
oid=uuid,
exec_mode=exec_mode, # dark or live
)
self._sent_orders[uuid] = msg
self._to_ems.send_nowait(msg.dict())
return msg
def update(
self,
@ -98,28 +90,27 @@ class OrderBook:
cmd = self._sent_orders[uuid]
msg = cmd.dict()
msg.update(data)
self._sent_orders[uuid] = OrderMsg(**msg)
self._sent_orders[uuid] = Order(**msg)
self._to_ems.send_nowait(msg)
return cmd
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]
msg = {
'action': 'cancel',
'oid': uuid,
'symbol': cmd['symbol'],
}
self._to_ems.send_nowait(msg)
msg = Cancel(
oid=uuid,
symbol=cmd.symbol,
)
self._to_ems.send_nowait(msg.dict())
_orders: OrderBook = None
def get_orders(
emsd_uid: Tuple[str, str] = None
emsd_uid: tuple[str, str] = None
) -> OrderBook:
""""
OrderBook singleton factory per actor.
@ -139,7 +130,10 @@ def get_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(
symbol_key: str,
to_ems_stream: tractor.MsgStream,
@ -184,7 +178,8 @@ async def relay_order_cmds_from_sync_code(
async def open_ems(
broker: str,
symbol: Symbol,
) -> None:
) -> (OrderBook, tractor.MsgStream, dict):
"""Spawn an EMS daemon and begin sending orders and receiving
alerts.
@ -232,9 +227,9 @@ async def open_ems(
broker=broker,
symbol=symbol.key,
# TODO: ``first`` here should be the active orders/execs
# persistent on the ems so that loca UI's can be populated.
) as (ctx, first),
# TODO: ``first`` here should be the active orders/execs
# persistent on the ems so that loca UI's can be populated.
) as (ctx, positions),
# open 2-way trade command stream
ctx.open_stream() as trades_stream,
@ -246,4 +241,4 @@ async def open_ems(
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)
req_msg = self.book._sent_orders.get(uuid)
if req_msg:
req_msg.ack_time_ns = time.time_ns()
# req_msg = self.book._sent_orders.get(uuid)
# if req_msg:
# req_msg.ack_time_ns = time.time_ns()
return line
@ -317,10 +317,14 @@ async def start_order_mode(
# spawn EMS actor-service
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
):
# update any exising positions
for sym, msg in positions.items():
order_mode.on_position_update(msg)
def get_index(time: float):
# XXX: not sure why the time is so off here
@ -343,16 +347,15 @@ async def start_order_mode(
fmsg = pformat(msg)
log.info(f'Received order msg:\n{fmsg}')
resp = msg['resp']
if resp in (
name = msg['name']
if name in (
'position',
):
# show line label once order is live
order_mode.on_position_update(msg)
continue
# delete the line from view
resp = msg['resp']
oid = msg['oid']
# response to 'action' request (buy/sell)
@ -375,21 +378,21 @@ async def start_order_mode(
order_mode.on_cancel(oid)
elif resp in (
'dark_executed'
'dark_triggered'
):
log.info(f'Dark order triggered for {fmsg}')
# for alerts add a triangle and remove the
# level line
if msg['cmd']['action'] == 'alert':
# should only be one "fill" for an alert
order_mode.on_fill(
oid,
price=msg['trigger_price'],
arrow_index=get_index(time.time())
)
await order_mode.on_exec(oid, msg)
elif resp in (
'alert_triggered'
):
# should only be one "fill" for an alert
# add a triangle and remove the level line
order_mode.on_fill(
oid,
price=msg['trigger_price'],
arrow_index=get_index(time.time())
)
await order_mode.on_exec(oid, msg)
# response to completed 'action' request for buy/sell
elif resp in (
@ -400,12 +403,15 @@ async def start_order_mode(
# each clearing tick is responded individually
elif resp in ('broker_filled',):
action = msg['action']
action = book._sent_orders[oid].action
details = msg['brokerd_msg']
# TODO: some kinda progress system
order_mode.on_fill(
oid,
price=msg['price'],
arrow_index=get_index(msg['broker_time']),
price=details['price'],
pointing='up' if action == 'buy' else 'down',
# TODO: put the actual exchange timestamp
arrow_index=get_index(details['broker_time']),
)