From bd8521401791a3030cf866f14ce8ea81ed00dbb8 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 1 Jan 2021 12:37:58 -0500 Subject: [PATCH 01/29] Add draft order actor architecture --- piker/ui/_chart.py | 83 +++++++++++++++++++++++++++++++++++++++++++--- piker/ui/cli.py | 1 + 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 6cc8ecb1..7af09d38 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -25,6 +25,7 @@ import numpy as np import pyqtgraph as pg import tractor import trio +from trio_typing import TaskStatus from ._axes import ( DynamicDateAxis, @@ -407,10 +408,6 @@ class ChartPlotWidget(pg.PlotWidget): self.default_view() - # TODO: stick in config - # use cross-hair for cursor? - # self.setCursor(QtCore.Qt.CrossCursor) - # Assign callback for rescaling y-axis automatically # based on data contents and ``ViewBox`` state. self.sigXRangeChanged.connect(self._set_yrange) @@ -821,6 +818,73 @@ class ChartPlotWidget(pg.PlotWidget): self.scene().leaveEvent(ev) +_to_router: trio.abc.SendChannel = None +_from_ui: trio.abc.ReceiveChannel = None + + +# TODO: make this a ``tractor.msg.pub`` +async def stream_orders(): + """Order streaming task: deliver orders transmitted from UI + to downstream consumers. + + This is run in the UI actor (usually the one running Qt). + The UI simply delivers order messages to the above ``_to_router`` + send channel (from sync code using ``.send_nowait()``), these values + are pulled from the channel here and send to any consumer(s). + + """ + global _from_ui + + async for order in _from_ui: + yield order + + +async def stream_and_route(ui_name): + """Order router actor entrypoint. + + """ + actor = tractor.current_actor() + + # new router entry point + async with tractor.wait_for_actor(ui_name) as portal: + async for order in await portal.run(stream_orders): + print(f'order {order} received in {actor.uid}') + + # push order back to parent as an "alert" + # (mocking for eg. a "fill") + yield order + + +async def spawn_router_stream_alerts( + ident: str, + task_status: TaskStatus[str] = trio.TASK_STATUS_IGNORED, +) -> None: + + # setup local ui event streaming channels + global _from_ui, _to_router + _to_router, _from_ui = trio.open_memory_channel(100) + + actor = tractor.current_actor() + subactor_name = ident + '.router' + + async with tractor.open_nursery() as n: + + portal = await n.start_actor( + subactor_name, + rpc_module_paths=[__name__], + ) + stream = await portal.run( + stream_and_route, + ui_name=actor.name + ) + + # let parent task continue + task_status.started(subactor_name) + + async for alert in stream: + print(f'alert {alert} received in {actor.uid}') + + async def _async_main( sym: str, brokername: str, @@ -906,6 +970,17 @@ async def _async_main( async with trio.open_nursery() as n: + router_name = await n.start( + spawn_router_stream_alerts, + sym, + ) + + # wait for router to come up before setting + # enabling send channel on chart + async with tractor.wait_for_actor(router_name): + global _to_router + linked_charts._to_router = _to_router + # load initial fsp chain (otherwise known as "indicators") n.start_soon( spawn_fsps, diff --git a/piker/ui/cli.py b/piker/ui/cli.py index e14ef3f6..0adeaf5a 100644 --- a/piker/ui/cli.py +++ b/piker/ui/cli.py @@ -150,5 +150,6 @@ def chart(config, symbol, date, rate, test, profile): tractor_kwargs={ 'debug_mode': True, 'loglevel': tractorloglevel, + 'rpc_module_paths': ['piker.ui._chart'], }, ) From d602cbc9858e1210367396ebee9934a41cb8b4cc Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 1 Jan 2021 12:59:31 -0500 Subject: [PATCH 02/29] Avoid active_plot race --- piker/ui/_graphics/_cursor.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/piker/ui/_graphics/_cursor.py b/piker/ui/_graphics/_cursor.py index 83f0ee96..d5d4575a 100644 --- a/piker/ui/_graphics/_cursor.py +++ b/piker/ui/_graphics/_cursor.py @@ -31,6 +31,10 @@ from .._style import ( _font, ) from .._axes import YAxisLabel, XAxisLabel +from ...log import get_logger + + +log = get_logger(__name__) # XXX: these settings seem to result in really decent mouse scroll # latency (in terms of perceived lag in cross hair) so really be sure @@ -295,6 +299,7 @@ class CrossHair(pg.GraphicsObject): return cursor def mouseAction(self, action, plot): # noqa + log.debug(f"{(action, plot.name)}") if action == 'Enter': self.active_plot = plot @@ -303,7 +308,6 @@ class CrossHair(pg.GraphicsObject): self.graphics[plot]['yl'].show() else: # Leave - self.active_plot = None # hide horiz line and y-label self.graphics[plot]['hl'].hide() From 7a53f19eeb6f4de6904d3df2b53ab8e2d826f8fb Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 1 Jan 2021 13:05:16 -0500 Subject: [PATCH 03/29] Highlight level line label on hover --- piker/ui/_graphics/_lines.py | 42 +++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/piker/ui/_graphics/_lines.py b/piker/ui/_graphics/_lines.py index bd5b9de6..61d2fd41 100644 --- a/piker/ui/_graphics/_lines.py +++ b/piker/ui/_graphics/_lines.py @@ -49,6 +49,8 @@ class LevelLabel(YSticky): ) -> None: super().__init__(chart, *args, **kwargs) + self._pen = self.line_pen + # orientation around axis options self._orient_v = orient_v self._orient_h = orient_h @@ -101,7 +103,7 @@ class LevelLabel(YSticky): p: QtGui.QPainter, rect: QtCore.QRectF ) -> None: - p.setPen(self.line_pen) + p.setPen(self._pen) if self._orient_v == 'bottom': lp, rp = rect.topLeft(), rect.topRight() @@ -111,6 +113,15 @@ class LevelLabel(YSticky): p.drawLine(lp.x(), lp.y(), rp.x(), rp.y()) + def highlight(self, pen) -> None: + self._pen = pen + self.update() + + def unhighlight(self): + self._pen = self.line_pen + self.update() + + class L1Label(LevelLabel): @@ -190,9 +201,38 @@ class LevelLine(pg.InfiniteLine): super().__init__(**kwargs) self.sigPositionChanged.connect(self.set_level) + # use slightly thicker highlight + self.setHoverPen( + color=(255, 0, 0), + width=self.pen.width() + 1 + ) + def set_level(self, value: float) -> None: self.label.update_from_data(0, self.value()) + def setMouseHover(self, hover: bool) -> None: + """Mouse hover callback. + + """ + if self.mouseHovering == hover: + return + self.mouseHovering = hover + + if hover: + + self.currentPen = self.hoverPen + self.label.highlight(self.hoverPen) + + else: + self.currentPen = self.pen + self.label.unhighlight() + + # highlight any attached label + + # self.setCursor(QtCore.Qt.OpenHandCursor) + # self.setCursor(QtCore.Qt.DragMoveCursor) + self.update() + def level_line( chart: 'ChartPlogWidget', # noqa From e474d8c30903bf8c2645117c95d65726a017ef3c Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 1 Jan 2021 13:05:50 -0500 Subject: [PATCH 04/29] Remove horizontal crosshair on "a" hotkey --- piker/ui/_interaction.py | 56 +++++++++++++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index f501238f..249acf49 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -215,12 +215,14 @@ class ChartView(ViewBox): self.addItem(self.select_box, ignoreBounds=True) self._chart: 'ChartPlotWidget' = None # noqa + self._keys_on = {} + @property - def chart(self) -> 'ChartPlotWidget': # noqa + def chart(self) -> 'ChartPlotWidget': # type: ignore # noqa return self._chart @chart.setter - def chart(self, chart: 'ChartPlotWidget') -> None: # noqa + def chart(self, chart: 'ChartPlotWidget') -> None: # type: ignore # noqa self._chart = chart self.select_box.chart = chart @@ -305,7 +307,7 @@ class ChartView(ViewBox): if axis == 1: # set a static y range special value on chart widget to # prevent sizing to data in view. - self._chart._static_yrange = 'axis' + self.chart._static_yrange = 'axis' scale_y = 1.3 ** (dif.y() * -1 / 20) self.setLimits(yMin=None, yMax=None) @@ -374,24 +376,46 @@ class ChartView(ViewBox): def keyReleaseEvent(self, ev): # print(f'release: {ev.text().encode()}') + if ev.isAutoRepeat(): + ev.ignore() + return + ev.accept() - if ev.key() == QtCore.Qt.Key_Shift: + 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) + if text == 'a': + + # how y line + chart = self.chart._cursor.active_plot + hl = chart._cursor.graphics[chart]['hl'] + hl.show() + def keyPressEvent(self, ev): """ This routine should capture key presses in the current view box. """ # print(ev.text().encode()) - ev.accept() + if ev.isAutoRepeat(): + ev.ignore() + return - if ev.modifiers() == QtCore.Qt.ShiftModifier: + ev.accept() + text = ev.text() + key = ev.key() + mods = ev.modifiers() + + if mods == QtCore.Qt.ShiftModifier: if self.state['mouseMode'] == ViewBox.PanMode: self.setMouseMode(ViewBox.RectMode) # ctl - if ev.modifiers() == QtCore.Qt.ControlModifier: + if mods == QtCore.Qt.ControlModifier: # print("CTRL") # TODO: ctrl-c as cancel? # https://forum.qt.io/topic/532/how-to-catch-ctrl-c-on-a-widget/9 @@ -400,17 +424,29 @@ class ChartView(ViewBox): pass # alt - if ev.modifiers() == QtCore.Qt.AltModifier: + if mods == QtCore.Qt.AltModifier: pass # print("ALT") # esc - if ev.key() == QtCore.Qt.Key_Escape: + if key == QtCore.Qt.Key_Escape: self.select_box.clear() - if ev.text() == 'r': + if text == 'r': self.chart.default_view() + if text == 'a': + self._keys_on['a'] = True + + # hide y line + chart = self.chart._cursor.active_plot + print(f'on chart: {chart.name}') + chart._cursor.graphics[chart]['hl'].hide() + + # XXX: should make this an explicit attr + # it's assigned inside ``.add_plot()`` + self.linked_charts._to_router.send_nowait('yo') + # Leaving this for light reference purposes # Key presses are used only when mouse mode is RectMode From 80d48e5ece20fa1c8b42c8405ee6964d35dbd9e0 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 1 Jan 2021 13:23:05 -0500 Subject: [PATCH 05/29] Dynamically override .boundingRect() after startup --- piker/ui/_graphics/_curve.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/piker/ui/_graphics/_curve.py b/piker/ui/_graphics/_curve.py index 7bf39cea..a9b24e7f 100644 --- a/piker/ui/_graphics/_curve.py +++ b/piker/ui/_graphics/_curve.py @@ -123,6 +123,18 @@ class FastAppendCurve(pg.PlotCurveItem): self.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache) def boundingRect(self): + if self.path is None: + return QtGui.QPainterPath().boundingRect() + else: + # dynamically override this method after initial + # path is created to avoid requiring the above None check + self.boundingRect = self._br + return self._br() + + def _br(self): + """Post init ``.boundingRect()```. + + """ hb = self.path.controlPointRect() hb_size = hb.size() # print(f'hb_size: {hb_size}') From a55d72f8d6ef7f3c033a2f07cf236e13d2ab7de2 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 1 Jan 2021 17:48:22 -0500 Subject: [PATCH 06/29] Create and "EMS" module for order execution/routing actor(s) --- piker/_ems.py | 91 ++++++++++++++++++++++++++++++++++++++++++++++ piker/ui/_chart.py | 90 ++++++--------------------------------------- piker/ui/cli.py | 2 +- 3 files changed, 104 insertions(+), 79 deletions(-) create mode 100644 piker/_ems.py diff --git a/piker/_ems.py b/piker/_ems.py new file mode 100644 index 00000000..f3650f49 --- /dev/null +++ b/piker/_ems.py @@ -0,0 +1,91 @@ +# piker: trading gear for hackers +# Copyright (C) 2018-present 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 . + +""" +In suit parlance: "Execution management systems" + +""" +import trio +from trio_typing import TaskStatus +import tractor + + +_to_router: trio.abc.SendChannel = None +_from_ui: trio.abc.ReceiveChannel = None + + +# TODO: make this a ``tractor.msg.pub`` +async def stream_orders(): + """Order streaming task: deliver orders transmitted from UI + to downstream consumers. + + This is run in the UI actor (usually the one running Qt). + The UI simply delivers order messages to the above ``_to_router`` + send channel (from sync code using ``.send_nowait()``), these values + are pulled from the channel here and send to any consumer(s). + + """ + global _from_ui + + async for order in _from_ui: + yield order + + +async def stream_and_route(ui_name): + """Order router (sub)actor entrypoint. + + """ + actor = tractor.current_actor() + + # new router entry point + async with tractor.wait_for_actor(ui_name) as portal: + + async for order in await portal.run(stream_orders): + print(f'order {order} received in {actor.uid}') + + # push order back to parent as an "alert" + # (mocking for eg. a "fill") + yield order + + +async def spawn_router_stream_alerts( + ident: str, + task_status: TaskStatus[str] = trio.TASK_STATUS_IGNORED, +) -> None: + + # setup local ui event streaming channels + global _from_ui, _to_router + _to_router, _from_ui = trio.open_memory_channel(100) + + actor = tractor.current_actor() + subactor_name = ident + '.router' + + async with tractor.open_nursery() as n: + + portal = await n.start_actor( + subactor_name, + rpc_module_paths=[__name__], + ) + stream = await portal.run( + stream_and_route, + ui_name=actor.name + ) + async with tractor.wait_for_actor(subactor_name): + # let parent task continue + task_status.started(_to_router) + + async for alert in stream: + print(f'alert {alert} received in {actor.uid}') diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 7af09d38..7d65d6c5 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -16,6 +16,7 @@ """ High level Qt chart widgets. + """ from typing import Tuple, Dict, Any, Optional, Callable from functools import partial @@ -25,7 +26,6 @@ import numpy as np import pyqtgraph as pg import tractor import trio -from trio_typing import TaskStatus from ._axes import ( DynamicDateAxis, @@ -59,6 +59,7 @@ from ..log import get_logger from ._exec import run_qtractor, current_screen from ._interaction import ChartView from .. import fsp +from .._ems import spawn_router_stream_alerts, _to_router log = get_logger(__name__) @@ -818,73 +819,6 @@ class ChartPlotWidget(pg.PlotWidget): self.scene().leaveEvent(ev) -_to_router: trio.abc.SendChannel = None -_from_ui: trio.abc.ReceiveChannel = None - - -# TODO: make this a ``tractor.msg.pub`` -async def stream_orders(): - """Order streaming task: deliver orders transmitted from UI - to downstream consumers. - - This is run in the UI actor (usually the one running Qt). - The UI simply delivers order messages to the above ``_to_router`` - send channel (from sync code using ``.send_nowait()``), these values - are pulled from the channel here and send to any consumer(s). - - """ - global _from_ui - - async for order in _from_ui: - yield order - - -async def stream_and_route(ui_name): - """Order router actor entrypoint. - - """ - actor = tractor.current_actor() - - # new router entry point - async with tractor.wait_for_actor(ui_name) as portal: - async for order in await portal.run(stream_orders): - print(f'order {order} received in {actor.uid}') - - # push order back to parent as an "alert" - # (mocking for eg. a "fill") - yield order - - -async def spawn_router_stream_alerts( - ident: str, - task_status: TaskStatus[str] = trio.TASK_STATUS_IGNORED, -) -> None: - - # setup local ui event streaming channels - global _from_ui, _to_router - _to_router, _from_ui = trio.open_memory_channel(100) - - actor = tractor.current_actor() - subactor_name = ident + '.router' - - async with tractor.open_nursery() as n: - - portal = await n.start_actor( - subactor_name, - rpc_module_paths=[__name__], - ) - stream = await portal.run( - stream_and_route, - ui_name=actor.name - ) - - # let parent task continue - task_status.started(subactor_name) - - async for alert in stream: - print(f'alert {alert} received in {actor.uid}') - - async def _async_main( sym: str, brokername: str, @@ -970,16 +904,6 @@ async def _async_main( async with trio.open_nursery() as n: - router_name = await n.start( - spawn_router_stream_alerts, - sym, - ) - - # wait for router to come up before setting - # enabling send channel on chart - async with tractor.wait_for_actor(router_name): - global _to_router - linked_charts._to_router = _to_router # load initial fsp chain (otherwise known as "indicators") n.start_soon( @@ -1001,8 +925,18 @@ async def _async_main( wap_in_history, ) + router_send_chan = await n.start( + spawn_router_stream_alerts, + sym, + ) + + # wait for router to come up before setting + # enabling send channel on chart + linked_charts._to_router = router_send_chan + # wait for a first quote before we start any update tasks quote = await feed.receive() + log.info(f'Received first quote {quote}') n.start_soon( diff --git a/piker/ui/cli.py b/piker/ui/cli.py index 0adeaf5a..d2050bbc 100644 --- a/piker/ui/cli.py +++ b/piker/ui/cli.py @@ -150,6 +150,6 @@ def chart(config, symbol, date, rate, test, profile): tractor_kwargs={ 'debug_mode': True, 'loglevel': tractorloglevel, - 'rpc_module_paths': ['piker.ui._chart'], + 'rpc_module_paths': ['piker._ems'], }, ) From d492f5c35a7da29d06a80bcb3cf79ecb8b3d1e4b Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 1 Jan 2021 17:49:23 -0500 Subject: [PATCH 07/29] Define our own mouse clicked handler --- piker/ui/_interaction.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 249acf49..3416dd39 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -288,6 +288,7 @@ class ChartView(ViewBox): ) -> None: # if axis is specified, event will only affect that axis. ev.accept() # we accept all buttons + button = ev.button() pos = ev.pos() lastPos = ev.lastPos() @@ -301,7 +302,9 @@ class ChartView(ViewBox): mask[1-axis] = 0.0 # Scale or translate based on mouse button - if ev.button() & (QtCore.Qt.LeftButton | QtCore.Qt.MidButton): + if button & (QtCore.Qt.LeftButton | QtCore.Qt.MidButton): + + # print(f'left click drag pos {pos}') # zoom only y-axis when click-n-drag on it if axis == 1: @@ -340,6 +343,8 @@ class ChartView(ViewBox): # update shape of scale box # self.updateScaleBox(ev.buttonDownPos(), ev.pos()) else: + # default bevavior: click to pan view + tr = self.childGroup.transform() tr = fn.invertQTransform(tr) tr = tr.map(dif*mask) - tr.map(Point(0, 0)) @@ -348,13 +353,16 @@ class ChartView(ViewBox): y = tr.y() if mask[1] == 1 else None self._resetTarget() + if x is not None or y is not None: self.translateBy(x=x, y=y) + self.sigRangeChangedManually.emit(self.state['mouseEnabled']) - elif ev.button() & QtCore.Qt.RightButton: + elif button & QtCore.Qt.RightButton: + + # right click zoom to center behaviour - # print "vb.rightDrag" if self.state['aspectLocked'] is not False: mask[0] = 0 @@ -374,6 +382,20 @@ class ChartView(ViewBox): self.scaleBy(x=x, y=y, center=center) self.sigRangeChangedManually.emit(self.state['mouseEnabled']) + def mouseClickEvent(self, ev): + """Full-click callback. + + """ + button = ev.button() + pos = ev.pos() + + if button == QtCore.Qt.RightButton and self.menuEnabled(): + ev.accept() + self.raiseContextMenu(ev) + + elif button == QtCore.Qt.LeftButton: + print(f'clicked {pos}') + def keyReleaseEvent(self, ev): # print(f'release: {ev.text().encode()}') if ev.isAutoRepeat(): From edbbcbf954dcf5a8edb210aea21c7acb79f7ad73 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 3 Jan 2021 10:39:06 -0500 Subject: [PATCH 08/29] Add alert yellow --- piker/ui/_style.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/piker/ui/_style.py b/piker/ui/_style.py index 9208e13c..f16d009a 100644 --- a/piker/ui/_style.py +++ b/piker/ui/_style.py @@ -166,4 +166,9 @@ def hcolor(name: str) -> str: 'tina_green': '#00cc00', 'tina_red': '#fa0000', + + # orders and alerts + 'alert_yellow': '#e2d083', + 'alert_yellow_light': '#ffef66', + }[name] From 14dad08d98d69810450e32d99e1739039b7d7f14 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 3 Jan 2021 10:43:53 -0500 Subject: [PATCH 09/29] Extend level line interaction Support tracking the mouse cursor using a new `on_tracked_sources()` callback method. Make hovered highlight a bit thicker and highlight when click-dragged. Add a delete method for removing from the scene along with label. --- piker/ui/_graphics/_lines.py | 103 ++++++++++++++++++++++++++++++----- 1 file changed, 90 insertions(+), 13 deletions(-) diff --git a/piker/ui/_graphics/_lines.py b/piker/ui/_graphics/_lines.py index 61d2fd41..ef4c00f1 100644 --- a/piker/ui/_graphics/_lines.py +++ b/piker/ui/_graphics/_lines.py @@ -33,8 +33,6 @@ from .._axes import YSticky class LevelLabel(YSticky): - line_pen = pg.mkPen(hcolor('bracket')) - _w_margin = 4 _h_margin = 3 level: float = 0 @@ -43,13 +41,15 @@ class LevelLabel(YSticky): self, chart, *args, + color: str = 'bracket', orient_v: str = 'bottom', orient_h: str = 'left', **kwargs ) -> None: super().__init__(chart, *args, **kwargs) - self._pen = self.line_pen + # TODO: this is kinda cludgy + self._pen = self.pen = pg.mkPen(hcolor(color)) # orientation around axis options self._orient_v = orient_v @@ -77,7 +77,7 @@ class LevelLabel(YSticky): br = self.boundingRect() h, w = br.height(), br.width() - # this triggers ``.pain()`` implicitly? + # this triggers ``.paint()`` implicitly? self.setPos(QPointF( self._h_shift * w - offset, abs_pos.y() - (self._v_shift * h) - offset @@ -118,11 +118,10 @@ class LevelLabel(YSticky): self.update() def unhighlight(self): - self._pen = self.line_pen + self._pen = self.pen self.update() - class L1Label(LevelLabel): size: float = 0 @@ -192,52 +191,124 @@ class L1Labels: class LevelLine(pg.InfiniteLine): + + # TODO: fill in these slots for orders + # .sigPositionChangeFinished.emit(self) + def __init__( self, + chart: 'ChartPlotWidget', # type: ignore # noqa label: LevelLabel, + highlight_color: str = 'default_light', **kwargs, ) -> None: self.label = label super().__init__(**kwargs) + self.sigPositionChanged.connect(self.set_level) + self._chart = chart + # use slightly thicker highlight - self.setHoverPen( - color=(255, 0, 0), - width=self.pen.width() + 1 - ) + pen = pg.mkPen(hcolor(highlight_color)) + pen.setWidth(2) + self.setHoverPen(pen) + self._track_cursor: bool = False def set_level(self, value: float) -> None: self.label.update_from_data(0, self.value()) + def on_tracked_source( + self, + x: int, + y: float + ) -> None: + self.movable = True + self.setPos(y) # implictly calls ``.set_level()`` + self.update() + def setMouseHover(self, hover: bool) -> None: """Mouse hover callback. """ if self.mouseHovering == hover: return + self.mouseHovering = hover + chart = self._chart + if hover: self.currentPen = self.hoverPen self.label.highlight(self.hoverPen) + # add us to cursor state + chart._cursor.add_hovered(self) + + # # hide y-crosshair + # chart._cursor.graphics[chart]['hl'].hide() + else: self.currentPen = self.pen self.label.unhighlight() + chart._cursor._hovered.remove(self) + # highlight any attached label # self.setCursor(QtCore.Qt.OpenHandCursor) # self.setCursor(QtCore.Qt.DragMoveCursor) self.update() + def mouseDragEvent(self, ev): + chart = self._chart + # hide y-crosshair + chart._cursor.graphics[chart]['hl'].hide() + + # highlight + self.currentPen = self.hoverPen + self.label.highlight(self.hoverPen) + + # normal tracking behavior + super().mouseDragEvent(ev) + + # This is the final position in the drag + if ev.isFinish(): + # show y-crosshair again + chart = self._chart + chart._cursor.graphics[chart]['hl'].show() + + def mouseDoubleClickEvent( + self, + ev: QtGui.QMouseEvent, + ) -> None: + print(f'double click {ev}') + + # def mouseMoved( + # self, + # ev: Tuple[QtGui.QMouseEvent], + # ) -> None: + # pos = evt[0] + # print(pos) + + def delete(self) -> None: + """Remove this line from containing chart/view/scene. + + """ + scene = self.scene() + if scene: + # self.label.parent.scene().removeItem(self.label) + scene.removeItem(self.label) + + self._chart.plotItem.removeItem(self) + def level_line( chart: 'ChartPlogWidget', # noqa level: float, digits: int = 1, + color: str = 'default', # size 4 font on 4k screen scaled down, so small-ish. font_size_inches: float = _down_2_font_inches_we_like, @@ -254,11 +325,13 @@ def level_line( parent=chart.getAxis('right'), # TODO: pass this from symbol data digits=digits, - opacity=1, + opacity=0.666, font_size_inches=font_size_inches, + color=color, + # TODO: make this take the view's bg pen bg_color='papas_special', - fg_color='default', + fg_color=color, **linelabelkwargs ) label.update_from_data(0, level) @@ -267,12 +340,16 @@ def level_line( label._size_br_from_str(label.label_str) line = LevelLine( + chart, label, + # lookup "highlight" equivalent + highlight_color=color + '_light', movable=True, angle=0, ) line.setValue(level) - line.setPen(pg.mkPen(hcolor('default'))) + line.setPen(pg.mkPen(hcolor(color))) + # activate/draw label line.setValue(level) From 08aa5984ecd08fd581da3a7965180bf02c8050df Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 3 Jan 2021 10:46:28 -0500 Subject: [PATCH 10/29] Rename crosshair (type) to cursor Since the "crosshair" is growing more and more UX implementation details it probably makes sense to call it what it is; a python level mouse abstraction. Add 2 internal sets: `_hovered` for allowing mouse hovered objects to register themselves to other cursor aware components, and `_trackers` for allowing scene items to "track" cursor movements via a `on_tracked_source()` callback. --- piker/ui/_graphics/_cursor.py | 39 ++++++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/piker/ui/_graphics/_cursor.py b/piker/ui/_graphics/_cursor.py index d5d4575a..5d312f39 100644 --- a/piker/ui/_graphics/_cursor.py +++ b/piker/ui/_graphics/_cursor.py @@ -17,7 +17,7 @@ Mouse interaction graphics """ -from typing import Optional, Tuple +from typing import Optional, Tuple, Set, Dict import inspect import numpy as np @@ -198,7 +198,7 @@ class ContentsLabel(pg.LabelItem): self.setText(f"{name}: {data:.2f}") -class CrossHair(pg.GraphicsObject): +class Cursor(pg.GraphicsObject): def __init__( self, @@ -217,11 +217,21 @@ class CrossHair(pg.GraphicsObject): style=QtCore.Qt.DashLine, ) self.lsc = linkedsplitcharts - self.graphics = {} - self.plots = [] + self.graphics: Dict[str, pg.GraphicsObject] = {} + self.plots: List['PlotChartWidget'] = [] # type: ignore # noqa self.active_plot = None - self.digits = digits - self._lastx = 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() + + def add_hovered( + self, + item: pg.GraphicsObject, + ) -> None: + assert getattr(item, 'delete'), f"{item} must define a ``.delete()``" + self._hovered.add(item) def add_plot( self, @@ -293,7 +303,11 @@ class CrossHair(pg.GraphicsObject): ) -> LineDot: # if this plot contains curves add line dot "cursors" to denote # the current sample under the mouse - cursor = LineDot(curve, index=plot._ohlc[-1]['index'], plot=plot) + cursor = LineDot( + curve, + index=plot._ohlc[-1]['index'], + plot=plot + ) plot.addItem(cursor) self.graphics[plot].setdefault('cursors', []).append(cursor) return cursor @@ -336,15 +350,21 @@ class CrossHair(pg.GraphicsObject): # update y-range items self.graphics[plot]['hl'].setY(y) + self.graphics[self.active_plot]['yl'].update_label( abs_pos=pos, value=y ) # Update x if cursor changed after discretization calc # (this saves draw cycles on small mouse moves) - lastx = self._lastx + lastx, lasty = self._datum_xy ix = round(x) # since bars are centered around index + # update all trackers + for item in self._trackers: + # print(f'setting {item} with {(ix, y)}') + item.on_tracked_source(ix, y) + if ix != lastx: for plot, opts in self.graphics.items(): @@ -355,7 +375,6 @@ class CrossHair(pg.GraphicsObject): plot.update_contents_labels(ix) # update all subscribed curve dots - # first = plot._ohlc[0]['index'] for cursor in opts.get('cursors', ()): cursor.setIndex(ix) @@ -371,7 +390,7 @@ class CrossHair(pg.GraphicsObject): value=x, ) - self._lastx = ix + self._datum_xy = ix, y def boundingRect(self): try: From 88d48bd188db42978836b1e7c130a6532790cb01 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 3 Jan 2021 11:10:08 -0500 Subject: [PATCH 11/29] Port to `Cursor` --- piker/ui/_chart.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 7d65d6c5..e8670c30 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -32,7 +32,7 @@ from ._axes import ( PriceAxis, ) from ._graphics._cursor import ( - CrossHair, + Cursor, ContentsLabel, ) from ._graphics._lines import ( @@ -186,7 +186,7 @@ class LinkedSplitCharts(QtGui.QWidget): def __init__(self): super().__init__() self.signals_visible: bool = False - self._ch: CrossHair = None # crosshair graphics + self._cursor: Cursor = None # crosshair graphics self.chart: ChartPlotWidget = None # main (ohlc) chart self.subplots: Dict[Tuple[str, ...], ChartPlotWidget] = {} @@ -234,7 +234,7 @@ class LinkedSplitCharts(QtGui.QWidget): self.digits = symbol.digits() # add crosshairs - self._ch = CrossHair( + self._cursor = Cursor( linkedsplitcharts=self, digits=self.digits ) @@ -246,7 +246,7 @@ class LinkedSplitCharts(QtGui.QWidget): _is_main=True, ) # add crosshair graphic - self.chart.addItem(self._ch) + self.chart.addItem(self._cursor) # axis placement if _xaxis_at == 'bottom': @@ -298,7 +298,7 @@ class LinkedSplitCharts(QtGui.QWidget): 'right': PriceAxis(linked_charts=self) }, viewBox=cv, - cursor=self._ch, + cursor=self._cursor, **cpw_kwargs, ) @@ -317,7 +317,7 @@ class LinkedSplitCharts(QtGui.QWidget): cpw.setXLink(self.chart) # add to cross-hair's known plots - self._ch.add_plot(cpw) + self._cursor.add_plot(cpw) # draw curve graphics if style == 'bar': @@ -368,7 +368,7 @@ class ChartPlotWidget(pg.PlotWidget): name: str, array: np.ndarray, static_yrange: Optional[Tuple[float, float]] = None, - cursor: Optional[CrossHair] = None, + cursor: Optional[Cursor] = None, **kwargs, ): """Configure chart display settings. From 97b2f86cfe0bbf0414f72d6f7191cc1c96aeabec Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 3 Jan 2021 11:12:51 -0500 Subject: [PATCH 12/29] Add preliminary support for alert line management --- piker/ui/_interaction.py | 92 ++++++++++++++++++++++++++++++++-------- 1 file changed, 75 insertions(+), 17 deletions(-) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 3416dd39..5b5a429d 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -26,6 +26,7 @@ import numpy as np from ..log import get_logger from ._style import _min_points_to_show, hcolor, _font +from ._graphics._lines import level_line log = get_logger(__name__) @@ -215,7 +216,8 @@ class ChartView(ViewBox): self.addItem(self.select_box, ignoreBounds=True) self._chart: 'ChartPlotWidget' = None # noqa - self._keys_on = {} + self._key_buffer = [] + self._active_staged_line: 'LevelLine' = None # noqa @property def chart(self) -> 'ChartPlotWidget': # type: ignore # noqa @@ -389,15 +391,37 @@ class ChartView(ViewBox): button = ev.button() pos = ev.pos() - if button == QtCore.Qt.RightButton and self.menuEnabled(): + if button == QtCore.Qt.RightButton and self.menuEnabled(): ev.accept() self.raiseContextMenu(ev) elif button == QtCore.Qt.LeftButton: - print(f'clicked {pos}') + + ev.accept() + + line = self._active_staged_line + if line: + chart = self.chart._cursor.active_plot + + y = chart._cursor._datum_xy[1] + + # XXX: should make this an explicit attr + # it's assigned inside ``.add_plot()`` + self.linked_charts._to_router.send_nowait({'alert': y}) + + line = level_line( + chart, + level=y, + color='alert_yellow', + ) + log.info(f'clicked {pos}') def keyReleaseEvent(self, ev): - # print(f'release: {ev.text().encode()}') + """ + Key release to normally to trigger release of input mode + + """ + # TODO: is there a global setting for this? if ev.isAutoRepeat(): ev.ignore() return @@ -405,7 +429,7 @@ class ChartView(ViewBox): ev.accept() text = ev.text() key = ev.key() - mods = ev.modifiers() + # mods = ev.modifiers() if key == QtCore.Qt.Key_Shift: if self.state['mouseMode'] == ViewBox.RectMode: @@ -413,16 +437,29 @@ class ChartView(ViewBox): if text == 'a': - # how y line chart = self.chart._cursor.active_plot - hl = chart._cursor.graphics[chart]['hl'] + chart.setCursor(QtCore.Qt.ArrowCursor) + cursor = chart._cursor + + # delete "staged" cursor tracking line from view + line = self._active_staged_line + cursor._trackers.remove(line) + + if line: + line.delete() + + self._active_staged_line = None + + # show the crosshair y line + hl = cursor.graphics[chart]['hl'] hl.show() def keyPressEvent(self, ev): """ This routine should capture key presses in the current view box. + """ - # print(ev.text().encode()) + # TODO: is there a global setting for this? if ev.isAutoRepeat(): ev.ignore() return @@ -448,26 +485,47 @@ class ChartView(ViewBox): # alt if mods == QtCore.Qt.AltModifier: pass - # print("ALT") # esc if key == QtCore.Qt.Key_Escape: self.select_box.clear() + self._key_buffer.append(text) + + # order modes if text == 'r': self.chart.default_view() - if text == 'a': - self._keys_on['a'] = True + elif text == 'a': - # hide y line chart = self.chart._cursor.active_plot - print(f'on chart: {chart.name}') - chart._cursor.graphics[chart]['hl'].hide() + chart.setCursor(QtCore.Qt.PointingHandCursor) + cursor = chart._cursor - # XXX: should make this an explicit attr - # it's assigned inside ``.add_plot()`` - self.linked_charts._to_router.send_nowait('yo') + # add a "staged" cursor-tracking alert line + + line = level_line( + chart, + level=chart._cursor._datum_xy[1], + color='alert_yellow', + ) + self._active_staged_line = line + + # hide crosshair y-line + cursor.graphics[chart]['hl'].hide() + + # add line to cursor trackers + cursor._trackers.add(line) + + elif text == 'd': + # Delete any hoverable under the cursor + cursor = self.chart._cursor + chart = cursor.active_plot + + for item in cursor._hovered: + # hovered items must also offer + # a ``.delete()`` method + item.delete() # Leaving this for light reference purposes From a3468fb91545dcabebfa108a4596c4c3d2cdd20a Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 3 Jan 2021 17:19:16 -0500 Subject: [PATCH 13/29] Barebones level based alerts are working! --- piker/_ems.py | 110 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 103 insertions(+), 7 deletions(-) diff --git a/piker/_ems.py b/piker/_ems.py index f3650f49..8a7a612b 100644 --- a/piker/_ems.py +++ b/piker/_ems.py @@ -18,10 +18,17 @@ In suit parlance: "Execution management systems" """ +from typing import AsyncIterator + import trio from trio_typing import TaskStatus import tractor +from . import data +from .log import get_logger + + +log = get_logger(__name__) _to_router: trio.abc.SendChannel = None _from_ui: trio.abc.ReceiveChannel = None @@ -44,7 +51,64 @@ async def stream_orders(): yield order -async def stream_and_route(ui_name): +async def exec_orders( + ctx: tractor.Context, + broker: str, + symbol: str, + exec_price: float, +) -> AsyncIterator[dict]: + + async with data.open_feed( + broker, + [symbol], + loglevel='info', + ) as feed: + + # TODO: get initial price + + quote = await feed.receive() + + # we automatically figure out what the alert check condition + # should be based on the current first price received from the + # feed, instead of being like every other shitty tina platform + # that makes the user choose the predicate operator. + last = quote[symbol]['close'] + + if exec_price > last: + + def check(price: float) -> bool: + if price >= exec_price: + return True + else: + return False + + elif exec_price < last: + + def check(price: float) -> bool: + if price <= exec_price: + return True + else: + return False + + async for quotes in feed.stream: + + for sym, quote in quotes.items(): + + for tick in quote.get('ticks', ()): + price = tick.get('price') + + # push trigger msg back to parent as an "alert" + # (mocking for eg. a "fill") + if price and check(price): + await ctx.send_yield({ + 'type': 'alert', + }) + return + # feed teardown + + +@tractor.stream +async def stream_and_route(ctx, ui_name): """Order router (sub)actor entrypoint. """ @@ -53,19 +117,38 @@ async def stream_and_route(ui_name): # new router entry point async with tractor.wait_for_actor(ui_name) as portal: - async for order in await portal.run(stream_orders): - print(f'order {order} received in {actor.uid}') + async with trio.open_nursery() as n: - # push order back to parent as an "alert" - # (mocking for eg. a "fill") - yield order + async for order in await portal.run(stream_orders): + + tp = order['type'] + price = order['price'] + sym = order['symbol'] + brokers = order['brokers'] + + if tp == 'alert': + log.info(f'Alert {order} received in {actor.uid}') + + n.start_soon( + exec_orders, + ctx, + # TODO: eventually support N-brokers + brokers[0], + sym, + price, + ) + + # begin wait on next order async def spawn_router_stream_alerts( ident: str, task_status: TaskStatus[str] = trio.TASK_STATUS_IGNORED, ) -> None: + """Spawn an EMS daemon and begin sending orders and receiving + alerts. + """ # setup local ui event streaming channels global _from_ui, _to_router _to_router, _from_ui = trio.open_memory_channel(100) @@ -88,4 +171,17 @@ async def spawn_router_stream_alerts( task_status.started(_to_router) async for alert in stream: - print(f'alert {alert} received in {actor.uid}') + + # TODO: this in another task? + # not sure if this will ever be a bottleneck, + # we probably could do graphics stuff first tho? + result = await trio.run_process( + [ + 'notify-send', + 'piker', + f'Alert: {alert}', + '-u', 'normal', + '-t', '10000', + ], + ) + log.runtime(result) From 6e2328d3513d965726880985267d3646d832a592 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 3 Jan 2021 17:23:06 -0500 Subject: [PATCH 14/29] More comments? --- piker/ui/_chart.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index e8670c30..2c14d683 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -59,7 +59,7 @@ from ..log import get_logger from ._exec import run_qtractor, current_screen from ._interaction import ChartView from .. import fsp -from .._ems import spawn_router_stream_alerts, _to_router +from .._ems import spawn_router_stream_alerts log = get_logger(__name__) @@ -868,12 +868,13 @@ async def _async_main( add_label=False, ) + # size view to data once at outset chart._set_yrange() # TODO: a data view api that makes this less shit chart._shm = ohlcv - # eventually we'll support some kind of n-compose syntax + # TODO: eventually we'll support some kind of n-compose syntax fsp_conf = { 'rsi': { 'period': 14, @@ -885,7 +886,8 @@ async def _async_main( } # make sure that the instrument supports volume history - # (sometimes this is not the case for some commodities and derivatives) + # (sometimes this is not the case for some commodities and + # derivatives) volm = ohlcv.array['volume'] if ( np.all(np.isin(volm, -1)) or @@ -904,7 +906,6 @@ async def _async_main( async with trio.open_nursery() as n: - # load initial fsp chain (otherwise known as "indicators") n.start_soon( spawn_fsps, @@ -925,6 +926,7 @@ async def _async_main( wap_in_history, ) + # spawn EMS actor-service router_send_chan = await n.start( spawn_router_stream_alerts, sym, From 616a74865bb37e6faa557803e3a4087bf994529f Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 3 Jan 2021 17:23:23 -0500 Subject: [PATCH 15/29] Better highlighted yellow --- piker/ui/_style.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/piker/ui/_style.py b/piker/ui/_style.py index f16d009a..656877cc 100644 --- a/piker/ui/_style.py +++ b/piker/ui/_style.py @@ -169,6 +169,6 @@ def hcolor(name: str) -> str: # orders and alerts 'alert_yellow': '#e2d083', - 'alert_yellow_light': '#ffef66', + 'alert_yellow_light': '#ffe366', }[name] From 7b5a72909e4d00982b1d6cd0f925434baf8525de Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 3 Jan 2021 17:23:45 -0500 Subject: [PATCH 16/29] Start a "real" alert looking msg --- piker/ui/_interaction.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 5b5a429d..61987bdf 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -407,7 +407,12 @@ class ChartView(ViewBox): # XXX: should make this an explicit attr # it's assigned inside ``.add_plot()`` - self.linked_charts._to_router.send_nowait({'alert': y}) + self.linked_charts._to_router.send_nowait({ + 'symbol': chart.name, + 'brokers': ['kraken'], + 'type': 'alert', + 'price': y, + }) line = level_line( chart, From c030b63101e559a37796be5d30a1f520c3dbdefa Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 4 Jan 2021 14:42:35 -0500 Subject: [PATCH 17/29] Rejig state with dataclasses; prep for numba --- piker/_ems.py | 257 +++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 212 insertions(+), 45 deletions(-) diff --git a/piker/_ems.py b/piker/_ems.py index 8a7a612b..8a8b0d25 100644 --- a/piker/_ems.py +++ b/piker/_ems.py @@ -18,7 +18,12 @@ In suit parlance: "Execution management systems" """ -from typing import AsyncIterator +from dataclasses import dataclass, field +from typing import ( + AsyncIterator, List, Dict, Callable, Tuple, + Any, +) +import uuid import trio from trio_typing import TaskStatus @@ -26,6 +31,7 @@ import tractor from . import data from .log import get_logger +from .data._source import Symbol log = get_logger(__name__) @@ -34,8 +40,35 @@ _to_router: trio.abc.SendChannel = None _from_ui: trio.abc.ReceiveChannel = None +_local_book = {} + + +@dataclass +class OrderBook: + """Send (client?) side order book tracking. + + Mostly for keeping local state to match the EMS and use + events to trigger graphics updates. + + """ + orders: Dict[str, dict] = field(default_factory=dict) + _cmds_from_ui: trio.abc.ReceiveChannel = _from_ui + + +_orders: OrderBook = None + + +def get_orders() -> OrderBook: + global _orders + + if _orders is None: + _orders = OrderBook + + return _orders + + # TODO: make this a ``tractor.msg.pub`` -async def stream_orders(): +async def send_order_cmds(): """Order streaming task: deliver orders transmitted from UI to downstream consumers. @@ -48,7 +81,109 @@ async def stream_orders(): global _from_ui async for order in _from_ui: - yield order + + lc = order['chart'] + symbol = lc.symbol + tp = order['type'] + price = order['price'] + + oid = str(uuid.uuid4()) + + cmd = { + 'price': price, + 'action': 'alert', + 'symbol': symbol.key, + 'brokers': symbol.brokers, + 'type': tp, + 'price': price, + 'oid': oid, + } + + _local_book[oid] = cmd + + yield cmd + + +# streaming tasks which check for conditions per symbol per broker +_scan_tasks: Dict[str, List] = {} + +# levels which have an executable action (eg. alert, order, signal) +_levels: Dict[str, list] = {} + +# up to date last values from target streams +_last_values: Dict[str, float] = {} + + +# TODO: numba all of this +def mk_check(trigger_price, known_last) -> Callable[[float, float], bool]: + """Create a predicate for given ``exec_price`` based on last known + price, ``known_last``. + + This is an automatic alert level thunk generator based on where the + current last known value is and where the specified value of + interest is; pick an appropriate comparison operator based on + avoiding the case where the a predicate returns true immediately. + + """ + + if trigger_price >= known_last: + + def check_gt(price: float) -> bool: + if price >= trigger_price: + return True + else: + return False + + return check_gt + + elif trigger_price <= known_last: + + def check_lt(price: float) -> bool: + if price <= trigger_price: + return True + else: + return False + + return check_lt + + +@dataclass +class _ExecBook: + """EMS-side execution book. + + Contains conditions for executions (aka "orders"). + A singleton instance is created per EMS actor. + + """ + orders: Dict[ + Tuple[str, str], + Tuple[ + # predicates + Callable[[float], bool], + + # actions + Callable[[float], Dict[str, Any]], + + ] + ] = field(default_factory=dict) + + # most recent values + lasts: Dict[ + Tuple[str, str], + float + ] = field(default_factory=dict) + + +_book = None + + +def get_book() -> _ExecBook: + global _book + + if _book is None: + _book = _ExecBook() + + return _book async def exec_orders( @@ -56,6 +191,7 @@ async def exec_orders( broker: str, symbol: str, exec_price: float, + task_status: TaskStatus[dict] = trio.TASK_STATUS_IGNORED, ) -> AsyncIterator[dict]: async with data.open_feed( @@ -66,45 +202,50 @@ async def exec_orders( # TODO: get initial price - quote = await feed.receive() + first_quote = await feed.receive() - # we automatically figure out what the alert check condition - # should be based on the current first price received from the - # feed, instead of being like every other shitty tina platform - # that makes the user choose the predicate operator. - last = quote[symbol]['close'] + book = get_book() + book.lasts[(broker, symbol)] = first_quote[symbol]['last'] - if exec_price > last: + task_status.started(first_quote) - def check(price: float) -> bool: - if price >= exec_price: - return True - else: - return False + # shield this field so the remote brokerd does not get cancelled + stream = feed.stream - elif exec_price < last: + with stream.shield(): + async for quotes in stream: - def check(price: float) -> bool: - if price <= exec_price: - return True - else: - return False + for sym, quote in quotes.items(): - async for quotes in feed.stream: + execs = book.orders.get((broker, sym)) - for sym, quote in quotes.items(): + for tick in quote.get('ticks', ()): + price = tick.get('price') + if price < 0: + # lel, fuck you ib + continue - for tick in quote.get('ticks', ()): - price = tick.get('price') + # update to keep new cmds informed + book.lasts[(broker, symbol)] = price - # push trigger msg back to parent as an "alert" - # (mocking for eg. a "fill") - if price and check(price): - await ctx.send_yield({ - 'type': 'alert', - }) - return - # feed teardown + # begin price actions sequence + + if not execs: + continue + + for pred, action in tuple(execs): + # push trigger msg back to parent as an "alert" + # (mocking for eg. a "fill") + if pred(price): + res = action(price) + await ctx.send_yield({ + 'type': 'alert', + 'price': price, + }) + execs.remove((pred, action)) + print(f"GOT ALERT FOR {exec_price} @ \n{tick}") + + # feed teardown @tractor.stream @@ -113,36 +254,59 @@ async def stream_and_route(ctx, ui_name): """ actor = tractor.current_actor() + book = get_book() # new router entry point async with tractor.wait_for_actor(ui_name) as portal: + # spawn one task per broker feed async with trio.open_nursery() as n: - async for order in await portal.run(stream_orders): + async for cmd in await portal.run(send_order_cmds): - tp = order['type'] - price = order['price'] - sym = order['symbol'] - brokers = order['brokers'] + tp = cmd.pop('type') + trigger_price = cmd['price'] + sym = cmd['symbol'] + brokers = cmd['brokers'] if tp == 'alert': - log.info(f'Alert {order} received in {actor.uid}') + log.info(f'Alert {cmd} received in {actor.uid}') - n.start_soon( + broker = brokers[0] + last = book.lasts.get((broker, sym)) + + if last is None: # spawn new brokerd feed task + + quote = await n.start( exec_orders, ctx, # TODO: eventually support N-brokers - brokers[0], + broker, sym, - price, + trigger_price, ) + print(f"received first quote {quote}") - # begin wait on next order + last = book.lasts[(broker, sym)] + print(f'Known last is {last}') + + # Auto-gen scanner predicate: + # we automatically figure out what the alert check condition + # should be based on the current first price received from the + # feed, instead of being like every other shitty tina platform + # that makes the user choose the predicate operator. + pred = mk_check(trigger_price, last) + + # create list of executions on first entry + book.orders.setdefault((broker, sym), []).append( + (pred, lambda p: p) + ) + + # continue and wait on next order cmd async def spawn_router_stream_alerts( - ident: str, + symbol: Symbol, task_status: TaskStatus[str] = trio.TASK_STATUS_IGNORED, ) -> None: """Spawn an EMS daemon and begin sending orders and receiving @@ -154,7 +318,7 @@ async def spawn_router_stream_alerts( _to_router, _from_ui = trio.open_memory_channel(100) actor = tractor.current_actor() - subactor_name = ident + '.router' + subactor_name = 'piker.ems' async with tractor.open_nursery() as n: @@ -166,6 +330,7 @@ async def spawn_router_stream_alerts( stream_and_route, ui_name=actor.name ) + async with tractor.wait_for_actor(subactor_name): # let parent task continue task_status.started(_to_router) @@ -175,6 +340,8 @@ async def spawn_router_stream_alerts( # TODO: this in another task? # not sure if this will ever be a bottleneck, # we probably could do graphics stuff first tho? + + # XXX: linux only for now result = await trio.run_process( [ 'notify-send', From c7ff0804db2358b59273f4d25cb801239c17d1f7 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 4 Jan 2021 14:43:59 -0500 Subject: [PATCH 18/29] Graph snap quote immediately on ib spin up --- piker/brokers/ib.py | 134 +++++++++++++++++++++++++++++--------------- 1 file changed, 89 insertions(+), 45 deletions(-) diff --git a/piker/brokers/ib.py b/piker/brokers/ib.py index 12f713ae..d0645dfe 100644 --- a/piker/brokers/ib.py +++ b/piker/brokers/ib.py @@ -49,9 +49,11 @@ from ..data import ( attach_shm_array, # get_shm_token, subscribe_ohlc_for_increment, + _buffer, ) from ..data._source import from_df from ._util import SymbolNotFound +from .._async_utils import maybe_with_if log = get_logger(__name__) @@ -355,11 +357,12 @@ class Client: symbol: str, to_trio, opts: Tuple[int] = ('375', '233',), + contract: Optional[Contract] = None, # opts: Tuple[int] = ('459',), ) -> None: """Stream a ticker using the std L1 api. """ - contract = await self.find_contract(symbol) + contract = contract or (await self.find_contract(symbol)) ticker: Ticker = self.ib.reqMktData(contract, ','.join(opts)) # define a simple queue push routine that streams quote packets @@ -386,6 +389,20 @@ class Client: # let the engine run and stream await self.ib.disconnectedEvent + async def get_quote( + self, + symbol: str, + ) -> Ticker: + """Return a single quote for symbol. + + """ + contract = await self.find_contract(symbol) + ticker: Ticker = self.ib.reqMktData( + contract, + snapshot=True, + ) + return contract, (await ticker.updateEvent) + # default config ports _tws_port: int = 7497 @@ -604,16 +621,21 @@ _local_buffer_writers = {} @asynccontextmanager async def activate_writer(key: str) -> (bool, trio.Nursery): + """Mark the current actor with module var determining + whether an existing shm writer task is already active. + + This avoids more then one writer resulting in data + clobbering. + """ + global _local_buffer_writers + try: - writer_already_exists = _local_buffer_writers.get(key, False) + assert not _local_buffer_writers.get(key, False) - if not writer_already_exists: - _local_buffer_writers[key] = True + _local_buffer_writers[key] = True - async with trio.open_nursery() as n: - yield writer_already_exists, n - else: - yield writer_already_exists, None + async with trio.open_nursery() as n: + yield n finally: _local_buffer_writers.pop(key, None) @@ -622,7 +644,7 @@ async def fill_bars( sym: str, first_bars: list, shm: 'ShmArray', # type: ignore # noqa - # count: int = 20, # NOTE: any more and we'll overrun the underlying buffer + # count: int = 20, # NOTE: any more and we'll overrun underlying buffer count: int = 2, # NOTE: any more and we'll overrun the underlying buffer ) -> None: """Fill historical bars into shared mem / storage afap. @@ -692,8 +714,14 @@ async def stream_quotes( # TODO: support multiple subscriptions sym = symbols[0] + contract, first_ticker = await _trio_run_client_method( + method='get_quote', + symbol=sym, + ) + stream = await _trio_run_client_method( method='stream_ticker', + contract=contract, # small speedup symbol=sym, ) @@ -701,14 +729,17 @@ async def stream_quotes( # check if a writer already is alive in a streaming task, # otherwise start one and mark it as now existing - async with activate_writer( - shm_token['shm_name'] - ) as (writer_already_exists, ln): - # maybe load historical ohlcv in to shared mem - # check if shm has already been created by previous - # feed initialization + key = shm_token['shm_name'] + + writer_already_exists = _local_buffer_writers.get(key, False) + + # maybe load historical ohlcv in to shared mem + # check if shm has already been created by previous + # feed initialization + async with trio.open_nursery() as ln: if not writer_already_exists: + _local_buffer_writers[key] = True shm = attach_shm_array( token=shm_token, @@ -744,12 +775,33 @@ async def stream_quotes( subscribe_ohlc_for_increment(shm, delay_s) # pass back token, and bool, signalling if we're the writer + # and that history has been written await ctx.send_yield((shm_token, not writer_already_exists)) - # first quote can be ignored as a 2nd with newer data is sent? - first_ticker = await stream.__anext__() + # check for special contract types + if type(first_ticker.contract) not in (ibis.Commodity, ibis.Forex): + suffix = 'exchange' + # should be real volume for this contract + calc_price = False + else: + # commodities and forex don't have an exchange name and + # no real volume so we have to calculate the price + suffix = 'secType' + calc_price = True + ticker = first_ticker - quote = normalize(first_ticker) + # pass first quote asap + quote = normalize(first_ticker, calc_price=calc_price) + con = quote['contract'] + topic = '.'.join((con['symbol'], con[suffix])).lower() + quote['symbol'] = topic + + first_quote = {topic: quote} + + # yield first quote asap + await ctx.send_yield(first_quote) + + # ticker.ticks = [] # ugh, clear ticks since we've consumed them # (ahem, ib_insync is stateful trash) @@ -762,39 +814,31 @@ async def stream_quotes( calc_price = False # should be real volume for contract - async for ticker in stream: + # wait for real volume on feed (trading might be closed) + async for ticker in stream: + + # for a real volume contract we rait for the first + # "real" trade to take place + if not calc_price and not ticker.rtTime: # spin consuming tickers until we get a real market datum - if not ticker.rtTime: - log.debug(f"New unsent ticker: {ticker}") - continue - else: - log.debug("Received first real volume tick") - # ugh, clear ticks since we've consumed them - # (ahem, ib_insync is truly stateful trash) - ticker.ticks = [] + log.debug(f"New unsent ticker: {ticker}") + continue + else: + log.debug("Received first real volume tick") + # ugh, clear ticks since we've consumed them + # (ahem, ib_insync is truly stateful trash) + ticker.ticks = [] - # XXX: this works because we don't use - # ``aclosing()`` above? - break - else: - # commodities don't have an exchange name for some reason? - suffix = 'secType' - calc_price = True - ticker = first_ticker + # tell incrementer task it can start + _buffer.shm_incrementing(key).set() - quote = normalize(ticker, calc_price=calc_price) - con = quote['contract'] - topic = '.'.join((con['symbol'], con[suffix])).lower() - quote['symbol'] = topic - - first_quote = {topic: quote} - ticker.ticks = [] - - # yield first quote asap - await ctx.send_yield(first_quote) + # XXX: this works because we don't use + # ``aclosing()`` above? + break # real-time stream async for ticker in stream: + # print(ticker.vwap) quote = normalize( ticker, From 9478adf600bda290773329919d4647d386a71de3 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 4 Jan 2021 14:44:39 -0500 Subject: [PATCH 19/29] Use event sync for increment task launch --- piker/data/_buffer.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/piker/data/_buffer.py b/piker/data/_buffer.py index fed6b965..9a496e7a 100644 --- a/piker/data/_buffer.py +++ b/piker/data/_buffer.py @@ -27,6 +27,12 @@ from ._sharedmem import ShmArray _shms: Dict[int, ShmArray] = {} +_start_increment: Dict[str, trio.Event] = {} + + +def shm_incrementing(shm_token_name: str) -> trio.Event: + global _start_increment + return _start_increment.setdefault(shm_token_name, trio.Event()) @tractor.msg.pub @@ -47,6 +53,10 @@ async def increment_ohlc_buffer( Note that if **no** actor has initiated this task then **none** of the underlying buffers will actually be incremented. """ + + # wait for brokerd to signal we should start sampling + await shm_incrementing(shm_token['shm_name']).wait() + # TODO: right now we'll spin printing bars if the last time stamp is # before a large period of no market activity. Likely the best way # to solve this is to make this task aware of the instrument's From 267c8c6bd35325cdcb23d7093b97f182022cb054 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 4 Jan 2021 14:45:12 -0500 Subject: [PATCH 20/29] Go back to manual label contents check for now --- piker/ui/_axes.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/piker/ui/_axes.py b/piker/ui/_axes.py index 08d2e1b5..f9893347 100644 --- a/piker/ui/_axes.py +++ b/piker/ui/_axes.py @@ -218,13 +218,13 @@ class AxisLabel(pg.GraphicsObject): p.drawRect(self.rect) def boundingRect(self): # noqa - # if self.label_str: - # self._size_br_from_str(self.label_str) - # return self.rect + if self.label_str: + self._size_br_from_str(self.label_str) + return self.rect - # return QtCore.QRectF() + return QtCore.QRectF() - return self.rect or QtCore.QRectF() + # return self.rect or QtCore.QRectF() def _size_br_from_str(self, value: str) -> None: """Do our best to render the bounding rect to a set margin From 3c424a153fac8d504bc3b8228d5538c391e0b5d7 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 4 Jan 2021 14:45:34 -0500 Subject: [PATCH 21/29] Port to new `Portal.run()` api --- piker/data/__init__.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/piker/data/__init__.py b/piker/data/__init__.py index fa26801c..579e596f 100644 --- a/piker/data/__init__.py +++ b/piker/data/__init__.py @@ -75,10 +75,12 @@ def get_ingestormod(name: str) -> ModuleType: return module +# capable rpc modules _data_mods = [ 'piker.brokers.core', 'piker.brokers.data', 'piker.data', + 'piker.data._buffer', ] @@ -104,10 +106,13 @@ async def maybe_spawn_brokerd( brokermod = get_brokermod(brokername) dname = f'brokerd.{brokername}' async with tractor.find_actor(dname) as portal: + # WTF: why doesn't this work? if portal is not None: yield portal - else: + + else: # no daemon has been spawned yet + log.info(f"Spawning {brokername} broker daemon") tractor_kwargs = getattr(brokermod, '_spawn_kwargs', {}) async with tractor.open_nursery() as nursery: @@ -115,7 +120,7 @@ async def maybe_spawn_brokerd( # spawn new daemon portal = await nursery.start_actor( dname, - rpc_module_paths=_data_mods + [brokermod.__name__], + enable_modules=_data_mods + [brokermod.__name__], loglevel=loglevel, **tractor_kwargs ) @@ -140,7 +145,7 @@ class Feed: stream: AsyncIterator[Dict[str, Any]] shm: ShmArray # ticks: ShmArray - _broker_portal: tractor._portal.Portal + _brokerd_portal: tractor._portal.Portal _index_stream: Optional[AsyncIterator[Dict[str, Any]]] = None async def receive(self) -> dict: @@ -151,9 +156,8 @@ class Feed: # XXX: this should be singleton on a host, # a lone broker-daemon per provider should be # created for all practical purposes - self._index_stream = await self._broker_portal.run( - 'piker.data', - 'increment_ohlc_buffer', + self._index_stream = await self._brokerd_portal.run( + increment_ohlc_buffer, shm_token=self.shm.token, topics=['index'], ) @@ -200,8 +204,7 @@ async def open_feed( loglevel=loglevel, ) as portal: stream = await portal.run( - mod.__name__, - 'stream_quotes', + mod.stream_quotes, symbols=symbols, shm_token=shm.token, @@ -225,5 +228,5 @@ async def open_feed( name=name, stream=stream, shm=shm, - _broker_portal=portal, + _brokerd_portal=portal, ) From 4d6b1d4bb1cb60a58754204e02cecb634afd96f0 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 4 Jan 2021 14:45:59 -0500 Subject: [PATCH 22/29] Add brokers list field to symbol type --- piker/data/_source.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/piker/data/_source.py b/piker/data/_source.py index 26180443..a77839ff 100644 --- a/piker/data/_source.py +++ b/piker/data/_source.py @@ -17,6 +17,7 @@ """ numpy data source coversion helpers. """ +from typing import List import decimal from dataclasses import dataclass @@ -81,6 +82,7 @@ class Symbol: """ key: str = '' + brokers: List[str] = None min_tick: float = 0.01 contract: str = '' From d0a3deae09c47e627bdf846f99477a1b95294b1d Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 4 Jan 2021 14:46:47 -0500 Subject: [PATCH 23/29] Pass symbol type further down chart stack --- piker/ui/_chart.py | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 2c14d683..77ab710a 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -125,10 +125,9 @@ class ChartSpace(QtGui.QWidget): # def init_strategy_ui(self): # self.strategy_box = StrategyBoxWidget(self) # self.toolbar_layout.addWidget(self.strategy_box) - def load_symbol( self, - symbol: str, + symbol: Symbol, data: np.ndarray, ohlc: bool = True, ) -> None: @@ -148,16 +147,15 @@ class ChartSpace(QtGui.QWidget): # self.symbol_label.setText(f'/`{symbol}`') linkedcharts = self._chart_cache.setdefault( - symbol, - LinkedSplitCharts() + symbol.key, + LinkedSplitCharts(symbol) ) - s = Symbol(key=symbol) # remove any existing plots if not self.v_layout.isEmpty(): self.v_layout.removeWidget(linkedcharts) - main_chart = linkedcharts.plot_ohlc_main(s, data) + main_chart = linkedcharts.plot_ohlc_main(symbol, data) self.v_layout.addWidget(linkedcharts) @@ -183,7 +181,10 @@ class LinkedSplitCharts(QtGui.QWidget): zoomIsDisabled = QtCore.pyqtSignal(bool) - def __init__(self): + def __init__( + self, + symbol: Symbol, + ) -> None: super().__init__() self.signals_visible: bool = False self._cursor: Cursor = None # crosshair graphics @@ -209,6 +210,13 @@ class LinkedSplitCharts(QtGui.QWidget): self.layout.setContentsMargins(0, 0, 0, 0) self.layout.addWidget(self.splitter) + # state tracker? + self._symbol: Symbol = symbol + + @property + def symbol(self) -> Symbol: + return self._symbol + def set_split_sizes( self, prop: float = 0.28 # proportion allocated to consumer subcharts @@ -381,7 +389,6 @@ class ChartPlotWidget(pg.PlotWidget): useOpenGL=True, **kwargs ) - self.name = name # self.setViewportMargins(0, 0, 0, 0) @@ -842,6 +849,8 @@ async def _async_main( # historical data fetch brokermod = brokers.get_brokermod(brokername) + symbol = Symbol(sym, [brokername]) + async with data.open_feed( brokername, [sym], @@ -852,8 +861,7 @@ async def _async_main( bars = ohlcv.array # load in symbol's ohlc data - # await tractor.breakpoint() - linked_charts, chart = chart_app.load_symbol(sym, bars) + linked_charts, chart = chart_app.load_symbol(symbol, bars) # plot historical vwap if available wap_in_history = False @@ -929,7 +937,7 @@ async def _async_main( # spawn EMS actor-service router_send_chan = await n.start( spawn_router_stream_alerts, - sym, + symbol, ) # wait for router to come up before setting From 24536ad76998b0c00fece0f77f6c651249fd10c9 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 5 Jan 2021 08:02:14 -0500 Subject: [PATCH 24/29] Get roundtrip alert uuids workin; stage order book api --- piker/_ems.py | 83 +++++++++++++++++++++++++++++++++------- piker/ui/_chart.py | 1 + piker/ui/_interaction.py | 40 +++++++++++++++---- 3 files changed, 103 insertions(+), 21 deletions(-) diff --git a/piker/_ems.py b/piker/_ems.py index 8a8b0d25..52b55d97 100644 --- a/piker/_ems.py +++ b/piker/_ems.py @@ -23,8 +23,9 @@ from typing import ( AsyncIterator, List, Dict, Callable, Tuple, Any, ) -import uuid +# import uuid +import pyqtgraph as pg import trio from trio_typing import TaskStatus import tractor @@ -32,12 +33,14 @@ import tractor from . import data from .log import get_logger from .data._source import Symbol +from .ui._style import hcolor log = get_logger(__name__) _to_router: trio.abc.SendChannel = None _from_ui: trio.abc.ReceiveChannel = None +_lines = {} _local_book = {} @@ -54,6 +57,21 @@ class OrderBook: orders: Dict[str, dict] = field(default_factory=dict) _cmds_from_ui: trio.abc.ReceiveChannel = _from_ui + async def alert(self, price: float) -> str: + ... + + async def buy(self, price: float) -> str: + ... + + async def sell(self, price: float) -> str: + ... + + async def modify(self, oid: str, price) -> bool: + ... + + async def cancel(self, oid: str) -> bool: + ... + _orders: OrderBook = None @@ -86,8 +104,11 @@ async def send_order_cmds(): symbol = lc.symbol tp = order['type'] price = order['price'] + oid = order['oid'] - oid = str(uuid.uuid4()) + print(f'oid: {oid}') + # TODO + # oid = str(uuid.uuid4()) cmd = { 'price': price, @@ -134,7 +155,7 @@ def mk_check(trigger_price, known_last) -> Callable[[float, float], bool]: else: return False - return check_gt + return check_gt, 'gt' elif trigger_price <= known_last: @@ -144,7 +165,7 @@ def mk_check(trigger_price, known_last) -> Callable[[float, float], bool]: else: return False - return check_lt + return check_lt, 'lt' @dataclass @@ -233,17 +254,23 @@ async def exec_orders( if not execs: continue - for pred, action in tuple(execs): + for oid, pred, action in tuple(execs): # push trigger msg back to parent as an "alert" # (mocking for eg. a "fill") if pred(price): - res = action(price) + name = action(price) await ctx.send_yield({ 'type': 'alert', 'price': price, + # current shm array index + 'index': feed.shm._last.value - 1, + 'name': name, + 'oid': oid, }) - execs.remove((pred, action)) - print(f"GOT ALERT FOR {exec_price} @ \n{tick}") + execs.remove((oid, pred, action)) + print( + f"GOT ALERT FOR {exec_price} @ \n{tick}\n") + print(f'execs are {execs}') # feed teardown @@ -264,10 +291,17 @@ async def stream_and_route(ctx, ui_name): async for cmd in await portal.run(send_order_cmds): + action = cmd.pop('action') + + if action == 'cancel': + pass + tp = cmd.pop('type') + trigger_price = cmd['price'] sym = cmd['symbol'] brokers = cmd['brokers'] + oid = cmd['oid'] if tp == 'alert': log.info(f'Alert {cmd} received in {actor.uid}') @@ -295,18 +329,20 @@ async def stream_and_route(ctx, ui_name): # should be based on the current first price received from the # feed, instead of being like every other shitty tina platform # that makes the user choose the predicate operator. - pred = mk_check(trigger_price, last) + pred, name = mk_check(trigger_price, last) # create list of executions on first entry book.orders.setdefault((broker, sym), []).append( - (pred, lambda p: p) + (oid, pred, lambda p: name) ) # continue and wait on next order cmd async def spawn_router_stream_alerts( + chart, symbol: Symbol, + # lines: 'LinesEditor', task_status: TaskStatus[str] = trio.TASK_STATUS_IGNORED, ) -> None: """Spawn an EMS daemon and begin sending orders and receiving @@ -314,7 +350,7 @@ async def spawn_router_stream_alerts( """ # setup local ui event streaming channels - global _from_ui, _to_router + global _from_ui, _to_router, _lines _to_router, _from_ui = trio.open_memory_channel(100) actor = tractor.current_actor() @@ -337,6 +373,27 @@ async def spawn_router_stream_alerts( async for alert in stream: + yb = pg.mkBrush(hcolor('alert_yellow')) + + angle = 90 if alert['name'] == 'lt' else -90 + + arrow = pg.ArrowItem( + angle=angle, + baseAngle=0, + headLen=5, + headWidth=2, + tailLen=None, + brush=yb, + ) + arrow.setPos(alert['index'], alert['price']) + chart.plotItem.addItem(arrow) + + # delete the line from view + oid = alert['oid'] + print(f'_lines: {_lines}') + print(f'deleting line with oid: {oid}') + _lines.pop(oid).delete() + # TODO: this in another task? # not sure if this will ever be a bottleneck, # we probably could do graphics stuff first tho? @@ -345,10 +402,10 @@ async def spawn_router_stream_alerts( result = await trio.run_process( [ 'notify-send', - 'piker', - f'Alert: {alert}', '-u', 'normal', '-t', '10000', + 'piker', + f'alert: {alert}', ], ) log.runtime(result) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 77ab710a..c743b5f0 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -937,6 +937,7 @@ async def _async_main( # spawn EMS actor-service router_send_chan = await n.start( spawn_router_stream_alerts, + chart, symbol, ) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 61987bdf..e9ad8744 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -17,7 +17,9 @@ """ UX interaction customs. """ +from dataclasses import dataclass from typing import Optional +import uuid import pyqtgraph as pg from pyqtgraph import ViewBox, Point, QtCore, QtGui @@ -27,6 +29,7 @@ import numpy as np from ..log import get_logger from ._style import _min_points_to_show, hcolor, _font from ._graphics._lines import level_line +from .._ems import _lines log = get_logger(__name__) @@ -195,13 +198,30 @@ class SelectRect(QtGui.QGraphicsRectItem): self.hide() +@dataclass +class LinesEditor: + view: 'ChartView' + chart: 'ChartPlotWidget' + active_line: 'LevelLine' + + def stage_line(self) -> 'LevelLine': + ... + + def commit_line(self) -> 'LevelLine': + ... + + def remove_line(self, line) -> None: + ... + + class ChartView(ViewBox): """Price chart view box with interaction behaviors you'd expect from any interactive platform: - zoom on mouse scroll that auto fits y-axis - - no vertical scrolling - - zoom to a "fixed point" on the y-axis + - vertical scrolling on y-axis + - zoom on x to most recent in view datum + - zoom on right-click-n-drag to cursor position """ def __init__( self, @@ -306,9 +326,7 @@ class ChartView(ViewBox): # Scale or translate based on mouse button if button & (QtCore.Qt.LeftButton | QtCore.Qt.MidButton): - # print(f'left click drag pos {pos}') - - # zoom only y-axis when click-n-drag on it + # zoom y-axis ONLY when click-n-drag on it if axis == 1: # set a static y range special value on chart widget to # prevent sizing to data in view. @@ -407,11 +425,16 @@ class ChartView(ViewBox): # XXX: should make this an explicit attr # it's assigned inside ``.add_plot()`` - self.linked_charts._to_router.send_nowait({ - 'symbol': chart.name, - 'brokers': ['kraken'], + lc = self.linked_charts + oid = str(uuid.uuid4()) + lc._to_router.send_nowait({ + 'chart': lc, 'type': 'alert', 'price': y, + 'oid': oid, + # 'symbol': lc.chart.name, + # 'brokers': lc.symbol.brokers, + # 'price': y, }) line = level_line( @@ -419,6 +442,7 @@ class ChartView(ViewBox): level=y, color='alert_yellow', ) + _lines[oid] = line log.info(f'clicked {pos}') def keyReleaseEvent(self, ev): From 268f207a6c521501bd2b263c380d123ce3f55a61 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 5 Jan 2021 08:02:48 -0500 Subject: [PATCH 25/29] Shadow 'close' field with 'last' --- piker/brokers/kraken.py | 1 + 1 file changed, 1 insertion(+) diff --git a/piker/brokers/kraken.py b/piker/brokers/kraken.py index 2289f743..2830f980 100644 --- a/piker/brokers/kraken.py +++ b/piker/brokers/kraken.py @@ -266,6 +266,7 @@ def normalize( quote['broker_ts'] = quote['time'] quote['brokerd_ts'] = time.time() quote['symbol'] = quote['pair'] = quote['pair'].replace('/', '') + quote['last'] = quote['close'] # seriously eh? what's with this non-symmetry everywhere # in subscription systems... From 8d66a17daf5f930abb0435a70539353c79eb350d Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 5 Jan 2021 13:37:03 -0500 Subject: [PATCH 26/29] Add a "lines editor" api/component --- piker/_ems.py | 14 ++- piker/ui/_graphics/_lines.py | 20 +++- piker/ui/_interaction.py | 223 ++++++++++++++++++++++------------- 3 files changed, 161 insertions(+), 96 deletions(-) diff --git a/piker/_ems.py b/piker/_ems.py index 52b55d97..93175e12 100644 --- a/piker/_ems.py +++ b/piker/_ems.py @@ -1,5 +1,5 @@ # piker: trading gear for hackers -# Copyright (C) 2018-present Tyler Goodlet (in stewardship for piker0) +# 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 @@ -47,8 +47,8 @@ _local_book = {} @dataclass -class OrderBook: - """Send (client?) side order book tracking. +class OrderBoi: + """'Buy' (client ?) side order book ctl and tracking. Mostly for keeping local state to match the EMS and use events to trigger graphics updates. @@ -73,14 +73,14 @@ class OrderBook: ... -_orders: OrderBook = None +_orders: OrderBoi = None -def get_orders() -> OrderBook: +def get_orders() -> OrderBoi: global _orders if _orders is None: - _orders = OrderBook + _orders = OrderBoi return _orders @@ -392,6 +392,8 @@ async def spawn_router_stream_alerts( oid = alert['oid'] print(f'_lines: {_lines}') print(f'deleting line with oid: {oid}') + + chart._vb._lines_editor _lines.pop(oid).delete() # TODO: this in another task? diff --git a/piker/ui/_graphics/_lines.py b/piker/ui/_graphics/_lines.py index ef4c00f1..c6bf1818 100644 --- a/piker/ui/_graphics/_lines.py +++ b/piker/ui/_graphics/_lines.py @@ -18,6 +18,7 @@ Lines for orders, alerts, L2. """ +from dataclasses import dataclass from typing import Tuple import pyqtgraph as pg @@ -87,10 +88,11 @@ class LevelLabel(YSticky): self.level = level def set_label_str(self, level: float): - # this is read inside ``.paint()`` # self.label_str = '{size} x {level:.{digits}f}'.format( - self.label_str = '{level:.{digits}f}'.format( # size=self._size, + + # this is read inside ``.paint()`` + self.label_str = '{level:.{digits}f}'.format( digits=self.digits, level=level ).replace(',', ' ') @@ -200,14 +202,16 @@ class LevelLine(pg.InfiniteLine): chart: 'ChartPlotWidget', # type: ignore # noqa label: LevelLabel, highlight_color: str = 'default_light', + hl_on_hover: bool = True, **kwargs, ) -> None: - self.label = label + super().__init__(**kwargs) + self.label = label self.sigPositionChanged.connect(self.set_level) - self._chart = chart + self._hoh = hl_on_hover # use slightly thicker highlight pen = pg.mkPen(hcolor(highlight_color)) @@ -231,7 +235,8 @@ class LevelLine(pg.InfiniteLine): """Mouse hover callback. """ - if self.mouseHovering == hover: + # XXX: currently we'll just return if _hoh is False + if self.mouseHovering == hover or not self._hoh: return self.mouseHovering = hover @@ -315,6 +320,10 @@ def level_line( show_label: bool = True, + # whether or not the line placed in view should highlight + # when moused over (aka "hovered") + hl_on_hover: bool = True, + **linelabelkwargs ) -> LevelLine: """Convenience routine to add a styled horizontal line to a plot. @@ -346,6 +355,7 @@ def level_line( highlight_color=color + '_light', movable=True, angle=0, + hl_on_hover=hl_on_hover, ) line.setValue(level) line.setPen(pg.mkPen(hcolor(color))) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index e9ad8744..fdaeb137 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -1,5 +1,5 @@ # piker: trading gear for hackers -# Copyright (C) 2018-present Tyler Goodlet (in stewardship of piker0) +# 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 @@ -17,7 +17,7 @@ """ UX interaction customs. """ -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Optional import uuid @@ -28,7 +28,7 @@ import numpy as np from ..log import get_logger from ._style import _min_points_to_show, hcolor, _font -from ._graphics._lines import level_line +from ._graphics._lines import level_line, LevelLine from .._ems import _lines @@ -199,19 +199,133 @@ class SelectRect(QtGui.QGraphicsRectItem): @dataclass -class LinesEditor: +class LineEditor: view: 'ChartView' - chart: 'ChartPlotWidget' - active_line: 'LevelLine' + _lines: field(default_factory=dict) + chart: 'ChartPlotWidget' = None # type: ignore # noqa + _active_staged_line: LevelLine = None + _stage_line: LevelLine = None - def stage_line(self) -> 'LevelLine': - ... + def stage_line(self, color: str = 'alert_yellow') -> LevelLine: + """Stage a line at the current chart's cursor position + and return it. - def commit_line(self) -> 'LevelLine': - ... + """ + chart = self.chart._cursor.active_plot + chart.setCursor(QtCore.Qt.PointingHandCursor) + cursor = chart._cursor + y = chart._cursor._datum_xy[1] - def remove_line(self, line) -> None: - ... + line = self._stage_line + if not line: + # add a "staged" cursor-tracking line to view + # and cash it in a a var + line = level_line( + chart, + level=y, + color=color, + + # don't highlight the "staging" line + hl_on_hover=False, + ) + self._stage_line = line + + else: + # use the existing staged line instead + # of allocating more mem / objects repeatedly + line.setValue(y) + line.show() + line.label.show() + + self._active_staged_line = line + + # hide crosshair y-line + cursor.graphics[chart]['hl'].hide() + + # 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 = chart._cursor + + # delete "staged" cursor tracking line from view + line = self._active_staged_line + + cursor._trackers.remove(line) + + if self._stage_line: + self._stage_line.hide() + self._stage_line.label.hide() + + # if line: + # line.delete() + + self._active_staged_line = None + + # show the crosshair y line + hl = cursor.graphics[chart]['hl'] + hl.show() + + def commit_line(self) -> LevelLine: + line = self._active_staged_line + if line: + chart = self.chart._cursor.active_plot + + y = chart._cursor._datum_xy[1] + + # XXX: should make this an explicit attr + # it's assigned inside ``.add_plot()`` + lc = self.view.linked_charts + + oid = str(uuid.uuid4()) + lc._to_router.send_nowait({ + 'chart': lc, + 'type': 'alert', + 'price': y, + 'oid': oid, + # 'symbol': lc.chart.name, + # 'brokers': lc.symbol.brokers, + # 'price': y, + }) + + line = level_line( + chart, + level=y, + color='alert_yellow', + ) + # register for later + _lines[oid] = line + + log.debug(f'clicked y: {y}') + + def remove_line( + self, + line: LevelLine = None, + uuid: str = None, + ) -> None: + """Remove a line by refernce or uuid. + + If no lines or ids are provided remove all lines under the + cursor position. + + """ + # Delete any hoverable under the cursor + cursor = self.chart._cursor + + if line: + line.delete() + else: + for item in cursor._hovered: + # hovered items must also offer + # a ``.delete()`` method + item.delete() class ChartView(ViewBox): @@ -236,8 +350,9 @@ class ChartView(ViewBox): self.addItem(self.select_box, ignoreBounds=True) self._chart: 'ChartPlotWidget' = None # noqa + self._lines_editor = LineEditor(view=self, _lines=_lines) self._key_buffer = [] - self._active_staged_line: 'LevelLine' = None # noqa + self._active_staged_line: LevelLine = None # noqa @property def chart(self) -> 'ChartPlotWidget': # type: ignore # noqa @@ -247,6 +362,7 @@ class ChartView(ViewBox): def chart(self, chart: 'ChartPlotWidget') -> None: # type: ignore # noqa self._chart = chart self.select_box.chart = chart + self._lines_editor.chart = chart def wheelEvent(self, ev, axis=None): """Override "center-point" location for scrolling. @@ -407,7 +523,7 @@ class ChartView(ViewBox): """ button = ev.button() - pos = ev.pos() + # pos = ev.pos() if button == QtCore.Qt.RightButton and self.menuEnabled(): ev.accept() @@ -417,33 +533,8 @@ class ChartView(ViewBox): ev.accept() - line = self._active_staged_line - if line: - chart = self.chart._cursor.active_plot - - y = chart._cursor._datum_xy[1] - - # XXX: should make this an explicit attr - # it's assigned inside ``.add_plot()`` - lc = self.linked_charts - oid = str(uuid.uuid4()) - lc._to_router.send_nowait({ - 'chart': lc, - 'type': 'alert', - 'price': y, - 'oid': oid, - # 'symbol': lc.chart.name, - # 'brokers': lc.symbol.brokers, - # 'price': y, - }) - - line = level_line( - chart, - level=y, - color='alert_yellow', - ) - _lines[oid] = line - log.info(f'clicked {pos}') + # commit the "staged" line under the cursor + self._lines_editor.commit_line() def keyReleaseEvent(self, ev): """ @@ -465,23 +556,8 @@ class ChartView(ViewBox): self.setMouseMode(ViewBox.PanMode) if text == 'a': - - chart = self.chart._cursor.active_plot - chart.setCursor(QtCore.Qt.ArrowCursor) - cursor = chart._cursor - - # delete "staged" cursor tracking line from view - line = self._active_staged_line - cursor._trackers.remove(line) - - if line: - line.delete() - - self._active_staged_line = None - - # show the crosshair y line - hl = cursor.graphics[chart]['hl'] - hl.show() + # draw "staged" line under cursor position + self._lines_editor.unstage_line() def keyPressEvent(self, ev): """ @@ -526,35 +602,12 @@ class ChartView(ViewBox): self.chart.default_view() elif text == 'a': - - chart = self.chart._cursor.active_plot - chart.setCursor(QtCore.Qt.PointingHandCursor) - cursor = chart._cursor - - # add a "staged" cursor-tracking alert line - - line = level_line( - chart, - level=chart._cursor._datum_xy[1], - color='alert_yellow', - ) - self._active_staged_line = line - - # hide crosshair y-line - cursor.graphics[chart]['hl'].hide() - - # add line to cursor trackers - cursor._trackers.add(line) + # add a line at the current cursor + self._lines_editor.stage_line() elif text == 'd': - # Delete any hoverable under the cursor - cursor = self.chart._cursor - chart = cursor.active_plot - - for item in cursor._hovered: - # hovered items must also offer - # a ``.delete()`` method - item.delete() + # delete any lines under the cursor + self._lines_editor.remove_line() # Leaving this for light reference purposes From 282cc85ba07cd593f57f2f04743bad449a88f019 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 7 Jan 2021 12:03:18 -0500 Subject: [PATCH 27/29] Create an "order mode" Our first major UI "mode" (yes kinda like the modes in emacs) that has handles to a client side order book api, line and arrow editors, and interacts with a spawned `emsd` (the EMS daemon actor). Buncha cleaning and fixes in here for various thingers as well. --- piker/_ems.py | 277 +++++++++++++++++++++-------------- piker/ui/_chart.py | 46 +++--- piker/ui/_graphics/_lines.py | 2 +- piker/ui/_interaction.py | 222 ++++++++++++++++++++++------ 4 files changed, 375 insertions(+), 172 deletions(-) diff --git a/piker/_ems.py b/piker/_ems.py index 93175e12..50578da6 100644 --- a/piker/_ems.py +++ b/piker/_ems.py @@ -20,12 +20,11 @@ In suit parlance: "Execution management systems" """ from dataclasses import dataclass, field from typing import ( - AsyncIterator, List, Dict, Callable, Tuple, + AsyncIterator, Dict, Callable, Tuple, Any, ) # import uuid -import pyqtgraph as pg import trio from trio_typing import TaskStatus import tractor @@ -33,32 +32,59 @@ import tractor from . import data from .log import get_logger from .data._source import Symbol -from .ui._style import hcolor log = get_logger(__name__) -_to_router: trio.abc.SendChannel = None -_from_ui: trio.abc.ReceiveChannel = None -_lines = {} - - -_local_book = {} +# setup local ui event streaming channels for request/resp +# streamging with EMS daemon +_to_ems, _from_order_book = trio.open_memory_channel(100) @dataclass -class OrderBoi: - """'Buy' (client ?) side order book ctl and tracking. +class OrderBook: + """Buy-side (client-side ?) order book ctl and tracking. - Mostly for keeping local state to match the EMS and use - events to trigger graphics updates. + A style similar to "model-view" is used here where this api is + provided as a supervised control for an EMS actor which does all the + hard/fast work of talking to brokers/exchanges to conduct + executions. + + Currently, mostly for keeping local state to match the EMS and use + received events to trigger graphics updates. """ - orders: Dict[str, dict] = field(default_factory=dict) - _cmds_from_ui: trio.abc.ReceiveChannel = _from_ui + _sent_orders: Dict[str, dict] = field(default_factory=dict) + _confirmed_orders: Dict[str, dict] = field(default_factory=dict) - async def alert(self, price: float) -> str: - ... + _to_ems: trio.abc.SendChannel = _to_ems + _from_order_book: trio.abc.ReceiveChannel = _from_order_book + + def on_fill(self, uuid: str) -> None: + cmd = self._sent_orders[uuid] + log.info(f"Order executed: {cmd}") + self._confirmed_orders[uuid] = cmd + + def alert( + self, + uuid: str, + symbol: 'Symbol', + price: float + ) -> str: + # XXX: should make this an explicit attr + # it's assigned inside ``.add_plot()`` + # lc = self.view.linked_charts + + # uid = str(uuid.uuid4()) + cmd = { + 'msg': 'alert', + 'price': price, + 'symbol': symbol.key, + 'brokers': symbol.brokers, + 'oid': uuid, + } + self._sent_orders[uuid] = cmd + self._to_ems.send_nowait(cmd) async def buy(self, price: float) -> str: ... @@ -66,21 +92,34 @@ class OrderBoi: async def sell(self, price: float) -> str: ... + async def cancel(self, oid: str) -> bool: + """Cancel an order (or alert) from the EMS. + + """ + ... + + # higher level operations + + async def transmit_to_broker(self, price: float) -> str: + ... + async def modify(self, oid: str, price) -> bool: ... - async def cancel(self, oid: str) -> bool: - ... + +_orders: OrderBook = None -_orders: OrderBoi = None +def get_orders(emsd_uid: Tuple[str, str] = None) -> OrderBook: + if emsd_uid is not None: + # TODO: read in target emsd's active book on startup + pass -def get_orders() -> OrderBoi: global _orders if _orders is None: - _orders = OrderBoi + _orders = OrderBook() return _orders @@ -91,48 +130,44 @@ async def send_order_cmds(): to downstream consumers. This is run in the UI actor (usually the one running Qt). - The UI simply delivers order messages to the above ``_to_router`` + The UI simply delivers order messages to the above ``_to_ems`` send channel (from sync code using ``.send_nowait()``), these values are pulled from the channel here and send to any consumer(s). + This effectively makes order messages look like they're being + "pushed" from the parent to the EMS actor. + """ - global _from_ui - async for order in _from_ui: + global _from_order_book + # book = get_orders() - lc = order['chart'] - symbol = lc.symbol - tp = order['type'] - price = order['price'] - oid = order['oid'] + async for cmd in _from_order_book: + + # send msg over IPC / wire + log.info(f'sending order cmd: {cmd}') + yield cmd + + # lc = order['chart'] + # symbol = order['symol'] + # msg = order['msg'] + # price = order['price'] + # oid = order['oid'] - print(f'oid: {oid}') # TODO # oid = str(uuid.uuid4()) - cmd = { - 'price': price, - 'action': 'alert', - 'symbol': symbol.key, - 'brokers': symbol.brokers, - 'type': tp, - 'price': price, - 'oid': oid, - } + # cmd = { + # 'price': price, + # 'action': 'alert', + # 'symbol': symbol.key, + # 'brokers': symbol.brokers, + # 'msg': msg, + # 'price': price, + # 'oid': oid, + # } - _local_book[oid] = cmd - - yield cmd - - -# streaming tasks which check for conditions per symbol per broker -_scan_tasks: Dict[str, List] = {} - -# levels which have an executable action (eg. alert, order, signal) -_levels: Dict[str, list] = {} - -# up to date last values from target streams -_last_values: Dict[str, float] = {} + # book._sent_orders[oid] = cmd # TODO: numba all of this @@ -146,26 +181,22 @@ def mk_check(trigger_price, known_last) -> Callable[[float, float], bool]: avoiding the case where the a predicate returns true immediately. """ + # str compares: + # https://stackoverflow.com/questions/46708708/compare-strings-in-numba-compiled-function if trigger_price >= known_last: def check_gt(price: float) -> bool: - if price >= trigger_price: - return True - else: - return False + return price >= trigger_price - return check_gt, 'gt' + return check_gt, 'down' elif trigger_price <= known_last: def check_lt(price: float) -> bool: - if price <= trigger_price: - return True - else: - return False + return price <= trigger_price - return check_lt, 'lt' + return check_lt, 'up' @dataclass @@ -173,9 +204,10 @@ class _ExecBook: """EMS-side execution book. Contains conditions for executions (aka "orders"). - A singleton instance is created per EMS actor. + A singleton instance is created per EMS actor (for now). """ + # levels which have an executable action (eg. alert, order, signal) orders: Dict[ Tuple[str, str], Tuple[ @@ -188,7 +220,7 @@ class _ExecBook: ] ] = field(default_factory=dict) - # most recent values + # tracks most recent values per symbol each from data feed lasts: Dict[ Tuple[str, str], float @@ -236,6 +268,11 @@ async def exec_orders( with stream.shield(): async for quotes in stream: + ############################## + # begin price actions sequence + # XXX: optimize this for speed + ############################## + for sym, quote in quotes.items(): execs = book.orders.get((broker, sym)) @@ -249,27 +286,36 @@ async def exec_orders( # update to keep new cmds informed book.lasts[(broker, symbol)] = price - # begin price actions sequence - if not execs: continue - for oid, pred, action in tuple(execs): + for oid, pred, name, cmd in tuple(execs): + # push trigger msg back to parent as an "alert" # (mocking for eg. a "fill") if pred(price): - name = action(price) - await ctx.send_yield({ - 'type': 'alert', - 'price': price, - # current shm array index - 'index': feed.shm._last.value - 1, - 'name': name, - 'oid': oid, - }) - execs.remove((oid, pred, action)) + + cmd['name'] = name + cmd['index'] = feed.shm._last.value - 1 + # current shm array index + cmd['trigger_price'] = price + + await ctx.send_yield(cmd) + # await ctx.send_yield({ + # 'type': 'alert', + # 'price': price, + # # current shm array index + # 'index': feed.shm._last.value - 1, + # 'name': name, + # 'oid': oid, + # }) + print( f"GOT ALERT FOR {exec_price} @ \n{tick}\n") + + print(f'removing pred for {oid}') + execs.remove((oid, pred, name, cmd)) + print(f'execs are {execs}') # feed teardown @@ -279,6 +325,10 @@ async def exec_orders( async def stream_and_route(ctx, ui_name): """Order router (sub)actor entrypoint. + This is the daemon (child) side routine which starts an EMS + runtime per broker/feed and and begins streaming back alerts + from executions back to subscribers. + """ actor = tractor.current_actor() book = get_book() @@ -291,19 +341,18 @@ async def stream_and_route(ctx, ui_name): async for cmd in await portal.run(send_order_cmds): - action = cmd.pop('action') + msg = cmd['msg'] - if action == 'cancel': + if msg == 'cancel': + # TODO: pass - tp = cmd.pop('type') - trigger_price = cmd['price'] sym = cmd['symbol'] brokers = cmd['brokers'] oid = cmd['oid'] - if tp == 'alert': + if msg == 'alert': log.info(f'Alert {cmd} received in {actor.uid}') broker = brokers[0] @@ -333,14 +382,17 @@ async def stream_and_route(ctx, ui_name): # create list of executions on first entry book.orders.setdefault((broker, sym), []).append( - (oid, pred, lambda p: name) + (oid, pred, name, cmd) ) + # ack-respond that order is live + await ctx.send_yield({'msg': 'ack', 'oid': oid}) + # continue and wait on next order cmd async def spawn_router_stream_alerts( - chart, + order_mode, symbol: Symbol, # lines: 'LinesEditor', task_status: TaskStatus[str] = trio.TASK_STATUS_IGNORED, @@ -349,13 +401,11 @@ async def spawn_router_stream_alerts( alerts. """ - # setup local ui event streaming channels - global _from_ui, _to_router, _lines - _to_router, _from_ui = trio.open_memory_channel(100) actor = tractor.current_actor() - subactor_name = 'piker.ems' + subactor_name = 'emsd' + # TODO: add ``maybe_spawn_emsd()`` for this async with tractor.open_nursery() as n: portal = await n.start_actor( @@ -369,32 +419,40 @@ async def spawn_router_stream_alerts( async with tractor.wait_for_actor(subactor_name): # let parent task continue - task_status.started(_to_router) + task_status.started(_to_ems) + # begin the trigger-alert stream + # this is where we receive **back** messages + # about executions **from** the EMS actor async for alert in stream: - yb = pg.mkBrush(hcolor('alert_yellow')) - - angle = 90 if alert['name'] == 'lt' else -90 - - arrow = pg.ArrowItem( - angle=angle, - baseAngle=0, - headLen=5, - headWidth=2, - tailLen=None, - brush=yb, - ) - arrow.setPos(alert['index'], alert['price']) - chart.plotItem.addItem(arrow) - # delete the line from view oid = alert['oid'] - print(f'_lines: {_lines}') + msg_type = alert['msg'] + + if msg_type == 'ack': + print(f"order accepted: {alert}") + + # show line label once order is live + order_mode.lines.commit_line(oid) + + continue + + order_mode.arrows.add( + oid, + alert['index'], + alert['price'], + pointing='up' if alert['name'] == 'up' else 'down' + ) + + # print(f'_lines: {_lines}') print(f'deleting line with oid: {oid}') - chart._vb._lines_editor - _lines.pop(oid).delete() + # delete level from view + order_mode.lines.remove_line(uuid=oid) + + # chart._vb._lines_editor + # _lines.pop(oid).delete() # TODO: this in another task? # not sure if this will ever be a bottleneck, @@ -411,3 +469,6 @@ async def spawn_router_stream_alerts( ], ) log.runtime(result) + + # do we need this? + # await _from_ems.put(alert) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index c743b5f0..39211e61 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -57,7 +57,7 @@ from .. import data from ..data import maybe_open_shm_array from ..log import get_logger from ._exec import run_qtractor, current_screen -from ._interaction import ChartView +from ._interaction import ChartView, open_order_mode from .. import fsp from .._ems import spawn_router_stream_alerts @@ -301,6 +301,7 @@ class LinkedSplitCharts(QtGui.QWidget): array=array, parent=self.splitter, + linked_charts=self, axisItems={ 'bottom': xaxis, 'right': PriceAxis(linked_charts=self) @@ -310,9 +311,9 @@ class LinkedSplitCharts(QtGui.QWidget): **cpw_kwargs, ) - # give viewbox a reference to primary chart - # allowing for kb controls and interactions - # (see our custom view in `._interactions.py`) + # give viewbox as reference to chart + # allowing for kb controls and interactions on **this** widget + # (see our custom view mode in `._interactions.py`) cv.chart = cpw cpw.plotItem.vb.linked_charts = self @@ -375,6 +376,7 @@ class ChartPlotWidget(pg.PlotWidget): # the data view we generate graphics from name: str, array: np.ndarray, + linked_charts: LinkedSplitCharts, static_yrange: Optional[Tuple[float, float]] = None, cursor: Optional[Cursor] = None, **kwargs, @@ -390,6 +392,7 @@ class ChartPlotWidget(pg.PlotWidget): **kwargs ) self.name = name + self._lc = linked_charts # self.setViewportMargins(0, 0, 0, 0) self._ohlc = array # readonly view of ohlc data @@ -934,17 +937,6 @@ async def _async_main( wap_in_history, ) - # spawn EMS actor-service - router_send_chan = await n.start( - spawn_router_stream_alerts, - chart, - symbol, - ) - - # wait for router to come up before setting - # enabling send channel on chart - linked_charts._to_router = router_send_chan - # wait for a first quote before we start any update tasks quote = await feed.receive() @@ -958,8 +950,26 @@ async def _async_main( linked_charts ) - # probably where we'll eventually start the user input loop - await trio.sleep_forever() + async with open_order_mode( + chart, + ) as order_mode: + + # TODO: this should probably be implicitly spawned + # inside the above mngr? + + # spawn EMS actor-service + to_ems_chan = await n.start( + spawn_router_stream_alerts, + order_mode, + symbol, + ) + + # wait for router to come up before setting + # enabling send channel on chart + linked_charts._to_ems = to_ems_chan + + # probably where we'll eventually start the user input loop + await trio.sleep_forever() async def chart_from_quotes( @@ -1019,7 +1029,7 @@ async def chart_from_quotes( chart, # determine precision/decimal lengths digits=max(float_digits(last), 2), - size_digits=min(float_digits(volume), 3) + size_digits=min(float_digits(last), 3) ) # TODO: diff --git a/piker/ui/_graphics/_lines.py b/piker/ui/_graphics/_lines.py index c6bf1818..b697692b 100644 --- a/piker/ui/_graphics/_lines.py +++ b/piker/ui/_graphics/_lines.py @@ -157,7 +157,7 @@ class L1Labels: self, chart: 'ChartPlotWidget', # noqa digits: int = 2, - size_digits: int = 0, + size_digits: int = 3, font_size_inches: float = _down_2_font_inches_we_like, ) -> None: diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index fdaeb137..ba779c34 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -17,8 +17,9 @@ """ UX interaction customs. """ +from contextlib import asynccontextmanager from dataclasses import dataclass, field -from typing import Optional +from typing import Optional, Dict, Callable import uuid import pyqtgraph as pg @@ -29,7 +30,7 @@ import numpy as np from ..log import get_logger from ._style import _min_points_to_show, hcolor, _font from ._graphics._lines import level_line, LevelLine -from .._ems import _lines +from .._ems import get_orders, OrderBook log = get_logger(__name__) @@ -198,10 +199,17 @@ class SelectRect(QtGui.QGraphicsRectItem): 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: view: 'ChartView' - _lines: field(default_factory=dict) + _order_lines: field(default_factory=_order_lines) chart: 'ChartPlotWidget' = None # type: ignore # noqa _active_staged_line: LevelLine = None _stage_line: LevelLine = None @@ -223,6 +231,7 @@ class LineEditor: line = level_line( chart, level=y, + digits=chart._lc.symbol.digits(), color=color, # don't highlight the "staging" line @@ -264,46 +273,48 @@ class LineEditor: self._stage_line.hide() self._stage_line.label.hide() - # if line: - # line.delete() - self._active_staged_line = None # show the crosshair y line hl = cursor.graphics[chart]['hl'] hl.show() - def commit_line(self) -> LevelLine: + def create_line(self, uuid: str) -> LevelLine: + line = self._active_staged_line - if line: - chart = self.chart._cursor.active_plot + if not line: + raise RuntimeError("No line commit is currently staged!?") - y = chart._cursor._datum_xy[1] + chart = self.chart._cursor.active_plot + y = chart._cursor._datum_xy[1] - # XXX: should make this an explicit attr - # it's assigned inside ``.add_plot()`` - lc = self.view.linked_charts + line = level_line( + chart, + level=y, + color='alert_yellow', + digits=chart._lc.symbol.digits(), + show_label=False, + ) - oid = str(uuid.uuid4()) - lc._to_router.send_nowait({ - 'chart': lc, - 'type': 'alert', - 'price': y, - 'oid': oid, - # 'symbol': lc.chart.name, - # 'brokers': lc.symbol.brokers, - # 'price': y, - }) + # register for later lookup/deletion + self._order_lines[uuid] = line + return line, y - line = level_line( - chart, - level=y, - color='alert_yellow', - ) - # register for later - _lines[oid] = line + def commit_line(self, uuid: str) -> LevelLine: + """Commit a "staged line" to view. - log.debug(f'clicked y: {y}') + Submits the line graphic under the cursor as a (new) permanent + graphic in view. + + """ + line = self._order_lines[uuid] + line.label.show() + + # TODO: other flashy things to indicate the order is active + + log.debug(f'Level active for level: {line.value()}') + + return line def remove_line( self, @@ -316,18 +327,114 @@ class LineEditor: cursor position. """ - # Delete any hoverable under the cursor - cursor = self.chart._cursor - if line: + # If line is passed delete it line.delete() + + elif uuid: + # try to look up line from our registry + self._order_lines.pop(uuid).delete() + else: + # Delete any hoverable under the cursor + cursor = self.chart._cursor + for item in cursor._hovered: # hovered items must also offer # a ``.delete()`` method item.delete() +@dataclass +class ArrowEditor: + + chart: 'ChartPlotWidget' # noqa + _arrows: field(default_factory=dict) + + def add( + self, + uid: str, + x: float, + y: float, + color='default', + pointing: str = 'up', + ) -> pg.ArrowItem: + """Add an arrow graphic to view at given (x, y). + + """ + yb = pg.mkBrush(hcolor('alert_yellow')) + + angle = 90 if pointing == 'up' else -90 + + arrow = pg.ArrowItem( + angle=angle, + baseAngle=0, + headLen=5, + headWidth=2, + tailLen=None, + brush=yb, + ) + 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 OrderMode: + """Major mode for placing orders on a chart view. + + """ + chart: 'ChartPlotWidget' + book: OrderBook + lines: LineEditor + arrows: ArrowEditor + + key_map: Dict[str, Callable] = field(default_factory=dict) + + def uuid(self) -> str: + return str(uuid.uuid4()) + + +@asynccontextmanager +async def open_order_mode( + chart, +): + # global _order_lines + + view = chart._vb + book = get_orders() + lines = LineEditor(view=view, _order_lines=_order_lines, chart=chart) + arrows = ArrowEditor(chart, {}) + + log.info("Opening order mode") + + mode = OrderMode(chart, book, lines, arrows) + view.mode = mode + + # # setup local ui event streaming channels for request/resp + # # streamging with EMS daemon + # global _to_ems, _from_order_book + # _to_ems, _from_order_book = trio.open_memory_channel(100) + + try: + yield mode + + finally: + # XXX special teardown handling like for ex. + # - cancelling orders if needed? + # - closing positions if desired? + # - switching special condition orders to safer/more reliable variants + log.info("Closing order mode") + + class ChartView(ViewBox): """Price chart view box with interaction behaviors you'd expect from any interactive platform: @@ -336,6 +443,7 @@ class ChartView(ViewBox): - vertical scrolling on y-axis - zoom on x to most recent in view datum - zoom on right-click-n-drag to cursor position + """ def __init__( self, @@ -350,9 +458,11 @@ class ChartView(ViewBox): self.addItem(self.select_box, ignoreBounds=True) self._chart: 'ChartPlotWidget' = None # noqa - self._lines_editor = LineEditor(view=self, _lines=_lines) + # self._lines_editor = LineEditor(view=self, _lines=_lines) + self.mode = None + + # kb ctrls processing self._key_buffer = [] - self._active_staged_line: LevelLine = None # noqa @property def chart(self) -> 'ChartPlotWidget': # type: ignore # noqa @@ -362,7 +472,7 @@ class ChartView(ViewBox): def chart(self, chart: 'ChartPlotWidget') -> None: # type: ignore # noqa self._chart = chart self.select_box.chart = chart - self._lines_editor.chart = chart + # self._lines_editor.chart = chart def wheelEvent(self, ev, axis=None): """Override "center-point" location for scrolling. @@ -533,8 +643,27 @@ class ChartView(ViewBox): ev.accept() - # commit the "staged" line under the cursor - self._lines_editor.commit_line() + # self._lines_editor.commit_line() + + # send order to EMS + + # register the "staged" line under the cursor + # to be displayed when above order ack arrives + # (means the line graphic doesn't show on screen until the + # order is live in the emsd). + mode = self.mode + uuid = mode.uuid() + + # make line graphic + line, y = mode.lines.create_line(uuid) + + # send order cmd to ems + mode.book.alert( + uuid=uuid, + symbol=mode.chart._lc._symbol, + price=y + ) + def keyReleaseEvent(self, ev): """ @@ -557,7 +686,8 @@ class ChartView(ViewBox): if text == 'a': # draw "staged" line under cursor position - self._lines_editor.unstage_line() + # self._lines_editor.unstage_line() + self.mode.lines.unstage_line() def keyPressEvent(self, ev): """ @@ -580,12 +710,11 @@ class ChartView(ViewBox): # ctl if mods == QtCore.Qt.ControlModifier: - # print("CTRL") # TODO: ctrl-c as cancel? # https://forum.qt.io/topic/532/how-to-catch-ctrl-c-on-a-widget/9 # if ev.text() == 'c': # self.rbScaleBox.hide() - pass + print(f"CTRL + key:{key} + text:{text}") # alt if mods == QtCore.Qt.AltModifier: @@ -603,13 +732,16 @@ class ChartView(ViewBox): elif text == 'a': # add a line at the current cursor - self._lines_editor.stage_line() + # self._lines_editor.stage_line() + self.mode.lines.stage_line() elif text == 'd': # delete any lines under the cursor - self._lines_editor.remove_line() + # self._lines_editor.remove_line() + self.mode.lines.remove_line() - # Leaving this for light reference purposes + # 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: From dba8457be94b022f48a4c421bb3c001e0f625d6f Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 7 Jan 2021 12:17:10 -0500 Subject: [PATCH 28/29] Trigger kraken sample increment after startup --- piker/brokers/kraken.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/piker/brokers/kraken.py b/piker/brokers/kraken.py index 2830f980..dfbf3c0f 100644 --- a/piker/brokers/kraken.py +++ b/piker/brokers/kraken.py @@ -1,5 +1,5 @@ # piker: trading gear for hackers -# Copyright (C) 2018-present Tyler Goodlet (in stewardship of piker0) +# 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 @@ -34,6 +34,7 @@ import tractor from ._util import resproc, SymbolNotFound, BrokerError from ..log import get_logger, get_console_log from ..data import ( + _buffer, # iterticks, attach_shm_array, get_shm_token, @@ -382,6 +383,9 @@ async def stream_quotes( # packetize as {topic: quote} yield {topic: quote} + # tell incrementer task it can start + _buffer.shm_incrementing(shm_token['shm_name']).set() + # keep start of last interval for volume tracking last_interval_start = ohlc_last.etime From 39e4953a6aa5aa5a5413996b0f34ee9103bbd06f Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 7 Jan 2021 22:08:25 -0500 Subject: [PATCH 29/29] Get order cancellation working --- piker/_ems.py | 227 ++++++++++++++++++--------------------- piker/ui/_interaction.py | 44 ++++---- 2 files changed, 132 insertions(+), 139 deletions(-) diff --git a/piker/_ems.py b/piker/_ems.py index 50578da6..8ede8f31 100644 --- a/piker/_ems.py +++ b/piker/_ems.py @@ -18,12 +18,11 @@ In suit parlance: "Execution management systems" """ +import time from dataclasses import dataclass, field from typing import ( AsyncIterator, Dict, Callable, Tuple, - Any, ) -# import uuid import trio from trio_typing import TaskStatus @@ -71,11 +70,6 @@ class OrderBook: symbol: 'Symbol', price: float ) -> str: - # XXX: should make this an explicit attr - # it's assigned inside ``.add_plot()`` - # lc = self.view.linked_charts - - # uid = str(uuid.uuid4()) cmd = { 'msg': 'alert', 'price': price, @@ -86,17 +80,22 @@ class OrderBook: self._sent_orders[uuid] = cmd self._to_ems.send_nowait(cmd) - async def buy(self, price: float) -> str: + def buy(self, price: float) -> str: ... - async def sell(self, price: float) -> str: + def sell(self, price: float) -> str: ... - async def cancel(self, oid: str) -> bool: + def cancel(self, uuid: str) -> bool: """Cancel an order (or alert) from the EMS. """ - ... + cmd = { + 'msg': 'cancel', + 'oid': uuid, + } + self._sent_orders[uuid] = cmd + self._to_ems.send_nowait(cmd) # higher level operations @@ -138,9 +137,7 @@ async def send_order_cmds(): "pushed" from the parent to the EMS actor. """ - global _from_order_book - # book = get_orders() async for cmd in _from_order_book: @@ -148,27 +145,6 @@ async def send_order_cmds(): log.info(f'sending order cmd: {cmd}') yield cmd - # lc = order['chart'] - # symbol = order['symol'] - # msg = order['msg'] - # price = order['price'] - # oid = order['oid'] - - # TODO - # oid = str(uuid.uuid4()) - - # cmd = { - # 'price': price, - # 'action': 'alert', - # 'symbol': symbol.key, - # 'brokers': symbol.brokers, - # 'msg': msg, - # 'price': price, - # 'oid': oid, - # } - - # book._sent_orders[oid] = cmd - # TODO: numba all of this def mk_check(trigger_price, known_last) -> Callable[[float, float], bool]: @@ -210,13 +186,13 @@ class _ExecBook: # levels which have an executable action (eg. alert, order, signal) orders: Dict[ Tuple[str, str], - Tuple[ - # predicates - Callable[[float], bool], - - # actions - Callable[[float], Dict[str, Any]], - + Dict[ + str, # uuid + Tuple[ + Callable[[float], bool], # predicate + str, # name + dict, # cmd / msg type + ] ] ] = field(default_factory=dict) @@ -273,6 +249,7 @@ async def exec_orders( # XXX: optimize this for speed ############################## + start = time.time() for sym, quote in quotes.items(): execs = book.orders.get((broker, sym)) @@ -289,7 +266,7 @@ async def exec_orders( if not execs: continue - for oid, pred, name, cmd in tuple(execs): + for oid, (pred, name, cmd) in tuple(execs.items()): # push trigger msg back to parent as an "alert" # (mocking for eg. a "fill") @@ -299,25 +276,19 @@ async def exec_orders( cmd['index'] = feed.shm._last.value - 1 # current shm array index cmd['trigger_price'] = price + cmd['msg'] = 'executed' await ctx.send_yield(cmd) - # await ctx.send_yield({ - # 'type': 'alert', - # 'price': price, - # # current shm array index - # 'index': feed.shm._last.value - 1, - # 'name': name, - # 'oid': oid, - # }) print( f"GOT ALERT FOR {exec_price} @ \n{tick}\n") print(f'removing pred for {oid}') - execs.remove((oid, pred, name, cmd)) + pred, name, cmd = execs.pop(oid) print(f'execs are {execs}') + print(f'execs scan took: {time.time() - start}') # feed teardown @@ -333,6 +304,8 @@ async def stream_and_route(ctx, ui_name): actor = tractor.current_actor() book = get_book() + _active_execs: Dict[str, (str, str)] = {} + # new router entry point async with tractor.wait_for_actor(ui_name) as portal: @@ -341,52 +314,63 @@ async def stream_and_route(ctx, ui_name): async for cmd in await portal.run(send_order_cmds): + log.info(f'{cmd} received in {actor.uid}') msg = cmd['msg'] - - if msg == 'cancel': - # TODO: - pass - - trigger_price = cmd['price'] - sym = cmd['symbol'] - brokers = cmd['brokers'] oid = cmd['oid'] - if msg == 'alert': - log.info(f'Alert {cmd} received in {actor.uid}') + if msg == 'cancel': + # destroy exec + pred, name, cmd = book.orders[_active_execs[oid]].pop(oid) - broker = brokers[0] - last = book.lasts.get((broker, sym)) + # ack-cmdond that order is live + await ctx.send_yield({'msg': 'cancelled', 'oid': oid}) - if last is None: # spawn new brokerd feed task + continue - quote = await n.start( - exec_orders, - ctx, - # TODO: eventually support N-brokers - broker, - sym, - trigger_price, - ) - print(f"received first quote {quote}") + elif msg in ('alert', 'buy', 'sell',): - last = book.lasts[(broker, sym)] - print(f'Known last is {last}') + trigger_price = cmd['price'] + sym = cmd['symbol'] + brokers = cmd['brokers'] - # Auto-gen scanner predicate: - # we automatically figure out what the alert check condition - # should be based on the current first price received from the - # feed, instead of being like every other shitty tina platform - # that makes the user choose the predicate operator. - pred, name = mk_check(trigger_price, last) + broker = brokers[0] + last = book.lasts.get((broker, sym)) - # create list of executions on first entry - book.orders.setdefault((broker, sym), []).append( - (oid, pred, name, cmd) - ) + if last is None: # spawn new brokerd feed task - # ack-respond that order is live - await ctx.send_yield({'msg': 'ack', 'oid': oid}) + quote = await n.start( + exec_orders, + ctx, + # TODO: eventually support N-brokers + broker, + sym, + trigger_price, + ) + print(f"received first quote {quote}") + + last = book.lasts[(broker, sym)] + print(f'Known last is {last}') + + # Auto-gen scanner predicate: + # we automatically figure out what the alert check + # condition should be based on the current first + # price received from the feed, instead of being + # like every other shitty tina platform that makes + # the user choose the predicate operator. + pred, name = mk_check(trigger_price, last) + + # create list of executions on first entry + book.orders.setdefault( + (broker, sym), {})[oid] = (pred, name, cmd) + + # reverse lookup for cancellations + _active_execs[oid] = (broker, sym) + + # ack-cmdond that order is live + await ctx.send_yield({ + 'msg': 'active', + 'oid': oid + }) # continue and wait on next order cmd @@ -410,7 +394,7 @@ async def spawn_router_stream_alerts( portal = await n.start_actor( subactor_name, - rpc_module_paths=[__name__], + enable_modules=[__name__], ) stream = await portal.run( stream_and_route, @@ -424,51 +408,52 @@ async def spawn_router_stream_alerts( # begin the trigger-alert stream # this is where we receive **back** messages # about executions **from** the EMS actor - async for alert in stream: + async for msg in stream: # delete the line from view - oid = alert['oid'] - msg_type = alert['msg'] + oid = msg['oid'] + resp = msg['msg'] - if msg_type == 'ack': - print(f"order accepted: {alert}") + if resp in ('active',): + print(f"order accepted: {msg}") # show line label once order is live order_mode.lines.commit_line(oid) continue - order_mode.arrows.add( - oid, - alert['index'], - alert['price'], - pointing='up' if alert['name'] == 'up' else 'down' - ) + elif resp in ('cancelled',): - # print(f'_lines: {_lines}') - print(f'deleting line with oid: {oid}') + # delete level from view + order_mode.lines.remove_line(uuid=oid) + print(f'deleting line with oid: {oid}') - # delete level from view - order_mode.lines.remove_line(uuid=oid) + elif resp in ('executed',): - # chart._vb._lines_editor - # _lines.pop(oid).delete() + order_mode.lines.remove_line(uuid=oid) + print(f'deleting line with oid: {oid}') - # TODO: this in another task? - # not sure if this will ever be a bottleneck, - # we probably could do graphics stuff first tho? + order_mode.arrows.add( + oid, + msg['index'], + msg['price'], + pointing='up' if msg['name'] == 'up' else 'down' + ) - # XXX: linux only for now - result = await trio.run_process( - [ - 'notify-send', - '-u', 'normal', - '-t', '10000', - 'piker', - f'alert: {alert}', - ], - ) - log.runtime(result) + # DESKTOP NOTIFICATIONS + # + # TODO: this in another task? + # not sure if this will ever be a bottleneck, + # we probably could do graphics stuff first tho? - # do we need this? - # await _from_ems.put(alert) + # XXX: linux only for now + result = await trio.run_process( + [ + 'notify-send', + '-u', 'normal', + '-t', '10000', + 'piker', + f'alert: {msg}', + ], + ) + log.runtime(result) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index ba779c34..02c95230 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -308,6 +308,7 @@ class LineEditor: """ line = self._order_lines[uuid] + line.oid = uuid line.label.show() # TODO: other flashy things to indicate the order is active @@ -316,6 +317,13 @@ class LineEditor: return line + def lines_under_cursor(self): + """Get the line(s) under the cursor position. + + """ + # Delete any hoverable under the cursor + return self.chart._cursor._hovered + def remove_line( self, line: LevelLine = None, @@ -328,21 +336,17 @@ class LineEditor: """ if line: - # If line is passed delete it - line.delete() + uuid = line.oid - elif uuid: - # try to look up line from our registry - self._order_lines.pop(uuid).delete() + # try to look up line from our registry + line = self._order_lines.pop(uuid) - else: - # Delete any hoverable under the cursor - cursor = self.chart._cursor + # if hovered remove from cursor set + hovered = self.chart._cursor._hovered + if line in hovered: + hovered.remove(line) - for item in cursor._hovered: - # hovered items must also offer - # a ``.delete()`` method - item.delete() + line.delete() @dataclass @@ -392,10 +396,15 @@ class OrderMode: """Major mode for placing orders on a chart view. """ - chart: 'ChartPlotWidget' + chart: 'ChartPlotWidget' # type: ignore # noqa book: OrderBook lines: LineEditor arrows: ArrowEditor + _arrow_colors = { + 'alert': 'alert_yellow', + 'buy': 'buy_green', + 'sell': 'sell_red', + } key_map: Dict[str, Callable] = field(default_factory=dict) @@ -664,7 +673,6 @@ class ChartView(ViewBox): price=y ) - def keyReleaseEvent(self, ev): """ Key release to normally to trigger release of input mode @@ -686,7 +694,6 @@ class ChartView(ViewBox): if text == 'a': # draw "staged" line under cursor position - # self._lines_editor.unstage_line() self.mode.lines.unstage_line() def keyPressEvent(self, ev): @@ -732,13 +739,14 @@ class ChartView(ViewBox): elif text == 'a': # add a line at the current cursor - # self._lines_editor.stage_line() self.mode.lines.stage_line() elif text == 'd': + # delete any lines under the cursor - # self._lines_editor.remove_line() - self.mode.lines.remove_line() + mode = self.mode + for line in mode.lines.lines_under_cursor(): + mode.book.cancel(uuid=line.oid) # XXX: Leaving this for light reference purposes, there # seems to be some work to at least gawk at for history mgmt.