2020-11-06 17:23:14 +00:00
|
|
|
# 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 <https://www.gnu.org/licenses/>.
|
|
|
|
|
2020-06-15 14:48:00 +00:00
|
|
|
"""
|
|
|
|
Chart axes graphics and behavior.
|
|
|
|
"""
|
2020-11-06 17:23:14 +00:00
|
|
|
|
2020-11-02 17:02:05 +00:00
|
|
|
from typing import List, Tuple, Optional
|
2020-08-31 21:18:02 +00:00
|
|
|
|
|
|
|
import pandas as pd
|
2020-06-15 14:48:00 +00:00
|
|
|
import pyqtgraph as pg
|
|
|
|
from PyQt5 import QtCore, QtGui
|
2020-08-30 16:27:41 +00:00
|
|
|
from PyQt5.QtCore import QPointF
|
2020-06-15 14:48:00 +00:00
|
|
|
|
2020-11-02 17:02:05 +00:00
|
|
|
from ._style import DpiAwareFont, hcolor, _font
|
2020-10-23 00:22:21 +00:00
|
|
|
from ..data._source import float_digits
|
2020-06-15 14:48:00 +00:00
|
|
|
|
2020-10-27 03:34:48 +00:00
|
|
|
_axis_pen = pg.mkPen(hcolor('bracket'))
|
|
|
|
|
2020-06-15 14:48:00 +00:00
|
|
|
|
2020-10-27 14:50:28 +00:00
|
|
|
class Axis(pg.AxisItem):
|
2020-11-02 17:02:05 +00:00
|
|
|
"""A better axis that sizes to typical tick contents considering font size.
|
2020-06-15 14:48:00 +00:00
|
|
|
|
2020-11-02 17:02:05 +00:00
|
|
|
"""
|
2020-08-27 01:45:13 +00:00
|
|
|
def __init__(
|
|
|
|
self,
|
2020-10-27 14:50:28 +00:00
|
|
|
linked_charts,
|
2020-12-09 13:53:09 +00:00
|
|
|
typical_max_str: str = '100 000.000',
|
2020-11-06 20:24:01 +00:00
|
|
|
min_tick: int = 2,
|
2020-10-27 14:50:28 +00:00
|
|
|
**kwargs
|
2020-08-27 01:45:13 +00:00
|
|
|
) -> None:
|
2020-10-27 14:50:28 +00:00
|
|
|
|
|
|
|
super().__init__(**kwargs)
|
2020-11-02 20:43:19 +00:00
|
|
|
self.linked_charts = linked_charts
|
2020-11-06 20:24:01 +00:00
|
|
|
self._min_tick = min_tick
|
2020-10-27 14:50:28 +00:00
|
|
|
|
2020-11-02 17:02:05 +00:00
|
|
|
self.setTickFont(_font.font)
|
2020-06-15 14:48:00 +00:00
|
|
|
self.setStyle(**{
|
2020-10-23 00:22:21 +00:00
|
|
|
'textFillLimits': [(0, 0.666)],
|
2020-11-02 17:02:05 +00:00
|
|
|
'tickFont': _font.font,
|
2020-11-16 22:39:14 +00:00
|
|
|
# offset of text *away from* axis line in px
|
|
|
|
'tickTextOffset': 2,
|
2020-06-15 14:48:00 +00:00
|
|
|
})
|
2020-10-27 14:50:28 +00:00
|
|
|
|
2020-11-02 17:02:05 +00:00
|
|
|
self.setTickFont(_font.font)
|
2020-10-27 03:34:48 +00:00
|
|
|
self.setPen(_axis_pen)
|
2020-11-02 17:02:05 +00:00
|
|
|
self.typical_br = _font._qfm.boundingRect(typical_max_str)
|
2020-10-27 14:50:28 +00:00
|
|
|
|
|
|
|
# size the pertinent axis dimension to a "typical value"
|
|
|
|
self.resize()
|
|
|
|
|
2020-11-06 20:24:01 +00:00
|
|
|
def set_min_tick(self, size: int) -> None:
|
|
|
|
self._min_tick = size
|
|
|
|
|
2020-10-27 14:50:28 +00:00
|
|
|
|
|
|
|
class PriceAxis(Axis):
|
|
|
|
|
|
|
|
def __init__(
|
|
|
|
self,
|
|
|
|
*args,
|
|
|
|
**kwargs,
|
|
|
|
) -> None:
|
|
|
|
super().__init__(*args, orientation='right', **kwargs)
|
2020-10-27 03:34:48 +00:00
|
|
|
|
2020-10-27 14:50:28 +00:00
|
|
|
def resize(self) -> None:
|
|
|
|
self.setWidth(self.typical_br.width())
|
2020-06-15 14:48:00 +00:00
|
|
|
|
|
|
|
# XXX: drop for now since it just eats up h space
|
|
|
|
|
2020-10-23 00:22:21 +00:00
|
|
|
def tickStrings(self, vals, scale, spacing):
|
2020-11-06 20:24:01 +00:00
|
|
|
|
|
|
|
# TODO: figure out how to enforce min tick spacing by passing
|
|
|
|
# it into the parent type
|
|
|
|
digits = max(float_digits(spacing * scale), self._min_tick)
|
2020-10-23 00:22:21 +00:00
|
|
|
|
|
|
|
# print(f'vals: {vals}\nscale: {scale}\nspacing: {spacing}')
|
|
|
|
# print(f'digits: {digits}')
|
|
|
|
|
|
|
|
return [
|
2020-11-16 22:39:14 +00:00
|
|
|
('{value:,.{digits}f}').format(
|
|
|
|
digits=digits,
|
|
|
|
value=v,
|
|
|
|
).replace(',', ' ') for v in vals
|
2020-10-23 00:22:21 +00:00
|
|
|
]
|
2020-06-15 14:48:00 +00:00
|
|
|
|
|
|
|
|
2020-10-27 14:50:28 +00:00
|
|
|
class DynamicDateAxis(Axis):
|
|
|
|
|
2020-08-31 21:18:02 +00:00
|
|
|
# time formats mapped by seconds between bars
|
|
|
|
tick_tpl = {
|
2020-11-05 17:08:02 +00:00
|
|
|
60 * 60 * 24: '%Y-%b-%d',
|
2020-08-31 21:18:02 +00:00
|
|
|
60: '%H:%M',
|
|
|
|
30: '%H:%M:%S',
|
|
|
|
5: '%H:%M:%S',
|
2020-11-16 22:39:14 +00:00
|
|
|
1: '%H:%M:%S',
|
2020-08-31 21:18:02 +00:00
|
|
|
}
|
2020-06-15 14:48:00 +00:00
|
|
|
|
2020-10-27 14:50:28 +00:00
|
|
|
def resize(self) -> None:
|
2020-11-16 22:39:14 +00:00
|
|
|
self.setHeight(self.typical_br.height() + 1)
|
2020-06-15 14:48:00 +00:00
|
|
|
|
2020-08-31 21:18:02 +00:00
|
|
|
def _indexes_to_timestrs(
|
|
|
|
self,
|
|
|
|
indexes: List[int],
|
|
|
|
) -> List[str]:
|
2020-11-02 17:02:05 +00:00
|
|
|
|
2020-12-09 13:53:09 +00:00
|
|
|
bars = self.linked_charts.chart._ohlc
|
2020-08-31 21:18:02 +00:00
|
|
|
bars_len = len(bars)
|
2020-11-02 17:02:05 +00:00
|
|
|
times = bars['time']
|
2020-08-31 21:18:02 +00:00
|
|
|
|
|
|
|
epochs = times[list(
|
|
|
|
map(int, filter(lambda i: i < bars_len, indexes))
|
|
|
|
)]
|
|
|
|
# TODO: **don't** have this hard coded shift to EST
|
2020-10-23 00:22:21 +00:00
|
|
|
dts = pd.to_datetime(epochs, unit='s') # - 4*pd.offsets.Hour()
|
2020-11-02 17:02:05 +00:00
|
|
|
|
|
|
|
delay = times[-1] - times[-2]
|
2020-08-31 21:18:02 +00:00
|
|
|
return dts.strftime(self.tick_tpl[delay])
|
2020-06-18 00:45:47 +00:00
|
|
|
|
2020-08-31 21:18:02 +00:00
|
|
|
def tickStrings(self, values: List[float], scale, spacing):
|
|
|
|
return self._indexes_to_timestrs(values)
|
2020-06-15 14:48:00 +00:00
|
|
|
|
|
|
|
|
|
|
|
class AxisLabel(pg.GraphicsObject):
|
|
|
|
|
2020-10-27 03:34:48 +00:00
|
|
|
_w_margin = 0
|
2020-11-02 17:02:05 +00:00
|
|
|
_h_margin = 0
|
2020-10-27 03:34:48 +00:00
|
|
|
|
2020-06-16 17:32:03 +00:00
|
|
|
def __init__(
|
|
|
|
self,
|
2020-10-27 14:50:28 +00:00
|
|
|
parent: Axis,
|
2020-10-25 00:04:57 +00:00
|
|
|
digits: int = 2,
|
|
|
|
bg_color: str = 'bracket',
|
|
|
|
fg_color: str = 'black',
|
2020-11-05 17:08:02 +00:00
|
|
|
opacity: int = 0,
|
2020-11-06 01:32:35 +00:00
|
|
|
font_size_inches: Optional[float] = None,
|
2020-06-16 17:32:03 +00:00
|
|
|
):
|
2020-06-15 14:48:00 +00:00
|
|
|
super().__init__(parent)
|
2020-11-02 20:43:19 +00:00
|
|
|
self.setFlag(self.ItemIgnoresTransformations)
|
|
|
|
|
2020-06-15 14:48:00 +00:00
|
|
|
self.parent = parent
|
|
|
|
self.opacity = opacity
|
|
|
|
self.label_str = ''
|
|
|
|
self.digits = digits
|
2020-11-02 17:02:05 +00:00
|
|
|
|
2020-10-27 03:34:48 +00:00
|
|
|
self._txt_br: QtCore.QRect = None
|
2020-06-15 14:48:00 +00:00
|
|
|
|
2020-11-06 01:32:35 +00:00
|
|
|
self._dpifont = DpiAwareFont(size_in_inches=font_size_inches)
|
2020-11-02 17:02:05 +00:00
|
|
|
self._dpifont.configure_to_dpi(_font._screen)
|
2020-11-02 20:27:48 +00:00
|
|
|
|
2020-10-25 00:04:57 +00:00
|
|
|
self.bg_color = pg.mkColor(hcolor(bg_color))
|
|
|
|
self.fg_color = pg.mkColor(hcolor(fg_color))
|
|
|
|
|
2020-10-27 03:34:48 +00:00
|
|
|
self.rect = None
|
2020-10-25 00:04:57 +00:00
|
|
|
|
2020-06-15 14:48:00 +00:00
|
|
|
def paint(self, p, option, widget):
|
2020-11-02 20:27:48 +00:00
|
|
|
# p.setCompositionMode(QtGui.QPainter.CompositionMode_SourceOver)
|
2020-10-25 00:04:57 +00:00
|
|
|
|
2020-06-15 14:48:00 +00:00
|
|
|
if self.label_str:
|
2020-10-27 03:34:48 +00:00
|
|
|
|
|
|
|
if not self.rect:
|
|
|
|
self._size_br_from_str(self.label_str)
|
|
|
|
|
2020-11-02 17:02:05 +00:00
|
|
|
p.setFont(self._dpifont.font)
|
2020-10-25 00:04:57 +00:00
|
|
|
p.setPen(self.fg_color)
|
2020-11-02 17:02:05 +00:00
|
|
|
p.setOpacity(self.opacity)
|
2020-10-27 03:34:48 +00:00
|
|
|
p.fillRect(self.rect, self.bg_color)
|
2020-06-15 14:48:00 +00:00
|
|
|
|
2020-11-03 21:21:21 +00:00
|
|
|
# can be overrided in subtype
|
|
|
|
self.draw(p, self.rect)
|
2020-08-31 21:18:02 +00:00
|
|
|
|
2020-11-02 17:02:05 +00:00
|
|
|
p.drawText(self.rect, self.text_flags, self.label_str)
|
2020-08-30 16:27:41 +00:00
|
|
|
|
2020-11-03 21:21:21 +00:00
|
|
|
def draw(
|
|
|
|
self,
|
|
|
|
p: QtGui.QPainter,
|
|
|
|
rect: QtCore.QRectF
|
|
|
|
) -> None:
|
|
|
|
# this adds a nice black outline around the label for some odd
|
|
|
|
# reason; ok by us
|
2020-11-05 17:08:02 +00:00
|
|
|
p.setOpacity(self.opacity)
|
2020-11-03 21:21:21 +00:00
|
|
|
p.drawRect(self.rect)
|
|
|
|
|
2020-08-30 16:27:41 +00:00
|
|
|
def boundingRect(self): # noqa
|
2020-11-02 20:43:19 +00:00
|
|
|
# if self.label_str:
|
|
|
|
# self._size_br_from_str(self.label_str)
|
|
|
|
# return self.rect
|
2020-11-02 17:02:05 +00:00
|
|
|
|
2020-11-02 20:43:19 +00:00
|
|
|
# return QtCore.QRectF()
|
2020-11-02 17:02:05 +00:00
|
|
|
|
2020-11-02 20:43:19 +00:00
|
|
|
return self.rect or QtCore.QRectF()
|
2020-10-27 03:34:48 +00:00
|
|
|
|
2020-10-27 14:50:28 +00:00
|
|
|
def _size_br_from_str(self, value: str) -> None:
|
|
|
|
"""Do our best to render the bounding rect to a set margin
|
|
|
|
around provided string contents.
|
2020-08-30 16:27:41 +00:00
|
|
|
|
2020-10-27 14:50:28 +00:00
|
|
|
"""
|
2020-11-02 17:02:05 +00:00
|
|
|
# size the filled rect to text and/or parent axis
|
|
|
|
br = self._txt_br = self._dpifont.boundingRect(value)
|
|
|
|
|
|
|
|
txt_h, txt_w = br.height(), br.width()
|
2020-10-27 14:50:28 +00:00
|
|
|
h, w = self.size_hint()
|
2020-11-02 17:02:05 +00:00
|
|
|
|
2020-10-27 14:50:28 +00:00
|
|
|
self.rect = QtCore.QRectF(
|
|
|
|
0, 0,
|
|
|
|
(w or txt_w) + self._w_margin,
|
|
|
|
(h or txt_h) + self._h_margin,
|
|
|
|
)
|
2020-08-30 16:27:41 +00:00
|
|
|
|
|
|
|
|
|
|
|
# _common_text_flags = (
|
|
|
|
# QtCore.Qt.TextDontClip |
|
|
|
|
# QtCore.Qt.AlignCenter |
|
|
|
|
# QtCore.Qt.AlignTop |
|
|
|
|
# QtCore.Qt.AlignHCenter |
|
|
|
|
# QtCore.Qt.AlignVCenter
|
|
|
|
# )
|
|
|
|
|
2020-06-15 14:48:00 +00:00
|
|
|
|
|
|
|
class XAxisLabel(AxisLabel):
|
2020-11-16 22:39:14 +00:00
|
|
|
_w_margin = 4
|
2020-06-15 14:48:00 +00:00
|
|
|
|
|
|
|
text_flags = (
|
2020-08-30 16:27:41 +00:00
|
|
|
QtCore.Qt.TextDontClip
|
|
|
|
| QtCore.Qt.AlignCenter
|
2020-06-15 14:48:00 +00:00
|
|
|
)
|
|
|
|
|
2020-10-27 14:50:28 +00:00
|
|
|
def size_hint(self) -> Tuple[float, float]:
|
|
|
|
# size to parent axis height
|
|
|
|
return self.parent.height(), None
|
|
|
|
|
2020-08-31 21:18:02 +00:00
|
|
|
def update_label(
|
|
|
|
self,
|
|
|
|
abs_pos: QPointF, # scene coords
|
2020-11-05 17:08:02 +00:00
|
|
|
value: float, # data for text
|
2020-10-25 00:04:57 +00:00
|
|
|
offset: int = 1 # if have margins, k?
|
2020-08-31 21:18:02 +00:00
|
|
|
) -> None:
|
2020-11-02 17:02:05 +00:00
|
|
|
|
2020-11-05 17:08:02 +00:00
|
|
|
timestrs = self.parent._indexes_to_timestrs([int(value)])
|
2020-10-27 14:50:28 +00:00
|
|
|
|
2020-09-09 14:47:08 +00:00
|
|
|
if not timestrs.any():
|
|
|
|
return
|
2020-10-27 14:50:28 +00:00
|
|
|
|
2020-09-09 14:47:08 +00:00
|
|
|
self.label_str = timestrs[0]
|
2020-11-02 17:02:05 +00:00
|
|
|
|
2020-11-02 20:27:48 +00:00
|
|
|
w = self.boundingRect().width()
|
2020-11-02 17:02:05 +00:00
|
|
|
self.setPos(QPointF(
|
2020-11-02 20:27:48 +00:00
|
|
|
abs_pos.x() - w / 2 - offset,
|
2020-11-16 22:39:14 +00:00
|
|
|
1,
|
2020-11-02 17:02:05 +00:00
|
|
|
))
|
2020-11-05 17:08:02 +00:00
|
|
|
self.update()
|
2020-06-15 14:48:00 +00:00
|
|
|
|
|
|
|
|
|
|
|
class YAxisLabel(AxisLabel):
|
2020-11-16 22:39:14 +00:00
|
|
|
_h_margin = 2
|
2020-06-15 14:48:00 +00:00
|
|
|
|
|
|
|
text_flags = (
|
2020-12-09 13:53:09 +00:00
|
|
|
QtCore.Qt.AlignLeft
|
|
|
|
# QtCore.Qt.AlignHCenter
|
2020-11-06 20:24:01 +00:00
|
|
|
| QtCore.Qt.AlignVCenter
|
2020-08-30 16:27:41 +00:00
|
|
|
| QtCore.Qt.TextDontClip
|
2020-06-15 14:48:00 +00:00
|
|
|
)
|
|
|
|
|
2020-10-27 14:50:28 +00:00
|
|
|
def size_hint(self) -> Tuple[float, float]:
|
|
|
|
# size to parent axis width
|
|
|
|
return None, self.parent.width()
|
|
|
|
|
2020-08-30 16:27:41 +00:00
|
|
|
def update_label(
|
|
|
|
self,
|
|
|
|
abs_pos: QPointF, # scene coords
|
2020-11-05 17:08:02 +00:00
|
|
|
value: float, # data for text
|
2020-11-02 20:43:19 +00:00
|
|
|
offset: int = 1 # on odd dimension and/or adds nice black line
|
2020-08-30 16:27:41 +00:00
|
|
|
) -> None:
|
2020-11-02 17:02:05 +00:00
|
|
|
|
|
|
|
# this is read inside ``.paint()``
|
2020-12-09 13:53:09 +00:00
|
|
|
self.label_str = ' {value:,.{digits}f}'.format(
|
2020-11-05 17:08:02 +00:00
|
|
|
digits=self.digits, value=value).replace(',', ' ')
|
2020-11-02 17:02:05 +00:00
|
|
|
|
|
|
|
br = self.boundingRect()
|
|
|
|
h = br.height()
|
|
|
|
self.setPos(QPointF(
|
2020-11-16 22:39:14 +00:00
|
|
|
1,
|
2020-11-02 20:27:48 +00:00
|
|
|
abs_pos.y() - h / 2 - offset
|
2020-11-02 17:02:05 +00:00
|
|
|
))
|
2020-11-05 17:08:02 +00:00
|
|
|
self.update()
|
2020-08-30 16:27:41 +00:00
|
|
|
|
|
|
|
|
|
|
|
class YSticky(YAxisLabel):
|
|
|
|
"""Y-axis label that sticks to where it's placed despite chart resizing.
|
|
|
|
"""
|
|
|
|
def __init__(
|
|
|
|
self,
|
|
|
|
chart,
|
|
|
|
*args,
|
|
|
|
**kwargs
|
|
|
|
) -> None:
|
2020-10-27 03:34:48 +00:00
|
|
|
|
2020-08-30 16:27:41 +00:00
|
|
|
super().__init__(*args, **kwargs)
|
|
|
|
|
2020-10-27 03:34:48 +00:00
|
|
|
self._chart = chart
|
2020-08-30 16:27:41 +00:00
|
|
|
chart.sigRangeChanged.connect(self.update_on_resize)
|
2020-11-02 17:02:05 +00:00
|
|
|
self._last_datum = (None, None)
|
2020-08-30 16:27:41 +00:00
|
|
|
|
|
|
|
def update_on_resize(self, vr, r):
|
2020-09-09 14:47:08 +00:00
|
|
|
# TODO: add an `.index` to the array data-buffer layer
|
|
|
|
# and make this way less shitty...
|
2020-11-05 17:08:02 +00:00
|
|
|
|
|
|
|
# pretty sure we did that ^ ?
|
2020-11-02 17:02:05 +00:00
|
|
|
index, last = self._last_datum
|
|
|
|
if index is not None:
|
2020-11-05 17:08:02 +00:00
|
|
|
self.update_from_data(index, last)
|
2020-11-02 17:02:05 +00:00
|
|
|
|
2020-08-30 16:27:41 +00:00
|
|
|
def update_from_data(
|
|
|
|
self,
|
|
|
|
index: int,
|
2020-10-19 15:37:28 +00:00
|
|
|
value: float,
|
2020-08-30 16:27:41 +00:00
|
|
|
) -> None:
|
2020-11-02 17:02:05 +00:00
|
|
|
self._last_datum = (index, value)
|
2020-08-30 16:27:41 +00:00
|
|
|
self.update_label(
|
2020-10-19 15:37:28 +00:00
|
|
|
self._chart.mapFromView(QPointF(index, value)),
|
|
|
|
value
|
2020-08-30 16:27:41 +00:00
|
|
|
)
|