# piker: trading gear for hackers # Copyright (C) Tyler Goodlet (in stewardship for pikers) # 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 . """ Chart trading, the only way to scalp. """ from __future__ import annotations from contextlib import asynccontextmanager from dataclasses import dataclass, field from decimal import Decimal from functools import partial from pprint import pformat import time from typing import ( Callable, Any, TYPE_CHECKING, ) import uuid from bidict import bidict import tractor import trio from piker import config from piker.accounting import ( Allocator, Position, mk_allocator, MktPair, ) from piker.clearing import ( open_ems, OrderClient, ) from piker.clearing._messages import ( Order, Status, BrokerdPosition, ) from piker.data import ( Feed, Flume, ) from piker.types import Struct from piker.log import get_logger from piker.ui.qt import Qt from ._editors import ( LineEditor, ArrowEditor, ) from ._lines import ( order_line, LevelLine, ) from ._position import ( PositionTracker, SettingsPane, ) from ._forms import FieldsForm from ._window import MultiStatus from ._style import _font from ._forms import open_form_input_handling from ._notify import notify_from_ems_status_msg if TYPE_CHECKING: from ._chart import ( ChartPlotWidget, GodWidget, ) log = get_logger(__name__) class Dialog(Struct): ''' Trade dialogue meta-data describing the lifetime of an order submission to ``emsd`` from a chart. ''' # TODO: use ``pydantic.UUID4`` field uuid: str order: Order symbol: str lines: list[LevelLine] last_status_close: Callable | None = None msgs: dict[str, dict] = {} fills: dict[str, Any] = {} @dataclass class OrderMode: ''' Major UX mode for placing orders on a chart view providing so called, "chart trading". This is the other "main" mode that pairs with "view mode" (when wathing the rt price update at the current time step) and allows entering orders using mouse and keyboard. This object is chart oriented, so there is an instance per chart / view currently. Current manual: a -> alert s/ctrl -> submission type modifier {on: live, off: dark} f (fill) -> 'buy' limit order d (dump) -> 'sell' limit order c (cancel) -> cancel order under cursor cc -> cancel all submitted orders on chart mouse click and drag -> modify current order under cursor ''' godw: GodWidget feed: Feed chart: ChartPlotWidget # type: ignore # noqa hist_chart: ChartPlotWidget # type: ignore # noqa nursery: trio.Nursery # used by ``ui._position`` code? client: OrderClient lines: LineEditor arrows: ArrowEditor multistatus: MultiStatus pane: SettingsPane trackers: dict[str, PositionTracker] # switched state, the current position current_pp: PositionTracker | None = None active: bool = False name: str = 'order' dialogs: dict[str, Dialog] = field(default_factory=dict) _colors = { 'alert': 'alert_yellow', 'buy': 'buy_green', 'sell': 'sell_red', } _staged_order: Order | None = None @property def curr_mkt(self) -> MktPair: ''' Deliver the currently selected `MktPair` according chart state. ''' return self.chart.linked.mkt 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.new_line_from_order()`` ''' # NOTE: the ``Order.account`` is set at order stage time inside # ``OrderMode.new_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 mkt: MktPair = self.curr_mkt order.price: Decimal = mkt.quantize( size=level, quantity_type='price', ) 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 new_line_from_order( self, order: Order, chart: ChartPlotWidget | None = None, **line_kwargs, ) -> LevelLine: # TODO, if we instead just always decimalize at the ems layer # we can avoid this back-n-forth casting? level = float(order.price) line = order_line( chart or self.chart, # TODO: convert these values into human-readable form # (i.e. with k, m, M, B) type embedded suffixes level=level, action=order.action, size=order.size, color=self._colors[order.action], dotted=True if ( order.exec_mode == 'dark' and order.action != 'alert' ) else False, **line_kwargs, ) # set level update callback to order pane method and update once # immediately if order.action != 'alert': line._on_level_change = partial( self.on_level_change_update_next_order_info, line=line, order=order, # use the corresponding position tracker for the # order's account. tracker=self.trackers[order.account], ) else: # for alerts we don't need to compute per price sizing via # the order mode allocator but we still need to update the # "staged" order message we'll send to the ems def update_order_price(y: float) -> None: mkt: MktPair = self.curr_mkt order.price: Decimal = mkt.quantize( size=y, quantity_type='price', ) line._on_level_change = update_order_price line.set_level(level) return line def lines_from_order( self, order: Order, **line_kwargs, ) -> list[LevelLine]: lines: list[LevelLine] = [] for chart, kwargs in [ (self.chart, {}), (self.hist_chart, {'only_show_markers_on_hover': True}), ]: kwargs.update(line_kwargs) line = self.new_line_from_order( order=order, chart=chart, **kwargs, ) lines.append(line) return lines def stage_order( self, action: str, trigger_type: str, ) -> list[LevelLine]: ''' Stage an order for submission by showing level lines and configuring the order request message dynamically based on allocator settings. ''' # not initialized yet cursor = self.godw.get_cursor() if not cursor: return chart = cursor.linked.chart if ( not chart and cursor and cursor.active_plot ): return chart = cursor.active_plot price: float = cursor._datum_xy[1] if not price: # zero prices are not supported by any means # since that's illogical / a no-op. return # TODO: should we be enforcing this precision # at a different layer in the stack? # |_ might require `MktPair` tracking in the EMS? # |_ right now any precision error will be relayed # all the way back from the backend and vice-versa.. # mkt: MktPair = self.curr_mkt price: Decimal = mkt.quantize( size=price, quantity_type='price', ) order = self._staged_order = Order( action=action, price=price, account=self.current_pp.alloc.account, size=0, symbol=mkt.fqme, brokers=[mkt.broker], oid='', # filled in on submit exec_mode=trigger_type, # dark or live ) # TODO: staged line mirroring? - need to keep track of multiple # staged lines in editor - need to call # `LineEditor.unstage_line()` on all staged lines.. # lines = self.lines_from_order( line = self.new_line_from_order( order, chart=chart, show_markers=True, # just for the stage line to avoid # flickering while moving the cursor # around where it might trigger highlight # then non-highlight depending on sensitivity always_show_labels=True, # don't highlight the "staging" line highlight_on_hover=False, # prevent flickering of marker while moving/tracking cursor only_show_markers_on_hover=False, ) self.lines.stage_line(line) # add line to cursor trackers cursor._trackers.add(line) # TODO: see above about mirroring. # for line in lines: # if line._chart is chart: # self.lines.stage_line(line) # cursor._trackers.add(line) # break # hide crosshair y-line and label cursor.hide_xhair() return line def submit_order( self, send_msg: bool = True, order: Order | None = None, ) -> Dialog|None: ''' Send execution order to EMS return a level line to represent the order on a chart. ''' if not order: staged: Order = self._staged_order # apply order fields for ems oid = str(uuid.uuid4()) # NOTE: we have to str-ify `MktPair` first since we can't # cast to it without being mega explicit with # `msgspec.Struct`, which we're not yet.. order: Order = staged.copy({ 'symbol': str(staged.symbol), 'oid': oid, }) if float(order.price) <= 0: log.error( '*!? Invalid `Order.price <= 0` ?!*\n' # TODO: make this present multi-line in object form # like `ib_insync.contracts.Contract.__repr__()` f'{order}\n' ) self.cancel_orders([order.oid]) return None lines = self.lines_from_order( order, show_markers=True, ) # register the "submitted" line under the cursor # to be displayed when above order ack arrives # (means the marker graphic doesn't show on screen until the # order is live in the emsd). # TODO: update the line once an ack event comes back # from the EMS! # maybe place a grey line in "submission" mode # which will be updated to it's appropriate action # color once the submission ack arrives. self.lines.submit_lines( lines=lines, uuid=order.oid, ) dialog = Dialog( uuid=order.oid, order=order, symbol=order.symbol, # XXX: always a str? lines=lines, last_status_close=self.multistatus.open_status( f'submitting {order.exec_mode}-{order.action}', # final_msg=f'submitted {order.exec_mode}-{order.action}', clear_on_next=True, ) ) # enter submission which will be popped once a response # from the EMS is received to move the order to a different# status self.dialogs[order.oid] = dialog for line in lines: # TODO: create a new ``OrderLine`` with this optional var defined line.dialog = dialog # hook up mouse drag handlers line._on_drag_start = self.order_line_modify_start line._on_drag_end = self.order_line_modify_complete # send order cmd to ems if send_msg: self.client.send_nowait(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.client._sent_orders[order.oid] = order return dialog # order-line modify handlers def order_line_modify_start( self, line: LevelLine, ) -> None: log.info(f'Order modify: {line}') # cancel original order until new position is found? # TODO: make a config option for this behaviour.. def order_line_modify_complete( self, line: LevelLine, ) -> None: ''' Retreive the level line's end state, compute the size and price for the new price-level, send an update msg to the EMS, adjust mirrored level line on secondary chart. ''' mktinfo: MktPair = self.chart.linked.mkt level = round( line.value(), ndigits=mktinfo.price_tick_digits, ) # updated by level change callback set in ``.new_line_from_order()`` dialog = line.dialog size = dialog.order.size # NOTE: sends modified order msg to EMS self.client.update_nowait( uuid=line.dialog.uuid, price=level, size=size, ) # adjust corresponding slow/fast chart line # to match level for ln in dialog.lines: if ln is not line: ln.set_level(line.value()) # EMS response msg handlers def on_submit( self, uuid: str, order: Order | None = None, ) -> Dialog | None: ''' Order submitted status event handler. Commit the order line and registered order uuid, store ack time stamp. ''' lines = self.lines.commit_line(uuid) # a submission is the start of a new order dialog dialog = self.dialogs[uuid] dialog.lines = lines cls: Callable|None = dialog.last_status_close if cls: cls() for line in lines: # if an order msg is provided update the line # **from** that msg. if order: price: float = float(order.price) if price <= 0: log.error(f'Order has 0 price, cancelling..\n{order}') self.cancel_orders([order.oid]) return None line.set_level(price) self.on_level_change_update_next_order_info( level=price, line=line, order=order, # use the corresponding position tracker for the # order's account. tracker=self.trackers[order.account], ) # hide any lines not currently moused-over if not line.get_cursor(): line.hide_labels() return dialog def on_fill( self, uuid: str, price: float, time_s: float, pointing: str | None = None, ) -> bool: ''' Fill msg handler. Triggered on reception of a `filled` message from the EMS. Update relevant UIs: - add arrow annotation on bar - update fill bar size ''' # XXX WARNING XXX # if a `Status(resp='error')` arrives *before* this # fill-status, the `.dialogs` entry may have already been # popped and thus the below will skipped. # # NOTE, to avoid this confusing scenario ensure that any # errors delivered thru from the broker-backend are not just # "noisy reporting" (like is very common from IB..) and are # instead ONLY errors-causing-order-dialog-cancellation! if not (dialog := self.dialogs.get(uuid)): log.warning( f'Order was already cleared from `.dialogs` ??\n' f'uuid: {uuid!r}\n' ) return False lines = dialog.lines chart = self.chart if not lines: log.warn("No line(s) for order {uuid}!?") return False # update line state(s) # # ?XXX this fails on certain types of races? # assert len(lines) == 2 flume: Flume = self.feed.flumes[chart.linked.mkt.fqme] _, _, ratio = flume.get_ds_info() for chart, shm in [ (self.chart, flume.rt_shm), (self.hist_chart, flume.hist_shm), ]: viz = chart.get_viz(chart.name) index_field = viz.index_field arr = shm.array # TODO: borked for int index based.. index = flume.get_index(time_s, arr) # get absolute index for arrow placement arrow_index = arr[index_field][index] self.arrows.add( chart.plotItem, uuid, arrow_index, price, pointing=pointing, color=lines[0].color ) def on_cancel( self, uuid: str, ) -> bool: msg: Order|None = self.client._sent_orders.pop(uuid, None) if msg is None: log.warning( f'Received cancel for unsubmitted order {pformat(msg)}' ) return False # remove GUI line, show cursor. self.lines.remove_line(uuid=uuid) self.chart.linked.cursor.show_xhair() # remove msg dialog (history) dialog: Dialog|None = self.dialogs.pop(uuid, None) if dialog: dialog.last_status_close() return True def cancel_orders_under_cursor(self) -> list[Dialog]: return self.cancel_orders( self.oids_from_lines( self.lines.lines_under_cursor() ) ) def oids_from_lines( self, lines: list[LevelLine], ) -> list[Dialog]: oids: set[str] = set() for line in lines: if dialog := getattr(line, 'dialog', None): oid: str = dialog.uuid if ( dialog and oid not in oids ): oids.add(oid) return oids def cancel_orders( self, oids: list[str], ) -> list[Dialog]: ''' Cancel all orders from a list of order ids: `oids`. ''' # key = self.multistatus.open_status( # f'cancelling {len(oids)} orders', # final_msg=f'cancelled orders:\n{oids}', # group_key=True # ) dialogs: list[Dialog] = [] for oid in oids: if dialog := self.dialogs.get(oid): self.client.cancel_nowait(uuid=oid) # cancel_status_close = self.multistatus.open_status( # f'cancelling order {oid}', # group_key=key, # ) # dialog.last_status_close = cancel_status_close dialogs.append(dialog) return dialogs def cancel_all_orders(self) -> None: ''' Cancel all unique orders / executions by extracting unique order ids from all order lines and then submitting cancel requests for each dialog. ''' return self.cancel_orders( self.oids_from_lines( self.lines.all_lines() ) ) def load_unknown_dialog_from_msg( self, msg: Status, ) -> Dialog | None: # NOTE: the `.order` attr **must** be set with the # equivalent order msg in order to be loaded. order: Order = msg.req oid = str(msg.oid) symbol: str = order.symbol # TODO: MEGA UGGG ZONEEEE! src = msg.src if ( src and src not in ('dark', 'paperboi') and src not in symbol ): fqme = symbol + '.' + src brokername = src else: fqme = symbol *head, brokername = fqme.rsplit('.') # fill out complex fields order.oid = str(order.oid) order.brokers = [brokername] # ?TODO? change this over to `MktPair`, but it's gonna be # tough since we don't have any such data really in our # clearing msg schema.. # BUT WAIT! WHY do we even want/need this!? # # order.symbol = self.curr_mkt # # XXX, the old approach.. which i don't quire member why.. # -[ ] verify we for sure don't require this any more! # |_https://github.com/pikers/piker/issues/517 # # order.symbol = Symbol.from_fqme( # fqsn=fqme, # info={}, # ) maybe_dialog: Dialog | None = self.submit_order( send_msg=False, order=order, ) if maybe_dialog is None: return None assert self.dialogs[oid] == maybe_dialog return maybe_dialog @asynccontextmanager async def open_order_mode( feed: Feed, godw: GodWidget, fqme: str, started: trio.Event, loglevel: str = 'info' ) -> None: ''' Activate chart-trader order mode loop: - connect to emsd - load existing positions - begin EMS response handling loop which updates local state, mostly graphics / UI. ''' chart = godw.rt_linked.chart hist_chart = godw.hist_linked.chart multistatus = chart.window().status_bar done = multistatus.open_status('starting order mode..') client: OrderClient trades_stream: tractor.MsgStream # The keys in this dict **must** be in set our set of "normalized" # symbol names (i.e. the same names you'd get back in search # results) in order for position msgs to correctly trigger the # display of a position indicator on screen. position_msgs: dict[str, dict[str, BrokerdPosition]] # spawn EMS actor-service async with ( open_ems( fqme, loglevel=loglevel, ) as ( client, trades_stream, position_msgs, brokerd_accounts, ems_dialog_msgs, ), tractor.trionics.collapse_eg(), trio.open_nursery() as tn, ): log.info(f'Opening order mode for {fqme}') # annotations editors lines = LineEditor(godw=godw) arrows = ArrowEditor(godw=godw) # market endpoint info mkt: MktPair = chart.linked.mkt # map of per-provider account keys to position tracker instances trackers: dict[str, PositionTracker] = {} # load account names from ``brokers.toml`` accounts_def: bidict[str, str | None] = config.load_accounts( providers=[mkt.broker], ) # XXX: ``brokerd`` delivers a set of account names that it # allows use of but the user also can define the accounts they'd # like to use, in order, in their `brokers.toml` file. accounts: dict[str, str] = {} for name in brokerd_accounts: # ensure name is in ``brokers.toml`` accounts[name] = accounts_def[name] # always add a paper entry so that paper cleared # order dialogs can be tracked in the order mode UIs. accounts['paper'] = 'paper' # first account listed is the one we select at startup # (aka order based selection). pp_account = next( # choose first account based on line order from `brokers.toml`. iter(accounts.keys()) ) if accounts else 'paper' # update pp trackers with data relayed from ``brokerd``. for account_name in accounts: # net-zero pp startup_pp = Position( mkt=mkt, # XXX: BLEH, do we care about this on the client side? bs_mktid=mkt.key, ) # allocator config alloc: Allocator = mk_allocator( mkt=mkt, account=account_name, # if this startup size is greater the allocator limit, # the limit is increased internally in this factory. startup_pp=startup_pp, ) pp_tracker = PositionTracker( [chart, hist_chart], alloc, startup_pp ) pp_tracker.nav.hide() trackers[account_name] = pp_tracker assert pp_tracker.startup_pp.cumsize == pp_tracker.live_pp.cumsize # TODO: do we even really need the "startup pp" or can we # just take the max and pass that into the some state / the # alloc? pp_tracker.update_from_pp() # on existing position, show pp tracking graphics if pp_tracker.startup_pp.cumsize != 0: pp_tracker.nav.show() pp_tracker.nav.hide_info() # setup order mode sidepane widgets form: FieldsForm = chart.sidepane form.vbox.setSpacing( int((1 + 5 / 8) * _font.px_size) ) from ._feedstatus import mk_feed_label feed_label = mk_feed_label( form, feed, chart, ) # XXX: we set this because? form.feed_label = feed_label order_pane = SettingsPane( form=form, # XXX: ugh, so hideous... fill_bar=form.fill_bar, pnl_label=form.left_label, step_label=form.bottom_label, limit_label=form.top_label, ) order_pane.set_accounts(list(trackers.keys())) form.vbox.addWidget( feed_label, alignment=Qt.AlignBottom, ) # update pp icons for name, tracker in trackers.items(): order_pane.update_account_icons({name: tracker.live_pp}) # top level abstraction which wraps all this crazyness into # a namespace.. mode = OrderMode( godw, feed, chart, hist_chart, tn, client, lines, arrows, multistatus, pane=order_pane, trackers=trackers, ) # XXX: MUST be set order_pane.order_mode = mode # select a pp to track tracker: PositionTracker = trackers[pp_account] mode.current_pp = tracker tracker.nav.show() tracker.nav.hide_info() # XXX: would love to not have to do this separate from edit # fields (which are done in an async loop - see below) # connect selection signals (from drop down widgets) # to order sync pane handler for key in ('account', 'size_unit',): w = form.fields[key] w.currentTextChanged.connect( partial( order_pane.on_selection_change, key=key, ) ) # make fill bar and positioning snapshot order_pane.update_status_ui(tracker) # TODO: create a mode "manager" of sorts? # -> probably just call it "UxModes" err sumthin? # so that view handlers can access it chart.view.order_mode = mode hist_chart.view.order_mode = mode order_pane.on_ui_settings_change('account', pp_account) mode.pane.display_pnl(mode.current_pp) # Begin order-response streaming done() # Pack position messages by account, should only be one-to-one. # NOTE: requires the backend exactly specifies # the expected symbol key in its positions msg. for ( (broker, acctid), pps_by_fqme ) in position_msgs.items(): for msg in pps_by_fqme.values(): await process_trade_msg( mode, client, msg, ) async with ( # pp pane kb inputs open_form_input_handling( form, focus_next=chart.linked.godwidget, on_value_change=order_pane.on_ui_settings_change, ), ): # signal to top level symbol loading task we're ready # to handle input since the ems connection is ready 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, client, msg, ) tn.start_soon( process_trades_and_update_ui, trades_stream, mode, client, ) yield mode async def process_trades_and_update_ui( trades_stream: tractor.MsgStream, mode: OrderMode, client: OrderClient, ) -> None: # this is where we receive **back** messages # about executions **from** the EMS actor async for msg in trades_stream: await process_trade_msg( mode, client, msg, ) async def process_trade_msg( mode: OrderMode, client: OrderClient, msg: dict, # emit linux DE notification? # XXX: currently my experience with `dunst` is that this # is horrible slow and clunky and invasive and noisy so i'm # disabling it for now until we find a better UX solution.. do_notify: bool = False, ) -> tuple[Dialog, Status]: # TODO: obvi once we're parsing to native struct instances we can # drop the `pformat()` call Bo fmtmsg: Struct | dict = msg if not isinstance(msg, Struct): fmtmsg: str = pformat(msg) log.debug(f'Received order msg:\n{fmtmsg}') name = msg['name'] if name in ( 'position', ): mkt: MktPair = mode.chart.linked.mkt pp_msg_symbol = msg['symbol'].lower() pp_msg_bsmktid = msg['bs_mktid'] fqme = mkt.fqme broker = mkt.broker if ( # match on any backed-specific(-unique)-ID first! ( pp_msg_bsmktid and mkt.bs_mktid == pp_msg_bsmktid ) or # OW try against what's provided as an FQME.. pp_msg_symbol == fqme or pp_msg_symbol == fqme.removesuffix(f'.{broker}') ): log.info( f'Loading position for `{fqme}`:\n' f'{fmtmsg}' ) tracker = mode.trackers[msg['account']] tracker.live_pp.update_from_msg(msg) tracker.update_from_pp( set_as_startup=True, ) # status/pane UI mode.pane.update_status_ui(tracker) if tracker.live_pp.cumsize: # display pnl mode.pane.display_pnl(tracker) # short circuit to next msg to avoid # unnecessary msg content lookups return msg = Status(**msg) # resp: str = msg.resp oid = msg.oid dialog: Dialog = mode.dialogs.get(oid) if dialog: fqme = dialog.symbol match msg: case Status( resp='dark_open' | 'open', ) if msg.req['action'] != 'cancel': order = Order(**msg.req) if ( dialog is not None and order.action != 'cancel' ): # show line label once order is live mode.on_submit(oid, order=order) elif order.action != 'cancel': log.warning( f'received msg for untracked dialog:\n{fmtmsg}' ) assert msg.resp in ('open', 'dark_open'), f'Unknown msg: {msg}' sym: MktPair = mode.chart.linked.mkt fqme = sym.fqme if ( ((order.symbol + f'.{msg.src}') == fqme) # a existing dark order for the same symbol or ( order.symbol == fqme and ( msg.src in ('dark', 'paperboi') or (msg.src in fqme) ) ) ): msg.req: Order = order dialog: ( Dialog # NOTE: on an invalid order submission (eg. # price <=0) the downstream APIs may return # a null. | None ) = mode.load_unknown_dialog_from_msg(msg) # cancel any invalid pre-existing order! if dialog is None: log.warning( 'Order was ignored/invalid?\n' f'{order}' ) # if valid, display the order line the same as if # it was submitted during this UI session. else: mode.on_submit(oid) case Status(resp='error'): # TODO: parse into broker-side msg, or should we # expect it to just be **that** msg verbatim (since # we'd presumably have only 1 `Error` msg-struct) broker_msg: dict = msg.brokerd_msg # XXX NOTE, this presumes the rxed "error" is # order-dialog-cancel-causing, THUS backends much ONLY # relay errors of this "severity"!! log.error( f'Order errored ??\n' f'oid: {oid!r}\n' f'\n' f'{pformat(broker_msg)}\n' f'\n' f'=> CANCELLING ORDER DIALOG <=\n' # from tractor.devx.pformat import ppfmt # !TODO LOL, wtf the msg is causing # a recursion bug! # -[ ] get this shit on msgspec stat! # f'{ppfmt(broker_msg)}' ) # do all the things for a cancel: # - drop order-msg dialog from client table # - delete level line from view mode.on_cancel(oid) case Status(resp='canceled'): # delete level line from view mode.on_cancel(oid) action = msg.req["action"] log.cancel(f'Canceled {action}:{oid}') case Status( resp='triggered', # req=Order(exec_mode='dark') # TODO: msgspec req={'exec_mode': 'dark'}, ): # TODO: UX for a "pending" clear/live order log.info(f'Dark order triggered for {fmtmsg}') # TODO: do the struct-msg version, blah blah.. # req=Order(exec_mode='live', action='alert') as req, case Status( resp='triggered', req={ 'exec_mode': 'live', 'action': 'alert', } as req, ): # should only be one "fill" for an alert # add a triangle and remove the level line req = Order(**req) tm = time.time() mode.on_fill( oid, price=float(req.price), time_s=tm, ) mode.lines.remove_line(uuid=oid) msg.req = req if do_notify: await notify_from_ems_status_msg(msg) # response to completed 'dialog' for order request case Status( resp='closed', # req=Order() as req, # TODO req=req, ): msg.req = Order(**req) if do_notify: await notify_from_ems_status_msg(msg) mode.lines.remove_line(uuid=oid) # each clearing tick is responded individually case Status(resp='fill'): # handle out-of-piker fills reporting? order: Order | None if not (order := client._sent_orders.get(oid)): # set it from last known request msg log.warning(f'order {oid} is unknown') order = msg.req # XXX TODO: have seen order be a dict here!? # that should never happen tho? action: str = ( getattr(order, 'action', None) or order['action'] ) details: dict = msg.brokerd_msg # TODO: state tracking: # - put the actual exchange timestamp? # - some kinda progress system? # NOTE: currently the ``kraken`` openOrders sub # doesn't deliver their engine timestamp as part of # it's schema, so this value is **not** from them # (see our backend code). We should probably either # include all provider-engine timestamps in the # summary 'closed' status msg and/or figure out # a way to indicate what is a `brokerd` stamp versus # a true backend one? This will require finagling # with how each backend tracks/summarizes time # stamps for the downstream API. tm = details['broker_time'] mode.on_fill( oid, price=float(details['price']), time_s=tm, pointing='up' if action == 'buy' else 'down', ) # TODO: append these fill events to the position's clear # table? # tracker = mode.trackers[msg['account']] # tracker.live_pp.fills.append(msg) # record message to dialog tracking if dialog: dialog.msgs[oid] = msg return dialog, msg