commit
8a6142632d
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
@ -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()
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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']),
|
||||||
|
)
|
||||||
|
|
Loading…
Reference in New Issue