# 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 . """ Lines for orders, alerts, L2. """ from __future__ import annotations from functools import partial from math import floor from typing import ( Callable, TYPE_CHECKING, ) import pyqtgraph as pg from pyqtgraph import ( Point, functions as fn, ) from piker.ui.qt import ( px_cache_mode, QtCore, QtGui, QGraphicsPathItem, QStyleOptionGraphicsItem, QGraphicsScene, QWidget, QPointF, ) from ._annotate import LevelMarker from ._anchors import ( vbr_left, right_axis, gpath_pin, ) from ..calc import humanize from ._label import Label from ._style import hcolor, _font from ..log import get_logger if TYPE_CHECKING: from ._cursor import Cursor log = get_logger(__name__) # TODO: probably worth investigating if we can # make .boundingRect() faster: # https://stackoverflow.com/questions/26156486/determine-bounding-rect-of-line-in-qt class LevelLine(pg.InfiniteLine): def __init__( self, chart: 'ChartPlotWidget', # type: ignore # noqa # style color: str = 'default', highlight_color: str = 'default_light', dotted: bool = False, # UX look and feel opts always_show_labels: bool = False, highlight_on_hover: bool = True, hide_xhair_on_hover: bool = True, only_show_markers_on_hover: bool = True, use_marker_margin: bool = False, movable: bool = True, ) -> None: # TODO: at this point it's probably not worth the inheritance # any more since we've reimplemented ``.pain()`` among other # things.. super().__init__( movable=movable, angle=0, # don't use the shitty ``InfLineLabel`` label=None, ) self._chart = chart self.highlight_on_hover = highlight_on_hover self._dotted = dotted self._hide_xhair_on_hover = hide_xhair_on_hover # callback that can be assigned by user code # to get updates from each level change self._on_level_change: Callable[[float], None] = lambda y: None self._marker = None self.only_show_markers_on_hover = only_show_markers_on_hover self.track_marker_pos: bool = False # should line go all the way to far end or leave a "margin" # space for other graphics (eg. L1 book) self.use_marker_margin: bool = use_marker_margin if dotted: self._style = QtCore.Qt.DashLine else: self._style = QtCore.Qt.SolidLine self._hcolor: str = None # the float y-value in the view coords self.level: float = 0 # list of labels anchored at one of the 2 line endpoints # inside the viewbox self._labels: list[Label] = [] self._markers: list[(int, Label)] = [] # whenever this line is moved trigger label updates self.sigPositionChanged.connect(self.on_pos_change) # sets color to value triggering pen creation self._hl_color = highlight_color self.color = color # TODO: for when we want to move groups of lines? self._track_cursor: bool = False self.always_show_labels = always_show_labels self._on_drag_start = lambda lvln: None self._on_drag_end = lambda lvln: None self._y_incr_mult = float(1 / chart.linked.mkt.size_tick) self._right_end_sc: float = 0 # use px caching self.setCacheMode( px_cache_mode.DeviceCoordinateCache ) def txt_offsets(self) -> tuple[int, int]: return 0, 0 @property def color(self): return self._hcolor @color.setter def color(self, color: str) -> None: # set pens to new color self._hcolor = color pen = pg.mkPen(hcolor(color)) hoverpen = pg.mkPen(hcolor(self._hl_color)) pen.setStyle(self._style) hoverpen.setStyle(self._style) # set regular pen self.setPen(pen) # use slightly thicker highlight for hover pen hoverpen.setWidth(2) self.hoverPen = hoverpen def on_pos_change( self, line: 'LevelLine', # noqa ) -> None: """Position changed handler. """ level = self.value() self.update_labels({'level': level}) self.set_level(level, called_from_on_pos_change=True) def update_labels( self, fields_data: dict, ) -> None: for label in self._labels: label.color = self.color label.fields.update(fields_data) label.render() level = fields_data.get('level') if level: label.set_view_pos(y=level) self.update() def hide_labels(self) -> None: for label in self._labels: label.hide() def show_labels(self) -> None: for label in self._labels: label.show() def set_level( self, level: float, called_from_on_pos_change: bool = False, ) -> None: if not called_from_on_pos_change: last: float = self.value() # if the position hasn't changed then ``.update_labels()`` # will not be called by a non-triggered `.on_pos_change()`, # so we need to call it manually to avoid mismatching # label-to-line color when the line is updated but not # from a "moved" event. if level == last: self.update_labels({'level': level}) self.setPos(level) self.level = self.value() self.update() # invoke any user code self._on_level_change(level) def on_tracked_source( self, x: int, y: float ) -> None: ''' Chart coordinates cursor tracking callback. this is called by our ``Cursor`` type once this line is set to track the cursor: for every movement this callback is invoked to reposition the line with the current view coordinates. ''' self.movable = True self.set_level(y) # implictly calls reposition handler def mouseDragEvent(self, ev): ''' Override the ``InfiniteLine`` handler since we need more detailed control and start end signalling. ''' cursor = self._chart.linked.cursor # hide y-crosshair cursor.hide_xhair() # highlight self.currentPen = self.hoverPen self.show_labels() # XXX: normal tracking behavior pulled out from parent type if self.movable and ev.button() == QtCore.Qt.LeftButton: ev.accept() if ev.isStart(): self.moving = True down_pos = ev.buttonDownPos() self.cursorOffset = self.pos() - self.mapToParent(down_pos) self.startPosition = self.pos() self._on_drag_start(self) if not self.moving: return pos = self.cursorOffset + self.mapToParent(ev.pos()) # TODO: we should probably figure out a std api # for this kind of thing given we already have # it on the cursor system... # round to nearest symbol tick m = self._y_incr_mult self.setPos( QPointF( self.pos().x(), # don't allow shifting horizontally round(pos.y() * m) / m ) ) self.sigDragged.emit(self) if ev.isFinish(): self.moving = False self.sigPositionChangeFinished.emit(self) self._on_drag_end(self) # This is the final position in the drag if ev.isFinish(): # show y-crosshair again cursor.show_xhair() def get_cursor(self) -> Cursor | None: chart = self._chart cur = chart.linked.cursor if self in cur._hovered: return cur return None def delete(self) -> None: ''' Remove this line from containing chart/view/scene. ''' scene: QGraphicsScene = self.scene() if scene: for label in self._labels: label.delete() # gc managed labels? self._labels.clear() if self._marker: self.scene().removeItem(self._marker) # remove from chart/cursor states chart = self._chart cur = self.get_cursor() if cur: cur._hovered.remove(self) chart.plotItem.removeItem(self) def mouseDoubleClickEvent( self, ev: QtGui.QMouseEvent, ) -> None: # TODO: enter labels edit mode log.debug(f'double click {ev}') def paint( self, p: QtGui.QPainter, opt: QStyleOptionGraphicsItem, w: QWidget ) -> None: ''' Core paint which we override (yet again) from pg.. ''' p.setRenderHint(p.Antialiasing) # these are in viewbox coords vb_left, vb_right = self._endPoints vb = self.getViewBox() line_end, marker_right, r_axis_x = self._chart.marker_right_points() # (legacy) NOTE: at one point this seemed slower when moving around # order lines.. not sure if that's still true or why but we've # dropped the original hacky `.pain()` transform stuff for inf # line markers now - check the git history if it needs to be # reverted. if self._marker: if self.track_marker_pos: # make the line end at the marker's x pos line_end = marker_right = self._marker.pos().x() # TODO: make this label update part of a scene-aware-marker # composed annotation self._marker.setPos( QPointF(marker_right, self.scene_y()) ) if hasattr(self._marker, 'label'): self._marker.label.update() elif not self.use_marker_margin: # basically means **don't** shorten the line with normally # reserved space for a direction marker but, leave small # blank gap for style line_end = r_axis_x - 10 line_end_view = vb.mapToView(Point(line_end, 0)).x() # self.currentPen.setJoinStyle(QtCore.Qt.MiterJoin) p.setPen(self.currentPen) p.drawLine( Point(vb_left, 0), Point(line_end_view, 0) ) self._right_end_sc = line_end def hide(self) -> None: super().hide() mkr = self._marker if mkr: mkr.hide() def show(self) -> None: super().show() if self._marker: self._marker.show() def scene_y(self) -> float: return self.getViewBox().mapFromView( Point(0, self.value()) ).y() def scene_endpoint(self) -> QPointF: if not self._right_end_sc: line_end, _, _ = self._chart.marker_right_points() self._right_end_sc = line_end - 10 return QPointF(self._right_end_sc, self.scene_y()) def add_marker( self, path: QGraphicsPathItem, ) -> QGraphicsPathItem: self._marker = path self._marker.setPen(self.currentPen) self._marker.setBrush(fn.mkBrush(self.currentPen.color())) # add path to scene self.getViewBox().scene().addItem(path) # place to just-left of L1 labels rsc = self._chart.pre_l1_xs()[0] path.setPos(QPointF(rsc, self.scene_y())) return path @property def marker(self) -> LevelMarker: return self._marker def hoverEvent(self, ev): ''' Mouse hover callback. ''' cur = self._chart.linked.cursor # hovered if ( not ev.isExit() and ev.acceptDrags(QtCore.Qt.LeftButton) ): # if already hovered we don't need to run again if ( self.mouseHovering is True and cur.is_hovered(self) ): log.debug( f'Already hovering ??\n' f'cur._hovered: {cur._hovered!r}\n' ) return if self.only_show_markers_on_hover: self.show_markers() # highlight if so configured if self.highlight_on_hover: self.currentPen = self.hoverPen if self not in cur._trackers: # only disable cursor y-label updates # if we're highlighting a line cur._y_label_update = False # add us to cursor state log.debug(f'Adding line {self!r}\n') cur.add_hovered(self) if self._hide_xhair_on_hover: cur.hide_xhair( # set y-label to current value y_label_level=self.value(), just_vertical=True, # fg_color=self._hcolor, # bg_color=self._hcolor, ) # if we want highlighting of labels # it should be delegated into this method self.show_labels() self.mouseHovering = True # un-hovered else: if self.mouseHovering is False: return cur._y_label_update = True self.currentPen = self.pen log.debug(f'Removing line {self!r}\n') cur._hovered.remove(self) if self.only_show_markers_on_hover: self.hide_markers() if self not in cur._trackers: cur.show_xhair(y_label_level=self.value()) if not self.always_show_labels: self.hide_labels() self.mouseHovering = False self.update() def hide_markers(self) -> None: if self._marker: self._marker.hide() self._marker.label.hide() def show_markers(self) -> None: if self._marker: self._marker.show() def level_line( chart: 'ChartPlotWidget', # noqa level: float, # line style dotted: bool = False, color: str = 'default', # ux highlight_on_hover: bool = True, # label fields and options always_show_labels: bool = False, add_label: bool = True, orient_v: str = 'bottom', **kwargs, ) -> LevelLine: ''' Convenience routine to add a styled horizontal line to a plot. ''' hl_color = color + '_light' if highlight_on_hover else color line = LevelLine( chart, color=color, # lookup "highlight" equivalent highlight_color=hl_color, dotted=dotted, # UX related options highlight_on_hover=highlight_on_hover, # when set to True the label is always shown instead of just on # highlight (which is a privacy thing for orders) always_show_labels=always_show_labels, **kwargs, ) chart.plotItem.addItem(line) if add_label: label = Label( view=line.getViewBox(), # by default we only display the line's level value # in the label fmt_str=('{level:,.{level_digits}f}'), color=color, ) # anchor to right side (of view ) label label.set_x_anchor_func( right_axis( chart, label, side='left', # side of axis offset=0, avoid_book=False, ) ) # add to label set which will be updated on level changes line._labels.append(label) label.orient_v = orient_v line.update_labels({'level': level, 'level_digits': 2}) label.render() # keep pp label details private until # the user edge triggers "order mode" line.hide_labels() # activate/draw label line.set_level(level) return line def order_line( chart, level: float, action: str | None = 'buy', # buy or sell marker_style: str | None = None, level_digits: float | None = 3, size: int | None = 1, size_digits: int = 1, show_markers: bool = False, submit_price: float = None, orient_v: str = 'bottom', **line_kwargs, ) -> LevelLine: ''' Convenience routine to add a line graphic representing an order execution submitted to the EMS via the chart's "order mode". ''' line = level_line( chart, level, add_label=False, use_marker_margin=True, **line_kwargs ) font_size = _font.font.pixelSize() # scale marker size with dpi-aware font size marker_size = floor(1.375 * font_size) orient_v = 'top' if action == 'sell' else 'bottom' if action == 'alert': label = Label( view=line.getViewBox(), color=line.color, # completely different labelling for alerts fmt_str='alert => {level}', ) # for now, we're just duplicating the label contents i guess.. line._labels.append(label) # anchor to left side of view / line label.set_x_anchor_func(vbr_left(label)) label.fields = { 'level': level, 'level_digits': level_digits, } marker_size = marker_size * 0.666 else: view = line.getViewBox() # far-side label label = Label( view=view, # display the order pos size, which is some multiple # of the user defined base unit size fmt_str=( '{account_text}{size:.{size_digits}f}u{fiat_text}' ), color=line.color, ) label.set_x_anchor_func(vbr_left(label)) line._labels.append(label) def maybe_show_fiat_text(fields: dict) -> str: fiat_size = fields.get('fiat_size') if not fiat_size: return '' return f' ~ ${humanize(fiat_size)}' def maybe_show_account_name(fields: dict) -> str: account = fields.get('account') if not account: return '' return f'{account}: ' label.fields = { 'size': size, 'size_digits': 0, 'fiat_size': None, 'fiat_text': maybe_show_fiat_text, 'account': None, 'account_text': maybe_show_account_name, } label.orient_v = orient_v label.render() label.show() if show_markers: # add arrow marker on end of line nearest y-axis marker_style = marker_style or { 'buy': '|<', 'sell': '>|', 'alert': 'v', }[action] # the old way which is still somehow faster? marker = LevelMarker( chart=chart, style=marker_style, get_level=line.value, # callback size=marker_size, keep_in_view=False, ) # XXX: this is our new approach but seems slower? marker = line.add_marker(marker) # XXX: DON'T COMMENT THIS! # this fixes it the artifact issue! # .. of course, bounding rect stuff line._maxMarkerSize = marker_size assert line._marker is marker assert not line.markers # above we use ``QPathGraphicsItem``s directly to draw markers # in scene coords instead of the way ``InfiniteLine`` does it # internally: by resetting the graphics item transform # intermittently inside ``.paint()`` which we've copied and # seperated as ``qgo_draw_markers()`` if we ever want to go back # to it; likely we can remove this. # manually append for later ``InfiniteLine.paint()`` drawing # XXX: this was manually tested as faster then using the # QGraphicsItem around a painter path.. probably needs further # testing to figure out why tf that's true. # line.markers.append((marker, 0, marker_size)) if action != 'alert': # add a partial position label if we also added a level # marker pp_size_label = Label( view=view, color=line.color, # this is "static" label # update_on_range_change=False, fmt_str='\n'.join(( '{slots_used:.1f}x', )), fields={ 'slots_used': 0, }, ) pp_size_label.render() pp_size_label.show() line._labels.append(pp_size_label) # TODO: pretty sure one of the reasons these "label # updatess" are a bit "jittery" is because we aren't # leveraging the "scene coordinates hierarchy" stuff: # i.e. using some parent object as the coord "origin" # which i presume would result in better pixel caching # results? def something to dig into.. pp_size_label.scene_anchor = partial( gpath_pin, gpath=marker, label=pp_size_label, ) # XXX: without this the pp proportion label next the marker # seems to lag? this is the same issue we had with position # lines which we handle with ``.update_graphcis()``. marker._on_paint = lambda marker: pp_size_label.update() # XXX: THIS IS AN UNTYPED MONKEY PATCH!?!?! marker.label = label # sanity check line.update_labels({'level': level}) return line # TODO: should probably consider making this a more general # purpose class method on the type? def copy_from_order_line( chart: 'ChartPlotWidget', # noqa line: LevelLine ) -> LevelLine: return order_line( chart, # label fields default values level=line.value(), marker_style=line._marker.style, # LevelLine kwargs color=line.color, dotted=line._dotted, show_markers=line.show_markers, only_show_markers_on_hover=line.only_show_markers_on_hover, )