Merge pull request #490 from pikers/log_linearized_curve_overlays

Log linearized curve overlays
binance_ws_ep_update
goodboy 2023-03-13 15:32:42 -04:00 committed by GitHub
commit f3b04f27e6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 1989 additions and 682 deletions

View File

@ -295,7 +295,7 @@ def slice_from_time(
arr: np.ndarray, arr: np.ndarray,
start_t: float, start_t: float,
stop_t: float, stop_t: float,
step: int | None = None, step: float, # sampler period step-diff
) -> slice: ) -> slice:
''' '''
@ -324,12 +324,6 @@ def slice_from_time(
# end of the input array. # end of the input array.
read_i_max = arr.shape[0] read_i_max = arr.shape[0]
# TODO: require this is always passed in?
if step is None:
step = round(t_last - times[-2])
if step == 0:
step = 1
# compute (presumed) uniform-time-step index offsets # compute (presumed) uniform-time-step index offsets
i_start_t = floor(start_t) i_start_t = floor(start_t)
read_i_start = floor(((i_start_t - t_first) // step)) - 1 read_i_start = floor(((i_start_t - t_first) // step)) - 1
@ -395,7 +389,7 @@ def slice_from_time(
# f'diff: {t_diff}\n' # f'diff: {t_diff}\n'
# f'REMAPPED START i: {read_i_start} -> {new_read_i_start}\n' # f'REMAPPED START i: {read_i_start} -> {new_read_i_start}\n'
# ) # )
read_i_start = new_read_i_start - 1 read_i_start = new_read_i_start
t_iv_stop = times[read_i_stop - 1] t_iv_stop = times[read_i_stop - 1]
if ( if (
@ -412,7 +406,7 @@ def slice_from_time(
times[read_i_start:], times[read_i_start:],
# times, # times,
i_stop_t, i_stop_t,
side='left', side='right',
) )
if ( if (

View File

@ -87,7 +87,6 @@ class Sampler:
# holds all the ``tractor.Context`` remote subscriptions for # holds all the ``tractor.Context`` remote subscriptions for
# a particular sample period increment event: all subscribers are # a particular sample period increment event: all subscribers are
# notified on a step. # notified on a step.
# subscribers: dict[int, list[tractor.MsgStream]] = {}
subscribers: defaultdict[ subscribers: defaultdict[
float, float,
list[ list[
@ -240,8 +239,11 @@ class Sampler:
subscribers for a given sample period. subscribers for a given sample period.
''' '''
pair: list[float, set]
pair = self.subscribers[period_s] pair = self.subscribers[period_s]
last_ts: float
subs: set
last_ts, subs = pair last_ts, subs = pair
task = trio.lowlevel.current_task() task = trio.lowlevel.current_task()
@ -253,12 +255,17 @@ class Sampler:
# f'consumers: {subs}' # f'consumers: {subs}'
) )
borked: set[tractor.MsgStream] = set() borked: set[tractor.MsgStream] = set()
for stream in subs: sent: set[tractor.MsgStream] = set()
while True:
try:
for stream in (subs - sent):
try: try:
await stream.send({ await stream.send({
'index': time_stamp or last_ts, 'index': time_stamp or last_ts,
'period': period_s, 'period': period_s,
}) })
sent.add(stream)
except ( except (
trio.BrokenResourceError, trio.BrokenResourceError,
trio.ClosedResourceError trio.ClosedResourceError
@ -267,11 +274,16 @@ class Sampler:
f'{stream._ctx.chan.uid} dropped connection' f'{stream._ctx.chan.uid} dropped connection'
) )
borked.add(stream) borked.add(stream)
else:
break
except RuntimeError:
log.warning(f'Client subs {subs} changed while broadcasting')
continue
for stream in borked: for stream in borked:
try: try:
subs.remove(stream) subs.remove(stream)
except ValueError: except KeyError:
log.warning( log.warning(
f'{stream._ctx.chan.uid} sub already removed!?' f'{stream._ctx.chan.uid} sub already removed!?'
) )
@ -419,7 +431,7 @@ async def maybe_open_samplerd(
loglevel: str | None = None, loglevel: str | None = None,
**kwargs, **kwargs,
) -> tractor._portal.Portal: # noqa ) -> tractor.Portal: # noqa
''' '''
Client-side helper to maybe startup the ``samplerd`` service Client-side helper to maybe startup the ``samplerd`` service
under the ``pikerd`` tree. under the ``pikerd`` tree.
@ -609,6 +621,14 @@ async def sample_and_broadcast(
fqsn = f'{broker_symbol}.{brokername}' fqsn = f'{broker_symbol}.{brokername}'
lags: int = 0 lags: int = 0
# TODO: speed up this loop in an AOT compiled lang (like
# rust or nim or zig) and/or instead of doing a fan out to
# TCP sockets here, we add a shm-style tick queue which
# readers can pull from instead of placing the burden of
# broadcast on solely on this `brokerd` actor. see issues:
# - https://github.com/pikers/piker/issues/98
# - https://github.com/pikers/piker/issues/107
for (stream, tick_throttle) in subs.copy(): for (stream, tick_throttle) in subs.copy():
try: try:
with trio.move_on_after(0.2) as cs: with trio.move_on_after(0.2) as cs:
@ -738,9 +758,6 @@ def frame_ticks(
ticks_by_type[ttype].append(tick) ticks_by_type[ttype].append(tick)
# TODO: a less naive throttler, here's some snippets:
# token bucket by njs:
# https://gist.github.com/njsmith/7ea44ec07e901cb78ebe1dd8dd846cb9
async def uniform_rate_send( async def uniform_rate_send(
rate: float, rate: float,
@ -750,8 +767,22 @@ async def uniform_rate_send(
task_status: TaskStatus = trio.TASK_STATUS_IGNORED, task_status: TaskStatus = trio.TASK_STATUS_IGNORED,
) -> None: ) -> None:
'''
Throttle a real-time (presumably tick event) stream to a uniform
transmissiom rate, normally for the purposes of throttling a data
flow being consumed by a graphics rendering actor which itself is limited
by a fixed maximum display rate.
# try not to error-out on overruns of the subscribed (chart) client Though this function isn't documented (nor was intentially written
to be) a token-bucket style algo, it effectively operates as one (we
think?).
TODO: a less naive throttler, here's some snippets:
token bucket by njs:
https://gist.github.com/njsmith/7ea44ec07e901cb78ebe1dd8dd846cb9
'''
# try not to error-out on overruns of the subscribed client
stream._ctx._backpressure = True stream._ctx._backpressure = True
# TODO: compute the approx overhead latency per cycle # TODO: compute the approx overhead latency per cycle
@ -848,6 +879,16 @@ async def uniform_rate_send(
# rate timing exactly lul # rate timing exactly lul
try: try:
await stream.send({sym: first_quote}) await stream.send({sym: first_quote})
except tractor.RemoteActorError as rme:
if rme.type is not tractor._exceptions.StreamOverrun:
raise
ctx = stream._ctx
chan = ctx.chan
log.warning(
'Throttled quote-stream overrun!\n'
f'{sym}:{ctx.cid}@{chan.uid}'
)
except ( except (
# NOTE: any of these can be raised by ``tractor``'s IPC # NOTE: any of these can be raised by ``tractor``'s IPC
# transport-layer and we want to be highly resilient # transport-layer and we want to be highly resilient

View File

@ -1589,6 +1589,9 @@ async def open_feed(
(brokermod, bfqsns), (brokermod, bfqsns),
) in zip(ctxs, providers.items()): ) in zip(ctxs, providers.items()):
# NOTE: do it asap to avoid overruns during multi-feed setup?
ctx._backpressure = backpressure
for fqsn, flume_msg in flumes_msg_dict.items(): for fqsn, flume_msg in flumes_msg_dict.items():
flume = Flume.from_msg(flume_msg) flume = Flume.from_msg(flume_msg)
assert flume.symbol.fqsn == fqsn assert flume.symbol.fqsn == fqsn

View File

@ -18,7 +18,7 @@
Annotations for ur faces. Annotations for ur faces.
""" """
from typing import Callable, Optional from typing import Callable
from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtCore import QPointF, QRectF from PyQt5.QtCore import QPointF, QRectF
@ -105,7 +105,7 @@ class LevelMarker(QGraphicsPathItem):
get_level: Callable[..., float], get_level: Callable[..., float],
size: float = 20, size: float = 20,
keep_in_view: bool = True, keep_in_view: bool = True,
on_paint: Optional[Callable] = None, on_paint: Callable | None = None,
) -> None: ) -> None:

View File

@ -1,5 +1,5 @@
# piker: trading gear for hackers # piker: trading gear for hackers
# Copyright (C) Tyler Goodlet (in stewardship for piker0) # Copyright (C) Tyler Goodlet (in stewardship for pikers)
# 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
@ -20,7 +20,7 @@ Chart axes graphics and behavior.
""" """
from __future__ import annotations from __future__ import annotations
from functools import lru_cache from functools import lru_cache
from typing import Optional, Callable from typing import Callable
from math import floor from math import floor
import numpy as np import numpy as np
@ -60,7 +60,8 @@ class Axis(pg.AxisItem):
**kwargs **kwargs
) )
# XXX: pretty sure this makes things slower # XXX: pretty sure this makes things slower!
# no idea why given we only move labels for the most part?
# self.setCacheMode(QtWidgets.QGraphicsItem.DeviceCoordinateCache) # self.setCacheMode(QtWidgets.QGraphicsItem.DeviceCoordinateCache)
self.pi = plotitem self.pi = plotitem
@ -190,7 +191,7 @@ class PriceAxis(Axis):
*args, *args,
min_tick: int = 2, min_tick: int = 2,
title: str = '', title: str = '',
formatter: Optional[Callable[[float], str]] = None, formatter: Callable[[float], str] | None = None,
**kwargs **kwargs
) -> None: ) -> None:
@ -202,8 +203,8 @@ class PriceAxis(Axis):
def set_title( def set_title(
self, self,
title: str, title: str,
view: Optional[ChartView] = None, view: ChartView | None = None,
color: Optional[str] = None, color: str | None = None,
) -> Label: ) -> Label:
''' '''
@ -303,8 +304,9 @@ class DynamicDateAxis(Axis):
viz = chart._vizs[chart.name] viz = chart._vizs[chart.name]
shm = viz.shm shm = viz.shm
array = shm.array array = shm.array
times = array['time'] ifield = viz.index_field
i_0, i_l = times[0], times[-1] index = array[ifield]
i_0, i_l = index[0], index[-1]
# edge cases # edge cases
if ( if (
@ -316,11 +318,13 @@ class DynamicDateAxis(Axis):
(indexes[0] > i_0 (indexes[0] > i_0
and indexes[-1] > i_l) and indexes[-1] > i_l)
): ):
# print(f"x-label indexes empty edge case: {indexes}")
return [] return []
if viz.index_field == 'index': if ifield == 'index':
arr_len = times.shape[0] arr_len = index.shape[0]
first = shm._first.value first = shm._first.value
times = array['time']
epochs = times[ epochs = times[
list( list(
map( map(

View File

@ -19,9 +19,12 @@ High level chart-widget apis.
''' '''
from __future__ import annotations from __future__ import annotations
from contextlib import (
contextmanager as cm,
ExitStack,
)
from typing import ( from typing import (
Iterator, Iterator,
Optional,
TYPE_CHECKING, TYPE_CHECKING,
) )
@ -102,7 +105,7 @@ class GodWidget(QWidget):
super().__init__(parent) super().__init__(parent)
self.search: Optional[SearchWidget] = None self.search: SearchWidget | None = None
self.hbox = QHBoxLayout(self) self.hbox = QHBoxLayout(self)
self.hbox.setContentsMargins(0, 0, 0, 0) self.hbox.setContentsMargins(0, 0, 0, 0)
@ -116,22 +119,14 @@ class GodWidget(QWidget):
self.hbox.addLayout(self.vbox) self.hbox.addLayout(self.vbox)
# self.toolbar_layout = QHBoxLayout()
# self.toolbar_layout.setContentsMargins(0, 0, 0, 0)
# self.vbox.addLayout(self.toolbar_layout)
# self.init_timeframes_ui()
# self.init_strategy_ui()
# self.vbox.addLayout(self.hbox)
self._chart_cache: dict[ self._chart_cache: dict[
str, str,
tuple[LinkedSplits, LinkedSplits], tuple[LinkedSplits, LinkedSplits],
] = {} ] = {}
self.hist_linked: Optional[LinkedSplits] = None self.hist_linked: LinkedSplits | None = None
self.rt_linked: Optional[LinkedSplits] = None self.rt_linked: LinkedSplits | None = None
self._active_cursor: Optional[Cursor] = None self._active_cursor: Cursor | None = None
# assigned in the startup func `_async_main()` # assigned in the startup func `_async_main()`
self._root_n: trio.Nursery = None self._root_n: trio.Nursery = None
@ -143,15 +138,18 @@ class GodWidget(QWidget):
# and the window does not? Never right?! # and the window does not? Never right?!
# self.reg_for_resize(self) # self.reg_for_resize(self)
# TODO: strat loader/saver that we don't need yet.
# def init_strategy_ui(self):
# self.toolbar_layout = QHBoxLayout()
# self.toolbar_layout.setContentsMargins(0, 0, 0, 0)
# self.vbox.addLayout(self.toolbar_layout)
# self.strategy_box = StrategyBoxWidget(self)
# self.toolbar_layout.addWidget(self.strategy_box)
@property @property
def linkedsplits(self) -> LinkedSplits: def linkedsplits(self) -> LinkedSplits:
return self.rt_linked return self.rt_linked
# XXX: strat loader/saver that we don't need yet.
# def init_strategy_ui(self):
# self.strategy_box = StrategyBoxWidget(self)
# self.toolbar_layout.addWidget(self.strategy_box)
def set_chart_symbols( def set_chart_symbols(
self, self,
group_key: tuple[str], # of form <fqsn>.<providername> group_key: tuple[str], # of form <fqsn>.<providername>
@ -263,7 +261,9 @@ class GodWidget(QWidget):
# last had the xlast in view, if so then shift so it's # last had the xlast in view, if so then shift so it's
# still in view, if the user was viewing history then # still in view, if the user was viewing history then
# do nothing yah? # do nothing yah?
self.rt_linked.chart.default_view() self.rt_linked.chart.main_viz.default_view(
do_min_bars=True,
)
# if a history chart instance is already up then # if a history chart instance is already up then
# set the search widget as its sidepane. # set the search widget as its sidepane.
@ -372,7 +372,7 @@ class ChartnPane(QFrame):
''' '''
sidepane: FieldsForm | SearchWidget sidepane: FieldsForm | SearchWidget
hbox: QHBoxLayout hbox: QHBoxLayout
chart: Optional[ChartPlotWidget] = None chart: ChartPlotWidget | None = None
def __init__( def __init__(
self, self,
@ -432,7 +432,7 @@ class LinkedSplits(QWidget):
self.godwidget = godwidget self.godwidget = godwidget
self.chart: ChartPlotWidget = None # main (ohlc) chart self.chart: ChartPlotWidget = None # main (ohlc) chart
self.subplots: dict[tuple[str, ...], ChartPlotWidget] = {} self.subplots: dict[str, ChartPlotWidget] = {}
self.godwidget = godwidget self.godwidget = godwidget
# placeholder for last appended ``PlotItem``'s bottom axis. # placeholder for last appended ``PlotItem``'s bottom axis.
@ -450,7 +450,7 @@ class LinkedSplits(QWidget):
# chart-local graphics state that can be passed to # chart-local graphics state that can be passed to
# a ``graphic_update_cycle()`` call by any task wishing to # a ``graphic_update_cycle()`` call by any task wishing to
# update the UI for a given "chart instance". # update the UI for a given "chart instance".
self.display_state: Optional[DisplayState] = None self.display_state: DisplayState | None = None
self._symbol: Symbol = None self._symbol: Symbol = None
@ -480,7 +480,7 @@ class LinkedSplits(QWidget):
def set_split_sizes( def set_split_sizes(
self, self,
prop: Optional[float] = None, prop: float | None = None,
) -> None: ) -> None:
''' '''
@ -494,7 +494,7 @@ class LinkedSplits(QWidget):
prop = 3/8 prop = 3/8
h = self.height() h = self.height()
histview_h = h * (6/16) histview_h = h * (4/11)
h = h - histview_h h = h - histview_h
major = 1 - prop major = 1 - prop
@ -574,11 +574,11 @@ class LinkedSplits(QWidget):
shm: ShmArray, shm: ShmArray,
flume: Flume, flume: Flume,
array_key: Optional[str] = None, array_key: str | None = None,
style: str = 'line', style: str = 'line',
_is_main: bool = False, _is_main: bool = False,
sidepane: Optional[QWidget] = None, sidepane: QWidget | None = None,
draw_kwargs: dict = {}, draw_kwargs: dict = {},
**cpw_kwargs, **cpw_kwargs,
@ -634,6 +634,7 @@ class LinkedSplits(QWidget):
axis.pi = cpw.plotItem axis.pi = cpw.plotItem
cpw.hideAxis('left') cpw.hideAxis('left')
# cpw.removeAxis('left')
cpw.hideAxis('bottom') cpw.hideAxis('bottom')
if ( if (
@ -750,12 +751,12 @@ class LinkedSplits(QWidget):
# NOTE: back-link the new sub-chart to trigger y-autoranging in # NOTE: back-link the new sub-chart to trigger y-autoranging in
# the (ohlc parent) main chart for this linked set. # the (ohlc parent) main chart for this linked set.
if self.chart: # if self.chart:
main_viz = self.chart.get_viz(self.chart.name) # main_viz = self.chart.get_viz(self.chart.name)
self.chart.view.enable_auto_yrange( # self.chart.view.enable_auto_yrange(
src_vb=cpw.view, # src_vb=cpw.view,
viz=main_viz, # viz=main_viz,
) # )
graphics = viz.graphics graphics = viz.graphics
data_key = viz.name data_key = viz.name
@ -793,7 +794,7 @@ class LinkedSplits(QWidget):
def resize_sidepanes( def resize_sidepanes(
self, self,
from_linked: Optional[LinkedSplits] = None, from_linked: LinkedSplits | None = None,
) -> None: ) -> None:
''' '''
@ -816,11 +817,17 @@ class LinkedSplits(QWidget):
self.chart.sidepane.setMinimumWidth(sp_w) self.chart.sidepane.setMinimumWidth(sp_w)
# TODO: we should really drop using this type and instead just # TODO: a general rework of this widget-interface:
# write our own wrapper around `PlotItem`.. # - we should really drop using this type and instead just lever our
# own override of `PlotItem`..
# - possibly rename to class -> MultiChart(pg.PlotWidget):
# where the widget is responsible for containing management
# harness for multi-Viz "view lists" and their associated mode-panes
# (fsp chain, order ctl, feed queue-ing params, actor ctl, etc).
class ChartPlotWidget(pg.PlotWidget): class ChartPlotWidget(pg.PlotWidget):
''' '''
``GraphicsView`` subtype containing a ``.plotItem: PlotItem`` as well ``PlotWidget`` subtype containing a ``.plotItem: PlotItem`` as well
as a `.pi_overlay: PlotItemOverlay`` which helps manage and overlay flow as a `.pi_overlay: PlotItemOverlay`` which helps manage and overlay flow
graphics view multiple compose view boxes. graphics view multiple compose view boxes.
@ -861,7 +868,7 @@ class ChartPlotWidget(pg.PlotWidget):
# TODO: load from config # TODO: load from config
use_open_gl: bool = False, use_open_gl: bool = False,
static_yrange: Optional[tuple[float, float]] = None, static_yrange: tuple[float, float] | None = None,
parent=None, parent=None,
**kwargs, **kwargs,
@ -876,7 +883,7 @@ class ChartPlotWidget(pg.PlotWidget):
# NOTE: must be set bfore calling ``.mk_vb()`` # NOTE: must be set bfore calling ``.mk_vb()``
self.linked = linkedsplits self.linked = linkedsplits
self.sidepane: Optional[FieldsForm] = None self.sidepane: FieldsForm | None = None
# source of our custom interactions # source of our custom interactions
self.cv = self.mk_vb(name) self.cv = self.mk_vb(name)
@ -1010,36 +1017,10 @@ class ChartPlotWidget(pg.PlotWidget):
# ) # )
return line_end, marker_right, r_axis_x return line_end, marker_right, r_axis_x
def default_view(
self,
bars_from_y: int = int(616 * 3/8),
y_offset: int = 0,
do_ds: bool = True,
) -> None:
'''
Set the view box to the "default" startup view of the scene.
'''
viz = self.get_viz(self.name)
if not viz:
log.warning(f'`Viz` for {self.name} not loaded yet?')
return
viz.default_view(
bars_from_y,
y_offset,
do_ds,
)
if do_ds:
self.linked.graphics_cycle()
def increment_view( def increment_view(
self, self,
datums: int = 1, datums: int = 1,
vb: Optional[ChartView] = None, vb: ChartView | None = None,
) -> None: ) -> None:
''' '''
@ -1057,6 +1038,7 @@ class ChartPlotWidget(pg.PlotWidget):
# breakpoint() # breakpoint()
return return
# should trigger broadcast on all overlays right?
view.setXRange( view.setXRange(
min=l + x_shift, min=l + x_shift,
max=r + x_shift, max=r + x_shift,
@ -1069,8 +1051,8 @@ class ChartPlotWidget(pg.PlotWidget):
def overlay_plotitem( def overlay_plotitem(
self, self,
name: str, name: str,
index: Optional[int] = None, index: int | None = None,
axis_title: Optional[str] = None, axis_title: str | None = None,
axis_side: str = 'right', axis_side: str = 'right',
axis_kwargs: dict = {}, axis_kwargs: dict = {},
@ -1119,6 +1101,15 @@ class ChartPlotWidget(pg.PlotWidget):
link_axes=(0,), link_axes=(0,),
) )
# hide all axes not named by ``axis_side``
for axname in (
({'bottom'} | allowed_sides) - {axis_side}
):
try:
pi.hideAxis(axname)
except Exception:
pass
# add axis title # add axis title
# TODO: do we want this API to still work? # TODO: do we want this API to still work?
# raxis = pi.getAxis('right') # raxis = pi.getAxis('right')
@ -1134,11 +1125,11 @@ class ChartPlotWidget(pg.PlotWidget):
shm: ShmArray, shm: ShmArray,
flume: Flume, flume: Flume,
array_key: Optional[str] = None, array_key: str | None = None,
overlay: bool = False, overlay: bool = False,
color: Optional[str] = None, color: str | None = None,
add_label: bool = True, add_label: bool = True,
pi: Optional[pg.PlotItem] = None, pi: pg.PlotItem | None = None,
step_mode: bool = False, step_mode: bool = False,
is_ohlc: bool = False, is_ohlc: bool = False,
add_sticky: None | str = 'right', add_sticky: None | str = 'right',
@ -1197,6 +1188,10 @@ class ChartPlotWidget(pg.PlotWidget):
) )
pi.viz = viz pi.viz = viz
# so that viewboxes are associated 1-to-1 with
# their parent plotitem
pi.vb._viz = viz
assert isinstance(viz.shm, ShmArray) assert isinstance(viz.shm, ShmArray)
# TODO: this probably needs its own method? # TODO: this probably needs its own method?
@ -1209,17 +1204,21 @@ class ChartPlotWidget(pg.PlotWidget):
pi = overlay pi = overlay
if add_sticky: if add_sticky:
axis = pi.getAxis(add_sticky)
if pi.name not in axis._stickies:
if pi is not self.plotItem: if pi is not self.plotItem:
# overlay = self.pi_overlay
# assert pi in overlay.overlays
overlay = self.pi_overlay overlay = self.pi_overlay
assert pi in overlay.overlays assert pi in overlay.overlays
overlay_axis = overlay.get_axis( axis = overlay.get_axis(
pi, pi,
add_sticky, add_sticky,
) )
assert overlay_axis is axis
else:
axis = pi.getAxis(add_sticky)
if pi.name not in axis._stickies:
# TODO: UGH! just make this not here! we should # TODO: UGH! just make this not here! we should
# be making the sticky from code which has access # be making the sticky from code which has access
@ -1263,7 +1262,7 @@ class ChartPlotWidget(pg.PlotWidget):
shm: ShmArray, shm: ShmArray,
flume: Flume, flume: Flume,
array_key: Optional[str] = None, array_key: str | None = None,
**draw_curve_kwargs, **draw_curve_kwargs,
) -> Viz: ) -> Viz:
@ -1280,24 +1279,6 @@ class ChartPlotWidget(pg.PlotWidget):
**draw_curve_kwargs, **draw_curve_kwargs,
) )
def update_graphics_from_flow(
self,
graphics_name: str,
array_key: Optional[str] = None,
**kwargs,
) -> pg.GraphicsObject:
'''
Update the named internal graphics from ``array``.
'''
viz = self._vizs[array_key or graphics_name]
return viz.update_graphics(
array_key=array_key,
**kwargs,
)
# TODO: pretty sure we can just call the cursor # TODO: pretty sure we can just call the cursor
# directly not? i don't wee why we need special "signal proxies" # directly not? i don't wee why we need special "signal proxies"
# for this lul.. # for this lul..
@ -1310,43 +1291,6 @@ class ChartPlotWidget(pg.PlotWidget):
self.sig_mouse_leave.emit(self) self.sig_mouse_leave.emit(self)
self.scene().leaveEvent(ev) self.scene().leaveEvent(ev)
def maxmin(
self,
name: Optional[str] = None,
bars_range: Optional[tuple[
int, int, int, int, int, int
]] = None,
) -> tuple[float, float]:
'''
Return the max and min y-data values "in view".
If ``bars_range`` is provided use that range.
'''
# TODO: here we should instead look up the ``Viz.shm.array``
# and read directly from shm to avoid copying to memory first
# and then reading it again here.
viz_key = name or self.name
viz = self._vizs.get(viz_key)
if viz is None:
log.error(f"viz {viz_key} doesn't exist in chart {self.name} !?")
return 0, 0
res = viz.maxmin()
if (
res is None
):
mxmn = 0, 0
if not self._on_screen:
self.default_view(do_ds=False)
self._on_screen = True
else:
x_range, read_slc, mxmn = res
return mxmn
def get_viz( def get_viz(
self, self,
key: str, key: str,
@ -1360,3 +1304,32 @@ class ChartPlotWidget(pg.PlotWidget):
@property @property
def main_viz(self) -> Viz: def main_viz(self) -> Viz:
return self.get_viz(self.name) return self.get_viz(self.name)
def iter_vizs(self) -> Iterator[Viz]:
return iter(self._vizs.values())
@cm
def reset_graphics_caches(self) -> None:
'''
Reset all managed ``Viz`` (flow) graphics objects
Qt cache modes (to ``NoCache`` mode) on enter and
restore on exit.
'''
with ExitStack() as stack:
for viz in self.iter_vizs():
stack.enter_context(
viz.graphics.reset_cache(),
)
# also reset any downsampled alt-graphics objects which
# might be active.
dsg = viz.ds_graphics
if dsg:
stack.enter_context(
dsg.reset_cache(),
)
try:
yield
finally:
stack.close()

View File

@ -1,5 +1,5 @@
# piker: trading gear for hackers # piker: trading gear for hackers
# Copyright (C) Tyler Goodlet (in stewardship for piker0) # Copyright (C) Tyler Goodlet (in stewardship for pikers)
# 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
@ -21,7 +21,6 @@ Mouse interaction graphics
from __future__ import annotations from __future__ import annotations
from functools import partial from functools import partial
from typing import ( from typing import (
Optional,
Callable, Callable,
TYPE_CHECKING, TYPE_CHECKING,
) )
@ -38,7 +37,10 @@ from ._style import (
_font_small, _font_small,
_font, _font,
) )
from ._axes import YAxisLabel, XAxisLabel from ._axes import (
YAxisLabel,
XAxisLabel,
)
from ..log import get_logger from ..log import get_logger
if TYPE_CHECKING: if TYPE_CHECKING:
@ -167,7 +169,7 @@ class ContentsLabel(pg.LabelItem):
anchor_at: str = ('top', 'right'), anchor_at: str = ('top', 'right'),
justify_text: str = 'left', justify_text: str = 'left',
font_size: Optional[int] = None, font_size: int | None = None,
) -> None: ) -> None:
@ -338,7 +340,7 @@ class Cursor(pg.GraphicsObject):
self.linked = linkedsplits self.linked = linkedsplits
self.graphics: dict[str, pg.GraphicsObject] = {} self.graphics: dict[str, pg.GraphicsObject] = {}
self.xaxis_label: Optional[XAxisLabel] = None self.xaxis_label: XAxisLabel | None = None
self.always_show_xlabel: bool = True self.always_show_xlabel: bool = True
self.plots: list['PlotChartWidget'] = [] # type: ignore # noqa self.plots: list['PlotChartWidget'] = [] # type: ignore # noqa
self.active_plot = None self.active_plot = None

View File

@ -19,7 +19,7 @@ Fast, smooth, sexy curves.
""" """
from contextlib import contextmanager as cm from contextlib import contextmanager as cm
from typing import Optional, Callable from typing import Callable
import numpy as np import numpy as np
import pyqtgraph as pg import pyqtgraph as pg
@ -86,7 +86,7 @@ class FlowGraphic(pg.GraphicsObject):
# line styling # line styling
color: str = 'bracket', color: str = 'bracket',
last_step_color: str | None = None, last_step_color: str | None = None,
fill_color: Optional[str] = None, fill_color: str | None = None,
style: str = 'solid', style: str = 'solid',
**kwargs **kwargs
@ -158,14 +158,37 @@ class FlowGraphic(pg.GraphicsObject):
drawn yet, ``None``. drawn yet, ``None``.
''' '''
return self._last_line.x1() if self._last_line else None if self._last_line:
return self._last_line.x1()
return None
# XXX: due to a variety of weird jitter bugs and "smearing"
# artifacts when click-drag panning and viewing history time series,
# we offer this ctx-mngr interface to allow temporarily disabling
# Qt's graphics caching mode; this is now currently used from
# ``ChartView.start/signal_ic()`` methods which also disable the
# rt-display loop when the user is moving around a view.
@cm
def reset_cache(self) -> None:
try:
none = QGraphicsItem.NoCache
log.debug(
f'{self._name} -> CACHE DISABLE: {none}'
)
self.setCacheMode(none)
yield
finally:
mode = self.cache_mode
log.debug(f'{self._name} -> CACHE ENABLE {mode}')
self.setCacheMode(mode)
class Curve(FlowGraphic): class Curve(FlowGraphic):
''' '''
A faster, simpler, append friendly version of A faster, simpler, append friendly version of
``pyqtgraph.PlotCurveItem`` built for highly customizable real-time ``pyqtgraph.PlotCurveItem`` built for highly customizable real-time
updates. updates; a graphics object to render a simple "line" plot.
This type is a much stripped down version of a ``pyqtgraph`` style This type is a much stripped down version of a ``pyqtgraph`` style
"graphics object" in the sense that the internal lower level "graphics object" in the sense that the internal lower level
@ -191,14 +214,14 @@ class Curve(FlowGraphic):
''' '''
# TODO: can we remove this? # TODO: can we remove this?
# sub_br: Optional[Callable] = None # sub_br: Callable | None = None
def __init__( def __init__(
self, self,
*args, *args,
# color: str = 'default_lightest', # color: str = 'default_lightest',
# fill_color: Optional[str] = None, # fill_color: str | None = None,
# style: str = 'solid', # style: str = 'solid',
**kwargs **kwargs
@ -248,12 +271,6 @@ class Curve(FlowGraphic):
self.fast_path.clear() self.fast_path.clear()
# self.fast_path = None # self.fast_path = None
@cm
def reset_cache(self) -> None:
self.setCacheMode(QtWidgets.QGraphicsItem.NoCache)
yield
self.setCacheMode(QGraphicsItem.DeviceCoordinateCache)
def boundingRect(self): def boundingRect(self):
''' '''
Compute and then cache our rect. Compute and then cache our rect.
@ -378,7 +395,6 @@ class Curve(FlowGraphic):
) -> None: ) -> None:
# default line draw last call # default line draw last call
# with self.reset_cache():
x = src_data[index_field] x = src_data[index_field]
y = src_data[array_key] y = src_data[array_key]
@ -406,10 +422,20 @@ class Curve(FlowGraphic):
# element such that the current datum in view can be shown # element such that the current datum in view can be shown
# (via it's max / min) even when highly zoomed out. # (via it's max / min) even when highly zoomed out.
class FlattenedOHLC(Curve): class FlattenedOHLC(Curve):
'''
More or less the exact same as a standard line ``Curve`` above
but meant to handle a traced-and-downsampled OHLC time series.
_
_| | _
|_ | |_ | |
_| => |_| |
| |
|_ |_
# avoids strange dragging/smearing artifacts when panning.. The main implementation different is that ``.draw_last_datum()``
cache_mode: int = QGraphicsItem.NoCache expects an underlying OHLC array for the ``src_data`` input.
'''
def draw_last_datum( def draw_last_datum(
self, self,
path: QPainterPath, path: QPainterPath,
@ -434,7 +460,19 @@ class FlattenedOHLC(Curve):
class StepCurve(Curve): class StepCurve(Curve):
'''
A familiar rectangle-with-y-height-per-datum type curve:
||
|| ||
|| || ||||
_||_||_||_||||_ where each datum's y-value is drawn as
a nearly full rectangle, each "level" spans some x-step size.
This is most often used for vlm and option OI style curves and/or
the very popular "bar chart".
'''
def declare_paintables( def declare_paintables(
self, self,
) -> None: ) -> None:

View File

@ -19,17 +19,20 @@ Data vizualization APIs
''' '''
from __future__ import annotations from __future__ import annotations
from functools import lru_cache
from math import ( from math import (
ceil, ceil,
floor, floor,
) )
from typing import ( from typing import (
Optional,
Literal, Literal,
TYPE_CHECKING, TYPE_CHECKING,
) )
import msgspec from msgspec import (
Struct,
field,
)
import numpy as np import numpy as np
import pyqtgraph as pg import pyqtgraph as pg
from PyQt5.QtCore import QLineF from PyQt5.QtCore import QLineF
@ -225,15 +228,51 @@ def render_baritems(
_sample_rates: set[float] = {1, 60} _sample_rates: set[float] = {1, 60}
class Viz(msgspec.Struct): # , frozen=True): class ViewState(Struct):
'''
Indexing objects representing the current view x-range -> y-range.
'''
# (xl, xr) "input" view range in x-domain
xrange: tuple[
float | int,
float | int
] | None = None
# TODO: cache the (ixl, ixr) read_slc-into-.array style slice index?
# (ymn, ymx) "output" min and max in viewed y-codomain
yrange: tuple[
float | int,
float | int
] | None = None
# last in view ``ShmArray.array[read_slc]`` data
in_view: np.ndarray | None = None
class Viz(Struct):
''' '''
(Data) "Visualization" compound type which wraps a real-time (Data) "Visualization" compound type which wraps a real-time
shm array stream with displayed graphics (curves, charts) shm array stream with displayed graphics (curves, charts)
for high level access and control as well as efficient incremental for high level access and control as well as efficient incremental
update. update, oriented around the idea of a "view state".
The intention is for this type to eventually be capable of shm-passing The (backend) intention is for this interface and type is to
of incrementally updated graphics stream data between actors. eventually be capable of shm-passing of incrementally updated
graphics stream data, thus providing a cross-actor solution to
sharing UI-related update state potentionally in a (compressed)
binary-interchange format.
Further, from an interaction-triggers-view-in-UI perspective, this type
operates as a transform:
(x_left, x_right) -> output metrics {ymn, ymx, uppx, ...}
wherein each x-domain range maps to some output set of (graphics
related) vizualization metrics. In further documentation we often
refer to this abstraction as a vizualization curve: Ci. Each Ci is
considered a function which maps an x-range (input view range) to
a multi-variate (metrics) output.
''' '''
name: str name: str
@ -242,13 +281,17 @@ class Viz(msgspec.Struct): # , frozen=True):
flume: Flume flume: Flume
graphics: Curve | BarItems graphics: Curve | BarItems
# for tracking y-mn/mx for y-axis auto-ranging vs: ViewState = field(default_factory=ViewState)
yrange: tuple[float, float] = None
# last calculated y-mn/mx from m4 downsample code, this
# is updated in the body of `Renderer.render()`.
ds_yrange: tuple[float, float] | None = None
yrange: tuple[float, float] | None = None
# in some cases a viz may want to change its # in some cases a viz may want to change its
# graphical "type" or, "form" when downsampling, to # graphical "type" or, "form" when downsampling, to
# start this is only ever an interpolation line. # start this is only ever an interpolation line.
ds_graphics: Optional[Curve] = None ds_graphics: Curve | None = None
is_ohlc: bool = False is_ohlc: bool = False
render: bool = True # toggle for display loop render: bool = True # toggle for display loop
@ -264,7 +307,7 @@ class Viz(msgspec.Struct): # , frozen=True):
] = 'time' ] = 'time'
# downsampling state # TODO: maybe compound this into a downsampling state type?
_last_uppx: float = 0 _last_uppx: float = 0
_in_ds: bool = False _in_ds: bool = False
_index_step: float | None = None _index_step: float | None = None
@ -282,20 +325,44 @@ class Viz(msgspec.Struct): # , frozen=True):
tuple[float, float], tuple[float, float],
] = {} ] = {}
# cache of median calcs from input read slice hashes
# see `.median()`
_meds: dict[
int,
float,
] = {}
# to make lru_cache-ing work, see
# https://docs.python.org/3/faq/programming.html#how-do-i-cache-method-calls
def __eq__(self, other):
return self._shm._token == other._shm._token
def __hash__(self):
return hash(self._shm._token)
@property @property
def shm(self) -> ShmArray: def shm(self) -> ShmArray:
return self._shm return self._shm
@property @property
def index_field(self) -> str: def index_field(self) -> str:
'''
The column name as ``str`` in the underlying ``._shm: ShmArray``
which will deliver the "index" array.
'''
return self._index_field return self._index_field
def index_step( def index_step(
self, self,
reset: bool = False, reset: bool = False,
) -> float: ) -> float:
'''
Return the size between sample steps in the units of the
x-domain, normally either an ``int`` array index size or an
epoch time in seconds.
'''
# attempt to dectect the best step size by scanning a sample of # attempt to dectect the best step size by scanning a sample of
# the source data. # the source data.
if self._index_step is None: if self._index_step is None:
@ -378,7 +445,7 @@ class Viz(msgspec.Struct): # , frozen=True):
# TODO: hash the slice instead maybe? # TODO: hash the slice instead maybe?
# https://stackoverflow.com/a/29980872 # https://stackoverflow.com/a/29980872
lbar, rbar = ixrng = round(x_range[0]), round(x_range[1]) ixrng = lbar, rbar = round(x_range[0]), round(x_range[1])
if use_caching: if use_caching:
cached_result = self._mxmns.get(ixrng) cached_result = self._mxmns.get(ixrng)
@ -389,6 +456,7 @@ class Viz(msgspec.Struct): # , frozen=True):
f'{ixrng} -> {cached_result}' f'{ixrng} -> {cached_result}'
) )
read_slc, mxmn = cached_result read_slc, mxmn = cached_result
self.vs.yrange = mxmn
return ( return (
ixrng, ixrng,
read_slc, read_slc,
@ -421,8 +489,8 @@ class Viz(msgspec.Struct): # , frozen=True):
) )
return None return None
elif self.yrange: elif self.ds_yrange:
mxmn = self.yrange mxmn = self.ds_yrange
if do_print: if do_print:
print( print(
f'{self.name} M4 maxmin:\n' f'{self.name} M4 maxmin:\n'
@ -455,6 +523,7 @@ class Viz(msgspec.Struct): # , frozen=True):
# cache result for input range # cache result for input range
assert mxmn assert mxmn
self._mxmns[ixrng] = (read_slc, mxmn) self._mxmns[ixrng] = (read_slc, mxmn)
self.vs.yrange = mxmn
profiler(f'yrange mxmn cacheing: {x_range} -> {mxmn}') profiler(f'yrange mxmn cacheing: {x_range} -> {mxmn}')
return ( return (
ixrng, ixrng,
@ -473,20 +542,11 @@ class Viz(msgspec.Struct): # , frozen=True):
vr.right(), vr.right(),
) )
def bars_range(self) -> tuple[int, int, int, int]:
'''
Return a range tuple for the left-view, left-datum, right-datum
and right-view x-indices.
'''
l, start, datum_start, datum_stop, stop, r = self.datums_range()
return l, datum_start, datum_stop, r
def datums_range( def datums_range(
self, self,
view_range: None | tuple[float, float] = None, view_range: None | tuple[float, float] = None,
index_field: str | None = None, index_field: str | None = None,
array: None | np.ndarray = None, array: np.ndarray | None = None,
) -> tuple[ ) -> tuple[
int, int, int, int, int, int int, int, int, int, int, int
@ -499,42 +559,47 @@ class Viz(msgspec.Struct): # , frozen=True):
index_field: str = index_field or self.index_field index_field: str = index_field or self.index_field
if index_field == 'index': if index_field == 'index':
l, r = round(l), round(r) l: int = round(l)
r: int = round(r)
if array is None: if array is None:
array = self.shm.array array = self.shm.array
index = array[index_field] index = array[index_field]
first = floor(index[0]) first: int = floor(index[0])
last = ceil(index[-1]) last: int = ceil(index[-1])
# first and last datums in view determined by
# l / r view range.
leftmost = floor(l)
rightmost = ceil(r)
# invalid view state # invalid view state
if ( if (
r < l r < l
or l < 0 or l < 0
or r < 0 or r < 0
or (l > last and r > last) or (
l > last
and r > last
)
): ):
leftmost = first leftmost: int = first
rightmost = last rightmost: int = last
else: else:
# determine first and last datums in view determined by
# l -> r view range.
rightmost = max( rightmost = max(
min(last, rightmost), min(last, ceil(r)),
first, first,
) )
leftmost = min( leftmost = min(
max(first, leftmost), max(first, floor(l)),
last, last,
rightmost - 1, rightmost - 1,
) )
assert leftmost < rightmost # sanity
# assert leftmost < rightmost
self.vs.xrange = leftmost, rightmost
return ( return (
l, # left x-in-view l, # left x-in-view
@ -547,7 +612,7 @@ class Viz(msgspec.Struct): # , frozen=True):
def read( def read(
self, self,
array_field: Optional[str] = None, array_field: str | None = None,
index_field: str | None = None, index_field: str | None = None,
profiler: None | Profiler = None, profiler: None | Profiler = None,
@ -563,11 +628,9 @@ class Viz(msgspec.Struct): # , frozen=True):
''' '''
index_field: str = index_field or self.index_field index_field: str = index_field or self.index_field
vr = l, r = self.view_range()
# readable data # readable data
array = self.shm.array array = self.shm.array
if profiler: if profiler:
profiler('self.shm.array READ') profiler('self.shm.array READ')
@ -579,7 +642,6 @@ class Viz(msgspec.Struct): # , frozen=True):
ilast, ilast,
r, r,
) = self.datums_range( ) = self.datums_range(
view_range=vr,
index_field=index_field, index_field=index_field,
array=array, array=array,
) )
@ -595,17 +657,21 @@ class Viz(msgspec.Struct): # , frozen=True):
array, array,
start_t=lbar, start_t=lbar,
stop_t=rbar, stop_t=rbar,
step=self.index_step(),
) )
# TODO: maybe we should return this from the slicer call # TODO: maybe we should return this from the slicer call
# above? # above?
in_view = array[read_slc] in_view = array[read_slc]
if in_view.size: if in_view.size:
self.vs.in_view = in_view
abs_indx = in_view['index'] abs_indx = in_view['index']
abs_slc = slice( abs_slc = slice(
int(abs_indx[0]), int(abs_indx[0]),
int(abs_indx[-1]), int(abs_indx[-1]),
) )
else:
self.vs.in_view = None
if profiler: if profiler:
profiler( profiler(
@ -626,10 +692,11 @@ class Viz(msgspec.Struct): # , frozen=True):
# BUT the ``in_view`` slice DOES.. # BUT the ``in_view`` slice DOES..
read_slc = slice(lbar_i, rbar_i) read_slc = slice(lbar_i, rbar_i)
in_view = array[lbar_i: rbar_i + 1] in_view = array[lbar_i: rbar_i + 1]
self.vs.in_view = in_view
# in_view = array[lbar_i-1: rbar_i+1] # in_view = array[lbar_i-1: rbar_i+1]
# XXX: same as ^ # XXX: same as ^
# to_draw = array[lbar - ifirst:(rbar - ifirst) + 1] # to_draw = array[lbar - ifirst:(rbar - ifirst) + 1]
if profiler: if profiler:
profiler('index arithmetic for slicing') profiler('index arithmetic for slicing')
@ -664,8 +731,8 @@ class Viz(msgspec.Struct): # , frozen=True):
pg.GraphicsObject, pg.GraphicsObject,
]: ]:
''' '''
Read latest datums from shm and render to (incrementally) Read latest datums from shm and (incrementally) render to
render to graphics. graphics.
''' '''
profiler = Profiler( profiler = Profiler(
@ -955,9 +1022,11 @@ class Viz(msgspec.Struct): # , frozen=True):
def default_view( def default_view(
self, self,
bars_from_y: int = int(616 * 3/8), min_bars_from_y: int = int(616 * 4/11),
y_offset: int = 0, # in datums y_offset: int = 0, # in datums
do_ds: bool = True, do_ds: bool = True,
do_min_bars: bool = False,
) -> None: ) -> None:
''' '''
@ -1013,12 +1082,10 @@ class Viz(msgspec.Struct): # , frozen=True):
data_diff = last_datum - first_datum data_diff = last_datum - first_datum
rl_diff = vr - vl rl_diff = vr - vl
rescale_to_data: bool = False rescale_to_data: bool = False
# new_uppx: float = 1
if rl_diff > data_diff: if rl_diff > data_diff:
rescale_to_data = True rescale_to_data = True
rl_diff = data_diff rl_diff = data_diff
new_uppx: float = data_diff / self.px_width()
# orient by offset from the y-axis including # orient by offset from the y-axis including
# space to compensate for the L1 labels. # space to compensate for the L1 labels.
@ -1027,17 +1094,29 @@ class Viz(msgspec.Struct): # , frozen=True):
offset = l1_offset offset = l1_offset
if ( if rescale_to_data:
rescale_to_data new_uppx: float = data_diff / self.px_width()
):
offset = (offset / uppx) * new_uppx offset = (offset / uppx) * new_uppx
else: else:
offset = (y_offset * step) + uppx*step offset = (y_offset * step) + uppx*step
# NOTE: if we are in the midst of start-up and a bunch of
# widgets are spawning/rendering concurrently, it's likely the
# label size above `l1_offset` won't have yet fully rendered.
# Here we try to compensate for that ensure at least a static
# bar gap between the last datum and the y-axis.
if (
do_min_bars
and offset <= (6 * step)
):
offset = 6 * step
# align right side of view to the rightmost datum + the selected # align right side of view to the rightmost datum + the selected
# offset from above. # offset from above.
r_reset = (self.graphics.x_last() or last_datum) + offset r_reset = (
self.graphics.x_last() or last_datum
) + offset
# no data is in view so check for the only 2 sane cases: # no data is in view so check for the only 2 sane cases:
# - entire view is LEFT of data # - entire view is LEFT of data
@ -1062,12 +1141,20 @@ class Viz(msgspec.Struct): # , frozen=True):
else: else:
log.warning(f'Unknown view state {vl} -> {vr}') log.warning(f'Unknown view state {vl} -> {vr}')
return return
# raise RuntimeError(f'Unknown view state {vl} -> {vr}')
else: else:
# maintain the l->r view distance # maintain the l->r view distance
l_reset = r_reset - rl_diff l_reset = r_reset - rl_diff
if (
do_min_bars
and (r_reset - l_reset) < min_bars_from_y
):
l_reset = (
(r_reset + offset)
-
min_bars_from_y * step
)
# remove any custom user yrange setttings # remove any custom user yrange setttings
if chartw._static_yrange == 'axis': if chartw._static_yrange == 'axis':
chartw._static_yrange = None chartw._static_yrange = None
@ -1079,9 +1166,7 @@ class Viz(msgspec.Struct): # , frozen=True):
) )
if do_ds: if do_ds:
# view.interaction_graphics_cycle() view.interact_graphics_cycle()
view.maybe_downsample_graphics()
view._set_yrange(viz=self)
def incr_info( def incr_info(
self, self,
@ -1236,3 +1321,152 @@ class Viz(msgspec.Struct): # , frozen=True):
vr, 0, vr, 0,
) )
).length() ).length()
@lru_cache(maxsize=6116)
def median_from_range(
self,
start: int,
stop: int,
) -> float:
in_view = self.shm.array[start:stop]
if self.is_ohlc:
return np.median(in_view['close'])
else:
return np.median(in_view[self.name])
@lru_cache(maxsize=6116)
def _dispersion(
self,
# xrange: tuple[float, float],
ymn: float,
ymx: float,
yref: float,
) -> tuple[float, float]:
return (
(ymx - yref) / yref,
(ymn - yref) / yref,
)
def disp_from_range(
self,
xrange: tuple[float, float] | None = None,
yref: float | None = None,
method: Literal[
'up',
'down',
'full', # both sides
'both', # both up and down as separate scalars
] = 'full',
) -> float | tuple[float, float] | None:
'''
Return a dispersion metric referenced from an optionally
provided ``yref`` or the left-most datum level by default.
'''
vs = self.vs
yrange = vs.yrange
if yrange is None:
return None
ymn, ymx = yrange
key = 'open' if self.is_ohlc else self.name
yref = yref or vs.in_view[0][key]
# xrange = xrange or vs.xrange
# call into the lru_cache-d sigma calculator method
r_up, r_down = self._dispersion(ymn, ymx, yref)
match method:
case 'full':
return r_up - r_down
case 'up':
return r_up
case 'down':
return r_up
case 'both':
return r_up, r_down
# @lru_cache(maxsize=6116)
def i_from_t(
self,
t: float,
return_y: bool = False,
) -> int | tuple[int, float]:
istart = slice_from_time(
self.vs.in_view,
start_t=t,
stop_t=t,
step=self.index_step(),
).start
if not return_y:
return istart
vs = self.vs
arr = vs.in_view
key = 'open' if self.is_ohlc else self.name
yref = arr[istart][key]
return istart, yref
def scalars_from_index(
self,
xref: float | None = None,
) -> tuple[
int,
float,
float,
float,
] | None:
'''
Calculate and deliver the log-returns scalars specifically
according to y-data supported on this ``Viz``'s underlying
x-domain data range from ``xref`` -> ``.vs.xrange[1]``.
The main use case for this method (currently) is to generate
scalars which will allow calculating the required y-range for
some "pinned" curve to be aligned *from* the ``xref`` time
stamped datum *to* the curve rendered by THIS viz.
'''
vs = self.vs
arr = vs.in_view
# TODO: make this work by parametrizing over input
# .vs.xrange input for caching?
# read_slc_start = self.i_from_t(xref)
read_slc = slice_from_time(
arr=self.vs.in_view,
start_t=xref,
stop_t=vs.xrange[1],
step=self.index_step(),
)
key = 'open' if self.is_ohlc else self.name
# NOTE: old code, it's no faster right?
# read_slc_start = read_slc.start
# yref = arr[read_slc_start][key]
read = arr[read_slc][key]
if not read.size:
return None
yref = read[0]
ymn, ymx = self.vs.yrange
# print(
# f'Viz[{self.name}].scalars_from_index(xref={xref})\n'
# f'read_slc: {read_slc}\n'
# f'ymnmx: {(ymn, ymx)}\n'
# )
return (
read_slc.start,
yref,
(ymx - yref) / yref,
(ymn - yref) / yref,
)

View File

@ -21,18 +21,18 @@ this module ties together quote and computational (fsp) streams with
graphics update methods via our custom ``pyqtgraph`` charting api. graphics update methods via our custom ``pyqtgraph`` charting api.
''' '''
from functools import partial
import itertools import itertools
from math import floor from math import floor
import time import time
from typing import ( from typing import (
Optional,
Any, Any,
TYPE_CHECKING,
) )
import tractor import tractor
import trio import trio
import pyqtgraph as pg import pyqtgraph as pg
# import pendulum
from msgspec import field from msgspec import field
@ -82,6 +82,9 @@ from .._profile import (
from ..log import get_logger from ..log import get_logger
from .._profile import Profiler from .._profile import Profiler
if TYPE_CHECKING:
from ._interaction import ChartView
log = get_logger(__name__) log = get_logger(__name__)
@ -146,12 +149,11 @@ def multi_maxmin(
profiler(f'vlm_viz.maxmin({read_slc})') profiler(f'vlm_viz.maxmin({read_slc})')
return ( return (
mx,
# enforcing price can't be negative? # enforcing price can't be negative?
# TODO: do we even need this? # TODO: do we even need this?
max(mn, 0), max(mn, 0),
mx,
mx_vlm_in_view, # vlm max mx_vlm_in_view, # vlm max
) )
@ -183,29 +185,23 @@ class DisplayState(Struct):
# misc state tracking # misc state tracking
vars: dict[str, Any] = field( vars: dict[str, Any] = field(
default_factory=lambda: { default_factory=lambda: {
'tick_margin': 0,
'i_last': 0, 'i_last': 0,
'i_last_append': 0, 'i_last_append': 0,
'last_mx_vlm': 0, 'last_mx_vlm': 0,
'last_mx': 0,
'last_mn': 0,
} }
) )
hist_vars: dict[str, Any] = field( hist_vars: dict[str, Any] = field(
default_factory=lambda: { default_factory=lambda: {
'tick_margin': 0,
'i_last': 0, 'i_last': 0,
'i_last_append': 0, 'i_last_append': 0,
'last_mx_vlm': 0, 'last_mx_vlm': 0,
'last_mx': 0,
'last_mn': 0,
} }
) )
globalz: None | dict[str, Any] = None globalz: None | dict[str, Any] = None
vlm_chart: Optional[ChartPlotWidget] = None vlm_chart: ChartPlotWidget | None = None
vlm_sticky: Optional[YAxisLabel] = None vlm_sticky: YAxisLabel | None = None
wap_in_history: bool = False wap_in_history: bool = False
@ -261,7 +257,10 @@ async def increment_history_view(
profiler('`hist Viz.update_graphics()` call') profiler('`hist Viz.update_graphics()` call')
if liv: if liv:
hist_viz.plot.vb._set_yrange(viz=hist_viz) hist_viz.plot.vb.interact_graphics_cycle(
do_linked_charts=False,
do_overlay_scaling=True, # always overlayT slow chart
)
profiler('hist chart yrange view') profiler('hist chart yrange view')
# check if tread-in-place view x-shift is needed # check if tread-in-place view x-shift is needed
@ -351,8 +350,8 @@ async def graphics_update_loop(
vlm_viz = vlm_chart._vizs.get('volume') if vlm_chart else None vlm_viz = vlm_chart._vizs.get('volume') if vlm_chart else None
( (
last_mx,
last_mn, last_mn,
last_mx,
last_mx_vlm, last_mx_vlm,
) = multi_maxmin( ) = multi_maxmin(
None, None,
@ -379,9 +378,6 @@ async def graphics_update_loop(
# levels this might be dark volume we need to # levels this might be dark volume we need to
# present differently -> likely dark vlm # present differently -> likely dark vlm
tick_size = symbol.tick_size
tick_margin = 3 * tick_size
fast_chart.show() fast_chart.show()
last_quote_s = time.time() last_quote_s = time.time()
@ -389,7 +385,6 @@ async def graphics_update_loop(
'fqsn': fqsn, 'fqsn': fqsn,
'godwidget': godwidget, 'godwidget': godwidget,
'quotes': {}, 'quotes': {},
# 'maxmin': maxmin,
'flume': flume, 'flume': flume,
@ -406,12 +401,11 @@ async def graphics_update_loop(
'l1': l1, 'l1': l1,
'vars': { 'vars': {
'tick_margin': tick_margin,
'i_last': 0, 'i_last': 0,
'i_last_append': 0, 'i_last_append': 0,
'last_mx_vlm': last_mx_vlm, 'last_mx_vlm': last_mx_vlm,
'last_mx': last_mx, # 'last_mx': last_mx,
'last_mn': last_mn, # 'last_mn': last_mn,
}, },
'globalz': globalz, 'globalz': globalz,
}) })
@ -422,7 +416,9 @@ async def graphics_update_loop(
ds.vlm_chart = vlm_chart ds.vlm_chart = vlm_chart
ds.vlm_sticky = vlm_sticky ds.vlm_sticky = vlm_sticky
fast_chart.default_view() fast_chart.main_viz.default_view(
do_min_bars=True,
)
# ds.hist_vars.update({ # ds.hist_vars.update({
# 'i_last_append': 0, # 'i_last_append': 0,
@ -474,7 +470,7 @@ async def graphics_update_loop(
fast_chart.pause_all_feeds() fast_chart.pause_all_feeds()
continue continue
ic = fast_chart.view._ic ic = fast_chart.view._in_interact
if ic: if ic:
fast_chart.pause_all_feeds() fast_chart.pause_all_feeds()
print(f'{fqsn} PAUSING DURING INTERACTION') print(f'{fqsn} PAUSING DURING INTERACTION')
@ -494,7 +490,7 @@ def graphics_update_cycle(
wap_in_history: bool = False, wap_in_history: bool = False,
trigger_all: bool = False, # flag used by prepend history updates trigger_all: bool = False, # flag used by prepend history updates
prepend_update_index: Optional[int] = None, prepend_update_index: int | None = None,
) -> None: ) -> None:
@ -517,7 +513,7 @@ def graphics_update_cycle(
chart = ds.chart chart = ds.chart
vlm_chart = ds.vlm_chart vlm_chart = ds.vlm_chart
varz = ds.vars # varz = ds.vars
l1 = ds.l1 l1 = ds.l1
flume = ds.flume flume = ds.flume
ohlcv = flume.rt_shm ohlcv = flume.rt_shm
@ -527,8 +523,6 @@ def graphics_update_cycle(
main_viz = ds.viz main_viz = ds.viz
index_field = main_viz.index_field index_field = main_viz.index_field
tick_margin = varz['tick_margin']
( (
uppx, uppx,
liv, liv,
@ -547,35 +541,37 @@ def graphics_update_cycle(
# them as an additional graphic. # them as an additional graphic.
clear_types = _tick_groups['clears'] clear_types = _tick_groups['clears']
mx = varz['last_mx'] # TODO: fancier y-range sorting..
mn = varz['last_mn'] # https://github.com/pikers/piker/issues/325
mx_vlm_in_view = varz['last_mx_vlm'] # - a proper streaming mxmn algo as per above issue.
# - we should probably scale the view margin based on the size of
# the true range? This way you can slap in orders outside the
# current L1 (only) book range.
main_vb: ChartView = main_viz.plot.vb
this_viz: Viz = chart._vizs[fqsn]
this_vb: ChartView = this_viz.plot.vb
this_yr = this_vb._yrange
if this_yr:
lmn, lmx = this_yr
else:
lmn = lmx = 0
mn: float = lmn
mx: float = lmx
mx_vlm_in_view: float | None = None
yrange_margin = 0.09
# update ohlc sampled price bars # update ohlc sampled price bars
if ( if (
# do_rt_update
# or do_px_step
(liv and do_px_step) (liv and do_px_step)
or trigger_all or trigger_all
): ):
# TODO: i think we're double calling this right now
# since .interact_graphics_cycle() also calls it?
# I guess we can add a guard in there?
_, i_read_range, _ = main_viz.update_graphics() _, i_read_range, _ = main_viz.update_graphics()
profiler('`Viz.update_graphics()` call') profiler('`Viz.update_graphics()` call')
(
mx_in_view,
mn_in_view,
mx_vlm_in_view,
) = multi_maxmin(
i_read_range,
main_viz,
ds.vlm_viz,
profiler,
)
mx = mx_in_view + tick_margin
mn = mn_in_view - tick_margin
profiler('{fqsdn} `multi_maxmin()` call')
# don't real-time "shift" the curve to the # don't real-time "shift" the curve to the
# left unless we get one of the following: # left unless we get one of the following:
if ( if (
@ -583,7 +579,6 @@ def graphics_update_cycle(
or trigger_all or trigger_all
): ):
chart.increment_view(datums=append_diff) chart.increment_view(datums=append_diff)
# main_viz.plot.vb._set_yrange(viz=main_viz)
# NOTE: since vlm and ohlc charts are axis linked now we don't # NOTE: since vlm and ohlc charts are axis linked now we don't
# need the double increment request? # need the double increment request?
@ -592,6 +587,21 @@ def graphics_update_cycle(
profiler('view incremented') profiler('view incremented')
# NOTE: do this **after** the tread to ensure we take the yrange
# from the most current view x-domain.
(
mn,
mx,
mx_vlm_in_view,
) = multi_maxmin(
i_read_range,
main_viz,
ds.vlm_viz,
profiler,
)
profiler(f'{fqsn} `multi_maxmin()` call')
# iterate frames of ticks-by-type such that we only update graphics # iterate frames of ticks-by-type such that we only update graphics
# using the last update per type where possible. # using the last update per type where possible.
ticks_by_type = quote.get('tbt', {}) ticks_by_type = quote.get('tbt', {})
@ -613,8 +623,22 @@ def graphics_update_cycle(
# TODO: make sure IB doesn't send ``-1``! # TODO: make sure IB doesn't send ``-1``!
and price > 0 and price > 0
): ):
mx = max(price + tick_margin, mx) if (
mn = min(price - tick_margin, mn) price < mn
):
mn = price
yrange_margin = 0.16
# # print(f'{this_viz.name} new MN from TICK {mn}')
if (
price > mx
):
mx = price
yrange_margin = 0.16
# # print(f'{this_viz.name} new MX from TICK {mx}')
# mx = max(price, mx)
# mn = min(price, mn)
# clearing price update: # clearing price update:
# generally, we only want to update grahpics from the *last* # generally, we only want to update grahpics from the *last*
@ -677,14 +701,16 @@ def graphics_update_cycle(
# Y-autoranging: adjust y-axis limits based on state tracking # Y-autoranging: adjust y-axis limits based on state tracking
# of previous "last" L1 values which are in view. # of previous "last" L1 values which are in view.
lmx = varz['last_mx']
lmn = varz['last_mn']
mx_diff = mx - lmx
mn_diff = mn - lmn mn_diff = mn - lmn
mx_diff = mx - lmx
if ( if (
mx_diff mn_diff or mx_diff # covers all cases below?
or mn_diff # (mx - lmx) > 0 # upward expansion
# or (mn - lmn) < 0 # downward expansion
# or (lmx - mx) > 0 # upward contraction
# or (lmn - mn) < 0 # downward contraction
): ):
# complain about out-of-range outliers which can show up # complain about out-of-range outliers which can show up
# in certain annoying feeds (like ib).. # in certain annoying feeds (like ib)..
@ -703,53 +729,77 @@ def graphics_update_cycle(
f'mn_diff: {mn_diff}\n' f'mn_diff: {mn_diff}\n'
) )
# FAST CHART resize case # TODO: track local liv maxmin without doing a recompute all the
# time..plus, just generally the user is more likely to be
# zoomed out enough on the slow chart that this is never an
# issue (the last datum going out of y-range).
# FAST CHART y-auto-range resize case
elif ( elif (
liv liv
and not chart._static_yrange == 'axis' and not chart._static_yrange == 'axis'
): ):
main_vb = main_viz.plot.vb # NOTE: this auto-yranging approach is a sort of, hybrid,
# between always aligning overlays to the their common ref
# sample and not updating at all:
# - whenever an interaction happens the overlays are scaled
# to one another and thus are ref-point aligned and
# scaled.
# - on treads and range updates due to new mn/mx from last
# datum, we don't scale to the overlayT instead only
# adjusting when the latest datum is outside the previous
# dispersion range.
mn = min(mn, lmn)
mx = max(mx, lmx)
if ( if (
main_vb._ic is None main_vb._in_interact is None
or not main_vb._ic.is_set() or not main_vb._in_interact.is_set()
): ):
yr = (mn, mx) # print(f'SETTING Y-mnmx -> {main_viz.name}: {(mn, mx)}')
# print( this_vb.interact_graphics_cycle(
# f'MAIN VIZ yrange update\n' do_linked_charts=False,
# f'{fqsn}: {yr}' # TODO: we could optionally offer always doing this
# ) # on treads thus always keeping fast-chart overlays
# aligned by their LHS datum?
main_vb._set_yrange( do_overlay_scaling=False,
# TODO: we should probably scale yrange_kwargs={
# the view margin based on the size this_viz: {
# of the true range? This way you can 'yrange': (mn, mx),
# slap in orders outside the current 'range_margin': yrange_margin,
# L1 (only) book range. },
# range_margin=0.1, }
yrange=yr
) )
profiler('main vb y-autorange') profiler('main vb y-autorange')
# SLOW CHART resize case # SLOW CHART y-auto-range resize casd
( # (NOTE: still is still inside the y-range
_, # guard block above!)
hist_liv, # (
_, # _,
_, # hist_liv,
_, # _,
_, # _,
_, # _,
) = hist_viz.incr_info( # _,
ds=ds, # _,
is_1m=True, # ) = hist_viz.incr_info(
) # ds=ds,
profiler('hist `Viz.incr_info()`') # is_1m=True,
# )
# if hist_liv:
# times = hist_viz.shm.array['time']
# last_t = times[-1]
# dt = pendulum.from_timestamp(last_t)
# log.info(
# f'{hist_viz.name} TIMESTEP:'
# f'epoch: {last_t}\n'
# f'datetime: {dt}\n'
# )
# profiler('hist `Viz.incr_info()`')
# TODO: track local liv maxmin without doing a recompute all the
# time..plut, just generally the user is more likely to be
# zoomed out enough on the slow chart that this is never an
# issue (the last datum going out of y-range).
# hist_chart = ds.hist_chart # hist_chart = ds.hist_chart
# if ( # if (
# hist_liv # hist_liv
@ -764,7 +814,8 @@ def graphics_update_cycle(
# XXX: update this every draw cycle to ensure y-axis auto-ranging # XXX: update this every draw cycle to ensure y-axis auto-ranging
# only adjusts when the in-view data co-domain actually expands or # only adjusts when the in-view data co-domain actually expands or
# contracts. # contracts.
varz['last_mx'], varz['last_mn'] = mx, mn # varz['last_mn'] = mn
# varz['last_mx'] = mx
# TODO: a similar, only-update-full-path-on-px-step approach for all # TODO: a similar, only-update-full-path-on-px-step approach for all
# fsp overlays and vlm stuff.. # fsp overlays and vlm stuff..
@ -772,10 +823,12 @@ def graphics_update_cycle(
# run synchronous update on all `Viz` overlays # run synchronous update on all `Viz` overlays
for curve_name, viz in chart._vizs.items(): for curve_name, viz in chart._vizs.items():
if viz.is_ohlc:
continue
# update any overlayed fsp flows # update any overlayed fsp flows
if ( if (
curve_name != fqsn curve_name != fqsn
and not viz.is_ohlc
): ):
update_fsp_chart( update_fsp_chart(
viz, viz,
@ -788,8 +841,7 @@ def graphics_update_cycle(
# px column to give the user the mx/mn # px column to give the user the mx/mn
# range of that set. # range of that set.
if ( if (
curve_name != fqsn liv
and liv
# and not do_px_step # and not do_px_step
# and not do_rt_update # and not do_rt_update
): ):
@ -809,8 +861,14 @@ def graphics_update_cycle(
# TODO: can we unify this with the above loop? # TODO: can we unify this with the above loop?
if vlm_chart: if vlm_chart:
vlm_vizs = vlm_chart._vizs vlm_vizs = vlm_chart._vizs
main_vlm_viz = vlm_vizs['volume'] main_vlm_viz = vlm_vizs['volume']
main_vlm_vb = main_vlm_viz.plot.vb
# TODO: we should probably read this
# from the `Viz.vs: ViewState`!
vlm_yr = main_vlm_vb._yrange
if vlm_yr:
(_, vlm_ymx) = vlm_yrange = vlm_yr
# always update y-label # always update y-label
ds.vlm_sticky.update_from_data( ds.vlm_sticky.update_from_data(
@ -848,16 +906,30 @@ def graphics_update_cycle(
profiler('`main_vlm_viz.update_graphics()`') profiler('`main_vlm_viz.update_graphics()`')
if ( if (
mx_vlm_in_view != varz['last_mx_vlm'] mx_vlm_in_view
and vlm_yr
and mx_vlm_in_view != vlm_ymx
): ):
varz['last_mx_vlm'] = mx_vlm_in_view # in this case we want to scale all overlays in the
# vlm_yr = (0, mx_vlm_in_view * 1.375) # sub-chart but only incrementally update the vlm since
# vlm_chart.view._set_yrange(yrange=vlm_yr) # we already calculated the new range above.
# profiler('`vlm_chart.view._set_yrange()`') # TODO: in theory we can incrementally update all
# overlays as well though it will require iteration of
# them here in the display loop right?
main_vlm_viz.plot.vb.interact_graphics_cycle(
do_overlay_scaling=True,
do_linked_charts=False,
yrange_kwargs={
main_vlm_viz: {
'yrange': vlm_yrange,
# 'range_margin': yrange_margin,
},
},
)
profiler('`vlm_chart.view.interact_graphics_cycle()`')
# update all downstream FSPs # update all downstream FSPs
for curve_name, viz in vlm_vizs.items(): for curve_name, viz in vlm_vizs.items():
if curve_name == 'volume': if curve_name == 'volume':
continue continue
@ -882,10 +954,13 @@ def graphics_update_cycle(
# XXX: without this we get completely # XXX: without this we get completely
# mangled/empty vlm display subchart.. # mangled/empty vlm display subchart..
# fvb = viz.plot.vb # fvb = viz.plot.vb
# fvb._set_yrange( # fvb.interact_graphics_cycle(
# viz=viz, # do_linked_charts=False,
# do_overlay_scaling=False,
# ) # )
profiler(f'vlm `Viz[{viz.name}].plot.vb._set_yrange()`') profiler(
f'Viz[{viz.name}].plot.vb.interact_graphics_cycle()`'
)
# even if we're downsampled bigly # even if we're downsampled bigly
# draw the last datum in the final # draw the last datum in the final
@ -1224,6 +1299,9 @@ async def display_symbol_data(
# to avoid internal pane creation. # to avoid internal pane creation.
# sidepane=False, # sidepane=False,
sidepane=godwidget.search, sidepane=godwidget.search,
draw_kwargs={
'last_step_color': 'original',
},
) )
# ensure the last datum graphic is generated # ensure the last datum graphic is generated
@ -1242,6 +1320,9 @@ async def display_symbol_data(
# in the case of history chart we explicitly set `False` # in the case of history chart we explicitly set `False`
# to avoid internal pane creation. # to avoid internal pane creation.
sidepane=pp_pane, sidepane=pp_pane,
draw_kwargs={
'last_step_color': 'original',
},
) )
rt_viz = rt_chart.get_viz(fqsn) rt_viz = rt_chart.get_viz(fqsn)
pis.setdefault(fqsn, [None, None])[0] = rt_chart.plotItem pis.setdefault(fqsn, [None, None])[0] = rt_chart.plotItem
@ -1308,13 +1389,6 @@ async def display_symbol_data(
name=fqsn, name=fqsn,
axis_title=fqsn, axis_title=fqsn,
) )
# only show a singleton bottom-bottom axis by default.
hist_pi.hideAxis('bottom')
# XXX: TODO: THIS WILL CAUSE A GAP ON OVERLAYS,
# i think it needs to be "removed" instead when there
# are none?
hist_pi.hideAxis('left')
hist_viz = hist_chart.draw_curve( hist_viz = hist_chart.draw_curve(
fqsn, fqsn,
@ -1333,10 +1407,6 @@ async def display_symbol_data(
# for zoom-interaction purposes. # for zoom-interaction purposes.
hist_viz.draw_last(array_key=fqsn) hist_viz.draw_last(array_key=fqsn)
hist_pi.vb.maxmin = partial(
hist_chart.maxmin,
name=fqsn,
)
# TODO: we need a better API to do this.. # TODO: we need a better API to do this..
# specially store ref to shm for lookup in display loop # specially store ref to shm for lookup in display loop
# since only a placeholder of `None` is entered in # since only a placeholder of `None` is entered in
@ -1350,9 +1420,6 @@ async def display_symbol_data(
axis_title=fqsn, axis_title=fqsn,
) )
rt_pi.hideAxis('left')
rt_pi.hideAxis('bottom')
rt_viz = rt_chart.draw_curve( rt_viz = rt_chart.draw_curve(
fqsn, fqsn,
ohlcv, ohlcv,
@ -1365,10 +1432,6 @@ async def display_symbol_data(
color=bg_chart_color, color=bg_chart_color,
last_step_color=bg_last_bar_color, last_step_color=bg_last_bar_color,
) )
rt_pi.vb.maxmin = partial(
rt_chart.maxmin,
name=fqsn,
)
# TODO: we need a better API to do this.. # TODO: we need a better API to do this..
# specially store ref to shm for lookup in display loop # specially store ref to shm for lookup in display loop
@ -1395,7 +1458,9 @@ async def display_symbol_data(
for fqsn, flume in feed.flumes.items(): for fqsn, flume in feed.flumes.items():
# size view to data prior to order mode init # size view to data prior to order mode init
rt_chart.default_view() rt_chart.main_viz.default_view(
do_min_bars=True,
)
rt_linked.graphics_cycle() rt_linked.graphics_cycle()
# TODO: look into this because not sure why it was # TODO: look into this because not sure why it was
@ -1406,7 +1471,9 @@ async def display_symbol_data(
# determine if auto-range adjustements should be made. # determine if auto-range adjustements should be made.
# rt_linked.subplots.pop('volume', None) # rt_linked.subplots.pop('volume', None)
hist_chart.default_view() hist_chart.main_viz.default_view(
do_min_bars=True,
)
hist_linked.graphics_cycle() hist_linked.graphics_cycle()
godwidget.resize_all() godwidget.resize_all()
@ -1449,10 +1516,14 @@ async def display_symbol_data(
# default view adjuments and sidepane alignment # default view adjuments and sidepane alignment
# as final default UX touch. # as final default UX touch.
rt_chart.default_view() rt_chart.main_viz.default_view(
do_min_bars=True,
)
await trio.sleep(0) await trio.sleep(0)
hist_chart.default_view() hist_chart.main_viz.default_view(
do_min_bars=True,
)
hist_viz = hist_chart.get_viz(fqsn) hist_viz = hist_chart.get_viz(fqsn)
await trio.sleep(0) await trio.sleep(0)

View File

@ -21,7 +21,6 @@ Higher level annotation editors.
from __future__ import annotations from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from typing import ( from typing import (
Optional,
TYPE_CHECKING TYPE_CHECKING
) )
@ -67,7 +66,7 @@ class ArrowEditor(Struct):
x: float, x: float,
y: float, y: float,
color='default', color='default',
pointing: Optional[str] = None, pointing: str | None = None,
) -> pg.ArrowItem: ) -> pg.ArrowItem:
''' '''
@ -221,7 +220,7 @@ class LineEditor(Struct):
line: LevelLine = None, line: LevelLine = None,
uuid: str = None, uuid: str = None,
) -> Optional[LevelLine]: ) -> LevelLine | None:
'''Remove a line by refernce or uuid. '''Remove a line by refernce or uuid.
If no lines or ids are provided remove all lines under the If no lines or ids are provided remove all lines under the

View File

@ -23,7 +23,9 @@ from contextlib import asynccontextmanager
from functools import partial from functools import partial
from math import floor from math import floor
from typing import ( from typing import (
Optional, Any, Callable, Awaitable Any,
Callable,
Awaitable,
) )
import trio import trio
@ -263,7 +265,7 @@ class Selection(QComboBox):
def set_icon( def set_icon(
self, self,
key: str, key: str,
icon_name: Optional[str], icon_name: str | None,
) -> None: ) -> None:
self.setItemIcon( self.setItemIcon(
@ -344,7 +346,7 @@ class FieldsForm(QWidget):
name: str, name: str,
font_size: Optional[int] = None, font_size: int | None = None,
font_color: str = 'default_lightest', font_color: str = 'default_lightest',
) -> QtGui.QLabel: ) -> QtGui.QLabel:
@ -469,7 +471,7 @@ def mk_form(
parent: QWidget, parent: QWidget,
fields_schema: dict, fields_schema: dict,
font_size: Optional[int] = None, font_size: int | None = None,
) -> FieldsForm: ) -> FieldsForm:
@ -628,7 +630,7 @@ def mk_fill_status_bar(
parent_pane: QWidget, parent_pane: QWidget,
form: FieldsForm, form: FieldsForm,
pane_vbox: QVBoxLayout, pane_vbox: QVBoxLayout,
label_font_size: Optional[int] = None, label_font_size: int | None = None,
) -> ( ) -> (
# TODO: turn this into a composite? # TODO: turn this into a composite?
@ -738,7 +740,7 @@ def mk_fill_status_bar(
def mk_order_pane_layout( def mk_order_pane_layout(
parent: QWidget, parent: QWidget,
# accounts: dict[str, Optional[str]], # accounts: dict[str, str | None],
) -> FieldsForm: ) -> FieldsForm:

View File

@ -24,7 +24,10 @@ from contextlib import asynccontextmanager as acm
from functools import partial from functools import partial
import inspect import inspect
from itertools import cycle from itertools import cycle
from typing import Optional, AsyncGenerator, Any from typing import (
AsyncGenerator,
Any,
)
import numpy as np import numpy as np
import msgspec import msgspec
@ -80,7 +83,7 @@ def has_vlm(ohlcv: ShmArray) -> bool:
def update_fsp_chart( def update_fsp_chart(
viz, viz,
graphics_name: str, graphics_name: str,
array_key: Optional[str], array_key: str | None,
**kwargs, **kwargs,
) -> None: ) -> None:
@ -476,7 +479,7 @@ class FspAdmin:
target: Fsp, target: Fsp,
conf: dict[str, dict[str, Any]], conf: dict[str, dict[str, Any]],
worker_name: Optional[str] = None, worker_name: str | None = None,
loglevel: str = 'info', loglevel: str = 'info',
) -> (Flume, trio.Event): ) -> (Flume, trio.Event):
@ -608,10 +611,11 @@ async def open_vlm_displays(
linked: LinkedSplits, linked: LinkedSplits,
flume: Flume, flume: Flume,
dvlm: bool = True, dvlm: bool = True,
loglevel: str = 'info',
task_status: TaskStatus[ChartPlotWidget] = trio.TASK_STATUS_IGNORED, task_status: TaskStatus[ChartPlotWidget] = trio.TASK_STATUS_IGNORED,
) -> ChartPlotWidget: ) -> None:
''' '''
Volume subchart displays. Volume subchart displays.
@ -666,7 +670,6 @@ async def open_vlm_displays(
# built-in vlm which we plot ASAP since it's # built-in vlm which we plot ASAP since it's
# usually data provided directly with OHLC history. # usually data provided directly with OHLC history.
shm = ohlcv shm = ohlcv
# ohlc_chart = linked.chart
vlm_chart = linked.add_plot( vlm_chart = linked.add_plot(
name='volume', name='volume',
@ -690,7 +693,14 @@ async def open_vlm_displays(
# the axis on the left it's totally not lined up... # the axis on the left it's totally not lined up...
# show volume units value on LHS (for dinkus) # show volume units value on LHS (for dinkus)
# vlm_chart.hideAxis('right') # vlm_chart.hideAxis('right')
# vlm_chart.showAxis('left') vlm_chart.hideAxis('left')
# TODO: is it worth being able to remove axes (from i guess
# a perf perspective) enough that we can actually do this and
# other axis related calls (for eg. label upddates in the
# display loop) don't raise when a the axis can't be loaded and
# thus would normally cause many label related calls to crash?
# axis = vlm_chart.removeAxis('left')
# send back new chart to caller # send back new chart to caller
task_status.started(vlm_chart) task_status.started(vlm_chart)
@ -704,17 +714,9 @@ async def open_vlm_displays(
# read from last calculated value # read from last calculated value
value = shm.array['volume'][-1] value = shm.array['volume'][-1]
last_val_sticky.update_from_data(-1, value) last_val_sticky.update_from_data(-1, value)
_, _, vlm_curve = vlm_chart.update_graphics_from_flow( _, _, vlm_curve = vlm_viz.update_graphics()
'volume',
)
# size view to data once at outset
vlm_chart.view._set_yrange(
viz=vlm_viz
)
# add axis title # add axis title
axis = vlm_chart.getAxis('right') axis = vlm_chart.getAxis('right')
@ -722,7 +724,6 @@ async def open_vlm_displays(
if dvlm: if dvlm:
tasks_ready = []
# spawn and overlay $ vlm on the same subchart # spawn and overlay $ vlm on the same subchart
dvlm_flume, started = await admin.start_engine_task( dvlm_flume, started = await admin.start_engine_task(
dolla_vlm, dolla_vlm,
@ -736,22 +737,8 @@ async def open_vlm_displays(
}, },
}, },
}, },
# loglevel, loglevel,
) )
tasks_ready.append(started)
# FIXME: we should error on starting the same fsp right
# since it might collide with existing shm.. or wait we
# had this before??
# dolla_vlm
tasks_ready.append(started)
# profiler(f'created shm for fsp actor: {display_name}')
# wait for all engine tasks to startup
async with trio.open_nursery() as n:
for event in tasks_ready:
n.start_soon(event.wait)
# dolla vlm overlay # dolla vlm overlay
# XXX: the main chart already contains a vlm "units" axis # XXX: the main chart already contains a vlm "units" axis
@ -774,10 +761,6 @@ async def open_vlm_displays(
}, },
) )
# TODO: should this maybe be implicit based on input args to
# `.overlay_plotitem()` above?
dvlm_pi.hideAxis('bottom')
# all to be overlayed curve names # all to be overlayed curve names
dvlm_fields = [ dvlm_fields = [
'dolla_vlm', 'dolla_vlm',
@ -827,6 +810,7 @@ async def open_vlm_displays(
) )
assert viz.plot is pi assert viz.plot is pi
await started.wait()
chart_curves( chart_curves(
dvlm_fields, dvlm_fields,
dvlm_pi, dvlm_pi,
@ -835,19 +819,17 @@ async def open_vlm_displays(
step_mode=True, step_mode=True,
) )
# spawn flow rates fsp **ONLY AFTER** the 'dolla_vlm' fsp is # NOTE: spawn flow rates fsp **ONLY AFTER** the 'dolla_vlm' fsp is
# up since this one depends on it. # up since calculating vlm "rates" obvs first requires the
# underlying vlm event feed ;)
fr_flume, started = await admin.start_engine_task( fr_flume, started = await admin.start_engine_task(
flow_rates, flow_rates,
{ # fsp engine conf { # fsp engine conf
'func_name': 'flow_rates', 'func_name': 'flow_rates',
'zero_on_step': True, 'zero_on_step': True,
}, },
# loglevel, loglevel,
) )
await started.wait()
# chart_curves( # chart_curves(
# dvlm_rate_fields, # dvlm_rate_fields,
# dvlm_pi, # dvlm_pi,
@ -859,13 +841,15 @@ async def open_vlm_displays(
# hide the original vlm curve since the $vlm one is now # hide the original vlm curve since the $vlm one is now
# displayed and the curves are effectively the same minus # displayed and the curves are effectively the same minus
# liquidity events (well at least on low OHLC periods - 1s). # liquidity events (well at least on low OHLC periods - 1s).
vlm_curve.hide() # vlm_curve.hide()
vlm_chart.removeItem(vlm_curve) vlm_chart.removeItem(vlm_curve)
vlm_viz = vlm_chart._vizs['volume'] vlm_viz = vlm_chart._vizs['volume']
vlm_viz.render = False
# avoid range sorting on volume once disabled
vlm_chart.view.disable_auto_yrange() vlm_chart.view.disable_auto_yrange()
# NOTE: DON'T DO THIS.
# WHY: we want range sorting on volume for the RHS label!
# -> if you don't want that then use this but likely you
# only will if we decide to drop unit vlm..
# vlm_viz.render = False
# Trade rate overlay # Trade rate overlay
# XXX: requires an additional overlay for # XXX: requires an additional overlay for
@ -888,8 +872,8 @@ async def open_vlm_displays(
}, },
) )
tr_pi.hideAxis('bottom')
await started.wait()
chart_curves( chart_curves(
trade_rate_fields, trade_rate_fields,
tr_pi, tr_pi,

View File

@ -1,5 +1,5 @@
# piker: trading gear for hackers # piker: trading gear for hackers
# Copyright (C) Tyler Goodlet (in stewardship for piker0) # Copyright (C) Tyler Goodlet (in stewardship for pikers)
# 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
@ -14,16 +14,17 @@
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
""" '''
Chart view box primitives Chart view box primitives
""" '''
from __future__ import annotations from __future__ import annotations
from contextlib import asynccontextmanager from contextlib import (
from functools import partial asynccontextmanager,
ExitStack,
)
import time import time
from typing import ( from typing import (
Optional,
Callable, Callable,
TYPE_CHECKING, TYPE_CHECKING,
) )
@ -40,6 +41,7 @@ import trio
from ..log import get_logger from ..log import get_logger
from .._profile import Profiler from .._profile import Profiler
from .._profile import pg_profile_enabled, ms_slower_then from .._profile import pg_profile_enabled, ms_slower_then
from .view_mode import overlay_viewlists
# from ._style import _min_points_to_show # from ._style import _min_points_to_show
from ._editors import SelectRect from ._editors import SelectRect
from . import _event from . import _event
@ -73,7 +75,7 @@ ORDER_MODE = {
async def handle_viewmode_kb_inputs( async def handle_viewmode_kb_inputs(
view: 'ChartView', view: ChartView,
recv_chan: trio.abc.ReceiveChannel, recv_chan: trio.abc.ReceiveChannel,
) -> None: ) -> None:
@ -87,7 +89,7 @@ async def handle_viewmode_kb_inputs(
last = time.time() last = time.time()
action: str action: str
on_next_release: Optional[Callable] = None on_next_release: Callable | None = None
# for quick key sequence-combo pattern matching # for quick key sequence-combo pattern matching
# we have a min_tap period and these should not # we have a min_tap period and these should not
@ -142,6 +144,23 @@ async def handle_viewmode_kb_inputs(
if mods == Qt.ControlModifier: if mods == Qt.ControlModifier:
ctrl = True ctrl = True
# UI REPL-shell
if (
ctrl and key in {
Qt.Key_U,
}
):
import tractor
god = order_mode.godw # noqa
feed = order_mode.feed # noqa
chart = order_mode.chart # noqa
viz = chart.main_viz # noqa
vlm_chart = chart.linked.subplots['volume'] # noqa
vlm_viz = vlm_chart.main_viz # noqa
dvlm_pi = vlm_chart._vizs['dolla_vlm'].plot # noqa
await tractor.breakpoint()
view.interact_graphics_cycle()
# SEARCH MODE # # SEARCH MODE #
# ctlr-<space>/<l> for "lookup", "search" -> open search tree # ctlr-<space>/<l> for "lookup", "search" -> open search tree
if ( if (
@ -169,9 +188,13 @@ async def handle_viewmode_kb_inputs(
# View modes # View modes
if key == Qt.Key_R: if key == Qt.Key_R:
# TODO: set this for all subplots # NOTE: seems that if we don't yield a Qt render
# edge triggered default view activation # cycle then the m4 downsampled curves will show here
view.chart.default_view() # without another reset..
view._viz.default_view()
view.interact_graphics_cycle()
await trio.sleep(0)
view.interact_graphics_cycle()
if len(fast_key_seq) > 1: if len(fast_key_seq) > 1:
# begin matches against sequences # begin matches against sequences
@ -313,7 +336,7 @@ async def handle_viewmode_kb_inputs(
async def handle_viewmode_mouse( async def handle_viewmode_mouse(
view: 'ChartView', view: ChartView,
recv_chan: trio.abc.ReceiveChannel, recv_chan: trio.abc.ReceiveChannel,
) -> None: ) -> None:
@ -359,7 +382,7 @@ class ChartView(ViewBox):
name: str, name: str,
parent: pg.PlotItem = None, parent: pg.PlotItem = None,
static_yrange: Optional[tuple[float, float]] = None, static_yrange: tuple[float, float] | None = None,
**kwargs, **kwargs,
): ):
@ -392,8 +415,13 @@ class ChartView(ViewBox):
self.order_mode: bool = False self.order_mode: bool = False
self.setFocusPolicy(QtCore.Qt.StrongFocus) self.setFocusPolicy(QtCore.Qt.StrongFocus)
self._ic = None self._in_interact: trio.Event | None = None
self._yranger: Callable | None = None self._interact_stack: ExitStack = ExitStack()
# TODO: probably just assign this whenever a new `PlotItem` is
# allocated since they're 1to1 with views..
self._viz: Viz | None = None
self._yrange: tuple[float, float] | None = None
def start_ic( def start_ic(
self, self,
@ -403,10 +431,15 @@ class ChartView(ViewBox):
to any interested task waiters. to any interested task waiters.
''' '''
if self._ic is None: if self._in_interact is None:
chart = self.chart
try: try:
self.chart.pause_all_feeds() self._in_interact = trio.Event()
self._ic = trio.Event()
chart.pause_all_feeds()
self._interact_stack.enter_context(
chart.reset_graphics_caches()
)
except RuntimeError: except RuntimeError:
pass pass
@ -420,11 +453,13 @@ class ChartView(ViewBox):
to any waiters. to any waiters.
''' '''
if self._ic: if self._in_interact:
try: try:
self._ic.set() self._interact_stack.close()
self._ic = None
self.chart.resume_all_feeds() self.chart.resume_all_feeds()
self._in_interact.set()
self._in_interact = None
except RuntimeError: except RuntimeError:
pass pass
@ -432,7 +467,7 @@ class ChartView(ViewBox):
async def open_async_input_handler( async def open_async_input_handler(
self, self,
) -> 'ChartView': ) -> ChartView:
async with ( async with (
_event.open_handlers( _event.open_handlers(
@ -492,7 +527,7 @@ class ChartView(ViewBox):
# don't zoom more then the min points setting # don't zoom more then the min points setting
viz = chart.get_viz(chart.name) viz = chart.get_viz(chart.name)
vl, lbar, rbar, vr = viz.bars_range() _, vl, lbar, rbar, vr, r = viz.datums_range()
# TODO: max/min zoom limits incorporating time step size. # TODO: max/min zoom limits incorporating time step size.
# rl = vr - vl # rl = vr - vl
@ -507,7 +542,7 @@ class ChartView(ViewBox):
# return # return
# actual scaling factor # actual scaling factor
s = 1.015 ** (ev.delta() * -1 / 20) # self.state['wheelScaleFactor']) s = 1.016 ** (ev.delta() * -1 / 20) # self.state['wheelScaleFactor'])
s = [(None if m is False else s) for m in mask] s = [(None if m is False else s) for m in mask]
if ( if (
@ -533,12 +568,13 @@ class ChartView(ViewBox):
# scale_y = 1.3 ** (center.y() * -1 / 20) # scale_y = 1.3 ** (center.y() * -1 / 20)
self.scaleBy(s, center) self.scaleBy(s, center)
# zoom in view-box area
else: else:
# use right-most point of current curve graphic # use right-most point of current curve graphic
xl = viz.graphics.x_last() xl = viz.graphics.x_last()
focal = min( focal = min(
xl, xl,
vr, r,
) )
self._resetTarget() self._resetTarget()
@ -552,7 +588,7 @@ class ChartView(ViewBox):
# update, but i gotta feelin that because this one is signal # update, but i gotta feelin that because this one is signal
# based (and thus not necessarily sync invoked right away) # based (and thus not necessarily sync invoked right away)
# that calling the resize method manually might work better. # that calling the resize method manually might work better.
self.sigRangeChangedManually.emit(mask) # self.sigRangeChangedManually.emit(mask)
# XXX: without this is seems as though sometimes # XXX: without this is seems as though sometimes
# when zooming in from far out (and maybe vice versa?) # when zooming in from far out (and maybe vice versa?)
@ -562,14 +598,15 @@ class ChartView(ViewBox):
# that never seems to happen? Only question is how much this # that never seems to happen? Only question is how much this
# "double work" is causing latency when these missing event # "double work" is causing latency when these missing event
# fires don't happen? # fires don't happen?
self.maybe_downsample_graphics() self.interact_graphics_cycle()
self.interact_graphics_cycle()
ev.accept() ev.accept()
def mouseDragEvent( def mouseDragEvent(
self, self,
ev, ev,
axis: Optional[int] = None, axis: int | None = None,
) -> None: ) -> None:
pos = ev.pos() pos = ev.pos()
@ -581,7 +618,10 @@ class ChartView(ViewBox):
button = ev.button() button = ev.button()
# Ignore axes if mouse is disabled # Ignore axes if mouse is disabled
mouseEnabled = np.array(self.state['mouseEnabled'], dtype=np.float) mouseEnabled = np.array(
self.state['mouseEnabled'],
dtype=np.float,
)
mask = mouseEnabled.copy() mask = mouseEnabled.copy()
if axis is not None: if axis is not None:
mask[1-axis] = 0.0 mask[1-axis] = 0.0
@ -645,9 +685,6 @@ class ChartView(ViewBox):
self.start_ic() self.start_ic()
except RuntimeError: except RuntimeError:
pass pass
# if self._ic is None:
# self.chart.pause_all_feeds()
# self._ic = trio.Event()
if axis == 1: if axis == 1:
self.chart._static_yrange = 'axis' self.chart._static_yrange = 'axis'
@ -664,16 +701,19 @@ class ChartView(ViewBox):
if x is not None or y is not None: if x is not None or y is not None:
self.translateBy(x=x, y=y) self.translateBy(x=x, y=y)
self.sigRangeChangedManually.emit(self.state['mouseEnabled']) # self.sigRangeChangedManually.emit(mask)
# self.state['mouseEnabled']
# )
self.interact_graphics_cycle()
if ev.isFinish(): if ev.isFinish():
self.signal_ic() self.signal_ic()
# self._ic.set() # self._in_interact.set()
# self._ic = None # self._in_interact = None
# self.chart.resume_all_feeds() # self.chart.resume_all_feeds()
# XXX: WHY # # XXX: WHY
ev.accept() # ev.accept()
# WEIRD "RIGHT-CLICK CENTER ZOOM" MODE # WEIRD "RIGHT-CLICK CENTER ZOOM" MODE
elif button & QtCore.Qt.RightButton: elif button & QtCore.Qt.RightButton:
@ -695,7 +735,9 @@ class ChartView(ViewBox):
center = Point(tr.map(ev.buttonDownPos(QtCore.Qt.RightButton))) center = Point(tr.map(ev.buttonDownPos(QtCore.Qt.RightButton)))
self._resetTarget() self._resetTarget()
self.scaleBy(x=x, y=y, center=center) self.scaleBy(x=x, y=y, center=center)
self.sigRangeChangedManually.emit(self.state['mouseEnabled'])
# self.sigRangeChangedManually.emit(self.state['mouseEnabled'])
self.interact_graphics_cycle()
# XXX: WHY # XXX: WHY
ev.accept() ev.accept()
@ -719,19 +761,19 @@ class ChartView(ViewBox):
self, self,
*, *,
yrange: Optional[tuple[float, float]] = None, yrange: tuple[float, float] | None = None,
viz: Viz | None = None, viz: Viz | None = None,
# NOTE: this value pairs (more or less) with L1 label text # NOTE: this value pairs (more or less) with L1 label text
# height offset from from the bid/ask lines. # height offset from from the bid/ask lines.
range_margin: float = 0.09, range_margin: float | None = 0.06,
bars_range: Optional[tuple[int, int, int, int]] = None, bars_range: tuple[int, int, int, int] | None = None,
# flag to prevent triggering sibling charts from the same linked # flag to prevent triggering sibling charts from the same linked
# set from recursion errors. # set from recursion errors.
autoscale_linked_plots: bool = False, autoscale_linked_plots: bool = False,
name: Optional[str] = None, name: str | None = None,
) -> None: ) -> None:
''' '''
@ -743,14 +785,13 @@ class ChartView(ViewBox):
''' '''
name = self.name name = self.name
# print(f'YRANGE ON {name}') # print(f'YRANGE ON {name} -> yrange{yrange}')
profiler = Profiler( profiler = Profiler(
msg=f'`ChartView._set_yrange()`: `{name}`', msg=f'`ChartView._set_yrange()`: `{name}`',
disabled=not pg_profile_enabled(), disabled=not pg_profile_enabled(),
ms_threshold=ms_slower_then, ms_threshold=ms_slower_then,
delayed=True, delayed=True,
) )
set_range = True
chart = self._chart chart = self._chart
# view has been set in 'axis' mode # view has been set in 'axis' mode
@ -759,8 +800,8 @@ class ChartView(ViewBox):
# - disable autoranging # - disable autoranging
# - remove any y range limits # - remove any y range limits
if chart._static_yrange == 'axis': if chart._static_yrange == 'axis':
set_range = False
self.setLimits(yMin=None, yMax=None) self.setLimits(yMin=None, yMax=None)
return
# static y-range has been set likely by # static y-range has been set likely by
# a specialized FSP configuration. # a specialized FSP configuration.
@ -773,8 +814,6 @@ class ChartView(ViewBox):
elif yrange is not None: elif yrange is not None:
ylow, yhigh = yrange ylow, yhigh = yrange
if set_range:
# XXX: only compute the mxmn range # XXX: only compute the mxmn range
# if none is provided as input! # if none is provided as input!
if not yrange: if not yrange:
@ -800,27 +839,47 @@ class ChartView(ViewBox):
ylow, yhigh = yrange ylow, yhigh = yrange
# view margins: stay within a % of the "true range" # always stash last range for diffing by
diff = yhigh - ylow # incremental update calculations BEFORE adding
ylow = ylow - (diff * range_margin) # margin.
yhigh = yhigh + (diff * range_margin) self._yrange = ylow, yhigh
# XXX: this often needs to be unset # view margins: stay within a % of the "true range"
# to get different view modes to operate if range_margin is not None:
# correctly! diff = yhigh - ylow
ylow = max(
ylow - (diff * range_margin),
0,
)
yhigh = min(
yhigh + (diff * range_margin),
yhigh * (1 + range_margin),
)
# print(
# f'set limits {self.name}:\n'
# f'ylow: {ylow}\n'
# f'yhigh: {yhigh}\n'
# )
self.setYRange(
ylow,
yhigh,
padding=0,
)
self.setLimits( self.setLimits(
yMin=ylow, yMin=ylow,
yMax=yhigh, yMax=yhigh,
) )
self.setYRange(ylow, yhigh) self.update()
profiler(f'set limits: {(ylow, yhigh)}')
# LOL: yet anothercucking pg buggg..
# can't use `msg=f'setYRange({ylow}, {yhigh}')`
profiler.finish() profiler.finish()
def enable_auto_yrange( def enable_auto_yrange(
self, self,
viz: Viz, viz: Viz,
src_vb: Optional[ChartView] = None, src_vb: ChartView | None = None,
) -> None: ) -> None:
''' '''
@ -831,18 +890,6 @@ class ChartView(ViewBox):
if src_vb is None: if src_vb is None:
src_vb = self src_vb = self
if self._yranger is None:
self._yranger = partial(
self._set_yrange,
viz=viz,
)
# widget-UIs/splitter(s) resizing
src_vb.sigResized.connect(self._yranger)
# mouse wheel doesn't emit XRangeChanged
src_vb.sigRangeChangedManually.connect(self._yranger)
# re-sampling trigger: # re-sampling trigger:
# TODO: a smarter way to avoid calling this needlessly? # TODO: a smarter way to avoid calling this needlessly?
# 2 things i can think of: # 2 things i can think of:
@ -850,23 +897,20 @@ class ChartView(ViewBox):
# iterate those. # iterate those.
# - only register this when certain downsample-able graphics are # - only register this when certain downsample-able graphics are
# "added to scene". # "added to scene".
src_vb.sigRangeChangedManually.connect( # src_vb.sigRangeChangedManually.connect(
self.maybe_downsample_graphics # self.interact_graphics_cycle
# )
# widget-UIs/splitter(s) resizing
src_vb.sigResized.connect(
self.interact_graphics_cycle
) )
def disable_auto_yrange(self) -> None: def disable_auto_yrange(self) -> None:
# XXX: not entirely sure why we can't de-reg this.. # XXX: not entirely sure why we can't de-reg this..
self.sigResized.disconnect( self.sigResized.disconnect(
self._yranger, self.interact_graphics_cycle
)
self.sigRangeChangedManually.disconnect(
self._yranger,
)
self.sigRangeChangedManually.disconnect(
self.maybe_downsample_graphics
) )
def x_uppx(self) -> float: def x_uppx(self) -> float:
@ -887,57 +931,54 @@ class ChartView(ViewBox):
else: else:
return 0 return 0
def maybe_downsample_graphics( def interact_graphics_cycle(
self, self,
autoscale_overlays: bool = False, *args, # capture Qt signal (slot) inputs
# debug_print: bool = False,
do_linked_charts: bool = True,
do_overlay_scaling: bool = True,
yrange_kwargs: dict[
str,
tuple[float, float],
] | None = None,
): ):
profiler = Profiler( profiler = Profiler(
msg=f'ChartView.maybe_downsample_graphics() for {self.name}', msg=f'ChartView.interact_graphics_cycle() for {self.name}',
disabled=not pg_profile_enabled(), disabled=not pg_profile_enabled(),
ms_threshold=ms_slower_then,
# XXX: important to avoid not seeing underlying # XXX: important to avoid not seeing underlying
# ``.update_graphics_from_flow()`` nested profiling likely # ``Viz.update_graphics()`` nested profiling likely
# due to the way delaying works and garbage collection of # due to the way delaying works and garbage collection of
# the profiler in the delegated method calls. # the profiler in the delegated method calls.
ms_threshold=6, delayed=True,
# ms_threshold=ms_slower_then,
# for hardcore latency checking, comment these flags above.
# disabled=False,
# ms_threshold=4,
) )
# TODO: a faster single-loop-iterator way of doing this XD linked = self.linked
if (
do_linked_charts
and linked
):
plots = {linked.chart.name: linked.chart}
plots |= linked.subplots
else:
chart = self._chart chart = self._chart
plots = {chart.name: chart} plots = {chart.name: chart}
linked = self.linked # TODO: a faster single-loop-iterator way of doing this?
if linked: return overlay_viewlists(
plots |= linked.subplots self._viz,
plots,
for chart_name, chart in plots.items(): profiler,
for name, flow in chart._vizs.items(): do_overlay_scaling=do_overlay_scaling,
do_linked_charts=do_linked_charts,
if ( yrange_kwargs=yrange_kwargs,
not flow.render )
# XXX: super important to be aware of this.
# or not flow.graphics.isVisible()
):
# print(f'skipping {flow.name}')
continue
# pass in no array which will read and render from the last
# passed array (normally provided by the display loop.)
chart.update_graphics_from_flow(name)
# for each overlay on this chart auto-scale the
# y-range to max-min values.
# if autoscale_overlays:
# overlay = chart.pi_overlay
# if overlay:
# for pi in overlay.overlays:
# pi.vb._set_yrange(
# # TODO: get the range once up front...
# # bars_range=br,
# viz=pi.viz,
# )
# profiler('autoscaled linked plots')
profiler(f'<{chart_name}>.update_graphics_from_flow({name})')

View File

@ -19,7 +19,10 @@ Non-shitty labels that don't re-invent the wheel.
""" """
from inspect import isfunction from inspect import isfunction
from typing import Callable, Optional, Any from typing import (
Callable,
Any,
)
import pyqtgraph as pg import pyqtgraph as pg
from PyQt5 import QtGui, QtWidgets from PyQt5 import QtGui, QtWidgets
@ -70,9 +73,7 @@ class Label:
self._fmt_str = fmt_str self._fmt_str = fmt_str
self._view_xy = QPointF(0, 0) self._view_xy = QPointF(0, 0)
self.scene_anchor: Optional[ self.scene_anchor: Callable[..., QPointF] | None = None
Callable[..., QPointF]
] = None
self._x_offset = x_offset self._x_offset = x_offset
@ -164,7 +165,7 @@ class Label:
self, self,
y: float, y: float,
x: Optional[float] = None, x: float | None = None,
) -> None: ) -> None:

View File

@ -22,7 +22,6 @@ from __future__ import annotations
from functools import partial from functools import partial
from math import floor from math import floor
from typing import ( from typing import (
Optional,
Callable, Callable,
TYPE_CHECKING, TYPE_CHECKING,
) )
@ -32,7 +31,7 @@ from pyqtgraph import Point, functions as fn
from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtCore import QPointF from PyQt5.QtCore import QPointF
from ._annotate import qgo_draw_markers, LevelMarker from ._annotate import LevelMarker
from ._anchors import ( from ._anchors import (
vbr_left, vbr_left,
right_axis, right_axis,
@ -295,7 +294,7 @@ class LevelLine(pg.InfiniteLine):
# show y-crosshair again # show y-crosshair again
cursor.show_xhair() cursor.show_xhair()
def get_cursor(self) -> Optional[Cursor]: def get_cursor(self) -> Cursor | None:
chart = self._chart chart = self._chart
cur = chart.linked.cursor cur = chart.linked.cursor
@ -610,11 +609,11 @@ def order_line(
chart, chart,
level: float, level: float,
action: Optional[str] = 'buy', # buy or sell action: str | None = 'buy', # buy or sell
marker_style: Optional[str] = None, marker_style: str | None = None,
level_digits: Optional[float] = 3, level_digits: float | None = 3,
size: Optional[int] = 1, size: int | None = 1,
size_digits: int = 1, size_digits: int = 1,
show_markers: bool = False, show_markers: bool = False,
submit_price: float = None, submit_price: float = None,

View File

@ -21,7 +21,6 @@ Notifications utils.
import os import os
import platform import platform
import subprocess import subprocess
from typing import Optional
import trio import trio
@ -33,7 +32,7 @@ from ..clearing._messages import (
log = get_logger(__name__) log = get_logger(__name__)
_dbus_uid: Optional[str] = '' _dbus_uid: str | None = ''
async def notify_from_ems_status_msg( async def notify_from_ems_status_msg(

View File

@ -28,7 +28,6 @@ from PyQt5.QtCore import (
QLineF, QLineF,
QRectF, QRectF,
) )
from PyQt5.QtWidgets import QGraphicsItem
from PyQt5.QtGui import QPainterPath from PyQt5.QtGui import QPainterPath
from ._curve import FlowGraphic from ._curve import FlowGraphic
@ -91,10 +90,6 @@ class BarItems(FlowGraphic):
"Price range" bars graphics rendered from a OHLC sampled sequence. "Price range" bars graphics rendered from a OHLC sampled sequence.
''' '''
# XXX: causes this weird jitter bug when click-drag panning
# where the path curve will awkwardly flicker back and forth?
cache_mode: int = QGraphicsItem.NoCache
def __init__( def __init__(
self, self,
*args, *args,
@ -113,8 +108,9 @@ class BarItems(FlowGraphic):
''' '''
if self._last_bar_lines: if self._last_bar_lines:
close_arm_line = self._last_bar_lines[-1] close_arm_line = self._last_bar_lines[-1]
return close_arm_line.x2() if close_arm_line else None if close_arm_line:
else: return close_arm_line.x2()
return None return None
# Qt docs: https://doc.qt.io/qt-5/qgraphicsitem.html#boundingRect # Qt docs: https://doc.qt.io/qt-5/qgraphicsitem.html#boundingRect

View File

@ -20,8 +20,9 @@ micro-ORM for coupling ``pydantic`` models with Qt input/output widgets.
""" """
from __future__ import annotations from __future__ import annotations
from typing import ( from typing import (
Optional, Generic, Generic,
TypeVar, Callable, TypeVar,
Callable,
) )
# from pydantic import BaseModel, validator # from pydantic import BaseModel, validator
@ -42,13 +43,11 @@ DataType = TypeVar('DataType')
class Field(GenericModel, Generic[DataType]): class Field(GenericModel, Generic[DataType]):
widget_factory: Optional[ widget_factory: Callable[
Callable[
[QWidget, 'Field'], [QWidget, 'Field'],
QWidget QWidget
] ] | None = None
] value: DataType | None = None
value: Optional[DataType] = None
class Selection(Field[DataType], Generic[DataType]): class Selection(Field[DataType], Generic[DataType]):

View File

@ -22,7 +22,6 @@ from collections import defaultdict
from functools import partial from functools import partial
from typing import ( from typing import (
Callable, Callable,
Optional,
) )
from pyqtgraph.graphicsItems.AxisItem import AxisItem from pyqtgraph.graphicsItems.AxisItem import AxisItem
@ -116,6 +115,7 @@ class ComposedGridLayout:
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0) layout.setSpacing(0)
layout.setMinimumWidth(0)
if name in ('top', 'bottom'): if name in ('top', 'bottom'):
orient = Qt.Vertical orient = Qt.Vertical
@ -125,7 +125,11 @@ class ComposedGridLayout:
layout.setOrientation(orient) layout.setOrientation(orient)
self.insert_plotitem(0, pi) self.insert_plotitem(
0,
pi,
remove_axes=False,
)
# insert surrounding linear layouts into the parent pi's layout # insert surrounding linear layouts into the parent pi's layout
# such that additional axes can be appended arbitrarily without # such that additional axes can be appended arbitrarily without
@ -140,7 +144,9 @@ class ComposedGridLayout:
assert linlayout.itemAt(0) is axis assert linlayout.itemAt(0) is axis
# XXX: see comment in ``.insert_plotitem()``... # XXX: see comment in ``.insert_plotitem()``...
# our `PlotItem.removeAxis()` does this internally.
# pi.layout.removeItem(axis) # pi.layout.removeItem(axis)
pi.layout.addItem(linlayout, *index) pi.layout.addItem(linlayout, *index)
layout = pi.layout.itemAt(*index) layout = pi.layout.itemAt(*index)
assert layout is linlayout assert layout is linlayout
@ -165,6 +171,8 @@ class ComposedGridLayout:
index: int, index: int,
plotitem: PlotItem, plotitem: PlotItem,
remove_axes: bool = False,
) -> tuple[int, list[AxisItem]]: ) -> tuple[int, list[AxisItem]]:
''' '''
Place item at index by inserting all axes into the grid Place item at index by inserting all axes into the grid
@ -193,25 +201,19 @@ class ComposedGridLayout:
axis_view = axis.linkedView() axis_view = axis.linkedView()
assert axis_view is plotitem.vb assert axis_view is plotitem.vb
if ( # if (
not axis.isVisible() # not axis.isVisible()
# XXX: we never skip moving the axes for the *root* # # XXX: we never skip moving the axes for the *root*
# plotitem inserted (even if not shown) since we need to # # plotitem inserted (even if not shown) since we need to
# move all the hidden axes into linear sub-layouts for # # move all the hidden axes into linear sub-layouts for
# that "central" plot in the overlay. Also if we don't # # that "central" plot in the overlay. Also if we don't
# do it there's weird geomoetry calc offsets that make # # do it there's weird geomoetry calc offsets that make
# view coords slightly off somehow .. smh # # view coords slightly off somehow .. smh
and not len(self.pitems) == 0 # and not len(self.pitems) == 0
): # ):
continue # print(f'SKIPPING MOVE: {plotitem.name}:{name} -> {axis}')
# continue
# XXX: Remove old axis?
# No, turns out we don't need this?
# DON'T UNLINK IT since we need the original ``ViewBox`` to
# still drive it with events/handlers B)
# popped = plotitem.removeAxis(name, unlink=False)
# assert axis is popped
# invert insert index for layouts which are # invert insert index for layouts which are
# not-left-to-right, top-to-bottom insert oriented # not-left-to-right, top-to-bottom insert oriented
@ -225,6 +227,16 @@ class ComposedGridLayout:
self._register_item(index, plotitem) self._register_item(index, plotitem)
if remove_axes:
for name, axis_info in plotitem.axes.copy().items():
axis = axis_info['item']
# XXX: Remove old axis?
# No, turns out we don't need this?
# DON'T UNLINK IT since we need the original ``ViewBox`` to
# still drive it with events/handlers B)
popped = plotitem.removeAxis(name, unlink=False)
assert axis is popped
return (index, inserted_axes) return (index, inserted_axes)
def append_plotitem( def append_plotitem(
@ -246,7 +258,7 @@ class ComposedGridLayout:
plot: PlotItem, plot: PlotItem,
name: str, name: str,
) -> Optional[AxisItem]: ) -> AxisItem | None:
''' '''
Retrieve the named axis for overlayed ``plot`` or ``None`` Retrieve the named axis for overlayed ``plot`` or ``None``
if axis for that name is not shown. if axis for that name is not shown.
@ -321,7 +333,7 @@ class PlotItemOverlay:
def add_plotitem( def add_plotitem(
self, self,
plotitem: PlotItem, plotitem: PlotItem,
index: Optional[int] = None, index: int | None = None,
# event/signal names which will be broadcasted to all added # event/signal names which will be broadcasted to all added
# (relayee) ``PlotItem``s (eg. ``ViewBox.mouseDragEvent``). # (relayee) ``PlotItem``s (eg. ``ViewBox.mouseDragEvent``).
@ -376,7 +388,7 @@ class PlotItemOverlay:
# TODO: drop this viewbox specific input and # TODO: drop this viewbox specific input and
# allow a predicate to be passed in by user. # allow a predicate to be passed in by user.
axis: 'Optional[int]' = None, axis: int | None = None,
*, *,
@ -487,10 +499,10 @@ class PlotItemOverlay:
else: else:
insert_index, axes = self.layout.insert_plotitem(index, plotitem) insert_index, axes = self.layout.insert_plotitem(index, plotitem)
plotitem.setGeometry(root.vb.sceneBoundingRect()) plotitem.vb.setGeometry(root.vb.sceneBoundingRect())
def size_to_viewbox(vb: 'ViewBox'): def size_to_viewbox(vb: 'ViewBox'):
plotitem.setGeometry(vb.sceneBoundingRect()) plotitem.vb.setGeometry(root.vb.sceneBoundingRect())
root.vb.sigResized.connect(size_to_viewbox) root.vb.sigResized.connect(size_to_viewbox)

View File

@ -22,8 +22,6 @@ Generally, our does not require "scentific precision" for pixel perfect
view transforms. view transforms.
""" """
from typing import Optional
import pyqtgraph as pg import pyqtgraph as pg
from ._axes import Axis from ._axes import Axis
@ -47,9 +45,10 @@ def invertQTransform(tr):
def _do_overrides() -> None: def _do_overrides() -> None:
"""Dooo eeet. '''
Dooo eeet.
""" '''
# we don't care about potential fp issues inside Qt # we don't care about potential fp issues inside Qt
pg.functions.invertQTransform = invertQTransform pg.functions.invertQTransform = invertQTransform
pg.PlotItem = PlotItem pg.PlotItem = PlotItem
@ -91,7 +90,7 @@ class PlotItem(pg.PlotItem):
title=None, title=None,
viewBox=None, viewBox=None,
axisItems=None, axisItems=None,
default_axes=['left', 'bottom'], default_axes=['right', 'bottom'],
enableMenu=True, enableMenu=True,
**kargs **kargs
): ):
@ -119,7 +118,7 @@ class PlotItem(pg.PlotItem):
name: str, name: str,
unlink: bool = True, unlink: bool = True,
) -> Optional[pg.AxisItem]: ) -> pg.AxisItem | None:
""" """
Remove an axis from the contained axis items Remove an axis from the contained axis items
by ```name: str```. by ```name: str```.
@ -130,7 +129,7 @@ class PlotItem(pg.PlotItem):
If the ``unlink: bool`` is set to ``False`` then the axis will If the ``unlink: bool`` is set to ``False`` then the axis will
stay linked to its view and will only be removed from the stay linked to its view and will only be removed from the
layoutonly be removed from the layout. layout.
If no axis with ``name: str`` is found then this is a noop. If no axis with ``name: str`` is found then this is a noop.
@ -144,7 +143,10 @@ class PlotItem(pg.PlotItem):
axis = entry['item'] axis = entry['item']
self.layout.removeItem(axis) self.layout.removeItem(axis)
axis.scene().removeItem(axis) scn = axis.scene()
if scn:
scn.removeItem(axis)
if unlink: if unlink:
axis.unlinkFromView() axis.unlinkFromView()
@ -166,14 +168,14 @@ class PlotItem(pg.PlotItem):
def setAxisItems( def setAxisItems(
self, self,
# XXX: yeah yeah, i know we can't use type annots like this yet. # XXX: yeah yeah, i know we can't use type annots like this yet.
axisItems: Optional[dict[str, pg.AxisItem]] = None, axisItems: dict[str, pg.AxisItem] | None = None,
add_to_layout: bool = True, add_to_layout: bool = True,
default_axes: list[str] = ['left', 'bottom'], default_axes: list[str] = ['left', 'bottom'],
): ):
""" '''
Override axis item setting to only Override axis item setting to only what is passed in.
""" '''
axisItems = axisItems or {} axisItems = axisItems or {}
# XXX: wth is is this even saying?!? # XXX: wth is is this even saying?!?

View File

@ -25,7 +25,6 @@ from functools import partial
from math import floor, copysign from math import floor, copysign
from typing import ( from typing import (
Callable, Callable,
Optional,
TYPE_CHECKING, TYPE_CHECKING,
) )
@ -170,12 +169,12 @@ class SettingsPane:
limit_label: QLabel limit_label: QLabel
# encompasing high level namespace # encompasing high level namespace
order_mode: Optional['OrderMode'] = None # typing: ignore # noqa order_mode: OrderMode | None = None # typing: ignore # noqa
def set_accounts( def set_accounts(
self, self,
names: list[str], names: list[str],
sizes: Optional[list[float]] = None, sizes: list[float] | None = None,
) -> None: ) -> None:
combo = self.form.fields['account'] combo = self.form.fields['account']
@ -540,8 +539,8 @@ class Nav(Struct):
charts: dict[int, ChartPlotWidget] charts: dict[int, ChartPlotWidget]
pp_labels: dict[str, Label] = {} pp_labels: dict[str, Label] = {}
size_labels: dict[str, Label] = {} size_labels: dict[str, Label] = {}
lines: dict[str, Optional[LevelLine]] = {} lines: dict[str, LevelLine | None] = {}
level_markers: dict[str, Optional[LevelMarker]] = {} level_markers: dict[str, LevelMarker | None] = {}
color: str = 'default_lightest' color: str = 'default_lightest'
def update_ui( def update_ui(
@ -550,7 +549,7 @@ class Nav(Struct):
price: float, price: float,
size: float, size: float,
slots_used: float, slots_used: float,
size_digits: Optional[int] = None, size_digits: int | None = None,
) -> None: ) -> None:
''' '''
@ -847,7 +846,7 @@ class PositionTracker:
def update_from_pp( def update_from_pp(
self, self,
position: Optional[Position] = None, position: Position | None = None,
set_as_startup: bool = False, set_as_startup: bool = False,
) -> None: ) -> None:

View File

@ -51,7 +51,20 @@ log = get_logger(__name__)
class Renderer(msgspec.Struct): class Renderer(msgspec.Struct):
'''
Low(er) level interface for converting a source, real-time updated,
data buffer (usually held in a ``ShmArray``) to a graphics data
format usable by `Qt`.
A renderer reads in context-specific source data using a ``Viz``,
formats that data to a 2D-xy pre-graphics format using
a ``IncrementalFormatter``, then renders that data to a set of
output graphics objects normally a ``.ui._curve.FlowGraphics``
sub-type to which the ``Renderer.path`` is applied and further "last
datum" graphics are updated from the source buffer's latest
sample(s).
'''
viz: Viz viz: Viz
fmtr: IncrementalFormatter fmtr: IncrementalFormatter
@ -179,6 +192,10 @@ class Renderer(msgspec.Struct):
) = fmt_out ) = fmt_out
if not x_1d.size:
log.warning(f'{array_key} has no `.size`?')
return
# redraw conditions # redraw conditions
if ( if (
prepend_length > 0 prepend_length > 0
@ -195,7 +212,7 @@ class Renderer(msgspec.Struct):
fast_path: QPainterPath = self.fast_path fast_path: QPainterPath = self.fast_path
reset: bool = False reset: bool = False
self.viz.yrange = None self.viz.ds_yrange = None
# redraw the entire source data if we have either of: # redraw the entire source data if we have either of:
# - no prior path graphic rendered or, # - no prior path graphic rendered or,
@ -218,7 +235,7 @@ class Renderer(msgspec.Struct):
) )
if ds_out is not None: if ds_out is not None:
x_1d, y_1d, ymn, ymx = ds_out x_1d, y_1d, ymn, ymx = ds_out
self.viz.yrange = ymn, ymx self.viz.ds_yrange = ymn, ymx
# print(f'{self.viz.name} post ds: ymn, ymx: {ymn},{ymx}') # print(f'{self.viz.name} post ds: ymn, ymx: {ymn},{ymx}')
reset = True reset = True

View File

@ -35,7 +35,6 @@ from collections import defaultdict
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from functools import partial from functools import partial
from typing import ( from typing import (
Optional,
Callable, Callable,
Awaitable, Awaitable,
Sequence, Sequence,
@ -178,8 +177,8 @@ class CompleterView(QTreeView):
def resize_to_results( def resize_to_results(
self, self,
w: Optional[float] = 0, w: float | None = 0,
h: Optional[float] = None, h: float | None = None,
) -> None: ) -> None:
model = self.model() model = self.model()
@ -380,7 +379,7 @@ class CompleterView(QTreeView):
self, self,
section: str, section: str,
) -> Optional[QModelIndex]: ) -> QModelIndex | None:
''' '''
Find the *first* depth = 1 section matching ``section`` in Find the *first* depth = 1 section matching ``section`` in
the tree and return its index. the tree and return its index.
@ -504,7 +503,7 @@ class CompleterView(QTreeView):
def show_matches( def show_matches(
self, self,
wh: Optional[tuple[float, float]] = None, wh: tuple[float, float] | None = None,
) -> None: ) -> None:
@ -529,7 +528,7 @@ class SearchBar(Edit):
self, self,
parent: QWidget, parent: QWidget,
godwidget: QWidget, godwidget: QWidget,
view: Optional[CompleterView] = None, view: CompleterView | None = None,
**kwargs, **kwargs,
) -> None: ) -> None:
@ -708,7 +707,7 @@ class SearchWidget(QtWidgets.QWidget):
self, self,
clear_to_cache: bool = True, clear_to_cache: bool = True,
) -> Optional[str]: ) -> str | None:
''' '''
Attempt to load and switch the current selected Attempt to load and switch the current selected
completion result to the affiliated chart app. completion result to the affiliated chart app.
@ -1167,7 +1166,7 @@ async def register_symbol_search(
provider_name: str, provider_name: str,
search_routine: Callable, search_routine: Callable,
pause_period: Optional[float] = None, pause_period: float | None = None,
) -> AsyncIterator[dict]: ) -> AsyncIterator[dict]:

View File

@ -18,7 +18,7 @@
Qt UI styling. Qt UI styling.
''' '''
from typing import Optional, Dict from typing import Dict
import math import math
import pyqtgraph as pg import pyqtgraph as pg
@ -52,7 +52,7 @@ class DpiAwareFont:
# TODO: move to config # TODO: move to config
name: str = 'Hack', name: str = 'Hack',
font_size: str = 'default', font_size: str = 'default',
# size_in_inches: Optional[float] = None,
) -> None: ) -> None:
self.name = name self.name = name
self._qfont = QtGui.QFont(name) self._qfont = QtGui.QFont(name)
@ -91,13 +91,14 @@ class DpiAwareFont:
def px_size(self) -> int: def px_size(self) -> int:
return self._qfont.pixelSize() return self._qfont.pixelSize()
def configure_to_dpi(self, screen: Optional[QtGui.QScreen] = None): def configure_to_dpi(self, screen: QtGui.QScreen | None = None):
"""Set an appropriately sized font size depending on the screen DPI. '''
Set an appropriately sized font size depending on the screen DPI.
If we end up needing to generalize this more here there are resources If we end up needing to generalize this more here there are resources
listed in the script in ``snippets/qt_screen_info.py``. listed in the script in ``snippets/qt_screen_info.py``.
""" '''
if screen is None: if screen is None:
screen = self.screen screen = self.screen

View File

@ -23,7 +23,6 @@ import signal
import time import time
from typing import ( from typing import (
Callable, Callable,
Optional,
Union, Union,
) )
import uuid import uuid
@ -64,9 +63,9 @@ class MultiStatus:
self, self,
msg: str, msg: str,
final_msg: Optional[str] = None, final_msg: str | None = None,
clear_on_next: bool = False, clear_on_next: bool = False,
group_key: Optional[Union[bool, str]] = False, group_key: Union[bool, str] | None = False,
) -> Union[Callable[..., None], str]: ) -> Union[Callable[..., None], str]:
''' '''
@ -178,11 +177,11 @@ class MainWindow(QMainWindow):
self.setWindowTitle(self.title) self.setWindowTitle(self.title)
# set by runtime after `trio` is engaged. # set by runtime after `trio` is engaged.
self.godwidget: Optional[GodWidget] = None self.godwidget: GodWidget | None = None
self._status_bar: QStatusBar = None self._status_bar: QStatusBar = None
self._status_label: QLabel = None self._status_label: QLabel = None
self._size: Optional[tuple[int, int]] = None self._size: tuple[int, int] | None = None
@property @property
def mode_label(self) -> QLabel: def mode_label(self) -> QLabel:
@ -289,7 +288,7 @@ class MainWindow(QMainWindow):
def configure_to_desktop( def configure_to_desktop(
self, self,
size: Optional[tuple[int, int]] = None, size: tuple[int, int] | None = None,
) -> None: ) -> None:
''' '''

View File

@ -25,7 +25,6 @@ from functools import partial
from pprint import pformat from pprint import pformat
import time import time
from typing import ( from typing import (
Optional,
Callable, Callable,
Any, Any,
TYPE_CHECKING, TYPE_CHECKING,
@ -129,7 +128,7 @@ class OrderMode:
trackers: dict[str, PositionTracker] trackers: dict[str, PositionTracker]
# switched state, the current position # switched state, the current position
current_pp: Optional[PositionTracker] = None current_pp: PositionTracker | None = None
active: bool = False active: bool = False
name: str = 'order' name: str = 'order'
dialogs: dict[str, Dialog] = field(default_factory=dict) dialogs: dict[str, Dialog] = field(default_factory=dict)
@ -139,7 +138,7 @@ class OrderMode:
'buy': 'buy_green', 'buy': 'buy_green',
'sell': 'sell_red', 'sell': 'sell_red',
} }
_staged_order: Optional[Order] = None _staged_order: Order | None = None
def on_level_change_update_next_order_info( def on_level_change_update_next_order_info(
self, self,
@ -180,7 +179,7 @@ class OrderMode:
def new_line_from_order( def new_line_from_order(
self, self,
order: Order, order: Order,
chart: Optional[ChartPlotWidget] = None, chart: ChartPlotWidget | None = None,
**line_kwargs, **line_kwargs,
) -> LevelLine: ) -> LevelLine:
@ -340,7 +339,7 @@ class OrderMode:
def submit_order( def submit_order(
self, self,
send_msg: bool = True, send_msg: bool = True,
order: Optional[Order] = None, order: Order | None = None,
) -> Dialog: ) -> Dialog:
''' '''
@ -452,7 +451,7 @@ class OrderMode:
def on_submit( def on_submit(
self, self,
uuid: str, uuid: str,
order: Optional[Order] = None, order: Order | None = None,
) -> Dialog: ) -> Dialog:
''' '''
@ -496,7 +495,7 @@ class OrderMode:
price: float, price: float,
time_s: float, time_s: float,
pointing: Optional[str] = None, pointing: str | None = None,
) -> None: ) -> None:
''' '''

View File

@ -0,0 +1,899 @@
# piker: trading gear for hackers
# Copyright (C) Tyler Goodlet (in stewardship for pikers)
# 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/>.
'''
Overlay (aka multi-chart) UX machinery.
'''
from __future__ import annotations
from typing import (
Any,
Literal,
TYPE_CHECKING,
)
import numpy as np
import pendulum
import pyqtgraph as pg
from ..data.types import Struct
from ..data._pathops import slice_from_time
from ..log import get_logger
from .._profile import Profiler
if TYPE_CHECKING:
from ._chart import ChartPlotWidget
from ._dataviz import Viz
from ._interaction import ChartView
log = get_logger(__name__)
class OverlayT(Struct):
'''
An overlay co-domain range transformer.
Used to translate and apply a range from one y-range
to another based on a returns logarithm:
R(ymn, ymx, yref) = (ymx - yref)/yref
which gives the log-scale multiplier, and
ymx_t = yref * (1 + R)
which gives the inverse to translate to the same value
in the target co-domain.
'''
viz: Viz | None = None
start_t: float | None = None
# % "range" computed from some ref value to the mn/mx
rng: float | None = None
in_view: np.ndarray | None = None
# pinned-minor curve modified mn and max for the major dispersion
# curve due to one series being shorter and the pin + scaling from
# that pin point causing the original range to have to increase.
y_val: float | None = None
def apply_r(
self,
y_ref: float, # reference value for dispersion metric
) -> float:
return y_ref * (1 + self.rng)
def intersect_from_longer(
start_t_first: float,
in_view_first: np.ndarray,
start_t_second: float,
in_view_second: np.ndarray,
step: float,
) -> np.ndarray:
tdiff = start_t_first - start_t_second
if tdiff == 0:
return False
i: int = 0
# first time series has an "earlier" first time stamp then the 2nd.
# aka 1st is "shorter" then the 2nd.
if tdiff > 0:
longer = in_view_second
find_t = start_t_first
i = 1
# second time series has an "earlier" first time stamp then the 1st.
# aka 2nd is "shorter" then the 1st.
elif tdiff < 0:
longer = in_view_first
find_t = start_t_second
i = 0
slc = slice_from_time(
arr=longer,
start_t=find_t,
stop_t=find_t,
step=step,
)
return (
longer[slc.start],
find_t,
i,
)
def _maybe_calc_yrange(
viz: Viz,
yrange_kwargs: dict[Viz, dict[str, Any]],
profiler: Profiler,
chart_name: str,
) -> tuple[
slice,
dict,
] | None:
if not viz.render:
return
# pass in no array which will read and render from the last
# passed array (normally provided by the display loop.)
in_view, i_read_range, _ = viz.update_graphics()
if not in_view:
return
profiler(f'{viz.name}@{chart_name} `Viz.update_graphics()`')
# check if explicit yrange (kwargs) was passed in by the caller
yrange_kwargs = yrange_kwargs.get(viz) if yrange_kwargs else None
if yrange_kwargs is not None:
read_slc = slice(*i_read_range)
else:
out = viz.maxmin(i_read_range=i_read_range)
if out is None:
log.warning(f'No yrange provided for {viz.name}!?')
return
(
_, # ixrng,
read_slc,
yrange
) = out
profiler(f'{viz.name}@{chart_name} `Viz.maxmin()`')
yrange_kwargs = {'yrange': yrange}
return (
read_slc,
yrange_kwargs,
)
def overlay_viewlists(
active_viz: Viz,
plots: dict[str, ChartPlotWidget],
profiler: Profiler,
# public config ctls
do_linked_charts: bool = True,
do_overlay_scaling: bool = True,
yrange_kwargs: dict[
str,
tuple[float, float],
] | None = None,
method: Literal[
'loglin_ref_to_curve',
'loglin_ref_to_first',
'mxmn',
'solo',
] = 'loglin_ref_to_curve',
# internal debug
debug_print: bool = False,
) -> None:
'''
Calculate and apply y-domain (axis y-range) multi-curve overlay adjustments
a set of ``plots`` based on the requested ``method``.
'''
chart_name: str
chart: ChartPlotWidget
for chart_name, chart in plots.items():
overlay_viz_items = chart._vizs.items()
# Common `PlotItem` maxmin table; presumes that some path
# graphics (and thus their backing data sets) are in the
# same co-domain and view box (since the were added
# a separate graphics objects to a common plot) and thus can
# be sorted as one set per plot.
mxmns_by_common_pi: dict[
pg.PlotItem,
tuple[float, float],
] = {}
# proportional group auto-scaling per overlay set.
# -> loop through overlays on each multi-chart widget
# and scale all y-ranges based on autoscale config.
# -> for any "group" overlay we want to dispersion normalize
# and scale minor charts onto the major chart: the chart
# with the most dispersion in the set.
# ONLY auto-yrange the viz mapped to THIS view box
if (
not do_overlay_scaling
or len(overlay_viz_items) < 2
):
viz = active_viz
out = _maybe_calc_yrange(
viz,
yrange_kwargs,
profiler,
chart_name,
)
if out is None:
continue
read_slc, yrange_kwargs = out
viz.plot.vb._set_yrange(**yrange_kwargs)
profiler(f'{viz.name}@{chart_name} single curve yrange')
if debug_print:
print(f'ONLY ranging THIS viz: {viz.name}')
# don't iterate overlays, just move to next chart
continue
if debug_print:
divstr = '#'*46
print(
f'BEGIN UX GRAPHICS CYCLE: @{chart_name}\n'
+
divstr
+
'\n'
)
# create a group overlay log-linearized y-range transform to
# track and eventually inverse transform all overlay curves
# to a common target max dispersion range.
dnt = OverlayT()
upt = OverlayT()
# collect certain flows have grapics objects **in seperate
# plots/viewboxes** into groups and do a common calc to
# determine auto-ranging input for `._set_yrange()`.
# this is primarly used for our so called "log-linearized
# multi-plot" overlay technique.
overlay_table: dict[
float,
tuple[
ChartView,
Viz,
float, # y start
float, # y min
float, # y max
float, # y median
slice, # in-view array slice
np.ndarray, # in-view array
float, # returns up scalar
float, # return down scalar
],
] = {}
# multi-curve overlay processing stage
for name, viz in overlay_viz_items:
out = _maybe_calc_yrange(
viz,
yrange_kwargs,
profiler,
chart_name,
)
if out is None:
continue
read_slc, yrange_kwargs = out
yrange = yrange_kwargs['yrange']
pi = viz.plot
# handle multiple graphics-objs per viewbox cases
mxmn = mxmns_by_common_pi.get(pi)
if mxmn:
yrange = mxmns_by_common_pi[pi] = (
min(yrange[0], mxmn[0]),
max(yrange[1], mxmn[1]),
)
else:
mxmns_by_common_pi[pi] = yrange
profiler(f'{viz.name}@{chart_name} common pi sort')
# non-overlay group case
if (
not viz.is_ohlc
or method == 'solo'
):
pi.vb._set_yrange(yrange=yrange)
profiler(
f'{viz.name}@{chart_name} simple std `._set_yrange()`'
)
continue
# handle overlay log-linearized group scaling cases
# TODO: a better predicate here, likely something
# to do with overlays and their settings..
# TODO: we probably eventually might want some other
# charts besides OHLC?
else:
ymn, ymx = yrange
# determine start datum in view
in_view = viz.vs.in_view
if in_view.size < 2:
if debug_print:
print(f'{viz.name} not in view?')
continue
row_start = in_view[0]
if viz.is_ohlc:
y_ref = row_start['open']
else:
y_ref = row_start[viz.name]
profiler(f'{viz.name}@{chart_name} MINOR curve median')
key = 'open' if viz.is_ohlc else viz.name
start_t = row_start['time']
# returns scalars
r_up = (ymx - y_ref) / y_ref
r_down = (ymn - y_ref) / y_ref
disp = r_up - r_down
msg = (
f'Viz[{viz.name}][{key}]: @{chart_name}\n'
f' .yrange = {viz.vs.yrange}\n'
f' .xrange = {viz.vs.xrange}\n\n'
f'start_t: {start_t}\n'
f'y_ref: {y_ref}\n'
f'ymn: {ymn}\n'
f'ymx: {ymx}\n'
f'r_up: {r_up}\n'
f'r_down: {r_down}\n'
f'(full) disp: {disp}\n'
)
profiler(msg)
if debug_print:
print(msg)
# track the "major" curve as the curve with most
# dispersion.
if (
dnt.rng is None
or (
r_down < dnt.rng
and r_down < 0
)
):
dnt.viz = viz
dnt.rng = r_down
dnt.in_view = in_view
dnt.start_t = in_view[0]['time']
dnt.y_val = ymn
profiler(f'NEW DOWN: {viz.name}@{chart_name} r: {r_down}')
else:
# minor in the down swing range so check that if
# we apply the current rng to the minor that it
# doesn't go outside the current range for the major
# otherwise we recompute the minor's range (when
# adjusted for it's intersect point to be the new
# major's range.
intersect = intersect_from_longer(
dnt.start_t,
dnt.in_view,
start_t,
in_view,
viz.index_step(),
)
profiler(f'{viz.name}@{chart_name} intersect by t')
if intersect:
longer_in_view, _t, i = intersect
scaled_mn = dnt.apply_r(y_ref)
if scaled_mn > ymn:
# after major curve scaling we detected
# the minor curve is still out of range
# so we need to adjust the major's range
# to include the new composed range.
y_maj_ref = longer_in_view[key]
new_major_ymn = y_maj_ref * (1 + r_down)
# rewrite the major range to the new
# minor-pinned-to-major range and mark
# the transform as "virtual".
msg = (
f'EXPAND DOWN bc {viz.name}@{chart_name}\n'
f'y_start epoch time @ {_t}:\n'
f'y_maj_ref @ {_t}: {y_maj_ref}\n'
f'R: {dnt.rng} -> {r_down}\n'
f'MN: {dnt.y_val} -> {new_major_ymn}\n'
)
dnt.rng = r_down
dnt.y_val = new_major_ymn
profiler(msg)
if debug_print:
print(msg)
# is the current up `OverlayT` not yet defined or
# the current `r_up` greater then the previous max.
if (
upt.rng is None
or (
r_up > upt.rng
and r_up > 0
)
):
upt.rng = r_up
upt.viz = viz
upt.in_view = in_view
upt.start_t = in_view[0]['time']
upt.y_val = ymx
profiler(f'NEW UP: {viz.name}@{chart_name} r: {r_up}')
else:
intersect = intersect_from_longer(
upt.start_t,
upt.in_view,
start_t,
in_view,
viz.index_step(),
)
profiler(f'{viz.name}@{chart_name} intersect by t')
if intersect:
longer_in_view, _t, i = intersect
# after major curve scaling we detect if
# the minor curve is still out of range
# so we need to adjust the major's range
# to include the new composed range.
scaled_mx = upt.apply_r(y_ref)
if scaled_mx < ymx:
y_maj_ref = longer_in_view[key]
new_major_ymx = y_maj_ref * (1 + r_up)
# rewrite the major range to the new
# minor-pinned-to-major range and mark
# the transform as "virtual".
msg = (
f'EXPAND UP bc {viz.name}@{chart_name}:\n'
f'y_maj_ref @ {_t}: {y_maj_ref}\n'
f'R: {upt.rng} -> {r_up}\n'
f'MX: {upt.y_val} -> {new_major_ymx}\n'
)
upt.rng = r_up
upt.y_val = new_major_ymx
profiler(msg)
print(msg)
# register curves by a "full" dispersion metric for
# later sort order in the overlay (technique
# ) application loop below.
overlay_table[disp] = (
viz.plot.vb,
viz,
y_ref,
ymn,
ymx,
read_slc,
in_view,
r_up,
r_down,
)
profiler(f'{viz.name}@{chart_name} yrange scan complete')
# __ END OF scan phase (loop) __
# NOTE: if no there were no overlay charts
# detected/collected (could be either no group detected or
# chart with a single symbol, thus a single viz/overlay)
# then we ONLY set the mone chart's (viz) yrange and short
# circuit to the next chart in the linked charts loop. IOW
# there's no reason to go through the overlay dispersion
# scaling in the next loop below when only one curve is
# detected.
if (
not mxmns_by_common_pi
and len(overlay_table) < 2
):
if debug_print:
print(f'ONLY ranging major: {viz.name}')
out = _maybe_calc_yrange(
viz,
yrange_kwargs,
profiler,
chart_name,
)
if out is None:
continue
read_slc, yrange_kwargs = out
viz.plot.vb._set_yrange(**yrange_kwargs)
profiler(f'{viz.name}@{chart_name} single curve yrange')
# move to next chart in linked set since
# no overlay transforming is needed.
continue
elif (
mxmns_by_common_pi
and not overlay_table
):
# move to next chart in linked set since
# no overlay transforming is needed.
continue
profiler('`Viz` curve (first) scan phase complete\n')
r_up_mx: float
r_dn_mn: float
mx_disp = max(overlay_table)
if debug_print:
# print overlay table in descending dispersion order
msg = 'overlays in dispersion order:\n'
for i, disp in enumerate(reversed(overlay_table)):
entry = overlay_table[disp]
msg += f' [{i}] {disp}: {entry[1].name}\n'
print(
'TRANSFORM PHASE' + '-'*100 + '\n\n'
+
msg
)
if method == 'loglin_ref_to_curve':
mx_entry = overlay_table.pop(mx_disp)
else:
# TODO: for pin to first-in-view we need to no pop this from the
# table, but can we simplify below code even more?
mx_entry = overlay_table[mx_disp]
(
mx_view, # viewbox
mx_viz, # viz
_, # y_ref
mx_ymn,
mx_ymx,
_, # read_slc
mx_in_view, # in_view array
r_up_mx,
r_dn_mn,
) = mx_entry
mx_time = mx_in_view['time']
mx_xref = mx_time[0]
# conduct "log-linearized multi-plot" range transform
# calculations for curves detected as overlays in the previous
# loop:
# -> iterate all curves Ci in dispersion-measure sorted order
# going from smallest swing to largest via the
# ``overlay_table: dict``,
# -> match on overlay ``method: str`` provided by caller,
# -> calc y-ranges from each curve's time series and store in
# a final table ``scaled: dict`` for final application in the
# scaling loop; the final phase.
scaled: dict[
float,
tuple[Viz, float, float, float, float]
] = {}
for full_disp in reversed(overlay_table):
(
view,
viz,
y_start,
y_min,
y_max,
read_slc,
minor_in_view,
r_up,
r_dn,
) = overlay_table[full_disp]
key = 'open' if viz.is_ohlc else viz.name
xref = minor_in_view[0]['time']
match method:
# Pin this curve to the "major dispersion" (or other
# target) curve:
#
# - find the intersect datum and then scaling according
# to the returns log-lin tranform 'at that intersect
# reference data'.
# - if the pinning/log-returns-based transform scaling
# results in this minor/pinned curve being out of
# view, adjust the scalars to match **this** curve's
# y-range to stay in view and then backpropagate that
# scaling to all curves, including the major-target,
# which were previously scaled before.
case 'loglin_ref_to_curve':
# calculate y-range scalars from the earliest
# "intersect" datum with the target-major
# (dispersion) curve so as to "pin" the curves
# in the y-domain at that spot.
# NOTE: there are 2 cases for un-matched support
# in x-domain (where one series is shorter then the
# other):
# => major is longer then minor:
# - need to scale the minor *from* the first
# supported datum in both series.
#
# => major is shorter then minor:
# - need to scale the minor *from* the first
# supported datum in both series (the
# intersect x-value) but using the
# intersecting point from the minor **not**
# its first value in view!
yref = y_start
if mx_xref > xref:
(
xref_pin,
yref,
) = viz.i_from_t(
mx_xref,
return_y=True,
)
xref_pin_dt = pendulum.from_timestamp(xref_pin)
xref = mx_xref
if debug_print:
print(
'MAJOR SHORTER!!!\n'
f'xref: {xref}\n'
f'xref_pin: {xref_pin}\n'
f'xref_pin-dt: {xref_pin_dt}\n'
f'yref@xref_pin: {yref}\n'
)
# XXX: we need to handle not-in-view cases?
# still not sure why or when tf this happens..
mx_scalars = mx_viz.scalars_from_index(xref)
if mx_scalars is None:
continue
(
i_start,
y_ref_major,
r_up_from_major_at_xref,
r_down_from_major_at_xref,
) = mx_scalars
if debug_print:
print(
'MAJOR PIN SCALING\n'
f'mx_xref: {mx_xref}\n'
f'major i_start: {i_start}\n'
f'y_ref_major: {y_ref_major}\n'
f'r_up_from_major_at_xref '
f'{r_up_from_major_at_xref}\n'
f'r_down_from_major_at_xref: '
f'{r_down_from_major_at_xref}\n'
f'-----to minor-----\n'
f'xref: {xref}\n'
f'y_start: {y_start}\n'
f'yref: {yref}\n'
)
ymn = yref * (1 + r_down_from_major_at_xref)
ymx = yref * (1 + r_up_from_major_at_xref)
# if this curve's y-range is detected as **not
# being in view** after applying the
# target-major's transform, adjust the
# target-major curve's range to (log-linearly)
# include it (the extra missing range) by
# adjusting the y-mxmn to this new y-range and
# applying the inverse transform of the minor
# back on the target-major (and possibly any
# other previously-scaled-to-target/major, minor
# curves).
if ymn >= y_min:
ymn = y_min
r_dn_minor = (ymn - yref) / yref
# rescale major curve's y-max to include new
# range increase required by **this minor**.
mx_ymn = y_ref_major * (1 + r_dn_minor)
mx_viz.vs.yrange = mx_ymn, mx_viz.vs.yrange[1]
if debug_print:
print(
f'RESCALE {mx_viz.name} DUE TO {viz.name} '
f'ymn -> {y_min}\n'
f'-> MAJ ymn (w r_down: {r_dn_minor}) '
f'-> {mx_ymn}\n\n'
)
# rescale all already scaled curves to new
# increased range for this side as
# determined by ``y_min`` staying in view;
# re-set the `scaled: dict` entry to
# ensure that this minor curve will be
# entirely in view.
# TODO: re updating already-scaled minor curves
# - is there a faster way to do this by
# mutating state on some object instead?
for _view in scaled:
_viz, _yref, _ymn, _ymx, _xref = scaled[_view]
(
_,
_,
_,
r_down_from_out_of_range,
) = mx_viz.scalars_from_index(_xref)
new_ymn = _yref * (1 + r_down_from_out_of_range)
scaled[_view] = (
_viz, _yref, new_ymn, _ymx, _xref)
if debug_print:
print(
f'RESCALE {_viz.name} ymn -> {new_ymn}'
f'RESCALE MAJ ymn -> {mx_ymn}'
)
# same as above but for minor being out-of-range
# on the upside.
if ymx <= y_max:
ymx = y_max
r_up_minor = (ymx - yref) / yref
mx_ymx = y_ref_major * (1 + r_up_minor)
mx_viz.vs.yrange = mx_viz.vs.yrange[0], mx_ymx
if debug_print:
print(
f'RESCALE {mx_viz.name} DUE TO {viz.name} '
f'ymx -> {y_max}\n'
f'-> MAJ ymx (r_up: {r_up_minor} '
f'-> {mx_ymx}\n\n'
)
for _view in scaled:
_viz, _yref, _ymn, _ymx, _xref = scaled[_view]
(
_,
_,
r_up_from_out_of_range,
_,
) = mx_viz.scalars_from_index(_xref)
new_ymx = _yref * (1 + r_up_from_out_of_range)
scaled[_view] = (
_viz, _yref, _ymn, new_ymx, _xref)
if debug_print:
print(
f'RESCALE {_viz.name} ymn -> {new_ymx}'
)
# register all overlays for a final pass where we
# apply all pinned-curve y-range transform scalings.
scaled[view] = (viz, yref, ymn, ymx, xref)
if debug_print:
print(
f'Viz[{viz.name}]: @ {chart_name}\n'
f' .yrange = {viz.vs.yrange}\n'
f' .xrange = {viz.vs.xrange}\n\n'
f'xref: {xref}\n'
f'xref-dt: {pendulum.from_timestamp(xref)}\n'
f'y_min: {y_min}\n'
f'y_max: {y_max}\n'
f'RESCALING\n'
f'r dn: {r_down_from_major_at_xref}\n'
f'r up: {r_up_from_major_at_xref}\n'
f'ymn: {ymn}\n'
f'ymx: {ymx}\n'
)
# Pin all curves by their first datum in view to all
# others such that each curve's earliest datum provides the
# reference point for returns vs. every other curve in
# view.
case 'loglin_ref_to_first':
ymn = dnt.apply_r(y_start)
ymx = upt.apply_r(y_start)
view._set_yrange(yrange=(ymn, ymx))
# Do not pin curves by log-linearizing their y-ranges,
# instead allow each curve to fully scale to the
# time-series in view's min and max y-values.
case 'mxmn':
view._set_yrange(yrange=(y_min, y_max))
case _:
raise RuntimeError(
f'overlay ``method`` is invalid `{method}'
)
# __ END OF transform calc phase (loop) __
# finally, scale the major target/dispersion curve to
# the (possibly re-scaled/modified) values were set in
# transform phase loop.
mx_view._set_yrange(yrange=(mx_ymn, mx_ymx))
if scaled:
if debug_print:
print(
'SCALING PHASE' + '-'*100 + '\n\n'
'_________MAJOR INFO___________\n'
f'SIGMA MAJOR C: {mx_viz.name} -> {mx_disp}\n'
f'UP MAJOR C: {upt.viz.name} with disp: {upt.rng}\n'
f'DOWN MAJOR C: {dnt.viz.name} with disp: {dnt.rng}\n'
f'xref: {mx_xref}\n'
f'xref-dt: {pendulum.from_timestamp(mx_xref)}\n'
f'dn: {r_dn_mn}\n'
f'up: {r_up_mx}\n'
f'mx_ymn: {mx_ymn}\n'
f'mx_ymx: {mx_ymx}\n'
'------------------------------'
)
for (
view,
(viz, yref, ymn, ymx, xref)
) in scaled.items():
# NOTE XXX: we have to set each curve's range once (and
# ONLY ONCE) here since we're doing this entire routine
# inside of a single render cycle (and apparently calling
# `ViewBox.setYRange()` multiple times within one only takes
# the first call as serious...) XD
view._set_yrange(yrange=(ymn, ymx))
profiler(f'{viz.name}@{chart_name} log-SCALE minor')
if debug_print:
print(
'_________MINOR INFO___________\n'
f'Viz[{viz.name}]: @ {chart_name}\n'
f' .yrange = {viz.vs.yrange}\n'
f' .xrange = {viz.vs.xrange}\n\n'
f'xref: {xref}\n'
f'xref-dt: {pendulum.from_timestamp(xref)}\n'
f'y_start: {y_start}\n'
f'y min: {y_min}\n'
f'y max: {y_max}\n'
f'T scaled ymn: {ymn}\n'
f'T scaled ymx: {ymx}\n\n'
'--------------------------------\n'
)
# __ END OF overlay scale phase (loop) __
if debug_print:
print(
f'END UX GRAPHICS CYCLE: @{chart_name}\n'
+
divstr
+
'\n'
)
profiler(f'<{chart_name}>.interact_graphics_cycle()')
if not do_linked_charts:
break
profiler.finish()