Merge pull request #200 from pikers/asyncify_input_modes

Asyncify input modes
ci_on_forks
goodboy 2021-07-11 12:57:09 -04:00 committed by GitHub
commit 8a6142632d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 1434 additions and 1016 deletions

View File

@ -125,7 +125,9 @@ def get_orders(
if _orders is None: if _orders is None:
# setup local ui event streaming channels for request/resp # setup local ui event streaming channels for request/resp
# streamging with EMS daemon # streamging with EMS daemon
_orders = OrderBook(*trio.open_memory_channel(1)) _orders = OrderBook(
*trio.open_memory_channel(100),
)
return _orders return _orders

View File

@ -18,7 +18,6 @@
Annotations for ur faces. Annotations for ur faces.
""" """
from PyQt5 import QtCore, QtGui from PyQt5 import QtCore, QtGui
from PyQt5.QtGui import QGraphicsPathItem from PyQt5.QtGui import QGraphicsPathItem
from pyqtgraph import Point, functions as fn, Color from pyqtgraph import Point, functions as fn, Color

View File

@ -38,7 +38,7 @@ class Axis(pg.AxisItem):
""" """
def __init__( def __init__(
self, self,
linked_charts, linkedsplits,
typical_max_str: str = '100 000.000', typical_max_str: str = '100 000.000',
min_tick: int = 2, min_tick: int = 2,
**kwargs **kwargs
@ -49,7 +49,7 @@ class Axis(pg.AxisItem):
# XXX: pretty sure this makes things slower # XXX: pretty sure this makes things slower
# self.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache) # self.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
self.linked_charts = linked_charts self.linkedsplits = linkedsplits
self._min_tick = min_tick self._min_tick = min_tick
self._dpi_font = _font self._dpi_font = _font
@ -132,9 +132,9 @@ class DynamicDateAxis(Axis):
) -> List[str]: ) -> List[str]:
# try: # try:
chart = self.linked_charts.chart chart = self.linkedsplits.chart
bars = chart._ohlc bars = chart._arrays['ohlc']
shm = self.linked_charts.chart._shm shm = self.linkedsplits.chart._shm
first = shm._first.value first = shm._first.value
bars_len = len(bars) bars_len = len(bars)
@ -232,7 +232,6 @@ class AxisLabel(pg.GraphicsObject):
p.setPen(self.fg_color) p.setPen(self.fg_color)
p.drawText(self.rect, self.text_flags, self.label_str) p.drawText(self.rect, self.text_flags, self.label_str)
def draw( def draw(
self, self,
p: QtGui.QPainter, p: QtGui.QPainter,
@ -250,9 +249,9 @@ class AxisLabel(pg.GraphicsObject):
# reason; ok by us # reason; ok by us
p.setOpacity(self.opacity) p.setOpacity(self.opacity)
# this cause the L1 labels to glitch out if used # this cause the L1 labels to glitch out if used in the subtype
# in the subtype and it will leave a small black strip # and it will leave a small black strip with the arrow path if
# with the arrow path if done before the above # done before the above
p.fillRect(self.rect, self.bg_color) p.fillRect(self.rect, self.bg_color)
@ -295,8 +294,8 @@ class AxisLabel(pg.GraphicsObject):
self.rect = QtCore.QRectF( self.rect = QtCore.QRectF(
0, 0, 0, 0,
(w or txt_w) + self._x_margin /2, (w or txt_w) + self._x_margin / 2,
(h or txt_h) + self._y_margin /2, (h or txt_h) + self._y_margin / 2,
) )
# print(self.rect) # print(self.rect)
# hb = self.path.controlPointRect() # hb = self.path.controlPointRect()

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,451 @@
# piker: trading gear for hackers
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# 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/>.
"""
Higher level annotation editors.
"""
from dataclasses import dataclass, field
from typing import Optional
import pyqtgraph as pg
from pyqtgraph import ViewBox, Point, QtCore, QtGui
from pyqtgraph import functions as fn
from PyQt5.QtCore import QPointF
import numpy as np
from ._style import hcolor, _font
from ._graphics._lines import order_line, LevelLine
from ..log import get_logger
log = get_logger(__name__)
@dataclass
class ArrowEditor:
chart: 'ChartPlotWidget' # noqa
_arrows: field(default_factory=dict)
def add(
self,
uid: str,
x: float,
y: float,
color='default',
pointing: Optional[str] = None,
) -> pg.ArrowItem:
"""Add an arrow graphic to view at given (x, y).
"""
angle = {
'up': 90,
'down': -90,
None: 180, # pointing to right (as in an alert)
}[pointing]
# scale arrow sizing to dpi-aware font
size = _font.font.pixelSize() * 0.8
arrow = pg.ArrowItem(
angle=angle,
baseAngle=0,
headLen=size,
headWidth=size/2,
tailLen=None,
pxMode=True,
# coloring
pen=pg.mkPen(hcolor('papas_special')),
brush=pg.mkBrush(hcolor(color)),
)
arrow.setPos(x, y)
self._arrows[uid] = arrow
# render to view
self.chart.plotItem.addItem(arrow)
return arrow
def remove(self, arrow) -> bool:
self.chart.plotItem.removeItem(arrow)
@dataclass
class LineEditor:
'''The great editor of linez.
'''
chart: 'ChartPlotWidget' = None # type: ignore # noqa
_order_lines: dict[str, LevelLine] = field(default_factory=dict)
_active_staged_line: LevelLine = None
def stage_line(
self,
action: str,
color: str = 'alert_yellow',
hl_on_hover: bool = False,
dotted: bool = False,
# fields settings
size: Optional[int] = None,
) -> LevelLine:
"""Stage a line at the current chart's cursor position
and return it.
"""
# chart.setCursor(QtCore.Qt.PointingHandCursor)
cursor = self.chart.linked.cursor
if not cursor:
return None
chart = cursor.active_plot
y = cursor._datum_xy[1]
symbol = chart._lc.symbol
# add a "staged" cursor-tracking line to view
# and cash it in a a var
if self._active_staged_line:
self.unstage_line()
line = order_line(
chart,
level=y,
level_digits=symbol.digits(),
size=size,
size_digits=symbol.lot_digits(),
# just for the stage line to avoid
# flickering while moving the cursor
# around where it might trigger highlight
# then non-highlight depending on sensitivity
always_show_labels=True,
# kwargs
color=color,
# don't highlight the "staging" line
hl_on_hover=hl_on_hover,
dotted=dotted,
exec_type='dark' if dotted else 'live',
action=action,
show_markers=True,
# prevent flickering of marker while moving/tracking cursor
only_show_markers_on_hover=False,
)
self._active_staged_line = line
# hide crosshair y-line and label
cursor.hide_xhair()
# add line to cursor trackers
cursor._trackers.add(line)
return line
def unstage_line(self) -> LevelLine:
"""Inverse of ``.stage_line()``.
"""
# chart = self.chart._cursor.active_plot
# # chart.setCursor(QtCore.Qt.ArrowCursor)
cursor = self.chart.linked.cursor
# delete "staged" cursor tracking line from view
line = self._active_staged_line
if line:
cursor._trackers.remove(line)
line.delete()
self._active_staged_line = None
# show the crosshair y line and label
cursor.show_xhair()
def create_order_line(
self,
uuid: str,
level: float,
chart: 'ChartPlotWidget', # noqa
size: float,
action: str,
) -> LevelLine:
line = self._active_staged_line
if not line:
raise RuntimeError("No line is currently staged!?")
sym = chart._lc.symbol
line = order_line(
chart,
# label fields default values
level=level,
level_digits=sym.digits(),
size=size,
size_digits=sym.lot_digits(),
# LevelLine kwargs
color=line.color,
dotted=line._dotted,
show_markers=True,
only_show_markers_on_hover=True,
action=action,
)
# for now, until submission reponse arrives
line.hide_labels()
# register for later lookup/deletion
self._order_lines[uuid] = line
return line
def commit_line(self, uuid: str) -> LevelLine:
"""Commit a "staged line" to view.
Submits the line graphic under the cursor as a (new) permanent
graphic in view.
"""
try:
line = self._order_lines[uuid]
except KeyError:
log.warning(f'No line for {uuid} could be found?')
return
else:
assert line.oid == uuid
line.show_labels()
# TODO: other flashy things to indicate the order is active
log.debug(f'Level active for level: {line.value()}')
return line
def lines_under_cursor(self) -> list[LevelLine]:
"""Get the line(s) under the cursor position.
"""
# Delete any hoverable under the cursor
return self.chart.linked.cursor._hovered
def all_lines(self) -> tuple[LevelLine]:
return tuple(self._order_lines.values())
def remove_line(
self,
line: LevelLine = None,
uuid: str = None,
) -> LevelLine:
"""Remove a line by refernce or uuid.
If no lines or ids are provided remove all lines under the
cursor position.
"""
if line:
uuid = line.oid
# try to look up line from our registry
line = self._order_lines.pop(uuid, None)
if line:
# if hovered remove from cursor set
cursor = self.chart.linked.cursor
hovered = cursor._hovered
if line in hovered:
hovered.remove(line)
# make sure the xhair doesn't get left off
# just because we never got a un-hover event
cursor.show_xhair()
line.delete()
return line
class SelectRect(QtGui.QGraphicsRectItem):
def __init__(
self,
viewbox: ViewBox,
color: str = 'dad_blue',
) -> None:
super().__init__(0, 0, 1, 1)
# self.rbScaleBox = QtGui.QGraphicsRectItem(0, 0, 1, 1)
self.vb = viewbox
self._chart: 'ChartPlotWidget' = None # noqa
# override selection box color
color = QtGui.QColor(hcolor(color))
self.setPen(fn.mkPen(color, width=1))
color.setAlpha(66)
self.setBrush(fn.mkBrush(color))
self.setZValue(1e9)
self.hide()
self._label = None
label = self._label = QtGui.QLabel()
label.setTextFormat(0) # markdown
label.setFont(_font.font)
label.setMargin(0)
label.setAlignment(
QtCore.Qt.AlignLeft
# | QtCore.Qt.AlignVCenter
)
# proxy is created after containing scene is initialized
self._label_proxy = None
self._abs_top_right = None
# TODO: "swing %" might be handy here (data's max/min # % change)
self._contents = [
'change: {pchng:.2f} %',
'range: {rng:.2f}',
'bars: {nbars}',
'max: {dmx}',
'min: {dmn}',
# 'time: {nbars}m', # TODO: compute this per bar size
'sigma: {std:.2f}',
]
@property
def chart(self) -> 'ChartPlotWidget': # noqa
return self._chart
@chart.setter
def chart(self, chart: 'ChartPlotWidget') -> None: # noqa
self._chart = chart
chart.sigRangeChanged.connect(self.update_on_resize)
palette = self._label.palette()
# TODO: get bg color working
palette.setColor(
self._label.backgroundRole(),
# QtGui.QColor(chart.backgroundBrush()),
QtGui.QColor(hcolor('papas_special')),
)
def update_on_resize(self, vr, r):
"""Re-position measure label on view range change.
"""
if self._abs_top_right:
self._label_proxy.setPos(
self.vb.mapFromView(self._abs_top_right)
)
def mouse_drag_released(
self,
p1: QPointF,
p2: QPointF
) -> None:
"""Called on final button release for mouse drag with start and
end positions.
"""
self.set_pos(p1, p2)
def set_pos(
self,
p1: QPointF,
p2: QPointF
) -> None:
"""Set position of selection rect and accompanying label, move
label to match.
"""
if self._label_proxy is None:
# https://doc.qt.io/qt-5/qgraphicsproxywidget.html
self._label_proxy = self.vb.scene().addWidget(self._label)
start_pos = self.vb.mapToView(p1)
end_pos = self.vb.mapToView(p2)
# map to view coords and update area
r = QtCore.QRectF(start_pos, end_pos)
# old way; don't need right?
# lr = QtCore.QRectF(p1, p2)
# r = self.vb.childGroup.mapRectFromParent(lr)
self.setPos(r.topLeft())
self.resetTransform()
self.scale(r.width(), r.height())
self.show()
y1, y2 = start_pos.y(), end_pos.y()
x1, x2 = start_pos.x(), end_pos.x()
# TODO: heh, could probably use a max-min streamin algo here too
_, xmn = min(y1, y2), min(x1, x2)
ymx, xmx = max(y1, y2), max(x1, x2)
pchng = (y2 - y1) / y1 * 100
rng = abs(y1 - y2)
ixmn, ixmx = round(xmn), round(xmx)
nbars = ixmx - ixmn + 1
data = self._chart._arrays['ohlc'][ixmn:ixmx]
if len(data):
std = data['close'].std()
dmx = data['high'].max()
dmn = data['low'].min()
else:
dmn = dmx = std = np.nan
# update label info
self._label.setText('\n'.join(self._contents).format(
pchng=pchng, rng=rng, nbars=nbars,
std=std, dmx=dmx, dmn=dmn,
))
# print(f'x2, y2: {(x2, y2)}')
# print(f'xmn, ymn: {(xmn, ymx)}')
label_anchor = Point(xmx + 2, ymx)
# XXX: in the drag bottom-right -> top-left case we don't
# want the label to overlay the box.
# if (x2, y2) == (xmn, ymx):
# # could do this too but needs to be added after coords transform
# # label_anchor = Point(x2, y2 + self._label.height())
# label_anchor = Point(xmn, ymn)
self._abs_top_right = label_anchor
self._label_proxy.setPos(self.vb.mapFromView(label_anchor))
# self._label.show()
def clear(self):
"""Clear the selection box from view.
"""
self._label.hide()
self.hide()

View File

@ -19,27 +19,41 @@ Qt event proxying and processing using ``trio`` mem chans.
""" """
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from typing import Callable
from PyQt5 import QtCore, QtGui from PyQt5 import QtCore
from PyQt5.QtCore import QEvent from PyQt5.QtCore import QEvent
from PyQt5.QtGui import QWidget
import trio import trio
class EventCloner(QtCore.QObject): class EventRelay(QtCore.QObject):
"""Clone and forward keyboard events over a trio memory channel '''
for later async processing. Relay Qt events over a trio memory channel for async processing.
""" '''
_event_types: set[QEvent] = set() _event_types: set[QEvent] = set()
_send_chan: trio.abc.SendChannel = None _send_chan: trio.abc.SendChannel = None
_filter_auto_repeats: bool = True
def eventFilter( def eventFilter(
self, self,
source: QtGui.QWidget, source: QWidget,
ev: QEvent, ev: QEvent,
) -> None: ) -> None:
'''
Qt global event filter: return `False` to pass through and `True`
to filter event out.
if ev.type() in self._event_types: https://doc.qt.io/qt-5/qobject.html#eventFilter
https://doc.qt.io/qtforpython/overviews/eventsandfilters.html#event-filters
'''
etype = ev.type()
# print(f'etype: {etype}')
if etype in self._event_types:
# ev.accept()
# TODO: what's the right way to allow this? # TODO: what's the right way to allow this?
# if ev.isAutoRepeat(): # if ev.isAutoRepeat():
@ -51,41 +65,77 @@ class EventCloner(QtCore.QObject):
# something to do with Qt internals and calling the # something to do with Qt internals and calling the
# parent handler? # parent handler?
key = ev.key() if etype in {QEvent.KeyPress, QEvent.KeyRelease}:
mods = ev.modifiers()
txt = ev.text()
# run async processing # TODO: is there a global setting for this?
self._send_chan.send_nowait((ev, key, mods, txt)) if ev.isAutoRepeat() and self._filter_auto_repeats:
ev.ignore()
return True
# never intercept the event key = ev.key()
mods = ev.modifiers()
txt = ev.text()
# NOTE: the event object instance coming out
# the other side is mutated since Qt resumes event
# processing **before** running a ``trio`` guest mode
# tick, thus special handling or copying must be done.
# send elements to async handler
self._send_chan.send_nowait((ev, etype, key, mods, txt))
else:
# send event to async handler
self._send_chan.send_nowait(ev)
# **do not** filter out this event
# and instead forward to the source widget
return False
# filter out this event
# https://doc.qt.io/qt-5/qobject.html#installEventFilter
return False return False
@asynccontextmanager @asynccontextmanager
async def open_key_stream( async def open_event_stream(
source_widget: QtGui.QWidget, source_widget: QWidget,
event_types: set[QEvent] = {QEvent.KeyPress}, event_types: set[QEvent] = {QEvent.KeyPress},
filter_auto_repeats: bool = True,
# TODO: should we offer some kinda option for toggling releases?
# would it require a channel per event type?
# QEvent.KeyRelease,
) -> trio.abc.ReceiveChannel: ) -> trio.abc.ReceiveChannel:
# 1 to force eager sending # 1 to force eager sending
send, recv = trio.open_memory_channel(16) send, recv = trio.open_memory_channel(16)
kc = EventCloner() kc = EventRelay()
kc._send_chan = send kc._send_chan = send
kc._event_types = event_types kc._event_types = event_types
kc._filter_auto_repeats = filter_auto_repeats
source_widget.installEventFilter(kc) source_widget.installEventFilter(kc)
try: try:
yield recv async with send:
yield recv
finally: finally:
await send.aclose()
source_widget.removeEventFilter(kc) source_widget.removeEventFilter(kc)
@asynccontextmanager
async def open_handler(
source_widget: QWidget,
event_types: set[QEvent],
async_handler: Callable[[QWidget, trio.abc.ReceiveChannel], None],
**kwargs,
) -> None:
async with (
trio.open_nursery() as n,
open_event_stream(source_widget, event_types, **kwargs) as event_recv_stream,
):
n.start_soon(async_handler, source_widget, event_recv_stream)
yield

View File

@ -18,8 +18,8 @@
Mouse interaction graphics Mouse interaction graphics
""" """
import math from functools import partial
from typing import Optional, Tuple, Set, Dict from typing import Optional, Callable
import inspect import inspect
import numpy as np import numpy as np
@ -30,7 +30,6 @@ from PyQt5.QtCore import QPointF, QRectF
from .._style import ( from .._style import (
_xaxis_at, _xaxis_at,
hcolor, hcolor,
_font,
_font_small, _font_small,
) )
from .._axes import YAxisLabel, XAxisLabel from .._axes import YAxisLabel, XAxisLabel
@ -98,7 +97,7 @@ class LineDot(pg.CurvePoint):
(x, y) = self.curve().getData() (x, y) = self.curve().getData()
index = self.property('index') index = self.property('index')
# first = self._plot._ohlc[0]['index'] # first = self._plot._arrays['ohlc'][0]['index']
# first = x[0] # first = x[0]
# i = index - first # i = index - first
i = index - x[0] i = index - x[0]
@ -133,11 +132,15 @@ class ContentsLabel(pg.LabelItem):
} }
def __init__( def __init__(
self, self,
chart: 'ChartPlotWidget', # noqa # chart: 'ChartPlotWidget', # noqa
view: pg.ViewBox,
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: Optional[int] = None,
) -> None: ) -> None:
font_size = font_size or _font_small.px_size font_size = font_size or _font_small.px_size
@ -148,9 +151,10 @@ class ContentsLabel(pg.LabelItem):
) )
# anchor to viewbox # anchor to viewbox
self.setParentItem(chart._vb) self.setParentItem(view)
chart.scene().addItem(self)
self.chart = chart self.vb = view
view.scene().addItem(self)
v, h = anchor_at v, h = anchor_at
index = (self._corner_anchors[h], self._corner_anchors[v]) index = (self._corner_anchors[h], self._corner_anchors[v])
@ -163,10 +167,12 @@ class ContentsLabel(pg.LabelItem):
self.anchor(itemPos=index, parentPos=index, offset=margins) self.anchor(itemPos=index, parentPos=index, offset=margins)
def update_from_ohlc( def update_from_ohlc(
self, self,
name: str, name: str,
index: int, index: int,
array: np.ndarray, array: np.ndarray,
) -> None: ) -> None:
# this being "html" is the dumbest shit :eyeroll: # this being "html" is the dumbest shit :eyeroll:
first = array[0]['index'] first = array[0]['index']
@ -188,25 +194,111 @@ class ContentsLabel(pg.LabelItem):
) )
def update_from_value( def update_from_value(
self, self,
name: str, name: str,
index: int, index: int,
array: np.ndarray, array: np.ndarray,
) -> None: ) -> None:
first = array[0]['index'] first = array[0]['index']
if index < array[-1]['index'] and index > first: if index < array[-1]['index'] and index > first:
data = array[index - first][name] data = array[index - first][name]
self.setText(f"{name}: {data:.2f}") self.setText(f"{name}: {data:.2f}")
class ContentsLabels:
'''Collection of labels that span a ``LinkedSplits`` set of chart plots
and can be updated from the underlying data from an x-index value sent
as input from a cursor or other query mechanism.
'''
def __init__(
self,
linkedsplits: 'LinkedSplits', # type: ignore # noqa
) -> None:
self.linkedsplits = linkedsplits
self._labels: list[(
'CharPlotWidget', # type: ignore # noqa
str,
ContentsLabel,
Callable
)] = []
def update_labels(
self,
index: int,
# array_name: str,
) -> None:
# for name, (label, update) in self._labels.items():
for chart, name, label, update in self._labels:
if not (index >= 0 and index < chart._arrays['ohlc'][-1]['index']):
# out of range
continue
array = chart._arrays[name]
# call provided update func with data point
try:
label.show()
update(index, array)
except IndexError:
log.exception(f"Failed to update label: {name}")
def hide(self) -> None:
for chart, name, label, update in self._labels:
label.hide()
def add_label(
self,
chart: 'ChartPlotWidget', # type: ignore # noqa
name: str,
anchor_at: tuple[str, str] = ('top', 'left'),
update_func: Callable = ContentsLabel.update_from_value,
) -> ContentsLabel:
label = ContentsLabel(
view=chart._vb,
anchor_at=anchor_at,
)
self._labels.append(
(chart, name, label, partial(update_func, label, name))
)
# label.hide()
return label
class Cursor(pg.GraphicsObject): class Cursor(pg.GraphicsObject):
def __init__( def __init__(
self, self,
linkedsplitcharts: 'LinkedSplitCharts', # noqa linkedsplits: 'LinkedSplits', # noqa
digits: int = 0 digits: int = 0
) -> None: ) -> None:
super().__init__() super().__init__()
self.linked = linkedsplits
self.graphics: dict[str, pg.GraphicsObject] = {}
self.plots: List['PlotChartWidget'] = [] # type: ignore # noqa
self.active_plot = None
self.digits: int = digits
self._datum_xy: tuple[int, float] = (0, 0)
self._hovered: set[pg.GraphicsObject] = set()
self._trackers: set[pg.GraphicsObject] = set()
# XXX: not sure why these are instance variables? # XXX: not sure why these are instance variables?
# It's not like we can change them on the fly..? # It's not like we can change them on the fly..?
self.pen = pg.mkPen( self.pen = pg.mkPen(
@ -217,19 +309,10 @@ class Cursor(pg.GraphicsObject):
color=hcolor('davies'), color=hcolor('davies'),
style=QtCore.Qt.DashLine, style=QtCore.Qt.DashLine,
) )
self.lsc = linkedsplitcharts
self.graphics: Dict[str, pg.GraphicsObject] = {}
self.plots: List['PlotChartWidget'] = [] # type: ignore # noqa
self.active_plot = None
self.digits: int = digits
self._datum_xy: Tuple[int, float] = (0, 0)
self._hovered: Set[pg.GraphicsObject] = set()
self._trackers: Set[pg.GraphicsObject] = set()
# value used for rounding y-axis discreet tick steps # value used for rounding y-axis discreet tick steps
# computing once, up front, here cuz why not # computing once, up front, here cuz why not
self._y_incr_mult = 1 / self.lsc._symbol.tick_size self._y_incr_mult = 1 / self.linked._symbol.tick_size
# line width in view coordinates # line width in view coordinates
self._lw = self.pixelWidth() * self.lines_pen.width() self._lw = self.pixelWidth() * self.lines_pen.width()
@ -239,6 +322,26 @@ class Cursor(pg.GraphicsObject):
self._y_label_update: bool = True self._y_label_update: bool = True
self.contents_labels = ContentsLabels(self.linked)
self._in_query_mode: bool = False
@property
def in_query_mode(self) -> bool:
return self._in_query_mode
@in_query_mode.setter
def in_query_mode(self, value: bool) -> None:
if self._in_query_mode and not value:
# edge trigger "off" hide all labels
self.contents_labels.hide()
elif not self._in_query_mode and value:
# edge trigger "on" hide all labels
self.contents_labels.update_labels(self._datum_xy[0])
self._in_query_mode = value
def add_hovered( def add_hovered(
self, self,
item: pg.GraphicsObject, item: pg.GraphicsObject,
@ -320,7 +423,7 @@ class Cursor(pg.GraphicsObject):
# the current sample under the mouse # the current sample under the mouse
cursor = LineDot( cursor = LineDot(
curve, curve,
index=plot._ohlc[-1]['index'], index=plot._arrays['ohlc'][-1]['index'],
plot=plot plot=plot
) )
plot.addItem(cursor) plot.addItem(cursor)
@ -344,7 +447,7 @@ class Cursor(pg.GraphicsObject):
def mouseMoved( def mouseMoved(
self, self,
evt: 'Tuple[QMouseEvent]', # noqa evt: 'tuple[QMouseEvent]', # noqa
) -> None: # noqa ) -> None: # noqa
"""Update horizonal and vertical lines when mouse moves inside """Update horizonal and vertical lines when mouse moves inside
either the main chart or any indicator subplot. either the main chart or any indicator subplot.
@ -392,10 +495,16 @@ class Cursor(pg.GraphicsObject):
item.on_tracked_source(ix, iy) item.on_tracked_source(ix, iy)
if ix != last_ix: if ix != last_ix:
if self.in_query_mode:
# show contents labels on all linked charts and update
# with cursor movement
self.contents_labels.update_labels(ix)
for plot, opts in self.graphics.items(): for plot, opts in self.graphics.items():
# update the chart's "contents" label # update the chart's "contents" label
plot.update_contents_labels(ix) # plot.update_contents_labels(ix)
# move the vertical line to the current "center of bar" # move the vertical line to the current "center of bar"
opts['vl'].setX(ix + line_offset) opts['vl'].setX(ix + line_offset)

View File

@ -259,10 +259,10 @@ class LevelLine(pg.InfiniteLine):
detailed control and start end signalling. detailed control and start end signalling.
""" """
chart = self._chart cursor = self._chart.linked.cursor
# hide y-crosshair # hide y-crosshair
chart._cursor.hide_xhair() cursor.hide_xhair()
# highlight # highlight
self.currentPen = self.hoverPen self.currentPen = self.hoverPen
@ -308,7 +308,7 @@ class LevelLine(pg.InfiniteLine):
# This is the final position in the drag # This is the final position in the drag
if ev.isFinish(): if ev.isFinish():
# show y-crosshair again # show y-crosshair again
chart._cursor.show_xhair() cursor.show_xhair()
def delete(self) -> None: def delete(self) -> None:
"""Remove this line from containing chart/view/scene. """Remove this line from containing chart/view/scene.
@ -326,7 +326,7 @@ class LevelLine(pg.InfiniteLine):
# remove from chart/cursor states # remove from chart/cursor states
chart = self._chart chart = self._chart
cur = chart._cursor cur = chart.linked.cursor
if self in cur._hovered: if self in cur._hovered:
cur._hovered.remove(self) cur._hovered.remove(self)
@ -457,8 +457,7 @@ class LevelLine(pg.InfiniteLine):
"""Mouse hover callback. """Mouse hover callback.
""" """
chart = self._chart cur = self._chart.linked.cursor
cur = chart._cursor
# hovered # hovered
if (not ev.isExit()) and ev.acceptDrags(QtCore.Qt.LeftButton): if (not ev.isExit()) and ev.acceptDrags(QtCore.Qt.LeftButton):
@ -648,7 +647,10 @@ def order_line(
# use ``QPathGraphicsItem``s to draw markers in scene coords # use ``QPathGraphicsItem``s to draw markers in scene coords
# instead of the old way that was doing the same but by # instead of the old way that was doing the same but by
# resetting the graphics item transform intermittently # resetting the graphics item transform intermittently
# XXX: this is our new approach but seems slower?
# line.add_marker(mk_marker(marker_style, marker_size)) # line.add_marker(mk_marker(marker_style, marker_size))
assert not line.markers assert not line.markers
# the old way which is still somehow faster? # the old way which is still somehow faster?
@ -659,7 +661,10 @@ def order_line(
marker_size, marker_size,
use_qgpath=False, use_qgpath=False,
) )
# manually append for later ``.pain()`` drawing # manually append for later ``InfiniteLine.paint()`` drawing
# XXX: this was manually tested as faster then using the
# QGraphicsItem around a painter path.. probably needs further
# testing to figure out why tf that's true.
line.markers.append((path, 0, marker_size)) line.markers.append((path, 0, marker_size))
orient_v = 'top' if action == 'sell' else 'bottom' orient_v = 'top' if action == 'sell' else 'bottom'
@ -754,9 +759,29 @@ def position_line(
ymn, ymx = vr[1] ymn, ymx = vr[1]
level = line.value() level = line.value()
if level > ymx or level < ymn: if gt := level > ymx or (lt := level < ymn):
line._marker.hide()
if chartview.mode.name == 'order':
# provide "nav hub" like indicator for where
# the position is on the y-dimension
if gt:
# pin to top of view since position is above current
# y-range
pass
elif lt:
# pin to bottom of view since position is above
# below y-range
pass
else:
# order mode is not active
# so hide the pp market
line._marker.hide()
else: else:
# pp line is viewable so show marker
line._marker.show() line._marker.show()
vb.sigYRangeChanged.connect(update_pp_nav) vb.sigYRangeChanged.connect(update_pp_nav)
@ -787,6 +812,7 @@ def position_line(
style = '>|' style = '>|'
arrow_path = mk_marker(style, size=arrow_size) arrow_path = mk_marker(style, size=arrow_size)
# XXX: uses new marker drawing approach
line.add_marker(arrow_path) line.add_marker(arrow_path)
line.set_level(level) line.set_level(level)

View File

@ -18,447 +18,209 @@
Chart view box primitives Chart view box primitives
""" """
from dataclasses import dataclass, field from contextlib import asynccontextmanager
from typing import Optional, Dict import time
from typing import Optional, Callable
import pyqtgraph as pg import pyqtgraph as pg
from PyQt5.QtCore import QPointF from PyQt5.QtCore import Qt, QEvent
from pyqtgraph import ViewBox, Point, QtCore, QtGui from pyqtgraph import ViewBox, Point, QtCore
from pyqtgraph import functions as fn from pyqtgraph import functions as fn
import numpy as np import numpy as np
import trio
from ..log import get_logger from ..log import get_logger
from ._style import _min_points_to_show, hcolor, _font from ._style import _min_points_to_show
from ._graphics._lines import order_line, LevelLine from ._editors import SelectRect
from ._window import main_window
log = get_logger(__name__) log = get_logger(__name__)
class SelectRect(QtGui.QGraphicsRectItem): async def handle_viewmode_inputs(
def __init__( view: 'ChartView',
self, recv_chan: trio.abc.ReceiveChannel,
viewbox: ViewBox,
color: str = 'dad_blue',
) -> None:
super().__init__(0, 0, 1, 1)
# self.rbScaleBox = QtGui.QGraphicsRectItem(0, 0, 1, 1) ) -> None:
self.vb = viewbox
self._chart: 'ChartPlotWidget' = None # noqa
# override selection box color mode = view.mode
color = QtGui.QColor(hcolor(color))
self.setPen(fn.mkPen(color, width=1))
color.setAlpha(66)
self.setBrush(fn.mkBrush(color))
self.setZValue(1e9)
self.hide()
self._label = None
label = self._label = QtGui.QLabel() # track edge triggered keys
label.setTextFormat(0) # markdown # (https://en.wikipedia.org/wiki/Interrupt#Triggering_methods)
label.setFont(_font.font) pressed: set[str] = set()
label.setMargin(0)
label.setAlignment(
QtCore.Qt.AlignLeft
# | QtCore.Qt.AlignVCenter
)
# proxy is created after containing scene is initialized last = time.time()
self._label_proxy = None trigger_mode: str
self._abs_top_right = None action: str
# TODO: "swing %" might be handy here (data's max/min # % change) # for quick key sequence-combo pattern matching
self._contents = [ # we have a min_tap period and these should not
'change: {pchng:.2f} %', # ever be auto-repeats since we filter those at the
'range: {rng:.2f}', # event filter level prior to the above mem chan.
'bars: {nbars}', min_tap = 1/6
'max: {dmx}', fast_key_seq: list[str] = []
'min: {dmn}', fast_taps: dict[str, Callable] = {
# 'time: {nbars}m', # TODO: compute this per bar size 'cc': mode.cancel_all_orders,
'sigma: {std:.2f}', }
]
@property async for event, etype, key, mods, text in recv_chan:
def chart(self) -> 'ChartPlotWidget': # noqa log.debug(f'key: {key}, mods: {mods}, text: {text}')
return self._chart now = time.time()
period = now - last
@chart.setter # reset mods
def chart(self, chart: 'ChartPlotWidget') -> None: # noqa ctrl: bool = False
self._chart = chart shift: bool = False
chart.sigRangeChanged.connect(self.update_on_resize)
palette = self._label.palette()
# TODO: get bg color working # press branch
palette.setColor( if etype in {QEvent.KeyPress}:
self._label.backgroundRole(),
# QtGui.QColor(chart.backgroundBrush()),
QtGui.QColor(hcolor('papas_special')),
)
def update_on_resize(self, vr, r): pressed.add(key)
"""Re-position measure label on view range change.
""" if (
if self._abs_top_right: # clear any old values not part of a "fast" tap sequence:
self._label_proxy.setPos( # presumes the period since last tap is longer then our
self.vb.mapFromView(self._abs_top_right) # min_tap period
) fast_key_seq and period >= min_tap or
def mouse_drag_released( # don't support more then 2 key sequences for now
self, len(fast_key_seq) > 2
p1: QPointF, ):
p2: QPointF fast_key_seq.clear()
) -> None:
"""Called on final button release for mouse drag with start and
end positions.
""" # capture key to fast tap sequence if we either
self.set_pos(p1, p2) # 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}')
def set_pos( # mods run through
self, if mods == Qt.ShiftModifier:
p1: QPointF, shift = True
p2: QPointF
) -> None:
"""Set position of selection rect and accompanying label, move
label to match.
""" if mods == Qt.ControlModifier:
if self._label_proxy is None: ctrl = True
# https://doc.qt.io/qt-5/qgraphicsproxywidget.html
self._label_proxy = self.vb.scene().addWidget(self._label)
start_pos = self.vb.mapToView(p1) # SEARCH MODE #
end_pos = self.vb.mapToView(p2) # ctlr-<space>/<l> for "lookup", "search" -> open search tree
if (
ctrl and key in {
Qt.Key_L,
Qt.Key_Space,
}
):
view._chart._lc.godwidget.search.focus()
# map to view coords and update area # esc and ctrl-c
r = QtCore.QRectF(start_pos, end_pos) 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()
# old way; don't need right? # cancel order or clear graphics
# lr = QtCore.QRectF(p1, p2) if key == Qt.Key_C or key == Qt.Key_Delete:
# r = self.vb.childGroup.mapRectFromParent(lr)
self.setPos(r.topLeft()) mode.cancel_orders_under_cursor()
self.resetTransform()
self.scale(r.width(), r.height())
self.show()
y1, y2 = start_pos.y(), end_pos.y() # View modes
x1, x2 = start_pos.x(), end_pos.x() if key == Qt.Key_R:
# TODO: heh, could probably use a max-min streamin algo here too # edge triggered default view activation
_, xmn = min(y1, y2), min(x1, x2) view.chart.default_view()
ymx, xmx = max(y1, y2), max(x1, x2)
pchng = (y2 - y1) / y1 * 100 if len(fast_key_seq) > 1:
rng = abs(y1 - y2) # begin matches against sequences
func: Callable = fast_taps.get(''.join(fast_key_seq))
if func:
func()
fast_key_seq.clear()
ixmn, ixmx = round(xmn), round(xmx) # release branch
nbars = ixmx - ixmn + 1 elif etype in {QEvent.KeyRelease}:
data = self._chart._ohlc[ixmn:ixmx] if key in pressed:
pressed.remove(key)
# QUERY MODE #
if {Qt.Key_Q}.intersection(pressed):
view.linkedsplits.cursor.in_query_mode = True
if len(data):
std = data['close'].std()
dmx = data['high'].max()
dmn = data['low'].min()
else: else:
dmn = dmx = std = np.nan view.linkedsplits.cursor.in_query_mode = False
# update label info # SELECTION MODE #
self._label.setText('\n'.join(self._contents).format(
pchng=pchng, rng=rng, nbars=nbars,
std=std, dmx=dmx, dmn=dmn,
))
# print(f'x2, y2: {(x2, y2)}') if shift:
# print(f'xmn, ymn: {(xmn, ymx)}') if view.state['mouseMode'] == ViewBox.PanMode:
view.setMouseMode(ViewBox.RectMode)
label_anchor = Point(xmx + 2, ymx)
# XXX: in the drag bottom-right -> top-left case we don't
# want the label to overlay the box.
# if (x2, y2) == (xmn, ymx):
# # could do this too but needs to be added after coords transform
# # label_anchor = Point(x2, y2 + self._label.height())
# label_anchor = Point(xmn, ymn)
self._abs_top_right = label_anchor
self._label_proxy.setPos(self.vb.mapFromView(label_anchor))
# self._label.show()
def clear(self):
"""Clear the selection box from view.
"""
self._label.hide()
self.hide()
# global store of order-lines graphics
# keyed by uuid4 strs - used to sync draw
# order lines **after** the order is 100%
# active in emsd
_order_lines: Dict[str, LevelLine] = {}
@dataclass
class LineEditor:
"""The great editor of linez..
"""
view: 'ChartView'
_order_lines: field(default_factory=_order_lines)
chart: 'ChartPlotWidget' = None # type: ignore # noqa
_active_staged_line: LevelLine = None
_stage_line: LevelLine = None
def stage_line(
self,
action: str,
color: str = 'alert_yellow',
hl_on_hover: bool = False,
dotted: bool = False,
# fields settings
size: Optional[int] = None,
) -> LevelLine:
"""Stage a line at the current chart's cursor position
and return it.
"""
# chart.setCursor(QtCore.Qt.PointingHandCursor)
chart = self.chart._cursor.active_plot
cursor = chart._cursor
y = chart._cursor._datum_xy[1]
symbol = chart._lc.symbol
# line = self._stage_line
# if not line:
# add a "staged" cursor-tracking line to view
# and cash it in a a var
if self._active_staged_line:
self.unstage_line()
line = order_line(
chart,
level=y,
level_digits=symbol.digits(),
size=size,
size_digits=symbol.lot_digits(),
# just for the stage line to avoid
# flickering while moving the cursor
# around where it might trigger highlight
# then non-highlight depending on sensitivity
always_show_labels=True,
# kwargs
color=color,
# don't highlight the "staging" line
hl_on_hover=hl_on_hover,
dotted=dotted,
exec_type='dark' if dotted else 'live',
action=action,
show_markers=True,
# prevent flickering of marker while moving/tracking cursor
only_show_markers_on_hover=False,
)
self._active_staged_line = line
# hide crosshair y-line and label
cursor.hide_xhair()
# add line to cursor trackers
cursor._trackers.add(line)
return line
def unstage_line(self) -> LevelLine:
"""Inverse of ``.stage_line()``.
"""
# chart = self.chart._cursor.active_plot
# # chart.setCursor(QtCore.Qt.ArrowCursor)
cursor = self.chart._cursor
# delete "staged" cursor tracking line from view
line = self._active_staged_line
if line:
cursor._trackers.remove(line)
line.delete()
self._active_staged_line = None
# show the crosshair y line and label
cursor.show_xhair()
def create_order_line(
self,
uuid: str,
level: float,
chart: 'ChartPlotWidget', # noqa
size: float,
action: str,
) -> LevelLine:
line = self._active_staged_line
if not line:
raise RuntimeError("No line is currently staged!?")
sym = chart._lc.symbol
line = order_line(
chart,
# label fields default values
level=level,
level_digits=sym.digits(),
size=size,
size_digits=sym.lot_digits(),
# LevelLine kwargs
color=line.color,
dotted=line._dotted,
show_markers=True,
only_show_markers_on_hover=True,
action=action,
)
# for now, until submission reponse arrives
line.hide_labels()
# register for later lookup/deletion
self._order_lines[uuid] = line
return line
def commit_line(self, uuid: str) -> LevelLine:
"""Commit a "staged line" to view.
Submits the line graphic under the cursor as a (new) permanent
graphic in view.
"""
try:
line = self._order_lines[uuid]
except KeyError:
log.warning(f'No line for {uuid} could be found?')
return
else: else:
assert line.oid == uuid view.setMouseMode(ViewBox.PanMode)
line.show_labels()
# TODO: other flashy things to indicate the order is active # ORDER MODE #
# live vs. dark trigger + an action {buy, sell, alert}
log.debug(f'Level active for level: {line.value()}') order_keys_pressed = {
Qt.Key_A,
Qt.Key_F,
Qt.Key_D
}.intersection(pressed)
return line if order_keys_pressed:
if (
# 's' for "submit" to activate "live" order
Qt.Key_S in pressed or
ctrl
):
trigger_mode: str = 'live'
def lines_under_cursor(self): else:
"""Get the line(s) under the cursor position. trigger_mode: str = 'dark'
""" # order mode trigger "actions"
# Delete any hoverable under the cursor if Qt.Key_D in pressed: # for "damp eet"
return self.chart._cursor._hovered action = 'sell'
def remove_line( elif Qt.Key_F in pressed: # for "fillz eet"
self, action = 'buy'
line: LevelLine = None,
uuid: str = None,
) -> LevelLine:
"""Remove a line by refernce or uuid.
If no lines or ids are provided remove all lines under the elif Qt.Key_A in pressed:
cursor position. action = 'alert'
trigger_mode = 'live'
""" view.order_mode = True
if line:
uuid = line.oid
# try to look up line from our registry # XXX: order matters here for line style!
line = self._order_lines.pop(uuid, None) view.mode._exec_mode = trigger_mode
if line: view.mode.set_exec(action)
# if hovered remove from cursor set prefix = trigger_mode + '-' if action != 'alert' else ''
hovered = self.chart._cursor._hovered view._chart.window().mode_label.setText(
if line in hovered: f'mode: {prefix}{action}')
hovered.remove(line)
# make sure the xhair doesn't get left off else: # none active
# just because we never got a un-hover event # if none are pressed, remove "staged" level
self.chart._cursor.show_xhair() # line under cursor position
view.mode.lines.unstage_line()
line.delete() if view.hasFocus():
return line # update mode label
view._chart.window().mode_label.setText('mode: view')
view.order_mode = False
@dataclass last = time.time()
class ArrowEditor:
chart: 'ChartPlotWidget' # noqa
_arrows: field(default_factory=dict)
def add(
self,
uid: str,
x: float,
y: float,
color='default',
pointing: Optional[str] = None,
) -> pg.ArrowItem:
"""Add an arrow graphic to view at given (x, y).
"""
angle = {
'up': 90,
'down': -90,
None: 180, # pointing to right (as in an alert)
}[pointing]
# scale arrow sizing to dpi-aware font
size = _font.font.pixelSize() * 0.8
arrow = pg.ArrowItem(
angle=angle,
baseAngle=0,
headLen=size,
headWidth=size/2,
tailLen=None,
pxMode=True,
# coloring
pen=pg.mkPen(hcolor('papas_special')),
brush=pg.mkBrush(hcolor(color)),
)
arrow.setPos(x, y)
self._arrows[uid] = arrow
# render to view
self.chart.plotItem.addItem(arrow)
return arrow
def remove(self, arrow) -> bool:
self.chart.plotItem.removeItem(arrow)
class ChartView(ViewBox): class ChartView(ViewBox):
"""Price chart view box with interaction behaviors you'd expect from '''
Price chart view box with interaction behaviors you'd expect from
any interactive platform: any interactive platform:
- zoom on mouse scroll that auto fits y-axis - zoom on mouse scroll that auto fits y-axis
@ -466,31 +228,48 @@ class ChartView(ViewBox):
- zoom on x to most recent in view datum - zoom on x to most recent in view datum
- zoom on right-click-n-drag to cursor position - zoom on right-click-n-drag to cursor position
""" '''
mode_name: str = 'mode: view' mode_name: str = 'mode: view'
def __init__( def __init__(
self, self,
name: str,
parent: pg.PlotItem = None, parent: pg.PlotItem = None,
**kwargs, **kwargs,
): ):
super().__init__(parent=parent, **kwargs) super().__init__(parent=parent, **kwargs)
# disable vertical scrolling # disable vertical scrolling
self.setMouseEnabled(x=True, y=False) self.setMouseEnabled(x=True, y=False)
self.linked_charts = None
self.select_box = SelectRect(self) self.linkedsplits = None
self.addItem(self.select_box, ignoreBounds=True)
self._chart: 'ChartPlotWidget' = None # noqa self._chart: 'ChartPlotWidget' = None # noqa
self.mode = None # add our selection box annotator
self.select_box = SelectRect(self)
self.addItem(self.select_box, ignoreBounds=True)
# kb ctrls processing self.name = name
self._key_buffer = [] self.mode = None
self._key_active: bool = False self.order_mode: bool = False
self.setFocusPolicy(QtCore.Qt.StrongFocus) self.setFocusPolicy(QtCore.Qt.StrongFocus)
@asynccontextmanager
async def open_async_input_handler(
self,
) -> 'ChartView':
from . import _event
async with _event.open_handler(
self,
event_types={QEvent.KeyPress, QEvent.KeyRelease},
async_handler=handle_viewmode_inputs,
):
yield self
@property @property
def chart(self) -> 'ChartPlotWidget': # type: ignore # noqa def chart(self) -> 'ChartPlotWidget': # type: ignore # noqa
return self._chart return self._chart
@ -501,21 +280,21 @@ class ChartView(ViewBox):
self.select_box.chart = chart self.select_box.chart = chart
def wheelEvent(self, ev, axis=None): def wheelEvent(self, ev, axis=None):
"""Override "center-point" location for scrolling. '''Override "center-point" location for scrolling.
This is an override of the ``ViewBox`` method simply changing This is an override of the ``ViewBox`` method simply changing
the center of the zoom to be the y-axis. the center of the zoom to be the y-axis.
TODO: PR a method into ``pyqtgraph`` to make this configurable TODO: PR a method into ``pyqtgraph`` to make this configurable
"""
'''
if axis in (0, 1): if axis in (0, 1):
mask = [False, False] mask = [False, False]
mask[axis] = self.state['mouseEnabled'][axis] mask[axis] = self.state['mouseEnabled'][axis]
else: else:
mask = self.state['mouseEnabled'][:] mask = self.state['mouseEnabled'][:]
chart = self.linked_charts.chart chart = self.linkedsplits.chart
# don't zoom more then the min points setting # don't zoom more then the min points setting
l, lbar, rbar, r = chart.bars_range() l, lbar, rbar, r = chart.bars_range()
@ -525,7 +304,7 @@ class ChartView(ViewBox):
log.debug("Max zoom bruh...") log.debug("Max zoom bruh...")
return return
if ev.delta() < 0 and vl >= len(chart._ohlc) + 666: if ev.delta() < 0 and vl >= len(chart._arrays['ohlc']) + 666:
log.debug("Min zoom bruh...") log.debug("Min zoom bruh...")
return return
@ -573,7 +352,6 @@ class ChartView(ViewBox):
end_of_l1, end_of_l1,
key=lambda p: p.x() key=lambda p: p.x()
) )
# breakpoint()
# focal = pg.Point(last_bar.x() + end_of_l1) # focal = pg.Point(last_bar.x() + end_of_l1)
self._resetTarget() self._resetTarget()
@ -693,131 +471,16 @@ class ChartView(ViewBox):
elif button == QtCore.Qt.LeftButton: elif button == QtCore.Qt.LeftButton:
# when in order mode, submit execution # when in order mode, submit execution
if self._key_active: if self.order_mode:
ev.accept() ev.accept()
self.mode.submit_exec() self.mode.submit_exec()
def keyReleaseEvent(self, ev: QtCore.QEvent): def keyReleaseEvent(self, event: QtCore.QEvent) -> None:
""" '''This routine is rerouted to an async handler.
Key release to normally to trigger release of input mode '''
pass
""" def keyPressEvent(self, event: QtCore.QEvent) -> None:
# TODO: is there a global setting for this? '''This routine is rerouted to an async handler.
if ev.isAutoRepeat(): '''
ev.ignore() pass
return
ev.accept()
# text = ev.text()
key = ev.key()
mods = ev.modifiers()
if key == QtCore.Qt.Key_Shift:
# if self.state['mouseMode'] == ViewBox.RectMode:
self.setMouseMode(ViewBox.PanMode)
# ctlalt = False
# if (QtCore.Qt.AltModifier | QtCore.Qt.ControlModifier) == mods:
# ctlalt = True
# if self.state['mouseMode'] == ViewBox.RectMode:
# if key == QtCore.Qt.Key_Space:
if mods == QtCore.Qt.ControlModifier or key == QtCore.Qt.Key_Control:
self.mode._exec_mode = 'dark'
if key in {QtCore.Qt.Key_A, QtCore.Qt.Key_F, QtCore.Qt.Key_D}:
# remove "staged" level line under cursor position
self.mode.lines.unstage_line()
self._key_active = False
def keyPressEvent(self, ev: QtCore.QEvent) -> None:
"""
This routine should capture key presses in the current view box.
"""
# TODO: is there a global setting for this?
if ev.isAutoRepeat():
ev.ignore()
return
ev.accept()
text = ev.text()
key = ev.key()
mods = ev.modifiers()
print(f'text: {text}, key: {key}')
if mods == QtCore.Qt.ShiftModifier:
if self.state['mouseMode'] == ViewBox.PanMode:
self.setMouseMode(ViewBox.RectMode)
# ctrl
ctrl = False
if mods == QtCore.Qt.ControlModifier:
ctrl = True
self.mode._exec_mode = 'live'
self._key_active = True
# ctrl + alt
# ctlalt = False
# if (QtCore.Qt.AltModifier | QtCore.Qt.ControlModifier) == mods:
# ctlalt = True
# ctlr-<space>/<l> for "lookup", "search" -> open search tree
if ctrl and key in {
QtCore.Qt.Key_L,
QtCore.Qt.Key_Space,
}:
search = self._chart._lc.chart_space.search
search.focus()
# esc
if key == QtCore.Qt.Key_Escape or (ctrl and key == QtCore.Qt.Key_C):
# ctrl-c as cancel
# https://forum.qt.io/topic/532/how-to-catch-ctrl-c-on-a-widget/9
self.select_box.clear()
# cancel order or clear graphics
if key == QtCore.Qt.Key_C or key == QtCore.Qt.Key_Delete:
# delete any lines under the cursor
mode = self.mode
for line in mode.lines.lines_under_cursor():
mode.book.cancel(uuid=line.oid)
self._key_buffer.append(text)
# View modes
if key == QtCore.Qt.Key_R:
self.chart.default_view()
# Order modes: stage orders at the current cursor level
elif key == QtCore.Qt.Key_D: # for "damp eet"
self.mode.set_exec('sell')
elif key == QtCore.Qt.Key_F: # for "fillz eet"
self.mode.set_exec('buy')
elif key == QtCore.Qt.Key_A:
self.mode.set_exec('alert')
# XXX: Leaving this for light reference purposes, there
# seems to be some work to at least gawk at for history mgmt.
# Key presses are used only when mouse mode is RectMode
# The following events are implemented:
# ctrl-A : zooms out to the default "full" view of the plot
# ctrl-+ : moves forward in the zooming stack (if it exists)
# ctrl-- : moves backward in the zooming stack (if it exists)
# self.scaleHistory(-1)
# elif ev.text() in ['+', '=']:
# self.scaleHistory(1)
# elif ev.key() == QtCore.Qt.Key_Backspace:
# self.scaleHistory(len(self.axHistory))
else:
# maybe propagate to parent widget
ev.ignore()
self._key_active = False

View File

@ -89,11 +89,16 @@ def right_axis(
class Label: class Label:
""" """
A plain ol' "scene label" using an underlying ``QGraphicsTextItem``.
After hacking for many days on multiple "label" systems inside After hacking for many days on multiple "label" systems inside
``pyqtgraph`` yet again we're left writing our own since it seems ``pyqtgraph`` yet again we're left writing our own since it seems
all of those are over complicated, ad-hoc, pieces of garbage that all of those are over complicated, ad-hoc, transform-mangling,
can't accomplish the simplest things, such as pinning to the left messes which can't accomplish the simplest things via their inputs
hand side of a view box. (such as pinning to the left hand side of a view box).
Here we do the simple thing where the label uses callables to figure
out the (x, y) coordinate "pin point": nice and simple.
This type is another effort (see our graphics) to start making This type is another effort (see our graphics) to start making
small, re-usable label components that can actually be used to build small, re-usable label components that can actually be used to build
@ -104,6 +109,7 @@ class Label:
self, self,
view: pg.ViewBox, view: pg.ViewBox,
fmt_str: str, fmt_str: str,
color: str = 'bracket', color: str = 'bracket',
x_offset: float = 0, x_offset: float = 0,

View File

@ -447,7 +447,7 @@ class SearchBar(QtWidgets.QLineEdit):
self.view: CompleterView = view self.view: CompleterView = view
self.dpi_font = font self.dpi_font = font
self.chart_app = parent_chart self.godwidget = parent_chart
# size it as we specify # size it as we specify
# https://doc.qt.io/qt-5/qsizepolicy.html#Policy-enum # https://doc.qt.io/qt-5/qsizepolicy.html#Policy-enum
@ -496,12 +496,12 @@ class SearchWidget(QtGui.QWidget):
def __init__( def __init__(
self, self,
chart_space: 'ChartSpace', # type: ignore # noqa godwidget: 'GodWidget', # type: ignore # noqa
columns: List[str] = ['src', 'symbol'], columns: List[str] = ['src', 'symbol'],
parent=None, parent=None,
) -> None: ) -> None:
super().__init__(parent or chart_space) super().__init__(parent or godwidget)
# size it as we specify # size it as we specify
self.setSizePolicy( self.setSizePolicy(
@ -509,7 +509,7 @@ class SearchWidget(QtGui.QWidget):
QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed,
) )
self.chart_app = chart_space self.godwidget = godwidget
self.vbox = QtGui.QVBoxLayout(self) self.vbox = QtGui.QVBoxLayout(self)
self.vbox.setContentsMargins(0, 0, 0, 0) self.vbox.setContentsMargins(0, 0, 0, 0)
@ -540,7 +540,7 @@ class SearchWidget(QtGui.QWidget):
) )
self.bar = SearchBar( self.bar = SearchBar(
parent=self, parent=self,
parent_chart=chart_space, parent_chart=godwidget,
view=self.view, view=self.view,
) )
self.bar_hbox.addWidget(self.bar) self.bar_hbox.addWidget(self.bar)
@ -557,7 +557,7 @@ class SearchWidget(QtGui.QWidget):
# fill cache list if nothing existing # fill cache list if nothing existing
self.view.set_section_entries( self.view.set_section_entries(
'cache', 'cache',
list(reversed(self.chart_app._chart_cache)), list(reversed(self.godwidget._chart_cache)),
clear_all=True, clear_all=True,
) )
@ -611,7 +611,7 @@ class SearchWidget(QtGui.QWidget):
return None return None
provider, symbol = value provider, symbol = value
chart = self.chart_app chart = self.godwidget
log.info(f'Requesting symbol: {symbol}.{provider}') log.info(f'Requesting symbol: {symbol}.{provider}')
@ -632,7 +632,7 @@ class SearchWidget(QtGui.QWidget):
# Re-order the symbol cache on the chart to display in # Re-order the symbol cache on the chart to display in
# LIFO order. this is normally only done internally by # LIFO order. this is normally only done internally by
# the chart on new symbols being loaded into memory # the chart on new symbols being loaded into memory
chart.set_chart_symbol(fqsn, chart.linkedcharts) chart.set_chart_symbol(fqsn, chart.linkedsplits)
self.view.set_section_entries( self.view.set_section_entries(
'cache', 'cache',
@ -650,6 +650,7 @@ _search_enabled: bool = False
async def pack_matches( async def pack_matches(
view: CompleterView, view: CompleterView,
has_results: dict[str, set[str]], has_results: dict[str, set[str]],
matches: dict[(str, str), [str]], matches: dict[(str, str), [str]],
@ -823,7 +824,7 @@ async def fill_results(
async def handle_keyboard_input( async def handle_keyboard_input(
search: SearchWidget, searchbar: SearchBar,
recv_chan: trio.abc.ReceiveChannel, recv_chan: trio.abc.ReceiveChannel,
) -> None: ) -> None:
@ -831,8 +832,9 @@ async def handle_keyboard_input(
global _search_active, _search_enabled global _search_active, _search_enabled
# startup # startup
chart = search.chart_app bar = searchbar
bar = search.bar search = searchbar.parent()
chart = search.godwidget
view = bar.view view = bar.view
view.set_font_size(bar.dpi_font.px_size) view.set_font_size(bar.dpi_font.px_size)
@ -851,7 +853,7 @@ async def handle_keyboard_input(
) )
) )
async for event, key, mods, txt in recv_chan: async for event, etype, key, mods, txt in recv_chan:
log.debug(f'key: {key}, mods: {mods}, txt: {txt}') log.debug(f'key: {key}, mods: {mods}, txt: {txt}')
@ -889,7 +891,7 @@ async def handle_keyboard_input(
# kill the search and focus back on main chart # kill the search and focus back on main chart
if chart: if chart:
chart.linkedcharts.focus() chart.linkedsplits.focus()
continue continue

View File

@ -21,7 +21,8 @@ Qt main window singletons and stuff.
import os import os
import signal import signal
import time import time
from typing import Callable from typing import Callable, Optional, Union
import uuid
from pyqtgraph import QtGui from pyqtgraph import QtGui
from PyQt5 import QtCore from PyQt5 import QtCore
@ -42,25 +43,101 @@ class MultiStatus:
def __init__(self, bar, statuses) -> None: def __init__(self, bar, statuses) -> None:
self.bar = bar self.bar = bar
self.statuses = statuses self.statuses = statuses
self._to_clear: set = set()
self._status_groups: dict[str, (set, Callable)] = {}
def open_status( def open_status(
self, self,
msg: str, msg: str,
) -> Callable[..., None]: final_msg: Optional[str] = None,
clear_on_next: bool = False,
group_key: Optional[Union[bool, str]] = False,
) -> Union[Callable[..., None], str]:
'''Add a status to the status bar and return a close callback which '''Add a status to the status bar and return a close callback which
when called will remove the status ``msg``. when called will remove the status ``msg``.
''' '''
for old_msg in self._to_clear:
try:
self.statuses.remove(old_msg)
except ValueError:
pass
self.statuses.append(msg) self.statuses.append(msg)
def remove_msg() -> None: def remove_msg() -> None:
self.statuses.remove(msg) try:
self.statuses.remove(msg)
except ValueError:
pass
self.render() self.render()
if final_msg is not None:
self.statuses.append(final_msg)
self.render()
self._to_clear.add(final_msg)
ret = remove_msg
# create a "status group" such that new `.open_status()`
# calls can be made passing in the returned group key.
# once all clear callbacks have been called from all statuses
# in the group the final status msg to be removed will be the one
# the one provided when `group_key=True`, this way you can
# create a long living status that completes once all
# sub-statuses have finished.
if group_key is True:
if clear_on_next:
ValueError("Can't create group status and clear it on next?")
# generate a key for a new "status group"
new_group_key = str(uuid.uuid4())
def pop_group_and_clear():
subs, final_clear = self._status_groups.pop(new_group_key)
assert not subs
return remove_msg()
self._status_groups[new_group_key] = (set(), pop_group_and_clear)
ret = new_group_key
elif group_key:
def pop_from_group_and_maybe_clear_group():
# remove the message for this sub-status
remove_msg()
# check to see if all other substatuses have cleared
group_tup = self._status_groups.get(group_key)
if group_tup:
subs, group_clear = group_tup
try:
subs.remove(msg)
except KeyError:
raise KeyError(f'no msg {msg} for group {group_key}!?')
if not subs:
group_clear()
self._status_groups[group_key][0].add(msg)
ret = pop_from_group_and_maybe_clear_group
self.render() self.render()
return remove_msg
if clear_on_next:
self._to_clear.add(msg)
return ret
def render(self) -> None: def render(self) -> None:
'''Display all open statuses to bar.
'''
if self.statuses: if self.statuses:
self.bar.showMessage(f'{" ".join(self.statuses)}') self.bar.showMessage(f'{" ".join(self.statuses)}')
else: else:

View File

@ -26,11 +26,12 @@ from typing import Optional, Dict, Callable, Any
import uuid import uuid
import pyqtgraph as pg import pyqtgraph as pg
import trio
from pydantic import BaseModel from pydantic import BaseModel
import trio
from ._graphics._lines import LevelLine, position_line from ._graphics._lines import LevelLine, position_line
from ._interaction import LineEditor, ArrowEditor, _order_lines from ._editors import LineEditor, ArrowEditor
from ._window import MultiStatus, main_window
from ..clearing._client import open_ems, OrderBook from ..clearing._client import open_ems, OrderBook
from ..data._source import Symbol from ..data._source import Symbol
from ..log import get_logger from ..log import get_logger
@ -48,18 +49,31 @@ class Position(BaseModel):
@dataclass @dataclass
class OrderMode: class OrderMode:
"""Major mode for placing orders on a chart view. '''Major mode for placing orders on a chart view.
This is the default mode that pairs with "follow mode" This is the default mode that pairs with "follow mode"
(when wathing the rt price update at the current time step) (when wathing the rt price update at the current time step)
and allows entering orders using the ``a, d, f`` keys and and allows entering orders using mouse and keyboard.
cancelling moused-over orders with the ``c`` key. This object is chart oriented, so there is an instance per
chart / view currently.
""" Current manual:
a -> alert
s/ctrl -> submission type modifier {on: live, off: dark}
f (fill) -> buy limit order
d (dump) -> sell limit order
c (cancel) -> cancel order under cursor
cc -> cancel all submitted orders on chart
mouse click and drag -> modify current order under cursor
'''
chart: 'ChartPlotWidget' # type: ignore # noqa chart: 'ChartPlotWidget' # type: ignore # noqa
book: OrderBook book: OrderBook
lines: LineEditor lines: LineEditor
arrows: ArrowEditor arrows: ArrowEditor
status_bar: MultiStatus
name: str = 'order'
_colors = { _colors = {
'alert': 'alert_yellow', 'alert': 'alert_yellow',
'buy': 'buy_green', 'buy': 'buy_green',
@ -71,7 +85,8 @@ class OrderMode:
_position: Dict[str, Any] = field(default_factory=dict) _position: Dict[str, Any] = field(default_factory=dict)
_position_line: dict = None _position_line: dict = None
key_map: Dict[str, Callable] = field(default_factory=dict) _pending_submissions: dict[str, (LevelLine, Callable)] = field(
default_factory=dict)
def on_position_update( def on_position_update(
self, self,
@ -108,12 +123,18 @@ class OrderMode:
"""Set execution mode. """Set execution mode.
""" """
# not initialized yet
if not self.chart.linked.cursor:
return
self._action = action self._action = action
self.lines.stage_line( self.lines.stage_line(
color=self._colors[action], color=self._colors[action],
# hl_on_hover=True if self._exec_mode == 'live' else False, # hl_on_hover=True if self._exec_mode == 'live' else False,
dotted=True if self._exec_mode == 'dark' else False, dotted=True if (
self._exec_mode == 'dark' and action != 'alert'
) else False,
size=size or self._size, size=size or self._size,
action=action, action=action,
) )
@ -127,6 +148,13 @@ class OrderMode:
""" """
line = self.lines.commit_line(uuid) line = self.lines.commit_line(uuid)
pending = self._pending_submissions.get(uuid)
if pending:
order_line, func = pending
assert order_line is line
func()
return line return line
def on_fill( def on_fill(
@ -182,8 +210,12 @@ class OrderMode:
if msg is not None: if msg is not None:
self.lines.remove_line(uuid=uuid) self.lines.remove_line(uuid=uuid)
self.chart._cursor.show_xhair() self.chart.linked.cursor.show_xhair()
pending = self._pending_submissions.pop(uuid, None)
if pending:
order_line, func = pending
func()
else: else:
log.warning( log.warning(
f'Received cancel for unsubmitted order {pformat(msg)}' f'Received cancel for unsubmitted order {pformat(msg)}'
@ -206,8 +238,9 @@ class OrderMode:
size = size or self._size size = size or self._size
chart = self.chart._cursor.active_plot cursor = self.chart.linked.cursor
y = chart._cursor._datum_xy[1] chart = cursor.active_plot
y = cursor._datum_xy[1]
symbol = self.chart._lc._symbol symbol = self.chart._lc._symbol
@ -238,17 +271,70 @@ class OrderMode:
) )
line.oid = uid line.oid = uid
# enter submission which will be popped once a response
# from the EMS is received to move the order to a different# status
self._pending_submissions[uid] = (
line,
self.status_bar.open_status(
f'submitting {self._exec_mode}-{action}',
final_msg=f'submitted {self._exec_mode}-{action}',
clear_on_next=True,
)
)
# hook up mouse drag handlers # hook up mouse drag handlers
line._on_drag_start = self.order_line_modify_start line._on_drag_start = self.order_line_modify_start
line._on_drag_end = self.order_line_modify_complete line._on_drag_end = self.order_line_modify_complete
return line return line
def cancel_order_under_cursor(self) -> None: def cancel_orders_under_cursor(self) -> list[str]:
for line in self.lines.lines_under_cursor(): return self.cancel_orders_from_lines(
self.book.cancel(uuid=line.oid) self.lines.lines_under_cursor()
)
def cancel_all_orders(self) -> list[str]:
'''Cancel all orders for the current chart.
'''
return self.cancel_orders_from_lines(
self.lines.all_lines()
)
def cancel_orders_from_lines(
self,
lines: list[LevelLine],
) -> list[str]:
ids: list = []
if lines:
key = self.status_bar.open_status(
f'cancelling {len(lines)} orders',
final_msg=f'cancelled {len(lines)} orders',
group_key=True
)
# cancel all active orders and triggers
for line in lines:
oid = getattr(line, 'oid', None)
if oid:
self._pending_submissions[oid] = (
line,
self.status_bar.open_status(
f'cancelling order {oid[:6]}',
group_key=key,
),
)
ids.append(oid)
self.book.cancel(uuid=oid)
return ids
# order-line modify handlers # order-line modify handlers
def order_line_modify_start( def order_line_modify_start(
self, self,
line: LevelLine, line: LevelLine,
@ -274,13 +360,14 @@ async def open_order_mode(
chart: pg.PlotWidget, chart: pg.PlotWidget,
book: OrderBook, book: OrderBook,
): ):
status_bar: MultiStatus = main_window().status_bar
view = chart._vb view = chart._vb
lines = LineEditor(view=view, chart=chart, _order_lines=_order_lines) lines = LineEditor(chart=chart)
arrows = ArrowEditor(chart, {}) arrows = ArrowEditor(chart, {})
log.info("Opening order mode") log.info("Opening order mode")
mode = OrderMode(chart, book, lines, arrows) mode = OrderMode(chart, book, lines, arrows, status_bar)
view.mode = mode view.mode = mode
asset_type = symbol.type_key asset_type = symbol.type_key
@ -306,10 +393,13 @@ async def open_order_mode(
async def start_order_mode( async def start_order_mode(
chart: 'ChartPlotWidget', # noqa chart: 'ChartPlotWidget', # noqa
symbol: Symbol, symbol: Symbol,
brokername: str, brokername: str,
started: trio.Event,
) -> None: ) -> None:
'''Activate chart-trader order mode loop: '''Activate chart-trader order mode loop:
- connect to emsd - connect to emsd
@ -317,12 +407,16 @@ async def start_order_mode(
- begin order handling loop - begin order handling loop
''' '''
done = chart.window().status_bar.open_status('Starting order mode...') done = chart.window().status_bar.open_status('starting order mode..')
# spawn EMS actor-service # spawn EMS actor-service
async with ( async with (
open_ems(brokername, symbol) as (book, trades_stream, positions), open_ems(brokername, symbol) as (book, trades_stream, positions),
open_order_mode(symbol, chart, book) as order_mode open_order_mode(symbol, chart, book) as order_mode,
# # start async input handling for chart's view
# # await godwidget._task_stack.enter_async_context(
# chart._vb.open_async_input_handler(),
): ):
# update any exising positions # update any exising positions
@ -345,83 +439,90 @@ async def start_order_mode(
# Begin order-response streaming # Begin order-response streaming
done() done()
# this is where we receive **back** messages # start async input handling for chart's view
# about executions **from** the EMS actor async with chart._vb.open_async_input_handler():
async for msg in trades_stream:
fmsg = pformat(msg) # signal to top level symbol loading task we're ready
log.info(f'Received order msg:\n{fmsg}') # to handle input since the ems connection is ready
started.set()
name = msg['name'] # this is where we receive **back** messages
if name in ( # about executions **from** the EMS actor
'position', async for msg in trades_stream:
):
# show line label once order is live
order_mode.on_position_update(msg)
continue
resp = msg['resp'] fmsg = pformat(msg)
oid = msg['oid'] log.info(f'Received order msg:\n{fmsg}')
# response to 'action' request (buy/sell) name = msg['name']
if resp in ( if name in (
'dark_submitted', 'position',
'broker_submitted' ):
): # show line label once order is live
order_mode.on_position_update(msg)
# show line label once order is live
order_mode.on_submit(oid)
# resp to 'cancel' request or error condition
# for action request
elif resp in (
'broker_cancelled',
'broker_inactive',
'dark_cancelled'
):
# delete level line from view
order_mode.on_cancel(oid)
elif resp in (
'dark_triggered'
):
log.info(f'Dark order triggered for {fmsg}')
elif resp in (
'alert_triggered'
):
# should only be one "fill" for an alert
# add a triangle and remove the level line
order_mode.on_fill(
oid,
price=msg['trigger_price'],
arrow_index=get_index(time.time())
)
await order_mode.on_exec(oid, msg)
# response to completed 'action' request for buy/sell
elif resp in (
'broker_executed',
):
await order_mode.on_exec(oid, msg)
# each clearing tick is responded individually
elif resp in ('broker_filled',):
known_order = book._sent_orders.get(oid)
if not known_order:
log.warning(f'order {oid} is unknown')
continue continue
action = known_order.action resp = msg['resp']
details = msg['brokerd_msg'] oid = msg['oid']
# TODO: some kinda progress system # response to 'action' request (buy/sell)
order_mode.on_fill( if resp in (
oid, 'dark_submitted',
price=details['price'], 'broker_submitted'
pointing='up' if action == 'buy' else 'down', ):
# TODO: put the actual exchange timestamp # show line label once order is live
arrow_index=get_index(details['broker_time']), order_mode.on_submit(oid)
)
# resp to 'cancel' request or error condition
# for action request
elif resp in (
'broker_cancelled',
'broker_inactive',
'dark_cancelled'
):
# delete level line from view
order_mode.on_cancel(oid)
elif resp in (
'dark_triggered'
):
log.info(f'Dark order triggered for {fmsg}')
elif resp in (
'alert_triggered'
):
# should only be one "fill" for an alert
# add a triangle and remove the level line
order_mode.on_fill(
oid,
price=msg['trigger_price'],
arrow_index=get_index(time.time())
)
await order_mode.on_exec(oid, msg)
# response to completed 'action' request for buy/sell
elif resp in (
'broker_executed',
):
await order_mode.on_exec(oid, msg)
# each clearing tick is responded individually
elif resp in ('broker_filled',):
known_order = book._sent_orders.get(oid)
if not known_order:
log.warning(f'order {oid} is unknown')
continue
action = known_order.action
details = msg['brokerd_msg']
# TODO: some kinda progress system
order_mode.on_fill(
oid,
price=details['price'],
pointing='up' if action == 'buy' else 'down',
# TODO: put the actual exchange timestamp
arrow_index=get_index(details['broker_time']),
)