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.
|
# 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
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
"""
|
"""
|
||||||
piker: trading gear for hackers.
|
piker: trading gear for hackers.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import msgpack # noqa
|
import msgpack # noqa
|
||||||
import msgpack_numpy
|
import msgpack_numpy
|
||||||
|
|
|
@ -16,10 +16,18 @@
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Profiling wrappers for internal libs.
|
Profiling wrappers for internal libs.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
|
_pg_profile: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def pg_profile_enabled() -> bool:
|
||||||
|
global _pg_profile
|
||||||
|
return _pg_profile
|
||||||
|
|
||||||
|
|
||||||
def timeit(fn):
|
def timeit(fn):
|
||||||
@wraps(fn)
|
@wraps(fn)
|
||||||
|
|
|
@ -622,8 +622,7 @@ async def fill_bars(
|
||||||
sym: str,
|
sym: str,
|
||||||
first_bars: list,
|
first_bars: list,
|
||||||
shm: 'ShmArray', # type: ignore # noqa
|
shm: 'ShmArray', # type: ignore # noqa
|
||||||
count: int = 21,
|
count: int = 21, # NOTE: any more and we'll overrun the underlying buffer
|
||||||
# count: int = 1,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Fill historical bars into shared mem / storage afap.
|
"""Fill historical bars into shared mem / storage afap.
|
||||||
|
|
||||||
|
|
|
@ -22,7 +22,7 @@ from typing import List, Tuple, Optional
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import pyqtgraph as pg
|
import pyqtgraph as pg
|
||||||
from PyQt5 import QtCore, QtGui
|
from PyQt5 import QtCore, QtGui, QtWidgets
|
||||||
from PyQt5.QtCore import QPointF
|
from PyQt5.QtCore import QPointF
|
||||||
|
|
||||||
from ._style import DpiAwareFont, hcolor, _font
|
from ._style import DpiAwareFont, hcolor, _font
|
||||||
|
@ -44,6 +44,10 @@ class Axis(pg.AxisItem):
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
|
# XXX: pretty sure this makes things slower
|
||||||
|
# self.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
|
||||||
|
|
||||||
self.linked_charts = linked_charts
|
self.linked_charts = linked_charts
|
||||||
self._min_tick = min_tick
|
self._min_tick = min_tick
|
||||||
|
|
||||||
|
@ -158,9 +162,12 @@ class AxisLabel(pg.GraphicsObject):
|
||||||
fg_color: str = 'black',
|
fg_color: str = 'black',
|
||||||
opacity: int = 0,
|
opacity: int = 0,
|
||||||
font_size_inches: Optional[float] = None,
|
font_size_inches: Optional[float] = None,
|
||||||
):
|
) -> None:
|
||||||
|
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setFlag(self.ItemIgnoresTransformations)
|
self.setFlag(self.ItemIgnoresTransformations)
|
||||||
|
# XXX: pretty sure this is faster
|
||||||
|
self.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
|
||||||
|
|
||||||
self.parent = parent
|
self.parent = parent
|
||||||
self.opacity = opacity
|
self.opacity = opacity
|
||||||
|
@ -177,7 +184,12 @@ class AxisLabel(pg.GraphicsObject):
|
||||||
|
|
||||||
self.rect = None
|
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)
|
# p.setCompositionMode(QtGui.QPainter.CompositionMode_SourceOver)
|
||||||
|
|
||||||
if self.label_str:
|
if self.label_str:
|
||||||
|
|
|
@ -30,13 +30,16 @@ from ._axes import (
|
||||||
DynamicDateAxis,
|
DynamicDateAxis,
|
||||||
PriceAxis,
|
PriceAxis,
|
||||||
)
|
)
|
||||||
from ._graphics import (
|
from ._graphics._cursor import (
|
||||||
CrossHair,
|
CrossHair,
|
||||||
ContentsLabel,
|
ContentsLabel,
|
||||||
BarItems,
|
)
|
||||||
|
from ._graphics._lines import (
|
||||||
level_line,
|
level_line,
|
||||||
L1Labels,
|
L1Labels,
|
||||||
)
|
)
|
||||||
|
from ._graphics._ohlc import BarItems
|
||||||
|
from ._graphics._curve import FastAppendCurve
|
||||||
from ._axes import YSticky
|
from ._axes import YSticky
|
||||||
from ._style import (
|
from ._style import (
|
||||||
_font,
|
_font,
|
||||||
|
@ -550,20 +553,37 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
}
|
}
|
||||||
pdi_kwargs.update(_pdi_defaults)
|
pdi_kwargs.update(_pdi_defaults)
|
||||||
|
|
||||||
curve = pg.PlotDataItem(
|
# curve = pg.PlotDataItem(
|
||||||
|
# curve = pg.PlotCurveItem(
|
||||||
|
curve = FastAppendCurve(
|
||||||
y=data[name],
|
y=data[name],
|
||||||
x=data['index'],
|
x=data['index'],
|
||||||
# antialias=True,
|
# antialias=True,
|
||||||
name=name,
|
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
|
# TODO: see how this handles with custom ohlcv bars graphics
|
||||||
# and/or if we can implement something similar for OHLC graphics
|
# and/or if we can implement something similar for OHLC graphics
|
||||||
# clipToView=True,
|
# autoDownsample=True,
|
||||||
autoDownsample=True,
|
# downsample=60,
|
||||||
downsampleMethod='subsample',
|
# downsampleMethod='subsample',
|
||||||
|
|
||||||
**pdi_kwargs,
|
**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)
|
self.addItem(curve)
|
||||||
|
|
||||||
# register curve graphics and backing array for name
|
# register curve graphics and backing array for name
|
||||||
|
@ -647,6 +667,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
"""Update the named internal graphics from ``array``.
|
"""Update the named internal graphics from ``array``.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if name not in self._overlays:
|
if name not in self._overlays:
|
||||||
self._ohlc = array
|
self._ohlc = array
|
||||||
else:
|
else:
|
||||||
|
@ -654,11 +675,13 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
|
|
||||||
curve = self._graphics[name]
|
curve = self._graphics[name]
|
||||||
|
|
||||||
|
if len(array):
|
||||||
# TODO: we should instead implement a diff based
|
# TODO: we should instead implement a diff based
|
||||||
# "only update with new items" on the pg.PlotCurveItem
|
# "only update with new items" on the pg.PlotCurveItem
|
||||||
# one place to dig around this might be the `QBackingStore`
|
# one place to dig around this might be the `QBackingStore`
|
||||||
# https://doc.qt.io/qt-5/qbackingstore.html
|
# 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
|
return curve
|
||||||
|
|
||||||
|
@ -689,13 +712,15 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
# figure out x-range in view such that user can scroll "off"
|
# figure out x-range in view such that user can scroll "off"
|
||||||
# the data set up to the point where ``_min_points_to_show``
|
# the data set up to the point where ``_min_points_to_show``
|
||||||
# are left.
|
# are left.
|
||||||
view_len = r - l
|
# view_len = r - l
|
||||||
|
|
||||||
# TODO: logic to check if end of bars in view
|
# TODO: logic to check if end of bars in view
|
||||||
extra = view_len - _min_points_to_show
|
# extra = view_len - _min_points_to_show
|
||||||
begin = self._ohlc[0]['index'] - extra
|
|
||||||
# end = len(self._ohlc) - 1 + extra
|
# begin = self._ohlc[0]['index'] - extra
|
||||||
end = self._ohlc[-1]['index'] - 1 + 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.
|
# 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
|
# 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
|
# current bar) and then either write the current bar manually
|
||||||
# or place a cursor for visual cue of the current time step.
|
# or place a cursor for visual cue of the current time step.
|
||||||
|
|
||||||
price_chart.update_ohlc_from_array(
|
# price_chart.update_ohlc_from_array(
|
||||||
price_chart.name,
|
# price_chart.name,
|
||||||
ohlcv.array,
|
# ohlcv.array,
|
||||||
just_history=True,
|
# just_history=True,
|
||||||
)
|
# )
|
||||||
|
|
||||||
# XXX: this puts a flat bar on the current time step
|
# XXX: this puts a flat bar on the current time step
|
||||||
# TODO: if we eventually have an x-axis time-step "cursor"
|
# TODO: if we eventually have an x-axis time-step "cursor"
|
||||||
|
|
|
@ -83,8 +83,7 @@ class MainWindow(QtGui.QMainWindow):
|
||||||
"""Cancel the root actor asap.
|
"""Cancel the root actor asap.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
# raising KBI seems to get intercepted by by Qt so just use the
|
# raising KBI seems to get intercepted by by Qt so just use the system.
|
||||||
# system.
|
|
||||||
os.kill(os.getpid(), signal.SIGINT)
|
os.kill(os.getpid(), signal.SIGINT)
|
||||||
|
|
||||||
|
|
||||||
|
@ -160,20 +159,20 @@ def run_qtractor(
|
||||||
'main': instance,
|
'main': instance,
|
||||||
}
|
}
|
||||||
|
|
||||||
# setup tractor entry point args
|
# define tractor entrypoint
|
||||||
main = partial(
|
async def main():
|
||||||
tractor._main,
|
|
||||||
async_fn=func,
|
async with tractor.open_root_actor(
|
||||||
args=args + (widgets,),
|
|
||||||
arbiter_addr=(
|
arbiter_addr=(
|
||||||
tractor._default_arbiter_host,
|
tractor._root._default_arbiter_host,
|
||||||
tractor._default_arbiter_port,
|
tractor._root._default_arbiter_port,
|
||||||
),
|
),
|
||||||
name='qtractor',
|
name='qtractor',
|
||||||
**tractor_kwargs,
|
**tractor_kwargs,
|
||||||
)
|
) as a:
|
||||||
|
await func(*(args + (widgets,)))
|
||||||
|
|
||||||
# guest mode
|
# guest mode entry
|
||||||
trio.lowlevel.start_guest_run(
|
trio.lowlevel.start_guest_run(
|
||||||
main,
|
main,
|
||||||
run_sync_soon_threadsafe=run_sync_soon_threadsafe,
|
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
|
_min_points_to_show = 6
|
||||||
_bars_from_right_in_follow_mode = int(6**2)
|
_bars_from_right_in_follow_mode = int(6**2)
|
||||||
_bars_to_left_in_follow_mode = int(6**3)
|
_bars_to_left_in_follow_mode = int(6**3)
|
||||||
|
|
||||||
|
|
||||||
_tina_mode = False
|
_tina_mode = False
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -84,7 +84,7 @@ def monitor(config, rate, name, dhost, test, tl):
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@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('--date', '-d', help='Contracts expiry date')
|
||||||
@click.option('--test', '-t', help='Test quote stream file')
|
@click.option('--test', '-t', help='Test quote stream file')
|
||||||
@click.option('--rate', '-r', default=1, help='Logging level')
|
@click.option('--rate', '-r', default=1, help='Logging level')
|
||||||
|
@ -121,16 +121,25 @@ def optschain(config, symbol, date, tl, rate, test):
|
||||||
|
|
||||||
|
|
||||||
@cli.command()
|
@cli.command()
|
||||||
|
@click.option(
|
||||||
|
'--profile',
|
||||||
|
is_flag=True,
|
||||||
|
help='Enable pyqtgraph profiling'
|
||||||
|
)
|
||||||
@click.option('--date', '-d', help='Contracts expiry date')
|
@click.option('--date', '-d', help='Contracts expiry date')
|
||||||
@click.option('--test', '-t', help='Test quote stream file')
|
@click.option('--test', '-t', help='Test quote stream file')
|
||||||
@click.option('--rate', '-r', default=1, help='Logging level')
|
@click.option('--rate', '-r', default=1, help='Logging level')
|
||||||
@click.argument('symbol', required=True)
|
@click.argument('symbol', required=True)
|
||||||
@click.pass_obj
|
@click.pass_obj
|
||||||
def chart(config, symbol, date, rate, test):
|
def chart(config, symbol, date, rate, test, profile):
|
||||||
"""Start an option chain UI
|
"""Start a real-time chartng UI
|
||||||
"""
|
"""
|
||||||
|
from .. import _profile
|
||||||
from ._chart import _main
|
from ._chart import _main
|
||||||
|
|
||||||
|
# possibly enable profiling
|
||||||
|
_profile._pg_profile = profile
|
||||||
|
|
||||||
# global opts
|
# global opts
|
||||||
brokername = config['broker']
|
brokername = config['broker']
|
||||||
tractorloglevel = config['tractorloglevel']
|
tractorloglevel = config['tractorloglevel']
|
||||||
|
|
Loading…
Reference in New Issue