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.basic_alerts
parent
8d66a17daf
commit
282cc85ba0
275
piker/_ems.py
275
piker/_ems.py
|
@ -20,12 +20,11 @@ In suit parlance: "Execution management systems"
|
||||||
"""
|
"""
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import (
|
from typing import (
|
||||||
AsyncIterator, List, Dict, Callable, Tuple,
|
AsyncIterator, Dict, Callable, Tuple,
|
||||||
Any,
|
Any,
|
||||||
)
|
)
|
||||||
# import uuid
|
# import uuid
|
||||||
|
|
||||||
import pyqtgraph as pg
|
|
||||||
import trio
|
import trio
|
||||||
from trio_typing import TaskStatus
|
from trio_typing import TaskStatus
|
||||||
import tractor
|
import tractor
|
||||||
|
@ -33,32 +32,59 @@ import tractor
|
||||||
from . import data
|
from . import data
|
||||||
from .log import get_logger
|
from .log import get_logger
|
||||||
from .data._source import Symbol
|
from .data._source import Symbol
|
||||||
from .ui._style import hcolor
|
|
||||||
|
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
_to_router: trio.abc.SendChannel = None
|
# setup local ui event streaming channels for request/resp
|
||||||
_from_ui: trio.abc.ReceiveChannel = None
|
# streamging with EMS daemon
|
||||||
_lines = {}
|
_to_ems, _from_order_book = trio.open_memory_channel(100)
|
||||||
|
|
||||||
|
|
||||||
_local_book = {}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class OrderBoi:
|
class OrderBook:
|
||||||
"""'Buy' (client ?) side order book ctl and tracking.
|
"""Buy-side (client-side ?) order book ctl and tracking.
|
||||||
|
|
||||||
Mostly for keeping local state to match the EMS and use
|
A style similar to "model-view" is used here where this api is
|
||||||
events to trigger graphics updates.
|
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)
|
_sent_orders: Dict[str, dict] = field(default_factory=dict)
|
||||||
_cmds_from_ui: trio.abc.ReceiveChannel = _from_ui
|
_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:
|
async def buy(self, price: float) -> str:
|
||||||
...
|
...
|
||||||
|
@ -66,21 +92,34 @@ class OrderBoi:
|
||||||
async def sell(self, price: float) -> str:
|
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 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
|
global _orders
|
||||||
|
|
||||||
if _orders is None:
|
if _orders is None:
|
||||||
_orders = OrderBoi
|
_orders = OrderBook()
|
||||||
|
|
||||||
return _orders
|
return _orders
|
||||||
|
|
||||||
|
@ -91,48 +130,44 @@ async def send_order_cmds():
|
||||||
to downstream consumers.
|
to downstream consumers.
|
||||||
|
|
||||||
This is run in the UI actor (usually the one running Qt).
|
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
|
send channel (from sync code using ``.send_nowait()``), these values
|
||||||
are pulled from the channel here and send to any consumer(s).
|
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']
|
async for cmd in _from_order_book:
|
||||||
symbol = lc.symbol
|
|
||||||
tp = order['type']
|
# send msg over IPC / wire
|
||||||
price = order['price']
|
log.info(f'sending order cmd: {cmd}')
|
||||||
oid = order['oid']
|
yield cmd
|
||||||
|
|
||||||
|
# lc = order['chart']
|
||||||
|
# symbol = order['symol']
|
||||||
|
# msg = order['msg']
|
||||||
|
# price = order['price']
|
||||||
|
# oid = order['oid']
|
||||||
|
|
||||||
print(f'oid: {oid}')
|
|
||||||
# TODO
|
# TODO
|
||||||
# oid = str(uuid.uuid4())
|
# oid = str(uuid.uuid4())
|
||||||
|
|
||||||
cmd = {
|
# cmd = {
|
||||||
'price': price,
|
# 'price': price,
|
||||||
'action': 'alert',
|
# 'action': 'alert',
|
||||||
'symbol': symbol.key,
|
# 'symbol': symbol.key,
|
||||||
'brokers': symbol.brokers,
|
# 'brokers': symbol.brokers,
|
||||||
'type': tp,
|
# 'msg': msg,
|
||||||
'price': price,
|
# 'price': price,
|
||||||
'oid': oid,
|
# 'oid': oid,
|
||||||
}
|
# }
|
||||||
|
|
||||||
_local_book[oid] = cmd
|
# book._sent_orders[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
|
# 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.
|
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:
|
if trigger_price >= known_last:
|
||||||
|
|
||||||
def check_gt(price: float) -> bool:
|
def check_gt(price: float) -> bool:
|
||||||
if price >= trigger_price:
|
return price >= trigger_price
|
||||||
return True
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return check_gt, 'gt'
|
return check_gt, 'down'
|
||||||
|
|
||||||
elif trigger_price <= known_last:
|
elif trigger_price <= known_last:
|
||||||
|
|
||||||
def check_lt(price: float) -> bool:
|
def check_lt(price: float) -> bool:
|
||||||
if price <= trigger_price:
|
return price <= trigger_price
|
||||||
return True
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return check_lt, 'lt'
|
return check_lt, 'up'
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
@ -173,9 +204,10 @@ class _ExecBook:
|
||||||
"""EMS-side execution book.
|
"""EMS-side execution book.
|
||||||
|
|
||||||
Contains conditions for executions (aka "orders").
|
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[
|
orders: Dict[
|
||||||
Tuple[str, str],
|
Tuple[str, str],
|
||||||
Tuple[
|
Tuple[
|
||||||
|
@ -188,7 +220,7 @@ class _ExecBook:
|
||||||
]
|
]
|
||||||
] = field(default_factory=dict)
|
] = field(default_factory=dict)
|
||||||
|
|
||||||
# most recent values
|
# tracks most recent values per symbol each from data feed
|
||||||
lasts: Dict[
|
lasts: Dict[
|
||||||
Tuple[str, str],
|
Tuple[str, str],
|
||||||
float
|
float
|
||||||
|
@ -236,6 +268,11 @@ async def exec_orders(
|
||||||
with stream.shield():
|
with stream.shield():
|
||||||
async for quotes in stream:
|
async for quotes in stream:
|
||||||
|
|
||||||
|
##############################
|
||||||
|
# begin price actions sequence
|
||||||
|
# XXX: optimize this for speed
|
||||||
|
##############################
|
||||||
|
|
||||||
for sym, quote in quotes.items():
|
for sym, quote in quotes.items():
|
||||||
|
|
||||||
execs = book.orders.get((broker, sym))
|
execs = book.orders.get((broker, sym))
|
||||||
|
@ -249,27 +286,36 @@ async def exec_orders(
|
||||||
# update to keep new cmds informed
|
# update to keep new cmds informed
|
||||||
book.lasts[(broker, symbol)] = price
|
book.lasts[(broker, symbol)] = price
|
||||||
|
|
||||||
# begin price actions sequence
|
|
||||||
|
|
||||||
if not execs:
|
if not execs:
|
||||||
continue
|
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"
|
# push trigger msg back to parent as an "alert"
|
||||||
# (mocking for eg. a "fill")
|
# (mocking for eg. a "fill")
|
||||||
if pred(price):
|
if pred(price):
|
||||||
name = action(price)
|
|
||||||
await ctx.send_yield({
|
cmd['name'] = name
|
||||||
'type': 'alert',
|
cmd['index'] = feed.shm._last.value - 1
|
||||||
'price': price,
|
|
||||||
# current shm array index
|
# current shm array index
|
||||||
'index': feed.shm._last.value - 1,
|
cmd['trigger_price'] = price
|
||||||
'name': name,
|
|
||||||
'oid': oid,
|
await ctx.send_yield(cmd)
|
||||||
})
|
# await ctx.send_yield({
|
||||||
execs.remove((oid, pred, action))
|
# 'type': 'alert',
|
||||||
|
# 'price': price,
|
||||||
|
# # current shm array index
|
||||||
|
# 'index': feed.shm._last.value - 1,
|
||||||
|
# 'name': name,
|
||||||
|
# 'oid': oid,
|
||||||
|
# })
|
||||||
|
|
||||||
print(
|
print(
|
||||||
f"GOT ALERT FOR {exec_price} @ \n{tick}\n")
|
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}')
|
print(f'execs are {execs}')
|
||||||
|
|
||||||
# feed teardown
|
# feed teardown
|
||||||
|
@ -279,6 +325,10 @@ async def exec_orders(
|
||||||
async def stream_and_route(ctx, ui_name):
|
async def stream_and_route(ctx, ui_name):
|
||||||
"""Order router (sub)actor entrypoint.
|
"""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()
|
actor = tractor.current_actor()
|
||||||
book = get_book()
|
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):
|
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
|
pass
|
||||||
|
|
||||||
tp = cmd.pop('type')
|
|
||||||
|
|
||||||
trigger_price = cmd['price']
|
trigger_price = cmd['price']
|
||||||
sym = cmd['symbol']
|
sym = cmd['symbol']
|
||||||
brokers = cmd['brokers']
|
brokers = cmd['brokers']
|
||||||
oid = cmd['oid']
|
oid = cmd['oid']
|
||||||
|
|
||||||
if tp == 'alert':
|
if msg == 'alert':
|
||||||
log.info(f'Alert {cmd} received in {actor.uid}')
|
log.info(f'Alert {cmd} received in {actor.uid}')
|
||||||
|
|
||||||
broker = brokers[0]
|
broker = brokers[0]
|
||||||
|
@ -333,14 +382,17 @@ async def stream_and_route(ctx, ui_name):
|
||||||
|
|
||||||
# create list of executions on first entry
|
# create list of executions on first entry
|
||||||
book.orders.setdefault((broker, sym), []).append(
|
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
|
# continue and wait on next order cmd
|
||||||
|
|
||||||
|
|
||||||
async def spawn_router_stream_alerts(
|
async def spawn_router_stream_alerts(
|
||||||
chart,
|
order_mode,
|
||||||
symbol: Symbol,
|
symbol: Symbol,
|
||||||
# lines: 'LinesEditor',
|
# lines: 'LinesEditor',
|
||||||
task_status: TaskStatus[str] = trio.TASK_STATUS_IGNORED,
|
task_status: TaskStatus[str] = trio.TASK_STATUS_IGNORED,
|
||||||
|
@ -349,13 +401,11 @@ async def spawn_router_stream_alerts(
|
||||||
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()
|
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:
|
async with tractor.open_nursery() as n:
|
||||||
|
|
||||||
portal = await n.start_actor(
|
portal = await n.start_actor(
|
||||||
|
@ -369,32 +419,40 @@ async def spawn_router_stream_alerts(
|
||||||
|
|
||||||
async with tractor.wait_for_actor(subactor_name):
|
async with tractor.wait_for_actor(subactor_name):
|
||||||
# let parent task continue
|
# 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:
|
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
|
# delete the line from view
|
||||||
oid = alert['oid']
|
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}')
|
print(f'deleting line with oid: {oid}')
|
||||||
|
|
||||||
chart._vb._lines_editor
|
# delete level from view
|
||||||
_lines.pop(oid).delete()
|
order_mode.lines.remove_line(uuid=oid)
|
||||||
|
|
||||||
|
# chart._vb._lines_editor
|
||||||
|
# _lines.pop(oid).delete()
|
||||||
|
|
||||||
# TODO: this in another task?
|
# TODO: this in another task?
|
||||||
# not sure if this will ever be a bottleneck,
|
# not sure if this will ever be a bottleneck,
|
||||||
|
@ -411,3 +469,6 @@ async def spawn_router_stream_alerts(
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
log.runtime(result)
|
log.runtime(result)
|
||||||
|
|
||||||
|
# do we need this?
|
||||||
|
# await _from_ems.put(alert)
|
||||||
|
|
|
@ -57,7 +57,7 @@ from .. import data
|
||||||
from ..data import maybe_open_shm_array
|
from ..data import maybe_open_shm_array
|
||||||
from ..log import get_logger
|
from ..log import get_logger
|
||||||
from ._exec import run_qtractor, current_screen
|
from ._exec import run_qtractor, current_screen
|
||||||
from ._interaction import ChartView
|
from ._interaction import ChartView, open_order_mode
|
||||||
from .. import fsp
|
from .. import fsp
|
||||||
from .._ems import spawn_router_stream_alerts
|
from .._ems import spawn_router_stream_alerts
|
||||||
|
|
||||||
|
@ -301,6 +301,7 @@ class LinkedSplitCharts(QtGui.QWidget):
|
||||||
|
|
||||||
array=array,
|
array=array,
|
||||||
parent=self.splitter,
|
parent=self.splitter,
|
||||||
|
linked_charts=self,
|
||||||
axisItems={
|
axisItems={
|
||||||
'bottom': xaxis,
|
'bottom': xaxis,
|
||||||
'right': PriceAxis(linked_charts=self)
|
'right': PriceAxis(linked_charts=self)
|
||||||
|
@ -310,9 +311,9 @@ class LinkedSplitCharts(QtGui.QWidget):
|
||||||
**cpw_kwargs,
|
**cpw_kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
# give viewbox a reference to primary chart
|
# give viewbox as reference to chart
|
||||||
# allowing for kb controls and interactions
|
# allowing for kb controls and interactions on **this** widget
|
||||||
# (see our custom view in `._interactions.py`)
|
# (see our custom view mode in `._interactions.py`)
|
||||||
cv.chart = cpw
|
cv.chart = cpw
|
||||||
|
|
||||||
cpw.plotItem.vb.linked_charts = self
|
cpw.plotItem.vb.linked_charts = self
|
||||||
|
@ -375,6 +376,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
# the data view we generate graphics from
|
# the data view we generate graphics from
|
||||||
name: str,
|
name: str,
|
||||||
array: np.ndarray,
|
array: np.ndarray,
|
||||||
|
linked_charts: LinkedSplitCharts,
|
||||||
static_yrange: Optional[Tuple[float, float]] = None,
|
static_yrange: Optional[Tuple[float, float]] = None,
|
||||||
cursor: Optional[Cursor] = None,
|
cursor: Optional[Cursor] = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
|
@ -390,6 +392,7 @@ class ChartPlotWidget(pg.PlotWidget):
|
||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
self.name = name
|
self.name = name
|
||||||
|
self._lc = linked_charts
|
||||||
|
|
||||||
# self.setViewportMargins(0, 0, 0, 0)
|
# self.setViewportMargins(0, 0, 0, 0)
|
||||||
self._ohlc = array # readonly view of ohlc data
|
self._ohlc = array # readonly view of ohlc data
|
||||||
|
@ -934,17 +937,6 @@ async def _async_main(
|
||||||
wap_in_history,
|
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
|
# wait for a first quote before we start any update tasks
|
||||||
quote = await feed.receive()
|
quote = await feed.receive()
|
||||||
|
|
||||||
|
@ -958,6 +950,24 @@ async def _async_main(
|
||||||
linked_charts
|
linked_charts
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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
|
# probably where we'll eventually start the user input loop
|
||||||
await trio.sleep_forever()
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
@ -1019,7 +1029,7 @@ async def chart_from_quotes(
|
||||||
chart,
|
chart,
|
||||||
# determine precision/decimal lengths
|
# determine precision/decimal lengths
|
||||||
digits=max(float_digits(last), 2),
|
digits=max(float_digits(last), 2),
|
||||||
size_digits=min(float_digits(volume), 3)
|
size_digits=min(float_digits(last), 3)
|
||||||
)
|
)
|
||||||
|
|
||||||
# TODO:
|
# TODO:
|
||||||
|
|
|
@ -157,7 +157,7 @@ class L1Labels:
|
||||||
self,
|
self,
|
||||||
chart: 'ChartPlotWidget', # noqa
|
chart: 'ChartPlotWidget', # noqa
|
||||||
digits: int = 2,
|
digits: int = 2,
|
||||||
size_digits: int = 0,
|
size_digits: int = 3,
|
||||||
font_size_inches: float = _down_2_font_inches_we_like,
|
font_size_inches: float = _down_2_font_inches_we_like,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
|
|
|
@ -17,8 +17,9 @@
|
||||||
"""
|
"""
|
||||||
UX interaction customs.
|
UX interaction customs.
|
||||||
"""
|
"""
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Optional
|
from typing import Optional, Dict, Callable
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
import pyqtgraph as pg
|
import pyqtgraph as pg
|
||||||
|
@ -29,7 +30,7 @@ import numpy as np
|
||||||
from ..log import get_logger
|
from ..log import get_logger
|
||||||
from ._style import _min_points_to_show, hcolor, _font
|
from ._style import _min_points_to_show, hcolor, _font
|
||||||
from ._graphics._lines import level_line, LevelLine
|
from ._graphics._lines import level_line, LevelLine
|
||||||
from .._ems import _lines
|
from .._ems import get_orders, OrderBook
|
||||||
|
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
@ -198,10 +199,17 @@ class SelectRect(QtGui.QGraphicsRectItem):
|
||||||
self.hide()
|
self.hide()
|
||||||
|
|
||||||
|
|
||||||
|
# global store of order-lines graphics
|
||||||
|
# keyed by uuid4 strs - used to sync draw
|
||||||
|
# order lines **after** the order is 100%
|
||||||
|
# active in emsd
|
||||||
|
_order_lines: Dict[str, LevelLine] = {}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class LineEditor:
|
class LineEditor:
|
||||||
view: 'ChartView'
|
view: 'ChartView'
|
||||||
_lines: field(default_factory=dict)
|
_order_lines: field(default_factory=_order_lines)
|
||||||
chart: 'ChartPlotWidget' = None # type: ignore # noqa
|
chart: 'ChartPlotWidget' = None # type: ignore # noqa
|
||||||
_active_staged_line: LevelLine = None
|
_active_staged_line: LevelLine = None
|
||||||
_stage_line: LevelLine = None
|
_stage_line: LevelLine = None
|
||||||
|
@ -223,6 +231,7 @@ class LineEditor:
|
||||||
line = level_line(
|
line = level_line(
|
||||||
chart,
|
chart,
|
||||||
level=y,
|
level=y,
|
||||||
|
digits=chart._lc.symbol.digits(),
|
||||||
color=color,
|
color=color,
|
||||||
|
|
||||||
# don't highlight the "staging" line
|
# don't highlight the "staging" line
|
||||||
|
@ -264,46 +273,48 @@ class LineEditor:
|
||||||
self._stage_line.hide()
|
self._stage_line.hide()
|
||||||
self._stage_line.label.hide()
|
self._stage_line.label.hide()
|
||||||
|
|
||||||
# if line:
|
|
||||||
# line.delete()
|
|
||||||
|
|
||||||
self._active_staged_line = None
|
self._active_staged_line = None
|
||||||
|
|
||||||
# show the crosshair y line
|
# show the crosshair y line
|
||||||
hl = cursor.graphics[chart]['hl']
|
hl = cursor.graphics[chart]['hl']
|
||||||
hl.show()
|
hl.show()
|
||||||
|
|
||||||
def commit_line(self) -> LevelLine:
|
def create_line(self, uuid: str) -> LevelLine:
|
||||||
|
|
||||||
line = self._active_staged_line
|
line = self._active_staged_line
|
||||||
if line:
|
if not line:
|
||||||
|
raise RuntimeError("No line commit is currently staged!?")
|
||||||
|
|
||||||
chart = self.chart._cursor.active_plot
|
chart = self.chart._cursor.active_plot
|
||||||
|
|
||||||
y = chart._cursor._datum_xy[1]
|
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(
|
line = level_line(
|
||||||
chart,
|
chart,
|
||||||
level=y,
|
level=y,
|
||||||
color='alert_yellow',
|
color='alert_yellow',
|
||||||
|
digits=chart._lc.symbol.digits(),
|
||||||
|
show_label=False,
|
||||||
)
|
)
|
||||||
# register for later
|
|
||||||
_lines[oid] = line
|
|
||||||
|
|
||||||
log.debug(f'clicked y: {y}')
|
# register for later lookup/deletion
|
||||||
|
self._order_lines[uuid] = line
|
||||||
|
return line, y
|
||||||
|
|
||||||
|
def commit_line(self, uuid: str) -> LevelLine:
|
||||||
|
"""Commit a "staged line" to view.
|
||||||
|
|
||||||
|
Submits the line graphic under the cursor as a (new) permanent
|
||||||
|
graphic in view.
|
||||||
|
|
||||||
|
"""
|
||||||
|
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(
|
def remove_line(
|
||||||
self,
|
self,
|
||||||
|
@ -316,18 +327,114 @@ class LineEditor:
|
||||||
cursor position.
|
cursor position.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
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
|
# Delete any hoverable under the cursor
|
||||||
cursor = self.chart._cursor
|
cursor = self.chart._cursor
|
||||||
|
|
||||||
if line:
|
|
||||||
line.delete()
|
|
||||||
else:
|
|
||||||
for item in cursor._hovered:
|
for item in cursor._hovered:
|
||||||
# hovered items must also offer
|
# hovered items must also offer
|
||||||
# a ``.delete()`` method
|
# a ``.delete()`` method
|
||||||
item.delete()
|
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):
|
class ChartView(ViewBox):
|
||||||
"""Price chart view box with interaction behaviors you'd expect from
|
"""Price chart view box with interaction behaviors you'd expect from
|
||||||
any interactive platform:
|
any interactive platform:
|
||||||
|
@ -336,6 +443,7 @@ class ChartView(ViewBox):
|
||||||
- vertical scrolling on y-axis
|
- vertical scrolling on y-axis
|
||||||
- zoom on x to most recent in view datum
|
- zoom on x to most recent in view datum
|
||||||
- zoom on right-click-n-drag to cursor position
|
- zoom on right-click-n-drag to cursor position
|
||||||
|
|
||||||
"""
|
"""
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
@ -350,9 +458,11 @@ class ChartView(ViewBox):
|
||||||
self.addItem(self.select_box, ignoreBounds=True)
|
self.addItem(self.select_box, ignoreBounds=True)
|
||||||
self._chart: 'ChartPlotWidget' = None # noqa
|
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._key_buffer = []
|
||||||
self._active_staged_line: LevelLine = None # noqa
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def chart(self) -> 'ChartPlotWidget': # type: ignore # noqa
|
def chart(self) -> 'ChartPlotWidget': # type: ignore # noqa
|
||||||
|
@ -362,7 +472,7 @@ class ChartView(ViewBox):
|
||||||
def chart(self, chart: 'ChartPlotWidget') -> None: # type: ignore # noqa
|
def chart(self, chart: 'ChartPlotWidget') -> None: # type: ignore # noqa
|
||||||
self._chart = chart
|
self._chart = chart
|
||||||
self.select_box.chart = chart
|
self.select_box.chart = chart
|
||||||
self._lines_editor.chart = chart
|
# self._lines_editor.chart = chart
|
||||||
|
|
||||||
def wheelEvent(self, ev, axis=None):
|
def wheelEvent(self, ev, axis=None):
|
||||||
"""Override "center-point" location for scrolling.
|
"""Override "center-point" location for scrolling.
|
||||||
|
@ -533,8 +643,27 @@ class ChartView(ViewBox):
|
||||||
|
|
||||||
ev.accept()
|
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):
|
def keyReleaseEvent(self, ev):
|
||||||
"""
|
"""
|
||||||
|
@ -557,7 +686,8 @@ class ChartView(ViewBox):
|
||||||
|
|
||||||
if text == 'a':
|
if text == 'a':
|
||||||
# draw "staged" line under cursor position
|
# 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):
|
def keyPressEvent(self, ev):
|
||||||
"""
|
"""
|
||||||
|
@ -580,12 +710,11 @@ class ChartView(ViewBox):
|
||||||
|
|
||||||
# ctl
|
# ctl
|
||||||
if mods == QtCore.Qt.ControlModifier:
|
if mods == QtCore.Qt.ControlModifier:
|
||||||
# print("CTRL")
|
|
||||||
# TODO: ctrl-c as cancel?
|
# TODO: ctrl-c as cancel?
|
||||||
# https://forum.qt.io/topic/532/how-to-catch-ctrl-c-on-a-widget/9
|
# https://forum.qt.io/topic/532/how-to-catch-ctrl-c-on-a-widget/9
|
||||||
# if ev.text() == 'c':
|
# if ev.text() == 'c':
|
||||||
# self.rbScaleBox.hide()
|
# self.rbScaleBox.hide()
|
||||||
pass
|
print(f"CTRL + key:{key} + text:{text}")
|
||||||
|
|
||||||
# alt
|
# alt
|
||||||
if mods == QtCore.Qt.AltModifier:
|
if mods == QtCore.Qt.AltModifier:
|
||||||
|
@ -603,13 +732,16 @@ class ChartView(ViewBox):
|
||||||
|
|
||||||
elif text == 'a':
|
elif text == 'a':
|
||||||
# add a line at the current cursor
|
# 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':
|
elif text == 'd':
|
||||||
# delete any lines under the cursor
|
# 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
|
# Key presses are used only when mouse mode is RectMode
|
||||||
# The following events are implemented:
|
# The following events are implemented:
|
||||||
|
|
Loading…
Reference in New Issue