# piker: trading gear for hackers # Copyright (C) Tyler Goodlet (in stewardship for piker0) # 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 functools import partial from pprint import pformat import time from typing import ( Optional, Callable, Any, TYPE_CHECKING, ) import uuid import tractor import trio from PyQt5.QtCore import Qt from .. import config from ..pp import Position from ..clearing._client import open_ems, OrderBook from ..clearing._allocate import ( mk_allocator, ) from ._style import _font from ..data._source import Symbol from ..data.feed import ( Feed, Flume, ) from ..data.types import Struct from ..log import get_logger 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 ..clearing._messages import ( # Cancel, Order, Status, # BrokerdOrder, # BrokerdStatus, BrokerdPosition, ) 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 = lambda: 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? book: OrderBook lines: LineEditor arrows: ArrowEditor multistatus: MultiStatus pane: SettingsPane trackers: dict[str, PositionTracker] # switched state, the current position current_pp: Optional[PositionTracker] = 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: Optional[Order] = None 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 order.price = level 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: Optional[ChartPlotWidget] = None, **line_kwargs, ) -> LevelLine: level = 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: order.price = y 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 = cursor._datum_xy[1] if not price: # zero prices are not supported by any means # since that's illogical / a no-op. return symbol = self.chart.linked.symbol order = self._staged_order = Order( action=action, price=price, account=self.current_pp.alloc.account, size=0, symbol=symbol, brokers=symbol.brokers, 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: Optional[Order] = None, ) -> Dialog: ''' Send execution order to EMS return a level line to represent the order on a chart. ''' if not order: staged = self._staged_order # apply order fields for ems oid = str(uuid.uuid4()) order = staged.copy() order.oid = oid order.symbol = order.symbol.front_fqsn() 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.book.send(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.book._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: level = line.value() # 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.book.send_update( 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: Optional[Order] = None, ) -> Dialog: ''' 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 dialog.last_status_close() for line in lines: # if an order msg is provided update the line # **from** that msg. if order: line.set_level(order.price) self.on_level_change_update_next_order_info( level=order.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, arrow_index: float, pointing: Optional[str] = None, ) -> None: ''' 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 ''' dialog = self.dialogs[uuid] lines = dialog.lines # XXX: seems to fail on certain types of races? # assert len(lines) == 2 if lines: flume: Flume = self.feed.flumes[self.chart.linked.symbol.fqsn] _, _, ratio = flume.get_ds_info() for i, chart in [ (arrow_index, self.chart), (flume.izero_hist + round((arrow_index - flume.izero_rt)/ratio), self.hist_chart) ]: self.arrows.add( chart.plotItem, uuid, i, price, pointing=pointing, color=lines[0].color ) else: log.warn("No line(s) for order {uuid}!?") def on_cancel( self, uuid: str ) -> None: msg = self.book._sent_orders.pop(uuid, None) if msg is not None: self.lines.remove_line(uuid=uuid) self.chart.linked.cursor.show_xhair() dialog = self.dialogs.pop(uuid, None) if dialog: dialog.last_status_close() else: log.warning( f'Received cancel for unsubmitted order {pformat(msg)}' ) def cancel_orders_under_cursor(self) -> list[str]: return self.cancel_orders_from_lines( self.lines.lines_under_cursor() ) def cancel_all_orders(self) -> list[str]: ''' Cancel all orders for the current chart. ''' return self.cancel_orders_from_lines( self.lines.all_lines() ) def cancel_orders_from_lines( self, lines: list[LevelLine], ) -> list[str]: ids: list = [] if lines: key = self.multistatus.open_status( f'cancelling {len(lines)} orders', final_msg=f'cancelled {len(lines)} orders', group_key=True ) # cancel all active orders and triggers for line in lines: dialog = getattr(line, 'dialog', None) if dialog: oid = dialog.uuid cancel_status_close = self.multistatus.open_status( f'cancelling order {oid}', group_key=key, ) dialog.last_status_close = cancel_status_close ids.append(oid) self.book.cancel(uuid=oid) return ids def load_unknown_dialog_from_msg( self, msg: Status, ) -> Dialog: # NOTE: the `.order` attr **must** be set with the # equivalent order msg in order to be loaded. order = msg.req oid = str(msg.oid) symbol = order.symbol # TODO: MEGA UGGG ZONEEEE! src = msg.src if ( src and src not in ('dark', 'paperboi') and src not in symbol ): fqsn = symbol + '.' + src brokername = src else: fqsn = symbol *head, brokername = fqsn.rsplit('.') # fill out complex fields order.oid = str(order.oid) order.brokers = [brokername] order.symbol = Symbol.from_fqsn( fqsn=fqsn, info={}, ) dialog = self.submit_order( send_msg=False, order=order, ) assert self.dialogs[oid] == dialog return dialog @asynccontextmanager async def open_order_mode( feed: Feed, godw: GodWidget, fqsn: str, started: trio.Event, ) -> 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..') book: OrderBook 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, list[BrokerdPosition]] # spawn EMS actor-service async with ( open_ems(fqsn) as ( book, trades_stream, position_msgs, brokerd_accounts, ems_dialog_msgs, ), trio.open_nursery() as tn, ): log.info(f'Opening order mode for {fqsn}') # annotations editors lines = LineEditor(godw=godw) arrows = ArrowEditor(godw=godw) # symbol id symbol = chart.linked.symbol symkey = symbol.front_fqsn() # map of per-provider account keys to position tracker instances trackers: dict[str, PositionTracker] = {} # load account names from ``brokers.toml`` accounts_def = config.load_accounts( providers=symbol.brokers ) # 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 = {} for name in brokerd_accounts: # ensure name is in ``brokers.toml`` accounts[name] = accounts_def[name] # 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( symbol=symbol, size=0, ppu=0, # XXX: BLEH, do we care about this on the client side? bsuid=symbol, ) # allocator config alloc = mk_allocator( symbol=symbol, 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.size == pp_tracker.live_pp.size # 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.size != 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, book, 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), msgs in position_msgs.items(): for msg in msgs: log.info(f'Loading pp for {symkey}:\n{pformat(msg)}') await process_trade_msg( mode, book, msg, ) # start async input handling for chart's view async with ( # ``ChartView`` input async handler startup chart.view.open_async_input_handler(), hist_chart.view.open_async_input_handler(), # 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, book, msg, ) tn.start_soon( process_trades_and_update_ui, trades_stream, mode, book, ) yield mode async def process_trades_and_update_ui( trades_stream: tractor.MsgStream, mode: OrderMode, book: OrderBook, ) -> 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, book, msg, ) async def process_trade_msg( mode: OrderMode, book: OrderBook, msg: dict, ) -> tuple[Dialog, Status]: fmsg = pformat(msg) log.debug(f'Received order msg:\n{fmsg}') name = msg['name'] if name in ( 'position', ): sym = mode.chart.linked.symbol pp_msg_symbol = msg['symbol'].lower() fqsn = sym.front_fqsn() broker, key = sym.front_feed() if ( pp_msg_symbol == fqsn or pp_msg_symbol == fqsn.removesuffix(f'.{broker}') ): log.info(f'{fqsn} matched pp msg: {fmsg}') 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.size: # display pnl mode.pane.display_pnl(tracker) # short circuit to next msg to avoid # unnecessary msg content lookups return msg = Status(**msg) resp = msg.resp oid = msg.oid dialog: Dialog = mode.dialogs.get(oid) if dialog: fqsn = dialog.symbol flume = mode.feed.flumes[fqsn] 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{fmsg}' ) assert msg.resp in ('open', 'dark_open'), f'Unknown msg: {msg}' sym = mode.chart.linked.symbol fqsn = sym.front_fqsn() if ( ((order.symbol + f'.{msg.src}') == fqsn) # a existing dark order for the same symbol or ( order.symbol == fqsn and ( msg.src in ('dark', 'paperboi') or (msg.src in fqsn) ) ) ): msg.req = order dialog = mode.load_unknown_dialog_from_msg(msg) mode.on_submit(oid) case Status(resp='error'): # delete level line from view mode.on_cancel(oid) broker_msg = msg.brokerd_msg log.error( f'Order {oid}->{resp} with:\n{pformat(broker_msg)}' ) 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 {fmsg}') case Status( resp='triggered', # req=Order(exec_mode='live', action='alert') as req, # TODO 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) index = flume.get_index(time.time()) mode.on_fill( oid, price=req.price, arrow_index=index, ) mode.lines.remove_line(uuid=oid) msg.req = req 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) 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 = book._sent_orders.get(oid) if not order: log.warning(f'order {oid} is unknown') order = msg.req action = order.action details = msg.brokerd_msg # TODO: put the actual exchange timestamp? # 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. index = flume.get_index( details['broker_time'] ) # TODO: some kinda progress system mode.on_fill( oid, price=details['price'], arrow_index=index, 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