piker/piker/ui/order_mode.py

597 lines
16 KiB
Python

# piker: trading gear for hackers
# Copyright (C) Tyler Goodlet (in stewardship for piker0)
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
Chart trading, the only way to scalp.
"""
from dataclasses import dataclass, field
from pprint import pformat
import time
from typing import Optional, Dict, Callable, Any
import uuid
from pydantic import BaseModel
import tractor
import trio
from ..clearing._client import open_ems, OrderBook
from ..data._source import Symbol
from ..log import get_logger
from ._editors import LineEditor, ArrowEditor
from ._lines import LevelLine
from ._position import PositionInfo
from ._window import MultiStatus, main_window
log = get_logger(__name__)
class OrderDialog(BaseModel):
'''Trade dialogue meta-data describing the lifetime
of an order submission to ``emsd`` from a chart.
'''
uuid: str
line: LevelLine
last_status_close: Callable = lambda: None
msgs: dict[str, dict] = {}
fills: Dict[str, Any] = {}
class Config:
arbitrary_types_allowed = True
underscore_attrs_are_private = False
@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 mouse and keyboard.
This object is chart oriented, so there is an instance per
chart / view currently.
Current manual:
a -> alert
s/ctrl -> submission type modifier {on: live, off: dark}
f (fill) -> 'buy' limit order
d (dump) -> 'sell' limit order
c (cancel) -> cancel order under cursor
cc -> cancel all submitted orders on chart
mouse click and drag -> modify current order under cursor
'''
chart: 'ChartPlotWidget' # type: ignore # noqa
book: OrderBook
lines: LineEditor
arrows: ArrowEditor
status_bar: MultiStatus
# pp status info
# label: Label
name: str = 'order'
_colors = {
'alert': 'alert_yellow',
'buy': 'buy_green',
'sell': 'sell_red',
}
_action: str = 'alert'
_exec_mode: str = 'dark'
_size: float = 100.0
_position: Dict[str, Any] = field(default_factory=dict)
# _position_line: dict = None
dialogs: dict[str, OrderDialog] = field(default_factory=dict)
# def on_position_update(
# self,
# size: float,
# price: float,
# ) -> None:
# line = self._position_line
# if line is None and size:
# # create and show a pp line
# line = self._position_line = position_line(
# self.chart,
# level=price,
# size=size,
# )
# line.show()
# elif line:
# if size != 0.0:
# line.set_level(price)
# line.update_labels({'size': size})
# line.show()
# else:
# # remove pp line from view
# line.delete()
# self._position_line = None
def uuid(self) -> str:
return str(uuid.uuid4())
def set_exec(
self,
action: str,
size: Optional[int] = None,
) -> None:
"""Set execution mode.
"""
# not initialized yet
if not self.chart.linked.cursor:
return
self._action = action
self.lines.stage_line(
color=self._colors[action],
# hl_on_hover=True if self._exec_mode == 'live' else False,
dotted=True if (
self._exec_mode == 'dark' and action != 'alert'
) else False,
size=size or self._size,
action=action,
)
def on_submit(self, uuid: str) -> OrderDialog:
'''Order submitted status event handler.
Commit the order line and registered order uuid, store ack time stamp.
'''
line = self.lines.commit_line(uuid)
# a submission is the start of a new order dialog
dialog = self.dialogs[uuid]
dialog.line = line
dialog.last_status_close()
return dialog
def on_fill(
self,
uuid: str,
price: float,
arrow_index: float,
pointing: Optional[str] = None,
# delete_line: bool = False,
) -> None:
dialog = self.dialogs[uuid]
line = dialog.line
if line:
self.arrows.add(
uuid,
arrow_index,
price,
pointing=pointing,
color=line.color
)
else:
log.warn("No line for order {uuid}!?")
async def on_exec(
self,
uuid: str,
msg: Dict[str, Any],
) -> None:
# 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.linked.cursor.show_xhair()
dialog = self.dialogs.pop(uuid, None)
if dialog:
dialog.last_status_close()
else:
log.warning(
f'Received cancel for unsubmitted order {pformat(msg)}'
)
def submit_exec(
self,
size: Optional[float] = None,
) -> OrderDialog:
"""Send execution order to EMS return a level line to
represent the order on a chart.
"""
# 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).
oid = str(uuid.uuid4())
size = size or self._size
cursor = self.chart.linked.cursor
chart = cursor.active_plot
y = cursor._datum_xy[1]
symbol = self.chart._lc._symbol
action = self._action
# TODO: update the line once an ack event comes back
# from the EMS!
# TODO: place a grey line in "submission" mode
# which will be updated to it's appropriate action
# color once the submission ack arrives.
# make line graphic if order push was sucessful
line = self.lines.create_order_line(
oid,
level=y,
chart=chart,
size=size,
action=action,
)
dialog = OrderDialog(
uuid=oid,
line=line,
last_status_close=self.status_bar.open_status(
f'submitting {self._exec_mode}-{action}',
final_msg=f'submitted {self._exec_mode}-{action}',
clear_on_next=True,
)
)
# TODO: create a new ``OrderLine`` with this optional var defined
line.dialog = dialog
# enter submission which will be popped once a response
# from the EMS is received to move the order to a different# status
self.dialogs[oid] = dialog
# hook up mouse drag handlers
line._on_drag_start = self.order_line_modify_start
line._on_drag_end = self.order_line_modify_complete
# send order cmd to ems
self.book.send(
uuid=oid,
symbol=symbol.key,
brokers=symbol.brokers,
price=y,
size=size,
action=action,
exec_mode=self._exec_mode,
)
return dialog
def cancel_orders_under_cursor(self) -> list[str]:
return self.cancel_orders_from_lines(
self.lines.lines_under_cursor()
)
def cancel_all_orders(self) -> list[str]:
'''Cancel all orders for the current chart.
'''
return self.cancel_orders_from_lines(
self.lines.all_lines()
)
def cancel_orders_from_lines(
self,
lines: list[LevelLine],
) -> list[str]:
ids: list = []
if lines:
key = self.status_bar.open_status(
f'cancelling {len(lines)} orders',
final_msg=f'cancelled {len(lines)} orders',
group_key=True
)
# cancel all active orders and triggers
for line in lines:
dialog = getattr(line, 'dialog', None)
if dialog:
oid = dialog.uuid
cancel_status_close = self.status_bar.open_status(
f'cancelling order {oid[:6]}',
group_key=key,
)
dialog.last_status_close = cancel_status_close
ids.append(oid)
self.book.cancel(uuid=oid)
return ids
# 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.dialog.uuid,
# TODO: should we round this to a nearest tick here?
price=line.value(),
)
async def run_order_mode(
chart: 'ChartPlotWidget', # noqa
symbol: Symbol,
brokername: str,
started: trio.Event,
) -> None:
'''Activate chart-trader order mode loop:
- connect to emsd
- load existing positions
- begin order handling loop
'''
done = chart.window().status_bar.open_status('starting order mode..')
book: OrderBook
trades_stream: tractor.MsgStream
positions: dict
# spawn EMS actor-service
async with (
open_ems(brokername, symbol) as (
book,
trades_stream,
positions
),
# # start async input handling for chart's view
# # await godwidget._task_stack.enter_async_context(
# chart._vb.open_async_input_handler(),
):
status_bar: MultiStatus = main_window().status_bar
view = chart._vb
lines = LineEditor(chart=chart)
arrows = ArrowEditor(chart, {})
log.info("Opening order mode")
pp = PositionInfo(chart)
mode = OrderMode(
chart,
book,
lines,
arrows,
status_bar,
)
mode.pp = pp
view.mode = mode
asset_type = symbol.type_key
# default entry sizing
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
# update any exising position
for sym, msg in positions.items():
our_sym = mode.chart._lc._symbol.key
if sym.lower() in our_sym:
pp.update(
avg_price=msg['avg_price'],
size=msg['size'],
)
# mode._position.update(msg)
# size = msg['size']
# price = msg['avg_price']
# pp_label.fields['entry_size'] = size
# pp_label.render()
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
done()
# start async input handling for chart's view
async with chart._vb.open_async_input_handler():
# signal to top level symbol loading task we're ready
# to handle input since the ems connection is ready
started.set()
# 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}')
name = msg['name']
if name in (
'position',
):
# show line label once order is live
sym = mode.chart._lc._symbol
if msg['symbol'].lower() in sym.key:
pp.update(
avg_price=msg['avg_price'],
size=msg['size'],
)
# mode._position.update(msg)
# size = msg['size']
# price = msg['avg_price']
# pp.update(size, price)
# pp_label.fields['entry_size'] = size
# pp_label.render()
# short circuit to next msg to avoid
# uncessary msg content lookups
continue
resp = msg['resp']
oid = msg['oid']
dialog = mode.dialogs[oid]
# record message to dialog tracking
dialog.msgs[oid] = msg
# response to 'action' request (buy/sell)
if resp in (
'dark_submitted',
'broker_submitted'
):
# show line label once order is live
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
mode.on_cancel(oid)
elif resp in (
'dark_triggered'
):
log.info(f'Dark order triggered for {fmsg}')
elif resp in (
'alert_triggered'
):
# should only be one "fill" for an alert
# add a triangle and remove the level line
mode.on_fill(
oid,
price=msg['trigger_price'],
arrow_index=get_index(time.time()),
)
mode.lines.remove_line(uuid=oid)
await mode.on_exec(oid, msg)
# response to completed 'action' request for buy/sell
elif resp in (
'broker_executed',
):
# right now this is just triggering a system alert
await mode.on_exec(oid, msg)
if msg['brokerd_msg']['remaining'] == 0:
mode.lines.remove_line(uuid=oid)
# each clearing tick is responded individually
elif resp in ('broker_filled',):
known_order = book._sent_orders.get(oid)
if not known_order:
log.warning(f'order {oid} is unknown')
continue
action = known_order.action
details = msg['brokerd_msg']
# TODO: some kinda progress system
mode.on_fill(
oid,
price=details['price'],
pointing='up' if action == 'buy' else 'down',
# TODO: put the actual exchange timestamp
arrow_index=get_index(details['broker_time']),
)