piker/piker/ui/_lines.py

804 lines
22 KiB
Python

# 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 <https://www.gnu.org/licenses/>.
"""
Lines for orders, alerts, L2.
"""
from functools import partial
from math import floor
from typing import Optional, Callable
import pyqtgraph as pg
from pyqtgraph import Point, functions as fn
from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtCore import QPointF
from ._annotate import qgo_draw_markers, LevelMarker
from ._anchors import (
vbr_left,
right_axis,
gpath_pin,
)
from ..calc import humanize
from ._label import Label
from ._style import hcolor, _font
# 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.show_markers: bool = True # presuming the line is hovered at init
# 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 l: None
self._on_drag_end = lambda l: None
self._y_incr_mult = 1 / chart.linked.symbol.tick_size
self._right_end_sc: float = 0
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 = 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 delete(self) -> None:
"""Remove this line from containing chart/view/scene.
"""
scene = 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 = chart.linked.cursor
if self in cur._hovered:
cur._hovered.remove(self)
chart.plotItem.removeItem(self)
def mouseDoubleClickEvent(
self,
ev: QtGui.QMouseEvent,
) -> None:
# TODO: enter labels edit mode
print(f'double click {ev}')
def paint(
self,
p: QtGui.QPainter,
opt: QtWidgets.QStyleOptionGraphicsItem,
w: QtWidgets.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()
if self.show_markers and self.markers:
p.setPen(self.pen)
qgo_draw_markers(
self.markers,
self.pen.color(),
p,
vb_left,
vb_right,
marker_right,
)
# marker_size = self.markers[0][2]
self._maxMarkerSize = max([m[2] / 2. for m in self.markers])
# this seems slower when moving around
# order lines.. not sure wtf is up with that.
# for now we're just using it on the position line.
elif self._marker:
# 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()
if self._marker:
self._marker.hide()
# needed for ``order_line()`` lines currently
self._marker.label.hide()
def show(self) -> None:
super().show()
if self._marker:
self._marker.show()
# self._marker.label.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: QtWidgets.QGraphicsPathItem,
) -> QtWidgets.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
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:
return
if self.only_show_markers_on_hover:
self.show_markers = True
if self._marker:
self._marker.show()
# 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
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
cur._hovered.remove(self)
if self.only_show_markers_on_hover:
self.show_markers = False
if self._marker:
self._marker.hide()
self._marker.label.hide()
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 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: Optional[str] = 'buy', # buy or sell
marker_style: Optional[str] = None,
level_digits: Optional[float] = 3,
size: Optional[int] = 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,
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()
marker._on_paint = lambda marker: pp_size_label.update()
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,
)