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:
# setup local ui event streaming channels for request/resp
# streamging with EMS daemon
_orders = OrderBook(*trio.open_memory_channel(1))
_orders = OrderBook(
*trio.open_memory_channel(100),
)
return _orders

View File

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

View File

@ -38,7 +38,7 @@ class Axis(pg.AxisItem):
"""
def __init__(
self,
linked_charts,
linkedsplits,
typical_max_str: str = '100 000.000',
min_tick: int = 2,
**kwargs
@ -49,7 +49,7 @@ class Axis(pg.AxisItem):
# XXX: pretty sure this makes things slower
# self.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache)
self.linked_charts = linked_charts
self.linkedsplits = linkedsplits
self._min_tick = min_tick
self._dpi_font = _font
@ -132,9 +132,9 @@ class DynamicDateAxis(Axis):
) -> List[str]:
# try:
chart = self.linked_charts.chart
bars = chart._ohlc
shm = self.linked_charts.chart._shm
chart = self.linkedsplits.chart
bars = chart._arrays['ohlc']
shm = self.linkedsplits.chart._shm
first = shm._first.value
bars_len = len(bars)
@ -232,7 +232,6 @@ class AxisLabel(pg.GraphicsObject):
p.setPen(self.fg_color)
p.drawText(self.rect, self.text_flags, self.label_str)
def draw(
self,
p: QtGui.QPainter,
@ -250,9 +249,9 @@ class AxisLabel(pg.GraphicsObject):
# reason; ok by us
p.setOpacity(self.opacity)
# this cause the L1 labels to glitch out if used
# in the subtype and it will leave a small black strip
# with the arrow path if done before the above
# this cause the L1 labels to glitch out if used in the subtype
# and it will leave a small black strip with the arrow path if
# done before the above
p.fillRect(self.rect, self.bg_color)
@ -295,8 +294,8 @@ class AxisLabel(pg.GraphicsObject):
self.rect = QtCore.QRectF(
0, 0,
(w or txt_w) + self._x_margin /2,
(h or txt_h) + self._y_margin /2,
(w or txt_w) + self._x_margin / 2,
(h or txt_h) + self._y_margin / 2,
)
# print(self.rect)
# 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 typing import Callable
from PyQt5 import QtCore, QtGui
from PyQt5 import QtCore
from PyQt5.QtCore import QEvent
from PyQt5.QtGui import QWidget
import trio
class EventCloner(QtCore.QObject):
"""Clone and forward keyboard events over a trio memory channel
for later async processing.
class EventRelay(QtCore.QObject):
'''
Relay Qt events over a trio memory channel for async processing.
"""
'''
_event_types: set[QEvent] = set()
_send_chan: trio.abc.SendChannel = None
_filter_auto_repeats: bool = True
def eventFilter(
self,
source: QtGui.QWidget,
source: QWidget,
ev: QEvent,
) -> 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?
# if ev.isAutoRepeat():
@ -51,41 +65,77 @@ class EventCloner(QtCore.QObject):
# something to do with Qt internals and calling the
# parent handler?
key = ev.key()
mods = ev.modifiers()
txt = ev.text()
if etype in {QEvent.KeyPress, QEvent.KeyRelease}:
# run async processing
self._send_chan.send_nowait((ev, key, mods, txt))
# TODO: is there a global setting for this?
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
@asynccontextmanager
async def open_key_stream(
async def open_event_stream(
source_widget: QtGui.QWidget,
source_widget: QWidget,
event_types: set[QEvent] = {QEvent.KeyPress},
# TODO: should we offer some kinda option for toggling releases?
# would it require a channel per event type?
# QEvent.KeyRelease,
filter_auto_repeats: bool = True,
) -> trio.abc.ReceiveChannel:
# 1 to force eager sending
send, recv = trio.open_memory_channel(16)
kc = EventCloner()
kc = EventRelay()
kc._send_chan = send
kc._event_types = event_types
kc._filter_auto_repeats = filter_auto_repeats
source_widget.installEventFilter(kc)
try:
yield recv
async with send:
yield recv
finally:
await send.aclose()
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
"""
import math
from typing import Optional, Tuple, Set, Dict
from functools import partial
from typing import Optional, Callable
import inspect
import numpy as np
@ -30,7 +30,6 @@ from PyQt5.QtCore import QPointF, QRectF
from .._style import (
_xaxis_at,
hcolor,
_font,
_font_small,
)
from .._axes import YAxisLabel, XAxisLabel
@ -98,7 +97,7 @@ class LineDot(pg.CurvePoint):
(x, y) = self.curve().getData()
index = self.property('index')
# first = self._plot._ohlc[0]['index']
# first = self._plot._arrays['ohlc'][0]['index']
# first = x[0]
# i = index - first
i = index - x[0]
@ -133,11 +132,15 @@ class ContentsLabel(pg.LabelItem):
}
def __init__(
self,
chart: 'ChartPlotWidget', # noqa
# chart: 'ChartPlotWidget', # noqa
view: pg.ViewBox,
anchor_at: str = ('top', 'right'),
justify_text: str = 'left',
font_size: Optional[int] = None,
) -> None:
font_size = font_size or _font_small.px_size
@ -148,9 +151,10 @@ class ContentsLabel(pg.LabelItem):
)
# anchor to viewbox
self.setParentItem(chart._vb)
chart.scene().addItem(self)
self.chart = chart
self.setParentItem(view)
self.vb = view
view.scene().addItem(self)
v, h = anchor_at
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)
def update_from_ohlc(
self,
name: str,
index: int,
array: np.ndarray,
) -> None:
# this being "html" is the dumbest shit :eyeroll:
first = array[0]['index']
@ -188,25 +194,111 @@ class ContentsLabel(pg.LabelItem):
)
def update_from_value(
self,
name: str,
index: int,
array: np.ndarray,
) -> None:
first = array[0]['index']
if index < array[-1]['index'] and index > first:
data = array[index - first][name]
self.setText(f"{name}: {data:.2f}")
class 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):
def __init__(
self,
linkedsplitcharts: 'LinkedSplitCharts', # noqa
linkedsplits: 'LinkedSplits', # noqa
digits: int = 0
) -> None:
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?
# It's not like we can change them on the fly..?
self.pen = pg.mkPen(
@ -217,19 +309,10 @@ class Cursor(pg.GraphicsObject):
color=hcolor('davies'),
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
# 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
self._lw = self.pixelWidth() * self.lines_pen.width()
@ -239,6 +322,26 @@ class Cursor(pg.GraphicsObject):
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(
self,
item: pg.GraphicsObject,
@ -320,7 +423,7 @@ class Cursor(pg.GraphicsObject):
# the current sample under the mouse
cursor = LineDot(
curve,
index=plot._ohlc[-1]['index'],
index=plot._arrays['ohlc'][-1]['index'],
plot=plot
)
plot.addItem(cursor)
@ -344,7 +447,7 @@ class Cursor(pg.GraphicsObject):
def mouseMoved(
self,
evt: 'Tuple[QMouseEvent]', # noqa
evt: 'tuple[QMouseEvent]', # noqa
) -> None: # noqa
"""Update horizonal and vertical lines when mouse moves inside
either the main chart or any indicator subplot.
@ -392,10 +495,16 @@ class Cursor(pg.GraphicsObject):
item.on_tracked_source(ix, iy)
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():
# 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"
opts['vl'].setX(ix + line_offset)

View File

@ -259,10 +259,10 @@ class LevelLine(pg.InfiniteLine):
detailed control and start end signalling.
"""
chart = self._chart
cursor = self._chart.linked.cursor
# hide y-crosshair
chart._cursor.hide_xhair()
cursor.hide_xhair()
# highlight
self.currentPen = self.hoverPen
@ -308,7 +308,7 @@ class LevelLine(pg.InfiniteLine):
# This is the final position in the drag
if ev.isFinish():
# show y-crosshair again
chart._cursor.show_xhair()
cursor.show_xhair()
def delete(self) -> None:
"""Remove this line from containing chart/view/scene.
@ -326,7 +326,7 @@ class LevelLine(pg.InfiniteLine):
# remove from chart/cursor states
chart = self._chart
cur = chart._cursor
cur = chart.linked.cursor
if self in cur._hovered:
cur._hovered.remove(self)
@ -457,8 +457,7 @@ class LevelLine(pg.InfiniteLine):
"""Mouse hover callback.
"""
chart = self._chart
cur = chart._cursor
cur = self._chart.linked.cursor
# hovered
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
# instead of the old way that was doing the same but by
# resetting the graphics item transform intermittently
# XXX: this is our new approach but seems slower?
# line.add_marker(mk_marker(marker_style, marker_size))
assert not line.markers
# the old way which is still somehow faster?
@ -659,7 +661,10 @@ def order_line(
marker_size,
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))
orient_v = 'top' if action == 'sell' else 'bottom'
@ -754,9 +759,29 @@ def position_line(
ymn, ymx = vr[1]
level = line.value()
if level > ymx or level < ymn:
line._marker.hide()
if gt := level > ymx or (lt := level < ymn):
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:
# pp line is viewable so show marker
line._marker.show()
vb.sigYRangeChanged.connect(update_pp_nav)
@ -787,6 +812,7 @@ def position_line(
style = '>|'
arrow_path = mk_marker(style, size=arrow_size)
# XXX: uses new marker drawing approach
line.add_marker(arrow_path)
line.set_level(level)

View File

@ -18,447 +18,209 @@
Chart view box primitives
"""
from dataclasses import dataclass, field
from typing import Optional, Dict
from contextlib import asynccontextmanager
import time
from typing import Optional, Callable
import pyqtgraph as pg
from PyQt5.QtCore import QPointF
from pyqtgraph import ViewBox, Point, QtCore, QtGui
from PyQt5.QtCore import Qt, QEvent
from pyqtgraph import ViewBox, Point, QtCore
from pyqtgraph import functions as fn
import numpy as np
import trio
from ..log import get_logger
from ._style import _min_points_to_show, hcolor, _font
from ._graphics._lines import order_line, LevelLine
from ._style import _min_points_to_show
from ._editors import SelectRect
from ._window import main_window
log = get_logger(__name__)
class SelectRect(QtGui.QGraphicsRectItem):
async def handle_viewmode_inputs(
def __init__(
self,
viewbox: ViewBox,
color: str = 'dad_blue',
) -> None:
super().__init__(0, 0, 1, 1)
view: 'ChartView',
recv_chan: trio.abc.ReceiveChannel,
# self.rbScaleBox = QtGui.QGraphicsRectItem(0, 0, 1, 1)
self.vb = viewbox
self._chart: 'ChartPlotWidget' = None # noqa
) -> None:
# 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
mode = view.mode
label = self._label = QtGui.QLabel()
label.setTextFormat(0) # markdown
label.setFont(_font.font)
label.setMargin(0)
label.setAlignment(
QtCore.Qt.AlignLeft
# | QtCore.Qt.AlignVCenter
)
# track edge triggered keys
# (https://en.wikipedia.org/wiki/Interrupt#Triggering_methods)
pressed: set[str] = set()
# proxy is created after containing scene is initialized
self._label_proxy = None
self._abs_top_right = None
last = time.time()
trigger_mode: str
action: str
# 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}',
]
# 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': mode.cancel_all_orders,
}
@property
def chart(self) -> 'ChartPlotWidget': # noqa
return self._chart
async for event, etype, key, mods, text in recv_chan:
log.debug(f'key: {key}, mods: {mods}, text: {text}')
now = time.time()
period = now - last
@chart.setter
def chart(self, chart: 'ChartPlotWidget') -> None: # noqa
self._chart = chart
chart.sigRangeChanged.connect(self.update_on_resize)
palette = self._label.palette()
# reset mods
ctrl: bool = False
shift: bool = False
# TODO: get bg color working
palette.setColor(
self._label.backgroundRole(),
# QtGui.QColor(chart.backgroundBrush()),
QtGui.QColor(hcolor('papas_special')),
)
# press branch
if etype in {QEvent.KeyPress}:
def update_on_resize(self, vr, r):
"""Re-position measure label on view range change.
pressed.add(key)
"""
if self._abs_top_right:
self._label_proxy.setPos(
self.vb.mapFromView(self._abs_top_right)
)
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
def mouse_drag_released(
self,
p1: QPointF,
p2: QPointF
) -> None:
"""Called on final button release for mouse drag with start and
end positions.
# don't support more then 2 key sequences for now
len(fast_key_seq) > 2
):
fast_key_seq.clear()
"""
self.set_pos(p1, p2)
# 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}')
def set_pos(
self,
p1: QPointF,
p2: QPointF
) -> None:
"""Set position of selection rect and accompanying label, move
label to match.
# mods run through
if mods == Qt.ShiftModifier:
shift = True
"""
if self._label_proxy is None:
# https://doc.qt.io/qt-5/qgraphicsproxywidget.html
self._label_proxy = self.vb.scene().addWidget(self._label)
if mods == Qt.ControlModifier:
ctrl = True
start_pos = self.vb.mapToView(p1)
end_pos = self.vb.mapToView(p2)
# SEARCH MODE #
# 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
r = QtCore.QRectF(start_pos, end_pos)
# 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()
# old way; don't need right?
# lr = QtCore.QRectF(p1, p2)
# r = self.vb.childGroup.mapRectFromParent(lr)
# cancel order or clear graphics
if key == Qt.Key_C or key == Qt.Key_Delete:
self.setPos(r.topLeft())
self.resetTransform()
self.scale(r.width(), r.height())
self.show()
mode.cancel_orders_under_cursor()
y1, y2 = start_pos.y(), end_pos.y()
x1, x2 = start_pos.x(), end_pos.x()
# View modes
if key == Qt.Key_R:
# 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)
# edge triggered default view activation
view.chart.default_view()
pchng = (y2 - y1) / y1 * 100
rng = abs(y1 - y2)
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()
ixmn, ixmx = round(xmn), round(xmx)
nbars = ixmx - ixmn + 1
# release branch
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:
dmn = dmx = std = np.nan
view.linkedsplits.cursor.in_query_mode = False
# update label info
self._label.setText('\n'.join(self._contents).format(
pchng=pchng, rng=rng, nbars=nbars,
std=std, dmx=dmx, dmn=dmn,
))
# SELECTION MODE #
# 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()
# 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
if shift:
if view.state['mouseMode'] == ViewBox.PanMode:
view.setMouseMode(ViewBox.RectMode)
else:
assert line.oid == uuid
line.show_labels()
view.setMouseMode(ViewBox.PanMode)
# 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):
"""Get the line(s) under the cursor position.
else:
trigger_mode: str = 'dark'
"""
# Delete any hoverable under the cursor
return self.chart._cursor._hovered
# order mode trigger "actions"
if Qt.Key_D in pressed: # for "damp eet"
action = 'sell'
def remove_line(
self,
line: LevelLine = None,
uuid: str = None,
) -> LevelLine:
"""Remove a line by refernce or uuid.
elif Qt.Key_F in pressed: # for "fillz eet"
action = 'buy'
If no lines or ids are provided remove all lines under the
cursor position.
elif Qt.Key_A in pressed:
action = 'alert'
trigger_mode = 'live'
"""
if line:
uuid = line.oid
view.order_mode = True
# try to look up line from our registry
line = self._order_lines.pop(uuid, None)
if line:
# XXX: order matters here for line style!
view.mode._exec_mode = trigger_mode
view.mode.set_exec(action)
# if hovered remove from cursor set
hovered = self.chart._cursor._hovered
if line in hovered:
hovered.remove(line)
prefix = trigger_mode + '-' if action != 'alert' else ''
view._chart.window().mode_label.setText(
f'mode: {prefix}{action}')
# make sure the xhair doesn't get left off
# just because we never got a un-hover event
self.chart._cursor.show_xhair()
else: # none active
# if none are pressed, remove "staged" level
# line under cursor position
view.mode.lines.unstage_line()
line.delete()
return line
if view.hasFocus():
# update mode label
view._chart.window().mode_label.setText('mode: view')
view.order_mode = False
@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)
last = time.time()
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:
- 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 right-click-n-drag to cursor position
"""
'''
mode_name: str = 'mode: view'
def __init__(
self,
name: str,
parent: pg.PlotItem = None,
**kwargs,
):
super().__init__(parent=parent, **kwargs)
# disable vertical scrolling
self.setMouseEnabled(x=True, y=False)
self.linked_charts = None
self.select_box = SelectRect(self)
self.addItem(self.select_box, ignoreBounds=True)
self.linkedsplits = None
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._key_buffer = []
self._key_active: bool = False
self.name = name
self.mode = None
self.order_mode: bool = False
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
def chart(self) -> 'ChartPlotWidget': # type: ignore # noqa
return self._chart
@ -501,21 +280,21 @@ class ChartView(ViewBox):
self.select_box.chart = chart
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
the center of the zoom to be the y-axis.
TODO: PR a method into ``pyqtgraph`` to make this configurable
"""
'''
if axis in (0, 1):
mask = [False, False]
mask[axis] = self.state['mouseEnabled'][axis]
else:
mask = self.state['mouseEnabled'][:]
chart = self.linked_charts.chart
chart = self.linkedsplits.chart
# don't zoom more then the min points setting
l, lbar, rbar, r = chart.bars_range()
@ -525,7 +304,7 @@ class ChartView(ViewBox):
log.debug("Max zoom bruh...")
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...")
return
@ -573,7 +352,6 @@ class ChartView(ViewBox):
end_of_l1,
key=lambda p: p.x()
)
# breakpoint()
# focal = pg.Point(last_bar.x() + end_of_l1)
self._resetTarget()
@ -693,131 +471,16 @@ class ChartView(ViewBox):
elif button == QtCore.Qt.LeftButton:
# when in order mode, submit execution
if self._key_active:
if self.order_mode:
ev.accept()
self.mode.submit_exec()
def keyReleaseEvent(self, ev: QtCore.QEvent):
"""
Key release to normally to trigger release of input mode
def keyReleaseEvent(self, event: QtCore.QEvent) -> None:
'''This routine is rerouted to an async handler.
'''
pass
"""
# 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()
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
def keyPressEvent(self, event: QtCore.QEvent) -> None:
'''This routine is rerouted to an async handler.
'''
pass

View File

@ -89,11 +89,16 @@ def right_axis(
class Label:
"""
A plain ol' "scene label" using an underlying ``QGraphicsTextItem``.
After hacking for many days on multiple "label" systems inside
``pyqtgraph`` yet again we're left writing our own since it seems
all of those are over complicated, ad-hoc, pieces of garbage that
can't accomplish the simplest things, such as pinning to the left
hand side of a view box.
all of those are over complicated, ad-hoc, transform-mangling,
messes which can't accomplish the simplest things via their inputs
(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
small, re-usable label components that can actually be used to build
@ -104,6 +109,7 @@ class Label:
self,
view: pg.ViewBox,
fmt_str: str,
color: str = 'bracket',
x_offset: float = 0,

View File

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

View File

@ -21,7 +21,8 @@ Qt main window singletons and stuff.
import os
import signal
import time
from typing import Callable
from typing import Callable, Optional, Union
import uuid
from pyqtgraph import QtGui
from PyQt5 import QtCore
@ -42,25 +43,101 @@ class MultiStatus:
def __init__(self, bar, statuses) -> None:
self.bar = bar
self.statuses = statuses
self._to_clear: set = set()
self._status_groups: dict[str, (set, Callable)] = {}
def open_status(
self,
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
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)
def remove_msg() -> None:
self.statuses.remove(msg)
try:
self.statuses.remove(msg)
except ValueError:
pass
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()
return remove_msg
if clear_on_next:
self._to_clear.add(msg)
return ret
def render(self) -> None:
'''Display all open statuses to bar.
'''
if self.statuses:
self.bar.showMessage(f'{" ".join(self.statuses)}')
else:

View File

@ -26,11 +26,12 @@ from typing import Optional, Dict, Callable, Any
import uuid
import pyqtgraph as pg
import trio
from pydantic import BaseModel
import trio
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 ..data._source import Symbol
from ..log import get_logger
@ -48,18 +49,31 @@ class Position(BaseModel):
@dataclass
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"
(when wathing the rt price update at the current time step)
and allows entering orders using the ``a, d, f`` keys and
cancelling moused-over orders with the ``c`` key.
and allows entering orders using mouse and keyboard.
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
book: OrderBook
lines: LineEditor
arrows: ArrowEditor
status_bar: MultiStatus
name: str = 'order'
_colors = {
'alert': 'alert_yellow',
'buy': 'buy_green',
@ -71,7 +85,8 @@ class OrderMode:
_position: Dict[str, Any] = field(default_factory=dict)
_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(
self,
@ -108,12 +123,18 @@ class OrderMode:
"""Set execution mode.
"""
# not initialized yet
if not self.chart.linked.cursor:
return
self._action = action
self.lines.stage_line(
color=self._colors[action],
# 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,
action=action,
)
@ -127,6 +148,13 @@ class OrderMode:
"""
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
def on_fill(
@ -182,8 +210,12 @@ class OrderMode:
if msg is not None:
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:
log.warning(
f'Received cancel for unsubmitted order {pformat(msg)}'
@ -206,8 +238,9 @@ class OrderMode:
size = size or self._size
chart = self.chart._cursor.active_plot
y = chart._cursor._datum_xy[1]
cursor = self.chart.linked.cursor
chart = cursor.active_plot
y = cursor._datum_xy[1]
symbol = self.chart._lc._symbol
@ -238,17 +271,70 @@ class OrderMode:
)
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
line._on_drag_start = self.order_line_modify_start
line._on_drag_end = self.order_line_modify_complete
return line
def cancel_order_under_cursor(self) -> None:
for line in self.lines.lines_under_cursor():
self.book.cancel(uuid=line.oid)
def cancel_orders_under_cursor(self) -> list[str]:
return self.cancel_orders_from_lines(
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
def order_line_modify_start(
self,
line: LevelLine,
@ -274,13 +360,14 @@ async def open_order_mode(
chart: pg.PlotWidget,
book: OrderBook,
):
status_bar: MultiStatus = main_window().status_bar
view = chart._vb
lines = LineEditor(view=view, chart=chart, _order_lines=_order_lines)
lines = LineEditor(chart=chart)
arrows = ArrowEditor(chart, {})
log.info("Opening order mode")
mode = OrderMode(chart, book, lines, arrows)
mode = OrderMode(chart, book, lines, arrows, status_bar)
view.mode = mode
asset_type = symbol.type_key
@ -306,10 +393,13 @@ async def open_order_mode(
async def start_order_mode(
chart: 'ChartPlotWidget', # noqa
symbol: Symbol,
brokername: str,
started: trio.Event,
) -> None:
'''Activate chart-trader order mode loop:
- connect to emsd
@ -317,12 +407,16 @@ async def start_order_mode(
- 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
async with (
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
@ -345,83 +439,90 @@ async def start_order_mode(
# Begin order-response streaming
done()
# this is where we receive **back** messages
# about executions **from** the EMS actor
async for msg in trades_stream:
# start async input handling for chart's view
async with chart._vb.open_async_input_handler():
fmsg = pformat(msg)
log.info(f'Received order msg:\n{fmsg}')
# signal to top level symbol loading task we're ready
# to handle input since the ems connection is ready
started.set()
name = msg['name']
if name in (
'position',
):
# show line label once order is live
order_mode.on_position_update(msg)
continue
# this is where we receive **back** messages
# about executions **from** the EMS actor
async for msg in trades_stream:
resp = msg['resp']
oid = msg['oid']
fmsg = pformat(msg)
log.info(f'Received order msg:\n{fmsg}')
# response to 'action' request (buy/sell)
if resp in (
'dark_submitted',
'broker_submitted'
):
# 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')
name = msg['name']
if name in (
'position',
):
# show line label once order is live
order_mode.on_position_update(msg)
continue
action = known_order.action
details = msg['brokerd_msg']
resp = msg['resp']
oid = msg['oid']
# TODO: some kinda progress system
order_mode.on_fill(
oid,
price=details['price'],
pointing='up' if action == 'buy' else 'down',
# response to 'action' request (buy/sell)
if resp in (
'dark_submitted',
'broker_submitted'
):
# TODO: put the actual exchange timestamp
arrow_index=get_index(details['broker_time']),
)
# 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
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']),
)