piker/piker/ui/order_mode.py

879 lines
24 KiB
Python
Raw Normal View History

2021-03-08 14:05:37 +00:00
# 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.
2021-03-08 14:05:37 +00:00
"""
from contextlib import asynccontextmanager
2021-03-08 14:05:37 +00:00
from dataclasses import dataclass, field
2021-08-23 18:48:20 +00:00
from functools import partial
2021-03-08 14:05:37 +00:00
from pprint import pformat
import time
from typing import Optional, Dict, Callable, Any
import uuid
from bidict import bidict
from pydantic import BaseModel
import tractor
import trio
2021-03-08 14:05:37 +00:00
from .. import config
2021-03-31 18:56:20 +00:00
from ..clearing._client import open_ems, OrderBook
from ..clearing._allocate import (
mk_allocator,
Position,
)
2021-03-08 14:05:37 +00:00
from ..data._source import Symbol
from ..data.feed import Feed
2021-03-08 14:05:37 +00:00
from ..log import get_logger
from ._editors import LineEditor, ArrowEditor
from ._lines import order_line, LevelLine
from ._position import (
PositionTracker,
SettingsPane,
)
from ._window import MultiStatus
from ..clearing._messages import Order
2021-08-23 18:48:20 +00:00
from ._forms import open_form_input_handling
2021-03-08 14:05:37 +00:00
log = get_logger(__name__)
class OrderDialog(BaseModel):
'''Trade dialogue meta-data describing the lifetime
of an order submission to ``emsd`` from a chart.
'''
2021-08-09 15:21:05 +00:00
# TODO: use ``pydantic.UUID4`` field
uuid: str
order: Order
symbol: Symbol
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
def on_level_change_update_next_order_info(
level: float,
# these are all ``partial``-ed in at callback assignment time.
line: LevelLine,
order: Order,
tracker: PositionTracker,
) -> None:
'''A callback applied for each level change to the line
which will recompute the order size based on allocator
settings. this is assigned inside
``OrderMode.line_from_order()``
'''
# NOTE: the ``Order.account`` is set at order stage time
# inside ``OrderMode.line_from_order()``.
order_info = tracker.alloc.next_order_info(
startup_pp=tracker.startup_pp,
live_pp=tracker.live_pp,
price=level,
action=order.action,
)
line.update_labels(order_info)
# update bound-in staged order
order.price = level
order.size = order_info['size']
2021-03-08 14:05:37 +00:00
@dataclass
class OrderMode:
2021-08-09 15:21:05 +00:00
'''Major UX mode for placing orders on a chart view providing so
called, "chart trading".
2021-03-08 14:05:37 +00:00
2021-08-09 15:21:05 +00:00
This is the other "main" mode that pairs with "view 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.
2021-03-08 14:05:37 +00:00
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
'''
2021-03-08 14:05:37 +00:00
chart: 'ChartPlotWidget' # type: ignore # noqa
nursery: trio.Nursery
quote_feed: Feed
2021-03-08 14:05:37 +00:00
book: OrderBook
lines: LineEditor
arrows: ArrowEditor
multistatus: MultiStatus
pane: SettingsPane
trackers: dict[str, PositionTracker]
2021-08-23 18:48:20 +00:00
# switched state, the current position
current_pp: Optional[PositionTracker] = None
active: bool = False
name: str = 'order'
dialogs: dict[str, OrderDialog] = field(default_factory=dict)
2021-03-08 14:05:37 +00:00
_colors = {
'alert': 'alert_yellow',
'buy': 'buy_green',
'sell': 'sell_red',
}
_staged_order: Optional[Order] = None
2021-03-08 14:05:37 +00:00
def line_from_order(
self,
order: Order,
symbol: Symbol,
**line_kwargs,
) -> LevelLine:
level = order.price
line = order_line(
self.chart,
# TODO: convert these values into human-readable form
# (i.e. with k, m, M, B) type embedded suffixes
level=level,
action=order.action,
size=order.size,
color=self._colors[order.action],
dotted=True if (
order.exec_mode == 'dark' and
order.action != 'alert'
) else False,
**line_kwargs,
)
2021-08-23 18:48:20 +00:00
# set level update callback to order pane method and update once
# immediately
if order.action != 'alert':
line._on_level_change = partial(
on_level_change_update_next_order_info,
line=line,
order=order,
tracker=self.current_pp,
)
else:
# for alerts we don't need to compute per price sizing via
# the order mode allocator but we still need to update the
# "staged" order message we'll send to the ems
def update_order_price(y: float) -> None:
order.price = y
line._on_level_change = update_order_price
line.set_level(level)
return line
def stage_order(
2021-03-08 14:05:37 +00:00
self,
2021-08-09 15:21:05 +00:00
2021-03-08 14:05:37 +00:00
action: str,
trigger_type: str,
2021-08-09 15:21:05 +00:00
2021-03-08 14:05:37 +00:00
) -> None:
'''Stage an order for submission.
2021-03-08 14:05:37 +00:00
2021-08-09 15:21:05 +00:00
'''
# not initialized yet
chart = self.chart
cursor = chart.linked.cursor
if not (chart and cursor and cursor.active_plot):
return
chart = cursor.active_plot
price = cursor._datum_xy[1]
symbol = self.chart.linked.symbol
2021-03-14 00:31:03 +00:00
order = self._staged_order = Order(
2021-03-14 00:31:03 +00:00
action=action,
price=price,
2021-09-14 17:10:39 +00:00
account=self.current_pp.alloc.account,
size=0,
symbol=symbol,
brokers=symbol.brokers,
oid='', # filled in on submit
exec_mode=trigger_type, # dark or live
)
line = self.line_from_order(
order,
symbol,
show_markers=True,
# just for the stage line to avoid
# flickering while moving the cursor
# around where it might trigger highlight
# then non-highlight depending on sensitivity
always_show_labels=True,
# don't highlight the "staging" line
highlight_on_hover=False,
# prevent flickering of marker while moving/tracking cursor
only_show_markers_on_hover=False,
)
line = self.lines.stage_line(line)
# hide crosshair y-line and label
cursor.hide_xhair()
# add line to cursor trackers
cursor._trackers.add(line)
return line
def submit_order(
self,
) -> OrderDialog:
'''Send execution order to EMS return a level line to
represent the order on a chart.
'''
staged = self._staged_order
symbol = staged.symbol
oid = str(uuid.uuid4())
# format order data for ems
order = staged.copy(
update={
'symbol': symbol.key,
'oid': oid,
}
)
line = self.line_from_order(
order,
symbol,
show_markers=True,
only_show_markers_on_hover=True,
)
# register the "submitted" line under the cursor
# to be displayed when above order ack arrives
# (means the marker graphic doesn't show on screen until the
# order is live in the emsd).
# TODO: update the line once an ack event comes back
# from the EMS!
# maybe place a grey line in "submission" mode
# which will be updated to it's appropriate action
# color once the submission ack arrives.
self.lines.submit_line(
line=line,
uuid=oid,
2021-03-08 14:05:37 +00:00
)
dialog = OrderDialog(
uuid=oid,
order=order,
symbol=symbol,
line=line,
last_status_close=self.multistatus.open_status(
f'submitting {self._trigger_type}-{order.action}',
final_msg=f'submitted {self._trigger_type}-{order.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(order)
return dialog
# 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?
# TODO: make a config option for this behaviour..
def order_line_modify_complete(
self,
line: LevelLine,
) -> None:
level = line.value()
# updateb by level change callback set in ``.line_from_order()``
size = line.dialog.order.size
self.book.update(
uuid=line.dialog.uuid,
price=level,
size=size,
)
# ems response loop handlers
2021-08-09 15:21:05 +00:00
def on_submit(
self,
uuid: str
) -> OrderDialog:
'''
Order submitted status event handler.
2021-03-08 14:05:37 +00:00
Commit the order line and registered order uuid, store ack time stamp.
2021-03-08 14:05:37 +00:00
'''
2021-03-08 14:05:37 +00:00
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
2021-03-08 14:05:37 +00:00
def on_fill(
self,
2021-08-09 15:21:05 +00:00
2021-03-08 14:05:37 +00:00
uuid: str,
price: float,
arrow_index: float,
2021-08-09 15:21:05 +00:00
pointing: Optional[str] = None,
2021-03-08 14:05:37 +00:00
) -> None:
2021-08-09 15:21:05 +00:00
'''
Fill msg handler.
2021-03-08 14:05:37 +00:00
2021-08-09 15:21:05 +00:00
Triggered on reception of a `filled` message from the
EMS.
Update relevant UIs:
- add arrow annotation on bar
- update fill bar size
'''
dialog = self.dialogs[uuid]
line = dialog.line
2021-03-08 14:05:37 +00:00
if line:
self.arrows.add(
uuid,
arrow_index,
price,
pointing=pointing,
color=line.color
)
else:
log.warn("No line for order {uuid}!?")
2021-03-08 14:05:37 +00:00
async def on_exec(
self,
2021-08-09 15:21:05 +00:00
2021-03-08 14:05:37 +00:00
uuid: str,
msg: Dict[str, Any],
2021-08-09 15:21:05 +00:00
2021-03-08 14:05:37 +00:00
) -> 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?
2021-08-09 15:21:05 +00:00
# TODO: make this not trash.
2021-03-08 14:05:37 +00:00
# XXX: linux only for now
result = await trio.run_process(
[
'notify-send',
'-u', 'normal',
'-t', '10000',
'piker',
f'alert: {msg}',
],
)
log.runtime(result)
2021-08-09 15:21:05 +00:00
def on_cancel(
self,
uuid: str
) -> None:
2021-03-08 14:05:37 +00:00
msg = self.book._sent_orders.pop(uuid, None)
2021-03-08 14:05:37 +00:00
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()
2021-03-08 14:05:37 +00:00
else:
log.warning(
f'Received cancel for unsubmitted order {pformat(msg)}'
)
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.multistatus.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.multistatus.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
2021-03-08 14:05:37 +00:00
@asynccontextmanager
async def open_order_mode(
feed: Feed,
2021-03-08 14:05:37 +00:00
chart: 'ChartPlotWidget', # noqa
symbol: Symbol,
brokername: str,
started: trio.Event,
2021-03-08 14:05:37 +00:00
) -> None:
'''Activate chart-trader order mode loop:
2021-08-09 15:21:05 +00:00
- connect to emsd
- load existing positions
2021-08-09 15:21:05 +00:00
- begin EMS response handling loop which updates local
state, mostly graphics / UI.
'''
multistatus = chart.window().status_bar
done = multistatus.open_status('starting order mode..')
book: OrderBook
trades_stream: tractor.MsgStream
position_msgs: dict
2021-03-08 14:05:37 +00:00
# spawn EMS actor-service
async with (
open_ems(brokername, symbol) as (
book,
trades_stream,
position_msgs,
brokerd_accounts,
),
trio.open_nursery() as tn,
):
2021-08-23 18:48:20 +00:00
log.info(f'Opening order mode for {brokername}.{symbol.key}')
2021-08-10 21:02:17 +00:00
view = chart.view
2021-08-23 18:48:20 +00:00
# annotations editors
lines = LineEditor(chart=chart)
arrows = ArrowEditor(chart, {})
# symbol id
symbol = chart.linked.symbol
symkey = symbol.key
# map of per-provider account keys to position tracker instances
trackers: dict[str, PositionTracker] = {}
# load account names from ``brokers.toml``
accounts_def = config.load_accounts(
providers=symbol.brokers
)
# use only loaded accounts according to brokerd
accounts = bidict({})
for name in brokerd_accounts:
accounts[name] = accounts_def[name]
if accounts:
# first account listed is the one we select at startup
# (aka order based selection).
pp_account = next(iter(accounts.keys()))
else:
pp_account = 'paper'
# NOTE: requires the backend exactly specifies
# the expected symbol key in its positions msg.
pp_msgs = position_msgs.get(symkey, ())
# update all pp trackers with existing data relayed
# from ``brokerd``.
for msg in pp_msgs:
log.info(f'Loading pp for {symkey}:\n{pformat(msg)}')
account_name = msg.get('account')
account_value = accounts.get(account_name)
if not account_name and account_value == 'paper':
account_name = 'paper'
# net-zero pp
startup_pp = Position(
symbol=symbol,
size=0,
avg_price=0,
)
startup_pp.update_from_msg(msg)
# allocator
alloc = mk_allocator(
symbol=symbol,
account=account_name,
# if this startup size is greater the allocator limit,
# the limit is increased internally in this factory.
startup_pp=startup_pp,
)
pp_tracker = PositionTracker(
chart,
alloc,
startup_pp
)
pp_tracker.hide()
trackers[account_name] = pp_tracker
assert pp_tracker.startup_pp.size == pp_tracker.live_pp.size
# TODO: do we even really need the "startup pp" or can we
# just take the max and pass that into the some state / the
# alloc?
pp_tracker.update_from_pp()
if pp_tracker.startup_pp.size != 0:
# if no position, don't show pp tracking graphics
pp_tracker.show()
pp_tracker.hide_info()
# fill out trackers for accounts with net-zero pps
zero_pp_accounts = set(accounts) - set(trackers)
for account_name in zero_pp_accounts:
startup_pp = Position(
symbol=symbol,
size=0,
avg_price=0,
)
# allocator
alloc = mk_allocator(
symbol=symbol,
account=account_name,
startup_pp=startup_pp,
)
pp_tracker = PositionTracker(
chart,
alloc,
startup_pp
)
pp_tracker.hide()
trackers[account_name] = pp_tracker
2021-08-23 18:48:20 +00:00
2021-09-12 23:36:14 +00:00
# setup order mode sidepane widgets
form = chart.sidepane
order_pane = SettingsPane(
2021-08-23 18:48:20 +00:00
form=form,
# XXX: ugh, so hideous...
2021-08-23 18:48:20 +00:00
fill_bar=form.fill_bar,
pnl_label=form.left_label,
step_label=form.bottom_label,
limit_label=form.top_label,
)
order_pane.set_accounts(list(trackers.keys()))
# update pp icons
2021-09-12 23:36:14 +00:00
for name, tracker in trackers.items():
order_pane.update_account_icons({name: tracker.live_pp})
# top level abstraction which wraps all this crazyness into
# a namespace..
mode = OrderMode(
chart,
tn,
feed,
book,
lines,
arrows,
multistatus,
pane=order_pane,
trackers=trackers,
)
# XXX: MUST be set
order_pane.order_mode = mode
# select a pp to track
tracker = trackers[pp_account]
mode.current_pp = tracker
tracker.show()
tracker.hide_info()
2021-08-23 18:48:20 +00:00
# XXX: would love to not have to do this separate from edit
# fields (which are done in an async loop - see below)
# connect selection signals (from drop down widgets)
# to order sync pane handler
for key in ('account', 'size_unit',):
2021-08-23 18:48:20 +00:00
w = form.fields[key]
w.currentTextChanged.connect(
partial(
order_pane.on_selection_change,
2021-08-23 18:48:20 +00:00
key=key,
)
)
# make fill bar and positioning snapshot
order_pane.on_ui_settings_change('limit', tracker.alloc.limit())
order_pane.update_status_ui(pp=tracker)
2021-08-10 21:02:17 +00:00
# TODO: create a mode "manager" of sorts?
# -> probably just call it "UxModes" err sumthin?
# so that view handlers can access it
view.order_mode = mode
order_pane.on_ui_settings_change('account', pp_account)
mode.pane.display_pnl(mode.current_pp)
2021-04-05 12:09:26 +00:00
# Begin order-response streaming
done()
# start async input handling for chart's view
async with (
# ``ChartView`` input async handler startup
chart.view.open_async_input_handler(),
2021-08-23 18:48:20 +00:00
# pp pane kb inputs
open_form_input_handling(
form,
focus_next=chart.linked.godwidget,
on_value_change=order_pane.on_ui_settings_change,
2021-08-23 18:48:20 +00:00
),
):
# signal to top level symbol loading task we're ready
# to handle input since the ems connection is ready
started.set()
2021-06-10 16:02:57 +00:00
tn.start_soon(
process_trades_and_update_ui,
tn,
feed,
mode,
trades_stream,
book,
)
yield mode
async def process_trades_and_update_ui(
n: trio.Nursery,
feed: Feed,
mode: OrderMode,
trades_stream: tractor.MsgStream,
book: OrderBook,
) -> None:
get_index = mode.chart.get_index
2021-09-07 16:54:32 +00:00
global _pnl_tasks
# 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',
):
sym = mode.chart.linked.symbol
if msg['symbol'].lower() in sym.key:
2021-09-07 16:54:32 +00:00
tracker = mode.trackers[msg['account']]
tracker.live_pp.update_from_msg(msg)
tracker.update_from_pp()
# update order pane widgets
mode.pane.update_status_ui(tracker)
2021-09-13 11:42:28 +00:00
# display pnl
mode.pane.display_pnl(tracker)
# short circuit to next msg to avoid
# unnecessary msg content lookups
continue
resp = msg['resp']
oid = msg['oid']
dialog = mode.dialogs.get(oid)
if dialog is None:
log.warning(f'received msg for untracked dialog:\n{fmsg}')
# TODO: enable pure tracking / mirroring of dialogs
# is desired.
continue
# 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',
'broker_errored',
'dark_cancelled'
):
# delete level line from view
mode.on_cancel(oid)
broker_msg = msg['brokerd_msg']
log.warning(f'Order {oid} failed with:\n{pformat(broker_msg)}')
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']),
)
# TODO: how should we look this up?
# tracker = mode.trackers[msg['account']]
# tracker.live_pp.fills.append(msg)