1159 lines
34 KiB
Python
1159 lines
34 KiB
Python
# 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/>.
|
|
|
|
'''
|
|
Chart view box primitives
|
|
|
|
'''
|
|
from __future__ import annotations
|
|
from contextlib import (
|
|
asynccontextmanager,
|
|
ExitStack,
|
|
)
|
|
from functools import partial
|
|
import time
|
|
from typing import (
|
|
Callable,
|
|
TYPE_CHECKING,
|
|
)
|
|
|
|
import pyqtgraph as pg
|
|
# NOTE XXX: pg is super annoying and re-implements it's own mouse
|
|
# event subsystem.. we should really look into re-working/writing
|
|
# this down the road.. Bo
|
|
from pyqtgraph.GraphicsScene import mouseEvents as mevs
|
|
# from pyqtgraph.GraphicsScene.mouseEvents import MouseDragEvent
|
|
from pyqtgraph import (
|
|
ViewBox,
|
|
Point,
|
|
QtCore,
|
|
functions as fn,
|
|
)
|
|
import numpy as np
|
|
import trio
|
|
|
|
from piker.ui.qt import (
|
|
QWheelEvent,
|
|
QGraphicsSceneMouseEvent as gs_mouse,
|
|
Qt,
|
|
QEvent,
|
|
)
|
|
from ..log import get_logger
|
|
from ..toolz import (
|
|
Profiler,
|
|
pg_profile_enabled,
|
|
ms_slower_then,
|
|
)
|
|
from .view_mode import overlay_viewlists
|
|
# from ._style import _min_points_to_show
|
|
from ._editors import SelectRect
|
|
from . import _event
|
|
|
|
if TYPE_CHECKING:
|
|
# from ._search import (
|
|
# SearchWidget,
|
|
# )
|
|
from ._chart import (
|
|
ChartnPane,
|
|
ChartPlotWidget,
|
|
GodWidget,
|
|
)
|
|
from ._dataviz import Viz
|
|
from .order_mode import OrderMode
|
|
from ._display import DisplayState
|
|
|
|
|
|
log = get_logger(__name__)
|
|
|
|
NUMBER_LINE = {
|
|
Qt.Key.Key_1,
|
|
Qt.Key.Key_2,
|
|
Qt.Key.Key_3,
|
|
Qt.Key.Key_4,
|
|
Qt.Key.Key_5,
|
|
Qt.Key.Key_6,
|
|
Qt.Key.Key_7,
|
|
Qt.Key.Key_8,
|
|
Qt.Key.Key_9,
|
|
Qt.Key.Key_0,
|
|
}
|
|
|
|
ORDER_MODE = {
|
|
Qt.Key.Key_A,
|
|
Qt.Key.Key_F,
|
|
Qt.Key.Key_D,
|
|
}
|
|
|
|
|
|
async def handle_viewmode_kb_inputs(
|
|
|
|
view: ChartView,
|
|
recv_chan: trio.abc.ReceiveChannel,
|
|
dss: dict[str, DisplayState],
|
|
|
|
) -> None:
|
|
|
|
order_mode: OrderMode = view.order_mode
|
|
godw: GodWidget = order_mode.godw # noqa
|
|
|
|
# track edge triggered keys
|
|
# (https://en.wikipedia.org/wiki/Interrupt#Triggering_methods)
|
|
pressed: set[str] = set()
|
|
|
|
last = time.time()
|
|
action: str
|
|
|
|
on_next_release: Callable | None = None
|
|
|
|
# for quick key sequence-combo pattern matching
|
|
# we have a min_tap period and these should not
|
|
# ever be auto-repeats since we filter those at the
|
|
# event filter level prior to the above mem chan.
|
|
min_tap = 1/6
|
|
fast_key_seq: list[str] = []
|
|
fast_taps: dict[str, Callable] = {
|
|
'cc': order_mode.cancel_all_orders,
|
|
}
|
|
|
|
async for kbmsg in recv_chan:
|
|
event, etype, key, mods, text = kbmsg.to_tuple()
|
|
log.debug(f'key: {key}, mods: {mods}, text: {text}')
|
|
now = time.time()
|
|
period = now - last
|
|
|
|
# reset mods
|
|
ctrl: bool = False
|
|
shift: bool = False
|
|
|
|
# press branch
|
|
if etype in {QEvent.KeyPress}:
|
|
|
|
pressed.add(key)
|
|
|
|
if (
|
|
# clear any old values not part of a "fast" tap sequence:
|
|
# presumes the period since last tap is longer then our
|
|
# min_tap period
|
|
fast_key_seq and period >= min_tap or
|
|
|
|
# don't support more then 2 key sequences for now
|
|
len(fast_key_seq) > 2
|
|
):
|
|
fast_key_seq.clear()
|
|
|
|
# capture key to fast tap sequence if we either
|
|
# have no previous keys or we do and the min_tap period is
|
|
# met
|
|
if (
|
|
not fast_key_seq or
|
|
period <= min_tap and fast_key_seq
|
|
):
|
|
fast_key_seq.append(text)
|
|
log.debug(f'fast keys seqs {fast_key_seq}')
|
|
|
|
# mods run through
|
|
if mods == Qt.ShiftModifier:
|
|
shift = True
|
|
|
|
if mods == Qt.ControlModifier:
|
|
ctrl = True
|
|
|
|
# UI REPL-shell, with ctrl-p (for "pause")
|
|
if (
|
|
ctrl
|
|
and key in {
|
|
Qt.Key_P,
|
|
}
|
|
):
|
|
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
|
|
import tractor
|
|
await tractor.pause()
|
|
view.interact_graphics_cycle()
|
|
|
|
# FORCE graphics reset-and-render of all currently
|
|
# shown data `Viz`s for the current chart app.
|
|
if (
|
|
ctrl
|
|
and key in {
|
|
Qt.Key_R,
|
|
}
|
|
):
|
|
fqme: str
|
|
ds: DisplayState
|
|
for fqme, ds in dss.items():
|
|
|
|
viz: Viz
|
|
for tf, viz in {
|
|
60: ds.hist_viz,
|
|
1: ds.viz,
|
|
}.items():
|
|
# TODO: only allow this when the data is IN VIEW!
|
|
# also, we probably can do this more efficiently
|
|
# / smarter by only redrawing the portion of the
|
|
# path necessary?
|
|
viz.reset_graphics()
|
|
|
|
# ------ - ------
|
|
# SEARCH MODE
|
|
# ------ - ------
|
|
# ctlr-<space>/<l> for "lookup", "search" -> open search tree
|
|
if (
|
|
ctrl
|
|
and key in {
|
|
Qt.Key_L,
|
|
# Qt.Key_Space,
|
|
}
|
|
):
|
|
godw = view._chart.linked.godwidget
|
|
godw.hist_linked.resize_sidepanes(from_linked=godw.rt_linked)
|
|
godw.search.focus()
|
|
|
|
# esc and ctrl-c
|
|
if (
|
|
key == Qt.Key_Escape
|
|
or (
|
|
ctrl
|
|
and key == Qt.Key_C
|
|
)
|
|
):
|
|
# ctrl-c as cancel
|
|
# https://forum.qt.io/topic/532/how-to-catch-ctrl-c-on-a-widget/9
|
|
view.select_box.clear()
|
|
view.linked.focus()
|
|
|
|
# cancel order or clear graphics
|
|
if (
|
|
key == Qt.Key_C
|
|
or key == Qt.Key_Delete
|
|
):
|
|
|
|
order_mode.cancel_orders_under_cursor()
|
|
|
|
# View modes
|
|
if (
|
|
ctrl
|
|
and (
|
|
key == Qt.Key_Equal
|
|
or key == Qt.Key_I
|
|
)
|
|
):
|
|
view.wheelEvent(
|
|
ev=None,
|
|
axis=None,
|
|
delta=view.def_delta,
|
|
)
|
|
elif (
|
|
ctrl
|
|
and (
|
|
key == Qt.Key_Minus
|
|
or key == Qt.Key_O
|
|
)
|
|
):
|
|
view.wheelEvent(
|
|
ev=None,
|
|
axis=None,
|
|
delta=-view.def_delta,
|
|
)
|
|
|
|
elif (
|
|
not ctrl
|
|
and key == Qt.Key_R
|
|
):
|
|
# NOTE: seems that if we don't yield a Qt render
|
|
# cycle then the m4 downsampled curves will show here
|
|
# 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:
|
|
# begin matches against sequences
|
|
func: Callable = fast_taps.get(''.join(fast_key_seq))
|
|
if func:
|
|
func()
|
|
fast_key_seq.clear()
|
|
|
|
# release branch
|
|
elif etype in {QEvent.KeyRelease}:
|
|
|
|
if on_next_release:
|
|
on_next_release()
|
|
on_next_release = None
|
|
|
|
if key in pressed:
|
|
pressed.remove(key)
|
|
|
|
# QUERY/QUOTE MODE
|
|
# ----------------
|
|
if {Qt.Key_Q}.intersection(pressed):
|
|
|
|
view.linked.cursor.in_query_mode = True
|
|
|
|
else:
|
|
view.linked.cursor.in_query_mode = False
|
|
|
|
# SELECTION MODE
|
|
# --------------
|
|
if shift:
|
|
if view.state['mouseMode'] == ViewBox.PanMode:
|
|
view.setMouseMode(ViewBox.RectMode)
|
|
else:
|
|
view.setMouseMode(ViewBox.PanMode)
|
|
|
|
# Toggle position config pane
|
|
if (
|
|
ctrl
|
|
and key in {
|
|
Qt.Key_Space,
|
|
}
|
|
):
|
|
# searchw: SearchWidget = godw.search
|
|
# pp_pane = order_mode.current_pp.pane
|
|
qframes: list[ChartnPane] = []
|
|
|
|
for linked in (
|
|
godw.rt_linked,
|
|
godw.hist_linked,
|
|
):
|
|
for chartw in (
|
|
[linked.chart]
|
|
+
|
|
list(linked.subplots.values())
|
|
):
|
|
qframes.append(
|
|
chartw.qframe
|
|
)
|
|
|
|
# NOTE: place priority on FIRST hiding all
|
|
# panes before showing them.
|
|
# TODO: make this more "fancy"?
|
|
# - maybe look at majority of hidden states and then
|
|
# flip based on that?
|
|
# - move these loops into the chart APIs?
|
|
# - store the UX-state for a given feed/symbol and
|
|
# apply when opening a new one (eg. if panes were
|
|
# hidden then also hide them on newly loaded mkt
|
|
# feeds).
|
|
if not any(
|
|
qf.sidepane.isHidden() for qf in qframes
|
|
):
|
|
for qf in qframes:
|
|
qf.sidepane.hide()
|
|
|
|
else:
|
|
for qf in qframes:
|
|
qf.sidepane.show()
|
|
|
|
# ORDER MODE
|
|
# ----------
|
|
# live vs. dark trigger + an action {buy, sell, alert}
|
|
order_keys_pressed = ORDER_MODE.intersection(pressed)
|
|
|
|
if order_keys_pressed:
|
|
|
|
# TODO: it seems like maybe the composition should be
|
|
# reversed here? Like, maybe we should have the nav have
|
|
# access to the pos state and then make encapsulated logic
|
|
# that shows the right stuff on screen instead or order mode
|
|
# and position-related abstractions doing this?
|
|
|
|
# show the pp size label only if there is
|
|
# a non-zero pos existing
|
|
tracker = order_mode.current_pp
|
|
if tracker.live_pp.cumsize:
|
|
tracker.nav.show()
|
|
|
|
# TODO: show pp config mini-params in status bar widget
|
|
# mode.pp_config.show()
|
|
|
|
trigger_type: str = 'dark'
|
|
if (
|
|
# 's' for "submit" to activate "live" order
|
|
Qt.Key_S in pressed or
|
|
ctrl
|
|
):
|
|
trigger_type: str = 'live'
|
|
|
|
# order mode trigger "actions"
|
|
if Qt.Key_D in pressed: # for "damp eet"
|
|
action = 'sell'
|
|
|
|
elif Qt.Key_F in pressed: # for "fillz eet"
|
|
action = 'buy'
|
|
|
|
elif Qt.Key_A in pressed:
|
|
action = 'alert'
|
|
trigger_type = 'live'
|
|
|
|
order_mode.active = True
|
|
|
|
# XXX: order matters here for line style!
|
|
order_mode._trigger_type = trigger_type
|
|
order_mode.stage_order(
|
|
action,
|
|
trigger_type=trigger_type,
|
|
)
|
|
|
|
prefix = trigger_type + '-' if action != 'alert' else ''
|
|
view._chart.window().set_mode_name(f'{prefix}{action}')
|
|
|
|
elif (
|
|
(
|
|
Qt.Key_S in pressed or
|
|
order_keys_pressed or
|
|
Qt.Key_O in pressed
|
|
)
|
|
and key in NUMBER_LINE
|
|
):
|
|
# hot key to set order slots size.
|
|
# change edit field to current number line value,
|
|
# update the pp allocator bar, unhighlight the
|
|
# field when ctrl is released.
|
|
num = int(text)
|
|
pp_pane = order_mode.pane
|
|
pp_pane.on_ui_settings_change('slots', num)
|
|
edit = pp_pane.form.fields['slots']
|
|
edit.selectAll()
|
|
# un-highlight on ctrl release
|
|
on_next_release = edit.deselect
|
|
pp_pane.update_status_ui(pp_pane.order_mode.current_pp)
|
|
|
|
else: # none active
|
|
|
|
# hide pp label
|
|
order_mode.current_pp.nav.hide_info()
|
|
|
|
# if none are pressed, remove "staged" level
|
|
# line under cursor position
|
|
order_mode.lines.unstage_line()
|
|
|
|
if view.hasFocus():
|
|
# update mode label
|
|
view._chart.window().set_mode_name('view')
|
|
|
|
order_mode.active = False
|
|
|
|
last = time.time()
|
|
|
|
|
|
async def handle_viewmode_mouse(
|
|
|
|
view: ChartView,
|
|
recv_chan: trio.abc.ReceiveChannel,
|
|
dss: dict[str, DisplayState],
|
|
|
|
) -> None:
|
|
|
|
async for msg in recv_chan:
|
|
button = msg.button
|
|
|
|
# XXX: ugggh ``pyqtgraph`` has its own mouse events..
|
|
# so we can't overried this easily.
|
|
# it's going to take probably some decent
|
|
# reworking of the mouseClickEvent() handler.
|
|
|
|
# if button == QtCore.Qt.RightButton and view.menuEnabled():
|
|
# event = mouseEvents.MouseClickEvent(msg.event)
|
|
# # event.accept()
|
|
# view.raiseContextMenu(event)
|
|
|
|
if (
|
|
view.order_mode.active and
|
|
button == QtCore.Qt.LeftButton
|
|
):
|
|
# when in order mode, submit execution
|
|
# msg.event.accept()
|
|
view.order_mode.submit_order()
|
|
|
|
|
|
class ChartView(ViewBox):
|
|
'''
|
|
Price chart view box with interaction behaviors you'd expect from
|
|
any interactive platform:
|
|
|
|
- zoom on mouse scroll that auto fits y-axis
|
|
- vertical scrolling on y-axis
|
|
- zoom on x to most recent in view datum
|
|
- zoom on right-click-n-drag to cursor position
|
|
|
|
'''
|
|
mode_name: str = 'view'
|
|
def_delta: float = 616 * 6
|
|
def_scale_factor: float = 1.016 ** (def_delta * -1 / 20)
|
|
# annots: dict[int, GraphicsObject] = {}
|
|
|
|
def __init__(
|
|
self,
|
|
|
|
name: str,
|
|
|
|
parent: pg.PlotItem = None,
|
|
static_yrange: tuple[float, float] | None = None,
|
|
**kwargs,
|
|
|
|
):
|
|
super().__init__(
|
|
parent=parent,
|
|
name=name,
|
|
# TODO: look into the default view padding
|
|
# support that might replace somem of our
|
|
# ``ChartPlotWidget._set_yrange()`
|
|
# defaultPadding=0.,
|
|
**kwargs
|
|
)
|
|
|
|
# for "known y-range style"
|
|
self._static_yrange = static_yrange
|
|
|
|
# disable vertical scrolling
|
|
self.setMouseEnabled(
|
|
x=True,
|
|
y=True,
|
|
)
|
|
|
|
self.linked = None
|
|
self._chart: ChartPlotWidget | None = None # noqa
|
|
|
|
# add our selection box annotator
|
|
self.select_box = SelectRect(self)
|
|
# self.select_box.add_to_view(self)
|
|
# self.addItem(
|
|
# self.select_box,
|
|
# ignoreBounds=True,
|
|
# )
|
|
|
|
self.mode = None
|
|
self.order_mode: bool = False
|
|
|
|
self.setFocusPolicy(QtCore.Qt.StrongFocus)
|
|
self._in_interact: trio.Event | 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(
|
|
self,
|
|
) -> None:
|
|
'''
|
|
Signal the beginning of a click-drag interaction
|
|
to any interested task waiters.
|
|
|
|
'''
|
|
if self._in_interact is None:
|
|
chart = self.chart
|
|
try:
|
|
self._in_interact = trio.Event()
|
|
|
|
chart.pause_all_feeds()
|
|
self._interact_stack.enter_context(
|
|
chart.reset_graphics_caches()
|
|
)
|
|
except RuntimeError:
|
|
pass
|
|
|
|
def signal_ic(
|
|
self,
|
|
*args,
|
|
|
|
) -> None:
|
|
'''
|
|
Signal the end of a click-drag interaction
|
|
to any waiters.
|
|
|
|
'''
|
|
if self._in_interact:
|
|
try:
|
|
self._interact_stack.close()
|
|
self.chart.resume_all_feeds()
|
|
|
|
self._in_interact.set()
|
|
self._in_interact = None
|
|
except RuntimeError:
|
|
pass
|
|
|
|
@asynccontextmanager
|
|
async def open_async_input_handler(
|
|
self,
|
|
**handler_kwargs,
|
|
|
|
) -> ChartView:
|
|
|
|
async with (
|
|
_event.open_handlers(
|
|
[self],
|
|
event_types={
|
|
QEvent.KeyPress,
|
|
QEvent.KeyRelease,
|
|
},
|
|
async_handler=partial(
|
|
handle_viewmode_kb_inputs,
|
|
**handler_kwargs,
|
|
),
|
|
),
|
|
_event.open_handlers(
|
|
[self],
|
|
event_types={
|
|
gs_mouse.GraphicsSceneMousePress,
|
|
},
|
|
async_handler=partial(
|
|
handle_viewmode_mouse,
|
|
**handler_kwargs,
|
|
),
|
|
),
|
|
):
|
|
yield self
|
|
|
|
@property
|
|
def chart(self) -> ChartPlotWidget: # type: ignore # noqa
|
|
return self._chart
|
|
|
|
@chart.setter
|
|
def chart(self, chart: ChartPlotWidget) -> None: # type: ignore # noqa
|
|
self._chart = chart
|
|
self.select_box.chart = chart
|
|
|
|
def wheelEvent(
|
|
self,
|
|
ev: QWheelEvent | None = None,
|
|
axis: int | None = None,
|
|
delta: float | None = None,
|
|
):
|
|
'''
|
|
Override "center-point" location for scrolling.
|
|
|
|
This is an override of the ``ViewBox`` method simply changing
|
|
the center of the zoom to be the y-axis.
|
|
|
|
TODO: PR a method into ``pyqtgraph`` to make this configurable
|
|
|
|
'''
|
|
# NOTE: certain operations are only avail when this handler is
|
|
# actually called on events.
|
|
if ev is None:
|
|
assert delta
|
|
assert axis is None
|
|
|
|
linked = self.linked
|
|
if (
|
|
not linked
|
|
):
|
|
return
|
|
|
|
if axis in (0, 1):
|
|
mask = [False, False]
|
|
mask[axis] = self.state['mouseEnabled'][axis]
|
|
else:
|
|
mask: list[bool] = self.state['mouseEnabled'][:]
|
|
|
|
chart = self.linked.chart
|
|
|
|
# don't zoom more then the min points setting
|
|
viz = chart.get_viz(chart.name)
|
|
_, vl, lbar, rbar, vr, r = viz.datums_range()
|
|
|
|
# TODO: max/min zoom limits incorporating time step size.
|
|
# rl = vr - vl
|
|
# if ev.delta() > 0 and rl <= _min_points_to_show:
|
|
# log.warning("Max zoom bruh...")
|
|
# return
|
|
# if (
|
|
# ev.delta() < 0
|
|
# and rl >= len(chart._vizs[chart.name].shm.array) + 666
|
|
# ):
|
|
# log.warning("Min zoom bruh...")
|
|
# return
|
|
|
|
# actual scaling factor
|
|
delta: float = ev.delta() if ev else delta
|
|
scale_factor: float = 1.016 ** (delta * -1 / 20)
|
|
|
|
# NOTE: if elem is False -> None meaning "do not scale that
|
|
# axis".
|
|
scales: list[float | bool] = [
|
|
(None if m is False else scale_factor)
|
|
for m in mask
|
|
]
|
|
|
|
if (
|
|
# zoom happened on axis
|
|
axis == 1
|
|
|
|
# if already in axis zoom mode then keep it
|
|
or self.chart._static_yrange == 'axis'
|
|
):
|
|
self.chart._static_yrange = 'axis'
|
|
self.setLimits(yMin=None, yMax=None)
|
|
|
|
# print(scale_y)
|
|
# pos = ev.pos()
|
|
# lastPos = ev.lastPos()
|
|
# dif = pos - lastPos
|
|
# dif = dif * -1
|
|
center = Point(
|
|
fn.invertQTransform(
|
|
self.childGroup.transform()
|
|
).map(ev.pos())
|
|
)
|
|
# scale_y = 1.3 ** (center.y() * -1 / 20)
|
|
self.scaleBy(scales, center)
|
|
|
|
# zoom in view-box area
|
|
else:
|
|
# use right-most point of current curve graphic
|
|
xl = viz.graphics.x_last()
|
|
focal = min(
|
|
xl,
|
|
r,
|
|
)
|
|
|
|
self._resetTarget()
|
|
|
|
# NOTE: scroll "around" the right most datum-element in view
|
|
# gives the feeling of staying "pinned" in place.
|
|
self.scaleBy(scales, focal)
|
|
|
|
# XXX: the order of the next 2 lines i'm pretty sure
|
|
# matters, we want the resize to trigger before the graphics
|
|
# update, but i gotta feelin that because this one is signal
|
|
# based (and thus not necessarily sync invoked right away)
|
|
# that calling the resize method manually might work better.
|
|
# self.sigRangeChangedManually.emit(mask)
|
|
|
|
# XXX: without this is seems as though sometimes
|
|
# when zooming in from far out (and maybe vice versa?)
|
|
# the signal isn't being fired enough since if you pan
|
|
# just after you'll see further downsampling code run
|
|
# (pretty noticeable on the OHLC ds curve) but with this
|
|
# that never seems to happen? Only question is how much this
|
|
# "double work" is causing latency when these missing event
|
|
# fires don't happen?
|
|
self.interact_graphics_cycle()
|
|
self.interact_graphics_cycle()
|
|
|
|
if ev:
|
|
ev.accept()
|
|
|
|
def mouseDragEvent(
|
|
self,
|
|
ev: mevs.MouseDragEvent,
|
|
axis: int | None = None,
|
|
|
|
) -> None:
|
|
pos: Point = ev.pos()
|
|
lastPos: Point = ev.lastPos()
|
|
dif: Point = (pos - lastPos) * -1
|
|
# dif: Point = pos - lastPos
|
|
# dif: Point = dif * -1
|
|
|
|
# NOTE: if axis is specified, event will only affect that axis.
|
|
btn = ev.button()
|
|
|
|
# Ignore axes if mouse is disabled
|
|
mouseEnabled = np.array(
|
|
self.state['mouseEnabled'],
|
|
dtype=float,
|
|
)
|
|
mask = mouseEnabled.copy()
|
|
if axis is not None:
|
|
mask[1-axis] = 0.0
|
|
|
|
# Scale or translate based on mouse button
|
|
if btn & (
|
|
QtCore.Qt.LeftButton | QtCore.Qt.MidButton
|
|
):
|
|
# zoom y-axis ONLY when click-n-drag on it
|
|
# if axis == 1:
|
|
# # set a static y range special value on chart widget to
|
|
# # prevent sizing to data in view.
|
|
# self.chart._static_yrange = 'axis'
|
|
|
|
# scale_y = 1.3 ** (dif.y() * -1 / 20)
|
|
# self.setLimits(yMin=None, yMax=None)
|
|
|
|
# # print(scale_y)
|
|
# self.scaleBy((0, scale_y))
|
|
|
|
# SELECTION MODE
|
|
if (
|
|
self.state['mouseMode'] == ViewBox.RectMode
|
|
and axis is None
|
|
):
|
|
# XXX: WHY
|
|
ev.accept()
|
|
|
|
down_pos: Point = ev.buttonDownPos(
|
|
btn=btn,
|
|
)
|
|
scen_pos: Point = ev.scenePos()
|
|
scen_down_pos: Point = ev.buttonDownScenePos(
|
|
btn=btn,
|
|
)
|
|
|
|
# This is the final position in the drag
|
|
if ev.isFinish():
|
|
|
|
# import pdbp; pdbp.set_trace()
|
|
|
|
# NOTE: think of this as a `.mouse_drag_release()`
|
|
# (bc HINT that's what i called the shit ass
|
|
# method that wrapped this call [yes, as a single
|
|
# fucking call] originally.. you bish, guille)
|
|
# Bo.. oraleeee
|
|
self.select_box.set_scen_pos(
|
|
# down_pos,
|
|
# pos,
|
|
scen_down_pos,
|
|
scen_pos,
|
|
)
|
|
|
|
# this is the zoom transform cmd
|
|
ax = QtCore.QRectF(down_pos, pos)
|
|
ax = self.childGroup.mapRectFromParent(ax)
|
|
# self.showAxRect(ax)
|
|
# axis history tracking
|
|
self.axHistoryPointer += 1
|
|
self.axHistory = self.axHistory[
|
|
:self.axHistoryPointer] + [ax]
|
|
|
|
else:
|
|
self.select_box.set_scen_pos(
|
|
# down_pos,
|
|
# pos,
|
|
scen_down_pos,
|
|
scen_pos,
|
|
)
|
|
|
|
# update shape of scale box
|
|
# self.updateScaleBox(ev.buttonDownPos(), ev.pos())
|
|
# breakpoint()
|
|
# self.updateScaleBox(
|
|
# down_pos,
|
|
# ev.pos(),
|
|
# )
|
|
|
|
# PANNING MODE
|
|
else:
|
|
try:
|
|
self.start_ic()
|
|
except RuntimeError:
|
|
pass
|
|
|
|
if axis == 1:
|
|
self.chart._static_yrange = 'axis'
|
|
|
|
tr = self.childGroup.transform()
|
|
tr = fn.invertQTransform(tr)
|
|
tr = tr.map(dif*mask) - tr.map(Point(0, 0))
|
|
|
|
x = tr.x() if mask[0] == 1 else None
|
|
y = tr.y() if mask[1] == 1 else None
|
|
|
|
self._resetTarget()
|
|
|
|
if x is not None or y is not None:
|
|
self.translateBy(x=x, y=y)
|
|
|
|
# self.sigRangeChangedManually.emit(mask)
|
|
# self.state['mouseEnabled']
|
|
# )
|
|
self.interact_graphics_cycle()
|
|
|
|
if ev.isFinish():
|
|
self.signal_ic()
|
|
# self._in_interact.set()
|
|
# self._in_interact = None
|
|
# self.chart.resume_all_feeds()
|
|
|
|
# # XXX: WHY
|
|
# ev.accept()
|
|
|
|
# WEIRD "RIGHT-CLICK CENTER ZOOM" MODE
|
|
elif btn & QtCore.Qt.RightButton:
|
|
|
|
if self.state['aspectLocked'] is not False:
|
|
mask[0] = 0
|
|
|
|
dif = ev.screenPos() - ev.lastScreenPos()
|
|
dif = np.array([dif.x(), dif.y()])
|
|
dif[0] *= -1
|
|
s = ((mask * 0.02) + 1) ** dif
|
|
|
|
tr = self.childGroup.transform()
|
|
tr = fn.invertQTransform(tr)
|
|
|
|
x = s[0] if mouseEnabled[0] == 1 else None
|
|
y = s[1] if mouseEnabled[1] == 1 else None
|
|
|
|
center = Point(tr.map(ev.buttonDownPos(QtCore.Qt.RightButton)))
|
|
self._resetTarget()
|
|
self.scaleBy(x=x, y=y, center=center)
|
|
|
|
# self.sigRangeChangedManually.emit(self.state['mouseEnabled'])
|
|
self.interact_graphics_cycle()
|
|
|
|
# XXX: WHY
|
|
ev.accept()
|
|
|
|
# def mouseClickEvent(self, event: QtCore.QEvent) -> None:
|
|
# '''This routine is rerouted to an async handler.
|
|
# '''
|
|
# pass
|
|
|
|
def keyReleaseEvent(self, event: QtCore.QEvent) -> None:
|
|
'''This routine is rerouted to an async handler.
|
|
'''
|
|
pass
|
|
|
|
def keyPressEvent(self, event: QtCore.QEvent) -> None:
|
|
'''This routine is rerouted to an async handler.
|
|
'''
|
|
pass
|
|
|
|
def _set_yrange(
|
|
self,
|
|
*,
|
|
|
|
yrange: tuple[float, float] | None = None,
|
|
viz: Viz | None = None,
|
|
|
|
# NOTE: this value pairs (more or less) with L1 label text
|
|
# height offset from from the bid/ask lines.
|
|
range_margin: float | None = 0.06,
|
|
|
|
bars_range: tuple[int, int, int, int] | None = None,
|
|
|
|
# flag to prevent triggering sibling charts from the same linked
|
|
# set from recursion errors.
|
|
autoscale_linked_plots: bool = False,
|
|
name: str | None = None,
|
|
|
|
) -> None:
|
|
'''
|
|
Set the viewable y-range based on embedded data.
|
|
|
|
This adds auto-scaling like zoom on the scroll wheel such
|
|
that data always fits nicely inside the current view of the
|
|
data set.
|
|
|
|
'''
|
|
name = self.name
|
|
# print(f'YRANGE ON {name} -> yrange{yrange}')
|
|
profiler = Profiler(
|
|
msg=f'`ChartView._set_yrange()`: `{name}`',
|
|
disabled=not pg_profile_enabled(),
|
|
ms_threshold=ms_slower_then,
|
|
delayed=True,
|
|
)
|
|
chart = self._chart
|
|
|
|
# view has been set in 'axis' mode
|
|
# meaning it can be panned and zoomed
|
|
# arbitrarily on the y-axis:
|
|
# - disable autoranging
|
|
# - remove any y range limits
|
|
if chart._static_yrange == 'axis':
|
|
self.setLimits(yMin=None, yMax=None)
|
|
return
|
|
|
|
# static y-range has been set likely by
|
|
# a specialized FSP configuration.
|
|
elif chart._static_yrange is not None:
|
|
ylow, yhigh = chart._static_yrange
|
|
|
|
# range passed in by caller, usually a
|
|
# maxmin detection algos inside the
|
|
# display loop for re-draw efficiency.
|
|
elif yrange is not None:
|
|
ylow, yhigh = yrange
|
|
|
|
# XXX: only compute the mxmn range
|
|
# if none is provided as input!
|
|
if not yrange:
|
|
|
|
if not viz:
|
|
breakpoint()
|
|
|
|
out = viz.maxmin()
|
|
if out is None:
|
|
log.warning(f'No yrange provided for {name}!?')
|
|
return
|
|
(
|
|
ixrng,
|
|
_,
|
|
yrange
|
|
) = out
|
|
|
|
profiler(f'`{self.name}:Viz.maxmin()` -> {ixrng}=>{yrange}')
|
|
|
|
if yrange is None:
|
|
log.warning(f'No yrange provided for {name}!?')
|
|
return
|
|
|
|
ylow, yhigh = yrange
|
|
|
|
# always stash last range for diffing by
|
|
# incremental update calculations BEFORE adding
|
|
# margin.
|
|
self._yrange = ylow, yhigh
|
|
|
|
# view margins: stay within a % of the "true range"
|
|
if range_margin is not None:
|
|
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(
|
|
yMin=ylow,
|
|
yMax=yhigh,
|
|
)
|
|
self.update()
|
|
|
|
# LOL: yet anothercucking pg buggg..
|
|
# can't use `msg=f'setYRange({ylow}, {yhigh}')`
|
|
profiler.finish()
|
|
|
|
def enable_auto_yrange(
|
|
self,
|
|
viz: Viz,
|
|
src_vb: ChartView | None = None,
|
|
|
|
) -> None:
|
|
'''
|
|
Assign callbacks for rescaling and resampling y-axis data
|
|
automatically based on data contents and ``ViewBox`` state.
|
|
|
|
'''
|
|
if src_vb is None:
|
|
src_vb = self
|
|
|
|
# re-sampling trigger:
|
|
# TODO: a smarter way to avoid calling this needlessly?
|
|
# 2 things i can think of:
|
|
# - register downsample-able graphics specially and only
|
|
# iterate those.
|
|
# - only register this when certain downsample-able graphics are
|
|
# "added to scene".
|
|
# src_vb.sigRangeChangedManually.connect(
|
|
# self.interact_graphics_cycle
|
|
# )
|
|
|
|
# widget-UIs/splitter(s) resizing
|
|
src_vb.sigResized.connect(
|
|
self.interact_graphics_cycle
|
|
)
|
|
|
|
def disable_auto_yrange(self) -> None:
|
|
|
|
# XXX: not entirely sure why we can't de-reg this..
|
|
self.sigResized.disconnect(
|
|
self.interact_graphics_cycle
|
|
)
|
|
|
|
def x_uppx(self) -> float:
|
|
'''
|
|
Return the "number of x units" within a single
|
|
pixel currently being displayed for relevant
|
|
graphics items which are our children.
|
|
|
|
'''
|
|
graphics = [f.graphics for f in self._chart._vizs.values()]
|
|
if not graphics:
|
|
return 0
|
|
|
|
for graphic in graphics:
|
|
xvec = graphic.pixelVectors()[0]
|
|
if xvec:
|
|
return xvec.x()
|
|
else:
|
|
return 0
|
|
|
|
def interact_graphics_cycle(
|
|
self,
|
|
*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(
|
|
msg=f'ChartView.interact_graphics_cycle() for {self.name}',
|
|
disabled=not pg_profile_enabled(),
|
|
ms_threshold=ms_slower_then,
|
|
|
|
# XXX: important to avoid not seeing underlying
|
|
# ``Viz.update_graphics()`` nested profiling likely
|
|
# due to the way delaying works and garbage collection of
|
|
# the profiler in the delegated method calls.
|
|
delayed=True,
|
|
|
|
# for hardcore latency checking, comment these flags above.
|
|
# disabled=False,
|
|
# ms_threshold=4,
|
|
)
|
|
|
|
linked = self.linked
|
|
if (
|
|
do_linked_charts
|
|
and linked
|
|
):
|
|
plots = {linked.chart.name: linked.chart}
|
|
plots |= linked.subplots
|
|
|
|
else:
|
|
chart = self._chart
|
|
plots = {chart.name: chart}
|
|
|
|
# TODO: a faster single-loop-iterator way of doing this?
|
|
return overlay_viewlists(
|
|
self._viz,
|
|
plots,
|
|
profiler,
|
|
do_overlay_scaling=do_overlay_scaling,
|
|
do_linked_charts=do_linked_charts,
|
|
yrange_kwargs=yrange_kwargs,
|
|
)
|