Support arbitrary fields (with update) in labels

This turned into a larger endeavour then intended but now we're using our
own label system on level lines to be able to display things nicely
**pinned wherever we want in the UI**. Keep the old ``LevelLabel`` for
now for the L1 graphics but we'll likely replace this as well since i'm
pretty sure the new label type (which wraps `QGraphicsTextItem`) is more
performant anyway.
basic_orders
Tyler Goodlet 2021-02-11 13:59:50 -05:00
parent cbf259f3f3
commit f51e503e47
1 changed files with 346 additions and 194 deletions

View File

@ -18,74 +18,93 @@
Lines for orders, alerts, L2. Lines for orders, alerts, L2.
""" """
from typing import Tuple, Dict, Any, Optional from typing import Tuple, Optional, List
import pyqtgraph as pg import pyqtgraph as pg
from PyQt5 import QtCore, QtGui from PyQt5 import QtCore, QtGui
from PyQt5.QtCore import QPointF from PyQt5.QtCore import QPointF
from .._label import Label, vbr_left, right_axis
from .._style import ( from .._style import (
hcolor, hcolor,
_down_2_font_inches_we_like, _down_2_font_inches_we_like,
# _font,
# DpiAwareFont
) )
from .._axes import YAxisLabel from .._axes import YAxisLabel
class LevelLabel(YAxisLabel): class LevelLabel(YAxisLabel):
"""Y-axis oriented label that sticks to where it's placed despite """Y-axis (vertically) oriented, horizontal label that sticks to
chart resizing and supports displaying multiple fields. where it's placed despite chart resizing and supports displaying
multiple fields.
""" """
_w_margin = 4 _x_margin = 0
_h_margin = 3 _y_margin = 0
# adjustment "further away from" parent axis # adjustment "further away from" anchor point
_x_offset = 0 _x_offset = 9
_y_offset = 0
# fields to be displayed # fields to be displayed in the label string
# class fields: _fields = {
level: float = 0.0 'level': 0,
digits: int = 2 'level_digits': 2,
size: float = 2.0 }
size_digits: int = int(2.0) # default label template is just a y-level with so much precision
_fmt_str = '{level:,.{level_digits}f}'
def __init__( def __init__(
self, self,
chart, chart,
*args, parent,
color: str = 'bracket', color: str = 'bracket',
orient_v: str = 'bottom', orient_v: str = 'bottom',
orient_h: str = 'left', orient_h: str = 'left',
**kwargs
opacity: float = 0,
# makes order line labels offset from their parent axis
# such that they don't collide with the L1/L2 lines/prices
# that are displayed on the axis
adjust_to_l1: bool = False,
**axis_label_kwargs,
) -> None: ) -> None:
super().__init__( super().__init__(
chart, chart,
*args, parent=parent,
use_arrow=False, use_arrow=False,
**kwargs opacity=opacity,
**axis_label_kwargs
) )
# TODO: this is kinda cludgy # TODO: this is kinda cludgy
self._hcolor = None self._hcolor: pg.Pen = None
self.color = color self.color: str = color
# orientation around axis options # orientation around axis options
self._orient_v = orient_v self._orient_v = orient_v
self._orient_h = orient_h self._orient_h = orient_h
self._adjust_to_l1 = adjust_to_l1
self._v_shift = { self._v_shift = {
'top': 1., 'top': -1.,
'bottom': 0, 'bottom': 0.,
'middle': 1 / 2. 'middle': 1 / 2.
}[orient_v] }[orient_v]
self._h_shift = { self._h_shift = {
'left': -1., 'right': 0 'left': -1.,
'right': 0.
}[orient_h] }[orient_h]
self._fmt_fields: Dict[str, Dict[str, Any]] = {} self.fields = self._fields.copy()
self._use_extra_fields: bool = False # ensure default format fields are in correct
self.set_fmt_str(self._fmt_str, self.fields)
@property @property
def color(self): def color(self):
@ -96,42 +115,65 @@ class LevelLabel(YAxisLabel):
self._hcolor = color self._hcolor = color
self._pen = self.pen = pg.mkPen(hcolor(color)) self._pen = self.pen = pg.mkPen(hcolor(color))
def update_on_resize(self, vr, r):
"""Tiis is a ``.sigRangeChanged()`` handler.
"""
self.update_fields(self.fields)
def update_fields(
self,
fields: dict = None,
) -> None:
"""Update the label's text contents **and** position from
a view box coordinate datum.
"""
self.fields.update(fields)
level = self.fields['level']
# map "level" to local coords
abs_xy = self._chart.mapFromView(QPointF(0, level))
self.update_label(
abs_xy,
self.fields,
)
def update_label( def update_label(
self, self,
abs_pos: QPointF, # scene coords abs_pos: QPointF, # scene coords
level: float, # data for text fields: dict,
offset: int = 1 # if have margins, k?
) -> None: ) -> None:
# write contents, type specific # write contents, type specific
h, w = self.set_label_str(level) h, w = self.set_label_str(fields)
if self._adjust_to_l1:
self._x_offset = _max_l1_line_len
# this triggers ``.paint()`` implicitly or no?
self.setPos(QPointF( self.setPos(QPointF(
self._h_shift * w - self._x_offset, self._h_shift * (w + self._x_offset),
abs_pos.y() - (self._v_shift * h) - offset abs_pos.y() + self._v_shift * h
)) ))
# trigger .paint()
self.update()
self.level = level def set_fmt_str(
self,
fmt_str: str,
fields: dict,
) -> (str, str):
# test that new fmt str can be rendered
self._fmt_str = fmt_str
self.set_label_str(fields)
self.fields.update(fields)
return fmt_str, self.label_str
def set_label_str(self, level: float): def set_label_str(
self,
fields: dict,
):
# use space as e3 delim # use space as e3 delim
label_str = (f'{level:,.{self.digits}f} ').replace(',', ' ') self.label_str = self._fmt_str.format(**fields).replace(',', ' ')
# XXX: not huge on this approach but we need a more formal
# way to define "label fields" that i don't have the brain space
# for atm.. it's at least a **lot** better then the wacky
# internals of InfLinelabel or wtv.
# mutate label to contain any extra defined format fields
if self._use_extra_fields:
for fmt_str, fields in self._fmt_fields.items():
label_str = fmt_str.format(
**{f: getattr(self, f) for f in fields}) + label_str
self.label_str = label_str
br = self.boundingRect() br = self.boundingRect()
h, w = br.height(), br.width() h, w = br.height(), br.width()
@ -147,6 +189,8 @@ class LevelLabel(YAxisLabel):
) -> None: ) -> None:
p.setPen(self._pen) p.setPen(self._pen)
rect = self.rect
if self._orient_v == 'bottom': if self._orient_v == 'bottom':
lp, rp = rect.topLeft(), rect.topRight() lp, rp = rect.topLeft(), rect.topRight()
# p.drawLine(rect.topLeft(), rect.topRight()) # p.drawLine(rect.topLeft(), rect.topRight())
@ -164,13 +208,6 @@ class LevelLabel(YAxisLabel):
self._pen = self.pen self._pen = self.pen
self.update() self.update()
# def view_size(self):
# """Widgth and height of this label in view box coordinates.
# """
# return self.height()
# self._chart.mapFromView(QPointF(index, value)),
# global for now but probably should be # global for now but probably should be
# attached to chart instance? # attached to chart instance?
@ -179,20 +216,19 @@ _max_l1_line_len: float = 0
class L1Label(LevelLabel): class L1Label(LevelLabel):
size: float = 0
size_digits: int = 3
text_flags = ( text_flags = (
QtCore.Qt.TextDontClip QtCore.Qt.TextDontClip
| QtCore.Qt.AlignLeft | QtCore.Qt.AlignLeft
) )
def set_label_str(self, level: float) -> None: def set_label_str(
"""Reimplement the label string write to include the level's order-queue's self,
size in the text, eg. 100 x 323.3. fields: dict,
) -> None:
"""Make sure the max L1 line module var is kept up to date.
""" """
h, w = super().set_label_str(level) h, w = super().set_label_str(fields)
# Set a global "max L1 label length" so we can look it up # Set a global "max L1 label length" so we can look it up
# on order lines and adjust their labels not to overlap with it. # on order lines and adjust their labels not to overlap with it.
@ -206,8 +242,6 @@ class L1Labels:
"""Level 1 bid ask labels for dynamic update on price-axis. """Level 1 bid ask labels for dynamic update on price-axis.
""" """
max_value: float = '100.0 x 100 000.00'
def __init__( def __init__(
self, self,
chart: 'ChartPlotWidget', # noqa chart: 'ChartPlotWidget', # noqa
@ -218,39 +252,41 @@ class L1Labels:
self.chart = chart self.chart = chart
self.bid_label = L1Label( raxis = chart.getAxis('right')
chart=chart, kwargs = {
parent=chart.getAxis('right'), 'chart': chart,
opacity=1, 'parent': raxis,
font_size_inches=font_size_inches,
bg_color='papas_special', 'opacity': 1,
fg_color='bracket', 'font_size_inches': font_size_inches,
'fg_color': chart.pen_color,
'bg_color': chart.view_color,
}
fmt_str = (
'{size:.{size_digits}f} x '
'{level:,.{level_digits}f}'
)
fields = {
'level': 0,
'level_digits': digits,
'size': 0,
'size_digits': size_digits,
}
bid = self.bid_label = L1Label(
orient_v='bottom', orient_v='bottom',
**kwargs,
) )
self.bid_label.size_digits = size_digits bid.set_fmt_str(fmt_str=fmt_str, fields=fields)
self.bid_label.digits = digits bid.show()
# self.bid_label._size_br_from_str(self.max_value)
self.ask_label = L1Label( ask = self.ask_label = L1Label(
chart=chart,
parent=chart.getAxis('right'),
opacity=1,
font_size_inches=font_size_inches,
bg_color='papas_special',
fg_color='bracket',
orient_v='top', orient_v='top',
**kwargs,
) )
self.ask_label.size_digits = size_digits ask.set_fmt_str(fmt_str=fmt_str, fields=fields)
self.ask_label.digits = digits ask.show()
# self.ask_label._size_br_from_str(self.max_value)
self.bid_label._use_extra_fields = True
self.ask_label._use_extra_fields = True
self.bid_label._fmt_fields['{size:.{size_digits}f} x '] = {
'size', 'size_digits'}
self.ask_label._fmt_fields['{size:.{size_digits}f} x '] = {
'size', 'size_digits'}
# TODO: probably worth investigating if we can # TODO: probably worth investigating if we can
@ -264,32 +300,46 @@ class LevelLine(pg.InfiniteLine):
def __init__( def __init__(
self, self,
chart: 'ChartPlotWidget', # type: ignore # noqa chart: 'ChartPlotWidget', # type: ignore # noqa
label: LevelLabel,
color: str = 'default', color: str = 'default',
highlight_color: str = 'default_light', highlight_color: str = 'default_light',
hl_on_hover: bool = True, hl_on_hover: bool = True,
dotted: bool = False, dotted: bool = False,
adjust_to_l1: bool = False, always_show_labels: bool = False,
always_show_label: bool = False,
**kwargs,
) -> None: ) -> None:
super().__init__(**kwargs) super().__init__(
self.label = label movable=True,
angle=0,
self.sigPositionChanged.connect(self.set_level) label=None, # don't use the shitty ``InfLineLabel``
)
self._chart = chart self._chart = chart
self._hoh = hl_on_hover self._hoh = hl_on_hover
self._dotted = dotted self._dotted = dotted
self._hcolor: str = None
self._hcolor = 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[(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.color = color self.color = color
# TODO: for when we want to move groups of lines? # TODO: for when we want to move groups of lines?
self._track_cursor: bool = False self._track_cursor: bool = False
self._adjust_to_l1 = adjust_to_l1 self._always_show_labels = always_show_labels
self._always_show_label = always_show_label
# # indexed by int
# self._endpoints = (None, None)
# testing markers # testing markers
# self.addMarker('<|', 0.1, 3) # self.addMarker('<|', 0.1, 3)
@ -301,6 +351,9 @@ class LevelLine(pg.InfiniteLine):
# self.addMarker('v', 0.7, 3) # self.addMarker('v', 0.7, 3)
# self.addMarker('o', 0.8, 3) # self.addMarker('o', 0.8, 3)
def txt_offsets(self) -> Tuple[int, int]:
return 0, 0
@property @property
def color(self): def color(self):
return self._hcolor return self._hcolor
@ -323,15 +376,84 @@ class LevelLine(pg.InfiniteLine):
hoverpen.setWidth(2) hoverpen.setWidth(2)
self.hoverPen = hoverpen self.hoverPen = hoverpen
def set_level(self) -> None: def add_label(
self,
label = self.label # by default we only display the line's level value
# in the label
fmt_str: str = (
'{level:,.{level_digits}f}'
),
side: str = 'right',
# TODO: a better way to accomplish this... font_size_inches: float = _down_2_font_inches_we_like,
if self._adjust_to_l1: color: str = None,
label._x_offset = _max_l1_line_len bg_color: str = None,
label.update_from_data(0, self.value()) **label_kwargs,
) -> LevelLabel:
"""Add a ``LevelLabel`` anchored at one of the line endpoints in view.
"""
vb = self.getViewBox()
label = Label(
view=vb,
fmt_str=fmt_str,
color=self.color,
)
if side == 'right':
label.set_x_anchor_func(right_axis(self._chart, label))
elif side == 'left':
label.set_x_anchor_func(vbr_left(label))
self._labels.append((side, label))
return label
def on_pos_change(
self,
line: 'LevelLine', # noqa
) -> None:
"""Position changed handler.
"""
self.update_labels({'level': self.value()})
def update_labels(
self,
fields_data: dict,
) -> None:
for at, label in self._labels:
label.color = self.color
label.fields.update(fields_data)
level = fields_data.get('level')
if level:
label.set_view_y(level)
label.render()
self.update()
def hide_labels(self) -> None:
for at, label in self._labels:
label.hide()
def show_labels(self) -> None:
for at, label in self._labels:
label.show()
def set_level(
self,
level: float,
) -> None:
self.setPos(level)
self.level = self.value()
self.update()
def on_tracked_source( def on_tracked_source(
self, self,
@ -342,8 +464,7 @@ class LevelLine(pg.InfiniteLine):
# line is set to track the cursor: for every movement # line is set to track the cursor: for every movement
# this callback is invoked to reposition the line # this callback is invoked to reposition the line
self.movable = True self.movable = True
self.setPos(y) # implictly calls ``.set_level()`` self.set_level(y) # implictly calls reposition handler
self.update()
def setMouseHover(self, hover: bool) -> None: def setMouseHover(self, hover: bool) -> None:
"""Mouse hover callback. """Mouse hover callback.
@ -361,12 +482,18 @@ class LevelLine(pg.InfiniteLine):
# highlight if so configured # highlight if so configured
if self._hoh: if self._hoh:
self.currentPen = self.hoverPen self.currentPen = self.hoverPen
self.label.highlight(self.hoverPen)
# for at, label in self._labels:
# label.highlight(self.hoverPen)
# add us to cursor state # add us to cursor state
chart._cursor.add_hovered(self) cur = chart._cursor
cur.add_hovered(self)
cur.graphics[chart]['yl'].hide()
for at, label in self._labels:
label.show()
self.label.show()
# TODO: hide y-crosshair? # TODO: hide y-crosshair?
# chart._cursor.graphics[chart]['hl'].hide() # chart._cursor.graphics[chart]['hl'].hide()
@ -374,12 +501,15 @@ class LevelLine(pg.InfiniteLine):
# self.setCursor(QtCore.Qt.DragMoveCursor) # self.setCursor(QtCore.Qt.DragMoveCursor)
else: else:
self.currentPen = self.pen self.currentPen = self.pen
self.label.unhighlight()
chart._cursor._hovered.remove(self) cur = chart._cursor
cur._hovered.remove(self)
cur.graphics[chart]['yl'].show()
if not self._always_show_label: if not self._always_show_labels:
self.label.hide() for at, label in self._labels:
label.hide()
# label.unhighlight()
# highlight any attached label # highlight any attached label
@ -387,12 +517,16 @@ class LevelLine(pg.InfiniteLine):
def mouseDragEvent(self, ev): def mouseDragEvent(self, ev):
chart = self._chart chart = self._chart
# hide y-crosshair # hide y-crosshair
chart._cursor.graphics[chart]['hl'].hide() chart._cursor.graphics[chart]['hl'].hide()
# highlight # highlight
self.currentPen = self.hoverPen self.currentPen = self.hoverPen
self.label.highlight(self.hoverPen) # self.label.highlight(self.hoverPen)
for at, label in self._labels:
# label.highlight(self.hoverPen)
label.show()
# normal tracking behavior # normal tracking behavior
super().mouseDragEvent(ev) super().mouseDragEvent(ev)
@ -400,15 +534,8 @@ class LevelLine(pg.InfiniteLine):
# This is the final position in the drag # This is the final position in the drag
if ev.isFinish(): if ev.isFinish():
# show y-crosshair again # show y-crosshair again
chart = self._chart
chart._cursor.graphics[chart]['hl'].show() chart._cursor.graphics[chart]['hl'].show()
def mouseDoubleClickEvent(
self,
ev: QtGui.QMouseEvent,
) -> None:
print(f'double click {ev}')
def delete(self) -> None: def delete(self) -> None:
"""Remove this line from containing chart/view/scene. """Remove this line from containing chart/view/scene.
@ -416,29 +543,26 @@ class LevelLine(pg.InfiniteLine):
scene = self.scene() scene = self.scene()
if scene: if scene:
# self.label.parent.scene().removeItem(self.label) # self.label.parent.scene().removeItem(self.label)
scene.removeItem(self.label) for at, label in self._labels:
label.delete()
self._labels.clear()
self._chart.plotItem.removeItem(self) self._chart.plotItem.removeItem(self)
def getEndpoints(self): def mouseDoubleClickEvent(
"""Get line endpoints at view edges. self,
ev: QtGui.QMouseEvent,
) -> None:
Stolen from InfLineLabel. # TODO: enter labels edit mode
print(f'double click {ev}')
"""
# calculate points where line intersects view box
# (in line coordinates)
lr = self.boundingRect()
pt1 = pg.Point(lr.left(), 0)
pt2 = pg.Point(lr.right(), 0)
return pt1, pt2
def level_line( def level_line(
chart: 'ChartPlogWidget', # noqa chart: 'ChartPlogWidget', # noqa
level: float, level: float,
digits: int = 1,
color: str = 'default', color: str = 'default',
# size 4 font on 4k screen scaled down, so small-ish. # size 4 font on 4k screen scaled down, so small-ish.
@ -451,91 +575,119 @@ def level_line(
# line style # line style
dotted: bool = False, dotted: bool = False,
adjust_to_l1: bool = False, # label fields and options
digits: int = 1,
always_show_label: bool = False, always_show_labels: bool = False,
add_label: bool = True,
orient_v: str = 'bottom',
**linelabelkwargs
) -> LevelLine: ) -> LevelLine:
"""Convenience routine to add a styled horizontal line to a plot. """Convenience routine to add a styled horizontal line to a plot.
""" """
label = LevelLabel(
chart=chart,
parent=chart.getAxis('right'),
# TODO: pass this from symbol data
digits=digits,
opacity=0.616,
font_size_inches=font_size_inches,
color=color,
# TODO: make this take the view's bg pen
bg_color='papas_special',
fg_color=color,
**linelabelkwargs
)
label.update_from_data(0, level)
# by default, the label must be shown by client code
label.hide()
# TODO: can we somehow figure out a max value from the parent axis?
label._size_br_from_str(label.label_str)
line = LevelLine( line = LevelLine(
chart, chart,
label,
color=color, color=color,
# lookup "highlight" equivalent # lookup "highlight" equivalent
highlight_color=color + '_light', highlight_color=color + '_light',
movable=True,
angle=0,
dotted=dotted, dotted=dotted,
# UX related options # UX related options
hl_on_hover=hl_on_hover, hl_on_hover=hl_on_hover,
# makes order line labels offset from their parent axis
# such that they don't collide with the L1/L2 lines/prices
# that are displayed on the axis
adjust_to_l1=adjust_to_l1,
# when set to True the label is always shown instead of just on # when set to True the label is always shown instead of just on
# highlight (which is a privacy thing for orders) # highlight (which is a privacy thing for orders)
always_show_label=always_show_label, always_show_labels=always_show_labels,
) )
# activate/draw label
line.setValue(level) # it's just .setPos() right?
line.set_level()
chart.plotItem.addItem(line) chart.plotItem.addItem(line)
if add_label:
label = line.add_label(
side='right',
opacity=1,
)
label.orient_v = orient_v
line.update_labels({'level': level, 'level_digits': 2})
label.render()
line.hide_labels()
# activate/draw label
line.set_level(level)
return line return line
def order_line( def order_line(
*args, chart,
size: Optional[int] = None, level: float,
level_digits: float,
size: Optional[int] = 1,
size_digits: int = 0, size_digits: int = 0,
**kwargs,
submit_price: float = None,
order_status: str = 'dark',
order_type: str = 'limit',
opacity=0.616,
orient_v: str = 'bottom',
**line_kwargs,
) -> LevelLine: ) -> LevelLine:
"""Convenience routine to add a line graphic representing an order execution """Convenience routine to add a line graphic representing an order
submitted to the EMS via the chart's "order mode". execution submitted to the EMS via the chart's "order mode".
""" """
line = level_line(*args, adjust_to_l1=True, **kwargs) line = level_line(
line.label._fmt_fields['{size:.{size_digits}f} x '] = { chart,
'size', 'size_digits'} level,
add_label=False,
**line_kwargs
)
if size is not None: llabel = line.add_label(
side='left',
fmt_str='{order_status}-{order_type}:{submit_price}',
)
llabel.fields = {
'order_status': order_status,
'order_type': order_type,
'submit_price': submit_price,
}
llabel.orient_v = orient_v
llabel.render()
llabel.show()
line.label._use_extra_fields = True rlabel = line.add_label(
line.label.size = size side='right',
line.label.size_digits = size_digits fmt_str=(
'{size:.{size_digits}f} x '
'{level:,.{level_digits}f}'
),
)
rlabel.fields = {
'size': size,
'size_digits': size_digits,
'level': level,
'level_digits': level_digits,
}
rlabel.orient_v = orient_v
rlabel.render()
rlabel.show()
# sanity check
line.update_labels({'level': level})
return line return line