diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 7228ef36..faf26841 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -18,10 +18,8 @@ High level Qt chart widgets. """ -from pprint import pformat from typing import Tuple, Dict, Any, Optional, Callable from functools import partial -import time from PyQt5 import QtCore, QtGui import numpy as np @@ -60,9 +58,9 @@ 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, open_order_mode +from ._interaction import ChartView +from .order_mode import start_order_mode from .. import fsp -from ..exchange._client import open_ems log = get_logger(__name__) @@ -1061,95 +1059,7 @@ async def _async_main( # chart, # linked_charts, # ) - - # spawn EMS actor-service - async with open_ems( - brokername, - symbol, - ) as (book, trades_stream): - - async with open_order_mode( - symbol, - chart, - book, - ) as order_mode: - - def get_index(time: float): - # XXX: not sure why the time is so off here - # looks like we're gonna have to do some fixing.. - ohlc = chart._shm.array - indexes = ohlc['time'] >= time - - if any(indexes): - return ohlc['index'][indexes[-1]] - else: - return ohlc['index'][-1] - - # Begin order-response streaming - - # this is where we receive **back** messages - # about executions **from** the EMS actor - async for msg in trades_stream: - - fmsg = pformat(msg) - log.info(f'Received order msg:\n{fmsg}') - - # delete the line from view - oid = msg['oid'] - resp = msg['resp'] - - # response to 'action' request (buy/sell) - if resp in ( - 'dark_submitted', - 'broker_submitted' - ): - - # show line label once order is live - order_mode.on_submit(oid) - - # resp to 'cancel' request or error condition - # for action request - elif resp in ( - 'broker_cancelled', - 'broker_inactive', - 'dark_cancelled' - ): - # delete level line from view - order_mode.on_cancel(oid) - - elif resp in ( - 'dark_executed' - ): - log.info(f'Dark order triggered for {fmsg}') - - # for alerts add a triangle and remove the - # level line - if msg['cmd']['action'] == 'alert': - - # should only be one "fill" for an alert - order_mode.on_fill( - oid, - price=msg['trigger_price'], - arrow_index=get_index(time.time()) - ) - await order_mode.on_exec(oid, msg) - - # response to completed 'action' request for buy/sell - elif resp in ( - 'broker_executed', - ): - await order_mode.on_exec(oid, msg) - - # each clearing tick is responded individually - elif resp in ('broker_filled',): - action = msg['action'] - # TODO: some kinda progress system - order_mode.on_fill( - oid, - price=msg['price'], - arrow_index=get_index(msg['broker_time']), - pointing='up' if action == 'buy' else 'down', - ) + await start_order_mode(chart, symbol, brokername) async def chart_from_quotes( diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 5bdd9fb7..16ae71f3 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -17,14 +17,9 @@ """ Chart view box primitives. """ -import time -from contextlib import asynccontextmanager from dataclasses import dataclass, field -from pprint import pformat -from typing import Optional, Dict, Callable, Any -import uuid +from typing import Optional, Dict -import trio import pyqtgraph as pg from pyqtgraph import ViewBox, Point, QtCore, QtGui from pyqtgraph import functions as fn @@ -33,8 +28,6 @@ import numpy as np from ..log import get_logger from ._style import _min_points_to_show, hcolor, _font from ._graphics._lines import order_line, LevelLine -from ..exchange._client import OrderBook -from ..data._source import Symbol log = get_logger(__name__) @@ -440,230 +433,6 @@ class ArrowEditor: self.chart.plotItem.removeItem(arrow) -@dataclass -class OrderMode: - """Major mode for placing orders on a chart view. - - This is the default mode that pairs with "follow mode" - (when wathing the rt price update at the current time step) - and allows entering orders using the ``a, d, f`` keys and - cancelling moused-over orders with the ``c`` key. - - """ - chart: 'ChartPlotWidget' # type: ignore # noqa - book: OrderBook - lines: LineEditor - arrows: ArrowEditor - _colors = { - 'alert': 'alert_yellow', - 'buy': 'buy_green', - 'sell': 'sell_red', - } - _action: str = 'alert' - _exec_mode: str = 'dark' - _size: float = 100.0 - - key_map: Dict[str, Callable] = field(default_factory=dict) - - def uuid(self) -> str: - return str(uuid.uuid4()) - - def set_exec( - self, - action: str, - size: Optional[int] = None, - ) -> None: - """Set execution mode. - - """ - self._action = action - self.lines.stage_line( - color=self._colors[action], - # hl_on_hover=True if self._exec_mode == 'live' else False, - dotted=True if self._exec_mode == 'dark' else False, - size=size or self._size, - ) - - def on_submit(self, uuid: str) -> dict: - """On order submitted event, commit the order line - and registered order uuid, store ack time stamp. - - TODO: annotate order line with submission type ('live' vs. - 'dark'). - - """ - line = self.lines.commit_line(uuid) - req_msg = self.book._sent_orders.get(uuid) - if req_msg: - req_msg['ack_time_ns'] = time.time_ns() - - return line - - def on_fill( - self, - uuid: str, - price: float, - arrow_index: float, - pointing: Optional[str] = None - ) -> None: - - line = self.lines._order_lines.get(uuid) - if line: - self.arrows.add( - uuid, - arrow_index, - price, - pointing=pointing, - color=line.color - ) - - async def on_exec( - self, - uuid: str, - msg: Dict[str, Any], - ) -> None: - - # only once all fills have cleared and the execution - # is complet do we remove our "order line" - line = self.lines.remove_line(uuid=uuid) - log.debug(f'deleting {line} with oid: {uuid}') - - # DESKTOP NOTIFICATIONS - # - # 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', - '-u', 'normal', - '-t', '10000', - 'piker', - f'alert: {msg}', - ], - ) - log.runtime(result) - - def on_cancel(self, uuid: str) -> None: - msg = self.book._sent_orders.pop(uuid, None) - if msg is not None: - self.lines.remove_line(uuid=uuid) - self.chart._cursor.show_xhair() - else: - log.warning( - f'Received cancel for unsubmitted order {pformat(msg)}' - ) - - def submit_exec( - self, - size: Optional[float] = None, - ) -> LevelLine: - """Send execution 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). - uid = str(uuid.uuid4()) - - size = size or self._size - - chart = self.chart._cursor.active_plot - y = chart._cursor._datum_xy[1] - - symbol = self.chart._lc._symbol - - # send order cmd to ems - self.book.send( - uuid=uid, - symbol=symbol.key, - brokers=symbol.brokers, - price=y, - size=size, - action=self._action, - exec_mode=self._exec_mode, - ) - - # make line graphic if order push was - # sucessful - line = self.lines.create_order_line( - uid, - level=y, - chart=chart, - size=size, - ) - line.oid = uid - - # hook up mouse drag handlers - line._on_drag_start = self.order_line_modify_start - line._on_drag_end = self.order_line_modify_complete - - return line - - def cancel_order_under_cursor(self) -> None: - for line in self.lines.lines_under_cursor(): - self.book.cancel(uuid=line.oid) - - # order-line modify handlers - def order_line_modify_start( - self, - line: LevelLine, - ) -> None: - print(f'Line modify: {line}') - # cancel original order until new position is found - - def order_line_modify_complete( - self, - line: LevelLine, - ) -> None: - self.book.update( - uuid=line.oid, - - # TODO: should we round this to a nearest tick here? - price=line.value(), - ) - - -@asynccontextmanager -async def open_order_mode( - symbol: Symbol, - chart: pg.PlotWidget, - book: OrderBook, -): - view = chart._vb - lines = LineEditor(view=view, chart=chart, _order_lines=_order_lines) - arrows = ArrowEditor(chart, {}) - - log.info("Opening order mode") - - mode = OrderMode(chart, book, lines, arrows) - view.mode = mode - - asset_type = symbol.type_key - - if asset_type == 'stock': - mode._size = 100.0 - - elif asset_type in ('future', 'option', 'futures_option'): - mode._size = 1.0 - - else: # to be safe - mode._size = 1.0 - - 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: diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py new file mode 100644 index 00000000..5ed163ce --- /dev/null +++ b/piker/ui/order_mode.py @@ -0,0 +1,357 @@ +# piker: trading gear for hackers +# Copyright (C) Tyler Goodlet (in stewardship for piker0) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +""" +Chart trading at it's finest. + +""" +from contextlib import asynccontextmanager +from dataclasses import dataclass, field +from pprint import pformat +import time +from typing import Optional, Dict, Callable, Any +import uuid + +import pyqtgraph as pg +import trio + +from ._graphics._lines import LevelLine +from ._interaction import LineEditor, ArrowEditor, _order_lines +from ..exchange._client import open_ems, OrderBook +from ..data._source import Symbol +from ..log import get_logger + + +log = get_logger(__name__) + + +@dataclass +class OrderMode: + """Major mode for placing orders on a chart view. + + This is the default mode that pairs with "follow mode" + (when wathing the rt price update at the current time step) + and allows entering orders using the ``a, d, f`` keys and + cancelling moused-over orders with the ``c`` key. + + """ + chart: 'ChartPlotWidget' # type: ignore # noqa + book: OrderBook + lines: LineEditor + arrows: ArrowEditor + _colors = { + 'alert': 'alert_yellow', + 'buy': 'buy_green', + 'sell': 'sell_red', + } + _action: str = 'alert' + _exec_mode: str = 'dark' + _size: float = 100.0 + + key_map: Dict[str, Callable] = field(default_factory=dict) + + def uuid(self) -> str: + return str(uuid.uuid4()) + + def set_exec( + self, + action: str, + size: Optional[int] = None, + ) -> None: + """Set execution mode. + + """ + self._action = action + self.lines.stage_line( + color=self._colors[action], + # hl_on_hover=True if self._exec_mode == 'live' else False, + dotted=True if self._exec_mode == 'dark' else False, + size=size or self._size, + ) + + def on_submit(self, uuid: str) -> dict: + """On order submitted event, commit the order line + and registered order uuid, store ack time stamp. + + TODO: annotate order line with submission type ('live' vs. + 'dark'). + + """ + line = self.lines.commit_line(uuid) + req_msg = self.book._sent_orders.get(uuid) + if req_msg: + req_msg['ack_time_ns'] = time.time_ns() + + return line + + def on_fill( + self, + uuid: str, + price: float, + arrow_index: float, + pointing: Optional[str] = None + ) -> None: + + line = self.lines._order_lines.get(uuid) + if line: + self.arrows.add( + uuid, + arrow_index, + price, + pointing=pointing, + color=line.color + ) + + async def on_exec( + self, + uuid: str, + msg: Dict[str, Any], + ) -> None: + + # only once all fills have cleared and the execution + # is complet do we remove our "order line" + line = self.lines.remove_line(uuid=uuid) + log.debug(f'deleting {line} with oid: {uuid}') + + # DESKTOP NOTIFICATIONS + # + # 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', + '-u', 'normal', + '-t', '10000', + 'piker', + f'alert: {msg}', + ], + ) + log.runtime(result) + + def on_cancel(self, uuid: str) -> None: + msg = self.book._sent_orders.pop(uuid, None) + if msg is not None: + self.lines.remove_line(uuid=uuid) + self.chart._cursor.show_xhair() + else: + log.warning( + f'Received cancel for unsubmitted order {pformat(msg)}' + ) + + def submit_exec( + self, + size: Optional[float] = None, + ) -> LevelLine: + """Send execution 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). + uid = str(uuid.uuid4()) + + size = size or self._size + + chart = self.chart._cursor.active_plot + y = chart._cursor._datum_xy[1] + + symbol = self.chart._lc._symbol + + # send order cmd to ems + self.book.send( + uuid=uid, + symbol=symbol.key, + brokers=symbol.brokers, + price=y, + size=size, + action=self._action, + exec_mode=self._exec_mode, + ) + + # make line graphic if order push was + # sucessful + line = self.lines.create_order_line( + uid, + level=y, + chart=chart, + size=size, + ) + line.oid = uid + + # hook up mouse drag handlers + line._on_drag_start = self.order_line_modify_start + line._on_drag_end = self.order_line_modify_complete + + return line + + def cancel_order_under_cursor(self) -> None: + for line in self.lines.lines_under_cursor(): + self.book.cancel(uuid=line.oid) + + # order-line modify handlers + def order_line_modify_start( + self, + line: LevelLine, + ) -> None: + print(f'Line modify: {line}') + # cancel original order until new position is found + + def order_line_modify_complete( + self, + line: LevelLine, + ) -> None: + self.book.update( + uuid=line.oid, + + # TODO: should we round this to a nearest tick here? + price=line.value(), + ) + + +@asynccontextmanager +async def open_order_mode( + symbol: Symbol, + chart: pg.PlotWidget, + book: OrderBook, +): + view = chart._vb + lines = LineEditor(view=view, chart=chart, _order_lines=_order_lines) + arrows = ArrowEditor(chart, {}) + + log.info("Opening order mode") + + mode = OrderMode(chart, book, lines, arrows) + view.mode = mode + + asset_type = symbol.type_key + + if asset_type == 'stock': + mode._size = 100.0 + + elif asset_type in ('future', 'option', 'futures_option'): + mode._size = 1.0 + + else: # to be safe + mode._size = 1.0 + + 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") + + +async def start_order_mode( + chart: 'ChartPlotWidget', # noqa + symbol: Symbol, + brokername: str, +) -> None: + # spawn EMS actor-service + async with open_ems( + brokername, + symbol, + ) as (book, trades_stream): + + async with open_order_mode( + symbol, + chart, + book, + ) as order_mode: + + def get_index(time: float): + # XXX: not sure why the time is so off here + # looks like we're gonna have to do some fixing.. + ohlc = chart._shm.array + indexes = ohlc['time'] >= time + + if any(indexes): + return ohlc['index'][indexes[-1]] + else: + return ohlc['index'][-1] + + # Begin order-response streaming + + # this is where we receive **back** messages + # about executions **from** the EMS actor + async for msg in trades_stream: + + fmsg = pformat(msg) + log.info(f'Received order msg:\n{fmsg}') + + # delete the line from view + oid = msg['oid'] + resp = msg['resp'] + + # response to 'action' request (buy/sell) + if resp in ( + 'dark_submitted', + 'broker_submitted' + ): + + # show line label once order is live + order_mode.on_submit(oid) + + # resp to 'cancel' request or error condition + # for action request + elif resp in ( + 'broker_cancelled', + 'broker_inactive', + 'dark_cancelled' + ): + # delete level line from view + order_mode.on_cancel(oid) + + elif resp in ( + 'dark_executed' + ): + log.info(f'Dark order triggered for {fmsg}') + + # for alerts add a triangle and remove the + # level line + if msg['cmd']['action'] == 'alert': + + # should only be one "fill" for an alert + order_mode.on_fill( + oid, + price=msg['trigger_price'], + arrow_index=get_index(time.time()) + ) + await order_mode.on_exec(oid, msg) + + # response to completed 'action' request for buy/sell + elif resp in ( + 'broker_executed', + ): + await order_mode.on_exec(oid, msg) + + # each clearing tick is responded individually + elif resp in ('broker_filled',): + action = msg['action'] + # TODO: some kinda progress system + order_mode.on_fill( + oid, + price=msg['price'], + arrow_index=get_index(msg['broker_time']), + pointing='up' if action == 'buy' else 'down', + )