diff --git a/piker/brokers/ib.py b/piker/brokers/ib.py index c8f8a102..85a34527 100644 --- a/piker/brokers/ib.py +++ b/piker/brokers/ib.py @@ -1419,6 +1419,7 @@ async def trades_dialogue( # - short-sale but securities haven't been located, in this # case we should probably keep the order in some kind of # weird state or cancel it outright? + # status='PendingSubmit', message=''), # status='Cancelled', message='Error 404, # reqId 1550: Order held while securities are located.'), diff --git a/piker/clearing/_paper_engine.py b/piker/clearing/_paper_engine.py index b71be312..84e681dd 100644 --- a/piker/clearing/_paper_engine.py +++ b/piker/clearing/_paper_engine.py @@ -35,7 +35,7 @@ from ..data._normalize import iterticks from ..log import get_logger from ._messages import ( BrokerdCancel, BrokerdOrder, BrokerdOrderAck, BrokerdStatus, - BrokerdFill, + BrokerdFill, BrokerdPosition, ) @@ -60,6 +60,7 @@ class PaperBoi: _buys: bidict _sells: bidict _reqids: bidict + _positions: dict[str, BrokerdPosition] # init edge case L1 spread last_ask: Tuple[float, float] = (float('inf'), 0) # price, size @@ -101,6 +102,9 @@ class PaperBoi: # in the broker trades event processing loop await trio.sleep(0.05) + if action == 'sell': + size = -size + msg = BrokerdStatus( status='submitted', reqid=reqid, @@ -118,7 +122,7 @@ class PaperBoi: ) or ( action == 'sell' and (clear_price := self.last_bid[0]) >= price ): - await self.fake_fill(clear_price, size, action, reqid, oid) + await self.fake_fill(symbol, clear_price, size, action, reqid, oid) else: # register this submissions as a paper live order @@ -170,6 +174,8 @@ class PaperBoi: async def fake_fill( self, + + symbol: str, price: float, size: float, action: str, # one of {'buy', 'sell'} @@ -181,6 +187,7 @@ class PaperBoi: # remaining lots to fill order_complete: bool = True, remaining: float = 0, + ) -> None: """Pretend to fill a broker order @ price and size. @@ -232,6 +239,49 @@ class PaperBoi: ) await self.ems_trades_stream.send(msg.dict()) + # lookup any existing position + token = f'{symbol}.{self.broker}' + pp_msg = self._positions.setdefault( + token, + BrokerdPosition( + broker=self.broker, + account='paper', + symbol=symbol, + # TODO: we need to look up the asset currency from + # broker info. i guess for crypto this can be + # inferred from the pair? + currency='', + size=0.0, + avg_price=0, + ) + ) + + # "avg position price" calcs + # TODO: eventually it'd be nice to have a small set of routines + # to do this stuff from a sequence of cleared orders to enable + # so called "contextual positions". + new_size = size + pp_msg.size + + # old size minus the new size gives us size differential with + # +ve -> increase in pp size + # -ve -> decrease in pp size + size_diff = abs(new_size) - abs(pp_msg.size) + + if new_size == 0: + pp_msg.avg_price = 0 + + elif size_diff > 0: + # only update the "average position price" when the position + # size increases not when it decreases (i.e. the position is + # being made smaller) + pp_msg.avg_price = ( + abs(size) * price + pp_msg.avg_price * abs(pp_msg.size) + ) / abs(new_size) + + pp_msg.size = new_size + + await self.ems_trades_stream.send(pp_msg.dict()) + async def simulate_fills( quote_stream: 'tractor.ReceiveStream', # noqa @@ -255,6 +305,7 @@ async def simulate_fills( # this stream may eventually contain multiple symbols async for quotes in quote_stream: + for sym, quote in quotes.items(): for tick in iterticks( @@ -274,6 +325,7 @@ async def simulate_fills( ) orders = client._buys.get(sym, {}) + book_sequence = reversed( sorted(orders.keys(), key=itemgetter(1))) @@ -307,6 +359,7 @@ async def simulate_fills( # clearing price would have filled entirely await client.fake_fill( + symbol=sym, # todo slippage to determine fill price price=tick_price, size=size, @@ -411,6 +464,9 @@ async def trades_dialogue( _sells={}, _reqids={}, + + # TODO: load paper positions from ``positions.toml`` + _positions={}, ) n.start_soon(handle_order_requests, client, ems_stream) @@ -452,10 +508,5 @@ async def open_paperboi( loglevel=loglevel, ) as (ctx, first): - try: - yield ctx, first - finally: - # be sure to tear down the paper service on exit - with trio.CancelScope(shield=True): - await portal.cancel_actor() + yield ctx, first diff --git a/piker/ui/_annotate.py b/piker/ui/_annotate.py index 9228a93a..36765026 100644 --- a/piker/ui/_annotate.py +++ b/piker/ui/_annotate.py @@ -26,9 +26,11 @@ import numpy as np def mk_marker( + style, size: float = 20.0, use_qgpath: bool = True, + ) -> QGraphicsPathItem: """Add a marker to be displayed on the line wrapped in a ``QGraphicsPathItem`` ready to be placed using scene coordinates (not view). diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 1fa0ef2d..41647481 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -40,13 +40,13 @@ from ._axes import ( PriceAxis, YAxisLabel, ) -from ._graphics._cursor import ( +from ._cursor import ( Cursor, ContentsLabel, ) from ._l1 import L1Labels -from ._graphics._ohlc import BarItems -from ._graphics._curve import FastAppendCurve +from ._ohlc import BarItems +from ._curve import FastAppendCurve from ._style import ( hcolor, CHART_MARGINS, @@ -296,7 +296,7 @@ class LinkedSplits(QtWidgets.QWidget): def set_split_sizes( self, - prop: float = 0.28 # proportion allocated to consumer subcharts + prop: float = 0.375 # proportion allocated to consumer subcharts ) -> None: """Set the proportion of space allocated for linked subcharts. """ @@ -1317,7 +1317,7 @@ async def run_fsp( # graphics.curve.setFillLevel(50) if fsp_func_name == 'rsi': - from ._graphics._lines import level_line + from ._lines import level_line # add moveable over-[sold/bought] lines # and labels only for the 70/30 lines level_line(chart, 20) diff --git a/piker/ui/_graphics/_cursor.py b/piker/ui/_cursor.py similarity index 99% rename from piker/ui/_graphics/_cursor.py rename to piker/ui/_cursor.py index 0ad4fb7a..8a74ffc7 100644 --- a/piker/ui/_graphics/_cursor.py +++ b/piker/ui/_cursor.py @@ -27,13 +27,13 @@ import pyqtgraph as pg from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5.QtCore import QPointF, QRectF -from .._style import ( +from ._style import ( _xaxis_at, hcolor, _font_small, ) -from .._axes import YAxisLabel, XAxisLabel -from ...log import get_logger +from ._axes import YAxisLabel, XAxisLabel +from ..log import get_logger log = get_logger(__name__) diff --git a/piker/ui/_graphics/_curve.py b/piker/ui/_curve.py similarity index 99% rename from piker/ui/_graphics/_curve.py rename to piker/ui/_curve.py index 6214c07c..6b38420a 100644 --- a/piker/ui/_graphics/_curve.py +++ b/piker/ui/_curve.py @@ -23,7 +23,7 @@ from typing import Tuple import pyqtgraph as pg from PyQt5 import QtCore, QtGui, QtWidgets -from ..._profile import pg_profile_enabled +from .._profile import pg_profile_enabled # TODO: got a feeling that dropping this inheritance gets us even more speedups diff --git a/piker/ui/_editors.py b/piker/ui/_editors.py index 00ef362d..1bb9d7e2 100644 --- a/piker/ui/_editors.py +++ b/piker/ui/_editors.py @@ -28,7 +28,7 @@ from PyQt5.QtCore import QPointF import numpy as np from ._style import hcolor, _font -from ._graphics._lines import order_line, LevelLine +from ._lines import order_line, LevelLine from ..log import get_logger @@ -237,7 +237,6 @@ class LineEditor: log.warning(f'No line for {uuid} could be found?') return else: - assert line.oid == uuid line.show_labels() # TODO: other flashy things to indicate the order is active @@ -260,18 +259,16 @@ class LineEditor: self, line: LevelLine = None, uuid: str = None, - ) -> LevelLine: - """Remove a line by refernce or uuid. + + ) -> Optional[LevelLine]: + '''Remove a line by refernce or uuid. If no lines or ids are provided remove all lines under the cursor position. - """ - if line: - uuid = line.oid - + ''' # try to look up line from our registry - line = self._order_lines.pop(uuid, None) + line = self._order_lines.pop(uuid, line) if line: # if hovered remove from cursor set @@ -284,8 +281,13 @@ class LineEditor: # just because we never got a un-hover event cursor.show_xhair() + log.debug(f'deleting {line} with oid: {uuid}') line.delete() - return line + + else: + log.warning(f'Could not find line for {line}') + + return line class SelectRect(QtGui.QGraphicsRectItem): diff --git a/piker/ui/_graphics/__init__.py b/piker/ui/_graphics/__init__.py deleted file mode 100644 index 2846367a..00000000 --- a/piker/ui/_graphics/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -# piker: trading gear for hackers -# Copyright (C) 2018-present Tyler Goodlet (in stewardship of 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 . - -""" -Internal custom graphics mostly built for low latency and reuse. - -""" diff --git a/piker/ui/_graphics/_lines.py b/piker/ui/_lines.py similarity index 94% rename from piker/ui/_graphics/_lines.py rename to piker/ui/_lines.py index a59ccb96..2f83ec7f 100644 --- a/piker/ui/_graphics/_lines.py +++ b/piker/ui/_lines.py @@ -26,9 +26,9 @@ from pyqtgraph import Point, functions as fn from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5.QtCore import QPointF -from .._annotate import mk_marker, qgo_draw_markers -from .._label import Label, vbr_left, right_axis -from .._style import hcolor, _font +from ._annotate import mk_marker, qgo_draw_markers +from ._label import Label, vbr_left, right_axis +from ._style import hcolor, _font # TODO: probably worth investigating if we can @@ -352,6 +352,21 @@ class LevelLine(pg.InfiniteLine): return up_to_l1_sc + def marker_right_points(self) -> (float, float, float): + + chart = self._chart + l1_len = chart._max_l1_line_len + ryaxis = chart.getAxis('right') + + r_axis_x = ryaxis.pos().x() + up_to_l1_sc = r_axis_x - l1_len + + size = self._default_mkr_size + marker_right = up_to_l1_sc - (1.375 * 2*size) + line_end = marker_right - (6/16 * size) + + return line_end, marker_right, r_axis_x + def paint( self, p: QtGui.QPainter, @@ -366,26 +381,14 @@ class LevelLine(pg.InfiniteLine): # these are in viewbox coords vb_left, vb_right = self._endPoints - - chart = self._chart - l1_len = chart._max_l1_line_len - ryaxis = chart.getAxis('right') - - r_axis_x = ryaxis.pos().x() - up_to_l1_sc = r_axis_x - l1_len - vb = self.getViewBox() - size = self._default_mkr_size - marker_right = up_to_l1_sc - (1.375 * 2*size) - line_end = marker_right - (6/16 * size) + line_end, marker_right, r_axis_x = self.marker_right_points() if self.show_markers and self.markers: - size = self.markers[0][2] - p.setPen(self.pen) - size = qgo_draw_markers( + qgo_draw_markers( self.markers, self.pen.color(), p, @@ -438,9 +441,8 @@ class LevelLine(pg.InfiniteLine): path: QtWidgets.QGraphicsPathItem, ) -> None: - # chart = self._chart - vb = self.getViewBox() - vb.scene().addItem(path) + # add path to scene + self.getViewBox().scene().addItem(path) self._marker = path @@ -758,27 +760,31 @@ def position_line( vr = vb.state['viewRange'] ymn, ymx = vr[1] level = line.value() + path = line._marker - if gt := level > ymx or (lt := level < ymn): + # provide "nav hub" like indicator for where + # the position is on the y-dimension + # print(path._height) + # print(vb.shape()) + # print(vb.boundingRect()) + # print(vb.height()) + _, marker_right, _ = line.marker_right_points() - if chartview.mode.name == 'order': + if level > ymx: # pin to top of view + path.setPos( + QPointF( + marker_right, + 2 + path._height, + ) + ) - # provide "nav hub" like indicator for where - # the position is on the y-dimension - if gt: - # pin to top of view since position is above current - # y-range - pass - - elif lt: - # pin to bottom of view since position is above - # below y-range - pass - - else: - # order mode is not active - # so hide the pp market - line._marker.hide() + elif level < ymn: # pin to bottom of view + path.setPos( + QPointF( + marker_right, + vb.height() - 16 + path._height, + ) + ) else: # pp line is viewable so show marker @@ -812,6 +818,10 @@ def position_line( style = '>|' arrow_path = mk_marker(style, size=arrow_size) + + # monkey-cache height for sizing on pp nav-hub + arrow_path._height = arrow_path.boundingRect().height() + # XXX: uses new marker drawing approach line.add_marker(arrow_path) line.set_level(level) diff --git a/piker/ui/_graphics/_ohlc.py b/piker/ui/_ohlc.py similarity index 99% rename from piker/ui/_graphics/_ohlc.py rename to piker/ui/_ohlc.py index 24f35075..7ae010fa 100644 --- a/piker/ui/_graphics/_ohlc.py +++ b/piker/ui/_ohlc.py @@ -27,8 +27,8 @@ from PyQt5.QtCore import QLineF, QPointF # from numba import types as ntypes # from ..data._source import numba_ohlc_dtype -from ..._profile import pg_profile_enabled -from .._style import hcolor +from .._profile import pg_profile_enabled +from ._style import hcolor def _mk_lines_array( diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index d57590cf..c8c54087 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -29,7 +29,7 @@ import pyqtgraph as pg from pydantic import BaseModel import trio -from ._graphics._lines import LevelLine, position_line +from ._lines import LevelLine, position_line from ._editors import LineEditor, ArrowEditor from ._window import MultiStatus, main_window from ..clearing._client import open_ems, OrderBook @@ -41,12 +41,31 @@ log = get_logger(__name__) class Position(BaseModel): + '''Basic pp representation with attached fills history. + + ''' symbol: Symbol size: float - avg_price: float + avg_price: float # TODO: contextual pricing fills: Dict[str, Any] = {} +class OrderDialog(BaseModel): + '''Trade dialogue meta-data describing the lifetime + of an order submission to ``emsd`` from a chart. + + ''' + uuid: str + line: LevelLine + last_status_close: Callable = lambda: None + msgs: dict[str, dict] = {} + fills: Dict[str, Any] = {} + + class Config: + arbitrary_types_allowed = True + underscore_attrs_are_private = False + + @dataclass class OrderMode: '''Major mode for placing orders on a chart view. @@ -60,8 +79,8 @@ class OrderMode: Current manual: a -> alert s/ctrl -> submission type modifier {on: live, off: dark} - f (fill) -> buy limit order - d (dump) -> sell limit order + 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 @@ -85,8 +104,7 @@ class OrderMode: _position: Dict[str, Any] = field(default_factory=dict) _position_line: dict = None - _pending_submissions: dict[str, (LevelLine, Callable)] = field( - default_factory=dict) + dialogs: dict[str, OrderDialog] = field(default_factory=dict) def on_position_update( self, @@ -139,33 +157,34 @@ class OrderMode: action=action, ) - def on_submit(self, uuid: str) -> dict: - """On order submitted event, commit the order line - and registered order uuid, store ack time stamp. + def on_submit(self, uuid: str) -> OrderDialog: + '''Order submitted status event handler. - TODO: annotate order line with submission type ('live' vs. - 'dark'). + Commit the order line and registered order uuid, store ack time stamp. - """ + ''' line = self.lines.commit_line(uuid) - pending = self._pending_submissions.get(uuid) - if pending: - order_line, func = pending - assert order_line is line - func() + # a submission is the start of a new order dialog + dialog = self.dialogs[uuid] + dialog.line = line + dialog.last_status_close() - return line + return dialog def on_fill( + self, uuid: str, price: float, arrow_index: float, - pointing: Optional[str] = None + pointing: Optional[str] = None, + # delete_line: bool = False, + ) -> None: - line = self.lines._order_lines.get(uuid) + dialog = self.dialogs[uuid] + line = dialog.line if line: self.arrows.add( uuid, @@ -174,6 +193,8 @@ class OrderMode: pointing=pointing, color=line.color ) + else: + log.warn("No line for order {uuid}!?") async def on_exec( self, @@ -181,11 +202,6 @@ class OrderMode: msg: Dict[str, Any], ) -> None: - # only once all fills have cleared and the execution - # is complet do we remove our "order line" - line = self.lines.remove_line(uuid=uuid) - log.debug(f'deleting {line} with oid: {uuid}') - # DESKTOP NOTIFICATIONS # # TODO: this in another task? @@ -212,10 +228,9 @@ class OrderMode: self.lines.remove_line(uuid=uuid) self.chart.linked.cursor.show_xhair() - pending = self._pending_submissions.pop(uuid, None) - if pending: - order_line, func = pending - func() + dialog = self.dialogs.pop(uuid, None) + if dialog: + dialog.last_status_close() else: log.warning( f'Received cancel for unsubmitted order {pformat(msg)}' @@ -225,7 +240,7 @@ class OrderMode: self, size: Optional[float] = None, - ) -> LevelLine: + ) -> OrderDialog: """Send execution order to EMS return a level line to represent the order on a chart. @@ -234,7 +249,7 @@ class OrderMode: # to be displayed when above order ack arrives # (means the line graphic doesn't show on screen until the # order is live in the emsd). - uid = str(uuid.uuid4()) + oid = str(uuid.uuid4()) size = size or self._size @@ -246,9 +261,46 @@ class OrderMode: action = self._action + # TODO: update the line once an ack event comes back + # from the EMS! + + # TODO: place a grey line in "submission" mode + # which will be updated to it's appropriate action + # color once the submission ack arrives. + + # make line graphic if order push was sucessful + line = self.lines.create_order_line( + oid, + level=y, + chart=chart, + size=size, + action=action, + ) + + dialog = OrderDialog( + uuid=oid, + line=line, + last_status_close=self.status_bar.open_status( + f'submitting {self._exec_mode}-{action}', + final_msg=f'submitted {self._exec_mode}-{action}', + clear_on_next=True, + ) + ) + + # TODO: create a new ``OrderLine`` with this optional var defined + line.dialog = dialog + + # enter submission which will be popped once a response + # from the EMS is received to move the order to a different# status + self.dialogs[oid] = 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 self.book.send( - uuid=uid, + uuid=oid, symbol=symbol.key, brokers=symbol.brokers, price=y, @@ -257,36 +309,7 @@ class OrderMode: exec_mode=self._exec_mode, ) - # TODO: update the line once an ack event comes back - # from the EMS! - - # make line graphic if order push was - # sucessful - line = self.lines.create_order_line( - uid, - level=y, - chart=chart, - size=size, - action=action, - ) - line.oid = uid - - # enter submission which will be popped once a response - # from the EMS is received to move the order to a different# status - self._pending_submissions[uid] = ( - line, - self.status_bar.open_status( - f'submitting {self._exec_mode}-{action}', - final_msg=f'submitted {self._exec_mode}-{action}', - clear_on_next=True, - ) - ) - - # hook up mouse drag handlers - line._on_drag_start = self.order_line_modify_start - line._on_drag_end = self.order_line_modify_complete - - return line + return dialog def cancel_orders_under_cursor(self) -> list[str]: return self.cancel_orders_from_lines( @@ -317,16 +340,16 @@ class OrderMode: # cancel all active orders and triggers for line in lines: - oid = getattr(line, 'oid', None) + dialog = getattr(line, 'dialog', None) - if oid: - self._pending_submissions[oid] = ( - line, - self.status_bar.open_status( - f'cancelling order {oid[:6]}', - group_key=key, - ), + if dialog: + oid = dialog.uuid + + cancel_status_close = self.status_bar.open_status( + f'cancelling order {oid[:6]}', + group_key=key, ) + dialog.last_status_close = cancel_status_close ids.append(oid) self.book.cancel(uuid=oid) @@ -338,16 +361,20 @@ class OrderMode: def order_line_modify_start( self, line: LevelLine, + ) -> None: + print(f'Line modify: {line}') # cancel original order until new position is found def order_line_modify_complete( self, line: LevelLine, + ) -> None: + self.book.update( - uuid=line.oid, + uuid=line.dialog.uuid, # TODO: should we round this to a nearest tick here? price=line.value(), @@ -464,6 +491,10 @@ async def start_order_mode( resp = msg['resp'] oid = msg['oid'] + dialog = order_mode.dialogs[oid] + # record message to dialog tracking + dialog.msgs[oid] = msg + # response to 'action' request (buy/sell) if resp in ( 'dark_submitted', @@ -496,16 +527,21 @@ async def start_order_mode( order_mode.on_fill( oid, price=msg['trigger_price'], - arrow_index=get_index(time.time()) + arrow_index=get_index(time.time()), ) + order_mode.lines.remove_line(uuid=oid) await order_mode.on_exec(oid, msg) # response to completed 'action' request for buy/sell elif resp in ( 'broker_executed', ): + # right now this is just triggering a system alert await order_mode.on_exec(oid, msg) + if msg['brokerd_msg']['remaining'] == 0: + order_mode.lines.remove_line(uuid=oid) + # each clearing tick is responded individually elif resp in ('broker_filled',):