diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index aed8c991..7228ef36 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -41,8 +41,8 @@ from ._graphics._cursor import ( from ._graphics._lines import ( level_line, order_line, - L1Labels, ) +from ._l1 import L1Labels from ._graphics._ohlc import BarItems from ._graphics._curve import FastAppendCurve from ._style import ( @@ -1105,7 +1105,7 @@ async def _async_main( ): # show line label once order is live - line = order_mode.on_submit(oid) + order_mode.on_submit(oid) # resp to 'cancel' request or error condition # for action request @@ -1132,7 +1132,7 @@ async def _async_main( price=msg['trigger_price'], arrow_index=get_index(time.time()) ) - line = await order_mode.on_exec(oid, msg) + await order_mode.on_exec(oid, msg) # response to completed 'action' request for buy/sell elif resp in ( diff --git a/piker/ui/_graphics/_lines.py b/piker/ui/_graphics/_lines.py index 04795ede..9dd1d500 100644 --- a/piker/ui/_graphics/_lines.py +++ b/piker/ui/_graphics/_lines.py @@ -22,274 +22,12 @@ from typing import Tuple, Optional, List import pyqtgraph as pg from PyQt5 import QtCore, QtGui -from PyQt5.QtCore import QPointF from .._label import Label, vbr_left, right_axis from .._style import ( hcolor, _down_2_font_inches_we_like, ) -from .._axes import YAxisLabel - - -class LevelLabel(YAxisLabel): - """Y-axis (vertically) oriented, horizontal label that sticks to - where it's placed despite chart resizing and supports displaying - multiple fields. - - - TODO: replace the rectangle-text part with our new ``Label`` type. - - """ - _x_margin = 0 - _y_margin = 0 - - # adjustment "further away from" anchor point - _x_offset = 9 - _y_offset = 0 - - # fields to be displayed in the label string - _fields = { - 'level': 0, - 'level_digits': 2, - } - # default label template is just a y-level with so much precision - _fmt_str = '{level:,.{level_digits}f}' - - def __init__( - self, - chart, - parent, - - color: str = 'bracket', - - orient_v: str = 'bottom', - orient_h: str = 'left', - - 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: - - super().__init__( - chart, - parent=parent, - use_arrow=False, - opacity=opacity, - **axis_label_kwargs - ) - - # TODO: this is kinda cludgy - self._hcolor: pg.Pen = None - self.color: str = color - - # orientation around axis options - self._orient_v = orient_v - self._orient_h = orient_h - - self._adjust_to_l1 = adjust_to_l1 - - self._v_shift = { - 'top': -1., - 'bottom': 0., - 'middle': 1 / 2. - }[orient_v] - - self._h_shift = { - 'left': -1., - 'right': 0. - }[orient_h] - - self.fields = self._fields.copy() - # ensure default format fields are in correct - self.set_fmt_str(self._fmt_str, self.fields) - - @property - def color(self): - return self._hcolor - - @color.setter - def color(self, color: str) -> None: - self._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( - self, - abs_pos: QPointF, # scene coords - fields: dict, - ) -> None: - - # write contents, type specific - h, w = self.set_label_str(fields) - - if self._adjust_to_l1: - self._x_offset = _max_l1_line_len - - self.setPos(QPointF( - self._h_shift * (w + self._x_offset), - abs_pos.y() + self._v_shift * h - )) - - 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, - fields: dict, - ): - # use space as e3 delim - self.label_str = self._fmt_str.format(**fields).replace(',', ' ') - - br = self.boundingRect() - h, w = br.height(), br.width() - return h, w - - def size_hint(self) -> Tuple[None, None]: - return None, None - - def draw( - self, - p: QtGui.QPainter, - rect: QtCore.QRectF - ) -> None: - p.setPen(self._pen) - - rect = self.rect - - if self._orient_v == 'bottom': - lp, rp = rect.topLeft(), rect.topRight() - # p.drawLine(rect.topLeft(), rect.topRight()) - - elif self._orient_v == 'top': - lp, rp = rect.bottomLeft(), rect.bottomRight() - - p.drawLine(lp.x(), lp.y(), rp.x(), rp.y()) - - def highlight(self, pen) -> None: - self._pen = pen - self.update() - - def unhighlight(self): - self._pen = self.pen - self.update() - - -# global for now but probably should be -# attached to chart instance? -_max_l1_line_len: float = 0 - - -class L1Label(LevelLabel): - - text_flags = ( - QtCore.Qt.TextDontClip - | QtCore.Qt.AlignLeft - ) - - def set_label_str( - self, - fields: dict, - ) -> None: - """Make sure the max L1 line module var is kept up to date. - - """ - h, w = super().set_label_str(fields) - - # 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. - global _max_l1_line_len - _max_l1_line_len = max(_max_l1_line_len, w) - - return h, w - - -class L1Labels: - """Level 1 bid ask labels for dynamic update on price-axis. - - """ - def __init__( - self, - chart: 'ChartPlotWidget', # noqa - digits: int = 2, - size_digits: int = 3, - font_size_inches: float = _down_2_font_inches_we_like, - ) -> None: - - self.chart = chart - - raxis = chart.getAxis('right') - kwargs = { - 'chart': chart, - 'parent': raxis, - - 'opacity': 1, - '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', - **kwargs, - ) - bid.set_fmt_str(fmt_str=fmt_str, fields=fields) - bid.show() - - ask = self.ask_label = L1Label( - orient_v='top', - **kwargs, - ) - ask.set_fmt_str(fmt_str=fmt_str, fields=fields) - ask.show() # TODO: probably worth investigating if we can @@ -391,7 +129,7 @@ class LevelLine(pg.InfiniteLine): bg_color: str = None, **label_kwargs, - ) -> LevelLabel: + ) -> Label: """Add a ``LevelLabel`` anchored at one of the line endpoints in view. """ diff --git a/piker/ui/_l1.py b/piker/ui/_l1.py new file mode 100644 index 00000000..90cc8aba --- /dev/null +++ b/piker/ui/_l1.py @@ -0,0 +1,291 @@ +# 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 . + +""" +Double auction top-of-book (L1) graphics. + +""" +from typing import Tuple + +import pyqtgraph as pg +from PyQt5 import QtCore, QtGui +from PyQt5.QtCore import QPointF + +from ._axes import YAxisLabel +from ._style import ( + hcolor, + _down_2_font_inches_we_like, +) + + +class LevelLabel(YAxisLabel): + """Y-axis (vertically) oriented, horizontal label that sticks to + where it's placed despite chart resizing and supports displaying + multiple fields. + + + TODO: replace the rectangle-text part with our new ``Label`` type. + + """ + _x_margin = 0 + _y_margin = 0 + + # adjustment "further away from" anchor point + _x_offset = 9 + _y_offset = 0 + + # fields to be displayed in the label string + _fields = { + 'level': 0, + 'level_digits': 2, + } + # default label template is just a y-level with so much precision + _fmt_str = '{level:,.{level_digits}f}' + + def __init__( + self, + chart, + parent, + + color: str = 'bracket', + + orient_v: str = 'bottom', + orient_h: str = 'left', + + 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: + + super().__init__( + chart, + parent=parent, + use_arrow=False, + opacity=opacity, + **axis_label_kwargs + ) + + # TODO: this is kinda cludgy + self._hcolor: pg.Pen = None + self.color: str = color + + # orientation around axis options + self._orient_v = orient_v + self._orient_h = orient_h + + self._adjust_to_l1 = adjust_to_l1 + + self._v_shift = { + 'top': -1., + 'bottom': 0., + 'middle': 1 / 2. + }[orient_v] + + self._h_shift = { + 'left': -1., + 'right': 0. + }[orient_h] + + self.fields = self._fields.copy() + # ensure default format fields are in correct + self.set_fmt_str(self._fmt_str, self.fields) + + @property + def color(self): + return self._hcolor + + @color.setter + def color(self, color: str) -> None: + self._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( + self, + abs_pos: QPointF, # scene coords + fields: dict, + ) -> None: + + # write contents, type specific + h, w = self.set_label_str(fields) + + if self._adjust_to_l1: + self._x_offset = _max_l1_line_len + + self.setPos(QPointF( + self._h_shift * (w + self._x_offset), + abs_pos.y() + self._v_shift * h + )) + + 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, + fields: dict, + ): + # use space as e3 delim + self.label_str = self._fmt_str.format(**fields).replace(',', ' ') + + br = self.boundingRect() + h, w = br.height(), br.width() + return h, w + + def size_hint(self) -> Tuple[None, None]: + return None, None + + def draw( + self, + p: QtGui.QPainter, + rect: QtCore.QRectF + ) -> None: + p.setPen(self._pen) + + rect = self.rect + + if self._orient_v == 'bottom': + lp, rp = rect.topLeft(), rect.topRight() + # p.drawLine(rect.topLeft(), rect.topRight()) + + elif self._orient_v == 'top': + lp, rp = rect.bottomLeft(), rect.bottomRight() + + p.drawLine(lp.x(), lp.y(), rp.x(), rp.y()) + + def highlight(self, pen) -> None: + self._pen = pen + self.update() + + def unhighlight(self): + self._pen = self.pen + self.update() + + +# global for now but probably should be +# attached to chart instance? +_max_l1_line_len: float = 0 + + +class L1Label(LevelLabel): + + text_flags = ( + QtCore.Qt.TextDontClip + | QtCore.Qt.AlignLeft + ) + + def set_label_str( + self, + fields: dict, + ) -> None: + """Make sure the max L1 line module var is kept up to date. + + """ + h, w = super().set_label_str(fields) + + # 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. + global _max_l1_line_len + _max_l1_line_len = max(_max_l1_line_len, w) + + return h, w + + +class L1Labels: + """Level 1 bid ask labels for dynamic update on price-axis. + + """ + def __init__( + self, + chart: 'ChartPlotWidget', # noqa + digits: int = 2, + size_digits: int = 3, + font_size_inches: float = _down_2_font_inches_we_like, + ) -> None: + + self.chart = chart + + raxis = chart.getAxis('right') + kwargs = { + 'chart': chart, + 'parent': raxis, + + 'opacity': 1, + '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', + **kwargs, + ) + bid.set_fmt_str(fmt_str=fmt_str, fields=fields) + bid.show() + + ask = self.ask_label = L1Label( + orient_v='top', + **kwargs, + ) + ask.set_fmt_str(fmt_str=fmt_str, fields=fields) + ask.show()