Merge pull request #134 from pikers/graphics_pixel_buffer
Graphics pixel buffers, sexy curves...kraken_history
commit
373327e3b7
|
@ -1,5 +1,5 @@
|
|||
# piker: trading gear for hackers.
|
||||
# Copyright 2018 Tyler Goodlet
|
||||
# Copyright 2020-eternity 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
|
||||
|
@ -16,6 +16,7 @@
|
|||
|
||||
"""
|
||||
piker: trading gear for hackers.
|
||||
|
||||
"""
|
||||
import msgpack # noqa
|
||||
import msgpack_numpy
|
||||
|
|
|
@ -16,10 +16,18 @@
|
|||
|
||||
"""
|
||||
Profiling wrappers for internal libs.
|
||||
|
||||
"""
|
||||
import time
|
||||
from functools import wraps
|
||||
|
||||
_pg_profile: bool = False
|
||||
|
||||
|
||||
def pg_profile_enabled() -> bool:
|
||||
global _pg_profile
|
||||
return _pg_profile
|
||||
|
||||
|
||||
def timeit(fn):
|
||||
@wraps(fn)
|
||||
|
|
|
@ -622,8 +622,7 @@ async def fill_bars(
|
|||
sym: str,
|
||||
first_bars: list,
|
||||
shm: 'ShmArray', # type: ignore # noqa
|
||||
count: int = 21,
|
||||
# count: int = 1,
|
||||
count: int = 21, # NOTE: any more and we'll overrun the underlying buffer
|
||||
) -> None:
|
||||
"""Fill historical bars into shared mem / storage afap.
|
||||
|
||||
|
|
|
@ -22,7 +22,7 @@ from typing import List, Tuple, Optional
|
|||
|
||||
import pandas as pd
|
||||
import pyqtgraph as pg
|
||||
from PyQt5 import QtCore, QtGui
|
||||
from PyQt5 import QtCore, QtGui, QtWidgets
|
||||
from PyQt5.QtCore import QPointF
|
||||
|
||||
from ._style import DpiAwareFont, hcolor, _font
|
||||
|
@ -44,6 +44,10 @@ class Axis(pg.AxisItem):
|
|||
) -> None:
|
||||
|
||||
super().__init__(**kwargs)
|
||||
|
||||
# XXX: pretty sure this makes things slower
|
||||
# self.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
|
||||
|
||||
self.linked_charts = linked_charts
|
||||
self._min_tick = min_tick
|
||||
|
||||
|
@ -158,9 +162,12 @@ class AxisLabel(pg.GraphicsObject):
|
|||
fg_color: str = 'black',
|
||||
opacity: int = 0,
|
||||
font_size_inches: Optional[float] = None,
|
||||
):
|
||||
) -> None:
|
||||
|
||||
super().__init__(parent)
|
||||
self.setFlag(self.ItemIgnoresTransformations)
|
||||
# XXX: pretty sure this is faster
|
||||
self.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
|
||||
|
||||
self.parent = parent
|
||||
self.opacity = opacity
|
||||
|
@ -177,7 +184,12 @@ class AxisLabel(pg.GraphicsObject):
|
|||
|
||||
self.rect = None
|
||||
|
||||
def paint(self, p, option, widget):
|
||||
def paint(
|
||||
self,
|
||||
p: QtGui.QPainter,
|
||||
opt: QtWidgets.QStyleOptionGraphicsItem,
|
||||
w: QtWidgets.QWidget
|
||||
) -> None:
|
||||
# p.setCompositionMode(QtGui.QPainter.CompositionMode_SourceOver)
|
||||
|
||||
if self.label_str:
|
||||
|
|
|
@ -30,13 +30,16 @@ from ._axes import (
|
|||
DynamicDateAxis,
|
||||
PriceAxis,
|
||||
)
|
||||
from ._graphics import (
|
||||
from ._graphics._cursor import (
|
||||
CrossHair,
|
||||
ContentsLabel,
|
||||
BarItems,
|
||||
)
|
||||
from ._graphics._lines import (
|
||||
level_line,
|
||||
L1Labels,
|
||||
)
|
||||
from ._graphics._ohlc import BarItems
|
||||
from ._graphics._curve import FastAppendCurve
|
||||
from ._axes import YSticky
|
||||
from ._style import (
|
||||
_font,
|
||||
|
@ -550,20 +553,37 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
}
|
||||
pdi_kwargs.update(_pdi_defaults)
|
||||
|
||||
curve = pg.PlotDataItem(
|
||||
# curve = pg.PlotDataItem(
|
||||
# curve = pg.PlotCurveItem(
|
||||
curve = FastAppendCurve(
|
||||
y=data[name],
|
||||
x=data['index'],
|
||||
# antialias=True,
|
||||
name=name,
|
||||
|
||||
# XXX: pretty sure this is just more overhead
|
||||
# on data reads and makes graphics rendering no faster
|
||||
# clipToView=True,
|
||||
|
||||
# TODO: see how this handles with custom ohlcv bars graphics
|
||||
# and/or if we can implement something similar for OHLC graphics
|
||||
# clipToView=True,
|
||||
autoDownsample=True,
|
||||
downsampleMethod='subsample',
|
||||
# autoDownsample=True,
|
||||
# downsample=60,
|
||||
# downsampleMethod='subsample',
|
||||
|
||||
**pdi_kwargs,
|
||||
)
|
||||
|
||||
# XXX: see explanation for differenct caching modes:
|
||||
# https://stackoverflow.com/a/39410081
|
||||
# seems to only be useful if we don't re-generate the entire
|
||||
# QPainterPath every time
|
||||
# curve.curve.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
|
||||
|
||||
# don't ever use this - it's a colossal nightmare of artefacts
|
||||
# and is disastrous for performance.
|
||||
# curve.setCacheMode(QtGui.QGraphicsItem.ItemCoordinateCache)
|
||||
|
||||
self.addItem(curve)
|
||||
|
||||
# register curve graphics and backing array for name
|
||||
|
@ -647,6 +667,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
"""Update the named internal graphics from ``array``.
|
||||
|
||||
"""
|
||||
|
||||
if name not in self._overlays:
|
||||
self._ohlc = array
|
||||
else:
|
||||
|
@ -654,11 +675,13 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
|
||||
curve = self._graphics[name]
|
||||
|
||||
if len(array):
|
||||
# TODO: we should instead implement a diff based
|
||||
# "only update with new items" on the pg.PlotCurveItem
|
||||
# one place to dig around this might be the `QBackingStore`
|
||||
# https://doc.qt.io/qt-5/qbackingstore.html
|
||||
curve.setData(y=array[name], x=array['index'], **kwargs)
|
||||
# curve.setData(y=array[name], x=array['index'], **kwargs)
|
||||
curve.update_from_array(x=array['index'], y=array[name], **kwargs)
|
||||
|
||||
return curve
|
||||
|
||||
|
@ -689,13 +712,15 @@ class ChartPlotWidget(pg.PlotWidget):
|
|||
# figure out x-range in view such that user can scroll "off"
|
||||
# the data set up to the point where ``_min_points_to_show``
|
||||
# are left.
|
||||
view_len = r - l
|
||||
# view_len = r - l
|
||||
|
||||
# TODO: logic to check if end of bars in view
|
||||
extra = view_len - _min_points_to_show
|
||||
begin = self._ohlc[0]['index'] - extra
|
||||
# end = len(self._ohlc) - 1 + extra
|
||||
end = self._ohlc[-1]['index'] - 1 + extra
|
||||
# extra = view_len - _min_points_to_show
|
||||
|
||||
# begin = self._ohlc[0]['index'] - extra
|
||||
|
||||
# # end = len(self._ohlc) - 1 + extra
|
||||
# end = self._ohlc[-1]['index'] - 1 + extra
|
||||
|
||||
# XXX: test code for only rendering lines for the bars in view.
|
||||
# This turns out to be very very poor perf when scaling out to
|
||||
|
@ -1292,11 +1317,11 @@ async def check_for_new_bars(feed, ohlcv, linked_charts):
|
|||
# current bar) and then either write the current bar manually
|
||||
# or place a cursor for visual cue of the current time step.
|
||||
|
||||
price_chart.update_ohlc_from_array(
|
||||
price_chart.name,
|
||||
ohlcv.array,
|
||||
just_history=True,
|
||||
)
|
||||
# price_chart.update_ohlc_from_array(
|
||||
# price_chart.name,
|
||||
# ohlcv.array,
|
||||
# just_history=True,
|
||||
# )
|
||||
|
||||
# XXX: this puts a flat bar on the current time step
|
||||
# TODO: if we eventually have an x-axis time-step "cursor"
|
||||
|
|
|
@ -83,8 +83,7 @@ class MainWindow(QtGui.QMainWindow):
|
|||
"""Cancel the root actor asap.
|
||||
|
||||
"""
|
||||
# raising KBI seems to get intercepted by by Qt so just use the
|
||||
# system.
|
||||
# raising KBI seems to get intercepted by by Qt so just use the system.
|
||||
os.kill(os.getpid(), signal.SIGINT)
|
||||
|
||||
|
||||
|
@ -160,20 +159,20 @@ def run_qtractor(
|
|||
'main': instance,
|
||||
}
|
||||
|
||||
# setup tractor entry point args
|
||||
main = partial(
|
||||
tractor._main,
|
||||
async_fn=func,
|
||||
args=args + (widgets,),
|
||||
# define tractor entrypoint
|
||||
async def main():
|
||||
|
||||
async with tractor.open_root_actor(
|
||||
arbiter_addr=(
|
||||
tractor._default_arbiter_host,
|
||||
tractor._default_arbiter_port,
|
||||
tractor._root._default_arbiter_host,
|
||||
tractor._root._default_arbiter_port,
|
||||
),
|
||||
name='qtractor',
|
||||
**tractor_kwargs,
|
||||
)
|
||||
) as a:
|
||||
await func(*(args + (widgets,)))
|
||||
|
||||
# guest mode
|
||||
# guest mode entry
|
||||
trio.lowlevel.start_guest_run(
|
||||
main,
|
||||
run_sync_soon_threadsafe=run_sync_soon_threadsafe,
|
||||
|
|
|
@ -1,997 +0,0 @@
|
|||
# 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/>.
|
||||
|
||||
"""
|
||||
Chart graphics for displaying a slew of different data types.
|
||||
"""
|
||||
import inspect
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from numba import jit, float64, int64 # , optional
|
||||
# from numba import types as ntypes
|
||||
from PyQt5 import QtCore, QtGui
|
||||
from PyQt5.QtCore import QLineF, QPointF
|
||||
|
||||
from .._profile import timeit
|
||||
# from ..data._source import numba_ohlc_dtype
|
||||
from ._style import (
|
||||
_xaxis_at,
|
||||
hcolor,
|
||||
_font,
|
||||
_down_2_font_inches_we_like,
|
||||
)
|
||||
from ._axes import YAxisLabel, XAxisLabel, YSticky
|
||||
|
||||
|
||||
# XXX: these settings seem to result in really decent mouse scroll
|
||||
# latency (in terms of perceived lag in cross hair) so really be sure
|
||||
# there's an improvement if you want to change it!
|
||||
_mouse_rate_limit = 60 # TODO; should we calc current screen refresh rate?
|
||||
_debounce_delay = 1 / 2e3
|
||||
_ch_label_opac = 1
|
||||
|
||||
|
||||
# TODO: we need to handle the case where index is outside
|
||||
# the underlying datums range
|
||||
class LineDot(pg.CurvePoint):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
curve: pg.PlotCurveItem,
|
||||
index: int,
|
||||
plot: 'ChartPlotWidget',
|
||||
pos=None,
|
||||
size: int = 2, # in pxs
|
||||
color: str = 'default_light',
|
||||
) -> None:
|
||||
pg.CurvePoint.__init__(
|
||||
self,
|
||||
curve,
|
||||
index=index,
|
||||
pos=pos,
|
||||
rotate=False,
|
||||
)
|
||||
self._plot = plot
|
||||
|
||||
# TODO: get pen from curve if not defined?
|
||||
cdefault = hcolor(color)
|
||||
pen = pg.mkPen(cdefault)
|
||||
brush = pg.mkBrush(cdefault)
|
||||
|
||||
# presuming this is fast since it's built in?
|
||||
dot = self.dot = QtGui.QGraphicsEllipseItem(
|
||||
QtCore.QRectF(-size / 2, -size / 2, size, size)
|
||||
)
|
||||
# if we needed transformable dot?
|
||||
# dot.translate(-size*0.5, -size*0.5)
|
||||
dot.setPen(pen)
|
||||
dot.setBrush(brush)
|
||||
dot.setParentItem(self)
|
||||
|
||||
# keep a static size
|
||||
self.setFlag(self.ItemIgnoresTransformations)
|
||||
|
||||
def event(
|
||||
self,
|
||||
ev: QtCore.QEvent,
|
||||
) -> None:
|
||||
# print((ev, type(ev)))
|
||||
if not isinstance(ev, QtCore.QDynamicPropertyChangeEvent) or self.curve() is None:
|
||||
return False
|
||||
|
||||
# if ev.propertyName() == 'index':
|
||||
# print(ev)
|
||||
# # self.setProperty
|
||||
|
||||
(x, y) = self.curve().getData()
|
||||
index = self.property('index')
|
||||
# first = self._plot._ohlc[0]['index']
|
||||
# first = x[0]
|
||||
# i = index - first
|
||||
i = index - x[0]
|
||||
if i > 0 and i < len(y):
|
||||
newPos = (index, y[i])
|
||||
QtGui.QGraphicsItem.setPos(self, *newPos)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
_corner_anchors = {
|
||||
'top': 0,
|
||||
'left': 0,
|
||||
'bottom': 1,
|
||||
'right': 1,
|
||||
}
|
||||
# XXX: fyi naming here is confusing / opposite to coords
|
||||
_corner_margins = {
|
||||
('top', 'left'): (-4, -5),
|
||||
('top', 'right'): (4, -5),
|
||||
|
||||
('bottom', 'left'): (-4, lambda font_size: font_size * 2),
|
||||
('bottom', 'right'): (4, lambda font_size: font_size * 2),
|
||||
}
|
||||
|
||||
|
||||
class ContentsLabel(pg.LabelItem):
|
||||
"""Label anchored to a ``ViewBox`` typically for displaying
|
||||
datum-wise points from the "viewed" contents.
|
||||
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
chart: 'ChartPlotWidget', # noqa
|
||||
anchor_at: str = ('top', 'right'),
|
||||
justify_text: str = 'left',
|
||||
font_size: Optional[int] = None,
|
||||
) -> None:
|
||||
font_size = font_size or _font.font.pixelSize()
|
||||
super().__init__(
|
||||
justify=justify_text,
|
||||
size=f'{str(font_size)}px'
|
||||
)
|
||||
|
||||
# anchor to viewbox
|
||||
self.setParentItem(chart._vb)
|
||||
chart.scene().addItem(self)
|
||||
self.chart = chart
|
||||
|
||||
v, h = anchor_at
|
||||
index = (_corner_anchors[h], _corner_anchors[v])
|
||||
margins = _corner_margins[(v, h)]
|
||||
|
||||
ydim = margins[1]
|
||||
if inspect.isfunction(margins[1]):
|
||||
margins = margins[0], ydim(font_size)
|
||||
|
||||
self.anchor(itemPos=index, parentPos=index, offset=margins)
|
||||
|
||||
def update_from_ohlc(
|
||||
self,
|
||||
name: str,
|
||||
index: int,
|
||||
array: np.ndarray,
|
||||
) -> None:
|
||||
# this being "html" is the dumbest shit :eyeroll:
|
||||
first = array[0]['index']
|
||||
|
||||
self.setText(
|
||||
"<b>i</b>:{index}<br/>"
|
||||
"<b>O</b>:{}<br/>"
|
||||
"<b>H</b>:{}<br/>"
|
||||
"<b>L</b>:{}<br/>"
|
||||
"<b>C</b>:{}<br/>"
|
||||
"<b>V</b>:{}<br/>"
|
||||
"<b>wap</b>:{}".format(
|
||||
*array[index - first][
|
||||
['open', 'high', 'low', 'close', 'volume', 'bar_wap']
|
||||
],
|
||||
name=name,
|
||||
index=index,
|
||||
)
|
||||
)
|
||||
|
||||
def update_from_value(
|
||||
self,
|
||||
name: str,
|
||||
index: int,
|
||||
array: np.ndarray,
|
||||
) -> None:
|
||||
first = array[0]['index']
|
||||
if index < array[-1]['index'] and index > first:
|
||||
data = array[index - first][name]
|
||||
self.setText(f"{name}: {data:.2f}")
|
||||
|
||||
|
||||
class CrossHair(pg.GraphicsObject):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
linkedsplitcharts: 'LinkedSplitCharts', # noqa
|
||||
digits: int = 0
|
||||
) -> None:
|
||||
super().__init__()
|
||||
# XXX: not sure why these are instance variables?
|
||||
# It's not like we can change them on the fly..?
|
||||
self.pen = pg.mkPen(
|
||||
color=hcolor('default'),
|
||||
style=QtCore.Qt.DashLine,
|
||||
)
|
||||
self.lines_pen = pg.mkPen(
|
||||
color='#a9a9a9', # gray?
|
||||
style=QtCore.Qt.DashLine,
|
||||
)
|
||||
self.lsc = linkedsplitcharts
|
||||
self.graphics = {}
|
||||
self.plots = []
|
||||
self.active_plot = None
|
||||
self.digits = digits
|
||||
self._lastx = None
|
||||
|
||||
def add_plot(
|
||||
self,
|
||||
plot: 'ChartPlotWidget', # noqa
|
||||
digits: int = 0,
|
||||
) -> None:
|
||||
# add ``pg.graphicsItems.InfiniteLine``s
|
||||
# vertical and horizonal lines and a y-axis label
|
||||
vl = plot.addLine(x=0, pen=self.lines_pen, movable=False)
|
||||
|
||||
hl = plot.addLine(y=0, pen=self.lines_pen, movable=False)
|
||||
hl.hide()
|
||||
|
||||
yl = YAxisLabel(
|
||||
parent=plot.getAxis('right'),
|
||||
digits=digits or self.digits,
|
||||
opacity=_ch_label_opac,
|
||||
bg_color='default',
|
||||
)
|
||||
yl.hide() # on startup if mouse is off screen
|
||||
|
||||
# TODO: checkout what ``.sigDelayed`` can be used for
|
||||
# (emitted once a sufficient delay occurs in mouse movement)
|
||||
px_moved = pg.SignalProxy(
|
||||
plot.scene().sigMouseMoved,
|
||||
rateLimit=_mouse_rate_limit,
|
||||
slot=self.mouseMoved,
|
||||
delay=_debounce_delay,
|
||||
)
|
||||
px_enter = pg.SignalProxy(
|
||||
plot.sig_mouse_enter,
|
||||
rateLimit=_mouse_rate_limit,
|
||||
slot=lambda: self.mouseAction('Enter', plot),
|
||||
delay=_debounce_delay,
|
||||
)
|
||||
px_leave = pg.SignalProxy(
|
||||
plot.sig_mouse_leave,
|
||||
rateLimit=_mouse_rate_limit,
|
||||
slot=lambda: self.mouseAction('Leave', plot),
|
||||
delay=_debounce_delay,
|
||||
)
|
||||
self.graphics[plot] = {
|
||||
'vl': vl,
|
||||
'hl': hl,
|
||||
'yl': yl,
|
||||
'px': (px_moved, px_enter, px_leave),
|
||||
}
|
||||
self.plots.append(plot)
|
||||
|
||||
# Determine where to place x-axis label.
|
||||
# Place below the last plot by default, ow
|
||||
# keep x-axis right below main chart
|
||||
plot_index = -1 if _xaxis_at == 'bottom' else 0
|
||||
|
||||
self.xaxis_label = XAxisLabel(
|
||||
parent=self.plots[plot_index].getAxis('bottom'),
|
||||
opacity=_ch_label_opac,
|
||||
bg_color='default',
|
||||
)
|
||||
# place label off-screen during startup
|
||||
self.xaxis_label.setPos(self.plots[0].mapFromView(QPointF(0, 0)))
|
||||
|
||||
def add_curve_cursor(
|
||||
self,
|
||||
plot: 'ChartPlotWidget', # noqa
|
||||
curve: 'PlotCurveItem', # noqa
|
||||
) -> LineDot:
|
||||
# if this plot contains curves add line dot "cursors" to denote
|
||||
# the current sample under the mouse
|
||||
cursor = LineDot(curve, index=plot._ohlc[-1]['index'], plot=plot)
|
||||
plot.addItem(cursor)
|
||||
self.graphics[plot].setdefault('cursors', []).append(cursor)
|
||||
return cursor
|
||||
|
||||
def mouseAction(self, action, plot): # noqa
|
||||
if action == 'Enter':
|
||||
self.active_plot = plot
|
||||
|
||||
# show horiz line and y-label
|
||||
self.graphics[plot]['hl'].show()
|
||||
self.graphics[plot]['yl'].show()
|
||||
|
||||
else: # Leave
|
||||
self.active_plot = None
|
||||
|
||||
# hide horiz line and y-label
|
||||
self.graphics[plot]['hl'].hide()
|
||||
self.graphics[plot]['yl'].hide()
|
||||
|
||||
def mouseMoved(
|
||||
self,
|
||||
evt: 'Tuple[QMouseEvent]', # noqa
|
||||
) -> None: # noqa
|
||||
"""Update horizonal and vertical lines when mouse moves inside
|
||||
either the main chart or any indicator subplot.
|
||||
"""
|
||||
pos = evt[0]
|
||||
|
||||
# find position inside active plot
|
||||
try:
|
||||
# map to view coordinate system
|
||||
mouse_point = self.active_plot.mapToView(pos)
|
||||
except AttributeError:
|
||||
# mouse was not on active plot
|
||||
return
|
||||
|
||||
x, y = mouse_point.x(), mouse_point.y()
|
||||
plot = self.active_plot
|
||||
|
||||
# update y-range items
|
||||
self.graphics[plot]['hl'].setY(y)
|
||||
|
||||
self.graphics[self.active_plot]['yl'].update_label(
|
||||
abs_pos=pos, value=y
|
||||
)
|
||||
|
||||
# Update x if cursor changed after discretization calc
|
||||
# (this saves draw cycles on small mouse moves)
|
||||
lastx = self._lastx
|
||||
ix = round(x) # since bars are centered around index
|
||||
|
||||
if ix != lastx:
|
||||
for plot, opts in self.graphics.items():
|
||||
|
||||
# move the vertical line to the current "center of bar"
|
||||
opts['vl'].setX(ix)
|
||||
|
||||
# update the chart's "contents" label
|
||||
plot.update_contents_labels(ix)
|
||||
|
||||
# update all subscribed curve dots
|
||||
# first = plot._ohlc[0]['index']
|
||||
for cursor in opts.get('cursors', ()):
|
||||
cursor.setIndex(ix) # - first)
|
||||
|
||||
# update the label on the bottom of the crosshair
|
||||
self.xaxis_label.update_label(
|
||||
|
||||
# XXX: requires:
|
||||
# https://github.com/pyqtgraph/pyqtgraph/pull/1418
|
||||
# otherwise gobbles tons of CPU..
|
||||
|
||||
# map back to abs (label-local) coordinates
|
||||
abs_pos=plot.mapFromView(QPointF(ix, y)),
|
||||
value=x,
|
||||
)
|
||||
|
||||
self._lastx = ix
|
||||
|
||||
def boundingRect(self):
|
||||
try:
|
||||
return self.active_plot.boundingRect()
|
||||
except AttributeError:
|
||||
return self.plots[0].boundingRect()
|
||||
|
||||
|
||||
def _mk_lines_array(
|
||||
data: List,
|
||||
size: int,
|
||||
elements_step: int = 6,
|
||||
) -> np.ndarray:
|
||||
"""Create an ndarray to hold lines graphics info.
|
||||
|
||||
"""
|
||||
return np.zeros_like(
|
||||
data,
|
||||
shape=(int(size), elements_step),
|
||||
dtype=object,
|
||||
)
|
||||
|
||||
|
||||
def lines_from_ohlc(row: np.ndarray, w: float) -> Tuple[QLineF]:
|
||||
open, high, low, close, index = row[
|
||||
['open', 'high', 'low', 'close', 'index']]
|
||||
|
||||
# high -> low vertical (body) line
|
||||
if low != high:
|
||||
hl = QLineF(index, low, index, high)
|
||||
else:
|
||||
# XXX: if we don't do it renders a weird rectangle?
|
||||
# see below for filtering this later...
|
||||
hl = None
|
||||
|
||||
# NOTE: place the x-coord start as "middle" of the drawing range such
|
||||
# that the open arm line-graphic is at the left-most-side of
|
||||
# the index's range according to the view mapping.
|
||||
|
||||
# open line
|
||||
o = QLineF(index - w, open, index, open)
|
||||
# close line
|
||||
c = QLineF(index, close, index + w, close)
|
||||
|
||||
return [hl, o, c]
|
||||
|
||||
|
||||
@jit(
|
||||
# TODO: for now need to construct this manually for readonly arrays, see
|
||||
# https://github.com/numba/numba/issues/4511
|
||||
# ntypes.Tuple((float64[:], float64[:], float64[:]))(
|
||||
# numba_ohlc_dtype[::1], # contiguous
|
||||
# int64,
|
||||
# optional(float64),
|
||||
# ),
|
||||
nopython=True,
|
||||
nogil=True
|
||||
)
|
||||
def path_arrays_from_ohlc(
|
||||
data: np.ndarray,
|
||||
start: int64,
|
||||
bar_gap: float64 = 0.43,
|
||||
) -> np.ndarray:
|
||||
"""Generate an array of lines objects from input ohlc data.
|
||||
|
||||
"""
|
||||
size = int(data.shape[0] * 6)
|
||||
|
||||
x = np.zeros(
|
||||
# data,
|
||||
shape=size,
|
||||
dtype=float64,
|
||||
)
|
||||
y, c = x.copy(), x.copy()
|
||||
|
||||
# TODO: report bug for assert @
|
||||
# /home/goodboy/repos/piker/env/lib/python3.8/site-packages/numba/core/typing/builtins.py:991
|
||||
for i, q in enumerate(data[start:], start):
|
||||
|
||||
# TODO: ask numba why this doesn't work..
|
||||
# open, high, low, close, index = q[
|
||||
# ['open', 'high', 'low', 'close', 'index']]
|
||||
|
||||
open = q['open']
|
||||
high = q['high']
|
||||
low = q['low']
|
||||
close = q['close']
|
||||
index = float64(q['index'])
|
||||
|
||||
istart = i * 6
|
||||
istop = istart + 6
|
||||
|
||||
# x,y detail the 6 points which connect all vertexes of a ohlc bar
|
||||
x[istart:istop] = (
|
||||
index - bar_gap,
|
||||
index,
|
||||
index,
|
||||
index,
|
||||
index,
|
||||
index + bar_gap,
|
||||
)
|
||||
y[istart:istop] = (
|
||||
open,
|
||||
open,
|
||||
low,
|
||||
high,
|
||||
close,
|
||||
close,
|
||||
)
|
||||
|
||||
# specifies that the first edge is never connected to the
|
||||
# prior bars last edge thus providing a small "gap"/"space"
|
||||
# between bars determined by ``bar_gap``.
|
||||
c[istart:istop] = (0, 1, 1, 1, 1, 1)
|
||||
|
||||
return x, y, c
|
||||
|
||||
|
||||
# @timeit
|
||||
def gen_qpath(
|
||||
data,
|
||||
start, # XXX: do we need this?
|
||||
w,
|
||||
) -> QtGui.QPainterPath:
|
||||
|
||||
x, y, c = path_arrays_from_ohlc(data, start, bar_gap=w)
|
||||
|
||||
# TODO: numba the internals of this!
|
||||
return pg.functions.arrayToQPath(x, y, connect=c)
|
||||
|
||||
|
||||
class BarItems(pg.GraphicsObject):
|
||||
"""Price range bars graphics rendered from a OHLC sequence.
|
||||
"""
|
||||
sigPlotChanged = QtCore.Signal(object)
|
||||
|
||||
# 0.5 is no overlap between arms, 1.0 is full overlap
|
||||
w: float = 0.43
|
||||
|
||||
# XXX: for the mega-lulz increasing width here increases draw latency...
|
||||
# so probably don't do it until we figure that out.
|
||||
bars_pen = pg.mkPen(hcolor('bracket'))
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
# scene: 'QGraphicsScene', # noqa
|
||||
plotitem: 'pg.PlotItem', # noqa
|
||||
) -> None:
|
||||
super().__init__()
|
||||
|
||||
self.last_bar = QtGui.QPicture()
|
||||
|
||||
self.path = QtGui.QPainterPath()
|
||||
# self._h_path = QtGui.QGraphicsPathItem(self.path)
|
||||
|
||||
self._pi = plotitem
|
||||
|
||||
self._xrange: Tuple[int, int]
|
||||
self._yrange: Tuple[float, float]
|
||||
|
||||
# XXX: not sure this actually needs to be an array other
|
||||
# then for the old tina mode calcs for up/down bars below?
|
||||
# lines container
|
||||
# self.lines = _mk_lines_array([], 50e3, 6)
|
||||
|
||||
# TODO: don't render the full backing array each time
|
||||
# self._path_data = None
|
||||
self._last_bar_lines: Optional[Tuple[QLineF, ...]] = None
|
||||
|
||||
# track the current length of drawable lines within the larger array
|
||||
self.start_index: int = 0
|
||||
self.stop_index: int = 0
|
||||
|
||||
# @timeit
|
||||
def draw_from_data(
|
||||
self,
|
||||
data: np.ndarray,
|
||||
start: int = 0,
|
||||
) -> QtGui.QPainterPath:
|
||||
"""Draw OHLC datum graphics from a ``np.ndarray``.
|
||||
|
||||
This routine is usually only called to draw the initial history.
|
||||
"""
|
||||
self.path = gen_qpath(data, start, self.w)
|
||||
|
||||
# save graphics for later reference and keep track
|
||||
# of current internal "last index"
|
||||
# self.start_index = len(data)
|
||||
index = data['index']
|
||||
self._xrange = (index[0], index[-1])
|
||||
self._yrange = (
|
||||
np.nanmax(data['high']),
|
||||
np.nanmin(data['low']),
|
||||
)
|
||||
|
||||
# up to last to avoid double draw of last bar
|
||||
self._last_bar_lines = lines_from_ohlc(data[-1], self.w)
|
||||
|
||||
# create pics
|
||||
# self.draw_history()
|
||||
self.draw_last_bar()
|
||||
|
||||
# trigger render
|
||||
# https://doc.qt.io/qt-5/qgraphicsitem.html#update
|
||||
self.update()
|
||||
|
||||
return self.path
|
||||
|
||||
# def update_ranges(
|
||||
# self,
|
||||
# xmn: int,
|
||||
# xmx: int,
|
||||
# ymn: float,
|
||||
# ymx: float,
|
||||
# ) -> None:
|
||||
# ...
|
||||
|
||||
|
||||
def draw_last_bar(self) -> None:
|
||||
"""Currently this draws lines to a cached ``QPicture`` which
|
||||
is supposed to speed things up on ``.paint()`` calls (which
|
||||
is a call to ``QPainter.drawPicture()`` but I'm not so sure.
|
||||
|
||||
"""
|
||||
p = QtGui.QPainter(self.last_bar)
|
||||
p.setPen(self.bars_pen)
|
||||
p.drawLines(*tuple(filter(bool, self._last_bar_lines)))
|
||||
p.end()
|
||||
|
||||
# @timeit
|
||||
def update_from_array(
|
||||
self,
|
||||
array: np.ndarray,
|
||||
just_history=False,
|
||||
) -> None:
|
||||
"""Update the last datum's bar graphic from input data array.
|
||||
|
||||
This routine should be interface compatible with
|
||||
``pg.PlotCurveItem.setData()``. Normally this method in
|
||||
``pyqtgraph`` seems to update all the data passed to the
|
||||
graphics object, and then update/rerender, but here we're
|
||||
assuming the prior graphics havent changed (OHLC history rarely
|
||||
does) so this "should" be simpler and faster.
|
||||
|
||||
This routine should be made (transitively) as fast as possible.
|
||||
"""
|
||||
# index = self.start_index
|
||||
istart, istop = self._xrange
|
||||
|
||||
index = array['index']
|
||||
first_index, last_index = index[0], index[-1]
|
||||
|
||||
# length = len(array)
|
||||
prepend_length = istart - first_index
|
||||
append_length = last_index - istop
|
||||
|
||||
# TODO: allow mapping only a range of lines thus
|
||||
# only drawing as many bars as exactly specified.
|
||||
|
||||
if prepend_length:
|
||||
|
||||
# new history was added and we need to render a new path
|
||||
new_bars = array[:prepend_length]
|
||||
prepend_path = gen_qpath(new_bars, 0, self.w)
|
||||
|
||||
# XXX: SOMETHING IS MAYBE FISHY HERE what with the old_path
|
||||
# y value not matching the first value from
|
||||
# array[prepend_length + 1] ???
|
||||
|
||||
# update path
|
||||
old_path = self.path
|
||||
self.path = prepend_path
|
||||
self.path.addPath(old_path)
|
||||
|
||||
if append_length:
|
||||
# generate new lines objects for updatable "current bar"
|
||||
self._last_bar_lines = lines_from_ohlc(array[-1], self.w)
|
||||
self.draw_last_bar()
|
||||
|
||||
# generate new graphics to match provided array
|
||||
# path appending logic:
|
||||
# we need to get the previous "current bar(s)" for the time step
|
||||
# and convert it to a sub-path to append to the historical set
|
||||
# new_bars = array[istop - 1:istop + append_length - 1]
|
||||
new_bars = array[-append_length - 1:-1]
|
||||
append_path = gen_qpath(new_bars, 0, self.w)
|
||||
self.path.moveTo(float(istop - self.w), float(new_bars[0]['open']))
|
||||
self.path.addPath(append_path)
|
||||
|
||||
self._xrange = first_index, last_index
|
||||
|
||||
if just_history:
|
||||
self.update()
|
||||
return
|
||||
|
||||
# last bar update
|
||||
i, o, h, l, last, v = array[-1][
|
||||
['index', 'open', 'high', 'low', 'close', 'volume']
|
||||
]
|
||||
# assert i == self.start_index - 1
|
||||
assert i == last_index
|
||||
body, larm, rarm = self._last_bar_lines
|
||||
|
||||
# XXX: is there a faster way to modify this?
|
||||
rarm.setLine(rarm.x1(), last, rarm.x2(), last)
|
||||
# writer is responsible for changing open on "first" volume of bar
|
||||
larm.setLine(larm.x1(), o, larm.x2(), o)
|
||||
|
||||
if l != h: # noqa
|
||||
if body is None:
|
||||
body = self._last_bar_lines[0] = QLineF(i, l, i, h)
|
||||
else:
|
||||
# update body
|
||||
body.setLine(i, l, i, h)
|
||||
|
||||
# XXX: pretty sure this is causing an issue where the bar has
|
||||
# a large upward move right before the next sample and the body
|
||||
# is getting set to None since the next bar is flat but the shm
|
||||
# array index update wasn't read by the time this code runs. Iow
|
||||
# we're doing this removal of the body for a bar index that is
|
||||
# now out of date / from some previous sample. It's weird
|
||||
# though because i've seen it do this to bars i - 3 back?
|
||||
|
||||
# else:
|
||||
# # XXX: h == l -> remove any HL line to avoid render bug
|
||||
# if body is not None:
|
||||
# body = self.lines[index - 1][0] = None
|
||||
|
||||
self.draw_last_bar()
|
||||
self.update()
|
||||
|
||||
# @timeit
|
||||
def paint(self, p, opt, widget):
|
||||
|
||||
# profiler = pg.debug.Profiler(disabled=False, delayed=False)
|
||||
|
||||
# TODO: use to avoid drawing artefacts?
|
||||
# self.prepareGeometryChange()
|
||||
|
||||
# p.setCompositionMode(0)
|
||||
|
||||
# TODO: one thing we could try here is pictures being drawn of
|
||||
# a fixed count of bars such that based on the viewbox indices we
|
||||
# only draw the "rounded up" number of "pictures worth" of bars
|
||||
# as is necesarry for what's in "view". Not sure if this will
|
||||
# lead to any perf gains other then when zoomed in to less bars
|
||||
# in view.
|
||||
p.drawPicture(0, 0, self.last_bar)
|
||||
|
||||
p.setPen(self.bars_pen)
|
||||
p.drawPath(self.path)
|
||||
|
||||
# @timeit
|
||||
def boundingRect(self):
|
||||
# Qt docs: https://doc.qt.io/qt-5/qgraphicsitem.html#boundingRect
|
||||
|
||||
# TODO: Can we do rect caching to make this faster
|
||||
# like `pg.PlotCurveItem` does? In theory it's just
|
||||
# computing max/min stuff again like we do in the udpate loop
|
||||
# anyway. Not really sure it's necessary since profiling already
|
||||
# shows this method is faf.
|
||||
|
||||
# boundingRect _must_ indicate the entire area that will be
|
||||
# drawn on or else we will get artifacts and possibly crashing.
|
||||
# (in this case, QPicture does all the work of computing the
|
||||
# bounding rect for us).
|
||||
|
||||
# compute aggregate bounding rectangle
|
||||
lb = self.last_bar.boundingRect()
|
||||
hb = self.path.boundingRect()
|
||||
|
||||
return QtCore.QRectF(
|
||||
# top left
|
||||
QtCore.QPointF(hb.topLeft()),
|
||||
# total size
|
||||
QtCore.QSizeF(QtCore.QSizeF(lb.size()) + hb.size())
|
||||
# QtCore.QSizeF(lb.size() + hb.size())
|
||||
)
|
||||
|
||||
|
||||
# XXX: when we get back to enabling tina mode for xb
|
||||
# class CandlestickItems(BarItems):
|
||||
|
||||
# w2 = 0.7
|
||||
# line_pen = pg.mkPen('#000000')
|
||||
# bull_brush = pg.mkBrush('#00ff00')
|
||||
# bear_brush = pg.mkBrush('#ff0000')
|
||||
|
||||
# def _generate(self, p):
|
||||
# rects = np.array(
|
||||
# [
|
||||
# QtCore.QRectF(
|
||||
# q.id - self.w,
|
||||
# q.open,
|
||||
# self.w2,
|
||||
# q.close - q.open
|
||||
# )
|
||||
# for q in Quotes
|
||||
# ]
|
||||
# )
|
||||
|
||||
# p.setPen(self.line_pen)
|
||||
# p.drawLines(
|
||||
# [QtCore.QLineF(q.id, q.low, q.id, q.high)
|
||||
# for q in Quotes]
|
||||
# )
|
||||
|
||||
# p.setBrush(self.bull_brush)
|
||||
# p.drawRects(*rects[Quotes.close > Quotes.open])
|
||||
|
||||
# p.setBrush(self.bear_brush)
|
||||
# p.drawRects(*rects[Quotes.close < Quotes.open])
|
||||
|
||||
|
||||
class LevelLabel(YSticky):
|
||||
|
||||
line_pen = pg.mkPen(hcolor('bracket'))
|
||||
|
||||
_w_margin = 4
|
||||
_h_margin = 3
|
||||
level: float = 0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
chart,
|
||||
*args,
|
||||
orient_v: str = 'bottom',
|
||||
orient_h: str = 'left',
|
||||
**kwargs
|
||||
) -> None:
|
||||
super().__init__(chart, *args, **kwargs)
|
||||
|
||||
# orientation around axis options
|
||||
self._orient_v = orient_v
|
||||
self._orient_h = orient_h
|
||||
self._v_shift = {
|
||||
'top': 1.,
|
||||
'bottom': 0,
|
||||
'middle': 1 / 2.
|
||||
}[orient_v]
|
||||
|
||||
self._h_shift = {
|
||||
'left': -1., 'right': 0
|
||||
}[orient_h]
|
||||
|
||||
def update_label(
|
||||
self,
|
||||
abs_pos: QPointF, # scene coords
|
||||
level: float, # data for text
|
||||
offset: int = 1 # if have margins, k?
|
||||
) -> None:
|
||||
|
||||
# write contents, type specific
|
||||
self.set_label_str(level)
|
||||
|
||||
br = self.boundingRect()
|
||||
h, w = br.height(), br.width()
|
||||
|
||||
# this triggers ``.pain()`` implicitly?
|
||||
self.setPos(QPointF(
|
||||
self._h_shift * w - offset,
|
||||
abs_pos.y() - (self._v_shift * h) - offset
|
||||
))
|
||||
self.update()
|
||||
|
||||
self.level = level
|
||||
|
||||
def set_label_str(self, level: float):
|
||||
# this is read inside ``.paint()``
|
||||
# self.label_str = '{size} x {level:.{digits}f}'.format(
|
||||
self.label_str = '{level:.{digits}f}'.format(
|
||||
# size=self._size,
|
||||
digits=self.digits,
|
||||
level=level
|
||||
).replace(',', ' ')
|
||||
|
||||
def size_hint(self) -> Tuple[None, None]:
|
||||
return None, None
|
||||
|
||||
def draw(
|
||||
self,
|
||||
p: QtGui.QPainter,
|
||||
rect: QtCore.QRectF
|
||||
) -> None:
|
||||
p.setPen(self.line_pen)
|
||||
|
||||
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())
|
||||
|
||||
|
||||
class L1Label(LevelLabel):
|
||||
|
||||
size: float = 0
|
||||
size_digits: float = 3
|
||||
|
||||
text_flags = (
|
||||
QtCore.Qt.TextDontClip
|
||||
| QtCore.Qt.AlignLeft
|
||||
)
|
||||
|
||||
def set_label_str(self, level: float) -> None:
|
||||
"""Reimplement the label string write to include the level's order-queue's
|
||||
size in the text, eg. 100 x 323.3.
|
||||
|
||||
"""
|
||||
self.label_str = '{size:.{size_digits}f} x {level:,.{digits}f}'.format(
|
||||
size_digits=self.size_digits,
|
||||
size=self.size or '?',
|
||||
digits=self.digits,
|
||||
level=level
|
||||
).replace(',', ' ')
|
||||
|
||||
|
||||
class L1Labels:
|
||||
"""Level 1 bid ask labels for dynamic update on price-axis.
|
||||
|
||||
"""
|
||||
max_value: float = '100.0 x 100 000.00'
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
chart: 'ChartPlotWidget', # noqa
|
||||
digits: int = 2,
|
||||
size_digits: int = 0,
|
||||
font_size_inches: float = _down_2_font_inches_we_like,
|
||||
) -> None:
|
||||
|
||||
self.chart = chart
|
||||
|
||||
self.bid_label = L1Label(
|
||||
chart=chart,
|
||||
parent=chart.getAxis('right'),
|
||||
# TODO: pass this from symbol data
|
||||
digits=digits,
|
||||
opacity=1,
|
||||
font_size_inches=font_size_inches,
|
||||
bg_color='papas_special',
|
||||
fg_color='bracket',
|
||||
orient_v='bottom',
|
||||
)
|
||||
self.bid_label.size_digits = size_digits
|
||||
self.bid_label._size_br_from_str(self.max_value)
|
||||
|
||||
self.ask_label = L1Label(
|
||||
chart=chart,
|
||||
parent=chart.getAxis('right'),
|
||||
# TODO: pass this from symbol data
|
||||
digits=digits,
|
||||
opacity=1,
|
||||
font_size_inches=font_size_inches,
|
||||
bg_color='papas_special',
|
||||
fg_color='bracket',
|
||||
orient_v='top',
|
||||
)
|
||||
self.ask_label.size_digits = size_digits
|
||||
self.ask_label._size_br_from_str(self.max_value)
|
||||
|
||||
|
||||
class LevelLine(pg.InfiniteLine):
|
||||
def __init__(
|
||||
self,
|
||||
label: LevelLabel,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
self.label = label
|
||||
super().__init__(**kwargs)
|
||||
self.sigPositionChanged.connect(self.set_level)
|
||||
|
||||
def set_level(self, value: float) -> None:
|
||||
self.label.update_from_data(0, self.value())
|
||||
|
||||
|
||||
def level_line(
|
||||
chart: 'ChartPlogWidget', # noqa
|
||||
level: float,
|
||||
digits: int = 1,
|
||||
|
||||
# size 4 font on 4k screen scaled down, so small-ish.
|
||||
font_size_inches: float = _down_2_font_inches_we_like,
|
||||
|
||||
show_label: bool = True,
|
||||
|
||||
**linelabelkwargs
|
||||
) -> LevelLine:
|
||||
"""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=1,
|
||||
font_size_inches=font_size_inches,
|
||||
# TODO: make this take the view's bg pen
|
||||
bg_color='papas_special',
|
||||
fg_color='default',
|
||||
**linelabelkwargs
|
||||
)
|
||||
label.update_from_data(0, level)
|
||||
|
||||
# TODO: can we somehow figure out a max value from the parent axis?
|
||||
label._size_br_from_str(label.label_str)
|
||||
|
||||
line = LevelLine(
|
||||
label,
|
||||
movable=True,
|
||||
angle=0,
|
||||
)
|
||||
line.setValue(level)
|
||||
line.setPen(pg.mkPen(hcolor('default')))
|
||||
# activate/draw label
|
||||
line.setValue(level)
|
||||
|
||||
chart.plotItem.addItem(line)
|
||||
|
||||
if not show_label:
|
||||
label.hide()
|
||||
|
||||
return line
|
|
@ -0,0 +1,20 @@
|
|||
# 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/>.
|
||||
|
||||
"""
|
||||
Internal custom graphics mostly built for low latency and reuse.
|
||||
|
||||
"""
|
|
@ -0,0 +1,376 @@
|
|||
# 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/>.
|
||||
"""
|
||||
Mouse interaction graphics
|
||||
|
||||
"""
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import inspect
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from PyQt5 import QtCore, QtGui
|
||||
from PyQt5.QtCore import QPointF
|
||||
|
||||
from .._style import (
|
||||
_xaxis_at,
|
||||
hcolor,
|
||||
_font,
|
||||
)
|
||||
from .._axes import YAxisLabel, XAxisLabel
|
||||
|
||||
# XXX: these settings seem to result in really decent mouse scroll
|
||||
# latency (in terms of perceived lag in cross hair) so really be sure
|
||||
# there's an improvement if you want to change it!
|
||||
_mouse_rate_limit = 60 # TODO; should we calc current screen refresh rate?
|
||||
_debounce_delay = 1 / 2e3
|
||||
_ch_label_opac = 1
|
||||
|
||||
|
||||
# TODO: we need to handle the case where index is outside
|
||||
# the underlying datums range
|
||||
class LineDot(pg.CurvePoint):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
curve: pg.PlotCurveItem,
|
||||
index: int,
|
||||
plot: 'ChartPlotWidget', # type: ingore # noqa
|
||||
pos=None,
|
||||
size: int = 2, # in pxs
|
||||
color: str = 'default_light',
|
||||
) -> None:
|
||||
pg.CurvePoint.__init__(
|
||||
self,
|
||||
curve,
|
||||
index=index,
|
||||
pos=pos,
|
||||
rotate=False,
|
||||
)
|
||||
self._plot = plot
|
||||
|
||||
# TODO: get pen from curve if not defined?
|
||||
cdefault = hcolor(color)
|
||||
pen = pg.mkPen(cdefault)
|
||||
brush = pg.mkBrush(cdefault)
|
||||
|
||||
# presuming this is fast since it's built in?
|
||||
dot = self.dot = QtGui.QGraphicsEllipseItem(
|
||||
QtCore.QRectF(-size / 2, -size / 2, size, size)
|
||||
)
|
||||
# if we needed transformable dot?
|
||||
# dot.translate(-size*0.5, -size*0.5)
|
||||
dot.setPen(pen)
|
||||
dot.setBrush(brush)
|
||||
dot.setParentItem(self)
|
||||
|
||||
# keep a static size
|
||||
self.setFlag(self.ItemIgnoresTransformations)
|
||||
|
||||
def event(
|
||||
self,
|
||||
ev: QtCore.QEvent,
|
||||
) -> None:
|
||||
# print((ev, type(ev)))
|
||||
if not isinstance(
|
||||
ev, QtCore.QDynamicPropertyChangeEvent
|
||||
) or self.curve() is None:
|
||||
return False
|
||||
|
||||
# if ev.propertyName() == 'index':
|
||||
# print(ev)
|
||||
# # self.setProperty
|
||||
|
||||
(x, y) = self.curve().getData()
|
||||
index = self.property('index')
|
||||
# first = self._plot._ohlc[0]['index']
|
||||
# first = x[0]
|
||||
# i = index - first
|
||||
i = index - x[0]
|
||||
if i > 0 and i < len(y):
|
||||
newPos = (index, y[i])
|
||||
QtGui.QGraphicsItem.setPos(self, *newPos)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
_corner_anchors = {
|
||||
'top': 0,
|
||||
'left': 0,
|
||||
'bottom': 1,
|
||||
'right': 1,
|
||||
}
|
||||
# XXX: fyi naming here is confusing / opposite to coords
|
||||
_corner_margins = {
|
||||
('top', 'left'): (-4, -5),
|
||||
('top', 'right'): (4, -5),
|
||||
|
||||
('bottom', 'left'): (-4, lambda font_size: font_size * 2),
|
||||
('bottom', 'right'): (4, lambda font_size: font_size * 2),
|
||||
}
|
||||
|
||||
|
||||
class ContentsLabel(pg.LabelItem):
|
||||
"""Label anchored to a ``ViewBox`` typically for displaying
|
||||
datum-wise points from the "viewed" contents.
|
||||
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
chart: 'ChartPlotWidget', # noqa
|
||||
anchor_at: str = ('top', 'right'),
|
||||
justify_text: str = 'left',
|
||||
font_size: Optional[int] = None,
|
||||
) -> None:
|
||||
font_size = font_size or _font.font.pixelSize()
|
||||
super().__init__(
|
||||
justify=justify_text,
|
||||
size=f'{str(font_size)}px'
|
||||
)
|
||||
|
||||
# anchor to viewbox
|
||||
self.setParentItem(chart._vb)
|
||||
chart.scene().addItem(self)
|
||||
self.chart = chart
|
||||
|
||||
v, h = anchor_at
|
||||
index = (_corner_anchors[h], _corner_anchors[v])
|
||||
margins = _corner_margins[(v, h)]
|
||||
|
||||
ydim = margins[1]
|
||||
if inspect.isfunction(margins[1]):
|
||||
margins = margins[0], ydim(font_size)
|
||||
|
||||
self.anchor(itemPos=index, parentPos=index, offset=margins)
|
||||
|
||||
def update_from_ohlc(
|
||||
self,
|
||||
name: str,
|
||||
index: int,
|
||||
array: np.ndarray,
|
||||
) -> None:
|
||||
# this being "html" is the dumbest shit :eyeroll:
|
||||
first = array[0]['index']
|
||||
|
||||
self.setText(
|
||||
"<b>i</b>:{index}<br/>"
|
||||
"<b>O</b>:{}<br/>"
|
||||
"<b>H</b>:{}<br/>"
|
||||
"<b>L</b>:{}<br/>"
|
||||
"<b>C</b>:{}<br/>"
|
||||
"<b>V</b>:{}<br/>"
|
||||
"<b>wap</b>:{}".format(
|
||||
*array[index - first][
|
||||
['open', 'high', 'low', 'close', 'volume', 'bar_wap']
|
||||
],
|
||||
name=name,
|
||||
index=index,
|
||||
)
|
||||
)
|
||||
|
||||
def update_from_value(
|
||||
self,
|
||||
name: str,
|
||||
index: int,
|
||||
array: np.ndarray,
|
||||
) -> None:
|
||||
first = array[0]['index']
|
||||
if index < array[-1]['index'] and index > first:
|
||||
data = array[index - first][name]
|
||||
self.setText(f"{name}: {data:.2f}")
|
||||
|
||||
|
||||
class CrossHair(pg.GraphicsObject):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
linkedsplitcharts: 'LinkedSplitCharts', # noqa
|
||||
digits: int = 0
|
||||
) -> None:
|
||||
super().__init__()
|
||||
# XXX: not sure why these are instance variables?
|
||||
# It's not like we can change them on the fly..?
|
||||
self.pen = pg.mkPen(
|
||||
color=hcolor('default'),
|
||||
style=QtCore.Qt.DashLine,
|
||||
)
|
||||
self.lines_pen = pg.mkPen(
|
||||
color='#a9a9a9', # gray?
|
||||
style=QtCore.Qt.DashLine,
|
||||
)
|
||||
self.lsc = linkedsplitcharts
|
||||
self.graphics = {}
|
||||
self.plots = []
|
||||
self.active_plot = None
|
||||
self.digits = digits
|
||||
self._lastx = None
|
||||
|
||||
def add_plot(
|
||||
self,
|
||||
plot: 'ChartPlotWidget', # noqa
|
||||
digits: int = 0,
|
||||
) -> None:
|
||||
# add ``pg.graphicsItems.InfiniteLine``s
|
||||
# vertical and horizonal lines and a y-axis label
|
||||
vl = plot.addLine(x=0, pen=self.lines_pen, movable=False)
|
||||
vl.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
|
||||
|
||||
hl = plot.addLine(y=0, pen=self.lines_pen, movable=False)
|
||||
hl.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
|
||||
hl.hide()
|
||||
|
||||
yl = YAxisLabel(
|
||||
parent=plot.getAxis('right'),
|
||||
digits=digits or self.digits,
|
||||
opacity=_ch_label_opac,
|
||||
bg_color='default',
|
||||
)
|
||||
yl.hide() # on startup if mouse is off screen
|
||||
|
||||
# TODO: checkout what ``.sigDelayed`` can be used for
|
||||
# (emitted once a sufficient delay occurs in mouse movement)
|
||||
px_moved = pg.SignalProxy(
|
||||
plot.scene().sigMouseMoved,
|
||||
rateLimit=_mouse_rate_limit,
|
||||
slot=self.mouseMoved,
|
||||
delay=_debounce_delay,
|
||||
)
|
||||
px_enter = pg.SignalProxy(
|
||||
plot.sig_mouse_enter,
|
||||
rateLimit=_mouse_rate_limit,
|
||||
slot=lambda: self.mouseAction('Enter', plot),
|
||||
delay=_debounce_delay,
|
||||
)
|
||||
px_leave = pg.SignalProxy(
|
||||
plot.sig_mouse_leave,
|
||||
rateLimit=_mouse_rate_limit,
|
||||
slot=lambda: self.mouseAction('Leave', plot),
|
||||
delay=_debounce_delay,
|
||||
)
|
||||
self.graphics[plot] = {
|
||||
'vl': vl,
|
||||
'hl': hl,
|
||||
'yl': yl,
|
||||
'px': (px_moved, px_enter, px_leave),
|
||||
}
|
||||
self.plots.append(plot)
|
||||
|
||||
# Determine where to place x-axis label.
|
||||
# Place below the last plot by default, ow
|
||||
# keep x-axis right below main chart
|
||||
plot_index = -1 if _xaxis_at == 'bottom' else 0
|
||||
|
||||
self.xaxis_label = XAxisLabel(
|
||||
parent=self.plots[plot_index].getAxis('bottom'),
|
||||
opacity=_ch_label_opac,
|
||||
bg_color='default',
|
||||
)
|
||||
# place label off-screen during startup
|
||||
self.xaxis_label.setPos(self.plots[0].mapFromView(QPointF(0, 0)))
|
||||
|
||||
def add_curve_cursor(
|
||||
self,
|
||||
plot: 'ChartPlotWidget', # noqa
|
||||
curve: 'PlotCurveItem', # noqa
|
||||
) -> LineDot:
|
||||
# if this plot contains curves add line dot "cursors" to denote
|
||||
# the current sample under the mouse
|
||||
cursor = LineDot(curve, index=plot._ohlc[-1]['index'], plot=plot)
|
||||
plot.addItem(cursor)
|
||||
self.graphics[plot].setdefault('cursors', []).append(cursor)
|
||||
return cursor
|
||||
|
||||
def mouseAction(self, action, plot): # noqa
|
||||
if action == 'Enter':
|
||||
self.active_plot = plot
|
||||
|
||||
# show horiz line and y-label
|
||||
self.graphics[plot]['hl'].show()
|
||||
self.graphics[plot]['yl'].show()
|
||||
|
||||
else: # Leave
|
||||
self.active_plot = None
|
||||
|
||||
# hide horiz line and y-label
|
||||
self.graphics[plot]['hl'].hide()
|
||||
self.graphics[plot]['yl'].hide()
|
||||
|
||||
def mouseMoved(
|
||||
self,
|
||||
evt: 'Tuple[QMouseEvent]', # noqa
|
||||
) -> None: # noqa
|
||||
"""Update horizonal and vertical lines when mouse moves inside
|
||||
either the main chart or any indicator subplot.
|
||||
"""
|
||||
pos = evt[0]
|
||||
|
||||
# find position inside active plot
|
||||
try:
|
||||
# map to view coordinate system
|
||||
mouse_point = self.active_plot.mapToView(pos)
|
||||
except AttributeError:
|
||||
# mouse was not on active plot
|
||||
return
|
||||
|
||||
x, y = mouse_point.x(), mouse_point.y()
|
||||
plot = self.active_plot
|
||||
|
||||
# update y-range items
|
||||
self.graphics[plot]['hl'].setY(y)
|
||||
|
||||
self.graphics[self.active_plot]['yl'].update_label(
|
||||
abs_pos=pos, value=y
|
||||
)
|
||||
|
||||
# Update x if cursor changed after discretization calc
|
||||
# (this saves draw cycles on small mouse moves)
|
||||
lastx = self._lastx
|
||||
ix = round(x) # since bars are centered around index
|
||||
|
||||
if ix != lastx:
|
||||
for plot, opts in self.graphics.items():
|
||||
|
||||
# move the vertical line to the current "center of bar"
|
||||
opts['vl'].setX(ix)
|
||||
|
||||
# update the chart's "contents" label
|
||||
plot.update_contents_labels(ix)
|
||||
|
||||
# update all subscribed curve dots
|
||||
# first = plot._ohlc[0]['index']
|
||||
for cursor in opts.get('cursors', ()):
|
||||
cursor.setIndex(ix)
|
||||
|
||||
# update the label on the bottom of the crosshair
|
||||
self.xaxis_label.update_label(
|
||||
|
||||
# XXX: requires:
|
||||
# https://github.com/pyqtgraph/pyqtgraph/pull/1418
|
||||
# otherwise gobbles tons of CPU..
|
||||
|
||||
# map back to abs (label-local) coordinates
|
||||
abs_pos=plot.mapFromView(QPointF(ix, y)),
|
||||
value=x,
|
||||
)
|
||||
|
||||
self._lastx = ix
|
||||
|
||||
def boundingRect(self):
|
||||
try:
|
||||
return self.active_plot.boundingRect()
|
||||
except AttributeError:
|
||||
return self.plots[0].boundingRect()
|
|
@ -0,0 +1,158 @@
|
|||
# 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/>.
|
||||
|
||||
"""
|
||||
Fast, smooth, sexy curves.
|
||||
"""
|
||||
from typing import Tuple
|
||||
|
||||
import pyqtgraph as pg
|
||||
from PyQt5 import QtCore, QtGui, QtWidgets
|
||||
|
||||
from ..._profile import pg_profile_enabled
|
||||
|
||||
|
||||
# TODO: got a feeling that dropping this inheritance gets us even more speedups
|
||||
class FastAppendCurve(pg.PlotCurveItem):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
# TODO: we can probably just dispense with the parent since
|
||||
# we're basically only using the pen setting now...
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self._last_line: QtCore.QLineF = None
|
||||
self._xrange: Tuple[int, int] = self.dataBounds(ax=0)
|
||||
|
||||
# TODO: one question still remaining is if this makes trasform
|
||||
# interactions slower (such as zooming) and if so maybe if/when
|
||||
# we implement a "history" mode for the view we disable this in
|
||||
# that mode?
|
||||
self.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
|
||||
|
||||
def update_from_array(
|
||||
self,
|
||||
x,
|
||||
y,
|
||||
) -> QtGui.QPainterPath:
|
||||
|
||||
profiler = pg.debug.Profiler(disabled=not pg_profile_enabled())
|
||||
flip_cache = False
|
||||
|
||||
# print(f"xrange: {self._xrange}")
|
||||
istart, istop = self._xrange
|
||||
|
||||
prepend_length = istart - x[0]
|
||||
append_length = x[-1] - istop
|
||||
|
||||
if self.path is None or prepend_length:
|
||||
self.path = pg.functions.arrayToQPath(
|
||||
x[:-1],
|
||||
y[:-1],
|
||||
connect='all'
|
||||
)
|
||||
profiler('generate fresh path')
|
||||
|
||||
# TODO: get this working - right now it's giving heck on vwap...
|
||||
# if prepend_length:
|
||||
# breakpoint()
|
||||
|
||||
# prepend_path = pg.functions.arrayToQPath(
|
||||
# x[0:prepend_length],
|
||||
# y[0:prepend_length],
|
||||
# connect='all'
|
||||
# )
|
||||
|
||||
# # swap prepend path in "front"
|
||||
# old_path = self.path
|
||||
# self.path = prepend_path
|
||||
# # self.path.moveTo(new_x[0], new_y[0])
|
||||
# self.path.connectPath(old_path)
|
||||
|
||||
if append_length:
|
||||
# print(f"append_length: {append_length}")
|
||||
new_x = x[-append_length - 2:-1]
|
||||
new_y = y[-append_length - 2:-1]
|
||||
# print((new_x, new_y))
|
||||
|
||||
append_path = pg.functions.arrayToQPath(
|
||||
new_x,
|
||||
new_y,
|
||||
connect='all'
|
||||
)
|
||||
# print(f"append_path br: {append_path.boundingRect()}")
|
||||
# self.path.moveTo(new_x[0], new_y[0])
|
||||
# self.path.connectPath(append_path)
|
||||
self.path.connectPath(append_path)
|
||||
|
||||
# XXX: pretty annoying but, without this there's little
|
||||
# artefacts on the append updates to the curve...
|
||||
self.setCacheMode(QtGui.QGraphicsItem.NoCache)
|
||||
self.prepareGeometryChange()
|
||||
flip_cache = True
|
||||
|
||||
# print(f"update br: {self.path.boundingRect()}")
|
||||
|
||||
# XXX: lol brutal, the internals of `CurvePoint` (inherited by
|
||||
# our `LineDot`) required ``.getData()`` to work..
|
||||
self.xData = x
|
||||
self.yData = y
|
||||
|
||||
self._xrange = x[0], x[-1]
|
||||
self._last_line = QtCore.QLineF(x[-2], y[-2], x[-1], y[-1])
|
||||
|
||||
# trigger redraw of path
|
||||
# do update before reverting to cache mode
|
||||
self.prepareGeometryChange()
|
||||
self.update()
|
||||
|
||||
if flip_cache:
|
||||
self.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
|
||||
|
||||
def boundingRect(self):
|
||||
hb = self.path.controlPointRect()
|
||||
hb_size = hb.size()
|
||||
# print(f'hb_size: {hb_size}')
|
||||
|
||||
w = hb_size.width() + 1
|
||||
h = hb_size.height() + 1
|
||||
br = QtCore.QRectF(
|
||||
|
||||
# top left
|
||||
QtCore.QPointF(hb.topLeft()),
|
||||
|
||||
# total size
|
||||
QtCore.QSizeF(w, h)
|
||||
)
|
||||
# print(f'bounding rect: {br}')
|
||||
return br
|
||||
|
||||
def paint(
|
||||
self,
|
||||
p: QtGui.QPainter,
|
||||
opt: QtWidgets.QStyleOptionGraphicsItem,
|
||||
w: QtWidgets.QWidget
|
||||
) -> None:
|
||||
|
||||
profiler = pg.debug.Profiler(disabled=not pg_profile_enabled())
|
||||
# p.setRenderHint(p.Antialiasing, True)
|
||||
|
||||
p.setPen(self.opts['pen'])
|
||||
p.drawLine(self._last_line)
|
||||
profiler('.drawLine()')
|
||||
|
||||
p.drawPath(self.path)
|
||||
profiler('.drawPath()')
|
|
@ -0,0 +1,244 @@
|
|||
# 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/>.
|
||||
|
||||
"""
|
||||
Lines for orders, alerts, L2.
|
||||
|
||||
"""
|
||||
from typing import Tuple
|
||||
|
||||
import pyqtgraph as pg
|
||||
from PyQt5 import QtCore, QtGui
|
||||
from PyQt5.QtCore import QPointF
|
||||
|
||||
from .._style import (
|
||||
hcolor,
|
||||
_down_2_font_inches_we_like,
|
||||
)
|
||||
from .._axes import YSticky
|
||||
|
||||
|
||||
class LevelLabel(YSticky):
|
||||
|
||||
line_pen = pg.mkPen(hcolor('bracket'))
|
||||
|
||||
_w_margin = 4
|
||||
_h_margin = 3
|
||||
level: float = 0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
chart,
|
||||
*args,
|
||||
orient_v: str = 'bottom',
|
||||
orient_h: str = 'left',
|
||||
**kwargs
|
||||
) -> None:
|
||||
super().__init__(chart, *args, **kwargs)
|
||||
|
||||
# orientation around axis options
|
||||
self._orient_v = orient_v
|
||||
self._orient_h = orient_h
|
||||
self._v_shift = {
|
||||
'top': 1.,
|
||||
'bottom': 0,
|
||||
'middle': 1 / 2.
|
||||
}[orient_v]
|
||||
|
||||
self._h_shift = {
|
||||
'left': -1., 'right': 0
|
||||
}[orient_h]
|
||||
|
||||
def update_label(
|
||||
self,
|
||||
abs_pos: QPointF, # scene coords
|
||||
level: float, # data for text
|
||||
offset: int = 1 # if have margins, k?
|
||||
) -> None:
|
||||
|
||||
# write contents, type specific
|
||||
self.set_label_str(level)
|
||||
|
||||
br = self.boundingRect()
|
||||
h, w = br.height(), br.width()
|
||||
|
||||
# this triggers ``.pain()`` implicitly?
|
||||
self.setPos(QPointF(
|
||||
self._h_shift * w - offset,
|
||||
abs_pos.y() - (self._v_shift * h) - offset
|
||||
))
|
||||
self.update()
|
||||
|
||||
self.level = level
|
||||
|
||||
def set_label_str(self, level: float):
|
||||
# this is read inside ``.paint()``
|
||||
# self.label_str = '{size} x {level:.{digits}f}'.format(
|
||||
self.label_str = '{level:.{digits}f}'.format(
|
||||
# size=self._size,
|
||||
digits=self.digits,
|
||||
level=level
|
||||
).replace(',', ' ')
|
||||
|
||||
def size_hint(self) -> Tuple[None, None]:
|
||||
return None, None
|
||||
|
||||
def draw(
|
||||
self,
|
||||
p: QtGui.QPainter,
|
||||
rect: QtCore.QRectF
|
||||
) -> None:
|
||||
p.setPen(self.line_pen)
|
||||
|
||||
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())
|
||||
|
||||
|
||||
class L1Label(LevelLabel):
|
||||
|
||||
size: float = 0
|
||||
size_digits: float = 3
|
||||
|
||||
text_flags = (
|
||||
QtCore.Qt.TextDontClip
|
||||
| QtCore.Qt.AlignLeft
|
||||
)
|
||||
|
||||
def set_label_str(self, level: float) -> None:
|
||||
"""Reimplement the label string write to include the level's order-queue's
|
||||
size in the text, eg. 100 x 323.3.
|
||||
|
||||
"""
|
||||
self.label_str = '{size:.{size_digits}f} x {level:,.{digits}f}'.format(
|
||||
size_digits=self.size_digits,
|
||||
size=self.size or '?',
|
||||
digits=self.digits,
|
||||
level=level
|
||||
).replace(',', ' ')
|
||||
|
||||
|
||||
class L1Labels:
|
||||
"""Level 1 bid ask labels for dynamic update on price-axis.
|
||||
|
||||
"""
|
||||
max_value: float = '100.0 x 100 000.00'
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
chart: 'ChartPlotWidget', # noqa
|
||||
digits: int = 2,
|
||||
size_digits: int = 0,
|
||||
font_size_inches: float = _down_2_font_inches_we_like,
|
||||
) -> None:
|
||||
|
||||
self.chart = chart
|
||||
|
||||
self.bid_label = L1Label(
|
||||
chart=chart,
|
||||
parent=chart.getAxis('right'),
|
||||
# TODO: pass this from symbol data
|
||||
digits=digits,
|
||||
opacity=1,
|
||||
font_size_inches=font_size_inches,
|
||||
bg_color='papas_special',
|
||||
fg_color='bracket',
|
||||
orient_v='bottom',
|
||||
)
|
||||
self.bid_label.size_digits = size_digits
|
||||
self.bid_label._size_br_from_str(self.max_value)
|
||||
|
||||
self.ask_label = L1Label(
|
||||
chart=chart,
|
||||
parent=chart.getAxis('right'),
|
||||
# TODO: pass this from symbol data
|
||||
digits=digits,
|
||||
opacity=1,
|
||||
font_size_inches=font_size_inches,
|
||||
bg_color='papas_special',
|
||||
fg_color='bracket',
|
||||
orient_v='top',
|
||||
)
|
||||
self.ask_label.size_digits = size_digits
|
||||
self.ask_label._size_br_from_str(self.max_value)
|
||||
|
||||
|
||||
class LevelLine(pg.InfiniteLine):
|
||||
def __init__(
|
||||
self,
|
||||
label: LevelLabel,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
self.label = label
|
||||
super().__init__(**kwargs)
|
||||
self.sigPositionChanged.connect(self.set_level)
|
||||
|
||||
def set_level(self, value: float) -> None:
|
||||
self.label.update_from_data(0, self.value())
|
||||
|
||||
|
||||
def level_line(
|
||||
chart: 'ChartPlogWidget', # noqa
|
||||
level: float,
|
||||
digits: int = 1,
|
||||
|
||||
# size 4 font on 4k screen scaled down, so small-ish.
|
||||
font_size_inches: float = _down_2_font_inches_we_like,
|
||||
|
||||
show_label: bool = True,
|
||||
|
||||
**linelabelkwargs
|
||||
) -> LevelLine:
|
||||
"""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=1,
|
||||
font_size_inches=font_size_inches,
|
||||
# TODO: make this take the view's bg pen
|
||||
bg_color='papas_special',
|
||||
fg_color='default',
|
||||
**linelabelkwargs
|
||||
)
|
||||
label.update_from_data(0, level)
|
||||
|
||||
# TODO: can we somehow figure out a max value from the parent axis?
|
||||
label._size_br_from_str(label.label_str)
|
||||
|
||||
line = LevelLine(
|
||||
label,
|
||||
movable=True,
|
||||
angle=0,
|
||||
)
|
||||
line.setValue(level)
|
||||
line.setPen(pg.mkPen(hcolor('default')))
|
||||
# activate/draw label
|
||||
line.setValue(level)
|
||||
|
||||
chart.plotItem.addItem(line)
|
||||
|
||||
if not show_label:
|
||||
label.hide()
|
||||
|
||||
return line
|
|
@ -0,0 +1,432 @@
|
|||
# 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/>.
|
||||
"""
|
||||
Super fast OHLC sampling graphics types.
|
||||
|
||||
"""
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from numba import jit, float64, int64 # , optional
|
||||
from PyQt5 import QtCore, QtGui, QtWidgets
|
||||
from PyQt5.QtCore import QLineF, QPointF
|
||||
# from numba import types as ntypes
|
||||
# from ..data._source import numba_ohlc_dtype
|
||||
|
||||
from ..._profile import pg_profile_enabled
|
||||
from .._style import hcolor
|
||||
|
||||
|
||||
def _mk_lines_array(
|
||||
data: List,
|
||||
size: int,
|
||||
elements_step: int = 6,
|
||||
) -> np.ndarray:
|
||||
"""Create an ndarray to hold lines graphics info.
|
||||
|
||||
"""
|
||||
return np.zeros_like(
|
||||
data,
|
||||
shape=(int(size), elements_step),
|
||||
dtype=object,
|
||||
)
|
||||
|
||||
|
||||
def lines_from_ohlc(row: np.ndarray, w: float) -> Tuple[QLineF]:
|
||||
open, high, low, close, index = row[
|
||||
['open', 'high', 'low', 'close', 'index']]
|
||||
|
||||
# high -> low vertical (body) line
|
||||
if low != high:
|
||||
hl = QLineF(index, low, index, high)
|
||||
else:
|
||||
# XXX: if we don't do it renders a weird rectangle?
|
||||
# see below for filtering this later...
|
||||
hl = None
|
||||
|
||||
# NOTE: place the x-coord start as "middle" of the drawing range such
|
||||
# that the open arm line-graphic is at the left-most-side of
|
||||
# the index's range according to the view mapping.
|
||||
|
||||
# open line
|
||||
o = QLineF(index - w, open, index, open)
|
||||
# close line
|
||||
c = QLineF(index, close, index + w, close)
|
||||
|
||||
return [hl, o, c]
|
||||
|
||||
|
||||
@jit(
|
||||
# TODO: for now need to construct this manually for readonly arrays, see
|
||||
# https://github.com/numba/numba/issues/4511
|
||||
# ntypes.Tuple((float64[:], float64[:], float64[:]))(
|
||||
# numba_ohlc_dtype[::1], # contiguous
|
||||
# int64,
|
||||
# optional(float64),
|
||||
# ),
|
||||
nopython=True,
|
||||
nogil=True
|
||||
)
|
||||
def path_arrays_from_ohlc(
|
||||
data: np.ndarray,
|
||||
start: int64,
|
||||
bar_gap: float64 = 0.43,
|
||||
) -> np.ndarray:
|
||||
"""Generate an array of lines objects from input ohlc data.
|
||||
|
||||
"""
|
||||
size = int(data.shape[0] * 6)
|
||||
|
||||
x = np.zeros(
|
||||
# data,
|
||||
shape=size,
|
||||
dtype=float64,
|
||||
)
|
||||
y, c = x.copy(), x.copy()
|
||||
|
||||
# TODO: report bug for assert @
|
||||
# /home/goodboy/repos/piker/env/lib/python3.8/site-packages/numba/core/typing/builtins.py:991
|
||||
for i, q in enumerate(data[start:], start):
|
||||
|
||||
# TODO: ask numba why this doesn't work..
|
||||
# open, high, low, close, index = q[
|
||||
# ['open', 'high', 'low', 'close', 'index']]
|
||||
|
||||
open = q['open']
|
||||
high = q['high']
|
||||
low = q['low']
|
||||
close = q['close']
|
||||
index = float64(q['index'])
|
||||
|
||||
istart = i * 6
|
||||
istop = istart + 6
|
||||
|
||||
# x,y detail the 6 points which connect all vertexes of a ohlc bar
|
||||
x[istart:istop] = (
|
||||
index - bar_gap,
|
||||
index,
|
||||
index,
|
||||
index,
|
||||
index,
|
||||
index + bar_gap,
|
||||
)
|
||||
y[istart:istop] = (
|
||||
open,
|
||||
open,
|
||||
low,
|
||||
high,
|
||||
close,
|
||||
close,
|
||||
)
|
||||
|
||||
# specifies that the first edge is never connected to the
|
||||
# prior bars last edge thus providing a small "gap"/"space"
|
||||
# between bars determined by ``bar_gap``.
|
||||
c[istart:istop] = (0, 1, 1, 1, 1, 1)
|
||||
|
||||
return x, y, c
|
||||
|
||||
|
||||
def gen_qpath(
|
||||
data,
|
||||
start, # XXX: do we need this?
|
||||
w,
|
||||
) -> QtGui.QPainterPath:
|
||||
|
||||
profiler = pg.debug.Profiler(disabled=not pg_profile_enabled())
|
||||
|
||||
x, y, c = path_arrays_from_ohlc(data, start, bar_gap=w)
|
||||
profiler("generate stream with numba")
|
||||
|
||||
# TODO: numba the internals of this!
|
||||
path = pg.functions.arrayToQPath(x, y, connect=c)
|
||||
profiler("generate path with arrayToQPath")
|
||||
|
||||
return path
|
||||
|
||||
|
||||
class BarItems(pg.GraphicsObject):
|
||||
"""Price range bars graphics rendered from a OHLC sequence.
|
||||
"""
|
||||
sigPlotChanged = QtCore.Signal(object)
|
||||
|
||||
# 0.5 is no overlap between arms, 1.0 is full overlap
|
||||
w: float = 0.43
|
||||
|
||||
# XXX: for the mega-lulz increasing width here increases draw latency...
|
||||
# so probably don't do it until we figure that out.
|
||||
bars_pen = pg.mkPen(hcolor('bracket'))
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
# scene: 'QGraphicsScene', # noqa
|
||||
plotitem: 'pg.PlotItem', # noqa
|
||||
) -> None:
|
||||
super().__init__()
|
||||
|
||||
# NOTE: this prevents redraws on mouse interaction which is
|
||||
# a huge boon for avg interaction latency.
|
||||
|
||||
# TODO: one question still remaining is if this makes trasform
|
||||
# interactions slower (such as zooming) and if so maybe if/when
|
||||
# we implement a "history" mode for the view we disable this in
|
||||
# that mode?
|
||||
self.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
|
||||
|
||||
# not sure if this is actually impoving anything but figured it
|
||||
# was worth a shot:
|
||||
# self.path.reserve(int(100e3 * 6))
|
||||
|
||||
self.path = QtGui.QPainterPath()
|
||||
|
||||
self._pi = plotitem
|
||||
|
||||
self._xrange: Tuple[int, int]
|
||||
self._yrange: Tuple[float, float]
|
||||
|
||||
# TODO: don't render the full backing array each time
|
||||
# self._path_data = None
|
||||
self._last_bar_lines: Optional[Tuple[QLineF, ...]] = None
|
||||
|
||||
# track the current length of drawable lines within the larger array
|
||||
self.start_index: int = 0
|
||||
self.stop_index: int = 0
|
||||
|
||||
def draw_from_data(
|
||||
self,
|
||||
data: np.ndarray,
|
||||
start: int = 0,
|
||||
) -> QtGui.QPainterPath:
|
||||
"""Draw OHLC datum graphics from a ``np.ndarray``.
|
||||
|
||||
This routine is usually only called to draw the initial history.
|
||||
"""
|
||||
self.path = gen_qpath(data, start, self.w)
|
||||
|
||||
# save graphics for later reference and keep track
|
||||
# of current internal "last index"
|
||||
# self.start_index = len(data)
|
||||
index = data['index']
|
||||
self._xrange = (index[0], index[-1])
|
||||
self._yrange = (
|
||||
np.nanmax(data['high']),
|
||||
np.nanmin(data['low']),
|
||||
)
|
||||
|
||||
# up to last to avoid double draw of last bar
|
||||
self._last_bar_lines = lines_from_ohlc(data[-1], self.w)
|
||||
|
||||
# trigger render
|
||||
# https://doc.qt.io/qt-5/qgraphicsitem.html#update
|
||||
self.update()
|
||||
|
||||
return self.path
|
||||
|
||||
def update_from_array(
|
||||
self,
|
||||
array: np.ndarray,
|
||||
just_history=False,
|
||||
) -> None:
|
||||
"""Update the last datum's bar graphic from input data array.
|
||||
|
||||
This routine should be interface compatible with
|
||||
``pg.PlotCurveItem.setData()``. Normally this method in
|
||||
``pyqtgraph`` seems to update all the data passed to the
|
||||
graphics object, and then update/rerender, but here we're
|
||||
assuming the prior graphics havent changed (OHLC history rarely
|
||||
does) so this "should" be simpler and faster.
|
||||
|
||||
This routine should be made (transitively) as fast as possible.
|
||||
"""
|
||||
# index = self.start_index
|
||||
istart, istop = self._xrange
|
||||
|
||||
index = array['index']
|
||||
first_index, last_index = index[0], index[-1]
|
||||
|
||||
# length = len(array)
|
||||
prepend_length = istart - first_index
|
||||
append_length = last_index - istop
|
||||
|
||||
flip_cache = False
|
||||
|
||||
# TODO: allow mapping only a range of lines thus
|
||||
# only drawing as many bars as exactly specified.
|
||||
|
||||
if prepend_length:
|
||||
|
||||
# new history was added and we need to render a new path
|
||||
new_bars = array[:prepend_length]
|
||||
prepend_path = gen_qpath(new_bars, 0, self.w)
|
||||
|
||||
# XXX: SOMETHING IS MAYBE FISHY HERE what with the old_path
|
||||
# y value not matching the first value from
|
||||
# array[prepend_length + 1] ???
|
||||
|
||||
# update path
|
||||
old_path = self.path
|
||||
self.path = prepend_path
|
||||
self.path.addPath(old_path)
|
||||
|
||||
# trigger redraw despite caching
|
||||
self.prepareGeometryChange()
|
||||
|
||||
if append_length:
|
||||
# generate new lines objects for updatable "current bar"
|
||||
self._last_bar_lines = lines_from_ohlc(array[-1], self.w)
|
||||
|
||||
# generate new graphics to match provided array
|
||||
# path appending logic:
|
||||
# we need to get the previous "current bar(s)" for the time step
|
||||
# and convert it to a sub-path to append to the historical set
|
||||
# new_bars = array[istop - 1:istop + append_length - 1]
|
||||
new_bars = array[-append_length - 1:-1]
|
||||
append_path = gen_qpath(new_bars, 0, self.w)
|
||||
self.path.moveTo(float(istop - self.w), float(new_bars[0]['open']))
|
||||
self.path.addPath(append_path)
|
||||
|
||||
# trigger redraw despite caching
|
||||
self.prepareGeometryChange()
|
||||
self.setCacheMode(QtGui.QGraphicsItem.NoCache)
|
||||
flip_cache = True
|
||||
|
||||
self._xrange = first_index, last_index
|
||||
|
||||
# last bar update
|
||||
i, o, h, l, last, v = array[-1][
|
||||
['index', 'open', 'high', 'low', 'close', 'volume']
|
||||
]
|
||||
# assert i == self.start_index - 1
|
||||
assert i == last_index
|
||||
body, larm, rarm = self._last_bar_lines
|
||||
|
||||
# XXX: is there a faster way to modify this?
|
||||
rarm.setLine(rarm.x1(), last, rarm.x2(), last)
|
||||
# writer is responsible for changing open on "first" volume of bar
|
||||
larm.setLine(larm.x1(), o, larm.x2(), o)
|
||||
|
||||
if l != h: # noqa
|
||||
if body is None:
|
||||
body = self._last_bar_lines[0] = QLineF(i, l, i, h)
|
||||
else:
|
||||
# update body
|
||||
body.setLine(i, l, i, h)
|
||||
|
||||
# XXX: pretty sure this is causing an issue where the bar has
|
||||
# a large upward move right before the next sample and the body
|
||||
# is getting set to None since the next bar is flat but the shm
|
||||
# array index update wasn't read by the time this code runs. Iow
|
||||
# we're doing this removal of the body for a bar index that is
|
||||
# now out of date / from some previous sample. It's weird
|
||||
# though because i've seen it do this to bars i - 3 back?
|
||||
|
||||
self.update()
|
||||
|
||||
if flip_cache:
|
||||
self.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
|
||||
|
||||
def paint(
|
||||
self,
|
||||
p: QtGui.QPainter,
|
||||
opt: QtWidgets.QStyleOptionGraphicsItem,
|
||||
w: QtWidgets.QWidget
|
||||
) -> None:
|
||||
|
||||
profiler = pg.debug.Profiler(disabled=not pg_profile_enabled())
|
||||
|
||||
# p.setCompositionMode(0)
|
||||
p.setPen(self.bars_pen)
|
||||
|
||||
# TODO: one thing we could try here is pictures being drawn of
|
||||
# a fixed count of bars such that based on the viewbox indices we
|
||||
# only draw the "rounded up" number of "pictures worth" of bars
|
||||
# as is necesarry for what's in "view". Not sure if this will
|
||||
# lead to any perf gains other then when zoomed in to less bars
|
||||
# in view.
|
||||
p.drawLines(*tuple(filter(bool, self._last_bar_lines)))
|
||||
profiler('draw last bar')
|
||||
|
||||
p.drawPath(self.path)
|
||||
profiler('draw history path')
|
||||
|
||||
def boundingRect(self):
|
||||
# Qt docs: https://doc.qt.io/qt-5/qgraphicsitem.html#boundingRect
|
||||
|
||||
# TODO: Can we do rect caching to make this faster
|
||||
# like `pg.PlotCurveItem` does? In theory it's just
|
||||
# computing max/min stuff again like we do in the udpate loop
|
||||
# anyway. Not really sure it's necessary since profiling already
|
||||
# shows this method is faf.
|
||||
|
||||
# boundingRect _must_ indicate the entire area that will be
|
||||
# drawn on or else we will get artifacts and possibly crashing.
|
||||
# (in this case, QPicture does all the work of computing the
|
||||
# bounding rect for us).
|
||||
|
||||
# apparently this a lot faster says the docs?
|
||||
# https://doc.qt.io/qt-5/qpainterpath.html#controlPointRect
|
||||
hb = self.path.controlPointRect()
|
||||
hb_size = hb.size()
|
||||
# print(f'hb_size: {hb_size}')
|
||||
|
||||
w = hb_size.width() + 1
|
||||
h = hb_size.height() + 1
|
||||
|
||||
br = QtCore.QRectF(
|
||||
|
||||
# top left
|
||||
QPointF(hb.topLeft()),
|
||||
|
||||
# total size
|
||||
QtCore.QSizeF(w, h)
|
||||
)
|
||||
# print(f'bounding rect: {br}')
|
||||
return br
|
||||
|
||||
|
||||
# XXX: when we get back to enabling tina mode for xb
|
||||
# class CandlestickItems(BarItems):
|
||||
|
||||
# w2 = 0.7
|
||||
# line_pen = pg.mkPen('#000000')
|
||||
# bull_brush = pg.mkBrush('#00ff00')
|
||||
# bear_brush = pg.mkBrush('#ff0000')
|
||||
|
||||
# def _generate(self, p):
|
||||
# rects = np.array(
|
||||
# [
|
||||
# QtCore.QRectF(
|
||||
# q.id - self.w,
|
||||
# q.open,
|
||||
# self.w2,
|
||||
# q.close - q.open
|
||||
# )
|
||||
# for q in Quotes
|
||||
# ]
|
||||
# )
|
||||
|
||||
# p.setPen(self.line_pen)
|
||||
# p.drawLines(
|
||||
# [QtCore.QLineF(q.id, q.low, q.id, q.high)
|
||||
# for q in Quotes]
|
||||
# )
|
||||
|
||||
# p.setBrush(self.bull_brush)
|
||||
# p.drawRects(*rects[Quotes.close > Quotes.open])
|
||||
|
||||
# p.setBrush(self.bear_brush)
|
||||
# p.drawRects(*rects[Quotes.close < Quotes.open])
|
|
@ -112,8 +112,6 @@ CHART_MARGINS = (0, 0, 2, 2)
|
|||
_min_points_to_show = 6
|
||||
_bars_from_right_in_follow_mode = int(6**2)
|
||||
_bars_to_left_in_follow_mode = int(6**3)
|
||||
|
||||
|
||||
_tina_mode = False
|
||||
|
||||
|
||||
|
|
|
@ -84,7 +84,7 @@ def monitor(config, rate, name, dhost, test, tl):
|
|||
|
||||
|
||||
@cli.command()
|
||||
@click.option('--tl', is_flag=True, help='Enable tractor logging')
|
||||
# @click.option('--tl', is_flag=True, help='Enable tractor logging')
|
||||
@click.option('--date', '-d', help='Contracts expiry date')
|
||||
@click.option('--test', '-t', help='Test quote stream file')
|
||||
@click.option('--rate', '-r', default=1, help='Logging level')
|
||||
|
@ -121,16 +121,25 @@ def optschain(config, symbol, date, tl, rate, test):
|
|||
|
||||
|
||||
@cli.command()
|
||||
@click.option(
|
||||
'--profile',
|
||||
is_flag=True,
|
||||
help='Enable pyqtgraph profiling'
|
||||
)
|
||||
@click.option('--date', '-d', help='Contracts expiry date')
|
||||
@click.option('--test', '-t', help='Test quote stream file')
|
||||
@click.option('--rate', '-r', default=1, help='Logging level')
|
||||
@click.argument('symbol', required=True)
|
||||
@click.pass_obj
|
||||
def chart(config, symbol, date, rate, test):
|
||||
"""Start an option chain UI
|
||||
def chart(config, symbol, date, rate, test, profile):
|
||||
"""Start a real-time chartng UI
|
||||
"""
|
||||
from .. import _profile
|
||||
from ._chart import _main
|
||||
|
||||
# possibly enable profiling
|
||||
_profile._pg_profile = profile
|
||||
|
||||
# global opts
|
||||
brokername = config['broker']
|
||||
tractorloglevel = config['tractorloglevel']
|
||||
|
|
Loading…
Reference in New Issue