2021-01-01 22:48:22 +00:00
|
|
|
# piker: trading gear for hackers
|
2021-01-05 18:37:03 +00:00
|
|
|
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
|
2021-01-01 22:48:22 +00:00
|
|
|
|
|
|
|
# This program is free software: you can redistribute it and/or modify
|
|
|
|
# it under the terms of the GNU Affero General Public License as published by
|
|
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
|
|
# (at your option) any later version.
|
|
|
|
|
|
|
|
# This program is distributed in the hope that it will be useful,
|
|
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
|
# GNU Affero General Public License for more details.
|
|
|
|
|
|
|
|
# You should have received a copy of the GNU Affero General Public License
|
|
|
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
|
|
|
|
"""
|
2021-02-20 20:25:53 +00:00
|
|
|
In da suit parlances: "Execution management systems"
|
2021-01-01 22:48:22 +00:00
|
|
|
|
|
|
|
"""
|
2021-06-22 11:48:31 +00:00
|
|
|
from contextlib import asynccontextmanager
|
|
|
|
from dataclasses import dataclass, field
|
2021-01-14 17:59:00 +00:00
|
|
|
from pprint import pformat
|
2021-01-12 02:24:14 +00:00
|
|
|
import time
|
2021-08-09 17:20:57 +00:00
|
|
|
from typing import AsyncIterator, Callable
|
2021-01-03 22:19:16 +00:00
|
|
|
|
2021-01-14 17:59:00 +00:00
|
|
|
from bidict import bidict
|
2021-06-01 14:27:16 +00:00
|
|
|
from pydantic import BaseModel
|
2021-01-01 22:48:22 +00:00
|
|
|
import trio
|
2021-06-25 04:57:58 +00:00
|
|
|
from trio_typing import TaskStatus
|
2021-01-01 22:48:22 +00:00
|
|
|
import tractor
|
|
|
|
|
2021-02-22 22:28:34 +00:00
|
|
|
from ..log import get_logger
|
|
|
|
from ..data._normalize import iterticks
|
2021-08-30 21:55:10 +00:00
|
|
|
from ..data.feed import Feed, maybe_open_feed
|
2021-06-25 04:57:58 +00:00
|
|
|
from .._daemon import maybe_spawn_brokerd
|
2021-06-08 16:14:45 +00:00
|
|
|
from . import _paper_engine as paper
|
|
|
|
from ._messages import (
|
|
|
|
Status, Order,
|
|
|
|
BrokerdCancel, BrokerdOrder, BrokerdOrderAck, BrokerdStatus,
|
|
|
|
BrokerdFill, BrokerdError, BrokerdPosition,
|
|
|
|
)
|
2021-01-03 22:19:16 +00:00
|
|
|
|
|
|
|
|
|
|
|
log = get_logger(__name__)
|
2021-01-01 22:48:22 +00:00
|
|
|
|
2021-01-04 19:42:35 +00:00
|
|
|
|
|
|
|
# TODO: numba all of this
|
2021-03-29 12:35:58 +00:00
|
|
|
def mk_check(
|
2021-10-27 16:58:41 +00:00
|
|
|
|
2021-03-29 12:35:58 +00:00
|
|
|
trigger_price: float,
|
|
|
|
known_last: float,
|
|
|
|
action: str,
|
2021-10-27 16:58:41 +00:00
|
|
|
|
2021-03-29 12:35:58 +00:00
|
|
|
) -> Callable[[float, float], bool]:
|
2022-02-10 17:48:13 +00:00
|
|
|
'''
|
|
|
|
Create a predicate for given ``exec_price`` based on last known
|
2021-01-04 19:42:35 +00:00
|
|
|
price, ``known_last``.
|
|
|
|
|
|
|
|
This is an automatic alert level thunk generator based on where the
|
|
|
|
current last known value is and where the specified value of
|
|
|
|
interest is; pick an appropriate comparison operator based on
|
|
|
|
avoiding the case where the a predicate returns true immediately.
|
|
|
|
|
2022-02-10 17:48:13 +00:00
|
|
|
'''
|
2021-01-07 17:03:18 +00:00
|
|
|
# str compares:
|
|
|
|
# https://stackoverflow.com/questions/46708708/compare-strings-in-numba-compiled-function
|
2021-01-04 19:42:35 +00:00
|
|
|
|
|
|
|
if trigger_price >= known_last:
|
|
|
|
|
|
|
|
def check_gt(price: float) -> bool:
|
2021-01-07 17:03:18 +00:00
|
|
|
return price >= trigger_price
|
2021-01-04 19:42:35 +00:00
|
|
|
|
2021-01-19 21:58:01 +00:00
|
|
|
return check_gt
|
2021-01-04 19:42:35 +00:00
|
|
|
|
|
|
|
elif trigger_price <= known_last:
|
|
|
|
|
|
|
|
def check_lt(price: float) -> bool:
|
2021-01-07 17:03:18 +00:00
|
|
|
return price <= trigger_price
|
2021-01-04 19:42:35 +00:00
|
|
|
|
2021-01-19 21:58:01 +00:00
|
|
|
return check_lt
|
2021-01-04 19:42:35 +00:00
|
|
|
|
2022-06-01 18:42:39 +00:00
|
|
|
raise ValueError(
|
|
|
|
f'trigger: {trigger_price}, last: {known_last}'
|
|
|
|
)
|
2021-01-09 15:55:36 +00:00
|
|
|
|
2021-01-04 19:42:35 +00:00
|
|
|
|
|
|
|
@dataclass
|
2021-02-20 20:25:53 +00:00
|
|
|
class _DarkBook:
|
2021-06-25 04:57:58 +00:00
|
|
|
'''EMS-trigger execution book.
|
2021-02-20 20:25:53 +00:00
|
|
|
|
2021-06-25 04:57:58 +00:00
|
|
|
Contains conditions for executions (aka "orders" or "triggers")
|
|
|
|
which are not exposed to brokers and thus the market; i.e. these are
|
|
|
|
privacy focussed "client side" orders which are submitted in real-time
|
|
|
|
based on specified trigger conditions.
|
2021-01-04 19:42:35 +00:00
|
|
|
|
2021-07-08 14:17:31 +00:00
|
|
|
An instance per `brokerd` is created per EMS actor (for now).
|
2021-01-04 19:42:35 +00:00
|
|
|
|
2021-06-25 04:57:58 +00:00
|
|
|
'''
|
2021-01-14 17:59:00 +00:00
|
|
|
broker: str
|
|
|
|
|
2021-01-07 17:03:18 +00:00
|
|
|
# levels which have an executable action (eg. alert, order, signal)
|
2021-06-01 14:27:16 +00:00
|
|
|
orders: dict[
|
2021-01-14 17:59:00 +00:00
|
|
|
str, # symbol
|
2021-06-01 14:27:16 +00:00
|
|
|
dict[
|
2021-01-08 03:08:25 +00:00
|
|
|
str, # uuid
|
2021-06-01 14:27:16 +00:00
|
|
|
tuple[
|
2021-01-08 03:08:25 +00:00
|
|
|
Callable[[float], bool], # predicate
|
|
|
|
str, # name
|
|
|
|
dict, # cmd / msg type
|
|
|
|
]
|
2021-01-04 19:42:35 +00:00
|
|
|
]
|
|
|
|
] = field(default_factory=dict)
|
|
|
|
|
2021-01-07 17:03:18 +00:00
|
|
|
# tracks most recent values per symbol each from data feed
|
2021-06-01 14:27:16 +00:00
|
|
|
lasts: dict[
|
2022-03-18 21:31:09 +00:00
|
|
|
str,
|
|
|
|
float,
|
2021-01-04 19:42:35 +00:00
|
|
|
] = field(default_factory=dict)
|
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
# mapping of piker ems order ids to current brokerd order flow message
|
|
|
|
_ems_entries: dict[str, str] = field(default_factory=dict)
|
|
|
|
_ems2brokerd_ids: dict[str, str] = field(default_factory=bidict)
|
2021-01-04 19:42:35 +00:00
|
|
|
|
2021-01-14 17:59:00 +00:00
|
|
|
|
2021-02-06 16:35:12 +00:00
|
|
|
# XXX: this is in place to prevent accidental positions that are too
|
|
|
|
# big. Now obviously this won't make sense for crypto like BTC, but
|
|
|
|
# for most traditional brokers it should be fine unless you start
|
2021-08-09 15:31:38 +00:00
|
|
|
# slinging NQ futes or something; check ur margin.
|
2021-02-06 16:35:12 +00:00
|
|
|
_DEFAULT_SIZE: float = 1.0
|
2021-01-19 21:58:01 +00:00
|
|
|
|
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
async def clear_dark_triggers(
|
|
|
|
|
|
|
|
brokerd_orders_stream: tractor.MsgStream,
|
|
|
|
ems_client_order_stream: tractor.MsgStream,
|
|
|
|
quote_stream: tractor.ReceiveMsgStream, # noqa
|
2021-02-20 20:25:53 +00:00
|
|
|
broker: str,
|
2022-03-18 21:31:09 +00:00
|
|
|
fqsn: str,
|
2021-06-08 16:14:45 +00:00
|
|
|
|
2021-02-20 20:25:53 +00:00
|
|
|
book: _DarkBook,
|
2021-06-08 16:14:45 +00:00
|
|
|
|
2021-02-20 20:25:53 +00:00
|
|
|
) -> None:
|
2022-02-10 17:48:13 +00:00
|
|
|
'''
|
|
|
|
Core dark order trigger loop.
|
2021-02-20 20:25:53 +00:00
|
|
|
|
|
|
|
Scan the (price) data feed and submit triggered orders
|
|
|
|
to broker.
|
|
|
|
|
2022-02-10 17:48:13 +00:00
|
|
|
'''
|
2021-03-07 18:12:39 +00:00
|
|
|
# XXX: optimize this for speed!
|
2022-02-10 17:48:13 +00:00
|
|
|
# TODO:
|
|
|
|
# - numba all this!
|
|
|
|
# - this stream may eventually contain multiple symbols
|
2021-06-08 16:14:45 +00:00
|
|
|
async for quotes in quote_stream:
|
2021-02-20 20:25:53 +00:00
|
|
|
# start = time.time()
|
|
|
|
for sym, quote in quotes.items():
|
2022-02-10 17:48:13 +00:00
|
|
|
execs = book.orders.get(sym, {})
|
2021-02-20 20:25:53 +00:00
|
|
|
for tick in iterticks(
|
|
|
|
quote,
|
|
|
|
# dark order price filter(s)
|
2022-02-10 17:48:13 +00:00
|
|
|
types=(
|
|
|
|
'ask',
|
|
|
|
'bid',
|
|
|
|
'trade',
|
|
|
|
'last',
|
|
|
|
# 'dark_trade', # TODO: should allow via config?
|
|
|
|
)
|
2021-02-20 20:25:53 +00:00
|
|
|
):
|
|
|
|
price = tick.get('price')
|
|
|
|
ttype = tick['type']
|
|
|
|
|
|
|
|
# update to keep new cmds informed
|
2022-03-18 21:31:09 +00:00
|
|
|
book.lasts[sym] = price
|
2021-02-20 20:25:53 +00:00
|
|
|
|
|
|
|
for oid, (
|
|
|
|
pred,
|
|
|
|
tf,
|
|
|
|
cmd,
|
|
|
|
percent_away,
|
|
|
|
abs_diff_away
|
|
|
|
) in (
|
|
|
|
tuple(execs.items())
|
|
|
|
):
|
2021-10-27 16:58:41 +00:00
|
|
|
if (
|
|
|
|
not pred or
|
|
|
|
ttype not in tf or
|
|
|
|
not pred(price)
|
|
|
|
):
|
|
|
|
log.debug(
|
|
|
|
f'skipping quote for {sym} '
|
|
|
|
f'{pred}, {ttype} not in {tf}?, {pred(price)}'
|
|
|
|
)
|
2021-02-20 20:25:53 +00:00
|
|
|
# majority of iterations will be non-matches
|
|
|
|
continue
|
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
action: str = cmd['action']
|
|
|
|
symbol: str = cmd['symbol']
|
2022-03-18 21:31:09 +00:00
|
|
|
bfqsn: str = symbol.replace(f'.{broker}', '')
|
2021-06-08 16:14:45 +00:00
|
|
|
|
|
|
|
if action == 'alert':
|
|
|
|
# nothing to do but relay a status
|
|
|
|
# message back to the requesting ems client
|
|
|
|
resp = 'alert_triggered'
|
2021-04-29 12:17:40 +00:00
|
|
|
|
2021-06-10 12:24:10 +00:00
|
|
|
else: # executable order submission
|
2021-04-29 12:17:40 +00:00
|
|
|
|
|
|
|
# submit_price = price + price*percent_away
|
|
|
|
submit_price = price + abs_diff_away
|
|
|
|
|
|
|
|
log.info(
|
|
|
|
f'Dark order triggered for price {price}\n'
|
|
|
|
f'Submitting order @ price {submit_price}')
|
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
msg = BrokerdOrder(
|
|
|
|
action=cmd['action'],
|
2021-04-29 12:17:40 +00:00
|
|
|
oid=oid,
|
2021-09-08 19:46:33 +00:00
|
|
|
account=cmd['account'],
|
2021-06-08 16:14:45 +00:00
|
|
|
time_ns=time.time_ns(),
|
|
|
|
|
2021-06-10 12:24:10 +00:00
|
|
|
# this **creates** new order request for the
|
2021-06-08 16:14:45 +00:00
|
|
|
# underlying broker so we set a "broker
|
2021-06-10 12:24:10 +00:00
|
|
|
# request id" (``reqid`` kwarg) to ``None``
|
|
|
|
# so that the broker client knows that we
|
|
|
|
# aren't trying to modify an existing
|
|
|
|
# order-request and instead create a new one.
|
2021-06-08 16:14:45 +00:00
|
|
|
reqid=None,
|
2021-04-29 12:17:40 +00:00
|
|
|
|
2022-03-18 21:31:09 +00:00
|
|
|
symbol=bfqsn,
|
2021-04-29 12:17:40 +00:00
|
|
|
price=submit_price,
|
|
|
|
size=cmd['size'],
|
|
|
|
)
|
2021-06-08 16:14:45 +00:00
|
|
|
await brokerd_orders_stream.send(msg.dict())
|
2021-06-10 12:24:10 +00:00
|
|
|
|
|
|
|
# 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.
|
2021-06-08 16:14:45 +00:00
|
|
|
book._ems_entries[oid] = msg
|
2021-04-29 12:17:40 +00:00
|
|
|
|
2021-06-10 12:24:10 +00:00
|
|
|
# our internal status value for client-side
|
|
|
|
# triggered "dark orders"
|
2021-06-08 16:14:45 +00:00
|
|
|
resp = 'dark_triggered'
|
2021-04-29 12:17:40 +00:00
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
msg = Status(
|
2021-06-10 12:24:10 +00:00
|
|
|
oid=oid, # ems order id
|
2021-06-08 16:14:45 +00:00
|
|
|
resp=resp,
|
|
|
|
time_ns=time.time_ns(),
|
2022-03-18 21:31:09 +00:00
|
|
|
symbol=fqsn,
|
2021-06-08 16:14:45 +00:00
|
|
|
trigger_price=price,
|
|
|
|
broker_details={'name': broker},
|
|
|
|
cmd=cmd, # original request message
|
|
|
|
|
|
|
|
).dict()
|
2021-02-20 20:25:53 +00:00
|
|
|
|
|
|
|
# remove exec-condition from set
|
|
|
|
log.info(f'removing pred for {oid}')
|
2022-01-21 13:46:31 +00:00
|
|
|
pred = execs.pop(oid, None)
|
|
|
|
if not pred:
|
|
|
|
log.warning(
|
|
|
|
f'pred for {oid} was already removed!?'
|
|
|
|
)
|
2021-02-20 20:25:53 +00:00
|
|
|
|
2022-03-24 17:23:34 +00:00
|
|
|
try:
|
|
|
|
await ems_client_order_stream.send(msg)
|
|
|
|
except (
|
|
|
|
trio.ClosedResourceError,
|
|
|
|
):
|
|
|
|
log.warning(
|
|
|
|
f'client {ems_client_order_stream} stream is broke'
|
|
|
|
)
|
|
|
|
break
|
2021-02-20 20:25:53 +00:00
|
|
|
|
|
|
|
else: # condition scan loop complete
|
|
|
|
log.debug(f'execs are {execs}')
|
|
|
|
if execs:
|
2022-03-18 21:31:09 +00:00
|
|
|
book.orders[fqsn] = execs
|
2021-02-20 20:25:53 +00:00
|
|
|
|
|
|
|
# print(f'execs scan took: {time.time() - start}')
|
2021-01-19 00:55:50 +00:00
|
|
|
|
|
|
|
|
2021-06-25 04:57:58 +00:00
|
|
|
@dataclass
|
|
|
|
class TradesRelay:
|
2021-09-10 15:33:08 +00:00
|
|
|
|
|
|
|
# for now we keep only a single connection open with
|
|
|
|
# each ``brokerd`` for simplicity.
|
2021-06-25 04:57:58 +00:00
|
|
|
brokerd_dialogue: tractor.MsgStream
|
2021-09-10 15:33:08 +00:00
|
|
|
|
|
|
|
# map of symbols to dicts of accounts to pp msgs
|
|
|
|
positions: dict[str, dict[str, BrokerdPosition]]
|
|
|
|
|
2021-09-14 14:36:13 +00:00
|
|
|
# allowed account names
|
2021-10-22 16:58:12 +00:00
|
|
|
accounts: tuple[str]
|
2021-09-14 14:36:13 +00:00
|
|
|
|
2021-09-10 15:33:08 +00:00
|
|
|
# count of connected ems clients for this ``brokerd``
|
2021-06-25 04:57:58 +00:00
|
|
|
consumers: int = 0
|
|
|
|
|
|
|
|
|
2021-08-09 15:31:38 +00:00
|
|
|
class Router(BaseModel):
|
2022-02-10 17:48:13 +00:00
|
|
|
'''
|
|
|
|
Order router which manages and tracks per-broker dark book,
|
2021-06-25 04:57:58 +00:00
|
|
|
alerts, clearing and related data feed management.
|
|
|
|
|
|
|
|
A singleton per ``emsd`` actor.
|
2021-06-22 11:48:31 +00:00
|
|
|
|
|
|
|
'''
|
2021-06-25 04:57:58 +00:00
|
|
|
# setup at actor spawn time
|
2021-06-22 11:48:31 +00:00
|
|
|
nursery: trio.Nursery
|
|
|
|
|
2021-06-25 04:57:58 +00:00
|
|
|
# broker to book map
|
2021-06-22 11:48:31 +00:00
|
|
|
books: dict[str, _DarkBook] = {}
|
2021-06-25 04:57:58 +00:00
|
|
|
|
|
|
|
# order id to client stream map
|
|
|
|
clients: set[tractor.MsgStream] = set()
|
2021-06-22 11:48:31 +00:00
|
|
|
dialogues: dict[str, list[tractor.MsgStream]] = {}
|
2021-06-25 04:57:58 +00:00
|
|
|
|
|
|
|
# brokername to trades-dialogues streams with ``brokerd`` actors
|
|
|
|
relays: dict[str, TradesRelay] = {}
|
2021-06-22 11:48:31 +00:00
|
|
|
|
|
|
|
class Config:
|
|
|
|
arbitrary_types_allowed = True
|
|
|
|
underscore_attrs_are_private = False
|
|
|
|
|
|
|
|
def get_dark_book(
|
|
|
|
self,
|
|
|
|
brokername: str,
|
|
|
|
|
|
|
|
) -> _DarkBook:
|
|
|
|
|
|
|
|
return self.books.setdefault(brokername, _DarkBook(brokername))
|
|
|
|
|
2021-06-25 04:57:58 +00:00
|
|
|
@asynccontextmanager
|
|
|
|
async def maybe_open_brokerd_trades_dialogue(
|
|
|
|
|
|
|
|
self,
|
|
|
|
feed: Feed,
|
|
|
|
symbol: str,
|
|
|
|
dark_book: _DarkBook,
|
|
|
|
_exec_mode: str,
|
|
|
|
loglevel: str,
|
|
|
|
|
|
|
|
) -> tuple[dict, tractor.MsgStream]:
|
|
|
|
'''Open and yield ``brokerd`` trades dialogue context-stream if none
|
|
|
|
already exists.
|
|
|
|
|
|
|
|
'''
|
|
|
|
relay = self.relays.get(feed.mod.name)
|
|
|
|
|
|
|
|
if relay is None:
|
|
|
|
|
|
|
|
relay = await self.nursery.start(
|
|
|
|
open_brokerd_trades_dialogue,
|
|
|
|
self,
|
|
|
|
feed,
|
|
|
|
symbol,
|
|
|
|
_exec_mode,
|
|
|
|
loglevel,
|
|
|
|
)
|
|
|
|
|
|
|
|
relay.consumers += 1
|
|
|
|
|
|
|
|
# TODO: get updated positions here?
|
|
|
|
assert relay.brokerd_dialogue
|
|
|
|
try:
|
|
|
|
yield relay
|
|
|
|
|
|
|
|
finally:
|
|
|
|
|
|
|
|
# TODO: what exactly needs to be torn down here or
|
|
|
|
# are we just consumer tracking?
|
|
|
|
|
|
|
|
relay.consumers -= 1
|
|
|
|
|
2021-06-22 11:48:31 +00:00
|
|
|
|
2021-08-09 15:31:38 +00:00
|
|
|
_router: Router = None
|
2021-06-22 11:48:31 +00:00
|
|
|
|
|
|
|
|
2021-06-25 04:57:58 +00:00
|
|
|
async def open_brokerd_trades_dialogue(
|
|
|
|
|
2021-08-09 15:31:38 +00:00
|
|
|
router: Router,
|
2021-06-25 04:57:58 +00:00
|
|
|
feed: Feed,
|
|
|
|
symbol: str,
|
|
|
|
_exec_mode: str,
|
|
|
|
loglevel: str,
|
|
|
|
|
|
|
|
task_status: TaskStatus[TradesRelay] = trio.TASK_STATUS_IGNORED,
|
|
|
|
|
|
|
|
) -> tuple[dict, tractor.MsgStream]:
|
2022-03-18 21:31:09 +00:00
|
|
|
'''
|
|
|
|
Open and yield ``brokerd`` trades dialogue context-stream if none
|
2021-06-25 04:57:58 +00:00
|
|
|
already exists.
|
|
|
|
|
|
|
|
'''
|
|
|
|
trades_endpoint = getattr(feed.mod, 'trades_dialogue', None)
|
|
|
|
|
|
|
|
broker = feed.mod.name
|
|
|
|
|
2021-07-08 14:17:31 +00:00
|
|
|
# TODO: make a `tractor` bug/test for this!
|
2021-09-16 13:17:14 +00:00
|
|
|
# if only i could member what the problem was..
|
|
|
|
# probably some GC of the portal thing?
|
|
|
|
# portal = feed.portal
|
2021-06-25 04:57:58 +00:00
|
|
|
|
|
|
|
# XXX: we must have our own portal + channel otherwise
|
2021-07-08 14:17:31 +00:00
|
|
|
# when the data feed closes it may result in a half-closed
|
2021-06-25 04:57:58 +00:00
|
|
|
# channel that the brokerd side thinks is still open somehow!?
|
|
|
|
async with maybe_spawn_brokerd(
|
|
|
|
|
|
|
|
broker,
|
|
|
|
loglevel=loglevel,
|
|
|
|
|
|
|
|
) as portal:
|
|
|
|
|
|
|
|
if trades_endpoint is None or _exec_mode == 'paper':
|
|
|
|
|
|
|
|
# for paper mode we need to mock this trades response feed
|
|
|
|
# so we load bidir stream to a new sub-actor running a
|
|
|
|
# paper-simulator clearing engine.
|
|
|
|
|
|
|
|
# load the paper trading engine
|
|
|
|
_exec_mode = 'paper'
|
|
|
|
log.warning(f'Entering paper trading mode for {broker}')
|
|
|
|
|
|
|
|
# load the paper trading engine as a subactor of this emsd
|
|
|
|
# actor to simulate the real IPC load it'll have when also
|
|
|
|
# pulling data from feeds
|
|
|
|
open_trades_endpoint = paper.open_paperboi(
|
2022-03-19 17:48:04 +00:00
|
|
|
fqsn='.'.join([symbol, broker]),
|
2021-06-25 04:57:58 +00:00
|
|
|
loglevel=loglevel,
|
|
|
|
)
|
|
|
|
|
|
|
|
else:
|
|
|
|
# open live brokerd trades endpoint
|
|
|
|
open_trades_endpoint = portal.open_context(
|
|
|
|
trades_endpoint,
|
|
|
|
loglevel=loglevel,
|
|
|
|
)
|
|
|
|
|
|
|
|
try:
|
2021-10-29 20:05:50 +00:00
|
|
|
positions: list[BrokerdPosition]
|
|
|
|
accounts: tuple[str]
|
|
|
|
|
2021-06-25 04:57:58 +00:00
|
|
|
async with (
|
2021-09-14 14:36:13 +00:00
|
|
|
open_trades_endpoint as (brokerd_ctx, (positions, accounts,)),
|
2021-06-25 04:57:58 +00:00
|
|
|
brokerd_ctx.open_stream() as brokerd_trades_stream,
|
|
|
|
|
|
|
|
):
|
|
|
|
# XXX: really we only want one stream per `emsd` actor
|
|
|
|
# to relay global `brokerd` order events unless we're
|
|
|
|
# doing to expect each backend to relay only orders
|
|
|
|
# affiliated with a particular ``trades_dialogue()``
|
|
|
|
# session (seems annoying for implementers). So, here
|
|
|
|
# we cache the relay task and instead of running multiple
|
|
|
|
# tasks (which will result in multiples of the same msg being
|
|
|
|
# relayed for each EMS client) we just register each client
|
|
|
|
# stream to this single relay loop using _router.dialogues
|
|
|
|
|
|
|
|
# begin processing order events from the target brokerd backend
|
|
|
|
# by receiving order submission response messages,
|
|
|
|
# normalizing them to EMS messages and relaying back to
|
|
|
|
# the piker order client set.
|
2021-09-13 12:21:42 +00:00
|
|
|
|
|
|
|
# locally cache and track positions per account.
|
2021-09-12 23:30:43 +00:00
|
|
|
pps = {}
|
|
|
|
for msg in positions:
|
2022-03-18 21:31:09 +00:00
|
|
|
log.info(f'loading pp: {msg}')
|
2021-09-14 14:36:13 +00:00
|
|
|
|
|
|
|
account = msg['account']
|
|
|
|
assert account in accounts
|
|
|
|
|
2021-09-13 12:21:42 +00:00
|
|
|
pps.setdefault(
|
2022-03-18 21:31:09 +00:00
|
|
|
f'{msg["symbol"]}.{broker}',
|
2021-09-13 12:21:42 +00:00
|
|
|
{}
|
2021-09-14 14:36:13 +00:00
|
|
|
)[account] = msg
|
2021-06-25 04:57:58 +00:00
|
|
|
|
|
|
|
relay = TradesRelay(
|
|
|
|
brokerd_dialogue=brokerd_trades_stream,
|
2021-09-12 23:30:43 +00:00
|
|
|
positions=pps,
|
2021-10-22 16:58:12 +00:00
|
|
|
accounts=accounts,
|
2021-09-14 14:36:13 +00:00
|
|
|
consumers=1,
|
2021-06-25 04:57:58 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
_router.relays[broker] = relay
|
|
|
|
|
|
|
|
# the ems scan loop may be cancelled by the client but we
|
|
|
|
# want to keep the ``brokerd`` dialogue up regardless
|
|
|
|
|
|
|
|
task_status.started(relay)
|
|
|
|
|
|
|
|
await translate_and_relay_brokerd_events(
|
|
|
|
broker,
|
|
|
|
brokerd_trades_stream,
|
|
|
|
_router,
|
|
|
|
)
|
|
|
|
|
|
|
|
# this context should block here indefinitely until
|
|
|
|
# the ``brokerd`` task either dies or is cancelled
|
|
|
|
|
|
|
|
finally:
|
|
|
|
# parent context must have been closed
|
|
|
|
# remove from cache so next client will respawn if needed
|
2022-04-11 01:51:22 +00:00
|
|
|
relay = _router.relays.pop(broker, None)
|
|
|
|
if not relay:
|
|
|
|
log.warning(f'Relay for {broker} was already removed!?')
|
2021-06-25 04:57:58 +00:00
|
|
|
|
|
|
|
|
2021-06-22 11:48:31 +00:00
|
|
|
@tractor.context
|
|
|
|
async def _setup_persistent_emsd(
|
|
|
|
|
|
|
|
ctx: tractor.Context,
|
|
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
|
|
global _router
|
|
|
|
|
|
|
|
# open a root "service nursery" for the ``emsd`` actor
|
|
|
|
async with trio.open_nursery() as service_nursery:
|
|
|
|
|
2021-08-09 15:31:38 +00:00
|
|
|
_router = Router(nursery=service_nursery)
|
2021-06-22 11:48:31 +00:00
|
|
|
|
2021-06-25 04:57:58 +00:00
|
|
|
# TODO: send back the full set of persistent
|
|
|
|
# orders/execs?
|
2021-06-22 11:48:31 +00:00
|
|
|
await ctx.started()
|
|
|
|
|
2021-06-25 04:57:58 +00:00
|
|
|
# allow service tasks to run until cancelled
|
2021-06-22 11:48:31 +00:00
|
|
|
await trio.sleep_forever()
|
|
|
|
|
2021-01-14 17:59:00 +00:00
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
async def translate_and_relay_brokerd_events(
|
|
|
|
|
|
|
|
broker: str,
|
|
|
|
brokerd_trades_stream: tractor.MsgStream,
|
2021-08-09 15:31:38 +00:00
|
|
|
router: Router,
|
2021-06-08 16:14:45 +00:00
|
|
|
|
2021-01-09 15:55:36 +00:00
|
|
|
) -> AsyncIterator[dict]:
|
2022-02-10 16:58:45 +00:00
|
|
|
'''
|
|
|
|
Trades update loop - receive updates from ``brokerd`` trades
|
2021-06-22 11:48:31 +00:00
|
|
|
endpoint, convert to EMS response msgs, transmit **only** to
|
|
|
|
ordering client(s).
|
2021-01-09 15:55:36 +00:00
|
|
|
|
2021-06-22 11:48:31 +00:00
|
|
|
This is where trade confirmations from the broker are processed and
|
|
|
|
appropriate responses relayed **only** back to the original EMS
|
|
|
|
client actor. There is a messaging translation layer throughout.
|
2021-01-09 15:55:36 +00:00
|
|
|
|
2021-01-19 00:55:50 +00:00
|
|
|
Expected message translation(s):
|
|
|
|
|
|
|
|
broker ems
|
|
|
|
'error' -> log it locally (for now)
|
|
|
|
'status' -> relabel as 'broker_<status>', if complete send 'executed'
|
|
|
|
'fill' -> 'broker_filled'
|
|
|
|
|
2021-06-22 11:48:31 +00:00
|
|
|
Currently handled status values from IB:
|
2021-02-06 16:35:12 +00:00
|
|
|
{'presubmitted', 'submitted', 'cancelled', 'inactive'}
|
2021-01-19 00:55:50 +00:00
|
|
|
|
2021-06-22 11:48:31 +00:00
|
|
|
'''
|
2021-06-25 04:57:58 +00:00
|
|
|
book = router.get_dark_book(broker)
|
|
|
|
relay = router.relays[broker]
|
|
|
|
|
|
|
|
assert relay.brokerd_dialogue == brokerd_trades_stream
|
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
async for brokerd_msg in brokerd_trades_stream:
|
2021-03-07 18:12:39 +00:00
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
name = brokerd_msg['name']
|
2021-01-19 00:55:50 +00:00
|
|
|
|
2022-05-10 13:22:46 +00:00
|
|
|
log.info(
|
|
|
|
f'Received broker trade event:\n'
|
|
|
|
f'{pformat(brokerd_msg)}'
|
|
|
|
)
|
2021-03-12 02:38:59 +00:00
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
if name == 'position':
|
2021-03-12 02:38:59 +00:00
|
|
|
|
2021-06-25 04:57:58 +00:00
|
|
|
pos_msg = BrokerdPosition(**brokerd_msg).dict()
|
|
|
|
|
2021-09-10 15:33:08 +00:00
|
|
|
# XXX: this will be useful for automatic strats yah?
|
|
|
|
# keep pps per account up to date locally in ``emsd`` mem
|
2022-03-18 21:31:09 +00:00
|
|
|
sym, broker = pos_msg['symbol'], pos_msg['broker']
|
|
|
|
|
|
|
|
relay.positions.setdefault(
|
|
|
|
# NOTE: translate to a FQSN!
|
|
|
|
f'{sym}.{broker}',
|
|
|
|
{}
|
|
|
|
).setdefault(
|
2021-09-10 15:33:08 +00:00
|
|
|
pos_msg['account'], {}
|
|
|
|
).update(pos_msg)
|
2021-06-25 04:57:58 +00:00
|
|
|
|
2021-09-10 15:33:08 +00:00
|
|
|
# fan-out-relay position msgs immediately by
|
2021-06-22 11:48:31 +00:00
|
|
|
# broadcasting updates on all client streams
|
2022-03-24 17:23:34 +00:00
|
|
|
for client_stream in router.clients.copy():
|
|
|
|
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?')
|
2021-06-22 11:48:31 +00:00
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
continue
|
2021-01-01 22:48:22 +00:00
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
# Get the broker (order) request id, this **must** be normalized
|
|
|
|
# into messaging provided by the broker backend
|
|
|
|
reqid = brokerd_msg['reqid']
|
2021-02-20 20:25:53 +00:00
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
# all piker originated requests will have an ems generated oid field
|
|
|
|
oid = brokerd_msg.get(
|
|
|
|
'oid',
|
|
|
|
book._ems2brokerd_ids.inverse.get(reqid)
|
|
|
|
)
|
2021-01-19 00:55:50 +00:00
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
if oid is None:
|
2021-01-19 00:55:50 +00:00
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
# XXX: paper clearing special cases
|
|
|
|
# paper engine race case: ``Client.submit_limit()`` hasn't
|
|
|
|
# returned yet and provided an output reqid to register
|
|
|
|
# locally, so we need to retreive the oid that was already
|
|
|
|
# packed at submission since we already know it ahead of
|
|
|
|
# time
|
|
|
|
paper = brokerd_msg['broker_details'].get('paper_info')
|
2022-05-10 13:22:46 +00:00
|
|
|
ext = brokerd_msg['broker_details'].get('external')
|
2021-06-08 16:14:45 +00:00
|
|
|
if paper:
|
|
|
|
# paperboi keeps the ems id up front
|
|
|
|
oid = paper['oid']
|
2021-01-19 00:55:50 +00:00
|
|
|
|
2022-05-10 13:22:46 +00:00
|
|
|
elif ext:
|
2021-06-08 16:14:45 +00:00
|
|
|
# may be an order msg specified as "external" to the
|
|
|
|
# piker ems flow (i.e. generated by some other
|
|
|
|
# external broker backend client (like tws for ib)
|
2022-05-10 13:22:46 +00:00
|
|
|
log.error(f"External trade event {ext}")
|
2021-01-23 04:00:01 +00:00
|
|
|
|
2021-04-29 12:40:16 +00:00
|
|
|
continue
|
2022-05-10 13:22:46 +00:00
|
|
|
|
|
|
|
else:
|
|
|
|
# something is out of order, we don't have an oid for
|
|
|
|
# this broker-side message.
|
|
|
|
log.error(
|
|
|
|
'Unknown oid:{oid} for msg:\n'
|
|
|
|
f'{pformat(brokerd_msg)}'
|
|
|
|
'Unable to relay message to client side!?'
|
|
|
|
)
|
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
else:
|
|
|
|
# check for existing live flow entry
|
|
|
|
entry = book._ems_entries.get(oid)
|
|
|
|
|
|
|
|
# initial response to brokerd order request
|
|
|
|
if name == 'ack':
|
|
|
|
|
2021-06-10 12:24:10 +00:00
|
|
|
# register the brokerd request id (that was generated
|
|
|
|
# / created internally by the broker backend) with our
|
|
|
|
# local ems order id for reverse lookup later.
|
|
|
|
# a ``BrokerdOrderAck`` **must** be sent after an order
|
|
|
|
# request in order to establish this id mapping.
|
2021-06-08 16:14:45 +00:00
|
|
|
book._ems2brokerd_ids[oid] = reqid
|
|
|
|
|
|
|
|
# new order which has not yet be registered into the
|
|
|
|
# local ems book, insert it now and handle 2 cases:
|
|
|
|
|
|
|
|
# - the order has previously been requested to be
|
|
|
|
# cancelled by the ems controlling client before we
|
|
|
|
# received this ack, in which case we relay that cancel
|
|
|
|
# signal **asap** to the backend broker
|
2021-08-29 16:23:01 +00:00
|
|
|
action = getattr(entry, 'action', None)
|
|
|
|
if action and action == 'cancel':
|
2021-06-08 16:14:45 +00:00
|
|
|
# assign newly providerd broker backend request id
|
|
|
|
entry.reqid = reqid
|
|
|
|
|
|
|
|
# tell broker to cancel immediately
|
|
|
|
await brokerd_trades_stream.send(entry.dict())
|
|
|
|
|
|
|
|
# - the order is now active and will be mirrored in
|
|
|
|
# our book -> registered as live flow
|
|
|
|
else:
|
|
|
|
# update the flow with the ack msg
|
|
|
|
book._ems_entries[oid] = BrokerdOrderAck(**brokerd_msg)
|
2021-01-08 03:08:25 +00:00
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
continue
|
2021-02-20 20:25:53 +00:00
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
# a live flow now exists
|
|
|
|
oid = entry.oid
|
|
|
|
|
|
|
|
resp = None
|
|
|
|
broker_details = {}
|
|
|
|
|
|
|
|
if name in (
|
|
|
|
'error',
|
|
|
|
):
|
|
|
|
# TODO: figure out how this will interact with EMS clients
|
|
|
|
# for ex. on an error do we react with a dark orders
|
|
|
|
# management response, like cancelling all dark orders?
|
|
|
|
|
|
|
|
# This looks like a supervision policy for pending orders on
|
|
|
|
# some unexpected failure - something we need to think more
|
|
|
|
# about. In most default situations, with composed orders
|
|
|
|
# (ex. brackets), most brokers seem to use a oca policy.
|
|
|
|
|
|
|
|
msg = BrokerdError(**brokerd_msg)
|
|
|
|
|
|
|
|
# XXX should we make one when it's blank?
|
|
|
|
log.error(pformat(msg))
|
|
|
|
|
|
|
|
# TODO: getting this bs, prolly need to handle status messages
|
|
|
|
# 'Market data farm connection is OK:usfarm.nj'
|
|
|
|
|
|
|
|
# another stupid ib error to handle
|
|
|
|
# if 10147 in message: cancel
|
|
|
|
|
2021-09-08 19:46:33 +00:00
|
|
|
resp = 'broker_errored'
|
|
|
|
broker_details = msg.dict()
|
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
# don't relay message to order requester client
|
2021-09-08 19:46:33 +00:00
|
|
|
# continue
|
2021-06-08 16:14:45 +00:00
|
|
|
|
|
|
|
elif name in (
|
|
|
|
'status',
|
|
|
|
):
|
|
|
|
msg = BrokerdStatus(**brokerd_msg)
|
|
|
|
|
2021-06-22 11:48:31 +00:00
|
|
|
if msg.status == 'cancelled':
|
|
|
|
|
|
|
|
log.info(f'Cancellation for {oid} is complete!')
|
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
if msg.status == 'filled':
|
|
|
|
|
|
|
|
# conditional execution is fully complete, no more
|
|
|
|
# fills for the noted order
|
|
|
|
if not msg.remaining:
|
|
|
|
|
|
|
|
resp = 'broker_executed'
|
|
|
|
|
2021-06-22 11:48:31 +00:00
|
|
|
# be sure to pop this stream from our dialogue set
|
|
|
|
# since the order dialogue should be done.
|
2021-06-08 16:14:45 +00:00
|
|
|
log.info(f'Execution for {oid} is complete!')
|
|
|
|
|
|
|
|
# just log it
|
|
|
|
else:
|
|
|
|
log.info(f'{broker} filled {msg}')
|
2021-02-12 14:07:49 +00:00
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
else:
|
|
|
|
# one of {submitted, cancelled}
|
|
|
|
resp = 'broker_' + msg.status
|
2021-01-19 00:55:50 +00:00
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
# pass the BrokerdStatus msg inside the broker details field
|
|
|
|
broker_details = msg.dict()
|
2021-02-20 20:25:53 +00:00
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
elif name in (
|
|
|
|
'fill',
|
|
|
|
):
|
|
|
|
msg = BrokerdFill(**brokerd_msg)
|
2021-02-20 20:25:53 +00:00
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
# proxy through the "fill" result(s)
|
|
|
|
resp = 'broker_filled'
|
|
|
|
broker_details = msg.dict()
|
2021-01-19 00:55:50 +00:00
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
log.info(f'\nFill for {oid} cleared with:\n{pformat(resp)}')
|
2021-01-12 02:24:14 +00:00
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
else:
|
|
|
|
raise ValueError(f'Brokerd message {brokerd_msg} is invalid')
|
2021-02-20 20:25:53 +00:00
|
|
|
|
2021-06-10 12:24:10 +00:00
|
|
|
# Create and relay response status message
|
|
|
|
# to requesting EMS client
|
2021-06-25 04:57:58 +00:00
|
|
|
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=broker_details,
|
|
|
|
).dict()
|
|
|
|
)
|
|
|
|
except KeyError:
|
|
|
|
log.error(
|
|
|
|
f'Received `brokerd` msg for unknown client with oid: {oid}')
|
2021-02-20 20:25:53 +00:00
|
|
|
|
2021-06-25 04:57:58 +00:00
|
|
|
# TODO: do we want this to keep things cleaned up?
|
|
|
|
# it might require a special status from brokerd to affirm the
|
|
|
|
# flow is complete?
|
2021-07-08 14:17:31 +00:00
|
|
|
# router.dialogues.pop(oid)
|
2021-06-22 11:48:31 +00:00
|
|
|
|
2021-01-09 15:55:36 +00:00
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
async def process_client_order_cmds(
|
2021-01-12 02:24:14 +00:00
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
client_order_stream: tractor.MsgStream, # noqa
|
|
|
|
brokerd_order_stream: tractor.MsgStream,
|
2021-06-01 14:27:16 +00:00
|
|
|
|
2021-03-07 21:25:47 +00:00
|
|
|
symbol: str,
|
2021-06-22 11:48:31 +00:00
|
|
|
feed: Feed, # noqa
|
2021-03-07 21:25:47 +00:00
|
|
|
dark_book: _DarkBook,
|
2021-08-09 15:31:38 +00:00
|
|
|
router: Router,
|
2021-06-01 14:27:16 +00:00
|
|
|
|
2021-03-07 21:25:47 +00:00
|
|
|
) -> None:
|
|
|
|
|
2021-06-25 04:57:58 +00:00
|
|
|
client_dialogues = router.dialogues
|
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
# cmd: dict
|
|
|
|
async for cmd in client_order_stream:
|
2021-03-07 21:25:47 +00:00
|
|
|
|
|
|
|
log.info(f'Received order cmd:\n{pformat(cmd)}')
|
|
|
|
|
|
|
|
action = cmd['action']
|
|
|
|
oid = cmd['oid']
|
2021-06-22 11:48:31 +00:00
|
|
|
|
2021-06-25 04:57:58 +00:00
|
|
|
# TODO: make ``tractor.MsgStream`` a frozen type again such that it
|
|
|
|
# can be stored in sets like the old context was.
|
|
|
|
# wait, maybe this **is** already working thanks to our parent
|
|
|
|
# `trio` type?
|
|
|
|
|
2021-06-22 11:48:31 +00:00
|
|
|
# register this stream as an active dialogue for this order id
|
|
|
|
# such that translated message from the brokerd backend can be
|
|
|
|
# routed (relayed) to **just** that client stream (and in theory
|
|
|
|
# others who are registered for such order affiliated msgs).
|
2021-06-25 04:57:58 +00:00
|
|
|
client_dialogues[oid] = client_order_stream
|
2021-06-22 11:48:31 +00:00
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
reqid = dark_book._ems2brokerd_ids.inverse.get(oid)
|
|
|
|
live_entry = dark_book._ems_entries.get(oid)
|
2021-03-07 21:25:47 +00:00
|
|
|
|
|
|
|
# TODO: can't wait for this stuff to land in 3.10
|
|
|
|
# https://www.python.org/dev/peps/pep-0636/#going-to-the-cloud-mappings
|
|
|
|
if action in ('cancel',):
|
|
|
|
|
|
|
|
# check for live-broker order
|
2021-06-08 16:14:45 +00:00
|
|
|
if live_entry:
|
2021-06-22 14:57:08 +00:00
|
|
|
reqid = live_entry.reqid
|
2021-06-22 11:48:31 +00:00
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
msg = BrokerdCancel(
|
|
|
|
oid=oid,
|
2021-06-22 11:48:31 +00:00
|
|
|
reqid=reqid,
|
2021-06-08 16:14:45 +00:00
|
|
|
time_ns=time.time_ns(),
|
2021-09-08 19:46:33 +00:00
|
|
|
account=live_entry.account,
|
2021-06-08 16:14:45 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
# NOTE: cancel response will be relayed back in messages
|
|
|
|
# from corresponding broker
|
2021-06-22 14:57:08 +00:00
|
|
|
if reqid:
|
2021-06-08 16:14:45 +00:00
|
|
|
|
2021-06-22 14:57:08 +00:00
|
|
|
# send cancel to brokerd immediately!
|
2022-05-10 13:22:46 +00:00
|
|
|
log.info(
|
|
|
|
f'Submitting cancel for live order {reqid}'
|
|
|
|
)
|
2021-06-22 14:57:08 +00:00
|
|
|
|
|
|
|
await brokerd_order_stream.send(msg.dict())
|
2021-06-08 16:14:45 +00:00
|
|
|
|
2021-06-22 14:57:08 +00:00
|
|
|
else:
|
|
|
|
# this might be a cancel for an order that hasn't been
|
|
|
|
# acked yet by a brokerd, so register a cancel for when
|
|
|
|
# the order ack does show up later such that the brokerd
|
|
|
|
# order request can be cancelled at that time.
|
|
|
|
dark_book._ems_entries[oid] = msg
|
|
|
|
|
|
|
|
# dark trigger cancel
|
|
|
|
else:
|
2021-03-07 21:25:47 +00:00
|
|
|
try:
|
2021-06-08 16:14:45 +00:00
|
|
|
# remove from dark book clearing
|
2021-03-07 21:25:47 +00:00
|
|
|
dark_book.orders[symbol].pop(oid, None)
|
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
# tell client side that we've cancelled the
|
|
|
|
# dark-trigger order
|
|
|
|
await client_order_stream.send(
|
|
|
|
Status(
|
|
|
|
resp='dark_cancelled',
|
|
|
|
oid=oid,
|
|
|
|
time_ns=time.time_ns(),
|
|
|
|
).dict()
|
|
|
|
)
|
2021-06-22 11:48:31 +00:00
|
|
|
# de-register this client dialogue
|
|
|
|
router.dialogues.pop(oid)
|
2021-06-08 16:14:45 +00:00
|
|
|
|
2021-03-07 21:25:47 +00:00
|
|
|
except KeyError:
|
|
|
|
log.exception(f'No dark order for {symbol}?')
|
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
# TODO: 3.10 struct-pattern matching and unpacking here
|
2021-03-07 21:25:47 +00:00
|
|
|
elif action in ('alert', 'buy', 'sell',):
|
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
msg = Order(**cmd)
|
|
|
|
|
2022-03-18 21:31:09 +00:00
|
|
|
fqsn = msg.symbol
|
2021-06-08 16:14:45 +00:00
|
|
|
trigger_price = msg.price
|
|
|
|
size = msg.size
|
|
|
|
exec_mode = msg.exec_mode
|
|
|
|
broker = msg.brokers[0]
|
2022-03-18 21:31:09 +00:00
|
|
|
# remove the broker part before creating a message
|
|
|
|
# to send to the specific broker since they probably
|
|
|
|
# aren't expectig their own name, but should they?
|
|
|
|
sym = fqsn.replace(f'.{broker}', '')
|
2021-03-07 21:25:47 +00:00
|
|
|
|
|
|
|
if exec_mode == 'live' and action in ('buy', 'sell',):
|
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
if live_entry is not None:
|
2021-03-07 21:25:47 +00:00
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
# sanity check on emsd id
|
|
|
|
assert live_entry.oid == oid
|
2021-08-17 18:07:07 +00:00
|
|
|
reqid = live_entry.reqid
|
2021-06-08 16:14:45 +00:00
|
|
|
# if we already had a broker order id then
|
|
|
|
# this is likely an order update commmand.
|
2021-08-17 18:07:07 +00:00
|
|
|
log.info(f"Modifying live {broker} order: {reqid}")
|
2021-06-08 16:14:45 +00:00
|
|
|
|
|
|
|
msg = BrokerdOrder(
|
2021-03-07 21:25:47 +00:00
|
|
|
oid=oid, # no ib support for oids...
|
2021-06-08 16:14:45 +00:00
|
|
|
time_ns=time.time_ns(),
|
2021-03-07 21:25:47 +00:00
|
|
|
|
|
|
|
# if this is None, creates a new order
|
|
|
|
# otherwise will modify any existing one
|
2021-06-08 16:14:45 +00:00
|
|
|
reqid=reqid,
|
2021-03-07 21:25:47 +00:00
|
|
|
|
|
|
|
symbol=sym,
|
|
|
|
action=action,
|
|
|
|
price=trigger_price,
|
|
|
|
size=size,
|
2021-09-08 19:46:33 +00:00
|
|
|
account=msg.account,
|
2021-03-07 21:25:47 +00:00
|
|
|
)
|
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
# send request to backend
|
|
|
|
# XXX: the trades data broker response loop
|
|
|
|
# (``translate_and_relay_brokerd_events()`` above) will
|
|
|
|
# handle relaying the ems side responses back to
|
|
|
|
# the client/cmd sender from this request
|
2021-06-10 12:24:10 +00:00
|
|
|
log.info(f'Sending live order to {broker}:\n{pformat(msg)}')
|
2021-06-08 16:14:45 +00:00
|
|
|
await brokerd_order_stream.send(msg.dict())
|
2021-03-07 21:25:47 +00:00
|
|
|
|
2021-06-22 11:48:31 +00:00
|
|
|
# an immediate response should be ``BrokerdOrderAck``
|
|
|
|
# with ems order id from the ``trades_dialogue()``
|
|
|
|
# endpoint, but we register our request as part of the
|
|
|
|
# flow so that if a cancel comes from the requesting
|
|
|
|
# client, before that ack, when the ack does arrive we
|
|
|
|
# immediately take the reqid from the broker and cancel
|
2021-06-25 04:57:58 +00:00
|
|
|
# that live order asap.
|
2021-06-08 16:14:45 +00:00
|
|
|
dark_book._ems_entries[oid] = msg
|
2021-03-07 21:25:47 +00:00
|
|
|
|
2021-06-22 11:48:31 +00:00
|
|
|
# "DARK" triggers
|
|
|
|
# submit order to local EMS book and scan loop,
|
|
|
|
# effectively a local clearing engine, which
|
|
|
|
# scans for conditions and triggers matching executions
|
2021-03-07 21:25:47 +00:00
|
|
|
elif exec_mode in ('dark', 'paper') or (
|
|
|
|
action in ('alert')
|
|
|
|
):
|
|
|
|
# Auto-gen scanner predicate:
|
|
|
|
# we automatically figure out what the alert check
|
|
|
|
# condition should be based on the current first
|
|
|
|
# price received from the feed, instead of being
|
|
|
|
# like every other shitty tina platform that makes
|
|
|
|
# the user choose the predicate operator.
|
2022-03-18 21:31:09 +00:00
|
|
|
last = dark_book.lasts[fqsn]
|
2021-03-29 12:35:58 +00:00
|
|
|
pred = mk_check(trigger_price, last, action)
|
2021-03-07 21:25:47 +00:00
|
|
|
|
2021-03-31 18:20:37 +00:00
|
|
|
spread_slap: float = 5
|
2021-03-07 21:25:47 +00:00
|
|
|
min_tick = feed.symbols[sym].tick_size
|
|
|
|
|
|
|
|
if action == 'buy':
|
|
|
|
tickfilter = ('ask', 'last', 'trade')
|
|
|
|
percent_away = 0.005
|
|
|
|
|
|
|
|
# TODO: we probably need to scale this based
|
|
|
|
# on some near term historical spread
|
|
|
|
# measure?
|
2021-03-31 18:20:37 +00:00
|
|
|
abs_diff_away = spread_slap * min_tick
|
2021-03-07 21:25:47 +00:00
|
|
|
|
|
|
|
elif action == 'sell':
|
|
|
|
tickfilter = ('bid', 'last', 'trade')
|
|
|
|
percent_away = -0.005
|
2021-03-31 18:20:37 +00:00
|
|
|
abs_diff_away = -spread_slap * min_tick
|
2021-03-07 21:25:47 +00:00
|
|
|
|
|
|
|
else: # alert
|
|
|
|
tickfilter = ('trade', 'utrade', 'last')
|
|
|
|
percent_away = 0
|
|
|
|
abs_diff_away = 0
|
|
|
|
|
|
|
|
# submit execution/order to EMS scan loop
|
2021-06-08 16:14:45 +00:00
|
|
|
|
|
|
|
# NOTE: this may result in an override of an existing
|
2021-03-07 21:25:47 +00:00
|
|
|
# dark book entry if the order id already exists
|
2021-06-08 16:14:45 +00:00
|
|
|
|
2021-03-07 21:25:47 +00:00
|
|
|
dark_book.orders.setdefault(
|
2022-03-18 21:31:09 +00:00
|
|
|
fqsn, {}
|
2021-03-07 21:25:47 +00:00
|
|
|
)[oid] = (
|
|
|
|
pred,
|
|
|
|
tickfilter,
|
|
|
|
cmd,
|
|
|
|
percent_away,
|
|
|
|
abs_diff_away
|
|
|
|
)
|
2021-06-22 11:48:31 +00:00
|
|
|
resp = 'dark_submitted'
|
2021-06-08 16:14:45 +00:00
|
|
|
|
2021-06-22 11:48:31 +00:00
|
|
|
# alerts have special msgs to distinguish
|
2021-06-08 16:14:45 +00:00
|
|
|
if action == 'alert':
|
|
|
|
resp = 'alert_submitted'
|
|
|
|
|
|
|
|
await client_order_stream.send(
|
|
|
|
Status(
|
|
|
|
resp=resp,
|
|
|
|
oid=oid,
|
|
|
|
time_ns=time.time_ns(),
|
|
|
|
).dict()
|
|
|
|
)
|
2021-03-07 21:25:47 +00:00
|
|
|
|
|
|
|
|
2021-06-01 14:27:16 +00:00
|
|
|
@tractor.context
|
2021-03-07 18:12:39 +00:00
|
|
|
async def _emsd_main(
|
2021-06-01 14:27:16 +00:00
|
|
|
|
2021-01-14 17:59:00 +00:00
|
|
|
ctx: tractor.Context,
|
2022-03-18 21:31:09 +00:00
|
|
|
fqsn: str,
|
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
_exec_mode: str = 'dark', # ('paper', 'dark', 'live')
|
|
|
|
loglevel: str = 'info',
|
2021-06-01 14:27:16 +00:00
|
|
|
|
2021-01-14 17:59:00 +00:00
|
|
|
) -> None:
|
2021-06-10 12:24:10 +00:00
|
|
|
'''EMS (sub)actor entrypoint providing the
|
2021-01-19 00:55:50 +00:00
|
|
|
execution management (micro)service which conducts broker
|
2021-09-14 14:36:13 +00:00
|
|
|
order clearing control on behalf of clients.
|
2021-01-19 00:55:50 +00:00
|
|
|
|
|
|
|
This is the daemon (child) side routine which starts an EMS runtime
|
2021-09-14 14:36:13 +00:00
|
|
|
task (one per broker-feed) and and begins streaming back alerts from
|
|
|
|
each broker's executions/fills.
|
2021-01-19 00:55:50 +00:00
|
|
|
|
|
|
|
``send_order_cmds()`` is called here to execute in a task back in
|
|
|
|
the actor which started this service (spawned this actor), presuming
|
|
|
|
capabilities allow it, such that requests for EMS executions 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.
|
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
The primary ``emsd`` task tree is:
|
2021-01-08 03:08:25 +00:00
|
|
|
|
2021-06-08 16:14:45 +00:00
|
|
|
- ``_emsd_main()``:
|
|
|
|
sets up brokerd feed, order feed with ems client, trades dialogue with
|
|
|
|
brokderd trading api.
|
|
|
|
|
|
2021-06-08 16:50:52 +00:00
|
|
|
- ``clear_dark_triggers()``:
|
|
|
|
run (dark order) conditions on inputs and trigger brokerd "live"
|
|
|
|
order submissions.
|
2021-06-08 16:14:45 +00:00
|
|
|
|
|
2021-06-25 04:57:58 +00:00
|
|
|
- (maybe) ``translate_and_relay_brokerd_events()``:
|
2021-06-08 16:14:45 +00:00
|
|
|
accept normalized trades responses from brokerd, process and
|
|
|
|
relay to ems client(s); this is a effectively a "trade event
|
|
|
|
reponse" proxy-broker.
|
|
|
|
|
|
|
|
|
- ``process_client_order_cmds()``:
|
2021-09-14 14:36:13 +00:00
|
|
|
accepts order cmds from requesting clients, registers dark orders and
|
|
|
|
alerts with clearing loop.
|
2021-01-08 03:08:25 +00:00
|
|
|
|
2021-06-10 12:24:10 +00:00
|
|
|
'''
|
2021-06-01 14:27:16 +00:00
|
|
|
global _router
|
2021-06-22 11:48:31 +00:00
|
|
|
assert _router
|
|
|
|
|
2022-04-11 05:01:36 +00:00
|
|
|
from ..data._source import unpack_fqsn
|
|
|
|
broker, symbol, suffix = unpack_fqsn(fqsn)
|
2021-06-01 14:27:16 +00:00
|
|
|
dark_book = _router.get_dark_book(broker)
|
2021-01-09 15:55:36 +00:00
|
|
|
|
2021-06-10 12:24:10 +00:00
|
|
|
# TODO: would be nice if in tractor we can require either a ctx arg,
|
|
|
|
# or a named arg with ctx in it and a type annotation of
|
|
|
|
# tractor.Context instead of strictly requiring a ctx arg.
|
2021-06-08 16:14:45 +00:00
|
|
|
ems_ctx = ctx
|
|
|
|
|
2021-08-09 15:31:38 +00:00
|
|
|
feed: Feed
|
2021-06-08 16:14:45 +00:00
|
|
|
|
2021-05-24 16:09:03 +00:00
|
|
|
# spawn one task per broker feed
|
2021-06-08 16:14:45 +00:00
|
|
|
async with (
|
2021-08-30 21:55:10 +00:00
|
|
|
maybe_open_feed(
|
2022-03-18 21:31:09 +00:00
|
|
|
[fqsn],
|
2021-06-08 16:14:45 +00:00
|
|
|
loglevel=loglevel,
|
2022-03-18 21:31:09 +00:00
|
|
|
) as (feed, quote_stream),
|
2021-06-08 16:14:45 +00:00
|
|
|
):
|
|
|
|
|
|
|
|
# XXX: this should be initial price quote from target provider
|
2022-03-18 21:31:09 +00:00
|
|
|
first_quote = feed.first_quotes[fqsn]
|
2021-06-08 16:14:45 +00:00
|
|
|
|
|
|
|
book = _router.get_dark_book(broker)
|
2022-03-18 21:31:09 +00:00
|
|
|
book.lasts[fqsn] = first_quote['last']
|
2022-02-06 17:21:11 +00:00
|
|
|
|
2021-08-09 15:31:38 +00:00
|
|
|
# open a stream with the brokerd backend for order
|
|
|
|
# flow dialogue
|
2021-06-22 11:48:31 +00:00
|
|
|
async with (
|
2021-06-08 16:14:45 +00:00
|
|
|
|
2021-06-22 11:48:31 +00:00
|
|
|
# only open if one isn't already up: we try to keep
|
|
|
|
# as few duplicate streams as necessary
|
2021-06-25 04:57:58 +00:00
|
|
|
_router.maybe_open_brokerd_trades_dialogue(
|
2021-06-22 11:48:31 +00:00
|
|
|
feed,
|
|
|
|
symbol,
|
|
|
|
dark_book,
|
|
|
|
_exec_mode,
|
|
|
|
loglevel,
|
2021-06-08 16:14:45 +00:00
|
|
|
|
2021-06-25 04:57:58 +00:00
|
|
|
) as relay,
|
2021-06-08 16:14:45 +00:00
|
|
|
|
2021-06-22 11:48:31 +00:00
|
|
|
trio.open_nursery() as n,
|
2021-06-08 16:14:45 +00:00
|
|
|
):
|
2021-06-22 11:48:31 +00:00
|
|
|
|
2021-06-25 04:57:58 +00:00
|
|
|
brokerd_stream = relay.brokerd_dialogue # .clone()
|
|
|
|
|
2021-09-14 14:36:13 +00:00
|
|
|
# flatten out collected pps from brokerd for delivery
|
|
|
|
pp_msgs = {
|
2022-03-18 21:31:09 +00:00
|
|
|
fqsn: list(pps.values())
|
|
|
|
for fqsn, pps in relay.positions.items()
|
2021-09-14 14:36:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
# signal to client that we're started and deliver
|
|
|
|
# all known pps and accounts for this ``brokerd``.
|
2021-09-28 22:42:23 +00:00
|
|
|
await ems_ctx.started((pp_msgs, list(relay.accounts)))
|
2021-06-08 16:14:45 +00:00
|
|
|
|
|
|
|
# establish 2-way stream with requesting order-client and
|
|
|
|
# begin handling inbound order requests and updates
|
|
|
|
async with ems_ctx.open_stream() as ems_client_order_stream:
|
|
|
|
|
|
|
|
# trigger scan and exec loop
|
|
|
|
n.start_soon(
|
|
|
|
clear_dark_triggers,
|
|
|
|
|
2021-06-25 04:57:58 +00:00
|
|
|
brokerd_stream,
|
2021-06-08 16:14:45 +00:00
|
|
|
ems_client_order_stream,
|
2022-03-18 21:31:09 +00:00
|
|
|
quote_stream,
|
2021-06-01 14:27:16 +00:00
|
|
|
broker,
|
2022-03-18 21:31:09 +00:00
|
|
|
fqsn, # form: <name>.<venue>.<suffix>.<broker>
|
2021-06-08 16:14:45 +00:00
|
|
|
book
|
2021-06-01 14:27:16 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
# start inbound (from attached client) order request processing
|
2021-06-25 04:57:58 +00:00
|
|
|
try:
|
|
|
|
_router.clients.add(ems_client_order_stream)
|
2021-06-22 11:48:31 +00:00
|
|
|
|
2021-09-08 19:46:33 +00:00
|
|
|
# main entrypoint, run here until cancelled.
|
2021-06-25 04:57:58 +00:00
|
|
|
await process_client_order_cmds(
|
|
|
|
|
|
|
|
ems_client_order_stream,
|
|
|
|
|
|
|
|
# relay.brokerd_dialogue,
|
|
|
|
brokerd_stream,
|
|
|
|
|
2022-03-18 21:31:09 +00:00
|
|
|
fqsn,
|
2021-06-25 04:57:58 +00:00
|
|
|
feed,
|
|
|
|
dark_book,
|
|
|
|
_router,
|
|
|
|
)
|
|
|
|
|
|
|
|
finally:
|
|
|
|
# remove client from "registry"
|
|
|
|
_router.clients.remove(ems_client_order_stream)
|
|
|
|
|
|
|
|
dialogues = _router.dialogues
|
|
|
|
|
2021-09-08 19:46:33 +00:00
|
|
|
for oid, client_stream in dialogues.copy().items():
|
2021-06-25 04:57:58 +00:00
|
|
|
|
|
|
|
if client_stream == ems_client_order_stream:
|
|
|
|
|
|
|
|
log.warning(
|
|
|
|
f'client dialogue is being abandoned:\n'
|
|
|
|
f'{oid} ->\n{client_stream._ctx.chan.uid}'
|
|
|
|
)
|
|
|
|
dialogues.pop(oid)
|
|
|
|
|
|
|
|
# TODO: for order dialogues left "alive" in
|
|
|
|
# the ems this is where we should allow some
|
|
|
|
# system to take over management. Likely we
|
|
|
|
# want to allow the user to choose what kind
|
|
|
|
# of policy to use (eg. cancel all orders
|
2021-07-08 14:17:31 +00:00
|
|
|
# from client, run some algo, etc.)
|