From 620f5fee6e9b56494b5ee088e77c111a160c3faf Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 9 Jan 2021 10:53:40 -0500 Subject: [PATCH 001/139] Wishful thinking with conditional mngrs --- piker/_async_utils.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/piker/_async_utils.py b/piker/_async_utils.py index b358e2f0..fb221215 100644 --- a/piker/_async_utils.py +++ b/piker/_async_utils.py @@ -17,7 +17,9 @@ """ Async utils no one seems to have built into a core lib (yet). """ +from typing import AsyncContextManager from collections import OrderedDict +from contextlib import asynccontextmanager def async_lifo_cache(maxsize=128): @@ -47,3 +49,18 @@ def async_lifo_cache(maxsize=128): return wrapper return decorator + + +@asynccontextmanager +async def _just_none(): + # noop -> skip entering context + yield None + + +@asynccontextmanager +async def maybe_with_if( + predicate: bool, + context: AsyncContextManager, +) -> AsyncContextManager: + async with context if predicate else _just_none() as output: + yield output From 280739b51a6c180cd7fa8f184d732fb3db4c081d Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 9 Jan 2021 10:54:09 -0500 Subject: [PATCH 002/139] Add trades data streaming support --- piker/brokers/ib.py | 156 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 132 insertions(+), 24 deletions(-) diff --git a/piker/brokers/ib.py b/piker/brokers/ib.py index d0645dfe..6d310c0d 100644 --- a/piker/brokers/ib.py +++ b/piker/brokers/ib.py @@ -35,6 +35,7 @@ import time from async_generator import aclosing from ib_insync.wrapper import RequestError from ib_insync.contract import Contract, ContractDetails +from ib_insync.order import Order from ib_insync.ticker import Ticker import ib_insync as ibis from ib_insync.wrapper import Wrapper @@ -53,7 +54,6 @@ from ..data import ( ) from ..data._source import from_df from ._util import SymbolNotFound -from .._async_utils import maybe_with_if log = get_logger(__name__) @@ -150,6 +150,9 @@ class Client: self.ib = ib self.ib.RaiseRequestErrors = True + # contract cache + self._contracts: Dict[str, Contract] = {} + # NOTE: the ib.client here is "throttled" to 45 rps by default async def bars( @@ -283,6 +286,20 @@ class Client: currency: str = 'USD', **kwargs, ) -> Contract: + + # TODO: we can't use this currently because + # ``wrapper.starTicker()`` currently cashes ticker instances + # which means getting a singel quote will potentially look up + # a quote for a ticker that it already streaming and thus run + # into state clobbering (eg. List: Ticker.ticks). It probably + # makes sense to try this once we get the pub-sub working on + # individual symbols... + # try: + # # give the cache a go + # return self._contracts[symbol] + # except KeyError: + # log.debug(f'Looking up contract for {symbol}') + # use heuristics to figure out contract "type" try: sym, exch = symbol.upper().rsplit('.', maxsplit=1) @@ -336,6 +353,8 @@ class Client: except IndexError: raise ValueError(f"No contract could be found {con}") + + self._contracts[symbol] = contract return contract async def get_head_time( @@ -401,7 +420,65 @@ class Client: contract, snapshot=True, ) - return contract, (await ticker.updateEvent) + ticker = await ticker.updateEvent + return contract, ticker + + async def submit_limit( + self, + contract: Contract, + price: float, + action: str = 'BUY', + quantity: int = 100, + ) -> None: + self.ib.placeOrder( + Order( + self, + orderType='LMT', + action=action, + totalQuantity=quantity, + lmtPrice=price, + # **kwargs + ) + ) + + async def recv_trade_updates( + self, + to_trio, + ) -> None: + """Stream a ticker using the std L1 api. + """ + # contract = contract or (await self.find_contract(symbol)) + + def push(eventkit_obj, trade): + """Push events to trio task. + + """ + print(f'{eventkit_obj}: {trade}') + log.debug(trade) + if trade is None: + print("YO WTF NONE") + try: + to_trio.send_nowait(trade) + except trio.BrokenResourceError: + # XXX: eventkit's ``Event.emit()`` for whatever redic + # reason will catch and ignore regular exceptions + # resulting in tracebacks spammed to console.. + # Manually do the dereg ourselves. + log.exception(f'Disconnected from {eventkit_obj} updates') + eventkit_obj.disconnect(push) + + # hook up to the weird eventkit object - event stream api + for ev_name in [ + # 'newOrderEvent', 'orderModifyEvent', 'cancelOrderEvent', + 'openOrderEvent', 'orderStatusEvent', 'execDetailsEvent', + # 'commissionReportEvent', 'updatePortfolioEvent', 'positionEvent', + ]: + eventkit_obj = getattr(self.ib, ev_name) + handler = partial(push, eventkit_obj) + eventkit_obj.connect(handler) + + # let the engine run and stream + await self.ib.disconnectedEvent # default config ports @@ -586,10 +663,11 @@ def normalize( # convert named tuples to dicts so we send usable keys new_ticks = [] for tick in ticker.ticks: - td = tick._asdict() - td['type'] = tick_types.get(td['tickType'], 'n/a') + if tick: + td = tick._asdict() + td['type'] = tick_types.get(td['tickType'], 'n/a') - new_ticks.append(td) + new_ticks.append(td) ticker.ticks = new_ticks @@ -788,7 +866,7 @@ async def stream_quotes( # no real volume so we have to calculate the price suffix = 'secType' calc_price = True - ticker = first_ticker + # ticker = first_ticker # pass first quote asap quote = normalize(first_ticker, calc_price=calc_price) @@ -814,27 +892,32 @@ async def stream_quotes( calc_price = False # should be real volume for contract - # wait for real volume on feed (trading might be closed) - async for ticker in stream: + with trio.move_on_after(10) as cs: + # 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 - 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 = [] + # 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 + 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 = [] - # tell incrementer task it can start - _buffer.shm_incrementing(key).set() + # tell incrementer task it can start + _buffer.shm_incrementing(key).set() + + # XXX: this works because we don't use + # ``aclosing()`` above? + break + + if cs.cancelled_caught: + await tractor.breakpoint() - # XXX: this works because we don't use - # ``aclosing()`` above? - break # real-time stream async for ticker in stream: @@ -895,3 +978,28 @@ async def stream_quotes( # ugh, clear ticks since we've consumed them ticker.ticks = [] + + +@tractor.msg.pub +async def stream_trades( + loglevel: str = None, + get_topics: Callable = None, +) -> AsyncIterator[Dict[str, Any]]: + + log.error('startedddd daa tradeeeez feeeedddzzz') + + # XXX: required to propagate ``tractor`` loglevel to piker logging + get_console_log(loglevel or tractor.current_actor().loglevel) + + stream = await _trio_run_client_method( + method='recv_trade_updates', + ) + + # more great work by our friend ib_insync... + # brutallll bby. + none = await stream.__anext__() + print(f'Cuz sending {none} makes sense..') + + async for trade_event in stream: + msg = asdict(trade_event) + yield {'all': msg} From bd180a3482e58a871854b56f86cfca0e37a49a50 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 9 Jan 2021 10:54:45 -0500 Subject: [PATCH 003/139] Add trades data stream routine to Feed --- piker/data/__init__.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/piker/data/__init__.py b/piker/data/__init__.py index 579e596f..cbdd7e9f 100644 --- a/piker/data/__init__.py +++ b/piker/data/__init__.py @@ -144,9 +144,11 @@ class Feed: name: str stream: AsyncIterator[Dict[str, Any]] shm: ShmArray + mod: ModuleType # ticks: ShmArray _brokerd_portal: tractor._portal.Portal - _index_stream: Optional[AsyncIterator[Dict[str, Any]]] = None + _index_stream: Optional[AsyncIterator[int]] = None + _trade_stream: Optional[AsyncIterator[Dict[str, Any]]] = None async def receive(self) -> dict: return await self.stream.__anext__() @@ -164,6 +166,26 @@ class Feed: return self._index_stream + async def recv_trades_data(self) -> AsyncIterator[dict]: + + if not getattr(self.mod, 'stream_trades', False): + log.warning(f"{self.mod.name} doesn't have trade data support yet :(") + + # yah this is bullshitty but it worx + async def nuttin(): + yield + return + + return nuttin() + + if not self._trade_stream: + self._trade_stream = await self._brokerd_portal.run( + self.mod.stream_trades, + topics=['all'], # do we need this? + ) + + return self._trade_stream + def sym_to_shm_key( broker: str, @@ -228,5 +250,6 @@ async def open_feed( name=name, stream=stream, shm=shm, + mod=mod, _brokerd_portal=portal, ) From 611486627f8dfc82841ff2d17ed13ebeed09622e Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 9 Jan 2021 10:55:00 -0500 Subject: [PATCH 004/139] Cleaning --- piker/data/_buffer.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/piker/data/_buffer.py b/piker/data/_buffer.py index 9a496e7a..896b503b 100644 --- a/piker/data/_buffer.py +++ b/piker/data/_buffer.py @@ -65,14 +65,6 @@ async def increment_ohlc_buffer( # adjust delay to compensate for trio processing time ad = min(_shms.keys()) - 0.001 - # async def sleep(): - # """Sleep until next time frames worth has passed from last bar. - # """ - # # last_ts = shm.array[-1]['time'] - # # delay = max((last_ts + ad) - time.time(), 0) - # # await trio.sleep(delay) - # await trio.sleep(ad) - total_s = 0 # total seconds counted lowest = min(_shms.keys()) ad = lowest - 0.001 @@ -83,9 +75,6 @@ async def increment_ohlc_buffer( await trio.sleep(ad) total_s += lowest - # # sleep for duration of current bar - # await sleep() - # increment all subscribed shm arrays # TODO: this in ``numba`` for delay_s, shms in _shms.items(): From 3e7057d247df9502529ac53043ee3e1d00850e2a Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 9 Jan 2021 10:55:36 -0500 Subject: [PATCH 005/139] Use feed's trade streamin in ems --- piker/_ems.py | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/piker/_ems.py b/piker/_ems.py index 8ede8f31..aa1bdac5 100644 --- a/piker/_ems.py +++ b/piker/_ems.py @@ -18,7 +18,7 @@ In suit parlance: "Execution management systems" """ -import time +# import time from dataclasses import dataclass, field from typing import ( AsyncIterator, Dict, Callable, Tuple, @@ -174,6 +174,9 @@ def mk_check(trigger_price, known_last) -> Callable[[float, float], bool]: return check_lt, 'up' + else: + return None, None + @dataclass class _ExecBook: @@ -236,7 +239,7 @@ async def exec_orders( book = get_book() book.lasts[(broker, symbol)] = first_quote[symbol]['last'] - task_status.started(first_quote) + task_status.started((first_quote, feed)) # shield this field so the remote brokerd does not get cancelled stream = feed.stream @@ -249,7 +252,7 @@ async def exec_orders( # XXX: optimize this for speed ############################## - start = time.time() + # start = time.time() for sym, quote in quotes.items(): execs = book.orders.get((broker, sym)) @@ -288,10 +291,20 @@ async def exec_orders( print(f'execs are {execs}') - print(f'execs scan took: {time.time() - start}') + # print(f'execs scan took: {time.time() - start}') # feed teardown +async def receive_trade_updates( + ctx: tractor.Context, + feed: 'Feed', # noqa +) -> AsyncIterator[dict]: + # await tractor.breakpoint() + print("TRADESZ") + async for update in await feed.recv_trades_data(): + log.info(update) + + @tractor.stream async def stream_and_route(ctx, ui_name): """Order router (sub)actor entrypoint. @@ -338,7 +351,7 @@ async def stream_and_route(ctx, ui_name): if last is None: # spawn new brokerd feed task - quote = await n.start( + quote, feed = await n.start( exec_orders, ctx, # TODO: eventually support N-brokers @@ -346,7 +359,14 @@ async def stream_and_route(ctx, ui_name): sym, trigger_price, ) - print(f"received first quote {quote}") + + n.start_soon( + receive_trade_updates, + ctx, + # TODO: eventually support N-brokers + feed, + ) + last = book.lasts[(broker, sym)] print(f'Known last is {last}') @@ -359,6 +379,7 @@ async def stream_and_route(ctx, ui_name): # 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) From 5503a5705a4bb3e6866d987dc36622fbb51bfcfe Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 9 Jan 2021 10:56:11 -0500 Subject: [PATCH 006/139] Drop old line --- piker/ui/_chart.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 39211e61..dad829f4 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -964,10 +964,6 @@ async def _async_main( 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() From e51670a57383e27e96e4ec773233b63f3d2c69db Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 9 Jan 2021 10:56:35 -0500 Subject: [PATCH 007/139] Try dynamically loading screen --- piker/ui/_style.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/piker/ui/_style.py b/piker/ui/_style.py index 656877cc..fa8af0d3 100644 --- a/piker/ui/_style.py +++ b/piker/ui/_style.py @@ -25,6 +25,7 @@ from PyQt5 import QtCore, QtGui from qdarkstyle.palette import DarkPalette from ..log import get_logger +from ._exec import current_screen log = get_logger(__name__) @@ -51,6 +52,10 @@ class DpiAwareFont: self._qfont.setPixelSize(px_size) self._qfm = QtGui.QFontMetrics(self._qfont) + @property + def screen(self) -> QtGui.QScreen: + return current_screen() + @property def font(self): return self._qfont @@ -82,7 +87,7 @@ class DpiAwareFont: def boundingRect(self, value: str) -> QtCore.QRectF: - screen = self._screen + screen = self.screen if screen is None: raise RuntimeError("You must call .configure_to_dpi() first!") From 12aebcc89cd5355954e3a3f628665cf9c299055a Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 11 Jan 2021 21:22:03 -0500 Subject: [PATCH 008/139] Expose "proxy" api a little better --- piker/brokers/ib.py | 60 +++++++++++++++++++++++---------------------- 1 file changed, 31 insertions(+), 29 deletions(-) diff --git a/piker/brokers/ib.py b/piker/brokers/ib.py index 6d310c0d..1d4ffc41 100644 --- a/piker/brokers/ib.py +++ b/piker/brokers/ib.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 @@ -423,23 +423,26 @@ class Client: ticker = await ticker.updateEvent return contract, ticker - async def submit_limit( + def submit_limit( self, contract: Contract, price: float, action: str = 'BUY', quantity: int = 100, - ) -> None: - self.ib.placeOrder( + ) -> int: + """Place an order and return integer request id provided by client. + + """ + trade = self.ib.placeOrder( Order( self, orderType='LMT', action=action, totalQuantity=quantity, lmtPrice=price, - # **kwargs ) ) + return trade.order.orderId async def recv_trade_updates( self, @@ -611,7 +614,7 @@ class _MethodProxy: ) -def get_method_proxy(portal, target) -> _MethodProxy: +def get_client_proxy(portal, target=Client) -> _MethodProxy: proxy = _MethodProxy(portal) @@ -635,11 +638,10 @@ async def get_client( """ async with maybe_spawn_brokerd( brokername='ib', - expose_mods=[__name__], infect_asyncio=True, **kwargs ) as portal: - yield get_method_proxy(portal, Client) + yield get_client_proxy(portal) # https://interactivebrokers.github.io/tws-api/tick_types.html @@ -892,32 +894,32 @@ async def stream_quotes( calc_price = False # should be real volume for contract - with trio.move_on_after(10) as cs: - # wait for real volume on feed (trading might be closed) - async for ticker in stream: + # with trio.move_on_after(10) as cs: + # wait for real volume on feed (trading might be closed) - # 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 - 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 = [] + async for ticker in stream: - # tell incrementer task it can start - _buffer.shm_incrementing(key).set() + # 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 + 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 + # tell incrementer task it can start + _buffer.shm_incrementing(key).set() - if cs.cancelled_caught: - await tractor.breakpoint() + # XXX: this works because we don't use + # ``aclosing()`` above? + break + # if cs.cancelled_caught: + # await tractor.breakpoint() # real-time stream async for ticker in stream: From c1266a7a1d88fc3f33065b29b1a7573ca9b4afb5 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 11 Jan 2021 21:22:21 -0500 Subject: [PATCH 009/139] Add buy/sell colors --- piker/ui/_style.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/piker/ui/_style.py b/piker/ui/_style.py index fa8af0d3..1bfb60be 100644 --- a/piker/ui/_style.py +++ b/piker/ui/_style.py @@ -171,9 +171,31 @@ def hcolor(name: str) -> str: 'tina_green': '#00cc00', 'tina_red': '#fa0000', + 'cucumber': '#006400', + 'cool_green': '#33b864', + 'dull_green': '#74a662', + 'hedge_green': '#518360', # orders and alerts 'alert_yellow': '#e2d083', 'alert_yellow_light': '#ffe366', + # buys + # 'hedge': '#768a75', + # 'hedge': '#41694d', + # 'hedge': '#558964', + # 'hedge_light': '#5e9870', + + # 'buy_green': '#41694d', + 'buy_green': '#558964', + 'buy_green_light': '#558964', + + # sells + # techincally "raspberry" + # 'sell_red': '#980036', + 'sell_red': '#990036', + 'sell_red_light': '#f85462', + # 'sell_red': '#f85462', + # 'sell_red_light': '#ff4d5c', + }[name] From 53c0816c5ffbc24ec8924fa11f87ba5b8ede56c9 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 11 Jan 2021 21:22:58 -0500 Subject: [PATCH 010/139] Add color properties for level line and label --- piker/ui/_graphics/_lines.py | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/piker/ui/_graphics/_lines.py b/piker/ui/_graphics/_lines.py index b697692b..70e8e5ea 100644 --- a/piker/ui/_graphics/_lines.py +++ b/piker/ui/_graphics/_lines.py @@ -50,7 +50,8 @@ class LevelLabel(YSticky): super().__init__(chart, *args, **kwargs) # TODO: this is kinda cludgy - self._pen = self.pen = pg.mkPen(hcolor(color)) + self._hcolor = None + self.color = color # orientation around axis options self._orient_v = orient_v @@ -65,6 +66,15 @@ class LevelLabel(YSticky): 'left': -1., 'right': 0 }[orient_h] + @property + def color(self): + return self._hcolor + + @color.setter + def color(self, color: str) -> None: + self._hcolor = color + self._pen = self.pen = pg.mkPen(hcolor(color)) + def update_label( self, abs_pos: QPointF, # scene coords @@ -201,6 +211,7 @@ class LevelLine(pg.InfiniteLine): self, chart: 'ChartPlotWidget', # type: ignore # noqa label: LevelLabel, + color: str = 'default', highlight_color: str = 'default_light', hl_on_hover: bool = True, **kwargs, @@ -213,12 +224,24 @@ class LevelLine(pg.InfiniteLine): self._chart = chart self._hoh = hl_on_hover + self._hcolor = None + self.color = color + # use slightly thicker highlight pen = pg.mkPen(hcolor(highlight_color)) pen.setWidth(2) self.setHoverPen(pen) self._track_cursor: bool = False + @property + def color(self): + return self._hcolor + + @color.setter + def color(self, color: str) -> None: + self._hcolor = color + self.setPen(pg.mkPen(hcolor(color))) + def set_level(self, value: float) -> None: self.label.update_from_data(0, self.value()) @@ -351,6 +374,7 @@ def level_line( line = LevelLine( chart, label, + color=color, # lookup "highlight" equivalent highlight_color=color + '_light', movable=True, @@ -358,7 +382,6 @@ def level_line( hl_on_hover=hl_on_hover, ) line.setValue(level) - line.setPen(pg.mkPen(hcolor(color))) # activate/draw label line.setValue(level) From f9d4df7378a76b5355db0038e2b511f1e54df171 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 11 Jan 2021 21:23:39 -0500 Subject: [PATCH 011/139] Start higher level order mode API --- piker/ui/_interaction.py | 95 +++++++++++++++++++++++++--------------- 1 file changed, 60 insertions(+), 35 deletions(-) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 02c95230..81d3ccf2 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -244,6 +244,8 @@ class LineEditor: # of allocating more mem / objects repeatedly line.setValue(y) line.show() + line.color = color + line.label.color = color line.label.show() self._active_staged_line = line @@ -279,7 +281,10 @@ class LineEditor: hl = cursor.graphics[chart]['hl'] hl.show() - def create_line(self, uuid: str) -> LevelLine: + def create_line( + self, + uuid: str + ) -> LevelLine: line = self._active_staged_line if not line: @@ -291,7 +296,7 @@ class LineEditor: line = level_line( chart, level=y, - color='alert_yellow', + color=line.color, digits=chart._lc.symbol.digits(), show_label=False, ) @@ -328,7 +333,7 @@ class LineEditor: self, line: LevelLine = None, uuid: str = None, - ) -> None: + ) -> LevelLine: """Remove a line by refernce or uuid. If no lines or ids are provided remove all lines under the @@ -347,6 +352,7 @@ class LineEditor: hovered.remove(line) line.delete() + return line @dataclass @@ -366,16 +372,18 @@ class ArrowEditor: """Add an arrow graphic to view at given (x, y). """ - yb = pg.mkBrush(hcolor('alert_yellow')) - angle = 90 if pointing == 'up' else -90 + yb = pg.mkBrush(hcolor(color)) arrow = pg.ArrowItem( angle=angle, baseAngle=0, headLen=5, headWidth=2, tailLen=None, + + # coloring + pen=pg.mkPen(hcolor('papas_special')), brush=yb, ) arrow.setPos(x, y) @@ -400,17 +408,46 @@ class OrderMode: book: OrderBook lines: LineEditor arrows: ArrowEditor - _arrow_colors = { + _colors = { 'alert': 'alert_yellow', 'buy': 'buy_green', 'sell': 'sell_red', } + _action: str = 'alert' key_map: Dict[str, Callable] = field(default_factory=dict) def uuid(self) -> str: return str(uuid.uuid4()) + def set_exec(self, name: str) -> None: + """Set execution mode. + + """ + self._action = name + self.lines.stage_line(color=self._colors[name]) + + def submit_exec(self) -> None: + """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()) + + # make line graphic + line, y = self.lines.create_line(uid) + + # send order cmd to ems + self.book.send( + uuid=uid, + symbol=self.chart._lc._symbol, + price=y, + action=self._action, + ) + @asynccontextmanager async def open_order_mode( @@ -649,29 +686,9 @@ class ChartView(ViewBox): self.raiseContextMenu(ev) elif button == QtCore.Qt.LeftButton: - + # when in order mode, submit execution ev.accept() - - # 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 - ) + self.mode.submit_exec() def keyReleaseEvent(self, ev): """ @@ -689,10 +706,10 @@ class ChartView(ViewBox): # mods = ev.modifiers() if key == QtCore.Qt.Key_Shift: - if self.state['mouseMode'] == ViewBox.RectMode: - self.setMouseMode(ViewBox.PanMode) + # if self.state['mouseMode'] == ViewBox.RectMode: + self.setMouseMode(ViewBox.PanMode) - if text == 'a': + if text in {'a', 'f', 's'}: # draw "staged" line under cursor position self.mode.lines.unstage_line() @@ -733,14 +750,22 @@ class ChartView(ViewBox): self._key_buffer.append(text) - # order modes + # View modes if text == 'r': self.chart.default_view() - elif text == 'a': - # add a line at the current cursor - self.mode.lines.stage_line() + # Order modes + # stage orders at the current cursor level + elif text == 's': + self.mode.set_exec('sell') + elif text == 'f': + self.mode.set_exec('buy') + + elif text == 'a': + self.mode.set_exec('alert') + + # delete orders under cursor elif text == 'd': # delete any lines under the cursor From 140f3231e741786bd8c69a6af3a7503a92a6f8f1 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 11 Jan 2021 21:24:14 -0500 Subject: [PATCH 012/139] Add basic client-side order entry to EMS --- piker/_ems.py | 77 ++++++++++++++++++++++++++++----------------------- 1 file changed, 42 insertions(+), 35 deletions(-) diff --git a/piker/_ems.py b/piker/_ems.py index aa1bdac5..a95d2aa4 100644 --- a/piker/_ems.py +++ b/piker/_ems.py @@ -18,7 +18,7 @@ In suit parlance: "Execution management systems" """ -# import time +import time from dataclasses import dataclass, field from typing import ( AsyncIterator, Dict, Callable, Tuple, @@ -59,19 +59,20 @@ class OrderBook: _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 on_fill(self, uuid: str) -> None: + # cmd = self._sent_orders[uuid] + # log.info(f"Order executed: {cmd}") + # self._confirmed_orders[uuid] = cmd - def alert( + def send( self, uuid: str, symbol: 'Symbol', - price: float + price: float, + action: str, ) -> str: cmd = { - 'msg': 'alert', + 'msg': action, 'price': price, 'symbol': symbol.key, 'brokers': symbol.brokers, @@ -80,12 +81,6 @@ class OrderBook: self._sent_orders[uuid] = cmd self._to_ems.send_nowait(cmd) - def buy(self, price: float) -> str: - ... - - def sell(self, price: float) -> str: - ... - def cancel(self, uuid: str) -> bool: """Cancel an order (or alert) from the EMS. @@ -218,11 +213,10 @@ def get_book() -> _ExecBook: return _book -async def exec_orders( +async def exec_loop( ctx: tractor.Context, broker: str, symbol: str, - exec_price: float, task_status: TaskStatus[dict] = trio.TASK_STATUS_IGNORED, ) -> AsyncIterator[dict]: @@ -239,7 +233,9 @@ async def exec_orders( book = get_book() book.lasts[(broker, symbol)] = first_quote[symbol]['last'] - task_status.started((first_quote, feed)) + client = feed.mod.get_client_proxy(feed._brokerd_portal) + + task_status.started((first_quote, feed, client)) # shield this field so the remote brokerd does not get cancelled stream = feed.stream @@ -275,21 +271,24 @@ async def exec_orders( # (mocking for eg. a "fill") if pred(price): - cmd['name'] = name - cmd['index'] = feed.shm._last.value - 1 - # current shm array index - cmd['trigger_price'] = price - cmd['msg'] = 'executed' + resp = { + 'msg': 'executed', + 'name': name, + 'time_ns': time.time_ns(), + # current shm array index + 'index': feed.shm._last.value - 1, + 'exec_price': price, + } - await ctx.send_yield(cmd) + await ctx.send_yield(resp) print( - f"GOT ALERT FOR {exec_price} @ \n{tick}\n") + f"GOT ALERT FOR {name} @ \n{tick}\n") - print(f'removing pred for {oid}') + log.info(f'removing pred for {oid}') pred, name, cmd = execs.pop(oid) - print(f'execs are {execs}') + log.debug(f'execs are {execs}') # print(f'execs scan took: {time.time() - start}') # feed teardown @@ -335,7 +334,7 @@ async def stream_and_route(ctx, ui_name): # destroy exec pred, name, cmd = book.orders[_active_execs[oid]].pop(oid) - # ack-cmdond that order is live + # ack-cmd that order is live await ctx.send_yield({'msg': 'cancelled', 'oid': oid}) continue @@ -351,24 +350,26 @@ async def stream_and_route(ctx, ui_name): if last is None: # spawn new brokerd feed task - quote, feed = await n.start( - exec_orders, + quote, feed, client = await n.start( + exec_loop, ctx, - # TODO: eventually support N-brokers + + # TODO: eventually support N-brokers? broker, sym, + trigger_price, ) + # TODO: eventually support N-brokers n.start_soon( receive_trade_updates, ctx, - # TODO: eventually support N-brokers feed, ) - last = book.lasts[(broker, sym)] + print(f'Known last is {last}') # Auto-gen scanner predicate: @@ -379,6 +380,11 @@ async def stream_and_route(ctx, ui_name): # the user choose the predicate operator. pred, name = mk_check(trigger_price, last) + # if the predicate resolves immediately send the + # execution to the broker asap + if pred(last): + # send order + print("ORDER FILLED IMMEDIATELY!?!?!?!") # create list of executions on first entry book.orders.setdefault( @@ -387,7 +393,7 @@ async def stream_and_route(ctx, ui_name): # reverse lookup for cancellations _active_execs[oid] = (broker, sym) - # ack-cmdond that order is live + # ack-response that order is live here await ctx.send_yield({ 'msg': 'active', 'oid': oid @@ -451,14 +457,15 @@ async def spawn_router_stream_alerts( elif resp in ('executed',): - order_mode.lines.remove_line(uuid=oid) + line = order_mode.lines.remove_line(uuid=oid) print(f'deleting line with oid: {oid}') order_mode.arrows.add( oid, msg['index'], msg['price'], - pointing='up' if msg['name'] == 'up' else 'down' + pointing='up' if msg['name'] == 'up' else 'down', + color=line.color ) # DESKTOP NOTIFICATIONS From ee8caa80d4d5ce619b11da0bf5ce82fb7c48772f Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 14 Jan 2021 12:56:27 -0500 Subject: [PATCH 013/139] Add order cancellation and error support --- piker/brokers/ib.py | 171 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 139 insertions(+), 32 deletions(-) diff --git a/piker/brokers/ib.py b/piker/brokers/ib.py index 1d4ffc41..79dfc5f1 100644 --- a/piker/brokers/ib.py +++ b/piker/brokers/ib.py @@ -100,8 +100,9 @@ class NonShittyWrapper(Wrapper): def tcpDataArrived(self): """Override time stamps to be floats for now. """ - # use a float to store epoch time instead of datetime - self.lastTime = time.time() + # use a ns int to store epoch time instead of datetime + self.lastTime = time.time_ns() + for ticker in self.pendingTickers: ticker.rtTime = None ticker.ticks = [] @@ -109,6 +110,18 @@ class NonShittyWrapper(Wrapper): ticker.domTicks = [] self.pendingTickers = set() + def execDetails( + self, + reqId: int, + contract: Contract, + execu, + ): + """ + Get rid of datetime on executions. + """ + execu.time = execu.time.timestamp() + return super().execDetails(reqId, contract, execu) + class NonShittyIB(ibis.IB): """The beginning of overriding quite a few decisions in this lib. @@ -121,7 +134,7 @@ class NonShittyIB(ibis.IB): # XXX: just to override this wrapper self.wrapper = NonShittyWrapper(self) self.client = ib_Client(self.wrapper) - self.errorEvent += self._onError + # self.errorEvent += self._onError self.client.apiEnd += self.disconnectedEvent self._logger = logging.getLogger('ib_insync.ib') @@ -220,9 +233,6 @@ class Client: df = ibis.util.df(bars) return bars, from_df(df) - def onError(self, reqId, errorCode, errorString, contract) -> None: - breakpoint() - async def search_stocks( self, pattern: str, @@ -348,9 +358,6 @@ class Client: exch = 'SMART' if not exch else exch contract = (await self.ib.qualifyContractsAsync(con))[0] - # head = await self.get_head_time(contract) - # print(head) - except IndexError: raise ValueError(f"No contract could be found {con}") @@ -423,9 +430,11 @@ class Client: ticker = await ticker.updateEvent return contract, ticker - def submit_limit( + # async to be consistent for the client proxy, and cuz why not. + async def submit_limit( self, - contract: Contract, + oid: str, + symbol: str, price: float, action: str = 'BUY', quantity: int = 100, @@ -433,29 +442,70 @@ class Client: """Place an order and return integer request id provided by client. """ + try: + contract = self._contracts[symbol] + except KeyError: + # require that the symbol has been previously cached by + # a data feed request - ensure we aren't making orders + # against non-known prices. + raise RuntimeError("Can not order {symbol}, no live feed?") + + # contract.exchange = 'SMART' + trade = self.ib.placeOrder( + contract, Order( - self, + # orderId=oid, + action=action.upper(), # BUY/SELL orderType='LMT', - action=action, - totalQuantity=quantity, lmtPrice=price, - ) + totalQuantity=quantity, + outsideRth=True, + + optOutSmartRouting=True, + routeMarketableToBbo=True, + designatedLocation='SMART', + ), ) return trade.order.orderId + async def submit_cancel( + self, + oid: str, + ) -> None: + """Send cancel request for order id ``oid``. + + """ + self.ib.cancelOrder( + Order( + orderId=oid, + clientId=self.ib.client.clientId, + ) + ) + async def recv_trade_updates( self, - to_trio, + to_trio: trio.abc.SendChannel, ) -> None: """Stream a ticker using the std L1 api. """ # contract = contract or (await self.find_contract(symbol)) + self.inline_errors(to_trio) - def push(eventkit_obj, trade): + def push_tradesies(eventkit_obj, trade, fill=None): """Push events to trio task. """ + # if fill is not None: + # heyoo we executed, and thanks to ib_insync + # we have to handle the callback signature differently + # due to its consistently non-consistent design. + + # yet again convert the datetime since they aren't + # ipc serializable... + # fill.time = fill.time.timestamp + # trade.fill = fill + print(f'{eventkit_obj}: {trade}') log.debug(trade) if trade is None: @@ -468,21 +518,62 @@ class Client: # resulting in tracebacks spammed to console.. # Manually do the dereg ourselves. log.exception(f'Disconnected from {eventkit_obj} updates') - eventkit_obj.disconnect(push) + eventkit_obj.disconnect(push_tradesies) # hook up to the weird eventkit object - event stream api for ev_name in [ - # 'newOrderEvent', 'orderModifyEvent', 'cancelOrderEvent', - 'openOrderEvent', 'orderStatusEvent', 'execDetailsEvent', - # 'commissionReportEvent', 'updatePortfolioEvent', 'positionEvent', + 'orderStatusEvent', + 'execDetailsEvent', + # XXX: not sure yet if we need these + # 'commissionReportEvent', + # 'updatePortfolioEvent', + # 'positionEvent', + + # XXX: these all seem to be weird ib_insync intrernal + # events that we probably don't care that much about + # given the internal design is wonky af.. + # 'newOrderEvent', + # 'orderModifyEvent', + # 'cancelOrderEvent', + # 'openOrderEvent', ]: eventkit_obj = getattr(self.ib, ev_name) - handler = partial(push, eventkit_obj) + handler = partial(push_tradesies, eventkit_obj) eventkit_obj.connect(handler) # let the engine run and stream await self.ib.disconnectedEvent + def inline_errors( + self, + to_trio: trio.abc.SendChannel, + ) -> None: + # connect error msgs + def push_err( + reqId: int, + errorCode: int, + errorString: str, + contract: Contract, + ) -> None: + log.error(errorString) + try: + to_trio.send_nowait( + {'error': { + 'brid': reqId, + 'message': errorString, + 'contract': contract, + }} + ) + except trio.BrokenResourceError: + # XXX: eventkit's ``Event.emit()`` for whatever redic + # reason will catch and ignore regular exceptions + # resulting in tracebacks spammed to console.. + # Manually do the dereg ourselves. + log.exception('Disconnected from errorEvent updates') + self.ib.errorEvent.disconnect(push_err) + + self.ib.errorEvent.connect(push_err) + # default config ports _tws_port: int = 7497 @@ -536,11 +627,13 @@ async def _aio_get_client( else: raise ConnectionRefusedError(_err) + # create and cache try: client = Client(ib) _client_cache[(host, port)] = client log.debug(f"Caching client for {(host, port)}") yield client + except BaseException: ib.disconnect() raise @@ -607,8 +700,7 @@ class _MethodProxy: **kwargs ) -> Any: return await self._portal.run( - __name__, - '_trio_run_client_method', + _trio_run_client_method, method=meth, **kwargs ) @@ -641,7 +733,8 @@ async def get_client( infect_asyncio=True, **kwargs ) as portal: - yield get_client_proxy(portal) + proxy_client = get_client_proxy(portal) + yield proxy_client # https://interactivebrokers.github.io/tws-api/tick_types.html @@ -997,11 +1090,25 @@ async def stream_trades( method='recv_trade_updates', ) - # more great work by our friend ib_insync... - # brutallll bby. - none = await stream.__anext__() - print(f'Cuz sending {none} makes sense..') + # init startup msg + yield {'trade_events': 'started'} - async for trade_event in stream: - msg = asdict(trade_event) - yield {'all': msg} + async for event in stream: + from pprint import pprint + + if not isinstance(event, dict): + # remove trade log entries for now until we figure out if we + # even want to retreive them this way and because they're using + # datetimes + event = asdict(event) + pprint(event) + event.pop('log', None) + + # fills = event.get('fills') + # if fills: + # await tractor.breakpoint() + # for fill in fills: + # fill['time'] = fill['time'].timestamp + # exec = fill.pop('execution') + + yield {'trade_events': event} From 1c7da2f23b52971e4442d3121fca6ccae8eb6e72 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 14 Jan 2021 12:56:47 -0500 Subject: [PATCH 014/139] Include bidict as dep --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 4f8818f5..80b57ea3 100755 --- a/setup.py +++ b/setup.py @@ -58,6 +58,7 @@ setup( # numerics 'arrow', # better datetimes + 'bidict', # 2 way map 'cython', 'numpy', 'numba', From c835cc10e0117c6e8e29df8b8dcd6368612a86bb Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 14 Jan 2021 12:59:00 -0500 Subject: [PATCH 015/139] Get "live" order mode mostly workin --- piker/_ems.py | 403 ++++++++++++++++++++++------------- piker/brokers/ib.py | 2 + piker/data/__init__.py | 9 +- piker/ui/_chart.py | 5 +- piker/ui/_graphics/_lines.py | 3 + piker/ui/_interaction.py | 53 +++++ 6 files changed, 322 insertions(+), 153 deletions(-) diff --git a/piker/_ems.py b/piker/_ems.py index a95d2aa4..aad8f9b4 100644 --- a/piker/_ems.py +++ b/piker/_ems.py @@ -18,12 +18,14 @@ In suit parlance: "Execution management systems" """ +from pprint import pformat import time from dataclasses import dataclass, field from typing import ( AsyncIterator, Dict, Callable, Tuple, ) +from bidict import bidict import trio from trio_typing import TaskStatus import tractor @@ -54,7 +56,7 @@ class OrderBook: """ _sent_orders: Dict[str, dict] = field(default_factory=dict) - _confirmed_orders: Dict[str, dict] = field(default_factory=dict) + # _confirmed_orders: Dict[str, dict] = field(default_factory=dict) _to_ems: trio.abc.SendChannel = _to_ems _from_order_book: trio.abc.ReceiveChannel = _from_order_book @@ -72,7 +74,7 @@ class OrderBook: action: str, ) -> str: cmd = { - 'msg': action, + 'action': action, 'price': price, 'symbol': symbol.key, 'brokers': symbol.brokers, @@ -81,24 +83,20 @@ class OrderBook: self._sent_orders[uuid] = cmd self._to_ems.send_nowait(cmd) + async def modify(self, oid: str, price) -> bool: + ... + def cancel(self, uuid: str) -> bool: """Cancel an order (or alert) from the EMS. """ - cmd = { - 'msg': 'cancel', + cmd = self._sent_orders[uuid] + msg = { + 'action': 'cancel', 'oid': uuid, + 'symbol': cmd['symbol'], } - self._sent_orders[uuid] = cmd - self._to_ems.send_nowait(cmd) - - # higher level operations - - async def transmit_to_broker(self, price: float) -> str: - ... - - async def modify(self, oid: str, price) -> bool: - ... + self._to_ems.send_nowait(msg) _orders: OrderBook = None @@ -123,13 +121,16 @@ async def send_order_cmds(): """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_ems`` - send channel (from sync code using ``.send_nowait()``), these values - are pulled from the channel here and send to any consumer(s). + This is run in the UI actor (usually the one running Qt but could be + any other client service code). This process 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 relayed to any consumer(s) that called this function using + a ``tractor`` portal. This effectively makes order messages look like they're being - "pushed" from the parent to the EMS actor. + "pushed" from the parent to the EMS where local sync code is likely + doing the pushing from some UI. """ global _from_order_book @@ -181,9 +182,12 @@ class _ExecBook: A singleton instance is created per EMS actor (for now). """ + broker: str + # levels which have an executable action (eg. alert, order, signal) orders: Dict[ - Tuple[str, str], + # Tuple[str, str], + str, # symbol Dict[ str, # uuid Tuple[ @@ -200,17 +204,21 @@ class _ExecBook: float ] = field(default_factory=dict) - -_book = None + # mapping of broker order ids to piker ems ids + _broker2ems_ids: Dict[str, str] = field(default_factory=bidict) -def get_book() -> _ExecBook: - global _book +_books: Dict[str, _ExecBook] = {} - if _book is None: - _book = _ExecBook() - return _book +def get_book(broker: str) -> _ExecBook: + + global _books + return _books.setdefault(broker, _ExecBook(broker)) + + +# def scan_quotes( +# quotes: dict, async def exec_loop( @@ -226,32 +234,38 @@ async def exec_loop( loglevel='info', ) as feed: - # TODO: get initial price - + # TODO: get initial price quote from target broker first_quote = await feed.receive() - - book = get_book() + book = get_book(broker) book.lasts[(broker, symbol)] = first_quote[symbol]['last'] + # TODO: wrap this in a more re-usable general api client = feed.mod.get_client_proxy(feed._brokerd_portal) + # return control to parent task task_status.started((first_quote, feed, client)) + ############################## + # begin price actions sequence + # XXX: optimize this for speed + ############################## + # shield this field so the remote brokerd does not get cancelled stream = feed.stream - with stream.shield(): + + # this stream may eventually contain multiple + # symbols async for quotes in stream: - ############################## - # begin price actions sequence - # XXX: optimize this for speed - ############################## + # TODO: numba all this! # start = time.time() for sym, quote in quotes.items(): execs = book.orders.get((broker, sym)) + if not execs: + continue for tick in quote.get('ticks', ()): price = tick.get('price') @@ -262,29 +276,33 @@ async def exec_loop( # update to keep new cmds informed book.lasts[(broker, symbol)] = price - if not execs: - continue - for oid, (pred, name, cmd) in tuple(execs.items()): # push trigger msg back to parent as an "alert" # (mocking for eg. a "fill") if pred(price): + # register broker id for ems id + order_id = await client.submit_limit( + oid=oid, + symbol=sym, + action=cmd['action'], + price=round(price, 2), + ) + # resp = book._broker2ems_ids.setdefault( + book._broker2ems_ids[order_id] = oid + resp = { - 'msg': 'executed', + 'resp': 'submitted', 'name': name, - 'time_ns': time.time_ns(), + 'ems_trigger_time_ns': time.time_ns(), # current shm array index 'index': feed.shm._last.value - 1, - 'exec_price': price, + 'trigger_price': price, } await ctx.send_yield(resp) - print( - f"GOT ALERT FOR {name} @ \n{tick}\n") - log.info(f'removing pred for {oid}') pred, name, cmd = execs.pop(oid) @@ -294,116 +312,227 @@ async def exec_loop( # feed teardown +# XXX: right now this is very very ad-hoc to IB +# TODO: lots of cases still to handle +# - short-sale but securities haven't been located, in this case we +# should probably keep the order in some kind of weird state or cancel +# it outright? +# status='PendingSubmit', message=''), +# status='Cancelled', message='Error 404, reqId 1550: Order held while securities are located.'), +# status='PreSubmitted', message='')], + async def receive_trade_updates( ctx: tractor.Context, feed: 'Feed', # noqa + book: _ExecBook, + task_status: TaskStatus[dict] = trio.TASK_STATUS_IGNORED, ) -> AsyncIterator[dict]: - # await tractor.breakpoint() - print("TRADESZ") - async for update in await feed.recv_trades_data(): - log.info(update) + """Trades update loop - receive updates from broker, convert + to EMS responses, transmit to ordering client(s). + + This is where trade confirmations from the broker are processed + and appropriate responses relayed back to the original EMS client + actor. There is a messaging translation layer throughout. + + """ + trades_stream = await feed.recv_trades_data() + first = await trades_stream.__anext__() + + # startup msg + assert first['trade_events'] == 'started' + task_status.started() + + async for trade_event in trades_stream: + event = trade_event['trade_events'] + + try: + order = event['order'] + except KeyError: + + # Relay broker error messages + err = event['error'] + + # broker request id - must be normalized + # into error transmission by broker backend. + reqid = err['brid'] + + # TODO: handle updates! + oid = book._broker2ems_ids.get(reqid) + + # XXX should we make one when it's blank? + log.error(pformat(err['message'])) + + else: + log.info(f'Received broker trade event:\n{pformat(event)}') + + status = event['orderStatus']['status'] + reqid = order['orderId'] + + # TODO: handle updates! + oid = book._broker2ems_ids.get(reqid) + + if status in {'Cancelled'}: + resp = {'resp': 'cancelled'} + + elif status in {'Submitted'}: + # ack-response that order is live/submitted + # to the broker + resp = {'resp': 'submitted'} + + # elif status in {'Executed', 'Filled'}: + elif status in {'Filled'}: + + # order was filled by broker + fills = [] + for fill in event['fills']: + e = fill['execution'] + fills.append( + (e.time, e.price, e.shares, e.side) + ) + + resp = { + 'resp': 'executed', + 'fills': fills, + } + + else: # active in EMS + # ack-response that order is live in EMS + # (aka as a client side limit) + resp = {'resp': 'active'} + + # send response packet to EMS client(s) + resp['oid'] = oid + + await ctx.send_yield(resp) @tractor.stream -async def stream_and_route(ctx, ui_name): - """Order router (sub)actor entrypoint. +async def stream_and_route( + ctx: tractor.Context, + client_actor_name: str, + broker: str, + symbol: str, + mode: str = 'live', # ('paper', 'dark', 'live') +) -> None: + """EMS (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. + from executions to order clients. """ actor = tractor.current_actor() - book = get_book() - - _active_execs: Dict[str, (str, str)] = {} + book = get_book(broker) # new router entry point - async with tractor.wait_for_actor(ui_name) as portal: + async with tractor.wait_for_actor(client_actor_name) as portal: # spawn one task per broker feed async with trio.open_nursery() as n: + # TODO: eventually support N-brokers + quote, feed, client = await n.start( + exec_loop, + ctx, + broker, + symbol, + ) + + # for paper mode we need to mock this trades response feed + await n.start( + receive_trade_updates, + ctx, + feed, + book, + ) + async for cmd in await portal.run(send_order_cmds): log.info(f'{cmd} received in {actor.uid}') - msg = cmd['msg'] + + action = cmd['action'] oid = cmd['oid'] + sym = cmd['symbol'] - if msg == 'cancel': - # destroy exec - pred, name, cmd = book.orders[_active_execs[oid]].pop(oid) + if action == 'cancel': - # ack-cmd that order is live - await ctx.send_yield({'msg': 'cancelled', 'oid': oid}) + # check for live-broker order + brid = book._broker2ems_ids.inverse[oid] + if brid: + log.info("Submitting cancel for live order") + await client.submit_cancel(oid=brid) - continue + # check for EMS active exec + else: + book.orders[symbol].pop(oid, None) + await ctx.send_yield( + {'action': 'cancelled', + 'oid': oid} + ) - elif msg in ('alert', 'buy', 'sell',): + elif action in ('alert', 'buy', 'sell',): trigger_price = cmd['price'] - sym = cmd['symbol'] brokers = cmd['brokers'] - broker = brokers[0] - last = book.lasts.get((broker, sym)) - - if last is None: # spawn new brokerd feed task - - quote, feed, client = await n.start( - exec_loop, - ctx, - - # TODO: eventually support N-brokers? - broker, - sym, - - trigger_price, - ) - - # TODO: eventually support N-brokers - n.start_soon( - receive_trade_updates, - ctx, - feed, - ) last = book.lasts[(broker, sym)] + # print(f'Known last is {last}') - print(f'Known last is {last}') + if action in ('buy', 'sell',): - # 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) + # if the predicate resolves immediately send the + # execution to the broker asap + # if pred(last): + if mode == 'live': + # send order + log.warning("ORDER FILLED IMMEDIATELY!?!?!?!") + # IF SEND ORDER RIGHT AWAY CONDITION - # if the predicate resolves immediately send the - # execution to the broker asap - if pred(last): - # send order - print("ORDER FILLED IMMEDIATELY!?!?!?!") + # register broker id for ems id + order_id = await client.submit_limit( + oid=oid, + symbol=sym, + action=action, + price=round(trigger_price, 2), + ) + book._broker2ems_ids[order_id] = oid - # create list of executions on first entry - book.orders.setdefault( - (broker, sym), {})[oid] = (pred, name, cmd) + # book.orders[symbol][oid] = None - # reverse lookup for cancellations - _active_execs[oid] = (broker, sym) + # XXX: the trades data broker response loop + # (``receive_trade_updates()`` above) will + # handle sending the ems side acks back to + # the cmd sender from here - # ack-response that order is live here - await ctx.send_yield({ - 'msg': 'active', - 'oid': oid - }) + elif mode in {'dark', 'paper'}: + + # 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) + + # submit execution/order to EMS scanner loop + # create list of executions on first entry + book.orders.setdefault( + (broker, sym), {} + )[oid] = (pred, name, cmd) + + # ack-response that order is live here + await ctx.send_yield({ + 'resp': 'ems_active', + 'oid': oid + }) # continue and wait on next order cmd -async def spawn_router_stream_alerts( +async def _ems_main( order_mode, + broker: str, symbol: Symbol, # lines: 'LinesEditor', task_status: TaskStatus[str] = trio.TASK_STATUS_IGNORED, @@ -425,7 +554,10 @@ async def spawn_router_stream_alerts( ) stream = await portal.run( stream_and_route, - ui_name=actor.name + client_actor_name=actor.name, + broker=broker, + symbol=symbol.key, + ) async with tractor.wait_for_actor(subactor_name): @@ -439,49 +571,22 @@ async def spawn_router_stream_alerts( # delete the line from view oid = msg['oid'] - resp = msg['msg'] + resp = msg['resp'] - if resp in ('active',): - print(f"order accepted: {msg}") + # response to 'action' request (buy/sell) + if resp in ('ems_active', 'submitted'): + log.info(f"order accepted: {msg}") # show line label once order is live - order_mode.lines.commit_line(oid) - - continue + order_mode.on_submit(oid) + # response to 'cancel' request elif resp in ('cancelled',): # delete level from view - order_mode.lines.remove_line(uuid=oid) - print(f'deleting line with oid: {oid}') + order_mode.on_cancel(oid) + log.info(f'deleting line with oid: {oid}') + # response to 'action' request (buy/sell) elif resp in ('executed',): - - line = order_mode.lines.remove_line(uuid=oid) - print(f'deleting line with oid: {oid}') - - order_mode.arrows.add( - oid, - msg['index'], - msg['price'], - pointing='up' if msg['name'] == 'up' else 'down', - color=line.color - ) - - # 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) + await order_mode.on_exec(oid, msg) diff --git a/piker/brokers/ib.py b/piker/brokers/ib.py index 79dfc5f1..4b9c6579 100644 --- a/piker/brokers/ib.py +++ b/piker/brokers/ib.py @@ -119,6 +119,8 @@ class NonShittyWrapper(Wrapper): """ Get rid of datetime on executions. """ + # this is the IB server's execution time supposedly + # https://interactivebrokers.github.io/tws-api/classIBApi_1_1Execution.html#a2e05cace0aa52d809654c7248e052ef2 execu.time = execu.time.timestamp() return super().execDetails(reqId, contract, execu) diff --git a/piker/data/__init__.py b/piker/data/__init__.py index cbdd7e9f..782aa933 100644 --- a/piker/data/__init__.py +++ b/piker/data/__init__.py @@ -89,7 +89,6 @@ async def maybe_spawn_brokerd( brokername: str, sleep: float = 0.5, loglevel: Optional[str] = None, - expose_mods: List = [], **tractor_kwargs, ) -> tractor._portal.Portal: """If no ``brokerd.{brokername}`` daemon-actor can be found, @@ -180,8 +179,14 @@ class Feed: if not self._trade_stream: self._trade_stream = await self._brokerd_portal.run( + self.mod.stream_trades, - topics=['all'], # do we need this? + + # do we need this? -> yes + # the broker side must declare this key + # in messages, though we could probably use + # more then one? + topics=['trade_events'], ) return self._trade_stream diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index dad829f4..9e97617f 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, open_order_mode from .. import fsp -from .._ems import spawn_router_stream_alerts +from .._ems import _ems_main log = get_logger(__name__) @@ -959,8 +959,9 @@ async def _async_main( # spawn EMS actor-service to_ems_chan = await n.start( - spawn_router_stream_alerts, + _ems_main, order_mode, + brokername, symbol, ) diff --git a/piker/ui/_graphics/_lines.py b/piker/ui/_graphics/_lines.py index 70e8e5ea..20c68399 100644 --- a/piker/ui/_graphics/_lines.py +++ b/piker/ui/_graphics/_lines.py @@ -202,6 +202,9 @@ class L1Labels: self.ask_label._size_br_from_str(self.max_value) +# TODO: probably worth investigating if we can +# make .boundingRect() faster: +# https://stackoverflow.com/questions/26156486/determine-bounding-rect-of-line-in-qt class LevelLine(pg.InfiniteLine): # TODO: fill in these slots for orders diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 81d3ccf2..47db7493 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -17,8 +17,10 @@ """ UX interaction customs. """ +import time from contextlib import asynccontextmanager from dataclasses import dataclass, field +from pprint import pformat from typing import Optional, Dict, Callable import uuid @@ -427,6 +429,57 @@ class OrderMode: self._action = name self.lines.stage_line(color=self._colors[name]) + def on_submit(self, uuid: str) -> dict: + self.lines.commit_line(uuid) + req_msg = self.book._sent_orders.get(uuid) + req_msg['ack_time_ns'] = time.time_ns() + # self.book._confirmed_orders[uuid] = req_msg + return req_msg + + async def on_exec( + self, + uuid: str, + msg: Dict[str, str], + ) -> None: + + line = self.lines.remove_line(uuid=uuid) + log.debug(f'deleting line with oid: {uuid}') + + for fill in msg['fills']: + + self.arrows.add( + uuid, + msg['index'], + msg['price'], + pointing='up' if msg['action'] == 'buy' else 'down', + color=line.color + ) + + # 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) + else: + log.warning(f'Received cancel for unsubmitted order {pformat(msg)}') + def submit_exec(self) -> None: """Send execution order to EMS. From bdc02010cfa3545f59a4457c7a345ba4bee8e055 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 15 Jan 2021 19:40:09 -0500 Subject: [PATCH 016/139] Finally, sanely normalize local trades event data --- piker/brokers/ib.py | 143 +++++++++++++++++++++++++------------------- 1 file changed, 82 insertions(+), 61 deletions(-) diff --git a/piker/brokers/ib.py b/piker/brokers/ib.py index 4b9c6579..a20ea7ef 100644 --- a/piker/brokers/ib.py +++ b/piker/brokers/ib.py @@ -32,6 +32,8 @@ import inspect import itertools import time +import trio +import tractor from async_generator import aclosing from ib_insync.wrapper import RequestError from ib_insync.contract import Contract, ContractDetails @@ -40,15 +42,12 @@ from ib_insync.ticker import Ticker import ib_insync as ibis from ib_insync.wrapper import Wrapper from ib_insync.client import Client as ib_Client -import trio -import tractor from ..log import get_logger, get_console_log from ..data import ( maybe_spawn_brokerd, iterticks, attach_shm_array, - # get_shm_token, subscribe_ohlc_for_increment, _buffer, ) @@ -217,7 +216,6 @@ class Client: # barSizeSetting='1 min', - # always use extended hours useRTH=False, @@ -306,6 +304,10 @@ class Client: # into state clobbering (eg. List: Ticker.ticks). It probably # makes sense to try this once we get the pub-sub working on # individual symbols... + + # XXX UPDATE: we can probably do the tick/trades scraping + # inside our eventkit handler instead to bypass this entirely? + # try: # # give the cache a go # return self._contracts[symbol] @@ -386,7 +388,6 @@ class Client: to_trio, opts: Tuple[int] = ('375', '233',), contract: Optional[Contract] = None, - # opts: Tuple[int] = ('459',), ) -> None: """Stream a ticker using the std L1 api. """ @@ -435,11 +436,11 @@ class Client: # async to be consistent for the client proxy, and cuz why not. async def submit_limit( self, - oid: str, + oid: str, # XXX: see return value symbol: str, price: float, - action: str = 'BUY', - quantity: int = 100, + action: str, + size: int = 100, ) -> int: """Place an order and return integer request id provided by client. @@ -452,16 +453,14 @@ class Client: # against non-known prices. raise RuntimeError("Can not order {symbol}, no live feed?") - # contract.exchange = 'SMART' - trade = self.ib.placeOrder( contract, Order( - # orderId=oid, + # orderId=oid, # stupid api devs.. action=action.upper(), # BUY/SELL orderType='LMT', lmtPrice=price, - totalQuantity=quantity, + totalQuantity=size, outsideRth=True, optOutSmartRouting=True, @@ -469,18 +468,21 @@ class Client: designatedLocation='SMART', ), ) + + # ib doesn't support setting your own id outside + # their own weird client int counting ids.. return trade.order.orderId async def submit_cancel( self, - oid: str, + reqid: str, ) -> None: """Send cancel request for order id ``oid``. """ self.ib.cancelOrder( Order( - orderId=oid, + orderId=reqid, clientId=self.ib.client.clientId, ) ) @@ -491,43 +493,37 @@ class Client: ) -> None: """Stream a ticker using the std L1 api. """ - # contract = contract or (await self.find_contract(symbol)) self.inline_errors(to_trio) def push_tradesies(eventkit_obj, trade, fill=None): """Push events to trio task. """ - # if fill is not None: - # heyoo we executed, and thanks to ib_insync - # we have to handle the callback signature differently - # due to its consistently non-consistent design. + if fill is not None: + # execution details event + item = ('fill', (trade, fill)) + else: + item = ('status', trade) - # yet again convert the datetime since they aren't - # ipc serializable... - # fill.time = fill.time.timestamp - # trade.fill = fill + log.info(f'{eventkit_obj}: {item}') - print(f'{eventkit_obj}: {trade}') - log.debug(trade) - if trade is None: - print("YO WTF NONE") try: - to_trio.send_nowait(trade) + to_trio.send_nowait(item) except trio.BrokenResourceError: - # XXX: eventkit's ``Event.emit()`` for whatever redic - # reason will catch and ignore regular exceptions - # resulting in tracebacks spammed to console.. - # Manually do the dereg ourselves. log.exception(f'Disconnected from {eventkit_obj} updates') eventkit_obj.disconnect(push_tradesies) # hook up to the weird eventkit object - event stream api for ev_name in [ - 'orderStatusEvent', - 'execDetailsEvent', - # XXX: not sure yet if we need these + 'orderStatusEvent', # all order updates + 'execDetailsEvent', # all "fill" updates + # 'commissionReportEvent', + # XXX: ugh, it is a separate event from IB and it's + # emitted as follows: + # self.ib.commissionReportEvent.emit(trade, fill, report) + + # XXX: not sure yet if we need these # 'updatePortfolioEvent', # 'positionEvent', @@ -559,13 +555,13 @@ class Client: ) -> None: log.error(errorString) try: - to_trio.send_nowait( - {'error': { - 'brid': reqId, - 'message': errorString, - 'contract': contract, - }} - ) + to_trio.send_nowait(( + 'error', + # error "object" + {'reqid': reqId, + 'message': errorString, + 'contract': contract} + )) except trio.BrokenResourceError: # XXX: eventkit's ``Event.emit()`` for whatever redic # reason will catch and ignore regular exceptions @@ -838,8 +834,8 @@ async def fill_bars( method='bars', symbol=sym, end_dt=next_dt, - ) + shm.push(bars_array, prepend=True) i += 1 next_dt = bars[0].date @@ -1092,25 +1088,50 @@ async def stream_trades( method='recv_trade_updates', ) - # init startup msg - yield {'trade_events': 'started'} + # startup msg + yield {'local_trades': 'start'} - async for event in stream: - from pprint import pprint + async for event_name, item in stream: - if not isinstance(event, dict): - # remove trade log entries for now until we figure out if we - # even want to retreive them this way and because they're using - # datetimes - event = asdict(event) - pprint(event) - event.pop('log', None) + # XXX: begin normalization of nonsense ib_insync internal + # object-state tracking representations... - # fills = event.get('fills') - # if fills: - # await tractor.breakpoint() - # for fill in fills: - # fill['time'] = fill['time'].timestamp - # exec = fill.pop('execution') + if event_name == 'status': - yield {'trade_events': event} + # unwrap needed data from ib_insync internal objects + trade = item + status = trade.orderStatus + + # skip duplicate filled updates - we get the deats + # from the execution details event + msg = { + 'reqid': trade.order.orderId, + 'status': status.status, + 'filled': status.filled, + 'reason': status.whyHeld, + + # this seems to not be necessarily up to date in the + # execDetails event.. so we have to send it here I guess? + 'remaining': status.remaining, + } + + elif event_name == 'fill': + trade, fill = item + execu = fill.execution + msg = { + 'reqid': execu.orderId, + 'execid': execu.execId, + + # supposedly IB server fill time + 'broker_time': execu.time, # converted to float by us + 'time': fill.time, # ns in main TCP handler by us + 'time_ns': time.time_ns(), # cuz why not + 'action': {'BOT': 'buy', 'SLD': 'sell'}[execu.side], + 'size': execu.shares, + 'price': execu.price, + } + + elif event_name == 'error': + msg = item + + yield {'local_trades': (event_name, msg)} From 3e959ec26014b0de12f68cf8ea573fcdf32acab3 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 15 Jan 2021 19:40:40 -0500 Subject: [PATCH 017/139] Add fill handler to order mode --- piker/ui/_interaction.py | 49 ++++++++++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 47db7493..deee2440 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -21,9 +21,10 @@ import time from contextlib import asynccontextmanager from dataclasses import dataclass, field from pprint import pformat -from typing import Optional, Dict, Callable +from typing import Optional, Dict, Callable, Any import uuid +import trio import pyqtgraph as pg from pyqtgraph import ViewBox, Point, QtCore, QtGui from pyqtgraph import functions as fn @@ -436,24 +437,41 @@ class OrderMode: # self.book._confirmed_orders[uuid] = req_msg return req_msg + def on_fill( + self, + uuid: str, + msg: Dict[str, Any], + ) -> None: + log.info(f'New fill\n{pformat(msg)}') + line = self.lines._order_lines[uuid] + + # XXX: not sure why the time is so off here + # looks like we're gonna have to do some fixing.. + ohlc = self.chart._shm.array + indexes = ohlc['time'] >= msg['broker_time'] + if any(indexes): + arrow_index = ohlc['index'][indexes[-1]] + else: + arrow_index = ohlc['index'][-1] + + self.arrows.add( + uuid, + arrow_index, + msg['price'], + pointing='up' if msg['action'] == 'buy' else 'down', + color=line.color + ) + async def on_exec( self, uuid: str, - msg: Dict[str, 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}') - - for fill in msg['fills']: - - self.arrows.add( - uuid, - msg['index'], - msg['price'], - pointing='up' if msg['action'] == 'buy' else 'down', - color=line.color - ) + log.debug(f'deleting {line} with oid: {uuid}') # DESKTOP NOTIFICATIONS # @@ -478,7 +496,9 @@ class OrderMode: if msg is not None: self.lines.remove_line(uuid=uuid) else: - log.warning(f'Received cancel for unsubmitted order {pformat(msg)}') + log.warning( + f'Received cancel for unsubmitted order {pformat(msg)}' + ) def submit_exec(self) -> None: """Send execution order to EMS. @@ -492,6 +512,7 @@ class OrderMode: # make line graphic line, y = self.lines.create_line(uid) + line.oid = uid # send order cmd to ems self.book.send( From 5acd780eb62dafca3973789c7a110c827c528eb9 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 15 Jan 2021 19:41:03 -0500 Subject: [PATCH 018/139] Get live mode correct and working --- piker/_ems.py | 196 +++++++++++++++++++++-------------------- piker/data/__init__.py | 2 +- piker/ui/_chart.py | 6 +- 3 files changed, 106 insertions(+), 98 deletions(-) diff --git a/piker/_ems.py b/piker/_ems.py index aad8f9b4..f7aa2792 100644 --- a/piker/_ems.py +++ b/piker/_ems.py @@ -61,11 +61,6 @@ class OrderBook: _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 send( self, uuid: str, @@ -217,10 +212,6 @@ def get_book(broker: str) -> _ExecBook: return _books.setdefault(broker, _ExecBook(broker)) -# def scan_quotes( -# quotes: dict, - - async def exec_loop( ctx: tractor.Context, broker: str, @@ -263,8 +254,8 @@ async def exec_loop( # start = time.time() for sym, quote in quotes.items(): - execs = book.orders.get((broker, sym)) - if not execs: + execs = book.orders.pop(sym, None) + if execs is None: continue for tick in quote.get('ticks', ()): @@ -283,30 +274,37 @@ async def exec_loop( if pred(price): # register broker id for ems id - order_id = await client.submit_limit( - oid=oid, + reqid = await client.submit_limit( + # oid=oid, symbol=sym, action=cmd['action'], price=round(price, 2), ) - # resp = book._broker2ems_ids.setdefault( - book._broker2ems_ids[order_id] = oid + book._broker2ems_ids[reqid] = oid resp = { - 'resp': 'submitted', + 'resp': 'dark_exec', 'name': name, - 'ems_trigger_time_ns': time.time_ns(), - # current shm array index - 'index': feed.shm._last.value - 1, + 'time_ns': time.time_ns(), 'trigger_price': price, + 'broker_reqid': reqid, + 'broker': broker, + # 'condition': True, + + # current shm array index - this needed? + 'ohlc_index': feed.shm._last.value - 1, } - await ctx.send_yield(resp) - + # remove exec-condition from set log.info(f'removing pred for {oid}') pred, name, cmd = execs.pop(oid) - log.debug(f'execs are {execs}') + await ctx.send_yield(resp) + + else: # condition scan loop complete + log.debug(f'execs are {execs}') + if execs: + book.orders[symbol] = execs # print(f'execs scan took: {time.time() - start}') # feed teardown @@ -318,10 +316,11 @@ async def exec_loop( # should probably keep the order in some kind of weird state or cancel # it outright? # status='PendingSubmit', message=''), -# status='Cancelled', message='Error 404, reqId 1550: Order held while securities are located.'), +# status='Cancelled', message='Error 404, +# reqId 1550: Order held while securities are located.'), # status='PreSubmitted', message='')], -async def receive_trade_updates( +async def process_broker_trades( ctx: tractor.Context, feed: 'Feed', # noqa book: _ExecBook, @@ -339,75 +338,55 @@ async def receive_trade_updates( first = await trades_stream.__anext__() # startup msg - assert first['trade_events'] == 'started' + assert first['local_trades'] == 'start' task_status.started() - async for trade_event in trades_stream: - event = trade_event['trade_events'] + async for msg in trades_stream: + name, ev = msg['local_trades'] + log.info(f'Received broker trade event:\n{pformat(ev)}') - try: - order = event['order'] - except KeyError: + # broker request id - must be normalized + # into error transmission by broker backend. + reqid = ev['reqid'] + oid = book._broker2ems_ids.get(reqid) - # Relay broker error messages - err = event['error'] + # make response packet to EMS client(s) + resp = {'oid': oid} - # broker request id - must be normalized - # into error transmission by broker backend. - reqid = err['brid'] - - # TODO: handle updates! - oid = book._broker2ems_ids.get(reqid) + if name in ('error',): + # TODO: figure out how this will interact with EMS clients + # for ex. on an error do we react with a dark orders + # management response, like cancelling all dark orders? # XXX should we make one when it's blank? - log.error(pformat(err['message'])) + log.error(pformat(ev['message'])) - else: - log.info(f'Received broker trade event:\n{pformat(event)}') + elif name in ('status',): - status = event['orderStatus']['status'] - reqid = order['orderId'] + status = ev['status'].lower() - # TODO: handle updates! - oid = book._broker2ems_ids.get(reqid) + if status == 'filled': + # conditional execution is fully complete + if not ev['remaining']: + log.info(f'Execution for {oid} is complete!') + await ctx.send_yield({'resp': 'executed', 'oid': oid}) + else: + # one of (submitted, cancelled) + resp['resp'] = 'broker_' + status - if status in {'Cancelled'}: - resp = {'resp': 'cancelled'} + await ctx.send_yield(resp) - elif status in {'Submitted'}: - # ack-response that order is live/submitted - # to the broker - resp = {'resp': 'submitted'} - - # elif status in {'Executed', 'Filled'}: - elif status in {'Filled'}: - - # order was filled by broker - fills = [] - for fill in event['fills']: - e = fill['execution'] - fills.append( - (e.time, e.price, e.shares, e.side) - ) - - resp = { - 'resp': 'executed', - 'fills': fills, - } - - else: # active in EMS - # ack-response that order is live in EMS - # (aka as a client side limit) - resp = {'resp': 'active'} - - # send response packet to EMS client(s) - resp['oid'] = oid + elif name in ('fill',): + # proxy through the "fill" result(s) + resp['resp'] = 'broker_filled' + resp.update(ev) + log.info(f'Fill for {oid} cleared with\n{pformat(resp)}') await ctx.send_yield(resp) @tractor.stream -async def stream_and_route( +async def _ems_main( ctx: tractor.Context, client_actor_name: str, broker: str, @@ -440,7 +419,7 @@ async def stream_and_route( # for paper mode we need to mock this trades response feed await n.start( - receive_trade_updates, + process_broker_trades, ctx, feed, book, @@ -452,32 +431,31 @@ async def stream_and_route( action = cmd['action'] oid = cmd['oid'] - sym = cmd['symbol'] - if action == 'cancel': + if action in ('cancel',): # check for live-broker order brid = book._broker2ems_ids.inverse[oid] if brid: log.info("Submitting cancel for live order") - await client.submit_cancel(oid=brid) + await client.submit_cancel(reqid=brid) # check for EMS active exec else: book.orders[symbol].pop(oid, None) await ctx.send_yield( - {'action': 'cancelled', + {'action': 'dark_cancelled', 'oid': oid} ) elif action in ('alert', 'buy', 'sell',): + sym = cmd['symbol'] trigger_price = cmd['price'] brokers = cmd['brokers'] broker = brokers[0] last = book.lasts[(broker, sym)] - # print(f'Known last is {last}') if action in ('buy', 'sell',): @@ -491,17 +469,18 @@ async def stream_and_route( # register broker id for ems id order_id = await client.submit_limit( - oid=oid, + oid=oid, # no ib support for this symbol=sym, action=action, price=round(trigger_price, 2), + size=1, ) book._broker2ems_ids[order_id] = oid # book.orders[symbol][oid] = None # XXX: the trades data broker response loop - # (``receive_trade_updates()`` above) will + # (``process_broker_trades()`` above) will # handle sending the ems side acks back to # the cmd sender from here @@ -516,30 +495,52 @@ async def stream_and_route( pred, name = mk_check(trigger_price, last) # submit execution/order to EMS scanner loop - # create list of executions on first entry book.orders.setdefault( (broker, sym), {} )[oid] = (pred, name, cmd) # ack-response that order is live here await ctx.send_yield({ - 'resp': 'ems_active', + 'resp': 'dark_submitted', 'oid': oid }) # continue and wait on next order cmd -async def _ems_main( +async def open_ems( order_mode, broker: str, symbol: Symbol, - # lines: 'LinesEditor', task_status: TaskStatus[str] = trio.TASK_STATUS_IGNORED, ) -> None: """Spawn an EMS daemon and begin sending orders and receiving alerts. + + This EMS tries to reduce most broker's terrible order entry apis to + a very simple protocol built on a few easy to grok and/or + "rantsy" premises: + + - most users will prefer "dark mode" where orders are not submitted + to a broker until and execution condition is triggered + (aka client-side "hidden orders") + + - Brokers over-complicate their apis and generally speaking hire + poor designers to create them. We're better off using creating a super + minimal, schema-simple, request-event-stream protocol to unify all the + existing piles of shit (and shocker, it'll probably just end up + looking like a decent crypto exchange's api) + + - all order types can be implemented with client-side limit orders + + - we aren't reinventing a wheel in this case since none of these + brokers are exposing FIX protocol; it is they doing the re-invention. + + + TODO: make some fancy diagrams using this: + + """ actor = tractor.current_actor() @@ -553,7 +554,7 @@ async def _ems_main( enable_modules=[__name__], ) stream = await portal.run( - stream_and_route, + _ems_main, client_actor_name=actor.name, broker=broker, symbol=symbol.key, @@ -564,29 +565,36 @@ async def _ems_main( # let parent task continue task_status.started(_to_ems) - # begin the trigger-alert stream + # Begin order-response streaming + # this is where we receive **back** messages # about executions **from** the EMS actor async for msg in stream: + log.info(f'Received order msg: {pformat(msg)}') # delete the line from view oid = msg['oid'] resp = msg['resp'] # response to 'action' request (buy/sell) - if resp in ('ems_active', 'submitted'): + if resp in ('dark_submitted', 'broker_submitted'): log.info(f"order accepted: {msg}") # show line label once order is live order_mode.on_submit(oid) - # response to 'cancel' request - elif resp in ('cancelled',): + # resp to 'cancel' request or error condition for action request + elif resp in ('broker_cancelled', 'dark_cancelled'): # delete level from view order_mode.on_cancel(oid) log.info(f'deleting line with oid: {oid}') - # response to 'action' request (buy/sell) + # response to completed 'action' request for buy/sell elif resp in ('executed',): await order_mode.on_exec(oid, msg) + + # each clearing tick is responded individually + elif resp in ('broker_filled',): + # TODO: some kinda progress system + order_mode.on_fill(oid, msg) diff --git a/piker/data/__init__.py b/piker/data/__init__.py index 782aa933..f2551993 100644 --- a/piker/data/__init__.py +++ b/piker/data/__init__.py @@ -186,7 +186,7 @@ class Feed: # the broker side must declare this key # in messages, though we could probably use # more then one? - topics=['trade_events'], + topics=['local_trades'], ) return self._trade_stream diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 9e97617f..25636794 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, open_order_mode from .. import fsp -from .._ems import _ems_main +from .._ems import open_ems log = get_logger(__name__) @@ -958,8 +958,8 @@ async def _async_main( # inside the above mngr? # spawn EMS actor-service - to_ems_chan = await n.start( - _ems_main, + await n.start( + open_ems, order_mode, brokername, symbol, From 2bf95d7ec77c1ce21564b9874fdff844498297a1 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 15 Jan 2021 20:57:25 -0500 Subject: [PATCH 019/139] Fix clients map typing annot --- piker/brokers/api.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/piker/brokers/api.py b/piker/brokers/api.py index ba54a565..f54a0e86 100644 --- a/piker/brokers/api.py +++ b/piker/brokers/api.py @@ -28,6 +28,7 @@ from ..log import get_logger log = get_logger(__name__) +_clients: Dict[str, 'Client'] = {} @asynccontextmanager async def get_cached_client( @@ -39,29 +40,38 @@ async def get_cached_client( If one has not been setup do it and cache it. """ - # check if a cached client is in the local actor's statespace - ss = tractor.current_actor().statespace + global _clients + clients = ss.setdefault('clients', {'_lock': trio.Lock()}) lock = clients['_lock'] + client = None + try: - log.info(f"Loading existing `{brokername}` daemon") + log.info(f"Loading existing `{brokername}` client") + async with lock: client = clients[brokername] client._consumers += 1 + yield client + except KeyError: log.info(f"Creating new client for broker {brokername}") + async with lock: brokermod = get_brokermod(brokername) exit_stack = AsyncExitStack() + client = await exit_stack.enter_async_context( brokermod.get_client() ) client._consumers = 0 client._exit_stack = exit_stack clients[brokername] = client + yield client + finally: client._consumers -= 1 if client._consumers <= 0: From f3ae8db04b7707b9c42b054c0055271325b99a63 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 18 Jan 2021 19:55:50 -0500 Subject: [PATCH 020/139] Big refactor; start paper client --- piker/_ems.py | 561 ++++++++++++++++++++++++----------------- piker/data/__init__.py | 27 +- 2 files changed, 342 insertions(+), 246 deletions(-) diff --git a/piker/_ems.py b/piker/_ems.py index f7aa2792..dec7989c 100644 --- a/piker/_ems.py +++ b/piker/_ems.py @@ -20,6 +20,7 @@ In suit parlance: "Execution management systems" """ from pprint import pformat import time +from contextlib import asynccontextmanager from dataclasses import dataclass, field from typing import ( AsyncIterator, Dict, Callable, Tuple, @@ -37,105 +38,6 @@ from .data._source import Symbol log = get_logger(__name__) -# 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 OrderBook: - """Buy-side (client-side ?) order book ctl and tracking. - - 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. - - """ - _sent_orders: Dict[str, dict] = field(default_factory=dict) - # _confirmed_orders: Dict[str, dict] = field(default_factory=dict) - - _to_ems: trio.abc.SendChannel = _to_ems - _from_order_book: trio.abc.ReceiveChannel = _from_order_book - - def send( - self, - uuid: str, - symbol: 'Symbol', - price: float, - action: str, - ) -> str: - cmd = { - 'action': action, - 'price': price, - 'symbol': symbol.key, - 'brokers': symbol.brokers, - 'oid': uuid, - } - self._sent_orders[uuid] = cmd - self._to_ems.send_nowait(cmd) - - async def modify(self, oid: str, price) -> bool: - ... - - def cancel(self, uuid: str) -> bool: - """Cancel an order (or alert) from the EMS. - - """ - cmd = self._sent_orders[uuid] - msg = { - 'action': 'cancel', - 'oid': uuid, - 'symbol': cmd['symbol'], - } - self._to_ems.send_nowait(msg) - - -_orders: OrderBook = 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 - - global _orders - - if _orders is None: - _orders = OrderBook() - - return _orders - - -# TODO: make this a ``tractor.msg.pub`` -async def send_order_cmds(): - """Order streaming task: deliver orders transmitted from UI - to downstream consumers. - - This is run in the UI actor (usually the one running Qt but could be - any other client service code). This process 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 relayed to any consumer(s) that called this function using - a ``tractor`` portal. - - This effectively makes order messages look like they're being - "pushed" from the parent to the EMS where local sync code is likely - doing the pushing from some UI. - - """ - global _from_order_book - - async for cmd in _from_order_book: - - # send msg over IPC / wire - log.info(f'sending order cmd: {cmd}') - yield cmd - # TODO: numba all of this def mk_check(trigger_price, known_last) -> Callable[[float, float], bool]: @@ -181,7 +83,6 @@ class _ExecBook: # levels which have an executable action (eg. alert, order, signal) orders: Dict[ - # Tuple[str, str], str, # symbol Dict[ str, # uuid @@ -212,10 +113,48 @@ def get_book(broker: str) -> _ExecBook: return _books.setdefault(broker, _ExecBook(broker)) +@dataclass +class PaperBoi: + """Emulates a broker order client providing the same API and + order-event response event stream format but with methods for + triggering desired events based on forward testing engine + requirements. + + """ + _to_trade_stream: trio.abc.SendChannel + trade_stream: trio.abc.ReceiveChannel + + async def submit_limit( + self, + oid: str, # XXX: see return value + symbol: str, + price: float, + action: str, + size: int = 100, + ) -> int: + """Place an order and return integer request id provided by client. + + """ + + async def submit_cancel( + self, + reqid: str, + ) -> None: + + # TODO: fake market simulation effects + self._to_trade_stream() + + def emulate_fill( + self + ) -> None: + ... + + async def exec_loop( ctx: tractor.Context, broker: str, symbol: str, + _exec_mode: str, task_status: TaskStatus[dict] = trio.TASK_STATUS_IGNORED, ) -> AsyncIterator[dict]: @@ -231,7 +170,27 @@ async def exec_loop( book.lasts[(broker, symbol)] = first_quote[symbol]['last'] # TODO: wrap this in a more re-usable general api - client = feed.mod.get_client_proxy(feed._brokerd_portal) + client_factory = getattr(feed.mod, 'get_client_proxy', None) + + # we have an order API for this broker + if client_factory is not None and _exec_mode != 'paper': + client = client_factory(feed._brokerd_portal) + + # force paper mode + else: + log.warning( + f'No order client is yet supported for {broker}, ' + 'entering paper mode') + + client = PaperBoi(*trio.open_memory_channel(100)) + + # for paper mode we need to mock this trades response feed + # so we pass a duck-typed feed-looking mem chan which is fed + # fill and submission events from the exec loop + feed._set_fake_trades_stream(client.trade_stream) + + # init the trades stream + client._to_trade_stream.send_nowait({'local_trades': 'start'}) # return control to parent task task_status.started((first_quote, feed, client)) @@ -245,8 +204,7 @@ async def exec_loop( stream = feed.stream with stream.shield(): - # this stream may eventually contain multiple - # symbols + # this stream may eventually contain multiple symbols async for quotes in stream: # TODO: numba all this! @@ -269,37 +227,39 @@ async def exec_loop( for oid, (pred, name, cmd) in tuple(execs.items()): - # push trigger msg back to parent as an "alert" - # (mocking for eg. a "fill") - if pred(price): + # majority of iterations will be non-matches + if not pred(price): + continue - # register broker id for ems id - reqid = await client.submit_limit( - # oid=oid, - symbol=sym, - action=cmd['action'], - price=round(price, 2), - ) - book._broker2ems_ids[reqid] = oid + reqid = await client.submit_limit( + oid=oid, + symbol=sym, + action=cmd['action'], + price=round(price, 2), + size=1, + ) + # register broker request id to ems id + book._broker2ems_ids[reqid] = oid - resp = { - 'resp': 'dark_exec', - 'name': name, - 'time_ns': time.time_ns(), - 'trigger_price': price, - 'broker_reqid': reqid, - 'broker': broker, - # 'condition': True, + resp = { + 'resp': 'dark_executed', + 'name': name, + 'time_ns': time.time_ns(), + 'trigger_price': price, + 'broker_reqid': reqid, + 'broker': broker, + 'oid': oid, + 'cmd': cmd, # original request message - # current shm array index - this needed? - 'ohlc_index': feed.shm._last.value - 1, - } + # current shm array index - this needed? + # 'ohlc_index': feed.shm._last.value - 1, + } - # remove exec-condition from set - log.info(f'removing pred for {oid}') - pred, name, cmd = execs.pop(oid) + # remove exec-condition from set + log.info(f'removing pred for {oid}') + pred, name, cmd = execs.pop(oid) - await ctx.send_yield(resp) + await ctx.send_yield(resp) else: # condition scan loop complete log.debug(f'execs are {execs}') @@ -310,8 +270,8 @@ async def exec_loop( # feed teardown -# XXX: right now this is very very ad-hoc to IB # TODO: lots of cases still to handle +# XXX: right now this is very very ad-hoc to IB # - short-sale but securities haven't been located, in this case we # should probably keep the order in some kind of weird state or cancel # it outright? @@ -333,24 +293,38 @@ async def process_broker_trades( and appropriate responses relayed back to the original EMS client actor. There is a messaging translation layer throughout. + Expected message translation(s): + + broker ems + 'error' -> log it locally (for now) + 'status' -> relabel as 'broker_', if complete send 'executed' + 'fill' -> 'broker_filled' + + Currently accepted status values from IB + {'presubmitted', 'submitted', 'cancelled'} + """ - trades_stream = await feed.recv_trades_data() - first = await trades_stream.__anext__() + broker = feed.mod.name + + with trio.fail_after(3): + trades_stream = await feed.recv_trades_data() + first = await trades_stream.__anext__() # startup msg assert first['local_trades'] == 'start' task_status.started() - async for msg in trades_stream: - name, ev = msg['local_trades'] - log.info(f'Received broker trade event:\n{pformat(ev)}') + async for event in trades_stream: - # broker request id - must be normalized - # into error transmission by broker backend. - reqid = ev['reqid'] - oid = book._broker2ems_ids.get(reqid) + name, msg = event['local_trades'] + log.info(f'Received broker trade event:\n{pformat(msg)}') + + # Get the broker (order) request id, this **must** be normalized + # into messaging provided by the broker backend + reqid = msg['reqid'] # make response packet to EMS client(s) + oid = book._broker2ems_ids.get(reqid) resp = {'oid': oid} if name in ('error',): @@ -358,18 +332,37 @@ async def process_broker_trades( # for ex. on an error do we react with a dark orders # management response, like cancelling all dark orders? + # This looks like a supervision policy for pending orders on + # some unexpected failure - something we need to think more + # about. In most default situations, with composed orders + # (ex. brackets), most brokers seem to use a oca policy. + + message = msg['message'] + # XXX should we make one when it's blank? - log.error(pformat(ev['message'])) + log.error(pformat(message)) + + # another stupid ib error to handle + # if 10147 in message: cancel elif name in ('status',): - status = ev['status'].lower() + # everyone doin camel case + status = msg['status'].lower() if status == 'filled': - # conditional execution is fully complete - if not ev['remaining']: + + # conditional execution is fully complete, no more + # fills for the noted order + if not msg['remaining']: + await ctx.send_yield( + {'resp': 'broker_executed', 'oid': oid}) log.info(f'Execution for {oid} is complete!') - await ctx.send_yield({'resp': 'executed', 'oid': oid}) + + # just log it + else: + log.info(f'{broker} filled {msg}') + else: # one of (submitted, cancelled) resp['resp'] = 'broker_' + status @@ -379,10 +372,9 @@ async def process_broker_trades( elif name in ('fill',): # proxy through the "fill" result(s) resp['resp'] = 'broker_filled' - resp.update(ev) - - log.info(f'Fill for {oid} cleared with\n{pformat(resp)}') + resp.update(msg) await ctx.send_yield(resp) + log.info(f'Fill for {oid} cleared with\n{pformat(resp)}') @tractor.stream @@ -393,31 +385,50 @@ async def _ems_main( symbol: str, mode: str = 'live', # ('paper', 'dark', 'live') ) -> None: - """EMS (sub)actor entrypoint. + """EMS (sub)actor entrypoint providing the + execution management (micro)service which conducts broker + order control on behalf of clients. - This is the daemon (child) side routine which starts an EMS - runtime per broker/feed and and begins streaming back alerts - from executions to order clients. + This is the daemon (child) side routine which starts an EMS runtime + (one per broker-feed) and and begins streaming back alerts from + broker executions/fills. + + ``send_order_cmds()`` is called here to execute in a task back in + the actor which started this service (spawned this actor), presuming + capabilities allow it, such that requests for EMS executions are + received in a stream from that client actor and then responses are + streamed back up to the original calling task in the same client. + + The task tree is: + - ``_ems_main()``: + accepts order cmds, registers execs with exec loop + + - ``exec_loop()``: run conditions on inputs and trigger executions + + - ``process_broker_trades()``: + accept normalized trades responses, process and relay to ems client(s) """ actor = tractor.current_actor() book = get_book(broker) - # new router entry point + # get a portal back to the client async with tractor.wait_for_actor(client_actor_name) as portal: # spawn one task per broker feed async with trio.open_nursery() as n: # TODO: eventually support N-brokers + + # start the condition scan loop quote, feed, client = await n.start( exec_loop, ctx, broker, symbol, + mode, ) - # for paper mode we need to mock this trades response feed await n.start( process_broker_trades, ctx, @@ -425,6 +436,7 @@ async def _ems_main( book, ) + # connect back to the calling actor to receive order requests async for cmd in await portal.run(send_order_cmds): log.info(f'{cmd} received in {actor.uid}') @@ -435,7 +447,7 @@ async def _ems_main( if action in ('cancel',): # check for live-broker order - brid = book._broker2ems_ids.inverse[oid] + brid = book._broker2ems_ids.inverse.get(oid) if brid: log.info("Submitting cancel for live order") await client.submit_cancel(reqid=brid) @@ -443,10 +455,11 @@ async def _ems_main( # check for EMS active exec else: book.orders[symbol].pop(oid, None) - await ctx.send_yield( - {'action': 'dark_cancelled', - 'oid': oid} - ) + + await ctx.send_yield({ + 'resp': 'dark_cancelled', + 'oid': oid + }) elif action in ('alert', 'buy', 'sell',): @@ -457,62 +470,163 @@ async def _ems_main( last = book.lasts[(broker, sym)] - if action in ('buy', 'sell',): + if mode == 'live' and action in ('buy', 'sell',): + + # register broker id for ems id + order_id = await client.submit_limit( + oid=oid, # no ib support for this + symbol=sym, + action=action, + price=round(trigger_price, 2), + size=1, + ) + book._broker2ems_ids[order_id] = oid + + # XXX: the trades data broker response loop + # (``process_broker_trades()`` above) will + # handle sending the ems side acks back to + # the cmd sender from here + + elif mode in ('dark', 'paper') or action in ('alert'): # if the predicate resolves immediately send the # execution to the broker asap # if pred(last): - if mode == 'live': - # send order - log.warning("ORDER FILLED IMMEDIATELY!?!?!?!") - # IF SEND ORDER RIGHT AWAY CONDITION + # send order - # register broker id for ems id - order_id = await client.submit_limit( - oid=oid, # no ib support for this - symbol=sym, - action=action, - price=round(trigger_price, 2), - size=1, - ) - book._broker2ems_ids[order_id] = oid + # IF SEND ORDER RIGHT AWAY CONDITION - # book.orders[symbol][oid] = None + # submit order to local EMS - # XXX: the trades data broker response loop - # (``process_broker_trades()`` above) will - # handle sending the ems side acks back to - # the cmd sender from here + # 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) - elif mode in {'dark', 'paper'}: + # submit execution/order to EMS scanner loop + book.orders.setdefault( + sym, {} + )[oid] = (pred, name, cmd) - # 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) - - # submit execution/order to EMS scanner loop - book.orders.setdefault( - (broker, sym), {} - )[oid] = (pred, name, cmd) - - # ack-response that order is live here - await ctx.send_yield({ - 'resp': 'dark_submitted', - 'oid': oid - }) + # ack-response that order is live here + await ctx.send_yield({ + 'resp': 'dark_submitted', + 'oid': oid + }) # continue and wait on next order cmd +@dataclass +class OrderBook: + """Buy-side (client-side ?) order book ctl and tracking. + + 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. + + """ + _to_ems: trio.abc.SendChannel + _from_order_book: trio.abc.ReceiveChannel + + _sent_orders: Dict[str, dict] = field(default_factory=dict) + _ready_to_receive: trio.Event = trio.Event() + + def send( + self, + uuid: str, + symbol: 'Symbol', + price: float, + action: str, + ) -> str: + cmd = { + 'action': action, + 'price': price, + 'symbol': symbol.key, + 'brokers': symbol.brokers, + 'oid': uuid, + } + self._sent_orders[uuid] = cmd + self._to_ems.send_nowait(cmd) + + async def modify(self, oid: str, price) -> bool: + ... + + def cancel(self, uuid: str) -> bool: + """Cancel an order (or alert) from the EMS. + + """ + cmd = self._sent_orders[uuid] + msg = { + 'action': 'cancel', + 'oid': uuid, + 'symbol': cmd['symbol'], + } + self._to_ems.send_nowait(msg) + + +_orders: OrderBook = 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 + + global _orders + + if _orders is None: + # setup local ui event streaming channels for request/resp + # streamging with EMS daemon + # _to_ems, _from_order_book = trio.open_memory_channel(100) + _orders = OrderBook(*trio.open_memory_channel(100)) + + return _orders + + +# TODO: make this a ``tractor.msg.pub`` +async def send_order_cmds(): + """Order streaming task: deliver orders transmitted from UI + to downstream consumers. + + This is run in the UI actor (usually the one running Qt but could be + any other client service code). This process 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 relayed to any consumer(s) that called this function using + a ``tractor`` portal. + + This effectively makes order messages look like they're being + "pushed" from the parent to the EMS where local sync code is likely + doing the pushing from some UI. + + """ + book = get_orders() + orders_stream = book._from_order_book + + # signal that ems connection is up and ready + book._ready_to_receive.set() + + async for cmd in orders_stream: + + # send msg over IPC / wire + log.info(f'sending order cmd: {cmd}') + yield cmd + + +@asynccontextmanager async def open_ems( - order_mode, broker: str, symbol: Symbol, - task_status: TaskStatus[str] = trio.TASK_STATUS_IGNORED, + # task_status: TaskStatus[str] = trio.TASK_STATUS_IGNORED, ) -> None: """Spawn an EMS daemon and begin sending orders and receiving alerts. @@ -538,11 +652,15 @@ async def open_ems( brokers are exposing FIX protocol; it is they doing the re-invention. - TODO: make some fancy diagrams using this: + TODO: make some fancy diagrams using mermaid.io + the possible set of responses from the stream is currently: + - 'dark_submitted', 'broker_submitted' + - 'dark_cancelled', 'broker_cancelled' + - 'dark_executed', 'broker_executed' + - 'broker_filled' """ - actor = tractor.current_actor() subactor_name = 'emsd' @@ -553,7 +671,7 @@ async def open_ems( subactor_name, enable_modules=[__name__], ) - stream = await portal.run( + trades_stream = await portal.run( _ems_main, client_actor_name=actor.name, broker=broker, @@ -561,40 +679,11 @@ async def open_ems( ) - async with tractor.wait_for_actor(subactor_name): - # let parent task continue - task_status.started(_to_ems) + # wait for service to connect back to us signalling + # ready for order commands + book = get_orders() - # Begin order-response streaming + with trio.fail_after(3): + await book._ready_to_receive.wait() - # this is where we receive **back** messages - # about executions **from** the EMS actor - async for msg in stream: - log.info(f'Received order msg: {pformat(msg)}') - - # delete the line from view - oid = msg['oid'] - resp = msg['resp'] - - # response to 'action' request (buy/sell) - if resp in ('dark_submitted', 'broker_submitted'): - log.info(f"order accepted: {msg}") - - # 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', 'dark_cancelled'): - - # delete level from view - order_mode.on_cancel(oid) - log.info(f'deleting line with oid: {oid}') - - # response to completed 'action' request for buy/sell - elif resp in ('executed',): - await order_mode.on_exec(oid, msg) - - # each clearing tick is responded individually - elif resp in ('broker_filled',): - # TODO: some kinda progress system - order_mode.on_fill(oid, msg) + yield book, trades_stream diff --git a/piker/data/__init__.py b/piker/data/__init__.py index f2551993..fe50eda6 100644 --- a/piker/data/__init__.py +++ b/piker/data/__init__.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 @@ -26,10 +26,10 @@ from contextlib import asynccontextmanager from importlib import import_module from types import ModuleType from typing import ( - Dict, List, Any, - Sequence, AsyncIterator, Optional + Dict, Any, Sequence, AsyncIterator, Optional ) +import trio import tractor from ..brokers import get_brokermod @@ -165,19 +165,26 @@ class Feed: return self._index_stream + def _set_fake_trades_stream( + self, + recv_chan: trio.abc.ReceiveChannel, + ) -> None: + self._trade_stream = recv_chan + async def recv_trades_data(self) -> AsyncIterator[dict]: if not getattr(self.mod, 'stream_trades', False): - log.warning(f"{self.mod.name} doesn't have trade data support yet :(") + log.warning( + f"{self.mod.name} doesn't have trade data support yet :(") - # yah this is bullshitty but it worx - async def nuttin(): - yield - return - - return nuttin() + if not self._trade_stream: + raise RuntimeError( + f'Can not stream trade data from {self.mod.name}') + # NOTE: this can be faked by setting a rx chan + # using the ``_.set_fake_trades_stream()`` method if not self._trade_stream: + self._trade_stream = await self._brokerd_portal.run( self.mod.stream_trades, From e6724b65599b3243c03d5c1d3cabc6c8b71f7068 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 18 Jan 2021 20:28:37 -0500 Subject: [PATCH 021/139] Move order mode handling into charting code --- piker/ui/_chart.py | 90 +++++++++++++++++++++++++++++++++------- piker/ui/_interaction.py | 47 ++++++++++----------- 2 files changed, 99 insertions(+), 38 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 25636794..91f8332f 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -18,8 +18,10 @@ 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 @@ -950,23 +952,83 @@ async def _async_main( linked_charts ) - async with open_order_mode( - chart, - ) as order_mode: + # spawn EMS actor-service + async with open_ems( + brokername, + symbol, + ) as (book, trades_stream): - # TODO: this should probably be implicitly spawned - # inside the above mngr? + async with open_order_mode( + chart, + book, + ) as order_mode: - # spawn EMS actor-service - await n.start( - open_ems, - order_mode, - brokername, - symbol, - ) + 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 - # probably where we'll eventually start the user input loop - await trio.sleep_forever() + 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: {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', 'dark_cancelled'): + + # delete level line from view + order_mode.on_cancel(oid) + + elif resp in ('dark_executed'): + log.info(f'Dark order filled 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', + ) async def chart_from_quotes( diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index deee2440..4ba6280d 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -33,7 +33,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 get_orders, OrderBook +from .._ems import OrderBook log = get_logger(__name__) @@ -370,12 +370,16 @@ class ArrowEditor: x: float, y: float, color='default', - pointing: str = 'up', + pointing: Optional[str] = None, ) -> pg.ArrowItem: """Add an arrow graphic to view at given (x, y). """ - angle = 90 if pointing == 'up' else -90 + angle = { + 'up': 90, + 'down': -90, + None: 0, + }[pointing] yb = pg.mkBrush(hcolor(color)) arrow = pg.ArrowItem( @@ -431,34 +435,33 @@ class OrderMode: self.lines.stage_line(color=self._colors[name]) 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'). + + """ self.lines.commit_line(uuid) req_msg = self.book._sent_orders.get(uuid) req_msg['ack_time_ns'] = time.time_ns() - # self.book._confirmed_orders[uuid] = req_msg + return req_msg def on_fill( self, uuid: str, - msg: Dict[str, Any], + price: float, + arrow_index: float, + pointing: Optional[str] = None ) -> None: - log.info(f'New fill\n{pformat(msg)}') + line = self.lines._order_lines[uuid] - - # XXX: not sure why the time is so off here - # looks like we're gonna have to do some fixing.. - ohlc = self.chart._shm.array - indexes = ohlc['time'] >= msg['broker_time'] - if any(indexes): - arrow_index = ohlc['index'][indexes[-1]] - else: - arrow_index = ohlc['index'][-1] - self.arrows.add( uuid, arrow_index, - msg['price'], - pointing='up' if msg['action'] == 'buy' else 'down', + price, + pointing=pointing, color=line.color ) @@ -526,11 +529,12 @@ class OrderMode: @asynccontextmanager async def open_order_mode( chart, + book: OrderBook, ): # global _order_lines view = chart._vb - book = get_orders() + # book = get_orders() lines = LineEditor(view=view, _order_lines=_order_lines, chart=chart) arrows = ArrowEditor(chart, {}) @@ -539,11 +543,6 @@ async def open_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 From 794665db70489fc945c79bc82de708d2c59e54b1 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 18 Jan 2021 20:28:52 -0500 Subject: [PATCH 022/139] Drop log msg --- piker/brokers/ib.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/piker/brokers/ib.py b/piker/brokers/ib.py index a20ea7ef..2b4fd4fb 100644 --- a/piker/brokers/ib.py +++ b/piker/brokers/ib.py @@ -1079,8 +1079,6 @@ async def stream_trades( get_topics: Callable = None, ) -> AsyncIterator[Dict[str, Any]]: - log.error('startedddd daa tradeeeez feeeedddzzz') - # XXX: required to propagate ``tractor`` loglevel to piker logging get_console_log(loglevel or tractor.current_actor().loglevel) From 149820b3b0b5b9a2fb3608e615786651ea0c0441 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 19 Jan 2021 16:58:01 -0500 Subject: [PATCH 023/139] Get "dark (order) mode" workin! Basically a stop limit mode where the dirty execution-condition deats are entirely held client side away from the broker. For now, there's a static order size setting and a 0.5% limit setting relative to the trigger price. Swap to using 'd' for dump and 'f' for fill - they're easier for use with ctrl (which is used now to submit orders directly to broker - ala "live (order) mode"). Still more kinks to work out with too fast cancelled orders and alerts but we're getting there. --- piker/_ems.py | 89 ++++++++++++++++++++++++++++++++++----------------- 1 file changed, 59 insertions(+), 30 deletions(-) diff --git a/piker/_ems.py b/piker/_ems.py index dec7989c..14501048 100644 --- a/piker/_ems.py +++ b/piker/_ems.py @@ -34,6 +34,7 @@ import tractor from . import data from .log import get_logger from .data._source import Symbol +from .data._normalize import iterticks log = get_logger(__name__) @@ -58,14 +59,14 @@ def mk_check(trigger_price, known_last) -> Callable[[float, float], bool]: def check_gt(price: float) -> bool: return price >= trigger_price - return check_gt, 'down' + return check_gt elif trigger_price <= known_last: def check_lt(price: float) -> bool: return price <= trigger_price - return check_lt, 'up' + return check_lt else: return None, None @@ -113,6 +114,9 @@ def get_book(broker: str) -> _ExecBook: return _books.setdefault(broker, _ExecBook(broker)) +_DEFAULT_SIZE: float = 100.0 + + @dataclass class PaperBoi: """Emulates a broker order client providing the same API and @@ -130,7 +134,7 @@ class PaperBoi: symbol: str, price: float, action: str, - size: int = 100, + size: int = _DEFAULT_SIZE, ) -> int: """Place an order and return integer request id provided by client. @@ -212,38 +216,51 @@ async def exec_loop( # start = time.time() for sym, quote in quotes.items(): - execs = book.orders.pop(sym, None) + execs = book.orders.get(sym, None) if execs is None: continue - for tick in quote.get('ticks', ()): + for tick in iterticks( + quote, + # dark order price filter(s) + types=('ask', 'bid', 'trade', 'last') + ): price = tick.get('price') + ttype = tick['type'] + + # lel, fuck you ib if price < 0: - # lel, fuck you ib + log.error(f'!!?!?!VOLUME TICK {tick}!?!?') continue # update to keep new cmds informed book.lasts[(broker, symbol)] = price - for oid, (pred, name, cmd) in tuple(execs.items()): + for oid, (pred, tf, cmd, percent_away) in ( + tuple(execs.items()) + ): - # majority of iterations will be non-matches - if not pred(price): + if (ttype not in tf) or (not pred(price)): + # majority of iterations will be non-matches continue + submit_price = price + price*percent_away + print( + f'Dark order triggered for price {price}\n' + f'Submitting order @ price {submit_price}') + reqid = await client.submit_limit( oid=oid, symbol=sym, action=cmd['action'], - price=round(price, 2), - size=1, + price=round(submit_price, 2), + size=_DEFAULT_SIZE, ) # register broker request id to ems id book._broker2ems_ids[reqid] = oid resp = { 'resp': 'dark_executed', - 'name': name, 'time_ns': time.time_ns(), 'trigger_price': price, 'broker_reqid': reqid, @@ -257,7 +274,7 @@ async def exec_loop( # remove exec-condition from set log.info(f'removing pred for {oid}') - pred, name, cmd = execs.pop(oid) + pred, tf, cmd, percent_away = execs.pop(oid) await ctx.send_yield(resp) @@ -306,7 +323,7 @@ async def process_broker_trades( """ broker = feed.mod.name - with trio.fail_after(3): + with trio.fail_after(5): trades_stream = await feed.recv_trades_data() first = await trades_stream.__anext__() @@ -383,7 +400,7 @@ async def _ems_main( client_actor_name: str, broker: str, symbol: str, - mode: str = 'live', # ('paper', 'dark', 'live') + mode: str = 'dark', # ('paper', 'dark', 'live') ) -> None: """EMS (sub)actor entrypoint providing the execution management (micro)service which conducts broker @@ -454,12 +471,15 @@ async def _ems_main( # check for EMS active exec else: - book.orders[symbol].pop(oid, None) + try: + book.orders[symbol].pop(oid, None) - await ctx.send_yield({ - 'resp': 'dark_cancelled', - 'oid': oid - }) + await ctx.send_yield({ + 'resp': 'dark_cancelled', + 'oid': oid + }) + except KeyError: + log.exception(f'No dark order for {symbol}?') elif action in ('alert', 'buy', 'sell',): @@ -467,6 +487,7 @@ async def _ems_main( trigger_price = cmd['price'] brokers = cmd['brokers'] broker = brokers[0] + mode = cmd.get('exec_mode', mode) last = book.lasts[(broker, sym)] @@ -478,7 +499,7 @@ async def _ems_main( symbol=sym, action=action, price=round(trigger_price, 2), - size=1, + size=_DEFAULT_SIZE, ) book._broker2ems_ids[order_id] = oid @@ -489,12 +510,8 @@ async def _ems_main( elif mode in ('dark', 'paper') or action in ('alert'): - # if the predicate resolves immediately send the - # execution to the broker asap - # if pred(last): - # send order - - # IF SEND ORDER RIGHT AWAY CONDITION + # TODO: if the predicate resolves immediately send the + # execution to the broker asap? Or no? # submit order to local EMS @@ -504,12 +521,22 @@ async def _ems_main( # 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) + pred = mk_check(trigger_price, last) + + if action == 'buy': + tickfilter = ('ask', 'last', 'trade') + percent_away = 0.005 + elif action == 'sell': + tickfilter = ('bid', 'last', 'trade') + percent_away = -0.005 + else: # alert + tickfilter = ('trade', 'utrade', 'last') + percent_away = 0 # submit execution/order to EMS scanner loop book.orders.setdefault( sym, {} - )[oid] = (pred, name, cmd) + )[oid] = (pred, tickfilter, cmd, percent_away) # ack-response that order is live here await ctx.send_yield({ @@ -545,6 +572,7 @@ class OrderBook: symbol: 'Symbol', price: float, action: str, + exec_mode: str, ) -> str: cmd = { 'action': action, @@ -552,6 +580,7 @@ class OrderBook: 'symbol': symbol.key, 'brokers': symbol.brokers, 'oid': uuid, + 'exec_mode': exec_mode, # dark or live } self._sent_orders[uuid] = cmd self._to_ems.send_nowait(cmd) @@ -683,7 +712,7 @@ async def open_ems( # ready for order commands book = get_orders() - with trio.fail_after(3): + with trio.fail_after(5): await book._ready_to_receive.wait() yield book, trades_stream From 7811119736c258e656de4ffd88028a7de8395185 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 19 Jan 2021 20:47:55 -0500 Subject: [PATCH 024/139] Support toggling level line highlighting --- piker/ui/_graphics/_lines.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/piker/ui/_graphics/_lines.py b/piker/ui/_graphics/_lines.py index 20c68399..b668f514 100644 --- a/piker/ui/_graphics/_lines.py +++ b/piker/ui/_graphics/_lines.py @@ -18,7 +18,6 @@ Lines for orders, alerts, L2. """ -from dataclasses import dataclass from typing import Tuple import pyqtgraph as pg @@ -99,7 +98,7 @@ class LevelLabel(YSticky): def set_label_str(self, level: float): # self.label_str = '{size} x {level:.{digits}f}'.format( - # size=self._size, + # size=self._size, # this is read inside ``.paint()`` self.label_str = '{level:.{digits}f}'.format( @@ -151,7 +150,7 @@ class L1Label(LevelLabel): """ self.label_str = '{size:.{size_digits}f} x {level:,.{digits}f}'.format( size_digits=self.size_digits, - size=self.size or '?', + size=self.size or 2, digits=self.digits, level=level ).replace(',', ' ') @@ -230,10 +229,6 @@ class LevelLine(pg.InfiniteLine): self._hcolor = None self.color = color - # use slightly thicker highlight - pen = pg.mkPen(hcolor(highlight_color)) - pen.setWidth(2) - self.setHoverPen(pen) self._track_cursor: bool = False @property @@ -245,6 +240,12 @@ class LevelLine(pg.InfiniteLine): self._hcolor = color self.setPen(pg.mkPen(hcolor(color))) + # set hover pen to new color + pen = pg.mkPen(hcolor(color + '_light')) + # use slightly thicker highlight + pen.setWidth(2) + self.setHoverPen(pen) + def set_level(self, value: float) -> None: self.label.update_from_data(0, self.value()) @@ -262,7 +263,7 @@ class LevelLine(pg.InfiniteLine): """ # XXX: currently we'll just return if _hoh is False - if self.mouseHovering == hover or not self._hoh: + if self.mouseHovering == hover: return self.mouseHovering = hover @@ -270,14 +271,15 @@ class LevelLine(pg.InfiniteLine): chart = self._chart if hover: - - self.currentPen = self.hoverPen - self.label.highlight(self.hoverPen) + # highlight if so configured + if self._hoh: + self.currentPen = self.hoverPen + self.label.highlight(self.hoverPen) # add us to cursor state chart._cursor.add_hovered(self) - # # hide y-crosshair + # TODO: hide y-crosshair? # chart._cursor.graphics[chart]['hl'].hide() else: From f82127de3117f14e585febfc6686a28c97cca40a Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 19 Jan 2021 20:48:58 -0500 Subject: [PATCH 025/139] Add "live order" submission using ctl- --- piker/ui/_interaction.py | 98 ++++++++++++++++++++++++++++------------ 1 file changed, 69 insertions(+), 29 deletions(-) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 4ba6280d..f6a0b917 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -217,7 +217,11 @@ class LineEditor: _active_staged_line: LevelLine = None _stage_line: LevelLine = None - def stage_line(self, color: str = 'alert_yellow') -> LevelLine: + def stage_line( + self, + color: str = 'alert_yellow', + hl_on_hover: bool = False, + ) -> LevelLine: """Stage a line at the current chart's cursor position and return it. @@ -238,18 +242,24 @@ class LineEditor: color=color, # don't highlight the "staging" line - hl_on_hover=False, + hl_on_hover=hl_on_hover, ) self._stage_line = line else: # use the existing staged line instead # of allocating more mem / objects repeatedly - line.setValue(y) - line.show() + print(f'hl on hover: {hl_on_hover}') + line._hoh = hl_on_hover line.color = color - line.label.color = color - line.label.show() + line.setMouseHover(hl_on_hover) + line.setValue(y) + line.update() + line.show() + + label = line.label + label.color = color + label.show() self._active_staged_line = line @@ -421,18 +431,26 @@ class OrderMode: 'sell': 'sell_red', } _action: str = 'alert' + _exec_mode: str = 'dark' key_map: Dict[str, Callable] = field(default_factory=dict) def uuid(self) -> str: return str(uuid.uuid4()) - def set_exec(self, name: str) -> None: + def set_exec( + self, + action: str, + # mode: str, + ) -> None: """Set execution mode. """ - self._action = name - self.lines.stage_line(color=self._colors[name]) + self._action = action + self.lines.stage_line( + color=self._colors[action], + hl_on_hover=True if self._exec_mode == 'live' else False, + ) def on_submit(self, uuid: str) -> dict: """On order submitted event, commit the order line @@ -523,6 +541,7 @@ class OrderMode: symbol=self.chart._lc._symbol, price=y, action=self._action, + exec_mode=self._exec_mode, ) @@ -582,6 +601,7 @@ class ChartView(ViewBox): # kb ctrls processing self._key_buffer = [] + self._key_active: bool = False @property def chart(self) -> 'ChartPlotWidget': # type: ignore # noqa @@ -760,8 +780,9 @@ class ChartView(ViewBox): elif button == QtCore.Qt.LeftButton: # when in order mode, submit execution - ev.accept() - self.mode.submit_exec() + if self._key_active: + ev.accept() + self.mode.submit_exec() def keyReleaseEvent(self, ev): """ @@ -774,18 +795,25 @@ class ChartView(ViewBox): return ev.accept() - text = ev.text() + # text = ev.text() key = ev.key() - # mods = ev.modifiers() + mods = ev.modifiers() if key == QtCore.Qt.Key_Shift: # if self.state['mouseMode'] == ViewBox.RectMode: self.setMouseMode(ViewBox.PanMode) - if text in {'a', 'f', 's'}: - # draw "staged" line under cursor position + # if self.state['mouseMode'] == ViewBox.RectMode: + # if key == QtCore.Qt.Key_Space: + if mods == QtCore.Qt.ControlModifier or key == QtCore.Qt.Key_Control: + self.mode._exec_mode = 'dark' + + if key in {QtCore.Qt.Key_A, QtCore.Qt.Key_F, QtCore.Qt.Key_D}: + # remove "staged" level line under cursor position self.mode.lines.unstage_line() + self._key_active = False + def keyPressEvent(self, ev): """ This routine should capture key presses in the current view box. @@ -805,41 +833,52 @@ class ChartView(ViewBox): if self.state['mouseMode'] == ViewBox.PanMode: self.setMouseMode(ViewBox.RectMode) - # ctl + # ctrl + ctrl = False if mods == QtCore.Qt.ControlModifier: - # 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() - print(f"CTRL + key:{key} + text:{text}") + ctrl = True + + print(mods) + if mods == QtCore.Qt.ControlModifier: + print('space') + self.mode._exec_mode = 'live' + + self._key_active = True # alt if mods == QtCore.Qt.AltModifier: pass # esc - if key == QtCore.Qt.Key_Escape: + if key == QtCore.Qt.Key_Escape or (ctrl and key == QtCore.Qt.Key_C): + # ctrl-c as cancel + # https://forum.qt.io/topic/532/how-to-catch-ctrl-c-on-a-widget/9 self.select_box.clear() + # delete any lines under the cursor + mode = self.mode + for line in mode.lines.lines_under_cursor(): + mode.book.cancel(uuid=line.oid) + self._key_buffer.append(text) # View modes - if text == 'r': + if key == QtCore.Qt.Key_R: self.chart.default_view() - # Order modes - # stage orders at the current cursor level - elif text == 's': + # Order modes: stage orders at the current cursor level + + elif key == QtCore.Qt.Key_D: self.mode.set_exec('sell') - elif text == 'f': + elif key == QtCore.Qt.Key_F: self.mode.set_exec('buy') - elif text == 'a': + elif key == QtCore.Qt.Key_A: self.mode.set_exec('alert') # delete orders under cursor - elif text == 'd': + elif key == QtCore.Qt.Key_Delete: # delete any lines under the cursor mode = self.mode @@ -862,3 +901,4 @@ class ChartView(ViewBox): # self.scaleHistory(len(self.axHistory)) else: ev.ignore() + self._key_active = False From 5327d7be5e09f2a9cf81f292ac097fafff6008f5 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 22 Jan 2021 16:46:39 -0500 Subject: [PATCH 026/139] Add screen acquire timeout loop --- piker/data/_normalize.py | 2 +- piker/ui/_exec.py | 36 +++++++++++++++++++++++++++++------- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/piker/data/_normalize.py b/piker/data/_normalize.py index 363f3c01..3474879e 100644 --- a/piker/data/_normalize.py +++ b/piker/data/_normalize.py @@ -1,5 +1,5 @@ # piker: trading gear for hackers -# Copyright (C) 2018-present Tyler Goodlet +# 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 diff --git a/piker/ui/_exec.py b/piker/ui/_exec.py index 30a93e04..365ab749 100644 --- a/piker/ui/_exec.py +++ b/piker/ui/_exec.py @@ -20,11 +20,11 @@ Trio - Qt integration Run ``trio`` in guest mode on top of the Qt event loop. All global Qt runtime settings are mostly defined here. """ +from typing import Tuple, Callable, Dict, Any import os import signal -from functools import partial +import time import traceback -from typing import Tuple, Callable, Dict, Any # Qt specific import PyQt5 # noqa @@ -32,13 +32,18 @@ import pyqtgraph as pg from pyqtgraph import QtGui from PyQt5 import QtCore from PyQt5.QtCore import ( - pyqtRemoveInputHook, Qt, QCoreApplication + pyqtRemoveInputHook, + Qt, + QCoreApplication, ) import qdarkstyle import trio import tractor from outcome import Error +from ..log import get_logger + +log = get_logger(__name__) # pyqtgraph global config # might as well enable this for now? @@ -51,10 +56,27 @@ _qt_app: QtGui.QApplication = None _qt_win: QtGui.QMainWindow = None -def current_screen() -> QtGui.QScreen: +def current_screen(timeout: float = 6) -> QtGui.QScreen: + print('yo screen zonnnee') global _qt_win, _qt_app - return _qt_app.screenAt(_qt_win.centralWidget().geometry().center()) + screen = _qt_app.screenAt(_qt_win.centralWidget().geometry().center()) + + start = time.time() + + # breakpoint() + # wait for 6 seconds to grab screen + while screen is None and ( + (time.time() - start) < timeout + ): + screen = _qt_app.screenAt(_qt_win.centralWidget().geometry().center()) + time.sleep(0.1) + log.info("Couldn't acquire screen trying again...") + + if screen is None: + raise RuntimeError("Failed to acquire screen?") + + return screen # Proper high DPI scaling is available in Qt >= 5.6.0. This attibute @@ -78,7 +100,7 @@ class MainWindow(QtGui.QMainWindow): def closeEvent( self, - event: 'QCloseEvent' + event: QtGui.QCloseEvent, ) -> None: """Cancel the root actor asap. @@ -169,7 +191,7 @@ def run_qtractor( ), name='qtractor', **tractor_kwargs, - ) as a: + ): await func(*(args + (widgets,))) # guest mode entry From 10e47e349c60ff101a31f7e9de70af1be3cfa150 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 22 Jan 2021 17:11:53 -0500 Subject: [PATCH 027/139] Include symbol deats in feed init message from ib Async spawn a deats getter task whenever we load a symbol data feed. Pass these symbol details in the first message delivered by the feed at open. Move stream loop into a new func. --- piker/brokers/ib.py | 301 +++++++++++++++++++++++++------------------- 1 file changed, 173 insertions(+), 128 deletions(-) diff --git a/piker/brokers/ib.py b/piker/brokers/ib.py index 2b4fd4fb..6073d036 100644 --- a/piker/brokers/ib.py +++ b/piker/brokers/ib.py @@ -426,12 +426,15 @@ class Client: """ contract = await self.find_contract(symbol) + + details_fute = self.ib.reqContractDetailsAsync(contract) ticker: Ticker = self.ib.reqMktData( contract, snapshot=True, ) ticker = await ticker.updateEvent - return contract, ticker + details = (await details_fute)[0] + return contract, ticker, details # async to be consistent for the client proxy, and cuz why not. async def submit_limit( @@ -440,7 +443,7 @@ class Client: symbol: str, price: float, action: str, - size: int = 100, + size: int, ) -> int: """Place an order and return integer request id provided by client. @@ -870,6 +873,7 @@ async def stream_quotes( symbols: List[str], shm_token: Tuple[str, str, List[tuple]], loglevel: str = None, + # compat for @tractor.msg.pub topics: Any = None, get_topics: Callable = None, @@ -885,10 +889,11 @@ async def stream_quotes( # TODO: support multiple subscriptions sym = symbols[0] - contract, first_ticker = await _trio_run_client_method( - method='get_quote', - symbol=sym, - ) + async with trio.open_nursery() as n: + contract, first_ticker, details = await _trio_run_client_method( + method='get_quote', + symbol=sym, + ) stream = await _trio_run_client_method( method='stream_ticker', @@ -896,8 +901,8 @@ async def stream_quotes( symbol=sym, ) - async with aclosing(stream): - + shm = None + async with trio.open_nursery() as ln: # check if a writer already is alive in a streaming task, # otherwise start one and mark it as now existing @@ -908,86 +913,100 @@ async def stream_quotes( # 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 + if not writer_already_exists: + _local_buffer_writers[key] = True - shm = attach_shm_array( - token=shm_token, + shm = attach_shm_array( + token=shm_token, - # we are the buffer writer - readonly=False, - ) + # we are the buffer writer + readonly=False, + ) - # async def retrieve_and_push(): - start = time.time() + # async def retrieve_and_push(): + start = time.time() - bars, bars_array = await _trio_run_client_method( - method='bars', - symbol=sym, + bars, bars_array = await _trio_run_client_method( + method='bars', + symbol=sym, - ) + ) - log.info(f"bars_array request: {time.time() - start}") + log.info(f"bars_array request: {time.time() - start}") - if bars_array is None: - raise SymbolNotFound(sym) + if bars_array is None: + raise SymbolNotFound(sym) - # write historical data to buffer - shm.push(bars_array) - shm_token = shm.token + # write historical data to buffer + shm.push(bars_array) + shm_token = shm.token - # TODO: generalize this for other brokers - # start bar filler task in bg - ln.start_soon(fill_bars, sym, bars, shm) + # TODO: generalize this for other brokers + # start bar filler task in bg + ln.start_soon(fill_bars, sym, bars, shm) - times = shm.array['time'] - delay_s = times[-1] - times[times != times[-1]][-1] - subscribe_ohlc_for_increment(shm, delay_s) + times = shm.array['time'] + delay_s = times[-1] - times[times != times[-1]][-1] + subscribe_ohlc_for_increment(shm, delay_s) + # pass back some symbol info like min_tick, trading_hours, etc. + # con = asdict(contract) + # syminfo = contract + symdeats = asdict(details) + symdeats.update(symdeats['contract']) + + # TODO: for loop through all symbols passed in + init_msgs = { # 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)) + sym: { + 'is_shm_writer': not writer_already_exists, + 'shm_token': shm_token, + 'symbol_info': symdeats, + } + } + await ctx.send_yield(init_msgs) - # 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 + # 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 - # 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 + # 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} + first_quote = {topic: quote} - # yield first quote asap - await ctx.send_yield(first_quote) + # yield first quote asap + await ctx.send_yield(first_quote) - # ticker.ticks = [] + # ticker.ticks = [] - # ugh, clear ticks since we've consumed them - # (ahem, ib_insync is stateful trash) - first_ticker.ticks = [] + # ugh, clear ticks since we've consumed them + # (ahem, ib_insync is stateful trash) + first_ticker.ticks = [] - log.debug(f"First ticker received {quote}") + log.debug(f"First ticker received {quote}") - if type(first_ticker.contract) not in (ibis.Commodity, ibis.Forex): - suffix = 'exchange' + if type(first_ticker.contract) not in (ibis.Commodity, ibis.Forex): + suffix = 'exchange' - calc_price = False # should be real volume for contract + calc_price = False # should be real volume for contract - # with trio.move_on_after(10) as cs: - # wait for real volume on feed (trading might be closed) + # with trio.move_on_after(10) as cs: + # wait for real volume on feed (trading might be closed) + async with aclosing(stream): async for ticker in stream: # for a real volume contract we rait for the first @@ -1009,76 +1028,105 @@ async def stream_quotes( # ``aclosing()`` above? break - # if cs.cancelled_caught: - # await tractor.breakpoint() - - # real-time stream - async for ticker in stream: - - # print(ticker.vwap) - quote = normalize( - ticker, - calc_price=calc_price + # enter stream loop + try: + await stream_and_write( + stream=stream, + calc_price=calc_price, + topic=topic, + writer_already_exists=writer_already_exists, + shm=shm, + suffix=suffix, + ctx=ctx, ) - quote['symbol'] = topic - # TODO: in theory you can send the IPC msg *before* - # writing to the sharedmem array to decrease latency, - # however, that will require `tractor.msg.pub` support - # here or at least some way to prevent task switching - # at the yield such that the array write isn't delayed - # while another consumer is serviced.. - - # if we are the lone tick writer start writing - # the buffer with appropriate trade data + finally: if not writer_already_exists: - for tick in iterticks(quote, types=('trade', 'utrade',)): - last = tick['price'] - - # print(f"{quote['symbol']}: {tick}") - - # update last entry - # benchmarked in the 4-5 us range - o, high, low, v = shm.array[-1][ - ['open', 'high', 'low', 'volume'] - ] - - new_v = tick.get('size', 0) - - if v == 0 and new_v: - # no trades for this bar yet so the open - # is also the close/last trade price - o = last - - shm.array[[ - 'open', - 'high', - 'low', - 'close', - 'volume', - ]][-1] = ( - o, - max(high, last), - min(low, last), - last, - v + new_v, - ) - - con = quote['contract'] - topic = '.'.join((con['symbol'], con[suffix])).lower() - quote['symbol'] = topic - - await ctx.send_yield({topic: quote}) - - # ugh, clear ticks since we've consumed them - ticker.ticks = [] + _local_buffer_writers[key] = False -@tractor.msg.pub +async def stream_and_write( + stream, + calc_price: bool, + topic: str, + writer_already_exists: bool, + suffix: str, + ctx: tractor.Context, + shm: Optional['SharedArray'], # noqa +) -> None: + """Core quote streaming and shm writing loop; optimize for speed! + + """ + # real-time stream + async for ticker in stream: + + # print(ticker.vwap) + quote = normalize( + ticker, + calc_price=calc_price + ) + quote['symbol'] = topic + # TODO: in theory you can send the IPC msg *before* + # writing to the sharedmem array to decrease latency, + # however, that will require `tractor.msg.pub` support + # here or at least some way to prevent task switching + # at the yield such that the array write isn't delayed + # while another consumer is serviced.. + + # if we are the lone tick writer start writing + # the buffer with appropriate trade data + if not writer_already_exists: + for tick in iterticks(quote, types=('trade', 'utrade',)): + last = tick['price'] + + # print(f"{quote['symbol']}: {tick}") + + # update last entry + # benchmarked in the 4-5 us range + o, high, low, v = shm.array[-1][ + ['open', 'high', 'low', 'volume'] + ] + + new_v = tick.get('size', 0) + + if v == 0 and new_v: + # no trades for this bar yet so the open + # is also the close/last trade price + o = last + + shm.array[[ + 'open', + 'high', + 'low', + 'close', + 'volume', + ]][-1] = ( + o, + max(high, last), + min(low, last), + last, + v + new_v, + ) + + con = quote['contract'] + topic = '.'.join((con['symbol'], con[suffix])).lower() + quote['symbol'] = topic + + await ctx.send_yield({topic: quote}) + + # ugh, clear ticks since we've consumed them + ticker.ticks = [] + + +@tractor.msg.pub( + send_on_connect={'local_trades': 'start'} +) async def stream_trades( loglevel: str = None, get_topics: Callable = None, ) -> AsyncIterator[Dict[str, Any]]: + global _trades_stream_is_live + # XXX: required to propagate ``tractor`` loglevel to piker logging get_console_log(loglevel or tractor.current_actor().loglevel) @@ -1086,9 +1134,6 @@ async def stream_trades( method='recv_trade_updates', ) - # startup msg - yield {'local_trades': 'start'} - async for event_name, item in stream: # XXX: begin normalization of nonsense ib_insync internal From b4a4f12aa4bdd4eb48922243248731f0cf8d000d Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 22 Jan 2021 22:55:49 -0500 Subject: [PATCH 028/139] Send init message with kraken --- piker/brokers/kraken.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/piker/brokers/kraken.py b/piker/brokers/kraken.py index dfbf3c0f..caa810e4 100644 --- a/piker/brokers/kraken.py +++ b/piker/brokers/kraken.py @@ -312,11 +312,13 @@ async def stream_quotes( get_console_log(loglevel or tractor.current_actor().loglevel) ws_pairs = {} + sym_infos = {} async with get_client() as client: # keep client cached for real-time section for sym in symbols: - ws_pairs[sym] = (await client.symbol_info(sym))['wsname'] + si = sym_infos[sym] = await client.symbol_info(sym) + ws_pairs[sym] = si['wsname'] # maybe load historical ohlcv in to shared mem # check if shm has already been created by previous @@ -340,7 +342,18 @@ async def stream_quotes( delay_s = times[-1] - times[times != times[-1]][-1] subscribe_ohlc_for_increment(shm, delay_s) - yield shm_token, not writer_exists + # yield shm_token, not writer_exists + init_msgs = { + # pass back token, and bool, signalling if we're the writer + # and that history has been written + symbol: { + 'is_shm_writer': not writer_exists, + 'shm_token': shm_token, + 'symbol_info': sym_infos[symbol], + } + for sym in symbols + } + yield init_msgs while True: try: From 92efb8fd8e8d5da330851f31e08dfce6bd0072af Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 22 Jan 2021 22:56:22 -0500 Subject: [PATCH 029/139] Expect new init message in feed from brokers --- piker/data/__init__.py | 66 ++++++++++++++++++++++++++++-------------- piker/data/_source.py | 19 +++++++++--- 2 files changed, 60 insertions(+), 25 deletions(-) diff --git a/piker/data/__init__.py b/piker/data/__init__.py index fe50eda6..0c08b532 100644 --- a/piker/data/__init__.py +++ b/piker/data/__init__.py @@ -21,7 +21,7 @@ We provide tsdb integrations for retrieving and storing data from your brokers as well as sharing your feeds with other fellow pikers. """ -from dataclasses import dataclass +from dataclasses import dataclass, field from contextlib import asynccontextmanager from importlib import import_module from types import ModuleType @@ -42,7 +42,7 @@ from ._sharedmem import ( ShmArray, get_shm_token, ) -from ._source import base_iohlc_dtype +from ._source import base_iohlc_dtype, Symbol from ._buffer import ( increment_ohlc_buffer, subscribe_ohlc_for_increment @@ -149,6 +149,10 @@ class Feed: _index_stream: Optional[AsyncIterator[int]] = None _trade_stream: Optional[AsyncIterator[Dict[str, Any]]] = None + # cache of symbol info messages received as first message when + # a stream startsc. + symbols: Dict[str, Symbol] = field(default_factory=dict) + async def receive(self) -> dict: return await self.stream.__anext__() @@ -208,23 +212,26 @@ def sym_to_shm_key( @asynccontextmanager async def open_feed( - name: str, + brokername: str, symbols: Sequence[str], loglevel: Optional[str] = None, ) -> AsyncIterator[Dict[str, Any]]: """Open a "data feed" which provides streamed real-time quotes. """ try: - mod = get_brokermod(name) + mod = get_brokermod(brokername) except ImportError: - mod = get_ingestormod(name) + mod = get_ingestormod(brokername) if loglevel is None: loglevel = tractor.current_actor().loglevel + # TODO: do all! + sym = symbols[0] + # Attempt to allocate (or attach to) shm array for this broker/symbol shm, opened = maybe_open_shm_array( - key=sym_to_shm_key(name, symbols[0]), + key=sym_to_shm_key(brokername, sym), # use any broker defined ohlc dtype: dtype=getattr(mod, '_ohlc_dtype', base_iohlc_dtype), @@ -234,34 +241,51 @@ async def open_feed( ) async with maybe_spawn_brokerd( - mod.name, + brokername, loglevel=loglevel, ) as portal: stream = await portal.run( mod.stream_quotes, + + # TODO: actually handy multiple symbols... symbols=symbols, + shm_token=shm.token, # compat with eventual ``tractor.msg.pub`` topics=symbols, ) - # TODO: we can't do this **and** be compate with - # ``tractor.msg.pub``, should we maybe just drop this after - # tests are in? - shm_token, is_writer = await stream.receive() - - if opened: - assert is_writer - log.info("Started shared mem bar writer") - - shm_token['dtype_descr'] = list(shm_token['dtype_descr']) - assert shm_token == shm.token # sanity - - yield Feed( - name=name, + feed = Feed( + name=brokername, stream=stream, shm=shm, mod=mod, _brokerd_portal=portal, ) + + # TODO: we can't do this **and** be compate with + # ``tractor.msg.pub``, should we maybe just drop this after + # tests are in? + init_msg = await stream.receive() + + for sym, data in init_msg.items(): + + si = data['symbol_info'] + symbol = Symbol( + sym, + min_tick=si.get('minTick', 0.01), + ) + symbol.broker_info[brokername] = si + + feed.symbols[sym] = symbol + + shm_token = data['shm_token'] + if opened: + assert data['is_shm_writer'] + log.info("Started shared mem bar writer") + + shm_token['dtype_descr'] = list(shm_token['dtype_descr']) + assert shm_token == shm.token # sanity + + yield feed diff --git a/piker/data/_source.py b/piker/data/_source.py index a77839ff..6a71b444 100644 --- a/piker/data/_source.py +++ b/piker/data/_source.py @@ -17,9 +17,9 @@ """ numpy data source coversion helpers. """ -from typing import List +from typing import Dict, Any, List import decimal -from dataclasses import dataclass +from dataclasses import dataclass, field import numpy as np import pandas as pd @@ -82,9 +82,13 @@ class Symbol: """ key: str = '' - brokers: List[str] = None min_tick: float = 0.01 - contract: str = '' + broker_info: Dict[str, Dict[str, Any]] = field(default_factory=dict) + deriv: str = '' + + @property + def brokers(self) -> List[str]: + return list(self.broker_info.keys()) def digits(self) -> int: """Return the trailing number of digits specified by the @@ -93,6 +97,13 @@ class Symbol: """ return float_digits(self.min_tick) + def nearest_tick(self, value: float) -> float: + """Return the nearest tick value based on mininum increment. + + """ + mult = 1 / self.min_tick + return round(value * mult) / mult + def from_df( df: pd.DataFrame, From 50d2d8517bb0e88efb51cfbb30e93d70fb60724d Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 22 Jan 2021 22:57:42 -0500 Subject: [PATCH 030/139] Add support for dotted style level lines --- piker/ui/_graphics/_lines.py | 37 ++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/piker/ui/_graphics/_lines.py b/piker/ui/_graphics/_lines.py index b668f514..d1a43247 100644 --- a/piker/ui/_graphics/_lines.py +++ b/piker/ui/_graphics/_lines.py @@ -216,6 +216,7 @@ class LevelLine(pg.InfiniteLine): color: str = 'default', highlight_color: str = 'default_light', hl_on_hover: bool = True, + dotted: bool = False, **kwargs, ) -> None: @@ -223,28 +224,48 @@ class LevelLine(pg.InfiniteLine): self.label = label self.sigPositionChanged.connect(self.set_level) + self._chart = chart self._hoh = hl_on_hover + self._dotted = dotted self._hcolor = None self.color = color + # TODO: for when we want to move groups of lines? self._track_cursor: bool = False + # testing markers + # self.addMarker('<|', 0.1, 3) + # self.addMarker('<|>', 0.2, 3) + # self.addMarker('>|', 0.3, 3) + # self.addMarker('>|<', 0.4, 3) + # self.addMarker('>|<', 0.5, 3) + # self.addMarker('^', 0.6, 3) + # self.addMarker('v', 0.7, 3) + # self.addMarker('o', 0.8, 3) + @property def color(self): return self._hcolor @color.setter def color(self, color: str) -> None: + # set pens to new color self._hcolor = color - self.setPen(pg.mkPen(hcolor(color))) + pen = pg.mkPen(hcolor(color)) + hoverpen = pg.mkPen(hcolor(color + '_light')) - # set hover pen to new color - pen = pg.mkPen(hcolor(color + '_light')) - # use slightly thicker highlight - pen.setWidth(2) - self.setHoverPen(pen) + if self._dotted: + pen.setStyle(QtCore.Qt.DashLine) + hoverpen.setStyle(QtCore.Qt.DashLine) + + # set regular pen + self.setPen(pen) + + # use slightly thicker highlight for hover pen + hoverpen.setWidth(2) + self.hoverPen = hoverpen def set_level(self, value: float) -> None: self.label.update_from_data(0, self.value()) @@ -352,6 +373,9 @@ def level_line( # when moused over (aka "hovered") hl_on_hover: bool = True, + # line style + dotted: bool = False, + **linelabelkwargs ) -> LevelLine: """Convenience routine to add a styled horizontal line to a plot. @@ -385,6 +409,7 @@ def level_line( movable=True, angle=0, hl_on_hover=hl_on_hover, + dotted=dotted, ) line.setValue(level) From f072e2551b7af38ff1f8eac78582eea7ebbb126a Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 22 Jan 2021 22:59:00 -0500 Subject: [PATCH 031/139] Snap y-axis crosshair to min tick(s) --- piker/ui/_chart.py | 14 +++++++----- piker/ui/_graphics/_cursor.py | 42 ++++++++++++++++++++++------------- 2 files changed, 35 insertions(+), 21 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 91f8332f..6696622d 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.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 @@ -138,7 +138,10 @@ class ChartSpace(QtGui.QWidget): Expects a ``numpy`` structured array containing all the ohlcv fields. """ # XXX: let's see if this causes mem problems - self.window.setWindowTitle(f'piker chart {symbol}') + self.window.setWindowTitle( + f'piker chart {symbol.key}@{symbol.brokers} ' + f'tick:{symbol.min_tick}' + ) # TODO: symbol search # # of course this doesn't work :eyeroll: @@ -192,6 +195,7 @@ class LinkedSplitCharts(QtGui.QWidget): self._cursor: Cursor = None # crosshair graphics self.chart: ChartPlotWidget = None # main (ohlc) chart self.subplots: Dict[Tuple[str, ...], ChartPlotWidget] = {} + self.digits: int = 2 self.xaxis = DynamicDateAxis( orientation='bottom', @@ -241,6 +245,7 @@ class LinkedSplitCharts(QtGui.QWidget): The data input struct array must include OHLC fields. """ + self.min_tick = symbol.min_tick self.digits = symbol.digits() # add crosshairs @@ -652,7 +657,7 @@ class ChartPlotWidget(pg.PlotWidget): chart=self, parent=self.getAxis('right'), # TODO: pass this from symbol data - # digits=0, + digits=self._lc._symbol.digits(), opacity=1, bg_color=bg_color, ) @@ -854,8 +859,6 @@ async def _async_main( # historical data fetch brokermod = brokers.get_brokermod(brokername) - symbol = Symbol(sym, [brokername]) - async with data.open_feed( brokername, [sym], @@ -864,6 +867,7 @@ async def _async_main( ohlcv = feed.shm bars = ohlcv.array + symbol = feed.symbols[sym] # load in symbol's ohlc data linked_charts, chart = chart_app.load_symbol(symbol, bars) diff --git a/piker/ui/_graphics/_cursor.py b/piker/ui/_graphics/_cursor.py index 5d312f39..791ffccc 100644 --- a/piker/ui/_graphics/_cursor.py +++ b/piker/ui/_graphics/_cursor.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 @@ -226,6 +226,10 @@ class Cursor(pg.GraphicsObject): self._hovered: Set[pg.GraphicsObject] = set() self._trackers: Set[pg.GraphicsObject] = set() + # value used for rounding y-axis discreet tick steps + # computing once, up front, here cuz why not + self._y_incr_mult = 1 / self.lsc._symbol.min_tick + def add_hovered( self, item: pg.GraphicsObject, @@ -347,25 +351,31 @@ class Cursor(pg.GraphicsObject): x, y = mouse_point.x(), mouse_point.y() plot = self.active_plot - # 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, lasty = self._datum_xy + last_ix, last_iy = 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) + # round y value to nearest tick step + m = self._y_incr_mult + iy = round(y * m) / m - if ix != lastx: + if iy != last_iy: + + # update y-range items + self.graphics[plot]['hl'].setY(iy) + + self.graphics[self.active_plot]['yl'].update_label( + abs_pos=pos, value=iy + ) + + # update all trackers + for item in self._trackers: + # print(f'setting {item} with {(ix, y)}') + item.on_tracked_source(ix, iy) + + if ix != last_ix: for plot, opts in self.graphics.items(): # move the vertical line to the current "center of bar" @@ -390,7 +400,7 @@ class Cursor(pg.GraphicsObject): value=x, ) - self._datum_xy = ix, y + self._datum_xy = ix, iy def boundingRect(self): try: From 4b2161a37b31203b4e3f8e4ea5422ec941080ac7 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 22 Jan 2021 23:00:01 -0500 Subject: [PATCH 032/139] Use 2 min-ticks offset for dark orders --- piker/_ems.py | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/piker/_ems.py b/piker/_ems.py index 14501048..07fabe29 100644 --- a/piker/_ems.py +++ b/piker/_ems.py @@ -236,7 +236,13 @@ async def exec_loop( # update to keep new cmds informed book.lasts[(broker, symbol)] = price - for oid, (pred, tf, cmd, percent_away) in ( + for oid, ( + pred, + tf, + cmd, + percent_away, + abs_diff_away + ) in ( tuple(execs.items()) ): @@ -244,8 +250,10 @@ async def exec_loop( # majority of iterations will be non-matches continue - submit_price = price + price*percent_away - print( + # submit_price = price + price*percent_away + submit_price = price + abs_diff_away + + log.info( f'Dark order triggered for price {price}\n' f'Submitting order @ price {submit_price}') @@ -274,7 +282,7 @@ async def exec_loop( # remove exec-condition from set log.info(f'removing pred for {oid}') - pred, tf, cmd, percent_away = execs.pop(oid) + execs.pop(oid) await ctx.send_yield(resp) @@ -359,6 +367,9 @@ async def process_broker_trades( # XXX should we make one when it's blank? log.error(pformat(message)) + # TODO: getting this bs, prolly need to handle status messages + # 'Market data farm connection is OK:usfarm.nj' + # another stupid ib error to handle # if 10147 in message: cancel @@ -523,20 +534,31 @@ async def _ems_main( # the user choose the predicate operator. pred = mk_check(trigger_price, last) + mt = feed.symbols[sym].min_tick + if action == 'buy': tickfilter = ('ask', 'last', 'trade') percent_away = 0.005 + abs_diff_away = 2 * mt elif action == 'sell': tickfilter = ('bid', 'last', 'trade') percent_away = -0.005 + abs_diff_away = -2 * mt else: # alert tickfilter = ('trade', 'utrade', 'last') percent_away = 0 + abs_diff_away = 0 # submit execution/order to EMS scanner loop book.orders.setdefault( sym, {} - )[oid] = (pred, tickfilter, cmd, percent_away) + )[oid] = ( + pred, + tickfilter, + cmd, + percent_away, + abs_diff_away + ) # ack-response that order is live here await ctx.send_yield({ From 18fafb501d77ea39cfc317359d59fdfd7e64e593 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 22 Jan 2021 23:01:12 -0500 Subject: [PATCH 033/139] Use dashed lines for dark orders --- piker/ui/_interaction.py | 74 +++++++++++++++++++++++----------------- 1 file changed, 43 insertions(+), 31 deletions(-) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index f6a0b917..2cf02fbf 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -221,6 +221,7 @@ class LineEditor: self, color: str = 'alert_yellow', hl_on_hover: bool = False, + dotted: bool = False, ) -> LevelLine: """Stage a line at the current chart's cursor position and return it. @@ -243,14 +244,18 @@ class LineEditor: # don't highlight the "staging" line hl_on_hover=hl_on_hover, + dotted=dotted, ) self._stage_line = line else: - # use the existing staged line instead - # of allocating more mem / objects repeatedly - print(f'hl on hover: {hl_on_hover}') + # print(f'hl on hover: {hl_on_hover}') + + # Use the existing staged line instead but copy + # overe it's current style "properties". + # Saves us allocating more mem / objects repeatedly line._hoh = hl_on_hover + line._dotted = dotted line.color = color line.setMouseHover(hl_on_hover) line.setValue(y) @@ -312,6 +317,7 @@ class LineEditor: color=line.color, digits=chart._lc.symbol.digits(), show_label=False, + dotted=line._dotted, ) # register for later lookup/deletion @@ -325,15 +331,20 @@ class LineEditor: graphic in view. """ - line = self._order_lines[uuid] - line.oid = uuid - line.label.show() + try: + line = self._order_lines[uuid] + except KeyError: + log.warning(f'No line for {uuid} could be found?') + return + else: + line.oid = uuid + line.label.show() - # TODO: other flashy things to indicate the order is active + # TODO: other flashy things to indicate the order is active - log.debug(f'Level active for level: {line.value()}') + log.debug(f'Level active for level: {line.value()}') - return line + return line def lines_under_cursor(self): """Get the line(s) under the cursor position. @@ -357,15 +368,15 @@ class LineEditor: uuid = line.oid # try to look up line from our registry - line = self._order_lines.pop(uuid) + line = self._order_lines.pop(uuid, None) + if line: + # if hovered remove from cursor set + hovered = self.chart._cursor._hovered + if line in hovered: + hovered.remove(line) - # if hovered remove from cursor set - hovered = self.chart._cursor._hovered - if line in hovered: - hovered.remove(line) - - line.delete() - return line + line.delete() + return line @dataclass @@ -449,7 +460,8 @@ class OrderMode: self._action = action self.lines.stage_line( color=self._colors[action], - hl_on_hover=True if self._exec_mode == 'live' else False, + # hl_on_hover=True if self._exec_mode == 'live' else False, + dotted=True if self._exec_mode == 'dark' else False, ) def on_submit(self, uuid: str) -> dict: @@ -462,7 +474,8 @@ class OrderMode: """ self.lines.commit_line(uuid) req_msg = self.book._sent_orders.get(uuid) - req_msg['ack_time_ns'] = time.time_ns() + if req_msg: + req_msg['ack_time_ns'] = time.time_ns() return req_msg @@ -474,14 +487,15 @@ class OrderMode: pointing: Optional[str] = None ) -> None: - line = self.lines._order_lines[uuid] - self.arrows.add( - uuid, - arrow_index, - price, - pointing=pointing, - color=line.color - ) + 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, @@ -838,9 +852,7 @@ class ChartView(ViewBox): if mods == QtCore.Qt.ControlModifier: ctrl = True - print(mods) if mods == QtCore.Qt.ControlModifier: - print('space') self.mode._exec_mode = 'live' self._key_active = True @@ -868,10 +880,10 @@ class ChartView(ViewBox): # Order modes: stage orders at the current cursor level - elif key == QtCore.Qt.Key_D: + elif key == QtCore.Qt.Key_D: # for "damp eet" self.mode.set_exec('sell') - elif key == QtCore.Qt.Key_F: + elif key == QtCore.Qt.Key_F: # for "fillz eet" self.mode.set_exec('buy') elif key == QtCore.Qt.Key_A: From cc5af7319fcc610f9bd234259b14f45b7558864e Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 22 Jan 2021 23:02:23 -0500 Subject: [PATCH 034/139] Slightly lighter sell red, try to fix screen stuff... --- piker/ui/_style.py | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/piker/ui/_style.py b/piker/ui/_style.py index 1bfb60be..36f80927 100644 --- a/piker/ui/_style.py +++ b/piker/ui/_style.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 @@ -54,7 +54,15 @@ class DpiAwareFont: @property def screen(self) -> QtGui.QScreen: - return current_screen() + if self._screen is not None: + try: + self._screen.refreshRate() + except RuntimeError: + self._screen = current_screen() + else: + self._screen = current_screen() + + return self._screen @property def font(self): @@ -64,13 +72,16 @@ class DpiAwareFont: def px_size(self): return self._qfont.pixelSize() - def configure_to_dpi(self, screen: QtGui.QScreen): + def configure_to_dpi(self, screen: Optional[QtGui.QScreen] = None): """Set an appropriately sized font size depending on the screen DPI. If we end up needing to generalize this more here there are resources listed in the script in ``snippets/qt_screen_info.py``. """ + if screen is None: + screen = self.screen + # take the max since scaling can make things ugly in some cases pdpi = screen.physicalDotsPerInch() ldpi = screen.logicalDotsPerInch() @@ -83,7 +94,6 @@ class DpiAwareFont: ) self._set_qfont_px_size(font_size) self._physical_dpi = dpi - self._screen = screen def boundingRect(self, value: str) -> QtCore.QRectF: @@ -186,15 +196,23 @@ def hcolor(name: str) -> str: # 'hedge': '#558964', # 'hedge_light': '#5e9870', + '80s_neon_green': '#00b677', # 'buy_green': '#41694d', 'buy_green': '#558964', 'buy_green_light': '#558964', # sells # techincally "raspberry" - # 'sell_red': '#980036', - 'sell_red': '#990036', + # 'sell_red': '#990036', + # 'sell_red': '#9A0036', + + # brighter then above + # 'sell_red': '#8c0030', + + 'sell_red': '#b6003f', + # 'sell_red': '#d00048', 'sell_red_light': '#f85462', + # 'sell_red': '#f85462', # 'sell_red_light': '#ff4d5c', From 990c3a1eacc3b6a95ce522c74301d4f0ea714b16 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 26 Jan 2021 11:26:30 -0500 Subject: [PATCH 035/139] Try out 3 ticks away as limit submission --- piker/_ems.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/piker/_ems.py b/piker/_ems.py index 07fabe29..17c68831 100644 --- a/piker/_ems.py +++ b/piker/_ems.py @@ -539,11 +539,17 @@ async def _ems_main( if action == 'buy': tickfilter = ('ask', 'last', 'trade') percent_away = 0.005 - abs_diff_away = 2 * mt + + # TODO: we probably need to scale this based + # on some near term historical spread + # measure? + abs_diff_away = 3 * mt + elif action == 'sell': tickfilter = ('bid', 'last', 'trade') percent_away = -0.005 - abs_diff_away = -2 * mt + abs_diff_away = -3 * mt + else: # alert tickfilter = ('trade', 'utrade', 'last') percent_away = 0 From b9d9dbfc4ab2755304e9ca192a4de3144aa062df Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 26 Jan 2021 11:27:50 -0500 Subject: [PATCH 036/139] Support size fields on order lines; avoid overlap with L1 lines --- piker/ui/_axes.py | 10 ++- piker/ui/_graphics/_lines.py | 167 +++++++++++++++++++++++++++-------- 2 files changed, 136 insertions(+), 41 deletions(-) diff --git a/piker/ui/_axes.py b/piker/ui/_axes.py index f9893347..5c9993c6 100644 --- a/piker/ui/_axes.py +++ b/piker/ui/_axes.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 @@ -166,6 +166,7 @@ class AxisLabel(pg.GraphicsObject): super().__init__(parent) self.setFlag(self.ItemIgnoresTransformations) + # XXX: pretty sure this is faster self.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache) @@ -177,7 +178,7 @@ class AxisLabel(pg.GraphicsObject): self._txt_br: QtCore.QRect = None self._dpifont = DpiAwareFont(size_in_inches=font_size_inches) - self._dpifont.configure_to_dpi(_font._screen) + self._dpifont.configure_to_dpi() self.bg_color = pg.mkColor(hcolor(bg_color)) self.fg_color = pg.mkColor(hcolor(fg_color)) @@ -232,8 +233,11 @@ class AxisLabel(pg.GraphicsObject): """ # size the filled rect to text and/or parent axis - br = self._txt_br = self._dpifont.boundingRect(value) + # if not self._txt_br: + # # XXX: this can't be c + # self._txt_br = self._dpifont.boundingRect(value) + br = self._txt_br = self._dpifont.boundingRect(value) txt_h, txt_w = br.height(), br.width() h, w = self.size_hint() diff --git a/piker/ui/_graphics/_lines.py b/piker/ui/_graphics/_lines.py index d1a43247..caaee1dc 100644 --- a/piker/ui/_graphics/_lines.py +++ b/piker/ui/_graphics/_lines.py @@ -18,7 +18,7 @@ Lines for orders, alerts, L2. """ -from typing import Tuple +from typing import Tuple, Dict, Any, Optional import pyqtgraph as pg from PyQt5 import QtCore, QtGui @@ -27,6 +27,8 @@ from PyQt5.QtCore import QPointF from .._style import ( hcolor, _down_2_font_inches_we_like, + # _font, + # DpiAwareFont ) from .._axes import YSticky @@ -35,7 +37,15 @@ class LevelLabel(YSticky): _w_margin = 4 _h_margin = 3 - level: float = 0 + + # adjustment "further away from" parent axis + _x_offset = 0 + + # fields to be displayed + level: float = 0.0 + size: float = 2.0 + size_digits: int = int(2.0) + def __init__( self, @@ -65,6 +75,9 @@ class LevelLabel(YSticky): 'left': -1., 'right': 0 }[orient_h] + self._fmt_fields: Dict[str, Dict[str, Any]] = {} + self._use_extra_fields: bool = False + @property def color(self): return self._hcolor @@ -82,29 +95,39 @@ class LevelLabel(YSticky): ) -> None: # write contents, type specific - self.set_label_str(level) + h, w = self.set_label_str(level) - br = self.boundingRect() - h, w = br.height(), br.width() - - # this triggers ``.paint()`` implicitly? + # this triggers ``.paint()`` implicitly or no? self.setPos(QPointF( - self._h_shift * w - offset, + self._h_shift * w - self._x_offset, abs_pos.y() - (self._v_shift * h) - offset )) + # trigger .paint() self.update() self.level = level def set_label_str(self, level: float): - # self.label_str = '{size} x {level:.{digits}f}'.format( - # size=self._size, + # use space as e3 delim + label_str = (f'{level:,.{self.digits}f}').replace(',', ' ') + + # XXX: not huge on this approach but we need a more formal + # way to define "label fields" that i don't have the brain space + # for atm.. it's at least a **lot** better then the wacky + # internals of InfLinelabel or wtv. + + # mutate label to contain any extra defined format fields + if self._use_extra_fields: + for fmt_str, fields in self._fmt_fields.items(): + label_str = fmt_str.format( + **{f: getattr(self, f) for f in fields}) + label_str + + self.label_str = label_str + + br = self.boundingRect() + h, w = br.height(), br.width() + return h, w - # this is read inside ``.paint()`` - self.label_str = '{level:.{digits}f}'.format( - digits=self.digits, - level=level - ).replace(',', ' ') def size_hint(self) -> Tuple[None, None]: return None, None @@ -119,6 +142,7 @@ class LevelLabel(YSticky): if self._orient_v == 'bottom': lp, rp = rect.topLeft(), rect.topRight() # p.drawLine(rect.topLeft(), rect.topRight()) + elif self._orient_v == 'top': lp, rp = rect.bottomLeft(), rect.bottomRight() @@ -133,10 +157,15 @@ class LevelLabel(YSticky): self.update() +# global for now but probably should be +# attached to chart instance? +_max_l1_line_len: float = 0 + + class L1Label(LevelLabel): size: float = 0 - size_digits: float = 3 + size_digits: int = 3 text_flags = ( QtCore.Qt.TextDontClip @@ -148,12 +177,14 @@ class L1Label(LevelLabel): size in the text, eg. 100 x 323.3. """ - self.label_str = '{size:.{size_digits}f} x {level:,.{digits}f}'.format( - size_digits=self.size_digits, - size=self.size or 2, - digits=self.digits, - level=level - ).replace(',', ' ') + h, w = super().set_label_str(level) + + # Set a global "max L1 label length" so we can look it up + # on order lines and adjust their labels not to overlap with it. + global _max_l1_line_len + _max_l1_line_len = max(_max_l1_line_len, w) + + return h, w class L1Labels: @@ -200,6 +231,14 @@ class L1Labels: self.ask_label.size_digits = size_digits self.ask_label._size_br_from_str(self.max_value) + self.bid_label._use_extra_fields = True + self.ask_label._use_extra_fields = True + + self.bid_label._fmt_fields['{size:.{size_digits}f} x '] = { + 'size', 'size_digits'} + self.ask_label._fmt_fields['{size:.{size_digits}f} x '] = { + 'size', 'size_digits'} + # TODO: probably worth investigating if we can # make .boundingRect() faster: @@ -217,6 +256,8 @@ class LevelLine(pg.InfiniteLine): highlight_color: str = 'default_light', hl_on_hover: bool = True, dotted: bool = False, + adjust_to_l1: bool = False, + always_show_label: bool = False, **kwargs, ) -> None: @@ -234,6 +275,8 @@ class LevelLine(pg.InfiniteLine): # TODO: for when we want to move groups of lines? self._track_cursor: bool = False + self._adjust_to_l1 = adjust_to_l1 + self._always_show_label = always_show_label # testing markers # self.addMarker('<|', 0.1, 3) @@ -267,14 +310,24 @@ class LevelLine(pg.InfiniteLine): hoverpen.setWidth(2) self.hoverPen = hoverpen - def set_level(self, value: float) -> None: - self.label.update_from_data(0, self.value()) + def set_level(self) -> None: + + label = self.label + + # TODO: a better way to accomplish this... + if self._adjust_to_l1: + label._x_offset = _max_l1_line_len + + label.update_from_data(0, self.value()) def on_tracked_source( self, x: int, y: float ) -> None: + # XXX: this is called by our ``Cursor`` type once this + # line is set to track the cursor: for every movement + # this callback is invoked to reposition the line self.movable = True self.setPos(y) # implictly calls ``.set_level()`` self.update() @@ -300,19 +353,23 @@ class LevelLine(pg.InfiniteLine): # add us to cursor state chart._cursor.add_hovered(self) + self.label.show() # TODO: hide y-crosshair? # chart._cursor.graphics[chart]['hl'].hide() + # self.setCursor(QtCore.Qt.OpenHandCursor) + # self.setCursor(QtCore.Qt.DragMoveCursor) else: self.currentPen = self.pen self.label.unhighlight() chart._cursor._hovered.remove(self) + if not self._always_show_label: + self.label.hide() + # highlight any attached label - # self.setCursor(QtCore.Qt.OpenHandCursor) - # self.setCursor(QtCore.Qt.DragMoveCursor) self.update() def mouseDragEvent(self, ev): @@ -339,13 +396,6 @@ class LevelLine(pg.InfiniteLine): ) -> 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. @@ -357,6 +407,20 @@ class LevelLine(pg.InfiniteLine): self._chart.plotItem.removeItem(self) + def getEndpoints(self): + """Get line endpoints at view edges. + + Stolen from InfLineLabel. + + """ + # calculate points where line intersects view box + # (in line coordinates) + lr = self.boundingRect() + pt1 = pg.Point(lr.left(), 0) + pt2 = pg.Point(lr.right(), 0) + + return pt1, pt2 + def level_line( chart: 'ChartPlogWidget', # noqa @@ -367,8 +431,6 @@ def level_line( # size 4 font on 4k screen scaled down, so small-ish. font_size_inches: float = _down_2_font_inches_we_like, - show_label: bool = True, - # whether or not the line placed in view should highlight # when moused over (aka "hovered") hl_on_hover: bool = True, @@ -376,6 +438,10 @@ def level_line( # line style dotted: bool = False, + adjust_to_l1: bool = False, + + always_show_label: bool = False, + **linelabelkwargs ) -> LevelLine: """Convenience routine to add a styled horizontal line to a plot. @@ -396,6 +462,7 @@ def level_line( **linelabelkwargs ) label.update_from_data(0, level) + label.hide() # TODO: can we somehow figure out a max value from the parent axis? label._size_br_from_str(label.label_str) @@ -410,15 +477,39 @@ def level_line( angle=0, hl_on_hover=hl_on_hover, dotted=dotted, + adjust_to_l1=adjust_to_l1, + always_show_label=always_show_label, ) - line.setValue(level) # activate/draw label line.setValue(level) + line.set_level() chart.plotItem.addItem(line) - if not show_label: - label.hide() + return line + + +def order_line( + *args, + size: Optional[int] = None, + size_digits: int = 0, + **kwargs, +) -> LevelLine: + """Convenience routine to add a line graphic representing an order execution + submitted to the EMS via the chart's "order mode". + + """ + line = level_line(*args, adjust_to_l1=True, **kwargs) + line.label._fmt_fields['{size:.{size_digits}f} x '] = { + 'size', 'size_digits'} + + if size is not None: + + line.label._use_extra_fields = True + line.label.size = size + line.label.size_digits = size_digits + + line.label.hide() return line From 25ec5faaefc7a065238a53f07715ea576dfdf71a Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 26 Jan 2021 11:28:24 -0500 Subject: [PATCH 037/139] Drop removed show_label kwarg --- piker/ui/_chart.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 6696622d..55601786 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -995,7 +995,8 @@ async def _async_main( if resp in ('dark_submitted', 'broker_submitted'): # show line label once order is live - order_mode.on_submit(oid) + line = order_mode.on_submit(oid) + # await tractor.breakpoint() # resp to 'cancel' request or error condition # for action request @@ -1017,7 +1018,7 @@ async def _async_main( price=msg['trigger_price'], arrow_index=get_index(time.time()) ) - await order_mode.on_exec(oid, msg) + line = await order_mode.on_exec(oid, msg) # response to completed 'action' request for buy/sell elif resp in ('broker_executed',): @@ -1354,10 +1355,10 @@ async def update_signals( # add moveable over-[sold/bought] lines # and labels only for the 70/30 lines - level_line(chart, 20, show_label=False) + l = level_line(chart, 20) level_line(chart, 30, orient_v='top') level_line(chart, 70, orient_v='bottom') - level_line(chart, 80, orient_v='top', show_label=False) + l = level_line(chart, 80, orient_v='top') chart._set_yrange() From a232e8bc3985c465a0a0aff47cdf024917cc045b Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 26 Jan 2021 11:36:44 -0500 Subject: [PATCH 038/139] Display order size on order lines in order mode --- piker/ui/_interaction.py | 61 ++++++++++++++++++++++++++++------------ 1 file changed, 43 insertions(+), 18 deletions(-) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 2cf02fbf..1a15c2a6 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -32,7 +32,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 ._graphics._lines import order_line, LevelLine from .._ems import OrderBook @@ -211,7 +211,11 @@ _order_lines: Dict[str, LevelLine] = {} @dataclass class LineEditor: + """The great editor of linez.. + + """ view: 'ChartView' + _order_lines: field(default_factory=_order_lines) chart: 'ChartPlotWidget' = None # type: ignore # noqa _active_staged_line: LevelLine = None @@ -222,13 +226,15 @@ class LineEditor: color: str = 'alert_yellow', hl_on_hover: bool = False, dotted: bool = False, + size: Optional[int] = None, ) -> LevelLine: """Stage a line at the current chart's cursor position and return it. """ + # chart.setCursor(QtCore.Qt.PointingHandCursor) + chart = self.chart._cursor.active_plot - chart.setCursor(QtCore.Qt.PointingHandCursor) cursor = chart._cursor y = chart._cursor._datum_xy[1] @@ -236,7 +242,7 @@ class LineEditor: if not line: # add a "staged" cursor-tracking line to view # and cash it in a a var - line = level_line( + line = order_line( chart, level=y, digits=chart._lc.symbol.digits(), @@ -245,11 +251,18 @@ class LineEditor: # don't highlight the "staging" line hl_on_hover=hl_on_hover, dotted=dotted, + size=size, ) self._stage_line = line else: - # print(f'hl on hover: {hl_on_hover}') + label = line.label + + # disable order size and other extras in label + label._use_extra_fields = size is not None + label.size = size + # label.size_digits = line.label.size_digits + label.color = color # Use the existing staged line instead but copy # overe it's current style "properties". @@ -258,14 +271,18 @@ class LineEditor: line._dotted = dotted line.color = color line.setMouseHover(hl_on_hover) - line.setValue(y) + + # XXX: must have this to trigger updated + # label contents rendering + line.setPos(y) + line.set_level() + line.update() line.show() - - label = line.label - label.color = color label.show() + # label.set_label_str(line.) + self._active_staged_line = line # hide crosshair y-line @@ -311,14 +328,16 @@ class LineEditor: chart = self.chart._cursor.active_plot y = chart._cursor._datum_xy[1] - line = level_line( + line = order_line( chart, level=y, color=line.color, digits=chart._lc.symbol.digits(), - show_label=False, dotted=line._dotted, + size=line.label.size, ) + # for now, until submission reponse arrives + line.label.hide() # register for later lookup/deletion self._order_lines[uuid] = line @@ -338,6 +357,8 @@ class LineEditor: return else: line.oid = uuid + line.set_level() + line.label.update() line.label.show() # TODO: other flashy things to indicate the order is active @@ -399,7 +420,7 @@ class ArrowEditor: angle = { 'up': 90, 'down': -90, - None: 0, + None: 180, # pointing to right }[pointing] yb = pg.mkBrush(hcolor(color)) @@ -443,6 +464,7 @@ class OrderMode: } _action: str = 'alert' _exec_mode: str = 'dark' + _size: int = 100 key_map: Dict[str, Callable] = field(default_factory=dict) @@ -452,7 +474,7 @@ class OrderMode: def set_exec( self, action: str, - # mode: str, + size: Optional[int] = None, ) -> None: """Set execution mode. @@ -462,6 +484,7 @@ class OrderMode: 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, ) def on_submit(self, uuid: str) -> dict: @@ -472,12 +495,12 @@ class OrderMode: 'dark'). """ - self.lines.commit_line(uuid) + 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 req_msg + return line def on_fill( self, @@ -557,6 +580,7 @@ class OrderMode: action=self._action, exec_mode=self._exec_mode, ) + return line @asynccontextmanager @@ -568,7 +592,7 @@ async def open_order_mode( view = chart._vb # book = get_orders() - lines = LineEditor(view=view, _order_lines=_order_lines, chart=chart) + lines = LineEditor(view=view, chart=chart, _order_lines=_order_lines) arrows = ArrowEditor(chart, {}) log.info("Opening order mode") @@ -873,6 +897,7 @@ class ChartView(ViewBox): mode.book.cancel(uuid=line.oid) self._key_buffer.append(text) + order_size = self.mode._size # View modes if key == QtCore.Qt.Key_R: @@ -881,13 +906,13 @@ class ChartView(ViewBox): # Order modes: stage orders at the current cursor level elif key == QtCore.Qt.Key_D: # for "damp eet" - self.mode.set_exec('sell') + self.mode.set_exec('sell', size=order_size) elif key == QtCore.Qt.Key_F: # for "fillz eet" - self.mode.set_exec('buy') + self.mode.set_exec('buy', size=order_size) elif key == QtCore.Qt.Key_A: - self.mode.set_exec('alert') + self.mode.set_exec('alert', size=None) # delete orders under cursor elif key == QtCore.Qt.Key_Delete: From 8501a9be4f0f6c93c7c6e5203aadd3499f4f9cd7 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 26 Jan 2021 12:26:43 -0500 Subject: [PATCH 039/139] Lol actually fix screen wakeup lookup.. --- piker/ui/_exec.py | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/piker/ui/_exec.py b/piker/ui/_exec.py index 365ab749..5103c256 100644 --- a/piker/ui/_exec.py +++ b/piker/ui/_exec.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 @@ -56,26 +56,29 @@ _qt_app: QtGui.QApplication = None _qt_win: QtGui.QMainWindow = None -def current_screen(timeout: float = 6) -> QtGui.QScreen: - print('yo screen zonnnee') +def current_screen() -> QtGui.QScreen: + """Get a frickin screen (if we can, gawd). + """ global _qt_win, _qt_app - screen = _qt_app.screenAt(_qt_win.centralWidget().geometry().center()) start = time.time() - # breakpoint() - # wait for 6 seconds to grab screen - while screen is None and ( - (time.time() - start) < timeout - ): - screen = _qt_app.screenAt(_qt_win.centralWidget().geometry().center()) - time.sleep(0.1) - log.info("Couldn't acquire screen trying again...") + tries = 3 + for _ in range(3): + screen = _qt_app.screenAt(_qt_win.pos()) + print(f'trying to get screen....') + if screen is None: + time.sleep(0.5) + continue - if screen is None: - raise RuntimeError("Failed to acquire screen?") + break + else: + if screen is None: + # try for the first one we can find + screen = _qt_app.screens()[0] + assert screen, "Wow Qt is dumb as shit and has no screen..." return screen From 5a0612e6a8f408d49bebdeb8763d1af7006172f0 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 26 Jan 2021 13:34:29 -0500 Subject: [PATCH 040/139] Factor some line and label steps --- piker/ui/_interaction.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 1a15c2a6..0079c371 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -253,6 +253,9 @@ class LineEditor: dotted=dotted, size=size, ) + line.label._use_extra_fields = size is not None + + # cache staging line after creation self._stage_line = line else: @@ -271,17 +274,13 @@ class LineEditor: line._dotted = dotted line.color = color line.setMouseHover(hl_on_hover) - - # XXX: must have this to trigger updated - # label contents rendering - line.setPos(y) - line.set_level() - - line.update() line.show() - label.show() - # label.set_label_str(line.) + # XXX: must have this to trigger updated + # label contents rendering + line.setPos(y) + line.set_level() + label.show() self._active_staged_line = line From 708086cbcbee05f03aeb0db7c4f60a8ddde79522 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 26 Jan 2021 13:34:49 -0500 Subject: [PATCH 041/139] Convert contractsto dicts on errors --- piker/brokers/ib.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/piker/brokers/ib.py b/piker/brokers/ib.py index 6073d036..dc449a43 100644 --- a/piker/brokers/ib.py +++ b/piker/brokers/ib.py @@ -819,7 +819,7 @@ async def fill_bars( first_bars: list, shm: 'ShmArray', # type: ignore # noqa # 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 + count: int = 6, # NOTE: any more and we'll overrun the underlying buffer ) -> None: """Fill historical bars into shared mem / storage afap. @@ -1177,4 +1177,9 @@ async def stream_trades( elif event_name == 'error': msg = item + # f$#$% gawd dammit insync.. + con = msg['contract'] + if isinstance(con, Contract): + msg['contract'] = asdict(con) + yield {'local_trades': (event_name, msg)} From cfc36e7928304ca6efd5caa2b95056d3d5eb1710 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 26 Jan 2021 22:15:45 -0500 Subject: [PATCH 042/139] Snap y-cursor-label to min tick --- piker/ui/_graphics/_cursor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/piker/ui/_graphics/_cursor.py b/piker/ui/_graphics/_cursor.py index 791ffccc..c7587d78 100644 --- a/piker/ui/_graphics/_cursor.py +++ b/piker/ui/_graphics/_cursor.py @@ -367,7 +367,8 @@ class Cursor(pg.GraphicsObject): self.graphics[plot]['hl'].setY(iy) self.graphics[self.active_plot]['yl'].update_label( - abs_pos=pos, value=iy + abs_pos=plot.mapFromView(QPointF(ix, iy)), + value=iy ) # update all trackers From 03541bd368dbff4f4de31b57469530bcd6502eda Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 26 Jan 2021 22:16:18 -0500 Subject: [PATCH 043/139] Drop unneeded .hide(); add more comments --- piker/ui/_graphics/_lines.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/piker/ui/_graphics/_lines.py b/piker/ui/_graphics/_lines.py index caaee1dc..655c7d08 100644 --- a/piker/ui/_graphics/_lines.py +++ b/piker/ui/_graphics/_lines.py @@ -462,6 +462,8 @@ def level_line( **linelabelkwargs ) label.update_from_data(0, level) + + # by default, the label must be shown by client code label.hide() # TODO: can we somehow figure out a max value from the parent axis? @@ -470,19 +472,32 @@ def level_line( line = LevelLine( chart, label, + color=color, # lookup "highlight" equivalent highlight_color=color + '_light', + movable=True, angle=0, - hl_on_hover=hl_on_hover, + dotted=dotted, + + # UX related options + + hl_on_hover=hl_on_hover, + + # makes order line labels offset from their parent axis + # such that they don't collide with the L1/L2 lines/prices + # that are displayed on the axis adjust_to_l1=adjust_to_l1, + + # when set to True the label is always shown instead of just on + # highlight (which is a privacy thing for orders) always_show_label=always_show_label, ) # activate/draw label - line.setValue(level) + line.setValue(level) # it's just .setPos() right? line.set_level() chart.plotItem.addItem(line) @@ -510,6 +525,4 @@ def order_line( line.label.size = size line.label.size_digits = size_digits - line.label.hide() - return line From a8c4829cb6c6ada33c35059355b043594ca731eb Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 6 Feb 2021 11:35:12 -0500 Subject: [PATCH 044/139] Start using `tick_size` throughout charting The min tick size is the smallest step an instrument can move in value (think the number of decimals places of precision the value can have). We start leveraging this in a few places: - make our internal "symbol" type expose it as part of it's api so that it can be passed around by UI components - in y-axis view box scaling, use it to keep the bid/ask spread (L1 UI) always on screen even in the case where the spread has moved further out of view then the last clearing price - allows the EMS to determine dark order live order submission offsets --- piker/_ems.py | 23 +++++-- piker/data/_source.py | 12 ++-- piker/ui/_chart.py | 124 +++++++++++++++++++--------------- piker/ui/_graphics/_cursor.py | 2 +- 4 files changed, 92 insertions(+), 69 deletions(-) diff --git a/piker/_ems.py b/piker/_ems.py index 17c68831..21e3b0fb 100644 --- a/piker/_ems.py +++ b/piker/_ems.py @@ -114,7 +114,11 @@ def get_book(broker: str) -> _ExecBook: return _books.setdefault(broker, _ExecBook(broker)) -_DEFAULT_SIZE: float = 100.0 +# XXX: this is in place to prevent accidental positions that are too +# big. Now obviously this won't make sense for crypto like BTC, but +# for most traditional brokers it should be fine unless you start +# slinging NQ futes or something. +_DEFAULT_SIZE: float = 1.0 @dataclass @@ -326,7 +330,7 @@ async def process_broker_trades( 'fill' -> 'broker_filled' Currently accepted status values from IB - {'presubmitted', 'submitted', 'cancelled'} + {'presubmitted', 'submitted', 'cancelled', 'inactive'} """ broker = feed.mod.name @@ -352,7 +356,9 @@ async def process_broker_trades( oid = book._broker2ems_ids.get(reqid) resp = {'oid': oid} - if name in ('error',): + if name in ( + 'error', + ): # TODO: figure out how this will interact with EMS clients # for ex. on an error do we react with a dark orders # management response, like cancelling all dark orders? @@ -373,8 +379,9 @@ async def process_broker_trades( # another stupid ib error to handle # if 10147 in message: cancel - elif name in ('status',): - + elif name in ( + 'status', + ): # everyone doin camel case status = msg['status'].lower() @@ -397,7 +404,9 @@ async def process_broker_trades( await ctx.send_yield(resp) - elif name in ('fill',): + elif name in ( + 'fill', + ): # proxy through the "fill" result(s) resp['resp'] = 'broker_filled' resp.update(msg) @@ -534,7 +543,7 @@ async def _ems_main( # the user choose the predicate operator. pred = mk_check(trigger_price, last) - mt = feed.symbols[sym].min_tick + mt = feed.symbols[sym].tick_size if action == 'buy': tickfilter = ('ask', 'last', 'trade') diff --git a/piker/data/_source.py b/piker/data/_source.py index 6a71b444..add32d13 100644 --- a/piker/data/_source.py +++ b/piker/data/_source.py @@ -80,9 +80,11 @@ class Symbol: """I guess this is some kinda container thing for dealing with all the different meta-data formats from brokers? + Yah, i guess dats what it izz. """ key: str = '' - min_tick: float = 0.01 + tick_size: float = 0.01 + v_tick_size: float = 0.01 broker_info: Dict[str, Dict[str, Any]] = field(default_factory=dict) deriv: str = '' @@ -91,17 +93,17 @@ class Symbol: return list(self.broker_info.keys()) def digits(self) -> int: - """Return the trailing number of digits specified by the - min tick size for the instrument. + """Return the trailing number of digits specified by the min + tick size for the instrument. """ - return float_digits(self.min_tick) + return float_digits(self.tick_size) def nearest_tick(self, value: float) -> float: """Return the nearest tick value based on mininum increment. """ - mult = 1 / self.min_tick + mult = 1 / self.tick_size return round(value * mult) / mult diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 55601786..a5bd1275 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -140,7 +140,7 @@ class ChartSpace(QtGui.QWidget): # XXX: let's see if this causes mem problems self.window.setWindowTitle( f'piker chart {symbol.key}@{symbol.brokers} ' - f'tick:{symbol.min_tick}' + f'tick:{symbol.tick_size}' ) # TODO: symbol search @@ -195,7 +195,6 @@ class LinkedSplitCharts(QtGui.QWidget): self._cursor: Cursor = None # crosshair graphics self.chart: ChartPlotWidget = None # main (ohlc) chart self.subplots: Dict[Tuple[str, ...], ChartPlotWidget] = {} - self.digits: int = 2 self.xaxis = DynamicDateAxis( orientation='bottom', @@ -245,13 +244,10 @@ class LinkedSplitCharts(QtGui.QWidget): The data input struct array must include OHLC fields. """ - self.min_tick = symbol.min_tick - self.digits = symbol.digits() - # add crosshairs self._cursor = Cursor( linkedsplitcharts=self, - digits=self.digits + digits=symbol.digits(), ) self.chart = self.add_plot( name=symbol.key, @@ -428,7 +424,7 @@ class ChartPlotWidget(pg.PlotWidget): # Assign callback for rescaling y-axis automatically # based on data contents and ``ViewBox`` state. - self.sigXRangeChanged.connect(self._set_yrange) + # self.sigXRangeChanged.connect(self._set_yrange) # for mouse wheel which doesn't seem to emit XRangeChanged self._vb.sigRangeChangedManually.connect(self._set_yrange) @@ -506,6 +502,7 @@ class ChartPlotWidget(pg.PlotWidget): max=end, padding=0, ) + self._set_yrange() def increment_view( self, @@ -657,7 +654,7 @@ class ChartPlotWidget(pg.PlotWidget): chart=self, parent=self.getAxis('right'), # TODO: pass this from symbol data - digits=self._lc._symbol.digits(), + digits=self._lc.symbol.digits(), opacity=1, bg_color=bg_color, ) @@ -708,6 +705,7 @@ class ChartPlotWidget(pg.PlotWidget): self, *, yrange: Optional[Tuple[float, float]] = None, + range_margin: float = 0.04, ) -> None: """Set the viewable y-range based on embedded data. @@ -767,7 +765,7 @@ class ChartPlotWidget(pg.PlotWidget): a = self._ohlc ifirst = a[0]['index'] - bars = a[lbar - ifirst:rbar - ifirst] + bars = a[lbar - ifirst:rbar - ifirst + 1] if not len(bars): # likely no data loaded yet or extreme scrolling? @@ -788,8 +786,8 @@ class ChartPlotWidget(pg.PlotWidget): if set_range: # view margins: stay within a % of the "true range" diff = yhigh - ylow - ylow = ylow - (diff * 0.04) - yhigh = yhigh + (diff * 0.04) + ylow = ylow - (diff * range_margin) + yhigh = yhigh + (diff * range_margin) self.setLimits( yMin=ylow, @@ -992,20 +990,27 @@ async def _async_main( resp = msg['resp'] # response to 'action' request (buy/sell) - if resp in ('dark_submitted', 'broker_submitted'): + if resp in ( + 'dark_submitted', + 'broker_submitted' + ): # show line label once order is live line = order_mode.on_submit(oid) - # await tractor.breakpoint() # resp to 'cancel' request or error condition # for action request - elif resp in ('broker_cancelled', 'dark_cancelled'): - + elif resp in ( + 'broker_cancelled', + 'broker_inactive', + 'dark_cancelled' + ): # delete level line from view order_mode.on_cancel(oid) - elif resp in ('dark_executed'): + elif resp in ( + 'dark_executed' + ): log.info(f'Dark order filled for {fmsg}') # for alerts add a triangle and remove the @@ -1021,7 +1026,9 @@ async def _async_main( line = await order_mode.on_exec(oid, msg) # response to completed 'action' request for buy/sell - elif resp in ('broker_executed',): + elif resp in ( + 'broker_executed', + ): await order_mode.on_exec(oid, msg) # each clearing tick is responded individually @@ -1081,7 +1088,7 @@ async def chart_from_quotes( # sym = chart.name # mx, mn = np.nanmax(in_view[sym]), np.nanmin(in_view[sym]) - return last_bars_range, mx, mn + return last_bars_range, mx, max(mn, 0) chart.default_view() @@ -1104,6 +1111,9 @@ async def chart_from_quotes( # levels this might be dark volume we need to # present differently? + tick_size = chart._lc.symbol.tick_size + tick_margin = 2 * tick_size + async for quotes in stream: for sym, quote in quotes.items(): @@ -1114,20 +1124,18 @@ async def chart_from_quotes( price = tick.get('price') size = tick.get('size') - # compute max and min trade values to display in view - # TODO: we need a streaming minmax algorithm here, see - # def above. - brange, mx_in_view, mn_in_view = maxmin() - l, lbar, rbar, r = brange + if ticktype == 'n/a' or price == -1: + # okkk.. + continue if ticktype in ('trade', 'utrade', 'last'): array = ohlcv.array # update price sticky(s) - last = array[-1] + end = array[-1] last_price_sticky.update_from_data( - *last[['index', 'close']] + *end[['index', 'close']] ) # plot bars @@ -1139,7 +1147,17 @@ async def chart_from_quotes( if wap_in_history: # update vwap overlay line - chart.update_curve_from_array('bar_wap', ohlcv.array) + chart.update_curve_from_array( + 'bar_wap', ohlcv.array) + + # compute max and min trade values to display in view + # TODO: we need a streaming minmax algorithm here, see + # def above. + brange, mx_in_view, mn_in_view = maxmin() + l, lbar, rbar, r = brange + + mx = mx_in_view + tick_margin + mn = mn_in_view - tick_margin # XXX: prettty sure this is correct? # if ticktype in ('trade', 'last'): @@ -1162,25 +1180,33 @@ async def chart_from_quotes( l1.ask_label.size = size l1.ask_label.update_from_data(0, price) - # update max price in view to keep ask on screen - mx_in_view = max(price, mx_in_view) elif ticktype in ('bid', 'bsize'): l1.bid_label.size = size l1.bid_label.update_from_data(0, price) - # update min price in view to keep bid on screen - mn_in_view = min(price, mn_in_view) + # update min price in view to keep bid on screen + mn = min(price - tick_margin, mn) + # update max price in view to keep ask on screen + mx = max(price + tick_margin, mx) - if mx_in_view > last_mx or mn_in_view < last_mn: - chart._set_yrange(yrange=(mn_in_view, mx_in_view)) - last_mx, last_mn = mx_in_view, mn_in_view + if (mx > last_mx) or ( + mn < last_mn + ): + print(f'new y range: {(mn, mx)}') + + chart._set_yrange( + yrange=(mn, mx), + # TODO: we should probably scale + # the view margin based on the size + # of the true range? This way you can + # slap in orders outside the current + # L1 (only) book range. + # range_margin=0.1, + ) + + last_mx, last_mn = mx, mn - if brange != last_bars_range: - # we **must always** update the last values due to - # the x-range change - last_mx, last_mn = mx_in_view, mn_in_view - last_bars_range = brange async def spawn_fsps( @@ -1355,10 +1381,10 @@ async def update_signals( # add moveable over-[sold/bought] lines # and labels only for the 70/30 lines - l = level_line(chart, 20) + level_line(chart, 20) level_line(chart, 30, orient_v='top') level_line(chart, 70, orient_v='bottom') - l = level_line(chart, 80, orient_v='top') + level_line(chart, 80, orient_v='top') chart._set_yrange() @@ -1395,6 +1421,7 @@ async def update_signals( async def check_for_new_bars(feed, ohlcv, linked_charts): """Task which updates from new bars in the shared ohlcv buffer every ``delay_s`` seconds. + """ # TODO: right now we'll spin printing bars if the last time # stamp is before a large period of no market activity. @@ -1422,12 +1449,6 @@ async def check_for_new_bars(feed, ohlcv, linked_charts): # current bar) and then either write the current bar manually # or place a cursor for visual cue of the current time step. - # price_chart.update_ohlc_from_array( - # price_chart.name, - # ohlcv.array, - # just_history=True, - # ) - # XXX: this puts a flat bar on the current time step # TODO: if we eventually have an x-axis time-step "cursor" # we can get rid of this since it is extra overhead. @@ -1437,9 +1458,6 @@ async def check_for_new_bars(feed, ohlcv, linked_charts): just_history=False, ) - # resize view - # price_chart._set_yrange() - for name in price_chart._overlays: price_chart.update_curve_from_array( @@ -1447,15 +1465,8 @@ async def check_for_new_bars(feed, ohlcv, linked_charts): price_chart._arrays[name] ) - # # TODO: standard api for signal lookups per plot - # if name in price_chart._ohlc.dtype.fields: - - # # should have already been incremented above - # price_chart.update_curve_from_array(name, price_chart._ohlc) - for name, chart in linked_charts.subplots.items(): chart.update_curve_from_array(chart.name, chart._shm.array) - # chart._set_yrange() # shift the view if in follow mode price_chart.increment_view() @@ -1467,6 +1478,7 @@ def _main( tractor_kwargs, ) -> None: """Sync entry point to start a chart app. + """ # Qt entry point run_qtractor( diff --git a/piker/ui/_graphics/_cursor.py b/piker/ui/_graphics/_cursor.py index c7587d78..b4db057a 100644 --- a/piker/ui/_graphics/_cursor.py +++ b/piker/ui/_graphics/_cursor.py @@ -228,7 +228,7 @@ class Cursor(pg.GraphicsObject): # value used for rounding y-axis discreet tick steps # computing once, up front, here cuz why not - self._y_incr_mult = 1 / self.lsc._symbol.min_tick + self._y_incr_mult = 1 / self.lsc._symbol.tick_size def add_hovered( self, From 69df73afc3e2d33aff05ae102ff6f73a1fc688f1 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 6 Feb 2021 14:23:27 -0500 Subject: [PATCH 045/139] Deliver symbol info from kraken; start using pydantic --- piker/brokers/kraken.py | 108 +++++++++++++++++++++++++++------------- setup.py | 1 + 2 files changed, 74 insertions(+), 35 deletions(-) diff --git a/piker/brokers/kraken.py b/piker/brokers/kraken.py index caa810e4..3bd6081c 100644 --- a/piker/brokers/kraken.py +++ b/piker/brokers/kraken.py @@ -18,7 +18,7 @@ Kraken backend. """ from contextlib import asynccontextmanager -from dataclasses import dataclass, asdict, field +from dataclasses import asdict, field from typing import List, Dict, Any, Tuple, Optional import json import time @@ -30,6 +30,8 @@ import asks import numpy as np import trio import tractor +from pydantic.dataclasses import dataclass +from pydantic import BaseModel from ._util import resproc, SymbolNotFound, BrokerError from ..log import get_logger, get_console_log @@ -68,6 +70,68 @@ ohlc_dtype = np.dtype(_ohlc_dtype) _show_wap_in_history = True +_symbol_info_translation: Dict[str, str] = { + 'tick_decimals': 'pair_decimals', +} + + +# https://www.kraken.com/features/api#get-tradable-pairs +class Pair(BaseModel): + altname: str # alternate pair name + wsname: str # WebSocket pair name (if available) + aclass_base: str # asset class of base component + base: str # asset id of base component + aclass_quote: str # asset class of quote component + quote: str # asset id of quote component + lot: str # volume lot size + + pair_decimals: int # scaling decimal places for pair + lot_decimals: int # scaling decimal places for volume + + # amount to multiply lot volume by to get currency volume + lot_multiplier: float + + # array of leverage amounts available when buying + leverage_buy: List[int] + # array of leverage amounts available when selling + leverage_sell: List[int] + + # fee schedule array in [volume, percent fee] tuples + fees: List[Tuple[int, float]] + + # maker fee schedule array in [volume, percent fee] tuples (if on + # maker/taker) + fees_maker: List[Tuple[int, float]] + + fee_volume_currency: str # volume discount currency + margin_call: str # margin call level + margin_stop: str # stop-out/liquidation margin level + ordermin: float # minimum order volume for pair + + +@dataclass +class OHLC: + """Description of the flattened OHLC quote format. + + For schema details see: + https://docs.kraken.com/websockets/#message-ohlc + """ + chan_id: int # internal kraken id + chan_name: str # eg. ohlc-1 (name-interval) + pair: str # fx pair + time: float # Begin time of interval, in seconds since epoch + etime: float # End time of interval, in seconds since epoch + open: float # Open price of interval + high: float # High price within interval + low: float # Low price within interval + close: float # Close price of interval + vwap: float # Volume weighted average price within interval + volume: float # Accumulated volume **within interval** + count: int # Number of trades within interval + # (sampled) generated tick data + ticks: List[Any] = field(default_factory=list) + + class Client: def __init__(self) -> None: @@ -165,36 +229,6 @@ async def get_client() -> Client: yield Client() -@dataclass -class OHLC: - """Description of the flattened OHLC quote format. - - For schema details see: - https://docs.kraken.com/websockets/#message-ohlc - """ - chan_id: int # internal kraken id - chan_name: str # eg. ohlc-1 (name-interval) - pair: str # fx pair - time: float # Begin time of interval, in seconds since epoch - etime: float # End time of interval, in seconds since epoch - open: float # Open price of interval - high: float # High price within interval - low: float # Low price within interval - close: float # Close price of interval - vwap: float # Volume weighted average price within interval - volume: float # Accumulated volume **within interval** - count: int # Number of trades within interval - # (sampled) generated tick data - ticks: List[Any] = field(default_factory=list) - - # XXX: ugh, super hideous.. needs built-in converters. - def __post_init__(self): - for f, val in self.__dataclass_fields__.items(): - if f == 'ticks': - continue - setattr(self, f, val.type(getattr(self, f))) - - async def recv_msg(recv): too_slow_count = last_hb = 0 @@ -317,8 +351,12 @@ async def stream_quotes( # keep client cached for real-time section for sym in symbols: - si = sym_infos[sym] = await client.symbol_info(sym) - ws_pairs[sym] = si['wsname'] + si = Pair(**await client.symbol_info(sym)) # validation + syminfo = si.dict() + syminfo['price_tick_size'] = 1/10**si.pair_decimals + syminfo['lot_tick_size'] = 1/10**si.lot_decimals + sym_infos[sym] = syminfo + ws_pairs[sym] = si.wsname # maybe load historical ohlcv in to shared mem # check if shm has already been created by previous @@ -349,9 +387,9 @@ async def stream_quotes( symbol: { 'is_shm_writer': not writer_exists, 'shm_token': shm_token, - 'symbol_info': sym_infos[symbol], + 'symbol_info': sym_infos[sym], } - for sym in symbols + # for sym in symbols } yield init_msgs diff --git a/setup.py b/setup.py index 80b57ea3..6a37f47e 100755 --- a/setup.py +++ b/setup.py @@ -45,6 +45,7 @@ setup( 'attrs', 'pygments', 'colorama', # numba traceback coloring + 'pydantic', # structured data # async 'trio', From 0c184b1b415430b1f9e8e46f9e68ff2db69a22f7 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 6 Feb 2021 14:37:24 -0500 Subject: [PATCH 046/139] Port ib to new provide new tick size fields in symbol info --- piker/brokers/ib.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/piker/brokers/ib.py b/piker/brokers/ib.py index dc449a43..c17bdcf3 100644 --- a/piker/brokers/ib.py +++ b/piker/brokers/ib.py @@ -386,7 +386,7 @@ class Client: self, symbol: str, to_trio, - opts: Tuple[int] = ('375', '233',), + opts: Tuple[int] = ('375', '233', '236'), contract: Optional[Contract] = None, ) -> None: """Stream a ticker using the std L1 api. @@ -818,8 +818,8 @@ async def fill_bars( sym: str, first_bars: list, shm: 'ShmArray', # type: ignore # noqa - # count: int = 20, # NOTE: any more and we'll overrun underlying buffer - count: int = 6, # NOTE: any more and we'll overrun the underlying buffer + count: int = 20, # NOTE: any more and we'll overrun underlying buffer + # count: int = 6, # NOTE: any more and we'll overrun the underlying buffer ) -> None: """Fill historical bars into shared mem / storage afap. @@ -952,8 +952,13 @@ async def stream_quotes( # pass back some symbol info like min_tick, trading_hours, etc. # con = asdict(contract) # syminfo = contract - symdeats = asdict(details) - symdeats.update(symdeats['contract']) + syminfo = asdict(details) + syminfo.update(syminfo['contract']) + + # TODO: more consistent field translation + syminfo['price_tick_size'] = syminfo['minTick'] + # for "traditional" assets, volume is discreet not a float + syminfo['lot_tick_size'] = 0 # TODO: for loop through all symbols passed in init_msgs = { @@ -962,7 +967,7 @@ async def stream_quotes( sym: { 'is_shm_writer': not writer_already_exists, 'shm_token': shm_token, - 'symbol_info': symdeats, + 'symbol_info': syminfo, } } await ctx.send_yield(init_msgs) From c3fa31e731563a884118f99479aac05a89122182 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 6 Feb 2021 14:38:00 -0500 Subject: [PATCH 047/139] Convert symbol type to use pydantic --- piker/data/__init__.py | 5 +++-- piker/data/_source.py | 12 +++++------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/piker/data/__init__.py b/piker/data/__init__.py index 0c08b532..af036b24 100644 --- a/piker/data/__init__.py +++ b/piker/data/__init__.py @@ -272,9 +272,10 @@ async def open_feed( for sym, data in init_msg.items(): si = data['symbol_info'] + symbol = Symbol( - sym, - min_tick=si.get('minTick', 0.01), + key=sym, + tick_size=si.get('price_tick_size', 0.01), ) symbol.broker_info[brokername] = si diff --git a/piker/data/_source.py b/piker/data/_source.py index add32d13..e6194a90 100644 --- a/piker/data/_source.py +++ b/piker/data/_source.py @@ -19,10 +19,10 @@ numpy data source coversion helpers. """ from typing import Dict, Any, List import decimal -from dataclasses import dataclass, field import numpy as np import pandas as pd +from pydantic import BaseModel # from numba import from_dtype @@ -75,18 +75,16 @@ def ohlc_zeros(length: int) -> np.ndarray: return np.zeros(length, dtype=base_ohlc_dtype) -@dataclass -class Symbol: +class Symbol(BaseModel): """I guess this is some kinda container thing for dealing with all the different meta-data formats from brokers? Yah, i guess dats what it izz. """ - key: str = '' + key: str tick_size: float = 0.01 - v_tick_size: float = 0.01 - broker_info: Dict[str, Dict[str, Any]] = field(default_factory=dict) - deriv: str = '' + lot_tick_size: float = 0.01 # "volume" precision as min step value + broker_info: Dict[str, Dict[str, Any]] = {} @property def brokers(self) -> List[str]: From d7f806c57b39e5549b85cc867e1bb54da84ff099 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 8 Feb 2021 06:40:11 -0500 Subject: [PATCH 048/139] Add arrowheads to labels For labels that want it add nice arrow paths that point just over the respective axis. Couple label text offset from the axis line based on parent 'tickTextOffset' setting. Drop `YSticky` it was not enough meat to bother with. --- piker/ui/_axes.py | 167 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 117 insertions(+), 50 deletions(-) diff --git a/piker/ui/_axes.py b/piker/ui/_axes.py index 5c9993c6..25c14f34 100644 --- a/piker/ui/_axes.py +++ b/piker/ui/_axes.py @@ -53,10 +53,10 @@ class Axis(pg.AxisItem): self.setTickFont(_font.font) self.setStyle(**{ - 'textFillLimits': [(0, 0.666)], + 'textFillLimits': [(0, 0.616)], 'tickFont': _font.font, # offset of text *away from* axis line in px - 'tickTextOffset': 2, + 'tickTextOffset': 6, }) self.setTickFont(_font.font) @@ -78,6 +78,10 @@ class PriceAxis(Axis): **kwargs, ) -> None: super().__init__(*args, orientation='right', **kwargs) + self.setStyle(**{ + # offset of text *away from* axis line in px + 'tickTextOffset': 9, + }) def resize(self) -> None: self.setWidth(self.typical_br.width()) @@ -151,8 +155,8 @@ class DynamicDateAxis(Axis): class AxisLabel(pg.GraphicsObject): - _w_margin = 0 - _h_margin = 0 + _x_margin = 0 + _y_margin = 0 def __init__( self, @@ -161,6 +165,7 @@ class AxisLabel(pg.GraphicsObject): bg_color: str = 'bracket', fg_color: str = 'black', opacity: int = 0, + use_arrow: bool = True, font_size_inches: Optional[float] = None, ) -> None: @@ -183,6 +188,10 @@ class AxisLabel(pg.GraphicsObject): self.bg_color = pg.mkColor(hcolor(bg_color)) self.fg_color = pg.mkColor(hcolor(fg_color)) + self._use_arrow = use_arrow + + # create triangle path + self.path = None self.rect = None def paint( @@ -195,13 +204,12 @@ class AxisLabel(pg.GraphicsObject): if self.label_str: - if not self.rect: - self._size_br_from_str(self.label_str) + # if not self.rect: + self._size_br_from_str(self.label_str) p.setFont(self._dpifont.font) p.setPen(self.fg_color) p.setOpacity(self.opacity) - p.fillRect(self.rect, self.bg_color) # can be overrided in subtype self.draw(p, self.rect) @@ -215,13 +223,33 @@ class AxisLabel(pg.GraphicsObject): ) -> None: # this adds a nice black outline around the label for some odd # reason; ok by us - p.setOpacity(self.opacity) + # p.setOpacity(self.opacity) p.drawRect(self.rect) + if self._use_arrow: + + if not self.path: + self._draw_arrow_path() + + p.drawPath(self.path) + p.fillPath(self.path, pg.mkBrush(self.bg_color)) + + p.fillRect(self.rect, self.bg_color) + + def boundingRect(self): # noqa if self.label_str: self._size_br_from_str(self.label_str) - return self.rect + + # if self.path: + # self.tl = self.path.controlPointRect().topLeft() + if not self.path: + self.tl = self.rect.topLeft() + + return QtCore.QRectF( + self.tl, + self.rect.bottomRight(), + ) return QtCore.QRectF() @@ -237,16 +265,20 @@ class AxisLabel(pg.GraphicsObject): # # XXX: this can't be c # self._txt_br = self._dpifont.boundingRect(value) - br = self._txt_br = self._dpifont.boundingRect(value) - txt_h, txt_w = br.height(), br.width() + txt_br = self._txt_br = self._dpifont.boundingRect(value) + txt_h, txt_w = txt_br.height(), txt_br.width() h, w = self.size_hint() self.rect = QtCore.QRectF( 0, 0, - (w or txt_w) + self._w_margin, - (h or txt_h) + self._h_margin, + (w or txt_w) + self._x_margin/2, + (h or txt_h) + self._y_margin/2, ) + # print(self.rect) + # hb = self.path.controlPointRect() + # hb_size = hb.size() + return self.rect # _common_text_flags = ( # QtCore.Qt.TextDontClip | @@ -258,7 +290,7 @@ class AxisLabel(pg.GraphicsObject): class XAxisLabel(AxisLabel): - _w_margin = 4 + _x_margin = 8 text_flags = ( QtCore.Qt.TextDontClip @@ -273,7 +305,7 @@ class XAxisLabel(AxisLabel): self, abs_pos: QPointF, # scene coords value: float, # data for text - offset: int = 1 # if have margins, k? + offset: int = 0 # if have margins, k? ) -> None: timestrs = self.parent._indexes_to_timestrs([int(value)]) @@ -281,18 +313,39 @@ class XAxisLabel(AxisLabel): if not timestrs.any(): return - self.label_str = timestrs[0] + pad = 1*' ' + self.label_str = pad + timestrs[0] + pad + + y_offset = self.parent.style['tickTextOffset'][1] w = self.boundingRect().width() self.setPos(QPointF( - abs_pos.x() - w / 2 - offset, - 1, + abs_pos.x() - w/2, + y_offset/2, )) self.update() + def _draw_arrow_path(self): + y_offset = self.parent.style['tickTextOffset'][1] + path = QtGui.QPainterPath() + h, w = self.rect.height(), self.rect.width() + # middle = (w + self._y_margin)/2 + # middle = (w + self._x_margin)/2 + middle = w/2 - 0.5 + # aw = (h + self._x_margin)/2 + aw = h/2 + left = middle - aw + right = middle + aw + path.moveTo(left, 0) + path.lineTo(middle, -y_offset) + path.lineTo(right, 0) + path.closeSubpath() + self.path = path + self.tl = QtCore.QPointF(0, -y_offset) + class YAxisLabel(AxisLabel): - _h_margin = 2 + _y_margin = 4 text_flags = ( QtCore.Qt.AlignLeft @@ -301,33 +354,6 @@ class YAxisLabel(AxisLabel): | QtCore.Qt.TextDontClip ) - def size_hint(self) -> Tuple[float, float]: - # size to parent axis width - return None, self.parent.width() - - def update_label( - self, - abs_pos: QPointF, # scene coords - value: float, # data for text - offset: int = 1 # on odd dimension and/or adds nice black line - ) -> None: - - # this is read inside ``.paint()`` - self.label_str = ' {value:,.{digits}f}'.format( - digits=self.digits, value=value).replace(',', ' ') - - br = self.boundingRect() - h = br.height() - self.setPos(QPointF( - 1, - abs_pos.y() - h / 2 - offset - )) - self.update() - - -class YSticky(YAxisLabel): - """Y-axis label that sticks to where it's placed despite chart resizing. - """ def __init__( self, chart, @@ -341,11 +367,41 @@ class YSticky(YAxisLabel): chart.sigRangeChanged.connect(self.update_on_resize) self._last_datum = (None, None) - def update_on_resize(self, vr, r): - # TODO: add an `.index` to the array data-buffer layer - # and make this way less shitty... + # pull text offset from axis from parent axis + self.x_offset = self.parent.style['tickTextOffset'][0] - # pretty sure we did that ^ ? + def size_hint(self) -> Tuple[float, float]: + # size to parent axis width + return None, self.parent.width() + + def update_label( + self, + abs_pos: QPointF, # scene coords + value: float, # data for text + + # on odd dimension and/or adds nice black line + x_offset: Optional[int] = None + ) -> None: + + # this is read inside ``.paint()`` + self.label_str = '{value:,.{digits}f}'.format( + digits=self.digits, value=value).replace(',', ' ') + + # pull text offset from axis from parent axis + x_offset = x_offset or self.x_offset + + br = self.boundingRect() + h = br.height() + self.setPos(QPointF( + x_offset, + abs_pos.y() - h / 2 - self._y_margin / 2 + )) + self.update() + + def update_on_resize(self, vr, r): + """Tiis is a ``.sigRangeChanged()`` handler. + + """ index, last = self._last_datum if index is not None: self.update_from_data(index, last) @@ -360,3 +416,14 @@ class YSticky(YAxisLabel): self._chart.mapFromView(QPointF(index, value)), value ) + + def _draw_arrow_path(self): + x_offset = self.parent.style['tickTextOffset'][0] + path = QtGui.QPainterPath() + h = self.rect.height() + path.moveTo(0, 0) + path.lineTo(-x_offset - 2, h/2.) + path.lineTo(0, h) + path.closeSubpath() + self.path = path + self.tl = path.controlPointRect().topLeft() From bf66eb0b3de0def0e0cc754f47fd841be021db58 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 8 Feb 2021 06:42:59 -0500 Subject: [PATCH 049/139] Support lot tick size (mostly for crypto) --- piker/data/__init__.py | 1 + piker/data/_source.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/piker/data/__init__.py b/piker/data/__init__.py index af036b24..addfefbb 100644 --- a/piker/data/__init__.py +++ b/piker/data/__init__.py @@ -276,6 +276,7 @@ async def open_feed( symbol = Symbol( key=sym, tick_size=si.get('price_tick_size', 0.01), + lot_tick_size=si.get('lot_tick_size', 0.0), ) symbol.broker_info[brokername] = si diff --git a/piker/data/_source.py b/piker/data/_source.py index e6194a90..e85727c2 100644 --- a/piker/data/_source.py +++ b/piker/data/_source.py @@ -97,6 +97,9 @@ class Symbol(BaseModel): """ return float_digits(self.tick_size) + def lot_digits(self) -> int: + return float_digits(self.lot_tick_size) + def nearest_tick(self, value: float) -> float: """Return the nearest tick value based on mininum increment. From 890f9324007e38e30c7a3682f592d5613bed0bcc Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 8 Feb 2021 07:00:34 -0500 Subject: [PATCH 050/139] Use through lot digits, drop YSticky --- piker/ui/_chart.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index a5bd1275..8d4f295f 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -32,6 +32,7 @@ import trio from ._axes import ( DynamicDateAxis, PriceAxis, + YAxisLabel, ) from ._graphics._cursor import ( Cursor, @@ -43,7 +44,6 @@ from ._graphics._lines import ( ) from ._graphics._ohlc import BarItems from ._graphics._curve import FastAppendCurve -from ._axes import YSticky from ._style import ( _font, hcolor, @@ -647,14 +647,22 @@ class ChartPlotWidget(pg.PlotWidget): self, name: str, bg_color='bracket', - # retreive: Callable[None, np.ndarray], - ) -> YSticky: + ) -> YAxisLabel: + + # if the sticky is for our symbol + # use the tick size precision for display + sym = self._lc.symbol + if name == sym.key: + digits = sym.digits() + else: + digits = 2 + # add y-axis "last" value label - last = self._ysticks[name] = YSticky( + last = self._ysticks[name] = YAxisLabel( chart=self, parent=self.getAxis('right'), # TODO: pass this from symbol data - digits=self._lc.symbol.digits(), + digits=digits, opacity=1, bg_color=bg_color, ) @@ -1096,11 +1104,13 @@ async def chart_from_quotes( last, volume = ohlcv.array[-1][['close', 'volume']] + symbol = chart._lc.symbol + l1 = L1Labels( chart, # determine precision/decimal lengths - digits=max(float_digits(last), 2), - size_digits=min(float_digits(last), 3) + digits=symbol.digits(), + size_digits=symbol.lot_digits(), ) # TODO: From 708ed898941bc72ace5f3f850fb5d0c689b4d250 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 8 Feb 2021 07:01:26 -0500 Subject: [PATCH 051/139] Hard code font inches --- piker/ui/_style.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/piker/ui/_style.py b/piker/ui/_style.py index 36f80927..14976350 100644 --- a/piker/ui/_style.py +++ b/piker/ui/_style.py @@ -30,8 +30,8 @@ from ._exec import current_screen log = get_logger(__name__) # chart-wide fonts specified in inches -_default_font_inches_we_like = 6 / 96 -_down_2_font_inches_we_like = 5 / 96 +_default_font_inches_we_like = 0.04 #5 / 96 +_down_2_font_inches_we_like = 0.03 #4 / 96 class DpiAwareFont: From 1ef2d18a41141fd7571d67c4888297574c0976c8 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 8 Feb 2021 07:03:28 -0500 Subject: [PATCH 052/139] Try to make crosshair lines px perfect --- piker/ui/_graphics/_cursor.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/piker/ui/_graphics/_cursor.py b/piker/ui/_graphics/_cursor.py index b4db057a..199bc51a 100644 --- a/piker/ui/_graphics/_cursor.py +++ b/piker/ui/_graphics/_cursor.py @@ -13,6 +13,7 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . + """ Mouse interaction graphics @@ -54,7 +55,7 @@ class LineDot(pg.CurvePoint): index: int, plot: 'ChartPlotWidget', # type: ingore # noqa pos=None, - size: int = 2, # in pxs + size: int = 6, # in pxs color: str = 'default_light', ) -> None: pg.CurvePoint.__init__( @@ -230,6 +231,9 @@ class Cursor(pg.GraphicsObject): # computing once, up front, here cuz why not self._y_incr_mult = 1 / self.lsc._symbol.tick_size + # line width in view coordinates + self._lw = self.pixelWidth() * self.lines_pen.width() + def add_hovered( self, item: pg.GraphicsObject, @@ -244,6 +248,7 @@ class Cursor(pg.GraphicsObject): ) -> None: # add ``pg.graphicsItems.InfiniteLine``s # vertical and horizonal lines and a y-axis label + vl = plot.addLine(x=0, pen=self.lines_pen, movable=False) vl.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache) @@ -252,6 +257,7 @@ class Cursor(pg.GraphicsObject): hl.hide() yl = YAxisLabel( + chart=plot, parent=plot.getAxis('right'), digits=digits or self.digits, opacity=_ch_label_opac, @@ -361,10 +367,13 @@ class Cursor(pg.GraphicsObject): m = self._y_incr_mult iy = round(y * m) / m + # px perfect... + line_offset = self._lw / 2 + if iy != last_iy: # update y-range items - self.graphics[plot]['hl'].setY(iy) + self.graphics[plot]['hl'].setY(iy + line_offset) self.graphics[self.active_plot]['yl'].update_label( abs_pos=plot.mapFromView(QPointF(ix, iy)), @@ -379,12 +388,12 @@ class Cursor(pg.GraphicsObject): if ix != last_ix: for plot, opts in self.graphics.items(): - # move the vertical line to the current "center of bar" - opts['vl'].setX(ix) - # update the chart's "contents" label plot.update_contents_labels(ix) + # move the vertical line to the current "center of bar" + opts['vl'].setX(ix + line_offset) + # update all subscribed curve dots for cursor in opts.get('cursors', ()): cursor.setIndex(ix) @@ -397,8 +406,8 @@ class Cursor(pg.GraphicsObject): # otherwise gobbles tons of CPU.. # map back to abs (label-local) coordinates - abs_pos=plot.mapFromView(QPointF(ix, y)), - value=x, + abs_pos=plot.mapFromView(QPointF(ix, iy)), + value=ix, ) self._datum_xy = ix, iy From 0449734c53f34c0dfc6fc23375984bc833151111 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 8 Feb 2021 07:04:08 -0500 Subject: [PATCH 053/139] Drop YSticky for level lines stuff --- piker/ui/_graphics/_lines.py | 41 ++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/piker/ui/_graphics/_lines.py b/piker/ui/_graphics/_lines.py index 655c7d08..d4242be6 100644 --- a/piker/ui/_graphics/_lines.py +++ b/piker/ui/_graphics/_lines.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 @@ -30,11 +30,14 @@ from .._style import ( # _font, # DpiAwareFont ) -from .._axes import YSticky +from .._axes import YAxisLabel -class LevelLabel(YSticky): +class LevelLabel(YAxisLabel): + """Y-axis oriented label that sticks to where it's placed despite + chart resizing and supports displaying multiple fields. + """ _w_margin = 4 _h_margin = 3 @@ -42,11 +45,12 @@ class LevelLabel(YSticky): _x_offset = 0 # fields to be displayed + # class fields: level: float = 0.0 + digits: int = 2 size: float = 2.0 size_digits: int = int(2.0) - def __init__( self, chart, @@ -56,7 +60,12 @@ class LevelLabel(YSticky): orient_h: str = 'left', **kwargs ) -> None: - super().__init__(chart, *args, **kwargs) + super().__init__( + chart, + *args, + use_arrow=False, + **kwargs + ) # TODO: this is kinda cludgy self._hcolor = None @@ -109,7 +118,7 @@ class LevelLabel(YSticky): def set_label_str(self, level: float): # use space as e3 delim - label_str = (f'{level:,.{self.digits}f}').replace(',', ' ') + label_str = (f'{level:,.{self.digits}f} ').replace(',', ' ') # XXX: not huge on this approach but we need a more formal # way to define "label fields" that i don't have the brain space @@ -128,7 +137,6 @@ class LevelLabel(YSticky): h, w = br.height(), br.width() return h, w - def size_hint(self) -> Tuple[None, None]: return None, None @@ -156,6 +164,13 @@ class LevelLabel(YSticky): self._pen = self.pen self.update() + # def view_size(self): + # """Widgth and height of this label in view box coordinates. + + # """ + # return self.height() + # self._chart.mapFromView(QPointF(index, value)), + # global for now but probably should be # attached to chart instance? @@ -206,8 +221,6 @@ class L1Labels: self.bid_label = L1Label( chart=chart, parent=chart.getAxis('right'), - # TODO: pass this from symbol data - digits=digits, opacity=1, font_size_inches=font_size_inches, bg_color='papas_special', @@ -215,13 +228,12 @@ class L1Labels: orient_v='bottom', ) self.bid_label.size_digits = size_digits - self.bid_label._size_br_from_str(self.max_value) + self.bid_label.digits = digits + # self.bid_label._size_br_from_str(self.max_value) self.ask_label = L1Label( chart=chart, parent=chart.getAxis('right'), - # TODO: pass this from symbol data - digits=digits, opacity=1, font_size_inches=font_size_inches, bg_color='papas_special', @@ -229,7 +241,8 @@ class L1Labels: orient_v='top', ) self.ask_label.size_digits = size_digits - self.ask_label._size_br_from_str(self.max_value) + self.ask_label.digits = digits + # self.ask_label._size_br_from_str(self.max_value) self.bid_label._use_extra_fields = True self.ask_label._use_extra_fields = True @@ -452,7 +465,7 @@ def level_line( parent=chart.getAxis('right'), # TODO: pass this from symbol data digits=digits, - opacity=0.666, + opacity=0.616, font_size_inches=font_size_inches, color=color, From ac840877555d1dd2436e3ac84dfb29b6ada87d21 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 8 Feb 2021 07:06:00 -0500 Subject: [PATCH 054/139] Fix stage line updating, size up arrow heads? --- piker/ui/_interaction.py | 42 +++++++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 0079c371..a5b70368 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -253,18 +253,19 @@ class LineEditor: dotted=dotted, size=size, ) - line.label._use_extra_fields = size is not None + # line.label._use_extra_fields = size is not None # cache staging line after creation self._stage_line = line else: + # apply input settings to existing staging line label = line.label # disable order size and other extras in label label._use_extra_fields = size is not None label.size = size - # label.size_digits = line.label.size_digits + label.color = color # Use the existing staged line instead but copy @@ -276,11 +277,14 @@ class LineEditor: line.setMouseHover(hl_on_hover) line.show() - # XXX: must have this to trigger updated - # label contents rendering - line.setPos(y) - line.set_level() - label.show() + # XXX: must have this to trigger updated + # label contents rendering + line.setPos(y) + line.set_level() + + # show order info label + line.label.update() + line.label.show() self._active_staged_line = line @@ -302,15 +306,13 @@ class LineEditor: # 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: + cursor._trackers.remove(line) self._active_staged_line = None + self._stage_line.hide() + self._stage_line.label.hide() + # show the crosshair y line hl = cursor.graphics[chart]['hl'] hl.show() @@ -422,17 +424,17 @@ class ArrowEditor: None: 180, # pointing to right }[pointing] - yb = pg.mkBrush(hcolor(color)) arrow = pg.ArrowItem( angle=angle, baseAngle=0, - headLen=5, - headWidth=2, + headLen=5*3, + headWidth=2*3, tailLen=None, + pxMode=True, # coloring pen=pg.mkPen(hcolor('papas_special')), - brush=yb, + brush=pg.mkBrush(hcolor(color)), ) arrow.setPos(x, y) @@ -866,6 +868,8 @@ class ChartView(ViewBox): key = ev.key() mods = ev.modifiers() + print(f'text: {text}, key: {key}') + if mods == QtCore.Qt.ShiftModifier: if self.state['mouseMode'] == ViewBox.PanMode: self.setMouseMode(ViewBox.RectMode) @@ -890,6 +894,8 @@ class ChartView(ViewBox): # https://forum.qt.io/topic/532/how-to-catch-ctrl-c-on-a-widget/9 self.select_box.clear() + # cancel order or clear graphics + if key == QtCore.Qt.Key_C: # delete any lines under the cursor mode = self.mode for line in mode.lines.lines_under_cursor(): From 386cd9404d316b47dcfd0f79f3d5296de3af2d3b Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 8 Feb 2021 07:07:04 -0500 Subject: [PATCH 055/139] Drop hidpi detection? it doesn't seem to yield benefits --- piker/ui/_exec.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/piker/ui/_exec.py b/piker/ui/_exec.py index 5103c256..32975709 100644 --- a/piker/ui/_exec.py +++ b/piker/ui/_exec.py @@ -84,11 +84,11 @@ def current_screen() -> QtGui.QScreen: # Proper high DPI scaling is available in Qt >= 5.6.0. This attibute # must be set before creating the application -if hasattr(Qt, 'AA_EnableHighDpiScaling'): - QCoreApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) +# if hasattr(Qt, 'AA_EnableHighDpiScaling'): +# QCoreApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) -if hasattr(Qt, 'AA_UseHighDpiPixmaps'): - QCoreApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) +# if hasattr(Qt, 'AA_UseHighDpiPixmaps'): +# QCoreApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) class MainWindow(QtGui.QMainWindow): From 21e1561a5734e84cd32623cc0267c4b8cb4356fb Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 11 Feb 2021 00:12:37 -0500 Subject: [PATCH 056/139] Add a sane label type..smh --- piker/ui/_label.py | 188 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 piker/ui/_label.py diff --git a/piker/ui/_label.py b/piker/ui/_label.py new file mode 100644 index 00000000..4704bfed --- /dev/null +++ b/piker/ui/_label.py @@ -0,0 +1,188 @@ +# 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 . + +""" +Non-shitty labels that don't re-invent the wheel. + +""" +from typing import Callable + +import pyqtgraph as pg +from PyQt5 import QtGui +from PyQt5.QtCore import QPointF, QRectF + + +from ._style import ( + DpiAwareFont, + hcolor, + _down_2_font_inches_we_like, +) + + +def vbr_left(label) -> float: + + def viewbox_left(): + return label.vbr().left() + + return viewbox_left + + +def right_axis(chart, label) -> float: + raxis = chart.getAxis('right') + + def right_axis_offset_by_w(): + return raxis.pos().x() - label.w + + return right_axis_offset_by_w + + +class Label: + """ + After hacking for many days on multiple "label" systems inside + ``pyqtgraph`` yet again we're left writing our own since it seems + all of those are over complicated, ad-hoc, pieces of garbage that + can't accomplish the simplest things, such as pinning to the left + hand side of a view box. + + This type is another effort (see our graphics lol) to start making + small, re-usable label components that can actually be used to build + production grade UIs. + + just.. smh, hard. + + """ + def __init__( + self, + view: pg.ViewBox, + fmt_str: str, + color: str = 'bracket', + x_offset: float = 0, + font_size_inches: float = _down_2_font_inches_we_like, + opacity: float = 0.666, + fields: dict = {} + + ) -> None: + + vb = self.vb = view + self._fmt_str = fmt_str + self._view_xy = QPointF(0, 0) + + self._x_offset = x_offset + + txt = self.txt = QtGui.QGraphicsTextItem() + vb.scene().addItem(txt) + + # configure font size based on DPI + dpi_font = DpiAwareFont( + size_in_inches=font_size_inches + ) + dpi_font.configure_to_dpi() + txt.setFont(dpi_font.font) + + txt.setOpacity(opacity) + + # register viewbox callbacks + vb.sigRangeChanged.connect(self.on_sigrange_change) + + self._hcolor: str = '' + self.color = color + + self.fields = fields + self.orient_v = 'bottom' + + self._af = self.txt.pos().x + + # TODO: edit and selection support + # https://doc.qt.io/qt-5/qt.html#TextInteractionFlag-enum + # self.setTextInteractionFlags(QtGui.Qt.TextEditorInteraction) + + @property + def color(self): + return self._hcolor + + @color.setter + def color(self, color: str) -> None: + self.txt.setDefaultTextColor(pg.mkColor(hcolor(color))) + self._hcolor = color + + def on_sigrange_change(self, vr, r) -> None: + self.set_view_y(self._view_xy.y()) + + @property + def w(self) -> float: + return self.txt.boundingRect().width() + + @property + def h(self) -> float: + return self.txt.boundingRect().height() + + def vbr(self) -> QRectF: + return self.vb.boundingRect() + + def set_x_anchor_func( + self, + func: Callable, + ) -> None: + assert isinstance(func(), float) + self._af = func + + def set_view_y( + self, + y: float, + ) -> None: + + scene_x = self._af() or self.txt.pos().x() + + # get new (inside the) view coordinates / position + self._view_xy = QPointF( + self.vb.mapToView(QPointF(scene_x, scene_x)).x(), + y, + ) + + # map back to the outer UI-land "scene" coordinates + s_xy = self.vb.mapFromView(self._view_xy) + + if self.orient_v == 'top': + s_xy = QPointF(s_xy.x(), s_xy.y() - self.h) + + # move label in scene coords to desired position + self.txt.setPos(s_xy) + + assert s_xy == self.txt.pos() + + def orient_on(self, h: str, v: str) -> None: + pass + + @property + def fmt_str(self) -> str: + return self._fmt_str + + @fmt_str.setter + def fmt_str(self, fmt_str: str) -> None: + self._fmt_str = fmt_str + + def format(self, **fields: dict) -> str: + text = self._fmt_str.format(**fields) + self.txt.setPlainText(text) + + def render(self) -> None: + self.format(**self.fields) + + def show(self) -> None: + self.txt.show() + + def hide(self) -> None: + self.txt.hide() From 02edfdf846a1b223d432f794cefd710a0d89c2b0 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 11 Feb 2021 11:47:34 -0500 Subject: [PATCH 057/139] Pass order size to ems --- piker/_ems.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/piker/_ems.py b/piker/_ems.py index 21e3b0fb..b664b46d 100644 --- a/piker/_ems.py +++ b/piker/_ems.py @@ -138,7 +138,7 @@ class PaperBoi: symbol: str, price: float, action: str, - size: int = _DEFAULT_SIZE, + size: float, ) -> int: """Place an order and return integer request id provided by client. @@ -266,7 +266,7 @@ async def exec_loop( symbol=sym, action=cmd['action'], price=round(submit_price, 2), - size=_DEFAULT_SIZE, + size=cmd['size'], ) # register broker request id to ems id book._broker2ems_ids[reqid] = oid @@ -420,7 +420,7 @@ async def _ems_main( client_actor_name: str, broker: str, symbol: str, - mode: str = 'dark', # ('paper', 'dark', 'live') + _mode: str = 'dark', # ('paper', 'dark', 'live') ) -> None: """EMS (sub)actor entrypoint providing the execution management (micro)service which conducts broker @@ -463,7 +463,7 @@ async def _ems_main( ctx, broker, symbol, - mode, + _mode, ) await n.start( @@ -505,13 +505,14 @@ async def _ems_main( sym = cmd['symbol'] trigger_price = cmd['price'] + size = cmd['size'] brokers = cmd['brokers'] - broker = brokers[0] - mode = cmd.get('exec_mode', mode) + exec_mode = cmd.get('exec_mode', _mode) + broker = brokers[0] last = book.lasts[(broker, sym)] - if mode == 'live' and action in ('buy', 'sell',): + if exec_mode == 'live' and action in ('buy', 'sell',): # register broker id for ems id order_id = await client.submit_limit( @@ -519,7 +520,7 @@ async def _ems_main( symbol=sym, action=action, price=round(trigger_price, 2), - size=_DEFAULT_SIZE, + size=size, ) book._broker2ems_ids[order_id] = oid @@ -528,7 +529,7 @@ async def _ems_main( # handle sending the ems side acks back to # the cmd sender from here - elif mode in ('dark', 'paper') or action in ('alert'): + elif exec_mode in ('dark', 'paper') or action in ('alert'): # TODO: if the predicate resolves immediately send the # execution to the broker asap? Or no? @@ -608,12 +609,14 @@ class OrderBook: uuid: str, symbol: 'Symbol', price: float, + size: float, action: str, exec_mode: str, ) -> str: cmd = { 'action': action, 'price': price, + 'size': size, 'symbol': symbol.key, 'brokers': symbol.brokers, 'oid': uuid, From dd1aed627ecbd1c29a7f275021b087e1a78c36bc Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 11 Feb 2021 11:48:19 -0500 Subject: [PATCH 058/139] Tidy axis code --- piker/ui/_axes.py | 94 +++++++++++++++++++++++++++++++---------------- 1 file changed, 63 insertions(+), 31 deletions(-) diff --git a/piker/ui/_axes.py b/piker/ui/_axes.py index 25c14f34..fac0c934 100644 --- a/piker/ui/_axes.py +++ b/piker/ui/_axes.py @@ -16,6 +16,7 @@ """ Chart axes graphics and behavior. + """ from typing import List, Tuple, Optional @@ -32,7 +33,7 @@ _axis_pen = pg.mkPen(hcolor('bracket')) class Axis(pg.AxisItem): - """A better axis that sizes to typical tick contents considering font size. + """A better axis that sizes tick contents considering font size. """ def __init__( @@ -64,11 +65,17 @@ class Axis(pg.AxisItem): self.typical_br = _font._qfm.boundingRect(typical_max_str) # size the pertinent axis dimension to a "typical value" - self.resize() + self.size_to_values() + + def size_to_values(self) -> None: + pass def set_min_tick(self, size: int) -> None: self._min_tick = size + def txt_offsets(self) -> Tuple[int, int]: + return tuple(self.style['tickTextOffset']) + class PriceAxis(Axis): @@ -77,13 +84,13 @@ class PriceAxis(Axis): *args, **kwargs, ) -> None: - super().__init__(*args, orientation='right', **kwargs) + super().__init__(*args, **kwargs) self.setStyle(**{ # offset of text *away from* axis line in px 'tickTextOffset': 9, }) - def resize(self) -> None: + def size_to_values(self) -> None: self.setWidth(self.typical_br.width()) # XXX: drop for now since it just eats up h space @@ -116,7 +123,7 @@ class DynamicDateAxis(Axis): 1: '%H:%M:%S', } - def resize(self) -> None: + def size_to_values(self) -> None: self.setHeight(self.typical_br.height() + 1) def _indexes_to_timestrs( @@ -160,22 +167,28 @@ class AxisLabel(pg.GraphicsObject): def __init__( self, - parent: Axis, + parent: pg.GraphicsItem, digits: int = 2, + + font_size_inches: Optional[float] = None, bg_color: str = 'bracket', fg_color: str = 'black', - opacity: int = 0, + opacity: int = 1, # XXX: seriously don't set this to 0 + use_arrow: bool = True, - font_size_inches: Optional[float] = None, + ) -> None: - super().__init__(parent) + super().__init__() + self.setParentItem(parent) + self.setFlag(self.ItemIgnoresTransformations) # XXX: pretty sure this is faster self.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache) - self.parent = parent + self._parent = parent + self.opacity = opacity self.label_str = '' self.digits = digits @@ -200,6 +213,11 @@ class AxisLabel(pg.GraphicsObject): opt: QtWidgets.QStyleOptionGraphicsItem, w: QtWidgets.QWidget ) -> None: + """Draw a filled rectangle based on the size of ``.label_str`` text. + + Subtypes can customize further by overloading ``.draw()``. + + """ # p.setCompositionMode(QtGui.QPainter.CompositionMode_SourceOver) if self.label_str: @@ -207,37 +225,41 @@ class AxisLabel(pg.GraphicsObject): # if not self.rect: self._size_br_from_str(self.label_str) - p.setFont(self._dpifont.font) - p.setPen(self.fg_color) - p.setOpacity(self.opacity) - # can be overrided in subtype self.draw(p, self.rect) + p.setFont(self._dpifont.font) + p.setPen(self.fg_color) p.drawText(self.rect, self.text_flags, self.label_str) + def draw( self, p: QtGui.QPainter, rect: QtCore.QRectF ) -> None: - # this adds a nice black outline around the label for some odd - # reason; ok by us - # p.setOpacity(self.opacity) - p.drawRect(self.rect) if self._use_arrow: - if not self.path: self._draw_arrow_path() p.drawPath(self.path) p.fillPath(self.path, pg.mkBrush(self.bg_color)) + # this adds a nice black outline around the label for some odd + # reason; ok by us + p.setOpacity(self.opacity) + + # this cause the L1 labels to glitch out if used + # in the subtype and it will leave a small black strip + # with the arrow path if done before the above p.fillRect(self.rect, self.bg_color) def boundingRect(self): # noqa + """Size the graphics space from the text contents. + + """ if self.label_str: self._size_br_from_str(self.label_str) @@ -267,12 +289,14 @@ class AxisLabel(pg.GraphicsObject): txt_br = self._txt_br = self._dpifont.boundingRect(value) txt_h, txt_w = txt_br.height(), txt_br.width() + + # allow subtypes to specify a static width and height h, w = self.size_hint() self.rect = QtCore.QRectF( 0, 0, - (w or txt_w) + self._x_margin/2, - (h or txt_h) + self._y_margin/2, + (w or txt_w) + self._x_margin /2, + (h or txt_h) + self._y_margin /2, ) # print(self.rect) # hb = self.path.controlPointRect() @@ -299,7 +323,7 @@ class XAxisLabel(AxisLabel): def size_hint(self) -> Tuple[float, float]: # size to parent axis height - return self.parent.height(), None + return self._parent.height(), None def update_label( self, @@ -308,7 +332,7 @@ class XAxisLabel(AxisLabel): offset: int = 0 # if have margins, k? ) -> None: - timestrs = self.parent._indexes_to_timestrs([int(value)]) + timestrs = self._parent._indexes_to_timestrs([int(value)]) if not timestrs.any(): return @@ -316,9 +340,10 @@ class XAxisLabel(AxisLabel): pad = 1*' ' self.label_str = pad + timestrs[0] + pad - y_offset = self.parent.style['tickTextOffset'][1] + _, y_offset = self._parent.txt_offsets() w = self.boundingRect().width() + self.setPos(QPointF( abs_pos.x() - w/2, y_offset/2, @@ -326,13 +351,10 @@ class XAxisLabel(AxisLabel): self.update() def _draw_arrow_path(self): - y_offset = self.parent.style['tickTextOffset'][1] + y_offset = self._parent.style['tickTextOffset'][1] path = QtGui.QPainterPath() h, w = self.rect.height(), self.rect.width() - # middle = (w + self._y_margin)/2 - # middle = (w + self._x_margin)/2 middle = w/2 - 0.5 - # aw = (h + self._x_margin)/2 aw = h/2 left = middle - aw right = middle + aw @@ -341,6 +363,8 @@ class XAxisLabel(AxisLabel): path.lineTo(right, 0) path.closeSubpath() self.path = path + + # top left point is local origin and tip of the arrow path self.tl = QtCore.QPointF(0, -y_offset) @@ -364,15 +388,18 @@ class YAxisLabel(AxisLabel): super().__init__(*args, **kwargs) self._chart = chart + chart.sigRangeChanged.connect(self.update_on_resize) + self._last_datum = (None, None) # pull text offset from axis from parent axis - self.x_offset = self.parent.style['tickTextOffset'][0] + if getattr(self._parent, 'txt_offsets', False): + self.x_offset, y_offset = self._parent.txt_offsets() def size_hint(self) -> Tuple[float, float]: # size to parent axis width - return None, self.parent.width() + return None, self._parent.width() def update_label( self, @@ -392,6 +419,7 @@ class YAxisLabel(AxisLabel): br = self.boundingRect() h = br.height() + self.setPos(QPointF( x_offset, abs_pos.y() - h / 2 - self._y_margin / 2 @@ -411,6 +439,10 @@ class YAxisLabel(AxisLabel): index: int, value: float, ) -> None: + """Update the label's text contents **and** position from + a view box coordinate datum. + + """ self._last_datum = (index, value) self.update_label( self._chart.mapFromView(QPointF(index, value)), @@ -418,7 +450,7 @@ class YAxisLabel(AxisLabel): ) def _draw_arrow_path(self): - x_offset = self.parent.style['tickTextOffset'][0] + x_offset = self._parent.style['tickTextOffset'][0] path = QtGui.QPainterPath() h = self.rect.height() path.moveTo(0, 0) From 0edca84b3d045748fa3e4046b0c041d972331eaa Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 11 Feb 2021 11:48:40 -0500 Subject: [PATCH 059/139] Yet another font size tweak --- piker/ui/_style.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/piker/ui/_style.py b/piker/ui/_style.py index 14976350..6e023095 100644 --- a/piker/ui/_style.py +++ b/piker/ui/_style.py @@ -30,8 +30,8 @@ from ._exec import current_screen log = get_logger(__name__) # chart-wide fonts specified in inches -_default_font_inches_we_like = 0.04 #5 / 96 -_down_2_font_inches_we_like = 0.03 #4 / 96 +_default_font_inches_we_like = 0.055 #5 / 96 +_down_2_font_inches_we_like = 0.05 #4 / 96 class DpiAwareFont: From ca576ca3cf3f6a211e5b0a96305a2e2017bd6200 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 11 Feb 2021 11:49:01 -0500 Subject: [PATCH 060/139] Add label delete method --- piker/ui/_label.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/piker/ui/_label.py b/piker/ui/_label.py index 4704bfed..8143871e 100644 --- a/piker/ui/_label.py +++ b/piker/ui/_label.py @@ -105,6 +105,9 @@ class Label: self._af = self.txt.pos().x + # not sure if this makes a diff + self.txt.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache) + # TODO: edit and selection support # https://doc.qt.io/qt-5/qt.html#TextInteractionFlag-enum # self.setTextInteractionFlags(QtGui.Qt.TextEditorInteraction) @@ -186,3 +189,6 @@ class Label: def hide(self) -> None: self.txt.hide() + + def delete(self) -> None: + self.vb.scene().removeItem(self.txt) From cbf259f3f3ed33306fdf7387ce7c7b0785041c49 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 11 Feb 2021 11:49:33 -0500 Subject: [PATCH 061/139] Add hidpi issue regarding it being borky --- piker/ui/_exec.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/piker/ui/_exec.py b/piker/ui/_exec.py index 32975709..4c081480 100644 --- a/piker/ui/_exec.py +++ b/piker/ui/_exec.py @@ -81,6 +81,8 @@ def current_screen() -> QtGui.QScreen: assert screen, "Wow Qt is dumb as shit and has no screen..." return screen +# XXX: pretty sure none of this shit works +# https://bugreports.qt.io/browse/QTBUG-53022 # Proper high DPI scaling is available in Qt >= 5.6.0. This attibute # must be set before creating the application From f51e503e4700157b4d062200a2e929eba84d0ec0 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 11 Feb 2021 13:59:50 -0500 Subject: [PATCH 062/139] Support arbitrary fields (with update) in labels This turned into a larger endeavour then intended but now we're using our own label system on level lines to be able to display things nicely **pinned wherever we want in the UI**. Keep the old ``LevelLabel`` for now for the L1 graphics but we'll likely replace this as well since i'm pretty sure the new label type (which wraps `QGraphicsTextItem`) is more performant anyway. --- piker/ui/_graphics/_lines.py | 540 ++++++++++++++++++++++------------- 1 file changed, 346 insertions(+), 194 deletions(-) diff --git a/piker/ui/_graphics/_lines.py b/piker/ui/_graphics/_lines.py index d4242be6..7bf5b4ba 100644 --- a/piker/ui/_graphics/_lines.py +++ b/piker/ui/_graphics/_lines.py @@ -18,74 +18,93 @@ Lines for orders, alerts, L2. """ -from typing import Tuple, Dict, Any, Optional +from typing import Tuple, Optional, List import pyqtgraph as pg from PyQt5 import QtCore, QtGui from PyQt5.QtCore import QPointF +from .._label import Label, vbr_left, right_axis from .._style import ( hcolor, _down_2_font_inches_we_like, - # _font, - # DpiAwareFont ) from .._axes import YAxisLabel class LevelLabel(YAxisLabel): - """Y-axis oriented label that sticks to where it's placed despite - chart resizing and supports displaying multiple fields. + """Y-axis (vertically) oriented, horizontal label that sticks to + where it's placed despite chart resizing and supports displaying + multiple fields. """ - _w_margin = 4 - _h_margin = 3 + _x_margin = 0 + _y_margin = 0 - # adjustment "further away from" parent axis - _x_offset = 0 + # adjustment "further away from" anchor point + _x_offset = 9 + _y_offset = 0 - # fields to be displayed - # class fields: - level: float = 0.0 - digits: int = 2 - size: float = 2.0 - size_digits: int = int(2.0) + # fields to be displayed in the label string + _fields = { + 'level': 0, + 'level_digits': 2, + } + # default label template is just a y-level with so much precision + _fmt_str = '{level:,.{level_digits}f}' def __init__( self, chart, - *args, + parent, + color: str = 'bracket', + orient_v: str = 'bottom', orient_h: str = 'left', - **kwargs + + opacity: float = 0, + + # makes order line labels offset from their parent axis + # such that they don't collide with the L1/L2 lines/prices + # that are displayed on the axis + adjust_to_l1: bool = False, + + **axis_label_kwargs, ) -> None: + super().__init__( chart, - *args, + parent=parent, use_arrow=False, - **kwargs + opacity=opacity, + **axis_label_kwargs ) # TODO: this is kinda cludgy - self._hcolor = None - self.color = color + self._hcolor: pg.Pen = None + self.color: str = color # orientation around axis options self._orient_v = orient_v self._orient_h = orient_h + + self._adjust_to_l1 = adjust_to_l1 + self._v_shift = { - 'top': 1., - 'bottom': 0, + 'top': -1., + 'bottom': 0., 'middle': 1 / 2. }[orient_v] self._h_shift = { - 'left': -1., 'right': 0 + 'left': -1., + 'right': 0. }[orient_h] - self._fmt_fields: Dict[str, Dict[str, Any]] = {} - self._use_extra_fields: bool = False + self.fields = self._fields.copy() + # ensure default format fields are in correct + self.set_fmt_str(self._fmt_str, self.fields) @property def color(self): @@ -96,42 +115,65 @@ class LevelLabel(YAxisLabel): self._hcolor = color self._pen = self.pen = pg.mkPen(hcolor(color)) + def update_on_resize(self, vr, r): + """Tiis is a ``.sigRangeChanged()`` handler. + + """ + self.update_fields(self.fields) + + def update_fields( + self, + fields: dict = None, + ) -> None: + """Update the label's text contents **and** position from + a view box coordinate datum. + + """ + self.fields.update(fields) + level = self.fields['level'] + + # map "level" to local coords + abs_xy = self._chart.mapFromView(QPointF(0, level)) + + self.update_label( + abs_xy, + self.fields, + ) + def update_label( self, abs_pos: QPointF, # scene coords - level: float, # data for text - offset: int = 1 # if have margins, k? + fields: dict, ) -> None: # write contents, type specific - h, w = self.set_label_str(level) + h, w = self.set_label_str(fields) + + if self._adjust_to_l1: + self._x_offset = _max_l1_line_len - # this triggers ``.paint()`` implicitly or no? self.setPos(QPointF( - self._h_shift * w - self._x_offset, - abs_pos.y() - (self._v_shift * h) - offset + self._h_shift * (w + self._x_offset), + abs_pos.y() + self._v_shift * h )) - # trigger .paint() - self.update() - self.level = level + def set_fmt_str( + self, + fmt_str: str, + fields: dict, + ) -> (str, str): + # test that new fmt str can be rendered + self._fmt_str = fmt_str + self.set_label_str(fields) + self.fields.update(fields) + return fmt_str, self.label_str - def set_label_str(self, level: float): + def set_label_str( + self, + fields: dict, + ): # use space as e3 delim - label_str = (f'{level:,.{self.digits}f} ').replace(',', ' ') - - # XXX: not huge on this approach but we need a more formal - # way to define "label fields" that i don't have the brain space - # for atm.. it's at least a **lot** better then the wacky - # internals of InfLinelabel or wtv. - - # mutate label to contain any extra defined format fields - if self._use_extra_fields: - for fmt_str, fields in self._fmt_fields.items(): - label_str = fmt_str.format( - **{f: getattr(self, f) for f in fields}) + label_str - - self.label_str = label_str + self.label_str = self._fmt_str.format(**fields).replace(',', ' ') br = self.boundingRect() h, w = br.height(), br.width() @@ -147,6 +189,8 @@ class LevelLabel(YAxisLabel): ) -> None: p.setPen(self._pen) + rect = self.rect + if self._orient_v == 'bottom': lp, rp = rect.topLeft(), rect.topRight() # p.drawLine(rect.topLeft(), rect.topRight()) @@ -164,13 +208,6 @@ class LevelLabel(YAxisLabel): self._pen = self.pen self.update() - # def view_size(self): - # """Widgth and height of this label in view box coordinates. - - # """ - # return self.height() - # self._chart.mapFromView(QPointF(index, value)), - # global for now but probably should be # attached to chart instance? @@ -179,20 +216,19 @@ _max_l1_line_len: float = 0 class L1Label(LevelLabel): - size: float = 0 - size_digits: int = 3 - text_flags = ( QtCore.Qt.TextDontClip | QtCore.Qt.AlignLeft ) - def set_label_str(self, level: float) -> None: - """Reimplement the label string write to include the level's order-queue's - size in the text, eg. 100 x 323.3. + def set_label_str( + self, + fields: dict, + ) -> None: + """Make sure the max L1 line module var is kept up to date. """ - h, w = super().set_label_str(level) + h, w = super().set_label_str(fields) # Set a global "max L1 label length" so we can look it up # on order lines and adjust their labels not to overlap with it. @@ -206,8 +242,6 @@ class L1Labels: """Level 1 bid ask labels for dynamic update on price-axis. """ - max_value: float = '100.0 x 100 000.00' - def __init__( self, chart: 'ChartPlotWidget', # noqa @@ -218,39 +252,41 @@ class L1Labels: self.chart = chart - self.bid_label = L1Label( - chart=chart, - parent=chart.getAxis('right'), - opacity=1, - font_size_inches=font_size_inches, - bg_color='papas_special', - fg_color='bracket', + raxis = chart.getAxis('right') + kwargs = { + 'chart': chart, + 'parent': raxis, + + 'opacity': 1, + 'font_size_inches': font_size_inches, + 'fg_color': chart.pen_color, + 'bg_color': chart.view_color, + } + + fmt_str = ( + '{size:.{size_digits}f} x ' + '{level:,.{level_digits}f}' + ) + fields = { + 'level': 0, + 'level_digits': digits, + 'size': 0, + 'size_digits': size_digits, + } + + bid = self.bid_label = L1Label( orient_v='bottom', + **kwargs, ) - self.bid_label.size_digits = size_digits - self.bid_label.digits = digits - # self.bid_label._size_br_from_str(self.max_value) + bid.set_fmt_str(fmt_str=fmt_str, fields=fields) + bid.show() - self.ask_label = L1Label( - chart=chart, - parent=chart.getAxis('right'), - opacity=1, - font_size_inches=font_size_inches, - bg_color='papas_special', - fg_color='bracket', + ask = self.ask_label = L1Label( orient_v='top', + **kwargs, ) - self.ask_label.size_digits = size_digits - self.ask_label.digits = digits - # self.ask_label._size_br_from_str(self.max_value) - - self.bid_label._use_extra_fields = True - self.ask_label._use_extra_fields = True - - self.bid_label._fmt_fields['{size:.{size_digits}f} x '] = { - 'size', 'size_digits'} - self.ask_label._fmt_fields['{size:.{size_digits}f} x '] = { - 'size', 'size_digits'} + ask.set_fmt_str(fmt_str=fmt_str, fields=fields) + ask.show() # TODO: probably worth investigating if we can @@ -264,32 +300,46 @@ class LevelLine(pg.InfiniteLine): def __init__( self, chart: 'ChartPlotWidget', # type: ignore # noqa - label: LevelLabel, + color: str = 'default', highlight_color: str = 'default_light', + hl_on_hover: bool = True, dotted: bool = False, - adjust_to_l1: bool = False, - always_show_label: bool = False, - **kwargs, + always_show_labels: bool = False, + ) -> None: - super().__init__(**kwargs) - self.label = label - - self.sigPositionChanged.connect(self.set_level) + super().__init__( + movable=True, + angle=0, + label=None, # don't use the shitty ``InfLineLabel`` + ) self._chart = chart self._hoh = hl_on_hover self._dotted = dotted + self._hcolor: str = None - self._hcolor = None + # the float y-value in the view coords + self.level: float = 0 + + # list of labels anchored at one of the 2 line endpoints + # inside the viewbox + self._labels: List[(int, Label)] = [] + + # whenever this line is moved trigger label updates + self.sigPositionChanged.connect(self.on_pos_change) + + # sets color to value triggering pen creation self.color = color # TODO: for when we want to move groups of lines? self._track_cursor: bool = False - self._adjust_to_l1 = adjust_to_l1 - self._always_show_label = always_show_label + self._always_show_labels = always_show_labels + + # # indexed by int + # self._endpoints = (None, None) # testing markers # self.addMarker('<|', 0.1, 3) @@ -301,6 +351,9 @@ class LevelLine(pg.InfiniteLine): # self.addMarker('v', 0.7, 3) # self.addMarker('o', 0.8, 3) + def txt_offsets(self) -> Tuple[int, int]: + return 0, 0 + @property def color(self): return self._hcolor @@ -323,15 +376,84 @@ class LevelLine(pg.InfiniteLine): hoverpen.setWidth(2) self.hoverPen = hoverpen - def set_level(self) -> None: + def add_label( + self, - label = self.label + # by default we only display the line's level value + # in the label + fmt_str: str = ( + '{level:,.{level_digits}f}' + ), + side: str = 'right', - # TODO: a better way to accomplish this... - if self._adjust_to_l1: - label._x_offset = _max_l1_line_len + font_size_inches: float = _down_2_font_inches_we_like, + color: str = None, + bg_color: str = None, - label.update_from_data(0, self.value()) + **label_kwargs, + ) -> LevelLabel: + """Add a ``LevelLabel`` anchored at one of the line endpoints in view. + + """ + vb = self.getViewBox() + + label = Label( + view=vb, + fmt_str=fmt_str, + color=self.color, + ) + + if side == 'right': + label.set_x_anchor_func(right_axis(self._chart, label)) + elif side == 'left': + label.set_x_anchor_func(vbr_left(label)) + + self._labels.append((side, label)) + + return label + + def on_pos_change( + self, + line: 'LevelLine', # noqa + ) -> None: + """Position changed handler. + + """ + self.update_labels({'level': self.value()}) + + def update_labels( + self, + fields_data: dict, + ) -> None: + + for at, label in self._labels: + label.color = self.color + + label.fields.update(fields_data) + + level = fields_data.get('level') + if level: + label.set_view_y(level) + + label.render() + + self.update() + + def hide_labels(self) -> None: + for at, label in self._labels: + label.hide() + + def show_labels(self) -> None: + for at, label in self._labels: + label.show() + + def set_level( + self, + level: float, + ) -> None: + self.setPos(level) + self.level = self.value() + self.update() def on_tracked_source( self, @@ -342,8 +464,7 @@ class LevelLine(pg.InfiniteLine): # line is set to track the cursor: for every movement # this callback is invoked to reposition the line self.movable = True - self.setPos(y) # implictly calls ``.set_level()`` - self.update() + self.set_level(y) # implictly calls reposition handler def setMouseHover(self, hover: bool) -> None: """Mouse hover callback. @@ -361,12 +482,18 @@ class LevelLine(pg.InfiniteLine): # highlight if so configured if self._hoh: self.currentPen = self.hoverPen - self.label.highlight(self.hoverPen) + + # for at, label in self._labels: + # label.highlight(self.hoverPen) # add us to cursor state - chart._cursor.add_hovered(self) + cur = chart._cursor + cur.add_hovered(self) + cur.graphics[chart]['yl'].hide() + + for at, label in self._labels: + label.show() - self.label.show() # TODO: hide y-crosshair? # chart._cursor.graphics[chart]['hl'].hide() @@ -374,12 +501,15 @@ class LevelLine(pg.InfiniteLine): # self.setCursor(QtCore.Qt.DragMoveCursor) else: self.currentPen = self.pen - self.label.unhighlight() - chart._cursor._hovered.remove(self) + cur = chart._cursor + cur._hovered.remove(self) + cur.graphics[chart]['yl'].show() - if not self._always_show_label: - self.label.hide() + if not self._always_show_labels: + for at, label in self._labels: + label.hide() + # label.unhighlight() # highlight any attached label @@ -387,12 +517,16 @@ class LevelLine(pg.InfiniteLine): 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) + # self.label.highlight(self.hoverPen) + for at, label in self._labels: + # label.highlight(self.hoverPen) + label.show() # normal tracking behavior super().mouseDragEvent(ev) @@ -400,15 +534,8 @@ class LevelLine(pg.InfiniteLine): # 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 delete(self) -> None: """Remove this line from containing chart/view/scene. @@ -416,29 +543,26 @@ class LevelLine(pg.InfiniteLine): scene = self.scene() if scene: # self.label.parent.scene().removeItem(self.label) - scene.removeItem(self.label) + for at, label in self._labels: + label.delete() + + self._labels.clear() self._chart.plotItem.removeItem(self) - def getEndpoints(self): - """Get line endpoints at view edges. + def mouseDoubleClickEvent( + self, + ev: QtGui.QMouseEvent, + ) -> None: - Stolen from InfLineLabel. - - """ - # calculate points where line intersects view box - # (in line coordinates) - lr = self.boundingRect() - pt1 = pg.Point(lr.left(), 0) - pt2 = pg.Point(lr.right(), 0) - - return pt1, pt2 + # TODO: enter labels edit mode + print(f'double click {ev}') 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. @@ -451,91 +575,119 @@ def level_line( # line style dotted: bool = False, - adjust_to_l1: bool = False, + # label fields and options + digits: int = 1, - always_show_label: bool = False, + always_show_labels: bool = False, + + add_label: bool = True, + + orient_v: str = 'bottom', - **linelabelkwargs ) -> LevelLine: """Convenience routine to add a styled horizontal line to a plot. """ - label = LevelLabel( - chart=chart, - parent=chart.getAxis('right'), - # TODO: pass this from symbol data - digits=digits, - opacity=0.616, - font_size_inches=font_size_inches, - color=color, - - # TODO: make this take the view's bg pen - bg_color='papas_special', - fg_color=color, - **linelabelkwargs - ) - label.update_from_data(0, level) - - # by default, the label must be shown by client code - label.hide() - - # TODO: can we somehow figure out a max value from the parent axis? - label._size_br_from_str(label.label_str) line = LevelLine( chart, - label, - color=color, # lookup "highlight" equivalent highlight_color=color + '_light', - movable=True, - angle=0, - dotted=dotted, # UX related options - hl_on_hover=hl_on_hover, - # makes order line labels offset from their parent axis - # such that they don't collide with the L1/L2 lines/prices - # that are displayed on the axis - adjust_to_l1=adjust_to_l1, - # when set to True the label is always shown instead of just on # highlight (which is a privacy thing for orders) - always_show_label=always_show_label, + always_show_labels=always_show_labels, ) - # activate/draw label - line.setValue(level) # it's just .setPos() right? - line.set_level() - chart.plotItem.addItem(line) + if add_label: + + label = line.add_label( + side='right', + opacity=1, + ) + label.orient_v = orient_v + + line.update_labels({'level': level, 'level_digits': 2}) + label.render() + + line.hide_labels() + + # activate/draw label + line.set_level(level) + return line def order_line( - *args, - size: Optional[int] = None, + chart, + level: float, + level_digits: float, + + size: Optional[int] = 1, size_digits: int = 0, - **kwargs, + + submit_price: float = None, + + order_status: str = 'dark', + order_type: str = 'limit', + + opacity=0.616, + + orient_v: str = 'bottom', + + **line_kwargs, ) -> LevelLine: - """Convenience routine to add a line graphic representing an order execution - submitted to the EMS via the chart's "order mode". + """Convenience routine to add a line graphic representing an order + execution submitted to the EMS via the chart's "order mode". """ - line = level_line(*args, adjust_to_l1=True, **kwargs) - line.label._fmt_fields['{size:.{size_digits}f} x '] = { - 'size', 'size_digits'} + line = level_line( + chart, + level, + add_label=False, + **line_kwargs + ) - if size is not None: + llabel = line.add_label( + side='left', + fmt_str='{order_status}-{order_type}:{submit_price}', + ) + llabel.fields = { + 'order_status': order_status, + 'order_type': order_type, + 'submit_price': submit_price, + } + llabel.orient_v = orient_v + llabel.render() + llabel.show() - line.label._use_extra_fields = True - line.label.size = size - line.label.size_digits = size_digits + rlabel = line.add_label( + side='right', + fmt_str=( + '{size:.{size_digits}f} x ' + '{level:,.{level_digits}f}' + ), + ) + rlabel.fields = { + 'size': size, + 'size_digits': size_digits, + 'level': level, + 'level_digits': level_digits, + } + + rlabel.orient_v = orient_v + rlabel.render() + rlabel.show() + + # sanity check + line.update_labels({'level': level}) return line From bf78c13df419e123dfc50422d63c27a3e304ecd5 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 11 Feb 2021 14:03:47 -0500 Subject: [PATCH 063/139] Attempt px perfection on crosshair lines placement --- piker/ui/_graphics/_cursor.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/piker/ui/_graphics/_cursor.py b/piker/ui/_graphics/_cursor.py index 199bc51a..6b6f9697 100644 --- a/piker/ui/_graphics/_cursor.py +++ b/piker/ui/_graphics/_cursor.py @@ -129,6 +129,7 @@ _corner_margins = { } +# TODO: change this into a ``pg.TextItem``... class ContentsLabel(pg.LabelItem): """Label anchored to a ``ViewBox`` typically for displaying datum-wise points from the "viewed" contents. @@ -376,7 +377,7 @@ class Cursor(pg.GraphicsObject): self.graphics[plot]['hl'].setY(iy + line_offset) self.graphics[self.active_plot]['yl'].update_label( - abs_pos=plot.mapFromView(QPointF(ix, iy)), + abs_pos=plot.mapFromView(QPointF(ix, iy + line_offset)), value=iy ) @@ -406,7 +407,7 @@ class Cursor(pg.GraphicsObject): # otherwise gobbles tons of CPU.. # map back to abs (label-local) coordinates - abs_pos=plot.mapFromView(QPointF(ix, iy)), + abs_pos=plot.mapFromView(QPointF(ix + line_offset, iy)), value=ix, ) From aec8f1d25c48c612c6b6849de7976f668e784467 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 11 Feb 2021 23:41:40 -0500 Subject: [PATCH 064/139] Hide y-label on level line mouse over --- piker/ui/_graphics/_curve.py | 1 + piker/ui/_graphics/_lines.py | 13 ++++++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/piker/ui/_graphics/_curve.py b/piker/ui/_graphics/_curve.py index a9b24e7f..4feb5d37 100644 --- a/piker/ui/_graphics/_curve.py +++ b/piker/ui/_graphics/_curve.py @@ -141,6 +141,7 @@ class FastAppendCurve(pg.PlotCurveItem): w = hb_size.width() + 1 h = hb_size.height() + 1 + br = QtCore.QRectF( # top left diff --git a/piker/ui/_graphics/_lines.py b/piker/ui/_graphics/_lines.py index 7bf5b4ba..c89021b6 100644 --- a/piker/ui/_graphics/_lines.py +++ b/piker/ui/_graphics/_lines.py @@ -490,6 +490,7 @@ class LevelLine(pg.InfiniteLine): cur = chart._cursor cur.add_hovered(self) cur.graphics[chart]['yl'].hide() + cur.graphics[chart]['hl'].hide() for at, label in self._labels: label.show() @@ -504,7 +505,10 @@ class LevelLine(pg.InfiniteLine): cur = chart._cursor cur._hovered.remove(self) - cur.graphics[chart]['yl'].show() + if self not in cur._trackers: + g = cur.graphics[chart] + g['yl'].show() + g['hl'].show() if not self._always_show_labels: for at, label in self._labels: @@ -519,7 +523,9 @@ class LevelLine(pg.InfiniteLine): chart = self._chart # hide y-crosshair - chart._cursor.graphics[chart]['hl'].hide() + graphics = chart._cursor.graphics[chart] + graphics['hl'].hide() + graphics['yl'].hide() # highlight self.currentPen = self.hoverPen @@ -534,7 +540,8 @@ class LevelLine(pg.InfiniteLine): # This is the final position in the drag if ev.isFinish(): # show y-crosshair again - chart._cursor.graphics[chart]['hl'].show() + graphics['hl'].show() + graphics['yl'].show() def delete(self) -> None: """Remove this line from containing chart/view/scene. From d91f07c947b639836c7720a9f7796941b4f7d43d Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 11 Feb 2021 23:42:17 -0500 Subject: [PATCH 065/139] Drop old candlestick graphics code --- piker/ui/_graphics/_ohlc.py | 49 +++++++------------------------------ 1 file changed, 9 insertions(+), 40 deletions(-) diff --git a/piker/ui/_graphics/_ohlc.py b/piker/ui/_graphics/_ohlc.py index 0be7853f..58a139ea 100644 --- a/piker/ui/_graphics/_ohlc.py +++ b/piker/ui/_graphics/_ohlc.py @@ -167,17 +167,18 @@ class BarItems(pg.GraphicsObject): # 0.5 is no overlap between arms, 1.0 is full overlap w: float = 0.43 - # XXX: for the mega-lulz increasing width here increases draw latency... - # so probably don't do it until we figure that out. - bars_pen = pg.mkPen(hcolor('bracket')) - def __init__( self, # scene: 'QGraphicsScene', # noqa plotitem: 'pg.PlotItem', # noqa + pen_color: str = 'bracket', ) -> None: super().__init__() + # XXX: for the mega-lulz increasing width here increases draw latency... + # so probably don't do it until we figure that out. + self.bars_pen = pg.mkPen(hcolor(pen_color)) + # NOTE: this prevents redraws on mouse interaction which is # a huge boon for avg interaction latency. @@ -215,7 +216,9 @@ class BarItems(pg.GraphicsObject): This routine is usually only called to draw the initial history. """ - self.path = gen_qpath(data, start, self.w) + hist, last = data[:-1], data[-1] + + self.path = gen_qpath(hist, start, self.w) # save graphics for later reference and keep track # of current internal "last index" @@ -228,7 +231,7 @@ class BarItems(pg.GraphicsObject): ) # up to last to avoid double draw of last bar - self._last_bar_lines = lines_from_ohlc(data[-1], self.w) + self._last_bar_lines = lines_from_ohlc(last, self.w) # trigger render # https://doc.qt.io/qt-5/qgraphicsitem.html#update @@ -396,37 +399,3 @@ class BarItems(pg.GraphicsObject): ) # print(f'bounding rect: {br}') return br - - -# XXX: when we get back to enabling tina mode for xb -# class CandlestickItems(BarItems): - -# w2 = 0.7 -# line_pen = pg.mkPen('#000000') -# bull_brush = pg.mkBrush('#00ff00') -# bear_brush = pg.mkBrush('#ff0000') - -# def _generate(self, p): -# rects = np.array( -# [ -# QtCore.QRectF( -# q.id - self.w, -# q.open, -# self.w2, -# q.close - q.open -# ) -# for q in Quotes -# ] -# ) - -# p.setPen(self.line_pen) -# p.drawLines( -# [QtCore.QLineF(q.id, q.low, q.id, q.high) -# for q in Quotes] -# ) - -# p.setBrush(self.bull_brush) -# p.drawRects(*rects[Quotes.close > Quotes.open]) - -# p.setBrush(self.bear_brush) -# p.drawRects(*rects[Quotes.close < Quotes.open]) From b794855ad3f97fe8e0d0f1c9d636c50ad126d887 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 11 Feb 2021 23:42:57 -0500 Subject: [PATCH 066/139] Port order mode to new order line api --- piker/ui/_interaction.py | 105 +++++++++++++++++++++++++-------------- 1 file changed, 68 insertions(+), 37 deletions(-) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index a5b70368..e515ce16 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -223,9 +223,12 @@ class LineEditor: def stage_line( self, + color: str = 'alert_yellow', hl_on_hover: bool = False, dotted: bool = False, + + # fields settings size: Optional[int] = None, ) -> LevelLine: """Stage a line at the current chart's cursor position @@ -238,20 +241,31 @@ class LineEditor: cursor = chart._cursor y = chart._cursor._datum_xy[1] + symbol = chart._lc.symbol + line = self._stage_line if not line: # add a "staged" cursor-tracking line to view # and cash it in a a var line = order_line( chart, - level=y, - digits=chart._lc.symbol.digits(), - color=color, + level=y, + level_digits=symbol.digits(), + size=size, + size_digits=symbol.lot_digits(), + + # 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, + + # kwargs + color=color, # don't highlight the "staging" line hl_on_hover=hl_on_hover, dotted=dotted, - size=size, ) # line.label._use_extra_fields = size is not None @@ -260,13 +274,13 @@ class LineEditor: else: # apply input settings to existing staging line - label = line.label + # label = line.label # disable order size and other extras in label - label._use_extra_fields = size is not None - label.size = size + # label._use_extra_fields = size is not None + # label.size = size - label.color = color + # label.color = color # Use the existing staged line instead but copy # overe it's current style "properties". @@ -276,20 +290,18 @@ class LineEditor: line.color = color line.setMouseHover(hl_on_hover) line.show() + line.show_labels() # XXX: must have this to trigger updated # label contents rendering - line.setPos(y) - line.set_level() - - # show order info label - line.label.update() - line.label.show() + line.set_level(level=y) self._active_staged_line = line - # hide crosshair y-line - cursor.graphics[chart]['hl'].hide() + # hide crosshair y-line and label + cg = cursor.graphics[chart] + cg['hl'].hide() + cg['yl'].hide() # add line to cursor trackers cursor._trackers.add(line) @@ -310,16 +322,20 @@ class LineEditor: cursor._trackers.remove(line) self._active_staged_line = None - self._stage_line.hide() - self._stage_line.label.hide() + sl = self._stage_line + if sl: + sl.hide() + sl.hide_labels() - # show the crosshair y line - hl = cursor.graphics[chart]['hl'] - hl.show() + # show the crosshair y line and label + cg = cursor.graphics[chart] + cg['hl'].show() + cg['yl'].show() - def create_line( + def create_order_line( self, - uuid: str + uuid: str, + size: float, ) -> LevelLine: line = self._active_staged_line @@ -328,17 +344,24 @@ class LineEditor: chart = self.chart._cursor.active_plot y = chart._cursor._datum_xy[1] + sym = chart._lc.symbol line = order_line( chart, + + # label fields default values level=y, + level_digits=sym.digits(), + + size=size, + size_digits=sym.lot_digits(), + + # LevelLine kwargs color=line.color, - digits=chart._lc.symbol.digits(), dotted=line._dotted, - size=line.label.size, ) # for now, until submission reponse arrives - line.label.hide() + line.hide_labels() # register for later lookup/deletion self._order_lines[uuid] = line @@ -358,9 +381,9 @@ class LineEditor: return else: line.oid = uuid - line.set_level() - line.label.update() - line.label.show() + # line.set_level(line.level) + line.show_labels() + # line.label.show() # TODO: other flashy things to indicate the order is active @@ -465,7 +488,7 @@ class OrderMode: } _action: str = 'alert' _exec_mode: str = 'dark' - _size: int = 100 + _size: float = 100.0 key_map: Dict[str, Callable] = field(default_factory=dict) @@ -485,7 +508,7 @@ class OrderMode: 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, + size=size or self._size, ) def on_submit(self, uuid: str) -> dict: @@ -559,7 +582,10 @@ class OrderMode: f'Received cancel for unsubmitted order {pformat(msg)}' ) - def submit_exec(self) -> None: + def submit_exec( + self, + size: Optional[float] = None, + ) -> LevelLine: """Send execution order to EMS. """ @@ -569,8 +595,13 @@ class OrderMode: # order is live in the emsd). uid = str(uuid.uuid4()) + size = size or self._size + # make line graphic - line, y = self.lines.create_line(uid) + line, y = self.lines.create_order_line( + uid, + size=size, + ) line.oid = uid # send order cmd to ems @@ -578,6 +609,7 @@ class OrderMode: uuid=uid, symbol=self.chart._lc._symbol, price=y, + size=size, action=self._action, exec_mode=self._exec_mode, ) @@ -902,7 +934,6 @@ class ChartView(ViewBox): mode.book.cancel(uuid=line.oid) self._key_buffer.append(text) - order_size = self.mode._size # View modes if key == QtCore.Qt.Key_R: @@ -911,13 +942,13 @@ class ChartView(ViewBox): # Order modes: stage orders at the current cursor level elif key == QtCore.Qt.Key_D: # for "damp eet" - self.mode.set_exec('sell', size=order_size) + self.mode.set_exec('sell') elif key == QtCore.Qt.Key_F: # for "fillz eet" - self.mode.set_exec('buy', size=order_size) + self.mode.set_exec('buy') elif key == QtCore.Qt.Key_A: - self.mode.set_exec('alert', size=None) + self.mode.set_exec('alert') # delete orders under cursor elif key == QtCore.Qt.Key_Delete: From 880bdcffa7306a15a7709b1b78fd0141c49086be Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 12 Feb 2021 09:07:49 -0500 Subject: [PATCH 067/139] Document order status list from ib --- piker/_ems.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/piker/_ems.py b/piker/_ems.py index b664b46d..e860199d 100644 --- a/piker/_ems.py +++ b/piker/_ems.py @@ -382,6 +382,18 @@ async def process_broker_trades( elif name in ( 'status', ): + # TODO: templating the ib statuses in comparison with other + # brokers is likely the way to go: + # https://interactivebrokers.github.io/tws-api/interfaceIBApi_1_1EWrapper.html#a17f2a02d6449710b6394d0266a353313 + # short list: + # - PendingSubmit + # - PendingCancel + # - PreSubmitted (simulated orders) + # - ApiCancelled (cancelled by client before submission to routing) + # - Cancelled + # - Filled + # - Inactive (reject or cancelled but not by trader) + # everyone doin camel case status = msg['status'].lower() @@ -481,6 +493,8 @@ async def _ems_main( action = cmd['action'] oid = cmd['oid'] + # TODO: can't wait for this stuff to land in 3.10 + # https://www.python.org/dev/peps/pep-0636/#going-to-the-cloud-mappings if action in ('cancel',): # check for live-broker order @@ -565,7 +579,7 @@ async def _ems_main( percent_away = 0 abs_diff_away = 0 - # submit execution/order to EMS scanner loop + # submit execution/order to EMS scan loop book.orders.setdefault( sym, {} )[oid] = ( From 1ac4cc3dd350bf5574af6aff781a57e12cf4a48a Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 16 Feb 2021 06:42:48 -0500 Subject: [PATCH 068/139] Use new field label api for L1 --- piker/ui/_chart.py | 150 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 123 insertions(+), 27 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 8d4f295f..373b50a4 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -40,6 +40,7 @@ from ._graphics._cursor import ( ) from ._graphics._lines import ( level_line, + order_line, L1Labels, ) from ._graphics._ohlc import BarItems @@ -53,7 +54,7 @@ from ._style import ( _bars_from_right_in_follow_mode, _bars_to_left_in_follow_mode, ) -from ..data._source import Symbol, float_digits +from ..data._source import Symbol from .. import brokers from .. import data from ..data import maybe_open_shm_array @@ -307,12 +308,14 @@ class LinkedSplitCharts(QtGui.QWidget): linked_charts=self, axisItems={ 'bottom': xaxis, - 'right': PriceAxis(linked_charts=self) + 'right': PriceAxis(linked_charts=self, orientation='right'), + 'left': PriceAxis(linked_charts=self, orientation='left'), }, viewBox=cv, cursor=self._cursor, **cpw_kwargs, ) + print(f'xaxis ps: {xaxis.pos()}') # give viewbox as reference to chart # allowing for kb controls and interactions on **this** widget @@ -380,14 +383,22 @@ class ChartPlotWidget(pg.PlotWidget): name: str, array: np.ndarray, linked_charts: LinkedSplitCharts, + + view_color: str = 'papas_special', + pen_color: str = 'bracket', + static_yrange: Optional[Tuple[float, float]] = None, cursor: Optional[Cursor] = None, + **kwargs, ): """Configure chart display settings. """ + self.view_color = view_color + self.pen_color = pen_color + super().__init__( - background=hcolor('papas_special'), + background=hcolor(view_color), # parent=None, # plotItem=None, # antialias=True, @@ -416,9 +427,10 @@ class ChartPlotWidget(pg.PlotWidget): # show only right side axes self.hideAxis('left') self.showAxis('right') + # self.showAxis('left') # show background grid - self.showGrid(x=True, y=True, alpha=0.5) + self.showGrid(x=False, y=True, alpha=0.3) self.default_view() @@ -507,7 +519,8 @@ class ChartPlotWidget(pg.PlotWidget): def increment_view( self, ) -> None: - """Increment the data view one step to the right thus "following" + """ + Increment the data view one step to the right thus "following" the current time slot/step/bar. """ @@ -524,12 +537,15 @@ class ChartPlotWidget(pg.PlotWidget): self, name: str, data: np.ndarray, - # XXX: pretty sure this is dumb and we don't need an Enum - style: pg.GraphicsObject = BarItems, ) -> pg.GraphicsObject: - """Draw OHLC datums to chart. """ - graphics = style(self.plotItem) + Draw OHLC datums to chart. + + """ + graphics = BarItems( + self.plotItem, + pen_color=self.pen_color + ) # adds all bar/candle graphics objects for each data point in # the np array buffer to be drawn on next render cycle @@ -842,6 +858,83 @@ class ChartPlotWidget(pg.PlotWidget): self.scene().leaveEvent(ev) +async def test_bed( + ohlcv, + chart, + lc, +): + sleep = 6 + + # from PyQt5.QtCore import QPointF + vb = chart._vb + # scene = vb.scene() + + # raxis = chart.getAxis('right') + # vb_right = vb.boundingRect().right() + + last, i_end = ohlcv.array[-1][['close', 'index']] + + line = order_line( + chart, + level=last, + level_digits=2 + ) + # eps = line.getEndpoints() + + # llabel = line._labels[1][1] + + line.update_labels({'level': last}) + return + + # rl = eps[1] + # rlabel.setPos(rl) + + # ti = pg.TextItem(text='Fuck you') + # ti.setPos(pg.Point(i_end, last)) + # ti.setParentItem(line) + # ti.setAnchor(pg.Point(1, 1)) + # vb.addItem(ti) + # chart.plotItem.addItem(ti) + + from ._label import Label + + txt = Label( + vb, + fmt_str='fuck {it}', + ) + txt.format(it='boy') + txt.place_on_scene('left') + txt.set_view_y(last) + + # txt = QtGui.QGraphicsTextItem() + # txt.setPlainText("FUCK YOU") + # txt.setFont(_font.font) + # txt.setDefaultTextColor(pg.mkColor(hcolor('bracket'))) + # # txt.setParentItem(vb) + # w = txt.boundingRect().width() + # scene.addItem(txt) + + # txt.setParentItem(line) + # d_coords = vb.mapFromView(QPointF(i_end, last)) + # txt.setPos(vb_right - w, d_coords.y()) + # txt.show() + # txt.update() + + # rlabel.setPos(vb_right - 2*w, d_coords.y()) + # rlabel.show() + + i = 0 + while True: + await trio.sleep(sleep) + await tractor.breakpoint() + txt.format(it=f'dog_{i}') + # d_coords = vb.mapFromView(QPointF(i_end, last)) + # txt.setPos(vb_right - w, d_coords.y()) + # txt.setPlainText(f"FUCK YOU {i}") + i += 1 + # rlabel.setPos(vb_right - 2*w, d_coords.y()) + + async def _async_main( sym: str, brokername: str, @@ -962,6 +1055,14 @@ async def _async_main( linked_charts ) + # interactive testing + # n.start_soon( + # test_bed, + # ohlcv, + # chart, + # linked_charts, + # ) + # spawn EMS actor-service async with open_ems( brokername, @@ -969,6 +1070,7 @@ async def _async_main( ) as (book, trades_stream): async with open_order_mode( + symbol, chart, book, ) as order_mode: @@ -1172,28 +1274,23 @@ async def chart_from_quotes( # XXX: prettty sure this is correct? # if ticktype in ('trade', 'last'): if ticktype in ('last',): # 'size'): - label = { - l1.ask_label.level: l1.ask_label, - l1.bid_label.level: l1.bid_label, + l1.ask_label.fields['level']: l1.ask_label, + l1.bid_label.fields['level']: l1.bid_label, }.get(price) if label is not None: - label.size = size - label.update_from_data(0, price) + label.update_fields({'level': price, 'size': size}) # on trades should we be knocking down # the relevant L1 queue? # label.size -= size elif ticktype in ('ask', 'asize'): - l1.ask_label.size = size - l1.ask_label.update_from_data(0, price) - + l1.ask_label.update_fields({'level': price, 'size': size}) elif ticktype in ('bid', 'bsize'): - l1.bid_label.size = size - l1.bid_label.update_from_data(0, price) + l1.bid_label.update_fields({'level': price, 'size': size}) # update min price in view to keep bid on screen mn = min(price - tick_margin, mn) @@ -1218,10 +1315,8 @@ async def chart_from_quotes( last_mx, last_mn = mx, mn - async def spawn_fsps( linked_charts: LinkedSplitCharts, - # fsp_func_name, fsps: Dict[str, str], sym, src_shm, @@ -1389,12 +1484,13 @@ async def update_signals( # graphics.curve.setBrush(50, 50, 200, 100) # graphics.curve.setFillLevel(50) - # add moveable over-[sold/bought] lines - # and labels only for the 70/30 lines - level_line(chart, 20) - level_line(chart, 30, orient_v='top') - level_line(chart, 70, orient_v='bottom') - level_line(chart, 80, orient_v='top') + if fsp_func_name == 'rsi': + # add moveable over-[sold/bought] lines + # and labels only for the 70/30 lines + level_line(chart, 20) + level_line(chart, 30, orient_v='top') + level_line(chart, 70, orient_v='bottom') + level_line(chart, 80, orient_v='top') chart._set_yrange() From bbd54e8f95daf00f4806d867def93a6a5880b844 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 19 Feb 2021 17:23:37 -0500 Subject: [PATCH 069/139] Report asset types, tick sizes, and order actions from ib --- piker/brokers/ib.py | 45 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/piker/brokers/ib.py b/piker/brokers/ib.py index c17bdcf3..f3785763 100644 --- a/piker/brokers/ib.py +++ b/piker/brokers/ib.py @@ -594,7 +594,6 @@ async def _aio_get_client( """ # first check cache for existing client - # breakpoint() try: if port: client = _client_cache[(host, port)] @@ -818,8 +817,8 @@ async def fill_bars( sym: str, first_bars: list, shm: 'ShmArray', # type: ignore # noqa - count: int = 20, # NOTE: any more and we'll overrun underlying buffer - # count: int = 6, # NOTE: any more and we'll overrun the underlying buffer + # count: int = 20, # NOTE: any more and we'll overrun underlying buffer + count: int = 6, # NOTE: any more and we'll overrun the underlying buffer ) -> None: """Fill historical bars into shared mem / storage afap. @@ -864,6 +863,25 @@ async def fill_bars( await tractor.breakpoint() +asset_type_map = { + 'STK': 'stock', + 'OPT': 'option', + 'FUT': 'future', + 'CONTFUT': 'continuous_future', + 'CASH': 'forex', + 'IND': 'index', + 'CFD': 'cfd', + 'BOND': 'bond', + 'CMDTY': 'commodity', + 'FOP': 'futures_option', + 'FUND': 'mutual_fund', + 'WAR': 'warrant', + 'IOPT': 'warran', + 'BAG': 'bag', + # 'NEWS': 'news', +} + + # TODO: figure out how to share quote feeds sanely despite # the wacky ``ib_insync`` api. # @tractor.msg.pub @@ -956,9 +974,17 @@ async def stream_quotes( syminfo.update(syminfo['contract']) # TODO: more consistent field translation - syminfo['price_tick_size'] = syminfo['minTick'] - # for "traditional" assets, volume is discreet not a float - syminfo['lot_tick_size'] = 0 + atype = syminfo['asset_type'] = asset_type_map[syminfo['secType']] + + # for stocks it seems TWS reports too small a tick size + # such that you can't submit orders with that granularity? + min_tick = 0.01 if atype == 'stock' else 0 + + syminfo['price_tick_size'] = max(syminfo['minTick'], min_tick) + + # for "traditional" assets, volume is normally discreet, not a float + syminfo['lot_tick_size'] = 0.0 + # TODO: for loop through all symbols passed in init_msgs = { @@ -1138,6 +1164,7 @@ async def stream_trades( stream = await _trio_run_client_method( method='recv_trade_updates', ) + action_map = {'BOT': 'buy', 'SLD': 'sell'} async for event_name, item in stream: @@ -1164,17 +1191,19 @@ async def stream_trades( } elif event_name == 'fill': + trade, fill = item execu = fill.execution + msg = { 'reqid': execu.orderId, 'execid': execu.execId, # supposedly IB server fill time 'broker_time': execu.time, # converted to float by us - 'time': fill.time, # ns in main TCP handler by us + 'time': fill.time, # ns from main TCP handler by us inside ``ib_insync`` override 'time_ns': time.time_ns(), # cuz why not - 'action': {'BOT': 'buy', 'SLD': 'sell'}[execu.side], + 'action': action_map[execu.side], 'size': execu.shares, 'price': execu.price, } From add63734f1e30c1fc283ab13d1553144be784a3a Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 19 Feb 2021 18:42:50 -0500 Subject: [PATCH 070/139] Add an auto-reconnect websocket API --- piker/brokers/kraken.py | 323 ++++++++++++++++++++++++++-------------- 1 file changed, 208 insertions(+), 115 deletions(-) diff --git a/piker/brokers/kraken.py b/piker/brokers/kraken.py index 3bd6081c..425963e9 100644 --- a/piker/brokers/kraken.py +++ b/piker/brokers/kraken.py @@ -17,14 +17,22 @@ """ Kraken backend. """ -from contextlib import asynccontextmanager +from contextlib import asynccontextmanager, AsyncExitStack from dataclasses import asdict, field +from types import ModuleType from typing import List, Dict, Any, Tuple, Optional import json import time import trio_websocket -from trio_websocket._impl import ConnectionClosed, DisconnectionTimeout +from trio_websocket._impl import ( + ConnectionClosed, + DisconnectionTimeout, + ConnectionRejected, + HandshakeError, + ConnectionTimeout, +) + import arrow import asks import numpy as np @@ -229,22 +237,27 @@ async def get_client() -> Client: yield Client() -async def recv_msg(recv): +async def stream_messages(ws): + too_slow_count = last_hb = 0 while True: - with trio.move_on_after(1.5) as cs: - msg = await recv() - # trigger reconnection logic if too slow + with trio.move_on_after(5) as cs: + msg = await ws.recv_msg() + + # trigger reconnection if heartbeat is laggy if cs.cancelled_caught: + too_slow_count += 1 - if too_slow_count > 2: + + if too_slow_count > 10: log.warning( - "Heartbeat is to slow, " - "resetting ws connection") - raise trio_websocket._impl.ConnectionClosed( - "Reset Connection") + "Heartbeat is too slow, resetting ws connection") + + await ws._connect() + too_slow_count = 0 + continue if isinstance(msg, dict): if msg.get('event') == 'heartbeat': @@ -252,11 +265,11 @@ async def recv_msg(recv): now = time.time() delay = now - last_hb last_hb = now - log.trace(f"Heartbeat after {delay}") - # TODO: hmm i guess we should use this - # for determining when to do connection - # resets eh? + # XXX: why tf is this not printing without --tl flag? + log.debug(f"Heartbeat after {delay}") + # print(f"Heartbeat after {delay}") + continue err = msg.get('errorMessage') @@ -326,6 +339,95 @@ def make_sub(pairs: List[str], data: Dict[str, Any]) -> Dict[str, str]: } +class AutoReconWs: + """Make ``trio_websocketw` sockets stay up no matter the bs. + + """ + recon_errors = ( + ConnectionClosed, + DisconnectionTimeout, + ConnectionRejected, + HandshakeError, + ConnectionTimeout, + ) + + def __init__( + self, + url: str, + stack: AsyncExitStack, + serializer: ModuleType = json, + ): + self.url = url + self._stack = stack + self._ws: 'WebSocketConnection' = None # noqa + + async def _connect( + self, + tries: int = 10000, + ) -> None: + try: + await self._stack.aclose() + except (DisconnectionTimeout, RuntimeError): + await trio.sleep(1) + + last_err = None + for i in range(tries): + try: + self._ws = await self._stack.enter_async_context( + trio_websocket.open_websocket_url(self.url) + ) + log.info(f'Connection success: {self.url}') + return + except self.recon_errors as err: + last_err = err + log.error( + f'{self} connection bail with ' + f'{type(err)}...retry attempt {i}' + ) + await trio.sleep(1) + continue + else: + log.exception('ws connection fail...') + raise last_err + + async def send_msg( + self, + data: Any, + ) -> None: + while True: + try: + return await self._ws.send_message(json.dumps(data)) + except self.recon_errors: + await self._connect() + + async def recv_msg( + self, + ) -> Any: + while True: + try: + return json.loads(await self._ws.get_message()) + except self.recon_errors: + await self._connect() + + +@asynccontextmanager +async def open_autorecon_ws(url): + """Apparently we can QoS for all sorts of reasons..so catch em. + + """ + async with AsyncExitStack() as stack: + ws = AutoReconWs(url, stack) + # async with trio_websocket.open_websocket_url(url) as ws: + # await tractor.breakpoint() + + await ws._connect() + try: + yield ws + + finally: + await stack.aclose() + + # @tractor.msg.pub async def stream_quotes( # get_topics: Callable, @@ -353,8 +455,8 @@ async def stream_quotes( for sym in symbols: si = Pair(**await client.symbol_info(sym)) # validation syminfo = si.dict() - syminfo['price_tick_size'] = 1/10**si.pair_decimals - syminfo['lot_tick_size'] = 1/10**si.lot_decimals + syminfo['price_tick_size'] = 1 / 10**si.pair_decimals + syminfo['lot_tick_size'] = 1 / 10**si.lot_decimals sym_infos[sym] = syminfo ws_pairs[sym] = si.wsname @@ -393,123 +495,114 @@ async def stream_quotes( } yield init_msgs - while True: - try: - async with trio_websocket.open_websocket_url( - 'wss://ws.kraken.com/', - ) as ws: + async with open_autorecon_ws('wss://ws.kraken.com/') as ws: - # XXX: setup subs - # https://docs.kraken.com/websockets/#message-subscribe - # specific logic for this in kraken's shitty sync client: - # https://github.com/krakenfx/kraken-wsclient-py/blob/master/kraken_wsclient_py/kraken_wsclient_py.py#L188 - ohlc_sub = make_sub( - list(ws_pairs.values()), - {'name': 'ohlc', 'interval': 1} - ) + # XXX: setup subs + # https://docs.kraken.com/websockets/#message-subscribe + # specific logic for this in kraken's shitty sync client: + # https://github.com/krakenfx/kraken-wsclient-py/blob/master/kraken_wsclient_py/kraken_wsclient_py.py#L188 + ohlc_sub = make_sub( + list(ws_pairs.values()), + {'name': 'ohlc', 'interval': 1} + ) - # TODO: we want to eventually allow unsubs which should - # be completely fine to request from a separate task - # since internally the ws methods appear to be FIFO - # locked. - await ws.send_message(json.dumps(ohlc_sub)) + # TODO: we want to eventually allow unsubs which should + # be completely fine to request from a separate task + # since internally the ws methods appear to be FIFO + # locked. + await ws.send_msg(ohlc_sub) - # trade data (aka L1) - l1_sub = make_sub( - list(ws_pairs.values()), - {'name': 'spread'} # 'depth': 10} + # trade data (aka L1) + l1_sub = make_sub( + list(ws_pairs.values()), + {'name': 'spread'} # 'depth': 10} + ) - ) - await ws.send_message(json.dumps(l1_sub)) + await ws.send_msg(l1_sub) - async def recv(): - return json.loads(await ws.get_message()) + # pull a first quote and deliver + msg_gen = stream_messages(ws) - # pull a first quote and deliver - msg_gen = recv_msg(recv) - typ, ohlc_last = await msg_gen.__anext__() + typ, ohlc_last = await msg_gen.__anext__() - topic, quote = normalize(ohlc_last) + topic, quote = normalize(ohlc_last) - # packetize as {topic: quote} - yield {topic: quote} + # packetize as {topic: quote} + yield {topic: quote} - # tell incrementer task it can start - _buffer.shm_incrementing(shm_token['shm_name']).set() + # 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 + # keep start of last interval for volume tracking + last_interval_start = ohlc_last.etime - # start streaming - async for typ, ohlc in msg_gen: + # start streaming + async for typ, ohlc in msg_gen: - if typ == 'ohlc': + if typ == 'ohlc': - # TODO: can get rid of all this by using - # ``trades`` subscription... + # TODO: can get rid of all this by using + # ``trades`` subscription... - # generate tick values to match time & sales pane: - # https://trade.kraken.com/charts/KRAKEN:BTC-USD?period=1m - volume = ohlc.volume + # generate tick values to match time & sales pane: + # https://trade.kraken.com/charts/KRAKEN:BTC-USD?period=1m + volume = ohlc.volume - # new interval - if ohlc.etime > last_interval_start: - last_interval_start = ohlc.etime - tick_volume = volume - else: - # this is the tick volume *within the interval* - tick_volume = volume - ohlc_last.volume + # new interval + if ohlc.etime > last_interval_start: + last_interval_start = ohlc.etime + tick_volume = volume + else: + # this is the tick volume *within the interval* + tick_volume = volume - ohlc_last.volume - last = ohlc.close - if tick_volume: - ohlc.ticks.append({ - 'type': 'trade', - 'price': last, - 'size': tick_volume, - }) + last = ohlc.close + if tick_volume: + ohlc.ticks.append({ + 'type': 'trade', + 'price': last, + 'size': tick_volume, + }) - topic, quote = normalize(ohlc) + topic, quote = normalize(ohlc) - # if we are the lone tick writer start writing - # the buffer with appropriate trade data - if not writer_exists: - # update last entry - # benchmarked in the 4-5 us range - o, high, low, v = shm.array[-1][ - ['open', 'high', 'low', 'volume'] - ] - new_v = tick_volume + # if we are the lone tick writer start writing + # the buffer with appropriate trade data + if not writer_exists: + # update last entry + # benchmarked in the 4-5 us range + o, high, low, v = shm.array[-1][ + ['open', 'high', 'low', 'volume'] + ] + new_v = tick_volume - if v == 0 and new_v: - # no trades for this bar yet so the open - # is also the close/last trade price - o = last + if v == 0 and new_v: + # no trades for this bar yet so the open + # is also the close/last trade price + o = last - # write shm - shm.array[ - ['open', - 'high', - 'low', - 'close', - 'bar_wap', # in this case vwap of bar - 'volume'] - ][-1] = ( - o, - max(high, last), - min(low, last), - last, - ohlc.vwap, - volume, - ) - ohlc_last = ohlc + # write shm + shm.array[ + ['open', + 'high', + 'low', + 'close', + 'bar_wap', # in this case vwap of bar + 'volume'] + ][-1] = ( + o, + max(high, last), + min(low, last), + last, + ohlc.vwap, + volume, + ) + ohlc_last = ohlc - elif typ == 'l1': - quote = ohlc - topic = quote['symbol'] + elif typ == 'l1': + quote = ohlc + topic = quote['symbol'] - # XXX: format required by ``tractor.msg.pub`` - # requires a ``Dict[topic: str, quote: dict]`` - yield {topic: quote} - - except (ConnectionClosed, DisconnectionTimeout): - log.exception("Good job kraken...reconnecting") + # XXX: format required by ``tractor.msg.pub`` + # requires a ``Dict[topic: str, quote: dict]`` + yield {topic: quote} From ead2f77d40cedddd22d7d892961a28868819efe9 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 19 Feb 2021 18:43:56 -0500 Subject: [PATCH 071/139] Add a symbol/asset type key --- piker/data/_source.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/piker/data/_source.py b/piker/data/_source.py index e85727c2..23524426 100644 --- a/piker/data/_source.py +++ b/piker/data/_source.py @@ -86,6 +86,10 @@ class Symbol(BaseModel): lot_tick_size: float = 0.01 # "volume" precision as min step value broker_info: Dict[str, Dict[str, Any]] = {} + # specifies a "class" of financial instrument + # ex. stock, futer, option, bond etc. + type_key: str + @property def brokers(self) -> List[str]: return list(self.broker_info.keys()) From d8b157d20971c81fa96510b27e1fbaf169040cb5 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 20 Feb 2021 15:25:53 -0500 Subject: [PATCH 072/139] First draft paper trading engine! It's a super naive implementation with no slippage model or network latency besides some slight delays. Clearing only happens on bid/ask sweep ticks at the moment - simple last volume based clearing coming up next. --- piker/_ems.py | 570 ++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 441 insertions(+), 129 deletions(-) diff --git a/piker/_ems.py b/piker/_ems.py index e860199d..65374ace 100644 --- a/piker/_ems.py +++ b/piker/_ems.py @@ -15,16 +15,18 @@ # along with this program. If not, see . """ -In suit parlance: "Execution management systems" +In da suit parlances: "Execution management systems" """ from pprint import pformat import time +from datetime import datetime from contextlib import asynccontextmanager from dataclasses import dataclass, field from typing import ( AsyncIterator, Dict, Callable, Tuple, ) +import uuid from bidict import bidict import trio @@ -73,10 +75,13 @@ def mk_check(trigger_price, known_last) -> Callable[[float, float], bool]: @dataclass -class _ExecBook: - """EMS-side execution book. +class _DarkBook: + """Client-side execution book. + + Contains conditions for executions (aka "orders") which are not + exposed to brokers and thus the market; i.e. these are privacy + focussed "client side" orders. - Contains conditions for executions (aka "orders"). A singleton instance is created per EMS actor (for now). """ @@ -105,13 +110,13 @@ class _ExecBook: _broker2ems_ids: Dict[str, str] = field(default_factory=bidict) -_books: Dict[str, _ExecBook] = {} +_books: Dict[str, _DarkBook] = {} -def get_book(broker: str) -> _ExecBook: +def get_dark_book(broker: str) -> _DarkBook: global _books - return _books.setdefault(broker, _ExecBook(broker)) + return _books.setdefault(broker, _DarkBook(broker)) # XXX: this is in place to prevent accidental positions that are too @@ -129,9 +134,20 @@ class PaperBoi: requirements. """ + broker: str _to_trade_stream: trio.abc.SendChannel trade_stream: trio.abc.ReceiveChannel + # map of paper "live" orders which be used + # to simulate fills based on paper engine settings + _buys: bidict + _sells: bidict + _reqids: bidict + + # init edge case L1 spread + last_ask: Tuple[float, float] = (float('inf'), 0) # price, size + last_bid: Tuple[float, float] = (0, 0) + async def submit_limit( self, oid: str, # XXX: see return value @@ -143,6 +159,46 @@ class PaperBoi: """Place an order and return integer request id provided by client. """ + # the trades stream expects events in the form + # {'local_trades': (event_name, msg)} + reqid = str(uuid.uuid4()) + + # register this submissions as a paper live order + if action == 'buy': + orders = self._buys + + elif action == 'sell': + orders = self._sells + + # buys/sells: (symbol -> (price -> order)) + orders.setdefault(symbol, {})[price] = (size, oid, reqid, action) + + self._reqids[reqid] = (oid, symbol, action, price) + + # TODO: net latency model + # we checkpoint here quickly particulalry + # for dark orders since we want the dark_executed + # to trigger first thus creating a lookup entry + # in the broker trades event processing loop + await trio.sleep(0.05) + + await self._to_trade_stream.send({ + + 'local_trades': ('status', { + + 'time_ns': time.time_ns(), + 'reqid': reqid, + + 'status': 'submitted', + 'broker': self.broker, + # 'cmd': cmd, # original request message + + 'paper_info': { + 'oid': oid, + }, + }), + }) + return reqid async def submit_cancel( self, @@ -150,12 +206,303 @@ class PaperBoi: ) -> None: # TODO: fake market simulation effects - self._to_trade_stream() + # await self._to_trade_stream.send( + oid, symbol, action, price = self._reqids[reqid] - def emulate_fill( - self + if action == 'buy': + self._buys[symbol].pop(price) + elif action == 'sell': + self._sells[symbol].pop(price) + + # TODO: net latency model + await trio.sleep(0.05) + + await self._to_trade_stream.send({ + + 'local_trades': ('status', { + + 'time_ns': time.time_ns(), + 'oid': oid, + 'reqid': reqid, + + 'status': 'cancelled', + 'broker': self.broker, + # 'cmd': cmd, # original request message + + 'paper': True, + }), + }) + + async def fake_fill( + self, + price: float, + size: float, + action: str, # one of {'buy', 'sell'} + + reqid: str, + oid: str, + + # determine whether to send a filled status that has zero + # remaining lots to fill + order_complete: bool = True, + remaining: float = 0, ) -> None: - ... + """Pretend to fill a broker order @ price and size. + + """ + # TODO: net latency model + await trio.sleep(0.05) + + await self._to_trade_stream.send({ + + 'local_trades': ('fill', { + + 'status': 'filled', + 'broker': self.broker, + # converted to float by us in ib backend + 'broker_time': datetime.now().timestamp(), + + 'action': action, + 'size': size, + 'price': price, + 'remaining': 0 if order_complete else remaining, + + # normally filled by real `brokerd` daemon + 'time': time.time_ns(), + 'time_ns': time.time_ns(), # cuz why not + + # fake ids + 'reqid': reqid, + + 'paper_info': { + 'oid': oid, + }, + + # XXX: fields we might not need to emulate? + # execution id from broker + # 'execid': execu.execId, + # 'cmd': cmd, # original request message? + }), + }) + if order_complete: + await self._to_trade_stream.send({ + + 'local_trades': ('status', { + 'reqid': reqid, + 'status': 'filled', + 'broker': self.broker, + 'filled': size, + 'remaining': 0 if order_complete else remaining, + + # converted to float by us in ib backend + 'broker_time': datetime.now().timestamp(), + 'paper_info': { + 'oid': oid, + }, + }), + }) + + +async def simulate_fills( + quote_stream: 'tractor.ReceiveStream', # noqa + client: PaperBoi, +) -> None: + + # TODO: more machinery to better simulate real-world market things: + + # - slippage models, check what quantopian has: + # https://github.com/quantopian/zipline/blob/master/zipline/finance/slippage.py + # * this should help with simulating partial fills in a fast moving mkt + # afaiu + + # - commisions models, also quantopian has em: + # https://github.com/quantopian/zipline/blob/master/zipline/finance/commission.py + + # - network latency models ?? + + # - position tracking: + # https://github.com/quantopian/zipline/blob/master/zipline/finance/ledger.py + + # this stream may eventually contain multiple symbols + async for quotes in quote_stream: + for sym, quote in quotes.items(): + + buys, sells = client._buys.get(sym), client._sells.get(sym) + + if not (buys or sells): + continue + + for tick in iterticks( + quote, + # dark order price filter(s) + types=('ask', 'bid', 'trade', 'last') + ): + print(tick) + tick_price = tick.get('price') + ttype = tick['type'] + + if ttype in ('ask',) and buys: + + client.last_ask = ( + tick_price, + tick.get('size', client.last_ask[1]), + ) + + # iterate book prices descending + for our_bid in reversed(sorted(buys.keys())): + + if tick_price < our_bid: + + # retreive order info + (size, oid, reqid, action) = buys.pop(our_bid) + + # clearing price would have filled entirely + await client.fake_fill( + # todo slippage to determine fill price + tick_price, + size, + action, + reqid, + oid, + ) + else: + # prices are interated in sorted order so + # we're done + break + + if ttype in ('bid',) and sells: + + # iterate book prices ascending + for our_ask in sorted(sells.keys()): + + client.last_bid = ( + tick_price, + tick.get('bid', client.last_bid[1]), + ) + + if tick_price > our_ask: + + # retreive order info + (size, oid, reqid, action) = sells.pop(our_ask) + + # clearing price would have filled entirely + await client.fake_fill( + tick_price, + size, + action, + reqid, + oid, + ) + else: + # prices are interated in sorted order so + # we're done + break + + if ttype in ('trade', 'last'): + # TODO: simulate actual book queues and our orders + # place in it, might require full L2 data? + pass + + +async def execute_triggers( + broker: str, + symbol: str, + stream: 'tractor.ReceiveStream', # noqa + ctx: tractor.Context, + client: 'Client', # noqa + book: _DarkBook, +) -> None: + """Core dark order trigger loop. + + Scan the (price) data feed and submit triggered orders + to broker. + + """ + # this stream may eventually contain multiple symbols + async for quotes in stream: + + # TODO: numba all this! + + # start = time.time() + for sym, quote in quotes.items(): + + execs = book.orders.get(sym, None) + if execs is None: + continue + + for tick in iterticks( + quote, + # dark order price filter(s) + types=('ask', 'bid', 'trade', 'last') + ): + price = tick.get('price') + ttype = tick['type'] + + # lel, fuck you ib + # if price < 0: + # log.error(f'!!?!?!VOLUME TICK {tick}!?!?') + # continue + + # update to keep new cmds informed + book.lasts[(broker, symbol)] = price + + for oid, ( + pred, + tf, + cmd, + percent_away, + abs_diff_away + ) in ( + tuple(execs.items()) + ): + + if (ttype not in tf) or (not pred(price)): + # majority of iterations will be non-matches + continue + + # submit_price = price + price*percent_away + submit_price = price + abs_diff_away + + log.info( + f'Dark order triggered for price {price}\n' + f'Submitting order @ price {submit_price}') + + reqid = await client.submit_limit( + oid=oid, + symbol=sym, + action=cmd['action'], + price=submit_price, + size=cmd['size'], + ) + + # register broker request id to ems id + book._broker2ems_ids[reqid] = oid + + resp = { + 'resp': 'dark_executed', + 'time_ns': time.time_ns(), + 'trigger_price': price, + + 'cmd': cmd, # original request message + + 'broker_reqid': reqid, + 'broker': broker, + 'oid': oid, # piker order id + + } + + # remove exec-condition from set + log.info(f'removing pred for {oid}') + execs.pop(oid) + + await ctx.send_yield(resp) + + else: # condition scan loop complete + log.debug(f'execs are {execs}') + if execs: + book.orders[symbol] = execs + + # print(f'execs scan took: {time.time() - start}') async def exec_loop( @@ -165,7 +512,10 @@ async def exec_loop( _exec_mode: str, task_status: TaskStatus[dict] = trio.TASK_STATUS_IGNORED, ) -> AsyncIterator[dict]: + """Main scan loop for order execution conditions and submission + to brokers. + """ async with data.open_feed( broker, [symbol], @@ -174,32 +524,40 @@ async def exec_loop( # TODO: get initial price quote from target broker first_quote = await feed.receive() - book = get_book(broker) + + book = get_dark_book(broker) book.lasts[(broker, symbol)] = first_quote[symbol]['last'] # TODO: wrap this in a more re-usable general api client_factory = getattr(feed.mod, 'get_client_proxy', None) - # we have an order API for this broker if client_factory is not None and _exec_mode != 'paper': + + # we have an order API for this broker client = client_factory(feed._brokerd_portal) - # force paper mode else: - log.warning( - f'No order client is yet supported for {broker}, ' - 'entering paper mode') + # force paper mode + log.warning(f'Entering paper trading mode for {broker}') - client = PaperBoi(*trio.open_memory_channel(100)) + client = PaperBoi( + broker, + *trio.open_memory_channel(100), + _buys={}, + _sells={}, + _reqids={}, + ) # for paper mode we need to mock this trades response feed # so we pass a duck-typed feed-looking mem chan which is fed # fill and submission events from the exec loop - feed._set_fake_trades_stream(client.trade_stream) + feed._trade_stream = client.trade_stream # init the trades stream client._to_trade_stream.send_nowait({'local_trades': 'start'}) + _exec_mode = 'paper' + # return control to parent task task_status.started((first_quote, feed, client)) @@ -211,92 +569,19 @@ async def exec_loop( # shield this field so the remote brokerd does not get cancelled stream = feed.stream with stream.shield(): + async with trio.open_nursery() as n: + n.start_soon( + execute_triggers, + broker, + symbol, + stream, + ctx, + client, + book + ) - # this stream may eventually contain multiple symbols - async for quotes in stream: - - # TODO: numba all this! - - # start = time.time() - for sym, quote in quotes.items(): - - execs = book.orders.get(sym, None) - if execs is None: - continue - - for tick in iterticks( - quote, - # dark order price filter(s) - types=('ask', 'bid', 'trade', 'last') - ): - price = tick.get('price') - ttype = tick['type'] - - # lel, fuck you ib - if price < 0: - log.error(f'!!?!?!VOLUME TICK {tick}!?!?') - continue - - # update to keep new cmds informed - book.lasts[(broker, symbol)] = price - - for oid, ( - pred, - tf, - cmd, - percent_away, - abs_diff_away - ) in ( - tuple(execs.items()) - ): - - if (ttype not in tf) or (not pred(price)): - # majority of iterations will be non-matches - continue - - # submit_price = price + price*percent_away - submit_price = price + abs_diff_away - - log.info( - f'Dark order triggered for price {price}\n' - f'Submitting order @ price {submit_price}') - - reqid = await client.submit_limit( - oid=oid, - symbol=sym, - action=cmd['action'], - price=round(submit_price, 2), - size=cmd['size'], - ) - # register broker request id to ems id - book._broker2ems_ids[reqid] = oid - - resp = { - 'resp': 'dark_executed', - 'time_ns': time.time_ns(), - 'trigger_price': price, - 'broker_reqid': reqid, - 'broker': broker, - 'oid': oid, - 'cmd': cmd, # original request message - - # current shm array index - this needed? - # 'ohlc_index': feed.shm._last.value - 1, - } - - # remove exec-condition from set - log.info(f'removing pred for {oid}') - execs.pop(oid) - - await ctx.send_yield(resp) - - else: # condition scan loop complete - log.debug(f'execs are {execs}') - if execs: - book.orders[symbol] = execs - - # print(f'execs scan took: {time.time() - start}') - # feed teardown + if _exec_mode == 'paper': + n.start_soon(simulate_fills, stream.clone(), client) # TODO: lots of cases still to handle @@ -312,7 +597,7 @@ async def exec_loop( async def process_broker_trades( ctx: tractor.Context, feed: 'Feed', # noqa - book: _ExecBook, + book: _DarkBook, task_status: TaskStatus[dict] = trio.TASK_STATUS_IGNORED, ) -> AsyncIterator[dict]: """Trades update loop - receive updates from broker, convert @@ -329,17 +614,18 @@ async def process_broker_trades( 'status' -> relabel as 'broker_', if complete send 'executed' 'fill' -> 'broker_filled' - Currently accepted status values from IB + Currently accepted status values from IB: {'presubmitted', 'submitted', 'cancelled', 'inactive'} """ broker = feed.mod.name with trio.fail_after(5): + # in the paper engine case this is just a mem receive channel trades_stream = await feed.recv_trades_data() first = await trades_stream.__anext__() - # startup msg + # startup msg expected as first from broker backend assert first['local_trades'] == 'start' task_status.started() @@ -354,7 +640,19 @@ async def process_broker_trades( # make response packet to EMS client(s) oid = book._broker2ems_ids.get(reqid) - resp = {'oid': oid} + + if oid is None: + # paper engine race case: ``Client.submit_limit()`` hasn't + # returned yet and provided an output reqid to register + # locally, so we need to retreive the oid that was already + # packed at submission since we already know it ahead of + # time + oid = msg['paper_info']['oid'] + + resp = { + 'resp': None, # placeholder + 'oid': oid + } if name in ( 'error', @@ -379,6 +677,9 @@ async def process_broker_trades( # another stupid ib error to handle # if 10147 in message: cancel + # don't relay message to order requester client + continue + elif name in ( 'status', ): @@ -398,12 +699,14 @@ async def process_broker_trades( status = msg['status'].lower() if status == 'filled': + # await tractor.breakpoint() # conditional execution is fully complete, no more # fills for the noted order if not msg['remaining']: - await ctx.send_yield( - {'resp': 'broker_executed', 'oid': oid}) + + resp['resp'] = 'broker_executed' + log.info(f'Execution for {oid} is complete!') # just log it @@ -414,16 +717,17 @@ async def process_broker_trades( # one of (submitted, cancelled) resp['resp'] = 'broker_' + status - await ctx.send_yield(resp) - elif name in ( 'fill', ): # proxy through the "fill" result(s) resp['resp'] = 'broker_filled' resp.update(msg) - await ctx.send_yield(resp) - log.info(f'Fill for {oid} cleared with\n{pformat(resp)}') + + log.info(f'\nFill for {oid} cleared with:\n{pformat(resp)}') + + # respond to requesting client + await ctx.send_yield(resp) @tractor.stream @@ -452,14 +756,14 @@ async def _ems_main( - ``_ems_main()``: accepts order cmds, registers execs with exec loop - - ``exec_loop()``: run conditions on inputs and trigger executions + - ``exec_loop()``: + run (dark) conditions on inputs and trigger broker submissions - ``process_broker_trades()``: accept normalized trades responses, process and relay to ems client(s) """ - actor = tractor.current_actor() - book = get_book(broker) + book = get_dark_book(broker) # get a portal back to the client async with tractor.wait_for_actor(client_actor_name) as portal: @@ -485,10 +789,13 @@ async def _ems_main( book, ) - # connect back to the calling actor to receive order requests + # connect back to the calling actor (the one that is + # acting as an EMS client and will submit orders) to + # receive requests pushed over a tractor stream + # using (for now) an async generator. async for cmd in await portal.run(send_order_cmds): - log.info(f'{cmd} received in {actor.uid}') + log.info(f'Received order cmd:\n{pformat(cmd)}') action = cmd['action'] oid = cmd['oid'] @@ -533,7 +840,7 @@ async def _ems_main( oid=oid, # no ib support for this symbol=sym, action=action, - price=round(trigger_price, 2), + price=trigger_price, size=size, ) book._broker2ems_ids[order_id] = oid @@ -590,7 +897,7 @@ async def _ems_main( abs_diff_away ) - # ack-response that order is live here + # ack-response that order is live in EMS await ctx.send_yield({ 'resp': 'dark_submitted', 'oid': oid @@ -608,10 +915,11 @@ class OrderBook: 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. + Currently, this is mostly for keeping local state to match the EMS + and use received events to trigger graphics updates. """ + # mem channels used to relay order requests to the EMS daemon _to_ems: trio.abc.SendChannel _from_order_book: trio.abc.ReceiveChannel @@ -626,7 +934,7 @@ class OrderBook: size: float, action: str, exec_mode: str, - ) -> str: + ) -> dict: cmd = { 'action': action, 'price': price, @@ -638,6 +946,7 @@ class OrderBook: } self._sent_orders[uuid] = cmd self._to_ems.send_nowait(cmd) + return cmd async def modify(self, oid: str, price) -> bool: ... @@ -658,8 +967,13 @@ class OrderBook: _orders: OrderBook = None -def get_orders(emsd_uid: Tuple[str, str] = None) -> OrderBook: +def get_orders( + emsd_uid: Tuple[str, str] = None +) -> OrderBook: + """" + OrderBook singleton factory per actor. + """ if emsd_uid is not None: # TODO: read in target emsd's active book on startup pass @@ -669,7 +983,6 @@ def get_orders(emsd_uid: Tuple[str, str] = None) -> OrderBook: if _orders is None: # setup local ui event streaming channels for request/resp # streamging with EMS daemon - # _to_ems, _from_order_book = trio.open_memory_channel(100) _orders = OrderBook(*trio.open_memory_channel(100)) return _orders @@ -701,7 +1014,7 @@ async def send_order_cmds(): async for cmd in orders_stream: # send msg over IPC / wire - log.info(f'sending order cmd: {cmd}') + log.info(f'Send order cmd:\n{pformat(cmd)}') yield cmd @@ -737,7 +1050,6 @@ async def open_ems( TODO: make some fancy diagrams using mermaid.io - the possible set of responses from the stream is currently: - 'dark_submitted', 'broker_submitted' - 'dark_cancelled', 'broker_cancelled' @@ -766,7 +1078,7 @@ async def open_ems( # ready for order commands book = get_orders() - with trio.fail_after(5): + with trio.fail_after(10): await book._ready_to_receive.wait() yield book, trades_stream From 38b2e990027b2fc073c71c8195bcac0dae91cf5e Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 20 Feb 2021 16:43:33 -0500 Subject: [PATCH 073/139] Fill clearable prices asap --- piker/_ems.py | 63 +++++++++++++++++++++++++++++---------------------- 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/piker/_ems.py b/piker/_ems.py index 65374ace..a0428e26 100644 --- a/piker/_ems.py +++ b/piker/_ems.py @@ -163,18 +163,6 @@ class PaperBoi: # {'local_trades': (event_name, msg)} reqid = str(uuid.uuid4()) - # register this submissions as a paper live order - if action == 'buy': - orders = self._buys - - elif action == 'sell': - orders = self._sells - - # buys/sells: (symbol -> (price -> order)) - orders.setdefault(symbol, {})[price] = (size, oid, reqid, action) - - self._reqids[reqid] = (oid, symbol, action, price) - # TODO: net latency model # we checkpoint here quickly particulalry # for dark orders since we want the dark_executed @@ -198,6 +186,30 @@ class PaperBoi: }, }), }) + + # register order internally + self._reqids[reqid] = (oid, symbol, action, price) + + # if we're already a clearing price simulate an immediate fill + if ( + action == 'buy' and (clear_price := self.last_ask[0]) <= price + ) or ( + action == 'sell' and (clear_price := self.last_bid[0]) >= price + ): + await self.fake_fill(clear_price, size, action, reqid, oid) + + else: # register this submissions as a paper live order + + # submit order to book simulation fill loop + if action == 'buy': + orders = self._buys + + elif action == 'sell': + orders = self._sells + + # buys/sells: (symbol -> (price -> order)) + orders.setdefault(symbol, {})[price] = (size, oid, reqid, action) + return reqid async def submit_cancel( @@ -327,30 +339,26 @@ async def simulate_fills( async for quotes in quote_stream: for sym, quote in quotes.items(): - buys, sells = client._buys.get(sym), client._sells.get(sym) - - if not (buys or sells): - continue - for tick in iterticks( quote, # dark order price filter(s) types=('ask', 'bid', 'trade', 'last') ): - print(tick) + # print(tick) tick_price = tick.get('price') ttype = tick['type'] - if ttype in ('ask',) and buys: + if ttype in ('ask',): client.last_ask = ( tick_price, tick.get('size', client.last_ask[1]), ) + buys = client._buys.get(sym, {}) + # iterate book prices descending for our_bid in reversed(sorted(buys.keys())): - if tick_price < our_bid: # retreive order info @@ -370,16 +378,17 @@ async def simulate_fills( # we're done break - if ttype in ('bid',) and sells: + if ttype in ('bid',): + + client.last_bid = ( + tick_price, + tick.get('size', client.last_bid[1]), + ) + + sells = client._sells.get(sym, {}) # iterate book prices ascending for our_ask in sorted(sells.keys()): - - client.last_bid = ( - tick_price, - tick.get('bid', client.last_bid[1]), - ) - if tick_price > our_ask: # retreive order info From 8c757d0bdd891aa93811f158e6720a7cebb0733f Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 21 Feb 2021 11:42:19 -0500 Subject: [PATCH 074/139] Accept a symbol type key from broker --- piker/data/__init__.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/piker/data/__init__.py b/piker/data/__init__.py index addfefbb..c52e5e6c 100644 --- a/piker/data/__init__.py +++ b/piker/data/__init__.py @@ -169,12 +169,6 @@ class Feed: return self._index_stream - def _set_fake_trades_stream( - self, - recv_chan: trio.abc.ReceiveChannel, - ) -> None: - self._trade_stream = recv_chan - async def recv_trades_data(self) -> AsyncIterator[dict]: if not getattr(self.mod, 'stream_trades', False): @@ -187,7 +181,7 @@ class Feed: # NOTE: this can be faked by setting a rx chan # using the ``_.set_fake_trades_stream()`` method - if not self._trade_stream: + if self._trade_stream is None: self._trade_stream = await self._brokerd_portal.run( @@ -254,6 +248,7 @@ async def open_feed( # compat with eventual ``tractor.msg.pub`` topics=symbols, + loglevel=loglevel, ) feed = Feed( @@ -275,6 +270,7 @@ async def open_feed( symbol = Symbol( key=sym, + type_key=si.get('asset_type', 'forex'), tick_size=si.get('price_tick_size', 0.01), lot_tick_size=si.get('lot_tick_size', 0.0), ) From 71745ddcd46559bd8a65d668b7fb6110a5c45af8 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 21 Feb 2021 11:44:58 -0500 Subject: [PATCH 075/139] Even smaller text fill on axes --- piker/ui/_axes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/piker/ui/_axes.py b/piker/ui/_axes.py index fac0c934..4bb16656 100644 --- a/piker/ui/_axes.py +++ b/piker/ui/_axes.py @@ -54,7 +54,7 @@ class Axis(pg.AxisItem): self.setTickFont(_font.font) self.setStyle(**{ - 'textFillLimits': [(0, 0.616)], + 'textFillLimits': [(0, 0.5)], 'tickFont': _font.font, # offset of text *away from* axis line in px 'tickTextOffset': 6, From d5b41e12c56d0268d7234e5142c6a0703e03bd2f Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 21 Feb 2021 11:45:24 -0500 Subject: [PATCH 076/139] Add crosshair hide/show convenience methods --- piker/ui/_graphics/_cursor.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/piker/ui/_graphics/_cursor.py b/piker/ui/_graphics/_cursor.py index 6b6f9697..e8cd8892 100644 --- a/piker/ui/_graphics/_cursor.py +++ b/piker/ui/_graphics/_cursor.py @@ -24,7 +24,7 @@ import inspect import numpy as np import pyqtgraph as pg from PyQt5 import QtCore, QtGui -from PyQt5.QtCore import QPointF +from PyQt5.QtCore import QPointF, QRectF from .._style import ( _xaxis_at, @@ -413,8 +413,23 @@ class Cursor(pg.GraphicsObject): self._datum_xy = ix, iy - def boundingRect(self): + def boundingRect(self) -> QRectF: try: return self.active_plot.boundingRect() except AttributeError: return self.plots[0].boundingRect() + + def show_xhair(self) -> None: + plot = self.active_plot + g = self.graphics[self.active_plot] + # show horiz line and y-label + g['hl'].show() + g['vl'].show() + g['yl'].show() + + def hide_xhair(self) -> None: + plot = self.active_plot + g = self.graphics[self.active_plot] + g['hl'].hide() + g['vl'].hide() + g['yl'].hide() From c2890b8197a14f69b123329050b8d43876a658b5 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 21 Feb 2021 11:48:51 -0500 Subject: [PATCH 077/139] Port level line to xhair methods --- piker/ui/_graphics/_lines.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/piker/ui/_graphics/_lines.py b/piker/ui/_graphics/_lines.py index c89021b6..04795ede 100644 --- a/piker/ui/_graphics/_lines.py +++ b/piker/ui/_graphics/_lines.py @@ -37,6 +37,9 @@ class LevelLabel(YAxisLabel): where it's placed despite chart resizing and supports displaying multiple fields. + + TODO: replace the rectangle-text part with our new ``Label`` type. + """ _x_margin = 0 _y_margin = 0 @@ -338,9 +341,6 @@ class LevelLine(pg.InfiniteLine): self._track_cursor: bool = False self._always_show_labels = always_show_labels - # # indexed by int - # self._endpoints = (None, None) - # testing markers # self.addMarker('<|', 0.1, 3) # self.addMarker('<|>', 0.2, 3) @@ -505,6 +505,7 @@ class LevelLine(pg.InfiniteLine): cur = chart._cursor cur._hovered.remove(self) + if self not in cur._trackers: g = cur.graphics[chart] g['yl'].show() @@ -523,13 +524,10 @@ class LevelLine(pg.InfiniteLine): chart = self._chart # hide y-crosshair - graphics = chart._cursor.graphics[chart] - graphics['hl'].hide() - graphics['yl'].hide() + chart._cursor.hide_xhair() # highlight self.currentPen = self.hoverPen - # self.label.highlight(self.hoverPen) for at, label in self._labels: # label.highlight(self.hoverPen) label.show() @@ -540,8 +538,7 @@ class LevelLine(pg.InfiniteLine): # This is the final position in the drag if ev.isFinish(): # show y-crosshair again - graphics['hl'].show() - graphics['yl'].show() + chart._cursor.show_xhair() def delete(self) -> None: """Remove this line from containing chart/view/scene. @@ -549,7 +546,6 @@ class LevelLine(pg.InfiniteLine): """ scene = self.scene() if scene: - # self.label.parent.scene().removeItem(self.label) for at, label in self._labels: label.delete() From aa4a2ef64f00c093adb7a4aa11c26f5ac91f116e Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 21 Feb 2021 11:49:27 -0500 Subject: [PATCH 078/139] Bump up font size one more time --- piker/ui/_style.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/piker/ui/_style.py b/piker/ui/_style.py index 6e023095..a290bdef 100644 --- a/piker/ui/_style.py +++ b/piker/ui/_style.py @@ -30,8 +30,10 @@ from ._exec import current_screen log = get_logger(__name__) # chart-wide fonts specified in inches -_default_font_inches_we_like = 0.055 #5 / 96 -_down_2_font_inches_we_like = 0.05 #4 / 96 +# _default_font_inches_we_like = 0.055 #5 / 96 +_default_font_inches_we_like = 0.0616 #5 / 96 +# _down_2_font_inches_we_like = 0.05 #4 / 96 +_down_2_font_inches_we_like = 0.055 #4 / 96 class DpiAwareFont: From 6fb1945360d58ba623d29001fc18b57cda52c109 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 21 Feb 2021 11:50:33 -0500 Subject: [PATCH 079/139] Fix and pass through piker loglevel correctly --- piker/ui/_chart.py | 18 +++++++++--------- piker/ui/_exec.py | 2 +- piker/ui/cli.py | 9 ++++----- 3 files changed, 14 insertions(+), 15 deletions(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 373b50a4..b89eede9 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -936,14 +936,13 @@ async def test_bed( async def _async_main( - sym: str, - brokername: str, - # implicit required argument provided by ``qtractor_run()`` widgets: Dict[str, Any], - # all kwargs are passed through from the CLI entrypoint - loglevel: str = None, + sym: str, + brokername: str, + loglevel: str, + ) -> None: """Main Qt-trio routine invoked by the Qt loop with the widgets ``dict``. @@ -1093,7 +1092,7 @@ async def _async_main( async for msg in trades_stream: fmsg = pformat(msg) - log.info(f'Received order msg: {fmsg}') + log.info(f'Received order msg:\n{fmsg}') # delete the line from view oid = msg['oid'] @@ -1121,7 +1120,7 @@ async def _async_main( elif resp in ( 'dark_executed' ): - log.info(f'Dark order filled for {fmsg}') + log.info(f'Dark order triggered for {fmsg}') # for alerts add a triangle and remove the # level line @@ -1300,7 +1299,7 @@ async def chart_from_quotes( if (mx > last_mx) or ( mn < last_mn ): - print(f'new y range: {(mn, mx)}') + # print(f'new y range: {(mn, mx)}') chart._set_yrange( yrange=(mn, mx), @@ -1581,6 +1580,7 @@ async def check_for_new_bars(feed, ohlcv, linked_charts): def _main( sym: str, brokername: str, + piker_loglevel: str, tractor_kwargs, ) -> None: """Sync entry point to start a chart app. @@ -1589,7 +1589,7 @@ def _main( # Qt entry point run_qtractor( func=_async_main, - args=(sym, brokername), + args=(sym, brokername, piker_loglevel), main_widget=ChartSpace, tractor_kwargs=tractor_kwargs, ) diff --git a/piker/ui/_exec.py b/piker/ui/_exec.py index 4c081480..9fbb3988 100644 --- a/piker/ui/_exec.py +++ b/piker/ui/_exec.py @@ -197,7 +197,7 @@ def run_qtractor( name='qtractor', **tractor_kwargs, ): - await func(*(args + (widgets,))) + await func(*((widgets,) + args)) # guest mode entry trio.lowlevel.start_guest_run( diff --git a/piker/ui/cli.py b/piker/ui/cli.py index d2050bbc..1dfea298 100644 --- a/piker/ui/cli.py +++ b/piker/ui/cli.py @@ -126,27 +126,26 @@ def optschain(config, symbol, date, tl, rate, test): is_flag=True, help='Enable pyqtgraph profiling' ) -@click.option('--date', '-d', help='Contracts expiry date') -@click.option('--test', '-t', help='Test quote stream file') -@click.option('--rate', '-r', default=1, help='Logging level') @click.argument('symbol', required=True) @click.pass_obj -def chart(config, symbol, date, rate, test, profile): +def chart(config, symbol, profile): """Start a real-time chartng UI """ from .. import _profile from ._chart import _main - # possibly enable profiling + # toggle to enable profiling _profile._pg_profile = profile # global opts brokername = config['broker'] tractorloglevel = config['tractorloglevel'] + pikerloglevel = config['loglevel'] _main( sym=symbol, brokername=brokername, + piker_loglevel=pikerloglevel, tractor_kwargs={ 'debug_mode': True, 'loglevel': tractorloglevel, From 4b0e5662a52d8103dcc45eccf249b6d0ea933095 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 21 Feb 2021 12:01:48 -0500 Subject: [PATCH 080/139] Add default order lot sizes by asset type --- piker/ui/_interaction.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index e515ce16..696f24b3 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -34,6 +34,7 @@ from ..log import get_logger from ._style import _min_points_to_show, hcolor, _font from ._graphics._lines import order_line, LevelLine from .._ems import OrderBook +from ..data._source import Symbol log = get_logger(__name__) @@ -283,7 +284,7 @@ class LineEditor: # label.color = color # Use the existing staged line instead but copy - # overe it's current style "properties". + # over it's current style "properties". # Saves us allocating more mem / objects repeatedly line._hoh = hl_on_hover line._dotted = dotted @@ -374,6 +375,9 @@ class LineEditor: graphic in view. """ + if uuid is None: + breakpoint() + try: line = self._order_lines[uuid] except KeyError: @@ -618,7 +622,8 @@ class OrderMode: @asynccontextmanager async def open_order_mode( - chart, + symbol: Symbol, + chart: pg.PlotWidget, book: OrderBook, ): # global _order_lines @@ -633,6 +638,17 @@ async def open_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 From f724798336880f7846270bcdfd97f287ec708fff Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 21 Feb 2021 12:02:20 -0500 Subject: [PATCH 081/139] Fix incorrect bounding rect calc --- piker/ui/_graphics/_ohlc.py | 56 ++++++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 17 deletions(-) diff --git a/piker/ui/_graphics/_ohlc.py b/piker/ui/_graphics/_ohlc.py index 58a139ea..044ce679 100644 --- a/piker/ui/_graphics/_ohlc.py +++ b/piker/ui/_graphics/_ohlc.py @@ -21,7 +21,7 @@ from typing import List, Optional, Tuple import numpy as np import pyqtgraph as pg -from numba import jit, float64, int64 # , optional +from numba import njit, float64, int64 # , optional from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5.QtCore import QLineF, QPointF # from numba import types as ntypes @@ -46,10 +46,20 @@ def _mk_lines_array( ) -def lines_from_ohlc(row: np.ndarray, w: float) -> Tuple[QLineF]: +def lines_from_ohlc( + row: np.ndarray, + w: float +) -> Tuple[QLineF]: + open, high, low, close, index = row[ ['open', 'high', 'low', 'close', 'index']] + # TODO: maybe consider using `QGraphicsLineItem` ?? + # gives us a ``.boundingRect()`` on the objects which may make + # computing the composite bounding rect of the last bars + the + # history path faster since it's done in C++: + # https://doc.qt.io/qt-5/qgraphicslineitem.html + # high -> low vertical (body) line if low != high: hl = QLineF(index, low, index, high) @@ -60,17 +70,18 @@ def lines_from_ohlc(row: np.ndarray, w: float) -> Tuple[QLineF]: # NOTE: place the x-coord start as "middle" of the drawing range such # that the open arm line-graphic is at the left-most-side of - # the index's range according to the view mapping. + # the index's range according to the view mapping coordinates. # open line o = QLineF(index - w, open, index, open) + # close line c = QLineF(index, close, index + w, close) return [hl, o, c] -@jit( +@njit( # TODO: for now need to construct this manually for readonly arrays, see # https://github.com/numba/numba/issues/4511 # ntypes.Tuple((float64[:], float64[:], float64[:]))( @@ -78,7 +89,6 @@ def lines_from_ohlc(row: np.ndarray, w: float) -> Tuple[QLineF]: # int64, # optional(float64), # ), - nopython=True, nogil=True ) def path_arrays_from_ohlc( @@ -177,7 +187,7 @@ class BarItems(pg.GraphicsObject): # XXX: for the mega-lulz increasing width here increases draw latency... # so probably don't do it until we figure that out. - self.bars_pen = pg.mkPen(hcolor(pen_color)) + self.bars_pen = pg.mkPen(hcolor(pen_color), width=1) # NOTE: this prevents redraws on mouse interaction which is # a huge boon for avg interaction latency. @@ -314,15 +324,17 @@ class BarItems(pg.GraphicsObject): ['index', 'open', 'high', 'low', 'close', 'volume'] ] # assert i == self.start_index - 1 - assert i == last_index + # assert i == last_index body, larm, rarm = self._last_bar_lines # XXX: is there a faster way to modify this? rarm.setLine(rarm.x1(), last, rarm.x2(), last) + # writer is responsible for changing open on "first" volume of bar larm.setLine(larm.x1(), o, larm.x2(), o) if l != h: # noqa + if body is None: body = self._last_bar_lines[0] = QLineF(i, l, i, h) else: @@ -383,19 +395,29 @@ class BarItems(pg.GraphicsObject): # apparently this a lot faster says the docs? # https://doc.qt.io/qt-5/qpainterpath.html#controlPointRect hb = self.path.controlPointRect() - hb_size = hb.size() - # print(f'hb_size: {hb_size}') + hb_tl, hb_br = hb.topLeft(), hb.bottomRight() - w = hb_size.width() + 1 - h = hb_size.height() + 1 + # need to include last bar height or BR will be off + mx_y = hb_br.y() + mn_y = hb_tl.y() - br = QtCore.QRectF( + body_line = self._last_bar_lines[0] + if body_line: + mx_y = max(mx_y, max(body_line.y1(), body_line.y2())) + mn_y = min(mn_y, min(body_line.y1(), body_line.y2())) + + return QtCore.QRectF( # top left - QPointF(hb.topLeft()), + QPointF( + hb_tl.x(), + mn_y, + ), + + # bottom right + QPointF( + hb_br.x() + 1, + mx_y, + ) - # total size - QtCore.QSizeF(w, h) ) - # print(f'bounding rect: {br}') - return br From e6ea053d40aeb7bb5edfd3fa9de8c448964d3d8d Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 21 Feb 2021 12:32:40 -0500 Subject: [PATCH 082/139] Get kivy/questrade shit working again --- piker/brokers/api.py | 12 ++++++++---- piker/brokers/data.py | 9 ++++++--- piker/data/marketstore.py | 2 +- piker/ui/kivy/monitor.py | 16 ++++++++++------ 4 files changed, 25 insertions(+), 14 deletions(-) diff --git a/piker/brokers/api.py b/piker/brokers/api.py index f54a0e86..75fc8a14 100644 --- a/piker/brokers/api.py +++ b/piker/brokers/api.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 @@ -16,7 +16,9 @@ """ Actor-aware broker agnostic interface. + """ +from typing import Dict from contextlib import asynccontextmanager, AsyncExitStack import trio @@ -28,7 +30,7 @@ from ..log import get_logger log = get_logger(__name__) -_clients: Dict[str, 'Client'] = {} +_cache: Dict[str, 'Client'] = {} @asynccontextmanager async def get_cached_client( @@ -40,9 +42,11 @@ async def get_cached_client( If one has not been setup do it and cache it. """ - global _clients + global _cache - clients = ss.setdefault('clients', {'_lock': trio.Lock()}) + clients = _cache.setdefault('clients', {'_lock': trio.Lock()}) + + # global cache task lock lock = clients['_lock'] client = None diff --git a/piker/brokers/data.py b/piker/brokers/data.py index cdf056b4..82e08507 100644 --- a/piker/brokers/data.py +++ b/piker/brokers/data.py @@ -180,15 +180,18 @@ async def symbol_data(broker: str, tickers: List[str]): return await feed.client.symbol_info(tickers) +_feeds_cache = {} + @asynccontextmanager async def get_cached_feed( brokername: str, ) -> BrokerFeed: """Get/create a ``BrokerFeed`` from/in the current actor. """ - # check if a cached client is in the local actor's statespace - ss = tractor.current_actor().statespace - feeds = ss.setdefault('feeds', {'_lock': trio.Lock()}) + global _feeds_cache + + # check if a cached feed is in the local actor + feeds = _feeds_cache.setdefault('feeds', {'_lock': trio.Lock()}) lock = feeds['_lock'] feed = None try: diff --git a/piker/data/marketstore.py b/piker/data/marketstore.py index d8cb3930..27bcda70 100644 --- a/piker/data/marketstore.py +++ b/piker/data/marketstore.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 diff --git a/piker/ui/kivy/monitor.py b/piker/ui/kivy/monitor.py index 47e52605..6c0a7736 100644 --- a/piker/ui/kivy/monitor.py +++ b/piker/ui/kivy/monitor.py @@ -62,7 +62,7 @@ async def update_quotes( color = colorcode('gray') # if the cell has been "highlighted" make sure to change its color - if hdrcell.background_color != [0]*4: + if hdrcell.background_color != [0] * 4: hdrcell.background_color = color # update row header and '%' cell text color @@ -144,14 +144,17 @@ async def update_quotes( log.warn("Data feed connection dropped") +_widgets = {} + + async def stream_symbol_selection(): """An RPC async gen for streaming the symbol corresponding value corresponding to the last clicked row. Essentially of an event stream of clicked symbol values. """ - widgets = tractor.current_actor().statespace['widgets'] - table = widgets['table'] + global _widgets + table = _widgets['table'] send_chan, recv_chan = trio.open_memory_channel(0) table._click_queues.append(send_chan) try: @@ -238,8 +241,6 @@ async def _async_main( # set up a pager view for large ticker lists table.bind(minimum_height=table.setter('height')) - ss = tractor.current_actor().statespace - async def spawn_opts_chain(): """Spawn an options chain UI in a new subactor. """ @@ -276,7 +277,10 @@ async def _async_main( 'header': header, 'pager': pager, } - ss['widgets'] = widgets + + global _widgets + _widgets = widgets + nursery.start_soon( update_quotes, nursery, From af82f36bd833da260b3306c34ed346cd79829adf Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 21 Feb 2021 16:04:04 -0500 Subject: [PATCH 083/139] Add comp trading ref --- README.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index cf8112a2..de58ad56 100644 --- a/README.rst +++ b/README.rst @@ -8,7 +8,7 @@ trading gear for hackers. :target: https://actions-badge.atrox.dev/piker/pikers/goto ``piker`` is a broker agnostic, next-gen FOSS toolset for real-time -trading targeted at hardcore Linux users. +computational trading targeted at `hardcore Linux users `_ . we use as much bleeding edge tech as possible including (but not limited to): @@ -32,6 +32,7 @@ we use as much bleeding edge tech as possible including (but not limited to): .. _pyqtgraph: https://github.com/pyqtgraph/pyqtgraph .. _glue: https://numpy.org/doc/stable/user/c-info.python-as-glue.html#using-python-as-glue .. _fast numerics: https://zerowithdot.com/python-numpy-and-pandas-performance/ +.. _comp_trader: https://jfaleiro.wordpress.com/2019/10/09/computational-trader/ focus and features: From 1142a538ea2b64a5f7fab7b159e0ad738d70cfb7 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 22 Feb 2021 10:45:01 -0500 Subject: [PATCH 084/139] Don't forward errors without an order id --- piker/brokers/ib.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/piker/brokers/ib.py b/piker/brokers/ib.py index f3785763..1577f0f6 100644 --- a/piker/brokers/ib.py +++ b/piker/brokers/ib.py @@ -23,13 +23,14 @@ built on it) and thus actor aware API calls must be spawned with """ from contextlib import asynccontextmanager from dataclasses import asdict -from functools import partial from datetime import datetime +from functools import partial from typing import List, Dict, Any, Tuple, Optional, AsyncIterator, Callable import asyncio -import logging +from pprint import pformat import inspect import itertools +import logging import time import trio @@ -1216,4 +1217,10 @@ async def stream_trades( if isinstance(con, Contract): msg['contract'] = asdict(con) + if msg['reqid'] == -1: + log.error(pformat(msg)) + + # don't forward, it's pointless.. + continue + yield {'local_trades': (event_name, msg)} From a9bbc223bba4bd56e57641976894b05ddd283371 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 22 Feb 2021 17:28:34 -0500 Subject: [PATCH 085/139] Add a new exchange subpackage --- piker/exchange/__init__.py | 20 ++++++++++++++++++++ piker/{ => exchange}/_ems.py | 8 ++++---- piker/ui/_chart.py | 2 +- piker/ui/_interaction.py | 2 +- piker/ui/cli.py | 2 +- 5 files changed, 27 insertions(+), 7 deletions(-) create mode 100644 piker/exchange/__init__.py rename piker/{ => exchange}/_ems.py (99%) diff --git a/piker/exchange/__init__.py b/piker/exchange/__init__.py new file mode 100644 index 00000000..c4fc2647 --- /dev/null +++ b/piker/exchange/__init__.py @@ -0,0 +1,20 @@ +# 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 . + +""" +Market machinery for order executions, book, management. + +""" diff --git a/piker/_ems.py b/piker/exchange/_ems.py similarity index 99% rename from piker/_ems.py rename to piker/exchange/_ems.py index a0428e26..f594b4ac 100644 --- a/piker/_ems.py +++ b/piker/exchange/_ems.py @@ -33,10 +33,10 @@ import trio from trio_typing import TaskStatus import tractor -from . import data -from .log import get_logger -from .data._source import Symbol -from .data._normalize import iterticks +from .. import data +from ..log import get_logger +from ..data._source import Symbol +from ..data._normalize import iterticks log = get_logger(__name__) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index b89eede9..f11ad0f0 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -62,7 +62,7 @@ from ..log import get_logger from ._exec import run_qtractor, current_screen from ._interaction import ChartView, open_order_mode from .. import fsp -from .._ems import open_ems +from ..exchange._ems import open_ems log = get_logger(__name__) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 696f24b3..4f6ee19e 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -33,7 +33,7 @@ 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 .._ems import OrderBook +from ..exchange._ems import OrderBook from ..data._source import Symbol diff --git a/piker/ui/cli.py b/piker/ui/cli.py index 1dfea298..78100523 100644 --- a/piker/ui/cli.py +++ b/piker/ui/cli.py @@ -149,6 +149,6 @@ def chart(config, symbol, profile): tractor_kwargs={ 'debug_mode': True, 'loglevel': tractorloglevel, - 'rpc_module_paths': ['piker._ems'], + 'enable_modules': ['piker.exchange._ems'], }, ) From 948e133caeff6a50f6f9bdce7022d3319a513304 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 22 Feb 2021 18:37:57 -0500 Subject: [PATCH 086/139] Split out ems daemon, client api and paper engine into new mods --- piker/exchange/_client.py | 212 ++++++++++++++ piker/exchange/_ems.py | 472 +------------------------------- piker/exchange/_paper_engine.py | 317 +++++++++++++++++++++ piker/ui/_chart.py | 2 +- piker/ui/_interaction.py | 2 +- piker/ui/cli.py | 5 +- 6 files changed, 538 insertions(+), 472 deletions(-) create mode 100644 piker/exchange/_client.py create mode 100644 piker/exchange/_paper_engine.py diff --git a/piker/exchange/_client.py b/piker/exchange/_client.py new file mode 100644 index 00000000..4bf84828 --- /dev/null +++ b/piker/exchange/_client.py @@ -0,0 +1,212 @@ +# 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 . + +""" +Orders and execution client API. + +""" +from contextlib import asynccontextmanager +from typing import Dict, Tuple +from pprint import pformat +from dataclasses import dataclass, field + +import trio +import tractor + +from ..data._source import Symbol +from ..log import get_logger +from ._ems import _ems_main + + +log = get_logger(__name__) + + +@dataclass +class OrderBook: + """Buy-side (client-side ?) order book ctl and tracking. + + 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, this is mostly for keeping local state to match the EMS + and use received events to trigger graphics updates. + + """ + # mem channels used to relay order requests to the EMS daemon + _to_ems: trio.abc.SendChannel + _from_order_book: trio.abc.ReceiveChannel + + _sent_orders: Dict[str, dict] = field(default_factory=dict) + _ready_to_receive: trio.Event = trio.Event() + + def send( + self, + uuid: str, + symbol: 'Symbol', + price: float, + size: float, + action: str, + exec_mode: str, + ) -> dict: + cmd = { + 'action': action, + 'price': price, + 'size': size, + 'symbol': symbol.key, + 'brokers': symbol.brokers, + 'oid': uuid, + 'exec_mode': exec_mode, # dark or live + } + self._sent_orders[uuid] = cmd + self._to_ems.send_nowait(cmd) + return cmd + + async def modify(self, oid: str, price) -> bool: + ... + + def cancel(self, uuid: str) -> bool: + """Cancel an order (or alert) from the EMS. + + """ + cmd = self._sent_orders[uuid] + msg = { + 'action': 'cancel', + 'oid': uuid, + 'symbol': cmd['symbol'], + } + self._to_ems.send_nowait(msg) + + +_orders: OrderBook = None + + +def get_orders( + emsd_uid: Tuple[str, str] = None +) -> OrderBook: + """" + OrderBook singleton factory per actor. + + """ + if emsd_uid is not None: + # TODO: read in target emsd's active book on startup + pass + + global _orders + + if _orders is None: + # setup local ui event streaming channels for request/resp + # streamging with EMS daemon + _orders = OrderBook(*trio.open_memory_channel(100)) + + return _orders + + +# TODO: make this a ``tractor.msg.pub`` +async def send_order_cmds(): + """Order streaming task: deliver orders transmitted from UI + to downstream consumers. + + This is run in the UI actor (usually the one running Qt but could be + any other client service code). This process 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 relayed to any consumer(s) that called this function using + a ``tractor`` portal. + + This effectively makes order messages look like they're being + "pushed" from the parent to the EMS where local sync code is likely + doing the pushing from some UI. + + """ + book = get_orders() + orders_stream = book._from_order_book + + # signal that ems connection is up and ready + book._ready_to_receive.set() + + async for cmd in orders_stream: + + # send msg over IPC / wire + log.info(f'Send order cmd:\n{pformat(cmd)}') + yield cmd + + +@asynccontextmanager +async def open_ems( + broker: str, + symbol: Symbol, +) -> None: + """Spawn an EMS daemon and begin sending orders and receiving + alerts. + + + This EMS tries to reduce most broker's terrible order entry apis to + a very simple protocol built on a few easy to grok and/or + "rantsy" premises: + + - most users will prefer "dark mode" where orders are not submitted + to a broker until and execution condition is triggered + (aka client-side "hidden orders") + + - Brokers over-complicate their apis and generally speaking hire + poor designers to create them. We're better off using creating a super + minimal, schema-simple, request-event-stream protocol to unify all the + existing piles of shit (and shocker, it'll probably just end up + looking like a decent crypto exchange's api) + + - all order types can be implemented with client-side limit orders + + - we aren't reinventing a wheel in this case since none of these + brokers are exposing FIX protocol; it is they doing the re-invention. + + + TODO: make some fancy diagrams using mermaid.io + + the possible set of responses from the stream is currently: + - 'dark_submitted', 'broker_submitted' + - 'dark_cancelled', 'broker_cancelled' + - 'dark_executed', 'broker_executed' + - 'broker_filled' + """ + actor = tractor.current_actor() + + # TODO: add ``maybe_spawn_emsd()`` for this + async with tractor.open_nursery() as n: + + portal = await n.start_actor( + 'emsd', + enable_modules=[ + 'piker.exchange._ems', + ], + ) + trades_stream = await portal.run( + _ems_main, + client_actor_name=actor.name, + broker=broker, + symbol=symbol.key, + + ) + + # wait for service to connect back to us signalling + # ready for order commands + book = get_orders() + + with trio.fail_after(10): + await book._ready_to_receive.wait() + + yield book, trades_stream diff --git a/piker/exchange/_ems.py b/piker/exchange/_ems.py index f594b4ac..465b9d15 100644 --- a/piker/exchange/_ems.py +++ b/piker/exchange/_ems.py @@ -20,13 +20,10 @@ In da suit parlances: "Execution management systems" """ from pprint import pformat import time -from datetime import datetime -from contextlib import asynccontextmanager from dataclasses import dataclass, field from typing import ( AsyncIterator, Dict, Callable, Tuple, ) -import uuid from bidict import bidict import trio @@ -35,8 +32,8 @@ import tractor from .. import data from ..log import get_logger -from ..data._source import Symbol from ..data._normalize import iterticks +from ._paper_engine import PaperBoi, simulate_fills log = get_logger(__name__) @@ -126,293 +123,6 @@ def get_dark_book(broker: str) -> _DarkBook: _DEFAULT_SIZE: float = 1.0 -@dataclass -class PaperBoi: - """Emulates a broker order client providing the same API and - order-event response event stream format but with methods for - triggering desired events based on forward testing engine - requirements. - - """ - broker: str - _to_trade_stream: trio.abc.SendChannel - trade_stream: trio.abc.ReceiveChannel - - # map of paper "live" orders which be used - # to simulate fills based on paper engine settings - _buys: bidict - _sells: bidict - _reqids: bidict - - # init edge case L1 spread - last_ask: Tuple[float, float] = (float('inf'), 0) # price, size - last_bid: Tuple[float, float] = (0, 0) - - async def submit_limit( - self, - oid: str, # XXX: see return value - symbol: str, - price: float, - action: str, - size: float, - ) -> int: - """Place an order and return integer request id provided by client. - - """ - # the trades stream expects events in the form - # {'local_trades': (event_name, msg)} - reqid = str(uuid.uuid4()) - - # TODO: net latency model - # we checkpoint here quickly particulalry - # for dark orders since we want the dark_executed - # to trigger first thus creating a lookup entry - # in the broker trades event processing loop - await trio.sleep(0.05) - - await self._to_trade_stream.send({ - - 'local_trades': ('status', { - - 'time_ns': time.time_ns(), - 'reqid': reqid, - - 'status': 'submitted', - 'broker': self.broker, - # 'cmd': cmd, # original request message - - 'paper_info': { - 'oid': oid, - }, - }), - }) - - # register order internally - self._reqids[reqid] = (oid, symbol, action, price) - - # if we're already a clearing price simulate an immediate fill - if ( - action == 'buy' and (clear_price := self.last_ask[0]) <= price - ) or ( - action == 'sell' and (clear_price := self.last_bid[0]) >= price - ): - await self.fake_fill(clear_price, size, action, reqid, oid) - - else: # register this submissions as a paper live order - - # submit order to book simulation fill loop - if action == 'buy': - orders = self._buys - - elif action == 'sell': - orders = self._sells - - # buys/sells: (symbol -> (price -> order)) - orders.setdefault(symbol, {})[price] = (size, oid, reqid, action) - - return reqid - - async def submit_cancel( - self, - reqid: str, - ) -> None: - - # TODO: fake market simulation effects - # await self._to_trade_stream.send( - oid, symbol, action, price = self._reqids[reqid] - - if action == 'buy': - self._buys[symbol].pop(price) - elif action == 'sell': - self._sells[symbol].pop(price) - - # TODO: net latency model - await trio.sleep(0.05) - - await self._to_trade_stream.send({ - - 'local_trades': ('status', { - - 'time_ns': time.time_ns(), - 'oid': oid, - 'reqid': reqid, - - 'status': 'cancelled', - 'broker': self.broker, - # 'cmd': cmd, # original request message - - 'paper': True, - }), - }) - - async def fake_fill( - self, - price: float, - size: float, - action: str, # one of {'buy', 'sell'} - - reqid: str, - oid: str, - - # determine whether to send a filled status that has zero - # remaining lots to fill - order_complete: bool = True, - remaining: float = 0, - ) -> None: - """Pretend to fill a broker order @ price and size. - - """ - # TODO: net latency model - await trio.sleep(0.05) - - await self._to_trade_stream.send({ - - 'local_trades': ('fill', { - - 'status': 'filled', - 'broker': self.broker, - # converted to float by us in ib backend - 'broker_time': datetime.now().timestamp(), - - 'action': action, - 'size': size, - 'price': price, - 'remaining': 0 if order_complete else remaining, - - # normally filled by real `brokerd` daemon - 'time': time.time_ns(), - 'time_ns': time.time_ns(), # cuz why not - - # fake ids - 'reqid': reqid, - - 'paper_info': { - 'oid': oid, - }, - - # XXX: fields we might not need to emulate? - # execution id from broker - # 'execid': execu.execId, - # 'cmd': cmd, # original request message? - }), - }) - if order_complete: - await self._to_trade_stream.send({ - - 'local_trades': ('status', { - 'reqid': reqid, - 'status': 'filled', - 'broker': self.broker, - 'filled': size, - 'remaining': 0 if order_complete else remaining, - - # converted to float by us in ib backend - 'broker_time': datetime.now().timestamp(), - 'paper_info': { - 'oid': oid, - }, - }), - }) - - -async def simulate_fills( - quote_stream: 'tractor.ReceiveStream', # noqa - client: PaperBoi, -) -> None: - - # TODO: more machinery to better simulate real-world market things: - - # - slippage models, check what quantopian has: - # https://github.com/quantopian/zipline/blob/master/zipline/finance/slippage.py - # * this should help with simulating partial fills in a fast moving mkt - # afaiu - - # - commisions models, also quantopian has em: - # https://github.com/quantopian/zipline/blob/master/zipline/finance/commission.py - - # - network latency models ?? - - # - position tracking: - # https://github.com/quantopian/zipline/blob/master/zipline/finance/ledger.py - - # this stream may eventually contain multiple symbols - async for quotes in quote_stream: - for sym, quote in quotes.items(): - - for tick in iterticks( - quote, - # dark order price filter(s) - types=('ask', 'bid', 'trade', 'last') - ): - # print(tick) - tick_price = tick.get('price') - ttype = tick['type'] - - if ttype in ('ask',): - - client.last_ask = ( - tick_price, - tick.get('size', client.last_ask[1]), - ) - - buys = client._buys.get(sym, {}) - - # iterate book prices descending - for our_bid in reversed(sorted(buys.keys())): - if tick_price < our_bid: - - # retreive order info - (size, oid, reqid, action) = buys.pop(our_bid) - - # clearing price would have filled entirely - await client.fake_fill( - # todo slippage to determine fill price - tick_price, - size, - action, - reqid, - oid, - ) - else: - # prices are interated in sorted order so - # we're done - break - - if ttype in ('bid',): - - client.last_bid = ( - tick_price, - tick.get('size', client.last_bid[1]), - ) - - sells = client._sells.get(sym, {}) - - # iterate book prices ascending - for our_ask in sorted(sells.keys()): - if tick_price > our_ask: - - # retreive order info - (size, oid, reqid, action) = sells.pop(our_ask) - - # clearing price would have filled entirely - await client.fake_fill( - tick_price, - size, - action, - reqid, - oid, - ) - else: - # prices are interated in sorted order so - # we're done - break - - if ttype in ('trade', 'last'): - # TODO: simulate actual book queues and our orders - # place in it, might require full L2 data? - pass - - async def execute_triggers( broker: str, symbol: str, @@ -772,6 +482,8 @@ async def _ems_main( accept normalized trades responses, process and relay to ems client(s) """ + from ._client import send_order_cmds + book = get_dark_book(broker) # get a portal back to the client @@ -913,181 +625,3 @@ async def _ems_main( }) # continue and wait on next order cmd - - -@dataclass -class OrderBook: - """Buy-side (client-side ?) order book ctl and tracking. - - 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, this is mostly for keeping local state to match the EMS - and use received events to trigger graphics updates. - - """ - # mem channels used to relay order requests to the EMS daemon - _to_ems: trio.abc.SendChannel - _from_order_book: trio.abc.ReceiveChannel - - _sent_orders: Dict[str, dict] = field(default_factory=dict) - _ready_to_receive: trio.Event = trio.Event() - - def send( - self, - uuid: str, - symbol: 'Symbol', - price: float, - size: float, - action: str, - exec_mode: str, - ) -> dict: - cmd = { - 'action': action, - 'price': price, - 'size': size, - 'symbol': symbol.key, - 'brokers': symbol.brokers, - 'oid': uuid, - 'exec_mode': exec_mode, # dark or live - } - self._sent_orders[uuid] = cmd - self._to_ems.send_nowait(cmd) - return cmd - - async def modify(self, oid: str, price) -> bool: - ... - - def cancel(self, uuid: str) -> bool: - """Cancel an order (or alert) from the EMS. - - """ - cmd = self._sent_orders[uuid] - msg = { - 'action': 'cancel', - 'oid': uuid, - 'symbol': cmd['symbol'], - } - self._to_ems.send_nowait(msg) - - -_orders: OrderBook = None - - -def get_orders( - emsd_uid: Tuple[str, str] = None -) -> OrderBook: - """" - OrderBook singleton factory per actor. - - """ - if emsd_uid is not None: - # TODO: read in target emsd's active book on startup - pass - - global _orders - - if _orders is None: - # setup local ui event streaming channels for request/resp - # streamging with EMS daemon - _orders = OrderBook(*trio.open_memory_channel(100)) - - return _orders - - -# TODO: make this a ``tractor.msg.pub`` -async def send_order_cmds(): - """Order streaming task: deliver orders transmitted from UI - to downstream consumers. - - This is run in the UI actor (usually the one running Qt but could be - any other client service code). This process 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 relayed to any consumer(s) that called this function using - a ``tractor`` portal. - - This effectively makes order messages look like they're being - "pushed" from the parent to the EMS where local sync code is likely - doing the pushing from some UI. - - """ - book = get_orders() - orders_stream = book._from_order_book - - # signal that ems connection is up and ready - book._ready_to_receive.set() - - async for cmd in orders_stream: - - # send msg over IPC / wire - log.info(f'Send order cmd:\n{pformat(cmd)}') - yield cmd - - -@asynccontextmanager -async def open_ems( - broker: str, - symbol: Symbol, - # task_status: TaskStatus[str] = trio.TASK_STATUS_IGNORED, -) -> None: - """Spawn an EMS daemon and begin sending orders and receiving - alerts. - - - This EMS tries to reduce most broker's terrible order entry apis to - a very simple protocol built on a few easy to grok and/or - "rantsy" premises: - - - most users will prefer "dark mode" where orders are not submitted - to a broker until and execution condition is triggered - (aka client-side "hidden orders") - - - Brokers over-complicate their apis and generally speaking hire - poor designers to create them. We're better off using creating a super - minimal, schema-simple, request-event-stream protocol to unify all the - existing piles of shit (and shocker, it'll probably just end up - looking like a decent crypto exchange's api) - - - all order types can be implemented with client-side limit orders - - - we aren't reinventing a wheel in this case since none of these - brokers are exposing FIX protocol; it is they doing the re-invention. - - - TODO: make some fancy diagrams using mermaid.io - - the possible set of responses from the stream is currently: - - 'dark_submitted', 'broker_submitted' - - 'dark_cancelled', 'broker_cancelled' - - 'dark_executed', 'broker_executed' - - 'broker_filled' - """ - actor = tractor.current_actor() - subactor_name = 'emsd' - - # TODO: add ``maybe_spawn_emsd()`` for this - async with tractor.open_nursery() as n: - - portal = await n.start_actor( - subactor_name, - enable_modules=[__name__], - ) - trades_stream = await portal.run( - _ems_main, - client_actor_name=actor.name, - broker=broker, - symbol=symbol.key, - - ) - - # wait for service to connect back to us signalling - # ready for order commands - book = get_orders() - - with trio.fail_after(10): - await book._ready_to_receive.wait() - - yield book, trades_stream diff --git a/piker/exchange/_paper_engine.py b/piker/exchange/_paper_engine.py new file mode 100644 index 00000000..35f24f98 --- /dev/null +++ b/piker/exchange/_paper_engine.py @@ -0,0 +1,317 @@ +# 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 . + +""" +Fake trading for forward testing. + +""" +from datetime import datetime +import time +from typing import Tuple +import uuid + +from bidict import bidict +import trio +from dataclasses import dataclass + +from ..data._normalize import iterticks + + +@dataclass +class PaperBoi: + """Emulates a broker order client providing the same API and + order-event response event stream format but with methods for + triggering desired events based on forward testing engine + requirements. + + """ + broker: str + _to_trade_stream: trio.abc.SendChannel + trade_stream: trio.abc.ReceiveChannel + + # map of paper "live" orders which be used + # to simulate fills based on paper engine settings + _buys: bidict + _sells: bidict + _reqids: bidict + + # init edge case L1 spread + last_ask: Tuple[float, float] = (float('inf'), 0) # price, size + last_bid: Tuple[float, float] = (0, 0) + + async def submit_limit( + self, + oid: str, # XXX: see return value + symbol: str, + price: float, + action: str, + size: float, + ) -> int: + """Place an order and return integer request id provided by client. + + """ + # the trades stream expects events in the form + # {'local_trades': (event_name, msg)} + reqid = str(uuid.uuid4()) + + # TODO: net latency model + # we checkpoint here quickly particulalry + # for dark orders since we want the dark_executed + # to trigger first thus creating a lookup entry + # in the broker trades event processing loop + await trio.sleep(0.05) + + await self._to_trade_stream.send({ + + 'local_trades': ('status', { + + 'time_ns': time.time_ns(), + 'reqid': reqid, + + 'status': 'submitted', + 'broker': self.broker, + # 'cmd': cmd, # original request message + + 'paper_info': { + 'oid': oid, + }, + }), + }) + + # register order internally + self._reqids[reqid] = (oid, symbol, action, price) + + # if we're already a clearing price simulate an immediate fill + if ( + action == 'buy' and (clear_price := self.last_ask[0]) <= price + ) or ( + action == 'sell' and (clear_price := self.last_bid[0]) >= price + ): + await self.fake_fill(clear_price, size, action, reqid, oid) + + else: # register this submissions as a paper live order + + # submit order to book simulation fill loop + if action == 'buy': + orders = self._buys + + elif action == 'sell': + orders = self._sells + + # buys/sells: (symbol -> (price -> order)) + orders.setdefault(symbol, {})[price] = (size, oid, reqid, action) + + return reqid + + async def submit_cancel( + self, + reqid: str, + ) -> None: + + # TODO: fake market simulation effects + # await self._to_trade_stream.send( + oid, symbol, action, price = self._reqids[reqid] + + if action == 'buy': + self._buys[symbol].pop(price) + elif action == 'sell': + self._sells[symbol].pop(price) + + # TODO: net latency model + await trio.sleep(0.05) + + await self._to_trade_stream.send({ + + 'local_trades': ('status', { + + 'time_ns': time.time_ns(), + 'oid': oid, + 'reqid': reqid, + + 'status': 'cancelled', + 'broker': self.broker, + # 'cmd': cmd, # original request message + + 'paper': True, + }), + }) + + async def fake_fill( + self, + price: float, + size: float, + action: str, # one of {'buy', 'sell'} + + reqid: str, + oid: str, + + # determine whether to send a filled status that has zero + # remaining lots to fill + order_complete: bool = True, + remaining: float = 0, + ) -> None: + """Pretend to fill a broker order @ price and size. + + """ + # TODO: net latency model + await trio.sleep(0.05) + + await self._to_trade_stream.send({ + + 'local_trades': ('fill', { + + 'status': 'filled', + 'broker': self.broker, + # converted to float by us in ib backend + 'broker_time': datetime.now().timestamp(), + + 'action': action, + 'size': size, + 'price': price, + 'remaining': 0 if order_complete else remaining, + + # normally filled by real `brokerd` daemon + 'time': time.time_ns(), + 'time_ns': time.time_ns(), # cuz why not + + # fake ids + 'reqid': reqid, + + 'paper_info': { + 'oid': oid, + }, + + # XXX: fields we might not need to emulate? + # execution id from broker + # 'execid': execu.execId, + # 'cmd': cmd, # original request message? + }), + }) + if order_complete: + await self._to_trade_stream.send({ + + 'local_trades': ('status', { + 'reqid': reqid, + 'status': 'filled', + 'broker': self.broker, + 'filled': size, + 'remaining': 0 if order_complete else remaining, + + # converted to float by us in ib backend + 'broker_time': datetime.now().timestamp(), + 'paper_info': { + 'oid': oid, + }, + }), + }) + + +async def simulate_fills( + quote_stream: 'tractor.ReceiveStream', # noqa + client: PaperBoi, +) -> None: + + # TODO: more machinery to better simulate real-world market things: + + # - slippage models, check what quantopian has: + # https://github.com/quantopian/zipline/blob/master/zipline/finance/slippage.py + # * this should help with simulating partial fills in a fast moving mkt + # afaiu + + # - commisions models, also quantopian has em: + # https://github.com/quantopian/zipline/blob/master/zipline/finance/commission.py + + # - network latency models ?? + + # - position tracking: + # https://github.com/quantopian/zipline/blob/master/zipline/finance/ledger.py + + # this stream may eventually contain multiple symbols + async for quotes in quote_stream: + for sym, quote in quotes.items(): + + for tick in iterticks( + quote, + # dark order price filter(s) + types=('ask', 'bid', 'trade', 'last') + ): + # print(tick) + tick_price = tick.get('price') + ttype = tick['type'] + + if ttype in ('ask',): + + client.last_ask = ( + tick_price, + tick.get('size', client.last_ask[1]), + ) + + buys = client._buys.get(sym, {}) + + # iterate book prices descending + for our_bid in reversed(sorted(buys.keys())): + if tick_price < our_bid: + + # retreive order info + (size, oid, reqid, action) = buys.pop(our_bid) + + # clearing price would have filled entirely + await client.fake_fill( + # todo slippage to determine fill price + tick_price, + size, + action, + reqid, + oid, + ) + else: + # prices are interated in sorted order so + # we're done + break + + if ttype in ('bid',): + + client.last_bid = ( + tick_price, + tick.get('size', client.last_bid[1]), + ) + + sells = client._sells.get(sym, {}) + + # iterate book prices ascending + for our_ask in sorted(sells.keys()): + if tick_price > our_ask: + + # retreive order info + (size, oid, reqid, action) = sells.pop(our_ask) + + # clearing price would have filled entirely + await client.fake_fill( + tick_price, + size, + action, + reqid, + oid, + ) + else: + # prices are interated in sorted order so + # we're done + break + + if ttype in ('trade', 'last'): + # TODO: simulate actual book queues and our orders + # place in it, might require full L2 data? + pass diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index f11ad0f0..aed8c991 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -62,7 +62,7 @@ from ..log import get_logger from ._exec import run_qtractor, current_screen from ._interaction import ChartView, open_order_mode from .. import fsp -from ..exchange._ems import open_ems +from ..exchange._client import open_ems log = get_logger(__name__) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 4f6ee19e..e497a677 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -33,7 +33,7 @@ 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._ems import OrderBook +from ..exchange._client import OrderBook from ..data._source import Symbol diff --git a/piker/ui/cli.py b/piker/ui/cli.py index 78100523..fcb9b854 100644 --- a/piker/ui/cli.py +++ b/piker/ui/cli.py @@ -16,6 +16,7 @@ """ Console interface to UI components. + """ from functools import partial import os @@ -149,6 +150,8 @@ def chart(config, symbol, profile): tractor_kwargs={ 'debug_mode': True, 'loglevel': tractorloglevel, - 'enable_modules': ['piker.exchange._ems'], + 'enable_modules': [ + 'piker.exchange._client' + ], }, ) From 9f9b5480a6c24ec05ccdc17f65ee33ee653def16 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 24 Feb 2021 12:05:09 -0500 Subject: [PATCH 087/139] More explicit private var name --- piker/ui/_label.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/piker/ui/_label.py b/piker/ui/_label.py index 8143871e..5297fa20 100644 --- a/piker/ui/_label.py +++ b/piker/ui/_label.py @@ -103,7 +103,7 @@ class Label: self.fields = fields self.orient_v = 'bottom' - self._af = self.txt.pos().x + self._anchor_func = self.txt.pos().x # not sure if this makes a diff self.txt.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache) @@ -140,14 +140,14 @@ class Label: func: Callable, ) -> None: assert isinstance(func(), float) - self._af = func + self._anchor_func = func def set_view_y( self, y: float, ) -> None: - scene_x = self._af() or self.txt.pos().x() + scene_x = self._anchor_func() or self.txt.pos().x() # get new (inside the) view coordinates / position self._view_xy = QPointF( From 327129db37c12cbb209dd04c3e4adf36d748f283 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 25 Feb 2021 18:44:40 -0500 Subject: [PATCH 088/139] Drop paper limit submissions for alerts --- piker/exchange/_ems.py | 5 ----- piker/exchange/_paper_engine.py | 7 ++++++- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/piker/exchange/_ems.py b/piker/exchange/_ems.py index 465b9d15..a3938b06 100644 --- a/piker/exchange/_ems.py +++ b/piker/exchange/_ems.py @@ -157,11 +157,6 @@ async def execute_triggers( price = tick.get('price') ttype = tick['type'] - # lel, fuck you ib - # if price < 0: - # log.error(f'!!?!?!VOLUME TICK {tick}!?!?') - # continue - # update to keep new cmds informed book.lasts[(broker, symbol)] = price diff --git a/piker/exchange/_paper_engine.py b/piker/exchange/_paper_engine.py index 35f24f98..d218ffb7 100644 --- a/piker/exchange/_paper_engine.py +++ b/piker/exchange/_paper_engine.py @@ -67,6 +67,10 @@ class PaperBoi: # {'local_trades': (event_name, msg)} reqid = str(uuid.uuid4()) + if action == 'alert': + # bypass all fill simulation + return reqid + # TODO: net latency model # we checkpoint here quickly particulalry # for dark orders since we want the dark_executed @@ -102,7 +106,8 @@ class PaperBoi: ): await self.fake_fill(clear_price, size, action, reqid, oid) - else: # register this submissions as a paper live order + else: + # register this submissions as a paper live order # submit order to book simulation fill loop if action == 'buy': From a1691cf1c5fe9f5fc7248bcc2a8dd87e295cf80e Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 1 Mar 2021 11:28:44 -0500 Subject: [PATCH 089/139] Keep to one EMS daemon if possible --- piker/exchange/_client.py | 44 +++++++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/piker/exchange/_client.py b/piker/exchange/_client.py index 4bf84828..bebbf039 100644 --- a/piker/exchange/_client.py +++ b/piker/exchange/_client.py @@ -146,6 +146,31 @@ async def send_order_cmds(): yield cmd +@asynccontextmanager +async def maybe_open_emsd( +) -> 'StreamReceiveChannel': # noqa + + async with tractor.find_actor('emsd') as portal: + if portal is not None: + yield portal + + else: + # we gotta spawn it + log.info("Spawning EMS daemon") + + # TODO: add ``maybe_spawn_emsd()`` for this + async with tractor.open_nursery() as n: + + portal = await n.start_actor( + 'emsd', + enable_modules=[ + 'piker.exchange._ems', + ], + ) + + yield portal + + @asynccontextmanager async def open_ems( broker: str, @@ -182,18 +207,16 @@ async def open_ems( - 'dark_cancelled', 'broker_cancelled' - 'dark_executed', 'broker_executed' - 'broker_filled' + """ actor = tractor.current_actor() - # TODO: add ``maybe_spawn_emsd()`` for this - async with tractor.open_nursery() as n: + # wait for service to connect back to us signalling + # ready for order commands + book = get_orders() + + async with maybe_open_emsd() as portal: - portal = await n.start_actor( - 'emsd', - enable_modules=[ - 'piker.exchange._ems', - ], - ) trades_stream = await portal.run( _ems_main, client_actor_name=actor.name, @@ -201,11 +224,6 @@ async def open_ems( symbol=symbol.key, ) - - # wait for service to connect back to us signalling - # ready for order commands - book = get_orders() - with trio.fail_after(10): await book._ready_to_receive.wait() From 72c4a4366bd07858a0909e74cf717c03d7065fbf Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 1 Mar 2021 12:01:48 -0500 Subject: [PATCH 090/139] Tag TWS trade events --- piker/brokers/ib.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/piker/brokers/ib.py b/piker/brokers/ib.py index 1577f0f6..3ed40876 100644 --- a/piker/brokers/ib.py +++ b/piker/brokers/ib.py @@ -908,11 +908,10 @@ async def stream_quotes( # TODO: support multiple subscriptions sym = symbols[0] - async with trio.open_nursery() as n: - contract, first_ticker, details = await _trio_run_client_method( - method='get_quote', - symbol=sym, - ) + contract, first_ticker, details = await _trio_run_client_method( + method='get_quote', + symbol=sym, + ) stream = await _trio_run_client_method( method='stream_ticker', @@ -986,7 +985,6 @@ async def stream_quotes( # for "traditional" assets, volume is normally discreet, not a float syminfo['lot_tick_size'] = 0.0 - # TODO: for loop through all symbols passed in init_msgs = { # pass back token, and bool, signalling if we're the writer @@ -1202,7 +1200,8 @@ async def stream_trades( # supposedly IB server fill time 'broker_time': execu.time, # converted to float by us - 'time': fill.time, # ns from main TCP handler by us inside ``ib_insync`` override + # ns from main TCP handler by us inside ``ib_insync`` override + 'time': fill.time, 'time_ns': time.time_ns(), # cuz why not 'action': action_map[execu.side], 'size': execu.shares, @@ -1220,7 +1219,13 @@ async def stream_trades( if msg['reqid'] == -1: log.error(pformat(msg)) - # don't forward, it's pointless.. - continue + # don't forward, it's pointless.. + continue + + if msg['reqid'] < -1: + # it's a trade event generated by TWS usage. + log.warning(f"TWS triggered trade:\n{pformat(msg)}") + + msg['reqid'] = 'tws-' + msg['reqid'] yield {'local_trades': (event_name, msg)} From 8997b6029bba08fb591afbfaa4557c7781bb43cd Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 1 Mar 2021 12:02:07 -0500 Subject: [PATCH 091/139] Drop cruft --- piker/brokers/kraken.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/piker/brokers/kraken.py b/piker/brokers/kraken.py index 425963e9..5d8763c3 100644 --- a/piker/brokers/kraken.py +++ b/piker/brokers/kraken.py @@ -417,8 +417,6 @@ async def open_autorecon_ws(url): """ async with AsyncExitStack() as stack: ws = AutoReconWs(url, stack) - # async with trio_websocket.open_websocket_url(url) as ws: - # await tractor.breakpoint() await ws._connect() try: From a1a1dec88511baa0f7acca54cdb7a65dab88d536 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 1 Mar 2021 14:48:08 -0500 Subject: [PATCH 092/139] Move L1 labels into lone module --- piker/ui/_chart.py | 6 +- piker/ui/_graphics/_lines.py | 264 +------------------------------ piker/ui/_l1.py | 291 +++++++++++++++++++++++++++++++++++ 3 files changed, 295 insertions(+), 266 deletions(-) create mode 100644 piker/ui/_l1.py diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index aed8c991..7228ef36 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -41,8 +41,8 @@ from ._graphics._cursor import ( from ._graphics._lines import ( level_line, order_line, - L1Labels, ) +from ._l1 import L1Labels from ._graphics._ohlc import BarItems from ._graphics._curve import FastAppendCurve from ._style import ( @@ -1105,7 +1105,7 @@ async def _async_main( ): # show line label once order is live - line = order_mode.on_submit(oid) + order_mode.on_submit(oid) # resp to 'cancel' request or error condition # for action request @@ -1132,7 +1132,7 @@ async def _async_main( price=msg['trigger_price'], arrow_index=get_index(time.time()) ) - line = await order_mode.on_exec(oid, msg) + await order_mode.on_exec(oid, msg) # response to completed 'action' request for buy/sell elif resp in ( diff --git a/piker/ui/_graphics/_lines.py b/piker/ui/_graphics/_lines.py index 04795ede..9dd1d500 100644 --- a/piker/ui/_graphics/_lines.py +++ b/piker/ui/_graphics/_lines.py @@ -22,274 +22,12 @@ from typing import Tuple, Optional, List import pyqtgraph as pg from PyQt5 import QtCore, QtGui -from PyQt5.QtCore import QPointF from .._label import Label, vbr_left, right_axis from .._style import ( hcolor, _down_2_font_inches_we_like, ) -from .._axes import YAxisLabel - - -class LevelLabel(YAxisLabel): - """Y-axis (vertically) oriented, horizontal label that sticks to - where it's placed despite chart resizing and supports displaying - multiple fields. - - - TODO: replace the rectangle-text part with our new ``Label`` type. - - """ - _x_margin = 0 - _y_margin = 0 - - # adjustment "further away from" anchor point - _x_offset = 9 - _y_offset = 0 - - # fields to be displayed in the label string - _fields = { - 'level': 0, - 'level_digits': 2, - } - # default label template is just a y-level with so much precision - _fmt_str = '{level:,.{level_digits}f}' - - def __init__( - self, - chart, - parent, - - color: str = 'bracket', - - orient_v: str = 'bottom', - orient_h: str = 'left', - - opacity: float = 0, - - # makes order line labels offset from their parent axis - # such that they don't collide with the L1/L2 lines/prices - # that are displayed on the axis - adjust_to_l1: bool = False, - - **axis_label_kwargs, - ) -> None: - - super().__init__( - chart, - parent=parent, - use_arrow=False, - opacity=opacity, - **axis_label_kwargs - ) - - # TODO: this is kinda cludgy - self._hcolor: pg.Pen = None - self.color: str = color - - # orientation around axis options - self._orient_v = orient_v - self._orient_h = orient_h - - self._adjust_to_l1 = adjust_to_l1 - - self._v_shift = { - 'top': -1., - 'bottom': 0., - 'middle': 1 / 2. - }[orient_v] - - self._h_shift = { - 'left': -1., - 'right': 0. - }[orient_h] - - self.fields = self._fields.copy() - # ensure default format fields are in correct - self.set_fmt_str(self._fmt_str, self.fields) - - @property - def color(self): - return self._hcolor - - @color.setter - def color(self, color: str) -> None: - self._hcolor = color - self._pen = self.pen = pg.mkPen(hcolor(color)) - - def update_on_resize(self, vr, r): - """Tiis is a ``.sigRangeChanged()`` handler. - - """ - self.update_fields(self.fields) - - def update_fields( - self, - fields: dict = None, - ) -> None: - """Update the label's text contents **and** position from - a view box coordinate datum. - - """ - self.fields.update(fields) - level = self.fields['level'] - - # map "level" to local coords - abs_xy = self._chart.mapFromView(QPointF(0, level)) - - self.update_label( - abs_xy, - self.fields, - ) - - def update_label( - self, - abs_pos: QPointF, # scene coords - fields: dict, - ) -> None: - - # write contents, type specific - h, w = self.set_label_str(fields) - - if self._adjust_to_l1: - self._x_offset = _max_l1_line_len - - self.setPos(QPointF( - self._h_shift * (w + self._x_offset), - abs_pos.y() + self._v_shift * h - )) - - def set_fmt_str( - self, - fmt_str: str, - fields: dict, - ) -> (str, str): - # test that new fmt str can be rendered - self._fmt_str = fmt_str - self.set_label_str(fields) - self.fields.update(fields) - return fmt_str, self.label_str - - def set_label_str( - self, - fields: dict, - ): - # use space as e3 delim - self.label_str = self._fmt_str.format(**fields).replace(',', ' ') - - br = self.boundingRect() - h, w = br.height(), br.width() - return h, w - - def size_hint(self) -> Tuple[None, None]: - return None, None - - def draw( - self, - p: QtGui.QPainter, - rect: QtCore.QRectF - ) -> None: - p.setPen(self._pen) - - rect = self.rect - - if self._orient_v == 'bottom': - lp, rp = rect.topLeft(), rect.topRight() - # p.drawLine(rect.topLeft(), rect.topRight()) - - elif self._orient_v == 'top': - lp, rp = rect.bottomLeft(), rect.bottomRight() - - 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.pen - self.update() - - -# global for now but probably should be -# attached to chart instance? -_max_l1_line_len: float = 0 - - -class L1Label(LevelLabel): - - text_flags = ( - QtCore.Qt.TextDontClip - | QtCore.Qt.AlignLeft - ) - - def set_label_str( - self, - fields: dict, - ) -> None: - """Make sure the max L1 line module var is kept up to date. - - """ - h, w = super().set_label_str(fields) - - # Set a global "max L1 label length" so we can look it up - # on order lines and adjust their labels not to overlap with it. - global _max_l1_line_len - _max_l1_line_len = max(_max_l1_line_len, w) - - return h, w - - -class L1Labels: - """Level 1 bid ask labels for dynamic update on price-axis. - - """ - def __init__( - self, - chart: 'ChartPlotWidget', # noqa - digits: int = 2, - size_digits: int = 3, - font_size_inches: float = _down_2_font_inches_we_like, - ) -> None: - - self.chart = chart - - raxis = chart.getAxis('right') - kwargs = { - 'chart': chart, - 'parent': raxis, - - 'opacity': 1, - 'font_size_inches': font_size_inches, - 'fg_color': chart.pen_color, - 'bg_color': chart.view_color, - } - - fmt_str = ( - '{size:.{size_digits}f} x ' - '{level:,.{level_digits}f}' - ) - fields = { - 'level': 0, - 'level_digits': digits, - 'size': 0, - 'size_digits': size_digits, - } - - bid = self.bid_label = L1Label( - orient_v='bottom', - **kwargs, - ) - bid.set_fmt_str(fmt_str=fmt_str, fields=fields) - bid.show() - - ask = self.ask_label = L1Label( - orient_v='top', - **kwargs, - ) - ask.set_fmt_str(fmt_str=fmt_str, fields=fields) - ask.show() # TODO: probably worth investigating if we can @@ -391,7 +129,7 @@ class LevelLine(pg.InfiniteLine): bg_color: str = None, **label_kwargs, - ) -> LevelLabel: + ) -> Label: """Add a ``LevelLabel`` anchored at one of the line endpoints in view. """ diff --git a/piker/ui/_l1.py b/piker/ui/_l1.py new file mode 100644 index 00000000..90cc8aba --- /dev/null +++ b/piker/ui/_l1.py @@ -0,0 +1,291 @@ +# 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 . + +""" +Double auction top-of-book (L1) graphics. + +""" +from typing import Tuple + +import pyqtgraph as pg +from PyQt5 import QtCore, QtGui +from PyQt5.QtCore import QPointF + +from ._axes import YAxisLabel +from ._style import ( + hcolor, + _down_2_font_inches_we_like, +) + + +class LevelLabel(YAxisLabel): + """Y-axis (vertically) oriented, horizontal label that sticks to + where it's placed despite chart resizing and supports displaying + multiple fields. + + + TODO: replace the rectangle-text part with our new ``Label`` type. + + """ + _x_margin = 0 + _y_margin = 0 + + # adjustment "further away from" anchor point + _x_offset = 9 + _y_offset = 0 + + # fields to be displayed in the label string + _fields = { + 'level': 0, + 'level_digits': 2, + } + # default label template is just a y-level with so much precision + _fmt_str = '{level:,.{level_digits}f}' + + def __init__( + self, + chart, + parent, + + color: str = 'bracket', + + orient_v: str = 'bottom', + orient_h: str = 'left', + + opacity: float = 0, + + # makes order line labels offset from their parent axis + # such that they don't collide with the L1/L2 lines/prices + # that are displayed on the axis + adjust_to_l1: bool = False, + + **axis_label_kwargs, + ) -> None: + + super().__init__( + chart, + parent=parent, + use_arrow=False, + opacity=opacity, + **axis_label_kwargs + ) + + # TODO: this is kinda cludgy + self._hcolor: pg.Pen = None + self.color: str = color + + # orientation around axis options + self._orient_v = orient_v + self._orient_h = orient_h + + self._adjust_to_l1 = adjust_to_l1 + + self._v_shift = { + 'top': -1., + 'bottom': 0., + 'middle': 1 / 2. + }[orient_v] + + self._h_shift = { + 'left': -1., + 'right': 0. + }[orient_h] + + self.fields = self._fields.copy() + # ensure default format fields are in correct + self.set_fmt_str(self._fmt_str, self.fields) + + @property + def color(self): + return self._hcolor + + @color.setter + def color(self, color: str) -> None: + self._hcolor = color + self._pen = self.pen = pg.mkPen(hcolor(color)) + + def update_on_resize(self, vr, r): + """Tiis is a ``.sigRangeChanged()`` handler. + + """ + self.update_fields(self.fields) + + def update_fields( + self, + fields: dict = None, + ) -> None: + """Update the label's text contents **and** position from + a view box coordinate datum. + + """ + self.fields.update(fields) + level = self.fields['level'] + + # map "level" to local coords + abs_xy = self._chart.mapFromView(QPointF(0, level)) + + self.update_label( + abs_xy, + self.fields, + ) + + def update_label( + self, + abs_pos: QPointF, # scene coords + fields: dict, + ) -> None: + + # write contents, type specific + h, w = self.set_label_str(fields) + + if self._adjust_to_l1: + self._x_offset = _max_l1_line_len + + self.setPos(QPointF( + self._h_shift * (w + self._x_offset), + abs_pos.y() + self._v_shift * h + )) + + def set_fmt_str( + self, + fmt_str: str, + fields: dict, + ) -> (str, str): + # test that new fmt str can be rendered + self._fmt_str = fmt_str + self.set_label_str(fields) + self.fields.update(fields) + return fmt_str, self.label_str + + def set_label_str( + self, + fields: dict, + ): + # use space as e3 delim + self.label_str = self._fmt_str.format(**fields).replace(',', ' ') + + br = self.boundingRect() + h, w = br.height(), br.width() + return h, w + + def size_hint(self) -> Tuple[None, None]: + return None, None + + def draw( + self, + p: QtGui.QPainter, + rect: QtCore.QRectF + ) -> None: + p.setPen(self._pen) + + rect = self.rect + + if self._orient_v == 'bottom': + lp, rp = rect.topLeft(), rect.topRight() + # p.drawLine(rect.topLeft(), rect.topRight()) + + elif self._orient_v == 'top': + lp, rp = rect.bottomLeft(), rect.bottomRight() + + 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.pen + self.update() + + +# global for now but probably should be +# attached to chart instance? +_max_l1_line_len: float = 0 + + +class L1Label(LevelLabel): + + text_flags = ( + QtCore.Qt.TextDontClip + | QtCore.Qt.AlignLeft + ) + + def set_label_str( + self, + fields: dict, + ) -> None: + """Make sure the max L1 line module var is kept up to date. + + """ + h, w = super().set_label_str(fields) + + # Set a global "max L1 label length" so we can look it up + # on order lines and adjust their labels not to overlap with it. + global _max_l1_line_len + _max_l1_line_len = max(_max_l1_line_len, w) + + return h, w + + +class L1Labels: + """Level 1 bid ask labels for dynamic update on price-axis. + + """ + def __init__( + self, + chart: 'ChartPlotWidget', # noqa + digits: int = 2, + size_digits: int = 3, + font_size_inches: float = _down_2_font_inches_we_like, + ) -> None: + + self.chart = chart + + raxis = chart.getAxis('right') + kwargs = { + 'chart': chart, + 'parent': raxis, + + 'opacity': 1, + 'font_size_inches': font_size_inches, + 'fg_color': chart.pen_color, + 'bg_color': chart.view_color, + } + + fmt_str = ( + '{size:.{size_digits}f} x ' + '{level:,.{level_digits}f}' + ) + fields = { + 'level': 0, + 'level_digits': digits, + 'size': 0, + 'size_digits': size_digits, + } + + bid = self.bid_label = L1Label( + orient_v='bottom', + **kwargs, + ) + bid.set_fmt_str(fmt_str=fmt_str, fields=fields) + bid.show() + + ask = self.ask_label = L1Label( + orient_v='top', + **kwargs, + ) + ask.set_fmt_str(fmt_str=fmt_str, fields=fields) + ask.show() From 2cabe1831cd7b63064783679384d4a034f2645fc Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 6 Mar 2021 16:33:56 -0500 Subject: [PATCH 093/139] Attempt to handle existing order updates with ib backend --- piker/brokers/ib.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/piker/brokers/ib.py b/piker/brokers/ib.py index 3ed40876..aa07f9ef 100644 --- a/piker/brokers/ib.py +++ b/piker/brokers/ib.py @@ -440,11 +440,19 @@ class Client: # async to be consistent for the client proxy, and cuz why not. async def submit_limit( self, - oid: str, # XXX: see return value + # ignored since ib doesn't support defining your + # own order id + oid: str, symbol: str, price: float, action: str, size: int, + + # XXX: by default 0 tells ``ib_insync`` methods that there is no + # existing order so ask the client to create a new one (which + # it seems to do by allocating an int counter - collision + # prone..) + brid: int = None, ) -> int: """Place an order and return integer request id provided by client. @@ -460,7 +468,7 @@ class Client: trade = self.ib.placeOrder( contract, Order( - # orderId=oid, # stupid api devs.. + orderId=brid or 0, # stupid api devs.. action=action.upper(), # BUY/SELL orderType='LMT', lmtPrice=price, @@ -1226,6 +1234,9 @@ async def stream_trades( # it's a trade event generated by TWS usage. log.warning(f"TWS triggered trade:\n{pformat(msg)}") - msg['reqid'] = 'tws-' + msg['reqid'] + msg['reqid'] = 'tws-' + str(-1 * msg['reqid']) + + yield {'remote_trades': (event_name, msg)} + continue yield {'local_trades': (event_name, msg)} From a43ab1b983c43cbab4b6bf690c80a8e6da29d22f Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 7 Mar 2021 10:56:09 -0500 Subject: [PATCH 094/139] Add order update method to client --- piker/exchange/_client.py | 38 +++++++++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/piker/exchange/_client.py b/piker/exchange/_client.py index bebbf039..0fa23e60 100644 --- a/piker/exchange/_client.py +++ b/piker/exchange/_client.py @@ -19,21 +19,32 @@ Orders and execution client API. """ from contextlib import asynccontextmanager -from typing import Dict, Tuple +from typing import Dict, Tuple, List from pprint import pformat from dataclasses import dataclass, field import trio import tractor +# import msgspec from ..data._source import Symbol from ..log import get_logger -from ._ems import _ems_main +from ._ems import _emsd_main log = get_logger(__name__) +# class Order(msgspec.Struct): +# action: str +# price: float +# size: float +# symbol: str +# brokers: List[str] +# oid: str +# exec_mode: str + + @dataclass class OrderBook: """Buy-side (client-side ?) order book ctl and tracking. @@ -57,7 +68,8 @@ class OrderBook: def send( self, uuid: str, - symbol: 'Symbol', + symbol: str, + brokers: List[str], price: float, size: float, action: str, @@ -67,8 +79,8 @@ class OrderBook: 'action': action, 'price': price, 'size': size, - 'symbol': symbol.key, - 'brokers': symbol.brokers, + 'symbol': symbol, + 'brokers': brokers, 'oid': uuid, 'exec_mode': exec_mode, # dark or live } @@ -76,8 +88,16 @@ class OrderBook: self._to_ems.send_nowait(cmd) return cmd - async def modify(self, oid: str, price) -> bool: - ... + def update( + self, + uuid: str, + **data: dict, + ) -> dict: + cmd = self._sent_orders[uuid] + cmd.update(data) + self._sent_orders[uuid] = cmd + self._to_ems.send_nowait(cmd) + return cmd def cancel(self, uuid: str) -> bool: """Cancel an order (or alert) from the EMS. @@ -111,7 +131,7 @@ def get_orders( if _orders is None: # setup local ui event streaming channels for request/resp # streamging with EMS daemon - _orders = OrderBook(*trio.open_memory_channel(100)) + _orders = OrderBook(*trio.open_memory_channel(1)) return _orders @@ -218,7 +238,7 @@ async def open_ems( async with maybe_open_emsd() as portal: trades_stream = await portal.run( - _ems_main, + _emsd_main, client_actor_name=actor.name, broker=broker, symbol=symbol.key, From 919ecab732a74be0ea15352c082472243e841e89 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 7 Mar 2021 13:12:39 -0500 Subject: [PATCH 095/139] Support order modification in ems request loop --- piker/exchange/_ems.py | 71 ++++++++++++++++++++++++++++-------------- 1 file changed, 47 insertions(+), 24 deletions(-) diff --git a/piker/exchange/_ems.py b/piker/exchange/_ems.py index a3938b06..5ebf1fa3 100644 --- a/piker/exchange/_ems.py +++ b/piker/exchange/_ems.py @@ -138,6 +138,7 @@ async def execute_triggers( """ # this stream may eventually contain multiple symbols + # XXX: optimize this for speed! async for quotes in stream: # TODO: numba all this! @@ -183,6 +184,14 @@ async def execute_triggers( reqid = await client.submit_limit( oid=oid, + + # this is a brand new order request for the + # underlying broker so we set out "broker request + # id" (brid) as nothing so that the broker + # client knows that we aren't trying to modify + # an existing order. + brid=None, + symbol=sym, action=cmd['action'], price=submit_price, @@ -275,11 +284,6 @@ async def exec_loop( # return control to parent task task_status.started((first_quote, feed, client)) - ############################## - # begin price actions sequence - # XXX: optimize this for speed - ############################## - # shield this field so the remote brokerd does not get cancelled stream = feed.stream with stream.shield(): @@ -346,6 +350,7 @@ async def process_broker_trades( async for event in trades_stream: name, msg = event['local_trades'] + log.info(f'Received broker trade event:\n{pformat(msg)}') # Get the broker (order) request id, this **must** be normalized @@ -413,7 +418,6 @@ async def process_broker_trades( status = msg['status'].lower() if status == 'filled': - # await tractor.breakpoint() # conditional execution is fully complete, no more # fills for the noted order @@ -445,7 +449,7 @@ async def process_broker_trades( @tractor.stream -async def _ems_main( +async def _emsd_main( ctx: tractor.Context, client_actor_name: str, broker: str, @@ -467,7 +471,7 @@ async def _ems_main( streamed back up to the original calling task in the same client. The task tree is: - - ``_ems_main()``: + - ``_emsd_main()``: accepts order cmds, registers execs with exec loop - ``exec_loop()``: @@ -479,7 +483,7 @@ async def _ems_main( """ from ._client import send_order_cmds - book = get_dark_book(broker) + dark_book = get_dark_book(broker) # get a portal back to the client async with tractor.wait_for_actor(client_actor_name) as portal: @@ -502,7 +506,7 @@ async def _ems_main( process_broker_trades, ctx, feed, - book, + dark_book, ) # connect back to the calling actor (the one that is @@ -516,12 +520,13 @@ async def _ems_main( action = cmd['action'] oid = cmd['oid'] + brid = dark_book._broker2ems_ids.inverse.get(oid) + # TODO: can't wait for this stuff to land in 3.10 # https://www.python.org/dev/peps/pep-0636/#going-to-the-cloud-mappings if action in ('cancel',): # check for live-broker order - brid = book._broker2ems_ids.inverse.get(oid) if brid: log.info("Submitting cancel for live order") await client.submit_cancel(reqid=brid) @@ -529,7 +534,7 @@ async def _ems_main( # check for EMS active exec else: try: - book.orders[symbol].pop(oid, None) + dark_book.orders[symbol].pop(oid, None) await ctx.send_yield({ 'resp': 'dark_cancelled', @@ -547,30 +552,43 @@ async def _ems_main( exec_mode = cmd.get('exec_mode', _mode) broker = brokers[0] - last = book.lasts[(broker, sym)] + last = dark_book.lasts[(broker, sym)] if exec_mode == 'live' and action in ('buy', 'sell',): # register broker id for ems id order_id = await client.submit_limit( - oid=oid, # no ib support for this + + oid=oid, # no ib support for oids... + + # if this is None, creates a new order + # otherwise will modify any existing one + brid=brid, + symbol=sym, action=action, price=trigger_price, size=size, ) - book._broker2ems_ids[order_id] = oid + + if brid: + assert dark_book._broker2ems_ids[brid] == oid + + # if we already had a broker order id then + # this is likely an order update commmand. + log.info(f"Modifying order: {brid}") + + else: + dark_book._broker2ems_ids[order_id] = oid # XXX: the trades data broker response loop # (``process_broker_trades()`` above) will # handle sending the ems side acks back to # the cmd sender from here - elif exec_mode in ('dark', 'paper') or action in ('alert'): - - # TODO: if the predicate resolves immediately send the - # execution to the broker asap? Or no? - + elif exec_mode in ('dark', 'paper') or ( + action in ('alert') + ): # submit order to local EMS # Auto-gen scanner predicate: @@ -581,7 +599,7 @@ async def _ems_main( # the user choose the predicate operator. pred = mk_check(trigger_price, last) - mt = feed.symbols[sym].tick_size + min_tick = feed.symbols[sym].tick_size if action == 'buy': tickfilter = ('ask', 'last', 'trade') @@ -590,12 +608,12 @@ async def _ems_main( # TODO: we probably need to scale this based # on some near term historical spread # measure? - abs_diff_away = 3 * mt + abs_diff_away = 3 * min_tick elif action == 'sell': tickfilter = ('bid', 'last', 'trade') percent_away = -0.005 - abs_diff_away = -3 * mt + abs_diff_away = -3 * min_tick else: # alert tickfilter = ('trade', 'utrade', 'last') @@ -603,7 +621,10 @@ async def _ems_main( abs_diff_away = 0 # submit execution/order to EMS scan loop - book.orders.setdefault( + # FYI: this may result in an override of an existing + # dark book entry if the order id already exists + + dark_book.orders.setdefault( sym, {} )[oid] = ( pred, @@ -612,6 +633,8 @@ async def _ems_main( percent_away, abs_diff_away ) + # TODO: if the predicate resolves immediately send the + # execution to the broker asap? Or no? # ack-response that order is live in EMS await ctx.send_yield({ From 0ade7daebce334674a5b8e812d02fbf6cd0179ce Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 7 Mar 2021 13:34:03 -0500 Subject: [PATCH 096/139] Support simulated live order modification in paper engine --- piker/exchange/_paper_engine.py | 71 +++++++++++++++++++++------------ 1 file changed, 45 insertions(+), 26 deletions(-) diff --git a/piker/exchange/_paper_engine.py b/piker/exchange/_paper_engine.py index d218ffb7..63ef93c8 100644 --- a/piker/exchange/_paper_engine.py +++ b/piker/exchange/_paper_engine.py @@ -19,8 +19,9 @@ Fake trading for forward testing. """ from datetime import datetime +from operator import itemgetter import time -from typing import Tuple +from typing import Tuple, Optional import uuid from bidict import bidict @@ -32,8 +33,9 @@ from ..data._normalize import iterticks @dataclass class PaperBoi: - """Emulates a broker order client providing the same API and - order-event response event stream format but with methods for + """ + Emulates a broker order client providing the same API and + delivering an order-event response stream but with methods for triggering desired events based on forward testing engine requirements. @@ -59,13 +61,23 @@ class PaperBoi: price: float, action: str, size: float, + brid: Optional[str], ) -> int: """Place an order and return integer request id provided by client. """ - # the trades stream expects events in the form - # {'local_trades': (event_name, msg)} - reqid = str(uuid.uuid4()) + + if brid is None: + reqid = str(uuid.uuid4()) + + # register order internally + self._reqids[reqid] = (oid, symbol, action, price) + + else: + # order is already existing, this is a modify + (oid, symbol, action, old_price) = self._reqids[brid] + assert old_price != price + reqid = brid if action == 'alert': # bypass all fill simulation @@ -95,9 +107,6 @@ class PaperBoi: }), }) - # register order internally - self._reqids[reqid] = (oid, symbol, action, price) - # if we're already a clearing price simulate an immediate fill if ( action == 'buy' and (clear_price := self.last_ask[0]) <= price @@ -116,8 +125,12 @@ class PaperBoi: elif action == 'sell': orders = self._sells + # set the simulated order in the respective table for lookup + # and trigger by the simulated clearing task normally + # running ``simulate_fills()``. + # buys/sells: (symbol -> (price -> order)) - orders.setdefault(symbol, {})[price] = (size, oid, reqid, action) + orders.setdefault(symbol, {})[(oid, price)] = (size, reqid, action) return reqid @@ -131,9 +144,9 @@ class PaperBoi: oid, symbol, action, price = self._reqids[reqid] if action == 'buy': - self._buys[symbol].pop(price) + self._buys[symbol].pop((oid, price)) elif action == 'sell': - self._sells[symbol].pop(price) + self._sells[symbol].pop((oid, price)) # TODO: net latency model await trio.sleep(0.05) @@ -174,6 +187,8 @@ class PaperBoi: # TODO: net latency model await trio.sleep(0.05) + # the trades stream expects events in the form + # {'local_trades': (event_name, msg)} await self._to_trade_stream.send({ 'local_trades': ('fill', { @@ -267,20 +282,22 @@ async def simulate_fills( buys = client._buys.get(sym, {}) # iterate book prices descending - for our_bid in reversed(sorted(buys.keys())): + for oid, our_bid in reversed( + sorted(buys.keys(), key=itemgetter(1)) + ): if tick_price < our_bid: # retreive order info - (size, oid, reqid, action) = buys.pop(our_bid) + (size, reqid, action) = buys.pop((oid, our_bid)) # clearing price would have filled entirely await client.fake_fill( # todo slippage to determine fill price - tick_price, - size, - action, - reqid, - oid, + price=tick_price, + size=size, + action=action, + reqid=reqid, + oid=oid, ) else: # prices are interated in sorted order so @@ -297,19 +314,21 @@ async def simulate_fills( sells = client._sells.get(sym, {}) # iterate book prices ascending - for our_ask in sorted(sells.keys()): + for oid, our_ask in sorted( + sells.keys(), key=itemgetter(1) + ): if tick_price > our_ask: # retreive order info - (size, oid, reqid, action) = sells.pop(our_ask) + (size, reqid, action) = sells.pop((oid, our_ask)) # clearing price would have filled entirely await client.fake_fill( - tick_price, - size, - action, - reqid, - oid, + price=tick_price, + size=size, + action=action, + reqid=reqid, + oid=oid, ) else: # prices are interated in sorted order so From 6851bacd0a6368137e5c9eafeffb364961147760 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 7 Mar 2021 15:50:50 -0500 Subject: [PATCH 097/139] Add drag start/end callback support, remove from cursor hovered on delete --- piker/ui/_graphics/_lines.py | 66 ++++++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 7 deletions(-) diff --git a/piker/ui/_graphics/_lines.py b/piker/ui/_graphics/_lines.py index 9dd1d500..4ba5cb65 100644 --- a/piker/ui/_graphics/_lines.py +++ b/piker/ui/_graphics/_lines.py @@ -36,7 +36,10 @@ from .._style import ( class LevelLine(pg.InfiniteLine): # TODO: fill in these slots for orders - # .sigPositionChangeFinished.emit(self) + # available parent signals + # sigDragged(self) + # sigPositionChangeFinished(self) + # sigPositionChanged(self) def __init__( self, @@ -60,6 +63,12 @@ class LevelLine(pg.InfiniteLine): self._chart = chart self._hoh = hl_on_hover self._dotted = dotted + + if dotted: + self._style = QtCore.Qt.DashLine + else: + self._style = QtCore.Qt.SolidLine + self._hcolor: str = None # the float y-value in the view coords @@ -79,6 +88,9 @@ class LevelLine(pg.InfiniteLine): self._track_cursor: bool = False self._always_show_labels = always_show_labels + self._on_drag_start = lambda l: None + self._on_drag_end = lambda l: None + # testing markers # self.addMarker('<|', 0.1, 3) # self.addMarker('<|>', 0.2, 3) @@ -103,9 +115,8 @@ class LevelLine(pg.InfiniteLine): pen = pg.mkPen(hcolor(color)) hoverpen = pg.mkPen(hcolor(color + '_light')) - if self._dotted: - pen.setStyle(QtCore.Qt.DashLine) - hoverpen.setStyle(QtCore.Qt.DashLine) + pen.setStyle(self._style) + hoverpen.setStyle(self._style) # set regular pen self.setPen(pen) @@ -166,6 +177,7 @@ class LevelLine(pg.InfiniteLine): for at, label in self._labels: label.color = self.color + # print(f'color is {self.color}') label.fields.update(fields_data) @@ -189,6 +201,16 @@ class LevelLine(pg.InfiniteLine): self, level: float, ) -> None: + last = self.value() + + # if the position hasn't changed then ``.update_labels()`` + # will not be called by a non-triggered `.on_pos_change()`, + # so we need to call it manually to avoid mismatching + # label-to-line color when the line is updated but not + # "moved". + if level == last: + self.update_labels({'level': level}) + self.setPos(level) self.level = self.value() self.update() @@ -259,6 +281,10 @@ class LevelLine(pg.InfiniteLine): self.update() def mouseDragEvent(self, ev): + """Override the ``InfiniteLine`` handler since we need more + detailed control and start end signalling. + + """ chart = self._chart # hide y-crosshair @@ -270,8 +296,27 @@ class LevelLine(pg.InfiniteLine): # label.highlight(self.hoverPen) label.show() - # normal tracking behavior - super().mouseDragEvent(ev) + # XXX: normal tracking behavior pulled out from parent type + if self.movable and ev.button() == QtCore.Qt.LeftButton: + + if ev.isStart(): + self.moving = True + self.cursorOffset = self.pos() - self.mapToParent( + ev.buttonDownPos()) + self.startPosition = self.pos() + self._on_drag_start(self) + + ev.accept() + + if not self.moving: + return + + self.setPos(self.cursorOffset + self.mapToParent(ev.pos())) + self.sigDragged.emit(self) + if ev.isFinish(): + self.moving = False + self.sigPositionChangeFinished.emit(self) + self._on_drag_end(self) # This is the final position in the drag if ev.isFinish(): @@ -289,7 +334,14 @@ class LevelLine(pg.InfiniteLine): self._labels.clear() - self._chart.plotItem.removeItem(self) + # remove from chart/cursor states + chart = self._chart + cur = chart._cursor + + if self in cur._hovered: + cur._hovered.remove(self) + + chart.plotItem.removeItem(self) def mouseDoubleClickEvent( self, From de5a69c59c948a004324c6369541b02f7a891331 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 7 Mar 2021 16:01:42 -0500 Subject: [PATCH 098/139] Add mouse drag order update support to UI --- piker/ui/_interaction.py | 200 +++++++++++++++++++++++---------------- 1 file changed, 118 insertions(+), 82 deletions(-) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index e497a677..60fb716c 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -244,65 +244,66 @@ class LineEditor: symbol = chart._lc.symbol - line = self._stage_line - if not line: - # add a "staged" cursor-tracking line to view - # and cash it in a a var - line = order_line( - chart, + # line = self._stage_line + # if not line: + # add a "staged" cursor-tracking line to view + # and cash it in a a var + if self._active_staged_line: + self.unstage_line() - level=y, - level_digits=symbol.digits(), - size=size, - size_digits=symbol.lot_digits(), + line = order_line( + chart, - # 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, + level=y, + level_digits=symbol.digits(), + size=size, + size_digits=symbol.lot_digits(), - # kwargs - color=color, - # don't highlight the "staging" line - hl_on_hover=hl_on_hover, - dotted=dotted, - ) - # line.label._use_extra_fields = size is not None + # 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, - # cache staging line after creation - self._stage_line = line + # kwargs + color=color, + # don't highlight the "staging" line + hl_on_hover=hl_on_hover, + dotted=dotted, + ) + # line.label._use_extra_fields = size is not None - else: - # apply input settings to existing staging line - # label = line.label + # cache staging line after creation + # self._stage_line = line - # disable order size and other extras in label - # label._use_extra_fields = size is not None - # label.size = size + # else: + # # apply input settings to existing staging line + # # label = line.label - # label.color = color + # # disable order size and other extras in label + # # label._use_extra_fields = size is not None + # # label.size = size - # Use the existing staged line instead but copy - # over it's current style "properties". - # Saves us allocating more mem / objects repeatedly - line._hoh = hl_on_hover - line._dotted = dotted - line.color = color - line.setMouseHover(hl_on_hover) - line.show() - line.show_labels() + # # label.color = color - # XXX: must have this to trigger updated - # label contents rendering - line.set_level(level=y) + # # Use the existing staged line instead but copy + # # over it's current style "properties". + # # Saves us allocating more mem / objects repeatedly + # line._hoh = hl_on_hover + # line._dotted = dotted + # line.color = color + # line.setMouseHover(hl_on_hover) + # line.show() + # line.show_labels() + + # # XXX: must have this to trigger updated + # # label contents rendering + # line.set_level(level=y) self._active_staged_line = line # hide crosshair y-line and label - cg = cursor.graphics[chart] - cg['hl'].hide() - cg['yl'].hide() + cursor.hide_xhair() # add line to cursor trackers cursor._trackers.add(line) @@ -313,45 +314,45 @@ class LineEditor: """Inverse of ``.stage_line()``. """ - chart = self.chart._cursor.active_plot - chart.setCursor(QtCore.Qt.ArrowCursor) - cursor = chart._cursor + # chart = self.chart._cursor.active_plot + # # chart.setCursor(QtCore.Qt.ArrowCursor) + cursor = self.chart._cursor # delete "staged" cursor tracking line from view line = self._active_staged_line if line: cursor._trackers.remove(line) + line.delete() + self._active_staged_line = None - sl = self._stage_line - if sl: - sl.hide() - sl.hide_labels() + # sl = self._stage_line + # if sl: + # sl.hide() + # sl.hide_labels() # show the crosshair y line and label - cg = cursor.graphics[chart] - cg['hl'].show() - cg['yl'].show() + cursor.show_xhair() def create_order_line( self, uuid: str, + level: float, + chart: 'ChartPlotWidget', # noqa size: float, ) -> LevelLine: line = self._active_staged_line if not line: - raise RuntimeError("No line commit is currently staged!?") + raise RuntimeError("No line is currently staged!?") - chart = self.chart._cursor.active_plot - y = chart._cursor._datum_xy[1] sym = chart._lc.symbol line = order_line( chart, # label fields default values - level=y, + level=level, level_digits=sym.digits(), size=size, @@ -361,12 +362,14 @@ class LineEditor: color=line.color, dotted=line._dotted, ) + # for now, until submission reponse arrives line.hide_labels() # register for later lookup/deletion self._order_lines[uuid] = line - return line, y + + return line def commit_line(self, uuid: str) -> LevelLine: """Commit a "staged line" to view. @@ -375,16 +378,14 @@ class LineEditor: graphic in view. """ - if uuid is None: - breakpoint() - try: line = self._order_lines[uuid] except KeyError: log.warning(f'No line for {uuid} could be found?') return else: - line.oid = uuid + assert line.oid == uuid + # line.oid = uuid # line.set_level(line.level) line.show_labels() # line.label.show() @@ -448,7 +449,7 @@ class ArrowEditor: angle = { 'up': 90, 'down': -90, - None: 180, # pointing to right + None: 180, # pointing to right (as in an alert) }[pointing] arrow = pg.ArrowItem( @@ -480,6 +481,11 @@ class ArrowEditor: 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 @@ -581,6 +587,7 @@ class OrderMode: 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)}' @@ -601,24 +608,61 @@ class OrderMode: size = size or self._size - # make line graphic - line, y = self.lines.create_order_line( - uid, - size=size, - ) - line.oid = uid + 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=self.chart._lc._symbol, + 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( @@ -943,7 +987,7 @@ class ChartView(ViewBox): self.select_box.clear() # cancel order or clear graphics - if key == QtCore.Qt.Key_C: + if key == QtCore.Qt.Key_C or key == QtCore.Qt.Key_Delete: # delete any lines under the cursor mode = self.mode for line in mode.lines.lines_under_cursor(): @@ -966,14 +1010,6 @@ class ChartView(ViewBox): elif key == QtCore.Qt.Key_A: self.mode.set_exec('alert') - # delete orders under cursor - elif key == QtCore.Qt.Key_Delete: - - # delete any lines under the cursor - 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. From e71bcb363c11b34adaed843023606f07f4029306 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 7 Mar 2021 16:07:23 -0500 Subject: [PATCH 099/139] Drop stage line cacheing; the complexity isn't worth it. --- piker/ui/_interaction.py | 44 +--------------------------------------- 1 file changed, 1 insertion(+), 43 deletions(-) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 60fb716c..5bdd9fb7 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -15,7 +15,7 @@ # along with this program. If not, see . """ -UX interaction customs. +Chart view box primitives. """ import time from contextlib import asynccontextmanager @@ -271,35 +271,6 @@ class LineEditor: hl_on_hover=hl_on_hover, dotted=dotted, ) - # line.label._use_extra_fields = size is not None - - # cache staging line after creation - # self._stage_line = line - - # else: - # # apply input settings to existing staging line - # # label = line.label - - # # disable order size and other extras in label - # # label._use_extra_fields = size is not None - # # label.size = size - - # # label.color = color - - # # Use the existing staged line instead but copy - # # over it's current style "properties". - # # Saves us allocating more mem / objects repeatedly - # line._hoh = hl_on_hover - # line._dotted = dotted - # line.color = color - # line.setMouseHover(hl_on_hover) - # line.show() - # line.show_labels() - - # # XXX: must have this to trigger updated - # # label contents rendering - # line.set_level(level=y) - self._active_staged_line = line # hide crosshair y-line and label @@ -326,11 +297,6 @@ class LineEditor: self._active_staged_line = None - # sl = self._stage_line - # if sl: - # sl.hide() - # sl.hide_labels() - # show the crosshair y line and label cursor.show_xhair() @@ -385,10 +351,7 @@ class LineEditor: return else: assert line.oid == uuid - # line.oid = uuid - # line.set_level(line.level) line.show_labels() - # line.label.show() # TODO: other flashy things to indicate the order is active @@ -670,10 +633,7 @@ async def open_order_mode( chart: pg.PlotWidget, book: OrderBook, ): - # global _order_lines - view = chart._vb - # book = get_orders() lines = LineEditor(view=view, chart=chart, _order_lines=_order_lines) arrows = ArrowEditor(chart, {}) @@ -727,7 +687,6 @@ class ChartView(ViewBox): self.addItem(self.select_box, ignoreBounds=True) self._chart: 'ChartPlotWidget' = None # noqa - # self._lines_editor = LineEditor(view=self, _lines=_lines) self.mode = None # kb ctrls processing @@ -742,7 +701,6 @@ 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. From 5deea5096351b794d1dfd3d98d86ecd9bd1bacca Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 7 Mar 2021 16:25:47 -0500 Subject: [PATCH 100/139] Factor order request processing into new func --- piker/exchange/_ems.py | 278 ++++++++++++++++++++++------------------- 1 file changed, 149 insertions(+), 129 deletions(-) diff --git a/piker/exchange/_ems.py b/piker/exchange/_ems.py index 5ebf1fa3..2ae11ae0 100644 --- a/piker/exchange/_ems.py +++ b/piker/exchange/_ems.py @@ -448,6 +448,145 @@ async def process_broker_trades( await ctx.send_yield(resp) +async def process_order_cmds( + ctx: tractor.Context, + cmd_stream: 'tractor.ReceiveStream', # noqa + symbol: str, + feed: 'Feed', # noqa + client: 'Client', # noqa + dark_book: _DarkBook, +) -> None: + + async for cmd in cmd_stream: + + log.info(f'Received order cmd:\n{pformat(cmd)}') + + action = cmd['action'] + oid = cmd['oid'] + + brid = dark_book._broker2ems_ids.inverse.get(oid) + + # TODO: can't wait for this stuff to land in 3.10 + # https://www.python.org/dev/peps/pep-0636/#going-to-the-cloud-mappings + if action in ('cancel',): + + # check for live-broker order + if brid: + log.info("Submitting cancel for live order") + await client.submit_cancel(reqid=brid) + + # check for EMS active exec + else: + try: + dark_book.orders[symbol].pop(oid, None) + + await ctx.send_yield({ + 'resp': 'dark_cancelled', + 'oid': oid + }) + except KeyError: + log.exception(f'No dark order for {symbol}?') + + elif action in ('alert', 'buy', 'sell',): + + sym = cmd['symbol'] + trigger_price = cmd['price'] + size = cmd['size'] + brokers = cmd['brokers'] + exec_mode = cmd['exec_mode'] + + broker = brokers[0] + last = dark_book.lasts[(broker, sym)] + + if exec_mode == 'live' and action in ('buy', 'sell',): + + # register broker id for ems id + order_id = await client.submit_limit( + + oid=oid, # no ib support for oids... + + # if this is None, creates a new order + # otherwise will modify any existing one + brid=brid, + + symbol=sym, + action=action, + price=trigger_price, + size=size, + ) + + if brid: + assert dark_book._broker2ems_ids[brid] == oid + + # if we already had a broker order id then + # this is likely an order update commmand. + log.info(f"Modifying order: {brid}") + + else: + dark_book._broker2ems_ids[order_id] = oid + + # XXX: the trades data broker response loop + # (``process_broker_trades()`` above) will + # handle sending the ems side acks back to + # the cmd sender from here + + elif exec_mode in ('dark', 'paper') or ( + action in ('alert') + ): + # submit order to local EMS + + # 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) + + min_tick = feed.symbols[sym].tick_size + + if action == 'buy': + tickfilter = ('ask', 'last', 'trade') + percent_away = 0.005 + + # TODO: we probably need to scale this based + # on some near term historical spread + # measure? + abs_diff_away = 3 * min_tick + + elif action == 'sell': + tickfilter = ('bid', 'last', 'trade') + percent_away = -0.005 + abs_diff_away = -3 * min_tick + + else: # alert + tickfilter = ('trade', 'utrade', 'last') + percent_away = 0 + abs_diff_away = 0 + + # submit execution/order to EMS scan loop + # FYI: this may result in an override of an existing + # dark book entry if the order id already exists + + dark_book.orders.setdefault( + sym, {} + )[oid] = ( + pred, + tickfilter, + cmd, + percent_away, + abs_diff_away + ) + # TODO: if the predicate resolves immediately send the + # execution to the broker asap? Or no? + + # ack-response that order is live in EMS + await ctx.send_yield({ + 'resp': 'dark_submitted', + 'oid': oid + }) + + @tractor.stream async def _emsd_main( ctx: tractor.Context, @@ -513,133 +652,14 @@ async def _emsd_main( # acting as an EMS client and will submit orders) to # receive requests pushed over a tractor stream # using (for now) an async generator. - async for cmd in await portal.run(send_order_cmds): + order_stream = await portal.run(send_order_cmds) - log.info(f'Received order cmd:\n{pformat(cmd)}') - - action = cmd['action'] - oid = cmd['oid'] - - brid = dark_book._broker2ems_ids.inverse.get(oid) - - # TODO: can't wait for this stuff to land in 3.10 - # https://www.python.org/dev/peps/pep-0636/#going-to-the-cloud-mappings - if action in ('cancel',): - - # check for live-broker order - if brid: - log.info("Submitting cancel for live order") - await client.submit_cancel(reqid=brid) - - # check for EMS active exec - else: - try: - dark_book.orders[symbol].pop(oid, None) - - await ctx.send_yield({ - 'resp': 'dark_cancelled', - 'oid': oid - }) - except KeyError: - log.exception(f'No dark order for {symbol}?') - - elif action in ('alert', 'buy', 'sell',): - - sym = cmd['symbol'] - trigger_price = cmd['price'] - size = cmd['size'] - brokers = cmd['brokers'] - exec_mode = cmd.get('exec_mode', _mode) - - broker = brokers[0] - last = dark_book.lasts[(broker, sym)] - - if exec_mode == 'live' and action in ('buy', 'sell',): - - # register broker id for ems id - order_id = await client.submit_limit( - - oid=oid, # no ib support for oids... - - # if this is None, creates a new order - # otherwise will modify any existing one - brid=brid, - - symbol=sym, - action=action, - price=trigger_price, - size=size, - ) - - if brid: - assert dark_book._broker2ems_ids[brid] == oid - - # if we already had a broker order id then - # this is likely an order update commmand. - log.info(f"Modifying order: {brid}") - - else: - dark_book._broker2ems_ids[order_id] = oid - - # XXX: the trades data broker response loop - # (``process_broker_trades()`` above) will - # handle sending the ems side acks back to - # the cmd sender from here - - elif exec_mode in ('dark', 'paper') or ( - action in ('alert') - ): - # submit order to local EMS - - # 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) - - min_tick = feed.symbols[sym].tick_size - - if action == 'buy': - tickfilter = ('ask', 'last', 'trade') - percent_away = 0.005 - - # TODO: we probably need to scale this based - # on some near term historical spread - # measure? - abs_diff_away = 3 * min_tick - - elif action == 'sell': - tickfilter = ('bid', 'last', 'trade') - percent_away = -0.005 - abs_diff_away = -3 * min_tick - - else: # alert - tickfilter = ('trade', 'utrade', 'last') - percent_away = 0 - abs_diff_away = 0 - - # submit execution/order to EMS scan loop - # FYI: this may result in an override of an existing - # dark book entry if the order id already exists - - dark_book.orders.setdefault( - sym, {} - )[oid] = ( - pred, - tickfilter, - cmd, - percent_away, - abs_diff_away - ) - # TODO: if the predicate resolves immediately send the - # execution to the broker asap? Or no? - - # ack-response that order is live in EMS - await ctx.send_yield({ - 'resp': 'dark_submitted', - 'oid': oid - }) - - # continue and wait on next order cmd + # start inbound order request processing + await process_order_cmds( + ctx, + order_stream, + symbol, + feed, + client, + dark_book, + ) From d58a82bd3879975506cf305013e03614225d8fdd Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 7 Mar 2021 16:41:14 -0500 Subject: [PATCH 101/139] Factor and simplify paper clearing logic --- piker/exchange/_paper_engine.py | 80 +++++++++++++-------------------- 1 file changed, 32 insertions(+), 48 deletions(-) diff --git a/piker/exchange/_paper_engine.py b/piker/exchange/_paper_engine.py index 63ef93c8..06499732 100644 --- a/piker/exchange/_paper_engine.py +++ b/piker/exchange/_paper_engine.py @@ -279,63 +279,47 @@ async def simulate_fills( tick.get('size', client.last_ask[1]), ) - buys = client._buys.get(sym, {}) + orders = client._buys.get(sym, {}) + book_sequence = reversed( + sorted(orders.keys(), key=itemgetter(1))) - # iterate book prices descending - for oid, our_bid in reversed( - sorted(buys.keys(), key=itemgetter(1)) - ): - if tick_price < our_bid: + def pred(our_price): + return tick_price < our_price - # retreive order info - (size, reqid, action) = buys.pop((oid, our_bid)) - - # clearing price would have filled entirely - await client.fake_fill( - # todo slippage to determine fill price - price=tick_price, - size=size, - action=action, - reqid=reqid, - oid=oid, - ) - else: - # prices are interated in sorted order so - # we're done - break - - if ttype in ('bid',): + elif ttype in ('bid',): client.last_bid = ( tick_price, tick.get('size', client.last_bid[1]), ) - sells = client._sells.get(sym, {}) + orders = client._sells.get(sym, {}) + book_sequence = sorted(orders.keys(), key=itemgetter(1)) - # iterate book prices ascending - for oid, our_ask in sorted( - sells.keys(), key=itemgetter(1) - ): - if tick_price > our_ask: + def pred(our_price): + return tick_price > our_price - # retreive order info - (size, reqid, action) = sells.pop((oid, our_ask)) - - # clearing price would have filled entirely - await client.fake_fill( - price=tick_price, - size=size, - action=action, - reqid=reqid, - oid=oid, - ) - else: - # prices are interated in sorted order so - # we're done - break - - if ttype in ('trade', 'last'): + elif ttype in ('trade', 'last'): # TODO: simulate actual book queues and our orders # place in it, might require full L2 data? - pass + continue + + # iterate book prices descending + for oid, our_price in book_sequence: + if pred(our_price): + + # retreive order info + (size, reqid, action) = orders.pop((oid, our_price)) + + # clearing price would have filled entirely + await client.fake_fill( + # todo slippage to determine fill price + price=tick_price, + size=size, + action=action, + reqid=reqid, + oid=oid, + ) + else: + # prices are iterated in sorted order so we're done + break From fff9efe4aa945fde50bf95f23370aa3f6f54d6db Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 7 Mar 2021 22:16:46 -0500 Subject: [PATCH 102/139] Snap level line movements to tick size --- piker/ui/_graphics/_lines.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/piker/ui/_graphics/_lines.py b/piker/ui/_graphics/_lines.py index 4ba5cb65..68e24624 100644 --- a/piker/ui/_graphics/_lines.py +++ b/piker/ui/_graphics/_lines.py @@ -22,6 +22,7 @@ from typing import Tuple, Optional, List import pyqtgraph as pg from PyQt5 import QtCore, QtGui +from PyQt5.QtCore import QPointF from .._label import Label, vbr_left, right_axis from .._style import ( @@ -91,6 +92,8 @@ class LevelLine(pg.InfiniteLine): self._on_drag_start = lambda l: None self._on_drag_end = lambda l: None + self._y_incr_mult = 1 / chart._lc._symbol.tick_size + # testing markers # self.addMarker('<|', 0.1, 3) # self.addMarker('<|>', 0.2, 3) @@ -298,21 +301,31 @@ class LevelLine(pg.InfiniteLine): # XXX: normal tracking behavior pulled out from parent type if self.movable and ev.button() == QtCore.Qt.LeftButton: + ev.accept() if ev.isStart(): self.moving = True - self.cursorOffset = self.pos() - self.mapToParent( - ev.buttonDownPos()) + down_pos = ev.buttonDownPos() + self.cursorOffset = self.pos() - self.mapToParent(down_pos) self.startPosition = self.pos() - self._on_drag_start(self) - ev.accept() + self._on_drag_start(self) if not self.moving: return - self.setPos(self.cursorOffset + self.mapToParent(ev.pos())) + pos = self.cursorOffset + self.mapToParent(ev.pos()) + + # TODO: we should probably figure out a std api + # for this kind of thing given we already have + # it on the cursor system... + + # round to nearest symbol tick + m = self._y_incr_mult + self.setPos(QPointF(pos.x(), round(pos.y() * m) / m)) + self.sigDragged.emit(self) + if ev.isFinish(): self.moving = False self.sigPositionChangeFinished.emit(self) From 7e214180a61a79297ed7f14ee230d30566a0752d Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 7 Mar 2021 22:17:19 -0500 Subject: [PATCH 103/139] Remove old simulated order price on update --- piker/exchange/_paper_engine.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/piker/exchange/_paper_engine.py b/piker/exchange/_paper_engine.py index 06499732..740345f5 100644 --- a/piker/exchange/_paper_engine.py +++ b/piker/exchange/_paper_engine.py @@ -70,15 +70,15 @@ class PaperBoi: if brid is None: reqid = str(uuid.uuid4()) - # register order internally - self._reqids[reqid] = (oid, symbol, action, price) - else: # order is already existing, this is a modify (oid, symbol, action, old_price) = self._reqids[brid] assert old_price != price reqid = brid + # register order internally + self._reqids[reqid] = (oid, symbol, action, price) + if action == 'alert': # bypass all fill simulation return reqid @@ -129,6 +129,10 @@ class PaperBoi: # and trigger by the simulated clearing task normally # running ``simulate_fills()``. + if brid is not None: + # remove any existing order for the old price + orders[symbol].pop((oid, old_price)) + # buys/sells: (symbol -> (price -> order)) orders.setdefault(symbol, {})[(oid, price)] = (size, reqid, action) From 7075a968b4ee82ad6bde94d1e8388e5ae37c8cda Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 8 Mar 2021 09:05:37 -0500 Subject: [PATCH 104/139] Create an order mode module --- piker/ui/_chart.py | 96 +---------- piker/ui/_interaction.py | 233 +------------------------ piker/ui/order_mode.py | 357 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 361 insertions(+), 325 deletions(-) create mode 100644 piker/ui/order_mode.py 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', + ) From 98bfee028ab12d34966b3af23717f0fd6b5b0c9c Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 11 Mar 2021 21:35:09 -0500 Subject: [PATCH 105/139] Add a position line api Add a line which shows the current average price position with and arrow marker denoting the direction (long or short). Required some further rewriting of the infinite line from pyqtgraph including: - adjusting marker (arrow) placement to be offset from axis + l1 labels - fixing the hover event to not require the `.movable` attribute to be set --- piker/ui/_graphics/_lines.py | 278 +++++++++++++++++++++++++++++++---- 1 file changed, 247 insertions(+), 31 deletions(-) diff --git a/piker/ui/_graphics/_lines.py b/piker/ui/_graphics/_lines.py index 68e24624..84177085 100644 --- a/piker/ui/_graphics/_lines.py +++ b/piker/ui/_graphics/_lines.py @@ -21,8 +21,10 @@ Lines for orders, alerts, L2. from typing import Tuple, Optional, List import pyqtgraph as pg -from PyQt5 import QtCore, QtGui +from pyqtgraph import Point, functions as fn +from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5.QtCore import QPointF +import numpy as np from .._label import Label, vbr_left, right_axis from .._style import ( @@ -31,6 +33,67 @@ from .._style import ( ) +def mk_marker( + self, + marker, + position: float = 0.5, + size: float = 10.0 +) -> QtGui.QPainterPath: + """Add a marker to be displayed on the line. + + ============= ========================================================= + **Arguments** + marker String indicating the style of marker to add: + ``'<|'``, ``'|>'``, ``'>|'``, ``'|<'``, ``'<|>'``, + ``'>|<'``, ``'^'``, ``'v'``, ``'o'`` + position Position (0.0-1.0) along the visible extent of the line + to place the marker. Default is 0.5. + size Size of the marker in pixels. Default is 10.0. + ============= ========================================================= + """ + path = QtGui.QPainterPath() + + if marker == 'o': + path.addEllipse(QtCore.QRectF(-0.5, -0.5, 1, 1)) + + # arrow pointing away-from the top of line + if '<|' in marker: + p = QtGui.QPolygonF([Point(0.5, 0), Point(0, -0.5), Point(-0.5, 0)]) + path.addPolygon(p) + path.closeSubpath() + + # arrow pointing away-from the bottom of line + if '|>' in marker: + p = QtGui.QPolygonF([Point(0.5, 0), Point(0, 0.5), Point(-0.5, 0)]) + path.addPolygon(p) + path.closeSubpath() + + # arrow pointing in-to the top of line + if '>|' in marker: + p = QtGui.QPolygonF([Point(0.5, -0.5), Point(0, 0), Point(-0.5, -0.5)]) + path.addPolygon(p) + path.closeSubpath() + + # arrow pointing in-to the bottom of line + if '|<' in marker: + p = QtGui.QPolygonF([Point(0.5, 0.5), Point(0, 0), Point(-0.5, 0.5)]) + path.addPolygon(p) + path.closeSubpath() + + if '^' in marker: + p = QtGui.QPolygonF([Point(0, -0.5), Point(0.5, 0), Point(0, 0.5)]) + path.addPolygon(p) + path.closeSubpath() + + if 'v' in marker: + p = QtGui.QPolygonF([Point(0, -0.5), Point(-0.5, 0), Point(0, 0.5)]) + path.addPolygon(p) + path.closeSubpath() + + self._maxMarkerSize = max([m[2] / 2. for m in self.markers]) + + + # TODO: probably worth investigating if we can # make .boundingRect() faster: # https://stackoverflow.com/questions/26156486/determine-bounding-rect-of-line-in-qt @@ -52,18 +115,23 @@ class LevelLine(pg.InfiniteLine): hl_on_hover: bool = True, dotted: bool = False, always_show_labels: bool = False, + hide_xhair_on_hover: bool = True, + movable: bool = True, ) -> None: super().__init__( - movable=True, + movable=movable, angle=0, - label=None, # don't use the shitty ``InfLineLabel`` + + # don't use the shitty ``InfLineLabel`` + label=None, ) self._chart = chart self._hoh = hl_on_hover self._dotted = dotted + self._hide_xhair_on_hover = hide_xhair_on_hover if dotted: self._style = QtCore.Qt.DashLine @@ -83,6 +151,7 @@ class LevelLine(pg.InfiniteLine): self.sigPositionChanged.connect(self.on_pos_change) # sets color to value triggering pen creation + self._hl_color = highlight_color self.color = color # TODO: for when we want to move groups of lines? @@ -94,16 +163,6 @@ class LevelLine(pg.InfiniteLine): self._y_incr_mult = 1 / chart._lc._symbol.tick_size - # testing markers - # self.addMarker('<|', 0.1, 3) - # self.addMarker('<|>', 0.2, 3) - # self.addMarker('>|', 0.3, 3) - # self.addMarker('>|<', 0.4, 3) - # self.addMarker('>|<', 0.5, 3) - # self.addMarker('^', 0.6, 3) - # self.addMarker('v', 0.7, 3) - # self.addMarker('o', 0.8, 3) - def txt_offsets(self) -> Tuple[int, int]: return 0, 0 @@ -116,7 +175,7 @@ class LevelLine(pg.InfiniteLine): # set pens to new color self._hcolor = color pen = pg.mkPen(hcolor(color)) - hoverpen = pg.mkPen(hcolor(color + '_light')) + hoverpen = pg.mkPen(hcolor(self._hl_color)) pen.setStyle(self._style) hoverpen.setStyle(self._style) @@ -240,6 +299,7 @@ class LevelLine(pg.InfiniteLine): self.mouseHovering = hover chart = self._chart + cur = chart._cursor if hover: # highlight if so configured @@ -250,13 +310,12 @@ class LevelLine(pg.InfiniteLine): # label.highlight(self.hoverPen) # add us to cursor state - cur = chart._cursor cur.add_hovered(self) - cur.graphics[chart]['yl'].hide() - cur.graphics[chart]['hl'].hide() - for at, label in self._labels: - label.show() + if self._hide_xhair_on_hover: + cur.hide_xhair() + + self.show_labels() # TODO: hide y-crosshair? # chart._cursor.graphics[chart]['hl'].hide() @@ -266,17 +325,18 @@ class LevelLine(pg.InfiniteLine): else: self.currentPen = self.pen - cur = chart._cursor cur._hovered.remove(self) if self not in cur._trackers: - g = cur.graphics[chart] - g['yl'].show() - g['hl'].show() + cur.show_xhair() + # g = cur.graphics[chart] + # g['yl'].show() + # g['hl'].show() if not self._always_show_labels: for at, label in self._labels: label.hide() + label.txt.update() # label.unhighlight() # highlight any attached label @@ -295,9 +355,7 @@ class LevelLine(pg.InfiniteLine): # highlight self.currentPen = self.hoverPen - for at, label in self._labels: - # label.highlight(self.hoverPen) - label.show() + self.show_labels() # XXX: normal tracking behavior pulled out from parent type if self.movable and ev.button() == QtCore.Qt.LeftButton: @@ -322,7 +380,12 @@ class LevelLine(pg.InfiniteLine): # round to nearest symbol tick m = self._y_incr_mult - self.setPos(QPointF(pos.x(), round(pos.y() * m) / m)) + self.setPos( + QPointF( + pos.x(), + round(pos.y() * m) / m + ) + ) self.sigDragged.emit(self) @@ -364,11 +427,112 @@ class LevelLine(pg.InfiniteLine): # TODO: enter labels edit mode print(f'double click {ev}') + def draw_markers( + self, + p: QtGui.QPainter, + left: float, + right: float, + right_offset: float, + ) -> None: + # paint markers in native coordinate system + tr = p.transform() + p.resetTransform() + + start = tr.map(Point(left, 0)) + end = tr.map(Point(right, 0)) + up = tr.map(Point(left, 1)) + dif = end - start + # length = Point(dif).length() + angle = np.arctan2(dif.y(), dif.x()) * 180 / np.pi + + p.translate(start) + p.rotate(angle) + + up = up - start + det = up.x() * dif.y() - dif.x() * up.y() + p.scale(1, 1 if det > 0 else -1) + + p.setBrush(fn.mkBrush(self.currentPen.color())) + tr = p.transform() + for path, pos, size in self.markers: + p.setTransform(tr) + # x = length * pos + x = right_offset + p.translate(x, 0) + p.scale(size, size) + p.drawPath(path) + + def right_point( + self, + ) -> float: + + chart = self._chart + l1_len = chart._max_l1_line_len + ryaxis = chart.getAxis('right') + + if self.markers: + size = self.markers[0][2] + else: + size = 0 + + r_axis_x = ryaxis.pos().x() + right_offset = l1_len + size + 10 + right_scene_coords = r_axis_x - right_offset + + right_view_coords = chart._vb.mapToView( + Point(right_scene_coords, 0)).x() + + return ( + right_scene_coords, + right_view_coords, + right_offset, + ) + + def paint( + self, + p: QtGui.QPainter, + opt: QtWidgets.QStyleOptionGraphicsItem, + w: QtWidgets.QWidget + ) -> None: + """Core paint which we override (yet again) + from pg.. + + """ + p.setRenderHint(p.Antialiasing) + + vb_left, vb_right = self._endPoints + pen = self.currentPen + pen.setJoinStyle(QtCore.Qt.MiterJoin) + p.setPen(pen) + + rsc, rvc, rosc = self.right_point() + + p.drawLine( + Point(vb_left, 0), + Point(rvc, 0) + ) + + if self.markers: + self.draw_markers( + p, + vb_left, + vb_right, + rsc + ) + + def hoverEvent(self, ev): + """Gawd, basically overriding it all at this point... + + """ + if (not ev.isExit()) and ev.acceptDrags(QtCore.Qt.LeftButton): + self.setMouseHover(True) + else: + self.setMouseHover(False) + def level_line( chart: 'ChartPlogWidget', # noqa level: float, - color: str = 'default', # size 4 font on 4k screen scaled down, so small-ish. @@ -390,16 +554,20 @@ def level_line( orient_v: str = 'bottom', + **kwargs, + ) -> LevelLine: """Convenience routine to add a styled horizontal line to a plot. """ + hl_color = color + '_light' if hl_on_hover else color line = LevelLine( chart, color=color, + # lookup "highlight" equivalent - highlight_color=color + '_light', + highlight_color=hl_color, dotted=dotted, @@ -409,6 +577,8 @@ def level_line( # when set to True the label is always shown instead of just on # highlight (which is a privacy thing for orders) always_show_labels=always_show_labels, + + **kwargs, ) chart.plotItem.addItem(line) @@ -445,8 +615,6 @@ def order_line( order_status: str = 'dark', order_type: str = 'limit', - opacity=0.616, - orient_v: str = 'bottom', **line_kwargs, @@ -497,3 +665,51 @@ def order_line( line.update_labels({'level': level}) return line + + +def position_line( + chart, + size: float, + + level: float, + + orient_v: str = 'bottom', + +) -> LevelLine: + """Convenience routine to add a line graphic representing an order + execution submitted to the EMS via the chart's "order mode". + + """ + line = level_line( + chart, + level, + color='default_light', + add_label=False, + hl_on_hover=False, + movable=False, + always_show_labels=False, + hide_xhair_on_hover=False, + ) + if size > 0: + line.addMarker('|<', 0.9, 20) + + elif size < 0: + line.addMarker('>|', 0.9, 20) + + rlabel = line.add_label( + side='left', + fmt_str='{direction}: {size}\n${$:.2f}', + ) + rlabel.fields = { + 'direction': 'long' if size > 0 else 'short', + '$': size * level, + 'size': size, + } + rlabel.orient_v = orient_v + rlabel.render() + rlabel.show() + + # sanity check + line.update_labels({'level': level}) + + return line From 6265ae805780c546ff2217a69cca2c8e2050dfda Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 11 Mar 2021 21:38:31 -0500 Subject: [PATCH 106/139] Add position event relay to ib broker backend --- piker/brokers/ib.py | 60 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 48 insertions(+), 12 deletions(-) diff --git a/piker/brokers/ib.py b/piker/brokers/ib.py index aa07f9ef..2679e988 100644 --- a/piker/brokers/ib.py +++ b/piker/brokers/ib.py @@ -40,6 +40,7 @@ from ib_insync.wrapper import RequestError from ib_insync.contract import Contract, ContractDetails from ib_insync.order import Order from ib_insync.ticker import Ticker +from ib_insync.objects import Position import ib_insync as ibis from ib_insync.wrapper import Wrapper from ib_insync.client import Client as ib_Client @@ -449,9 +450,8 @@ class Client: size: int, # XXX: by default 0 tells ``ib_insync`` methods that there is no - # existing order so ask the client to create a new one (which - # it seems to do by allocating an int counter - collision - # prone..) + # existing order so ask the client to create a new one (which it + # seems to do by allocating an int counter - collision prone..) brid: int = None, ) -> int: """Place an order and return integer request id provided by client. @@ -507,17 +507,21 @@ class Client: """ self.inline_errors(to_trio) - def push_tradesies(eventkit_obj, trade, fill=None): + def push_tradesies(eventkit_obj, obj, fill=None): """Push events to trio task. """ if fill is not None: # execution details event - item = ('fill', (trade, fill)) - else: - item = ('status', trade) + item = ('fill', (obj, fill)) - log.info(f'{eventkit_obj}: {item}') + elif eventkit_obj.name() == 'positionEvent': + item = ('position', obj) + + else: + item = ('status', obj) + + log.info(f'eventkit event -> {eventkit_obj}: {item}') try: to_trio.send_nowait(item) @@ -529,6 +533,7 @@ class Client: for ev_name in [ 'orderStatusEvent', # all order updates 'execDetailsEvent', # all "fill" updates + 'positionEvent', # avg price updates per symbol per account # 'commissionReportEvent', # XXX: ugh, it is a separate event from IB and it's @@ -537,7 +542,6 @@ class Client: # XXX: not sure yet if we need these # 'updatePortfolioEvent', - # 'positionEvent', # XXX: these all seem to be weird ib_insync intrernal # events that we probably don't care that much about @@ -584,6 +588,15 @@ class Client: self.ib.errorEvent.connect(push_err) + async def positions( + self, + account: str = '', + ) -> List[Position]: + """ + Retrieve position info for ``account``. + """ + return self.ib.positions(account=account) + # default config ports _tws_port: int = 7497 @@ -1155,6 +1168,18 @@ async def stream_and_write( ticker.ticks = [] +def pack_position(pos: Position) -> Dict[str, Any]: + con = pos.contract + return { + 'broker': 'ib', + 'account': pos.account, + 'symbol': con.symbol, + 'currency': con.currency, + 'size': float(pos.position), + 'avg_price': float(pos.avgCost) / float(con.multiplier or 1.0), + } + + @tractor.msg.pub( send_on_connect={'local_trades': 'start'} ) @@ -1163,14 +1188,18 @@ async def stream_trades( get_topics: Callable = None, ) -> AsyncIterator[Dict[str, Any]]: - global _trades_stream_is_live - # XXX: required to propagate ``tractor`` loglevel to piker logging get_console_log(loglevel or tractor.current_actor().loglevel) stream = await _trio_run_client_method( method='recv_trade_updates', ) + + # deliver positions to subscriber before anything else + positions = await _trio_run_client_method(method='positions') + for pos in positions: + yield {'local_trades': ('position', pack_position(pos))} + action_map = {'BOT': 'buy', 'SLD': 'sell'} async for event_name, item in stream: @@ -1230,12 +1259,19 @@ async def stream_trades( # don't forward, it's pointless.. continue - if msg['reqid'] < -1: + elif event_name == 'position': + msg = pack_position(item) + + if msg.get('reqid', 0) < -1: # it's a trade event generated by TWS usage. log.warning(f"TWS triggered trade:\n{pformat(msg)}") msg['reqid'] = 'tws-' + str(-1 * msg['reqid']) + # mark msg as from "external system" + # TODO: probably something better then this.. + msg['external'] = True + yield {'remote_trades': (event_name, msg)} continue From e91e7bea1f2f82e2d5e2dc1a8d5ede9b5b5027e5 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 11 Mar 2021 21:38:59 -0500 Subject: [PATCH 107/139] Add position event support to ems --- piker/exchange/_ems.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/piker/exchange/_ems.py b/piker/exchange/_ems.py index 2ae11ae0..291e9c5f 100644 --- a/piker/exchange/_ems.py +++ b/piker/exchange/_ems.py @@ -353,6 +353,13 @@ async def process_broker_trades( log.info(f'Received broker trade event:\n{pformat(msg)}') + if name == 'position': + msg['resp'] = 'position' + + # relay through + await ctx.send_yield(msg) + continue + # Get the broker (order) request id, this **must** be normalized # into messaging provided by the broker backend reqid = msg['reqid'] @@ -366,7 +373,13 @@ async def process_broker_trades( # locally, so we need to retreive the oid that was already # packed at submission since we already know it ahead of # time - oid = msg['paper_info']['oid'] + paper = msg.get('paper_info') + if paper: + oid = paper['oid'] + + else: + msg['external'] + continue resp = { 'resp': None, # placeholder @@ -543,6 +556,7 @@ async def process_order_cmds( # the user choose the predicate operator. pred = mk_check(trigger_price, last) + tick_slap: float = 5 min_tick = feed.symbols[sym].tick_size if action == 'buy': @@ -552,12 +566,12 @@ async def process_order_cmds( # TODO: we probably need to scale this based # on some near term historical spread # measure? - abs_diff_away = 3 * min_tick + abs_diff_away = tick_slap * min_tick elif action == 'sell': tickfilter = ('bid', 'last', 'trade') percent_away = -0.005 - abs_diff_away = -3 * min_tick + abs_diff_away = -tick_slap * min_tick else: # alert tickfilter = ('trade', 'utrade', 'last') @@ -567,7 +581,6 @@ async def process_order_cmds( # submit execution/order to EMS scan loop # FYI: this may result in an override of an existing # dark book entry if the order id already exists - dark_book.orders.setdefault( sym, {} )[oid] = ( From ea8120156f4fd0d783e0e10e9e738ffd51ef21e0 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 11 Mar 2021 21:40:06 -0500 Subject: [PATCH 108/139] Add position line updating to order mode --- piker/ui/order_mode.py | 56 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index 5ed163ce..8c643953 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -15,7 +15,7 @@ # along with this program. If not, see . """ -Chart trading at it's finest. +Chart trading, the only way to scalp. """ from contextlib import asynccontextmanager @@ -27,8 +27,9 @@ import uuid import pyqtgraph as pg import trio +from pydantic import BaseModel -from ._graphics._lines import LevelLine +from ._graphics._lines import LevelLine, position_line from ._interaction import LineEditor, ArrowEditor, _order_lines from ..exchange._client import open_ems, OrderBook from ..data._source import Symbol @@ -38,6 +39,14 @@ from ..log import get_logger log = get_logger(__name__) + +class Position(BaseModel): + symbol: Symbol + size: float + avg_price: float + fills: Dict[str, Any] = {} + + @dataclass class OrderMode: """Major mode for placing orders on a chart view. @@ -60,9 +69,32 @@ class OrderMode: _action: str = 'alert' _exec_mode: str = 'dark' _size: float = 100.0 + _position: Dict[str, Any] = field(default_factory=dict) + _position_line: dict = None key_map: Dict[str, Callable] = field(default_factory=dict) + def on_position_update( + self, + msg: dict, + ) -> None: + print(f'Position update {msg}') + + sym = self.chart._lc._symbol + if msg['symbol'].lower() not in sym.key: + return + + self._position.update(msg) + if self._position_line: + self._position_line.delete() + + line = self._position_line = position_line( + self.chart, + level=msg['avg_price'], + size=msg['size'], + ) + line.show() + def uuid(self) -> str: return str(uuid.uuid4()) @@ -224,6 +256,14 @@ class OrderMode: price=line.value(), ) + # def on_key_press( + # self, + # key: + # mods: + # text: str, + # ) -> None: + # pass + @asynccontextmanager async def open_order_mode( @@ -280,8 +320,10 @@ async def start_order_mode( ) 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 @@ -299,9 +341,17 @@ async def start_order_mode( fmsg = pformat(msg) log.info(f'Received order msg:\n{fmsg}') + resp = msg['resp'] + + if resp in ( + 'position', + ): + # show line label once order is live + order_mode.on_position_update(msg) + continue + # delete the line from view oid = msg['oid'] - resp = msg['resp'] # response to 'action' request (buy/sell) if resp in ( From 31c14a2f9fbd9f977f952df10325e65cd20b593e Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 11 Mar 2021 21:40:50 -0500 Subject: [PATCH 109/139] Add l1 label size tracking to chart widget --- piker/ui/_chart.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index faf26841..950ed010 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -372,6 +372,8 @@ class ChartPlotWidget(pg.PlotWidget): sig_mouse_leave = QtCore.Signal(object) sig_mouse_enter = QtCore.Signal(object) + _l1_labels: L1Labels = None + # TODO: can take a ``background`` color setting - maybe there's # a better one? @@ -406,6 +408,10 @@ class ChartPlotWidget(pg.PlotWidget): self.name = name self._lc = linked_charts + # view-local placeholder for book graphics + # sizing to avoid overlap with data contents + self._max_l1_line_len: float = 0 + # self.setViewportMargins(0, 0, 0, 0) self._ohlc = array # readonly view of ohlc data @@ -727,7 +733,7 @@ class ChartPlotWidget(pg.PlotWidget): self, *, yrange: Optional[Tuple[float, float]] = None, - range_margin: float = 0.04, + range_margin: float = 0.06, ) -> None: """Set the viewable y-range based on embedded data. @@ -1123,6 +1129,7 @@ async def chart_from_quotes( digits=symbol.digits(), size_digits=symbol.lot_digits(), ) + chart._l1_labels = l1 # TODO: # - in theory we should be able to read buffer data faster From a51d5c536e64595b71027c4c6c91d68dd5b86cd5 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 11 Mar 2021 21:41:13 -0500 Subject: [PATCH 110/139] Add a couple more grays --- piker/ui/_style.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/piker/ui/_style.py b/piker/ui/_style.py index a290bdef..fff63ea5 100644 --- a/piker/ui/_style.py +++ b/piker/ui/_style.py @@ -142,6 +142,10 @@ def enable_tina_mode() -> None: def hcolor(name: str) -> str: """Hex color codes by hipster speak. + + This is an internal set of color codes hand picked + for certain purposes. + """ return { @@ -154,6 +158,8 @@ def hcolor(name: str) -> str: # fifty shades 'gray': '#808080', # like the kick + 'grayer': '#4c4c4c', + 'grayest': '#3f3f3f', 'jet': '#343434', 'cadet': '#91A3B0', 'marengo': '#91A3B0', From 4a1df686a5c0f08997c27a03c4dd29087ac3b7f7 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 11 Mar 2021 21:42:38 -0500 Subject: [PATCH 111/139] Update max l1 label size on chart --- piker/ui/_graphics/_curve.py | 3 ++- piker/ui/_interaction.py | 2 +- piker/ui/_l1.py | 19 +++++++++---------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/piker/ui/_graphics/_curve.py b/piker/ui/_graphics/_curve.py index 4feb5d37..9236ed79 100644 --- a/piker/ui/_graphics/_curve.py +++ b/piker/ui/_graphics/_curve.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 @@ -16,6 +16,7 @@ """ Fast, smooth, sexy curves. + """ from typing import Tuple diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 16ae71f3..41bc8571 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -121,7 +121,7 @@ class SelectRect(QtGui.QGraphicsRectItem): p1: QtCore.QPointF, p2: QtCore.QPointF ) -> None: - """Set position of selection rectagle and accompanying label, move + """Set position of selection rect and accompanying label, move label to match. """ diff --git a/piker/ui/_l1.py b/piker/ui/_l1.py index 90cc8aba..ab7f8363 100644 --- a/piker/ui/_l1.py +++ b/piker/ui/_l1.py @@ -152,7 +152,7 @@ class LevelLabel(YAxisLabel): h, w = self.set_label_str(fields) if self._adjust_to_l1: - self._x_offset = _max_l1_line_len + self._x_offset = self._chart._max_l1_line_len self.setPos(QPointF( self._h_shift * (w + self._x_offset), @@ -211,11 +211,6 @@ class LevelLabel(YAxisLabel): self.update() -# global for now but probably should be -# attached to chart instance? -_max_l1_line_len: float = 0 - - class L1Label(LevelLabel): text_flags = ( @@ -232,10 +227,14 @@ class L1Label(LevelLabel): """ h, w = super().set_label_str(fields) - # Set a global "max L1 label length" so we can look it up - # on order lines and adjust their labels not to overlap with it. - global _max_l1_line_len - _max_l1_line_len = max(_max_l1_line_len, w) + # Set a global "max L1 label length" so we can + # look it up on order lines and adjust their + # labels not to overlap with it. + chart = self._chart + chart._max_l1_line_len: float = max( + chart._max_l1_line_len, + w + ) return h, w From d016abcd0d119b42bf9fb4831c9dd38e14dcdf2a Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 11 Mar 2021 21:43:29 -0500 Subject: [PATCH 112/139] Adjust right axis anchor closure to include l1 label size --- piker/ui/_label.py | 40 +++++++++++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/piker/ui/_label.py b/piker/ui/_label.py index 5297fa20..8e773666 100644 --- a/piker/ui/_label.py +++ b/piker/ui/_label.py @@ -32,7 +32,11 @@ from ._style import ( ) -def vbr_left(label) -> float: +def vbr_left(label) -> Callable[None, float]: + """Return a closure which gives the scene x-coordinate for the + leftmost point of the containing view box. + + """ def viewbox_left(): return label.vbr().left() @@ -40,11 +44,30 @@ def vbr_left(label) -> float: return viewbox_left -def right_axis(chart, label) -> float: - raxis = chart.getAxis('right') +def right_axis( - def right_axis_offset_by_w(): - return raxis.pos().x() - label.w + chart: 'ChartPlotWidget', # noqa + label: 'Label', # noqa + offset: float = 10, + width: float = None, + +) -> Callable[None, float]: + """Return a position closure which gives the scene x-coordinate for + the x point on the right y-axis minus the width of the label given + it's contents. + + """ + ryaxis = chart.getAxis('right') + + def right_axis_offset_by_w() -> float: + + # l1 spread graphics x-size + l1_len = chart._max_l1_line_len + + # sum of all distances "from" the y-axis + right_offset = l1_len + label.w + offset + + return ryaxis.pos().x() - right_offset return right_axis_offset_by_w @@ -57,14 +80,13 @@ class Label: can't accomplish the simplest things, such as pinning to the left hand side of a view box. - This type is another effort (see our graphics lol) to start making + This type is another effort (see our graphics) to start making small, re-usable label components that can actually be used to build - production grade UIs. - - just.. smh, hard. + production grade UIs... """ def __init__( + self, view: pg.ViewBox, fmt_str: str, From 624617d8e1418d0ad0a04139f7a8b2dab41fd3aa Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 11 Mar 2021 21:44:10 -0500 Subject: [PATCH 113/139] Don't run brokerds in debug mode by default --- piker/data/__init__.py | 26 ++++++++++++++++++-------- piker/ui/cli.py | 4 ++-- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/piker/data/__init__.py b/piker/data/__init__.py index c52e5e6c..b9c09e1c 100644 --- a/piker/data/__init__.py +++ b/piker/data/__init__.py @@ -87,9 +87,11 @@ _data_mods = [ @asynccontextmanager async def maybe_spawn_brokerd( brokername: str, - sleep: float = 0.5, loglevel: Optional[str] = None, - **tractor_kwargs, + + # XXX: you should pretty much never want debug mode + # for data daemons when running in production. + debug_mode: bool = False, ) -> tractor._portal.Portal: """If no ``brokerd.{brokername}`` daemon-actor can be found, spawn one in a local subactor and return a portal to it. @@ -97,11 +99,6 @@ async def maybe_spawn_brokerd( if loglevel: get_console_log(loglevel) - # disable debugger in brokerd? - # tractor._state._runtime_vars['_debug_mode'] = False - - tractor_kwargs['loglevel'] = loglevel - brokermod = get_brokermod(brokername) dname = f'brokerd.{brokername}' async with tractor.find_actor(dname) as portal: @@ -113,18 +110,25 @@ async def maybe_spawn_brokerd( else: # no daemon has been spawned yet log.info(f"Spawning {brokername} broker daemon") + + # retrieve any special config from the broker mod tractor_kwargs = getattr(brokermod, '_spawn_kwargs', {}) - async with tractor.open_nursery() as nursery: + + async with tractor.open_nursery( + debug_mode=False, + ) as nursery: try: # spawn new daemon portal = await nursery.start_actor( dname, enable_modules=_data_mods + [brokermod.__name__], loglevel=loglevel, + debug_mode=debug_mode, **tractor_kwargs ) async with tractor.wait_for_actor(dname) as portal: yield portal + finally: # client code may block indefinitely so cancel when # teardown is invoked @@ -235,9 +239,15 @@ async def open_feed( ) async with maybe_spawn_brokerd( + brokername, loglevel=loglevel, + + # TODO: add a cli flag for this + debug_mode=False, + ) as portal: + stream = await portal.run( mod.stream_quotes, diff --git a/piker/ui/cli.py b/piker/ui/cli.py index fcb9b854..a0912497 100644 --- a/piker/ui/cli.py +++ b/piker/ui/cli.py @@ -91,7 +91,7 @@ def monitor(config, rate, name, dhost, test, tl): @click.option('--rate', '-r', default=1, help='Logging level') @click.argument('symbol', required=True) @click.pass_obj -def optschain(config, symbol, date, tl, rate, test): +def optschain(config, symbol, date, rate, test): """Start an option chain UI """ # global opts @@ -117,7 +117,7 @@ def optschain(config, symbol, date, tl, rate, test): tractor.run( partial(main, tries=1), name='kivy-options-chain', - loglevel=loglevel if tl else None, + # loglevel=loglevel if tl else None, ) From 18ab81a967c08635a5d09c3eaa1347cea76d16ab Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 11 Mar 2021 21:44:35 -0500 Subject: [PATCH 114/139] Make crosshair lines a milder gray --- piker/ui/_graphics/_cursor.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/piker/ui/_graphics/_cursor.py b/piker/ui/_graphics/_cursor.py index e8cd8892..8429f655 100644 --- a/piker/ui/_graphics/_cursor.py +++ b/piker/ui/_graphics/_cursor.py @@ -215,7 +215,7 @@ class Cursor(pg.GraphicsObject): style=QtCore.Qt.DashLine, ) self.lines_pen = pg.mkPen( - color='#a9a9a9', # gray? + color=hcolor('davies'), style=QtCore.Qt.DashLine, ) self.lsc = linkedsplitcharts @@ -257,12 +257,14 @@ class Cursor(pg.GraphicsObject): hl.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache) hl.hide() + label_color = 'default' + yl = YAxisLabel( chart=plot, parent=plot.getAxis('right'), digits=digits or self.digits, opacity=_ch_label_opac, - bg_color='default', + bg_color=label_color, ) yl.hide() # on startup if mouse is off screen @@ -302,7 +304,7 @@ class Cursor(pg.GraphicsObject): self.xaxis_label = XAxisLabel( parent=self.plots[plot_index].getAxis('bottom'), opacity=_ch_label_opac, - bg_color='default', + bg_color=label_color, ) # place label off-screen during startup self.xaxis_label.setPos(self.plots[0].mapFromView(QPointF(0, 0))) From 396f6b2a0d9c79c3c0379a37db68830c37e34731 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 12 Mar 2021 07:41:47 -0500 Subject: [PATCH 115/139] Fix type annot --- piker/ui/_label.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/piker/ui/_label.py b/piker/ui/_label.py index 8e773666..20638c2e 100644 --- a/piker/ui/_label.py +++ b/piker/ui/_label.py @@ -32,7 +32,7 @@ from ._style import ( ) -def vbr_left(label) -> Callable[None, float]: +def vbr_left(label) -> Callable[..., float]: """Return a closure which gives the scene x-coordinate for the leftmost point of the containing view box. @@ -51,7 +51,7 @@ def right_axis( offset: float = 10, width: float = None, -) -> Callable[None, float]: +) -> Callable[..., float]: """Return a position closure which gives the scene x-coordinate for the x point on the right y-axis minus the width of the label given it's contents. From f6dbdfab84f34599c2be288707ed17e564c0ce6a Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 13 Mar 2021 17:28:22 -0500 Subject: [PATCH 116/139] Don't crash on unknown orders execing --- piker/exchange/_ems.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/piker/exchange/_ems.py b/piker/exchange/_ems.py index 291e9c5f..5ebfee4a 100644 --- a/piker/exchange/_ems.py +++ b/piker/exchange/_ems.py @@ -378,7 +378,10 @@ async def process_broker_trades( oid = paper['oid'] else: - msg['external'] + msg.get('external') + if not msg: + log.error(f"Unknown trade event {event}") + continue resp = { From 97986899617fc34faf950ba38df089ffbe13c858 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 13 Mar 2021 17:28:57 -0500 Subject: [PATCH 117/139] Lengthen label arrow a tad --- piker/ui/_axes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/piker/ui/_axes.py b/piker/ui/_axes.py index 4bb16656..c70578d5 100644 --- a/piker/ui/_axes.py +++ b/piker/ui/_axes.py @@ -454,7 +454,7 @@ class YAxisLabel(AxisLabel): path = QtGui.QPainterPath() h = self.rect.height() path.moveTo(0, 0) - path.lineTo(-x_offset - 2, h/2.) + path.lineTo(-x_offset - 4, h/2.) path.lineTo(0, h) path.closeSubpath() self.path = path From 887c305d46a8d8681deda49c047ac0a203c5260a Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 13 Mar 2021 17:29:35 -0500 Subject: [PATCH 118/139] Allow y-label pinning on cross hair hide --- piker/ui/_graphics/_cursor.py | 52 +++++++++++++++++++++++++---------- 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/piker/ui/_graphics/_cursor.py b/piker/ui/_graphics/_cursor.py index 8429f655..cab01856 100644 --- a/piker/ui/_graphics/_cursor.py +++ b/piker/ui/_graphics/_cursor.py @@ -119,17 +119,18 @@ _corner_anchors = { 'bottom': 1, 'right': 1, } +_y_margin = 5 # XXX: fyi naming here is confusing / opposite to coords _corner_margins = { - ('top', 'left'): (-4, -5), - ('top', 'right'): (4, -5), + ('top', 'left'): (-4, -_y_margin), + ('top', 'right'): (4, -_y_margin), - ('bottom', 'left'): (-4, lambda font_size: font_size * 2), - ('bottom', 'right'): (4, lambda font_size: font_size * 2), + ('bottom', 'left'): (-4, lambda font_size: font_size + 2*_y_margin), + ('bottom', 'right'): (4, lambda font_size: font_size + 2*_y_margin), } -# TODO: change this into a ``pg.TextItem``... +# TODO: change this into our own ``Label`` class ContentsLabel(pg.LabelItem): """Label anchored to a ``ViewBox`` typically for displaying datum-wise points from the "viewed" contents. @@ -235,6 +236,11 @@ class Cursor(pg.GraphicsObject): # line width in view coordinates self._lw = self.pixelWidth() * self.lines_pen.width() + # xhair label's color name + self.label_color: str = 'default' + + self._y_label_update: bool = True + def add_hovered( self, item: pg.GraphicsObject, @@ -257,14 +263,12 @@ class Cursor(pg.GraphicsObject): hl.setCacheMode(QtGui.QGraphicsItem.DeviceCoordinateCache) hl.hide() - label_color = 'default' - yl = YAxisLabel( chart=plot, parent=plot.getAxis('right'), digits=digits or self.digits, opacity=_ch_label_opac, - bg_color=label_color, + bg_color=self.label_color, ) yl.hide() # on startup if mouse is off screen @@ -304,7 +308,7 @@ class Cursor(pg.GraphicsObject): self.xaxis_label = XAxisLabel( parent=self.plots[plot_index].getAxis('bottom'), opacity=_ch_label_opac, - bg_color=label_color, + bg_color=self.label_color, ) # place label off-screen during startup self.xaxis_label.setPos(self.plots[0].mapFromView(QPointF(0, 0))) @@ -373,7 +377,7 @@ class Cursor(pg.GraphicsObject): # px perfect... line_offset = self._lw / 2 - if iy != last_iy: + if iy != last_iy and self._y_label_update: # update y-range items self.graphics[plot]['hl'].setY(iy + line_offset) @@ -422,16 +426,34 @@ class Cursor(pg.GraphicsObject): return self.plots[0].boundingRect() def show_xhair(self) -> None: - plot = self.active_plot g = self.graphics[self.active_plot] # show horiz line and y-label g['hl'].show() g['vl'].show() - g['yl'].show() - def hide_xhair(self) -> None: - plot = self.active_plot + yl = g['yl'] + # yl.fg_color = pg.mkColor(hcolor('black')) + # yl.bg_color = pg.mkColor(hcolor(self.label_color)) + yl.show() + + def hide_xhair( + self, + hide_label: bool = False, + y_label_level: float = None, + fg_color: str = None, + # bg_color: str = 'papas_special', + ) -> None: g = self.graphics[self.active_plot] g['hl'].hide() g['vl'].hide() - g['yl'].hide() + + yl = g['yl'] + + if hide_label: + yl.hide() + elif y_label_level: + yl.update_from_data(0, y_label_level) + + if fg_color is not None: + yl.fg_color = pg.mkColor(hcolor(fg_color)) + yl.bg_color = pg.mkColor(hcolor('papas_special')) From c71a3e0fc5b393c56c10a60863b997d488c7546b Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 13 Mar 2021 17:31:15 -0500 Subject: [PATCH 119/139] Level line look and feel rework Add support for drawing ``QPathGraphicsItem`` markers but don't use them since they seem to be shitting up when combined with the infinite line (bounding rect?): weird artifacts and whatnot. The only way to avoid said glitches seems to be to update inside the infinite line's `.paint()` but that slows stuff down.. Instead stick with the manual paint job use the same pin point: left of the L1 spread graphics - where the lines now also extend to. Further stuff: - Pin the y-label to a line's value on hover. - Disable x-dimension line moving - Rework the labelling to be more minimal --- piker/ui/_graphics/_lines.py | 268 ++++++++++++++++++++++++++--------- 1 file changed, 204 insertions(+), 64 deletions(-) diff --git a/piker/ui/_graphics/_lines.py b/piker/ui/_graphics/_lines.py index 84177085..95e24b82 100644 --- a/piker/ui/_graphics/_lines.py +++ b/piker/ui/_graphics/_lines.py @@ -23,6 +23,7 @@ from typing import Tuple, Optional, List import pyqtgraph as pg from pyqtgraph import Point, functions as fn from PyQt5 import QtCore, QtGui, QtWidgets +from PyQt5.QtGui import QGraphicsPathItem from PyQt5.QtCore import QPointF import numpy as np @@ -34,22 +35,18 @@ from .._style import ( def mk_marker( - self, marker, - position: float = 0.5, - size: float = 10.0 -) -> QtGui.QPainterPath: - """Add a marker to be displayed on the line. + size: float = 20.0 +) -> QGraphicsPathItem: + """Add a marker to be displayed on the line wrapped in a ``QGraphicsPathItem`` + ready to be placed using scene coordinates (not view). - ============= ========================================================= **Arguments** marker String indicating the style of marker to add: ``'<|'``, ``'|>'``, ``'>|'``, ``'|<'``, ``'<|>'``, ``'>|<'``, ``'^'``, ``'v'``, ``'o'`` - position Position (0.0-1.0) along the visible extent of the line - to place the marker. Default is 0.5. size Size of the marker in pixels. Default is 10.0. - ============= ========================================================= + """ path = QtGui.QPainterPath() @@ -90,8 +87,9 @@ def mk_marker( path.addPolygon(p) path.closeSubpath() - self._maxMarkerSize = max([m[2] / 2. for m in self.markers]) + # self._maxMarkerSize = max([m[2] / 2. for m in self.markers]) + return QGraphicsPathItem(path) # TODO: probably worth investigating if we can @@ -133,6 +131,8 @@ class LevelLine(pg.InfiniteLine): self._dotted = dotted self._hide_xhair_on_hover = hide_xhair_on_hover + self._marker = None + if dotted: self._style = QtCore.Qt.DashLine else: @@ -146,6 +146,7 @@ class LevelLine(pg.InfiniteLine): # list of labels anchored at one of the 2 line endpoints # inside the viewbox self._labels: List[(int, Label)] = [] + self._markers: List[(int, Label)] = [] # whenever this line is moved trigger label updates self.sigPositionChanged.connect(self.on_pos_change) @@ -196,26 +197,37 @@ class LevelLine(pg.InfiniteLine): '{level:,.{level_digits}f}' ), side: str = 'right', + side_of_axis: str = 'left', + x_offset: float = 50, font_size_inches: float = _down_2_font_inches_we_like, color: str = None, bg_color: str = None, + avoid_book: bool = True, **label_kwargs, ) -> Label: """Add a ``LevelLabel`` anchored at one of the line endpoints in view. """ - vb = self.getViewBox() - label = Label( - view=vb, + view=self.getViewBox(), fmt_str=fmt_str, color=self.color, ) + # set anchor callback if side == 'right': - label.set_x_anchor_func(right_axis(self._chart, label)) + label.set_x_anchor_func( + right_axis( + self._chart, + label, + side=side_of_axis, + offset=x_offset, + avoid_book=avoid_book, + ) + ) + elif side == 'left': label.set_x_anchor_func(vbr_left(label)) @@ -288,7 +300,8 @@ class LevelLine(pg.InfiniteLine): self.movable = True self.set_level(y) # implictly calls reposition handler - def setMouseHover(self, hover: bool) -> None: + # TODO: just put this in the hoverEvent handler + def set_mouser_hover(self, hover: bool) -> None: """Mouse hover callback. """ @@ -304,34 +317,38 @@ class LevelLine(pg.InfiniteLine): if hover: # highlight if so configured if self._hoh: + self.currentPen = self.hoverPen - # for at, label in self._labels: - # label.highlight(self.hoverPen) + # only disable cursor y-label updates + # if we're highlighting a line + cur._y_label_update = False # add us to cursor state cur.add_hovered(self) if self._hide_xhair_on_hover: - cur.hide_xhair() + cur.hide_xhair( + # set y-label to current value + y_label_level=self.value(), + # fg_color=self._hcolor, + # bg_color=self._hcolor, + ) + + # if we want highlighting of labels + # it should be delegated into this method self.show_labels() - # TODO: hide y-crosshair? - # chart._cursor.graphics[chart]['hl'].hide() - - # self.setCursor(QtCore.Qt.OpenHandCursor) - # self.setCursor(QtCore.Qt.DragMoveCursor) else: + cur._y_label_update = True + self.currentPen = self.pen cur._hovered.remove(self) if self not in cur._trackers: cur.show_xhair() - # g = cur.graphics[chart] - # g['yl'].show() - # g['hl'].show() if not self._always_show_labels: for at, label in self._labels: @@ -382,7 +399,7 @@ class LevelLine(pg.InfiniteLine): m = self._y_incr_mult self.setPos( QPointF( - pos.x(), + self.pos().x(), # don't allow shifting horizontally round(pos.y() * m) / m ) ) @@ -410,6 +427,9 @@ class LevelLine(pg.InfiniteLine): self._labels.clear() + if self._marker: + self.scene().removeItem(self._marker) + # remove from chart/cursor states chart = self._chart cur = chart._cursor @@ -456,8 +476,11 @@ class LevelLine(pg.InfiniteLine): tr = p.transform() for path, pos, size in self.markers: p.setTransform(tr) + + # XXX: we drop the "scale / %" placement # x = length * pos x = right_offset + p.translate(x, 0) p.scale(size, size) p.drawPath(path) @@ -502,7 +525,7 @@ class LevelLine(pg.InfiniteLine): vb_left, vb_right = self._endPoints pen = self.currentPen - pen.setJoinStyle(QtCore.Qt.MiterJoin) + # pen.setJoinStyle(QtCore.Qt.MiterJoin) p.setPen(pen) rsc, rvc, rosc = self.right_point() @@ -512,6 +535,18 @@ class LevelLine(pg.InfiniteLine): Point(rvc, 0) ) + # this seems slower when moving around + # order lines.. not sure wtf is up with that. + # for now we're just using it on the position line. + if self._marker: + scene_pos = QPointF(rsc, self.scene_y()) + self._marker.setPos(scene_pos) + + # somehow this is adding a lot of lag, but without + # if we're getting weird trail artefacs grrr. + # gotta be some kinda boundingRect problem yet again + # self._marker.update() + if self.markers: self.draw_markers( p, @@ -520,14 +555,36 @@ class LevelLine(pg.InfiniteLine): rsc ) + def scene_y(self) -> float: + return self.getViewBox().mapFromView(Point(0, self.value())).y() + + def add_marker( + self, + path: QtGui.QGraphicsPathItem, + ) -> None: + + # chart = self._chart + vb = self.getViewBox() + vb.scene().addItem(path) + + self._marker = path + + rsc, rvc, rosc = self.right_point() + + self._marker.setPen(self.currentPen) + self._marker.setBrush(fn.mkBrush(self.currentPen.color())) + self._marker.scale(20, 20) + # y_in_sc = chart._vb.mapFromView(Point(0, self.value())).y() + path.setPos(QPointF(rsc, self.scene_y())) + def hoverEvent(self, ev): """Gawd, basically overriding it all at this point... """ if (not ev.isExit()) and ev.acceptDrags(QtCore.Qt.LeftButton): - self.setMouseHover(True) + self.set_mouser_hover(True) else: - self.setMouseHover(False) + self.set_mouser_hover(False) def level_line( @@ -588,6 +645,8 @@ def level_line( label = line.add_label( side='right', opacity=1, + x_offset=0, + avoid_book=False, ) label.orient_v = orient_v @@ -606,18 +665,18 @@ def order_line( chart, level: float, level_digits: float, + action: str, # buy or sell size: Optional[int] = 1, size_digits: int = 0, - + show_markers: bool = False, submit_price: float = None, - - order_status: str = 'dark', + exec_type: str = 'dark', order_type: str = 'limit', - orient_v: str = 'bottom', **line_kwargs, + ) -> LevelLine: """Convenience routine to add a line graphic representing an order execution submitted to the EMS via the chart's "order mode". @@ -630,36 +689,115 @@ def order_line( **line_kwargs ) - llabel = line.add_label( - side='left', - fmt_str='{order_status}-{order_type}:{submit_price}', - ) - llabel.fields = { - 'order_status': order_status, - 'order_type': order_type, - 'submit_price': submit_price, - } - llabel.orient_v = orient_v - llabel.render() - llabel.show() + if show_markers: + # add arrow marker on end of line nearest y-axis + marker_style, marker_size = { + 'buy': ('|<', 20), + 'sell': ('>|', 20), + 'alert': ('^', 12), + }[action] - rlabel = line.add_label( - side='right', - fmt_str=( - '{size:.{size_digits}f} x ' - '{level:,.{level_digits}f}' - ), - ) - rlabel.fields = { - 'size': size, - 'size_digits': size_digits, - 'level': level, - 'level_digits': level_digits, - } + # XXX: not sure wtf but this is somehow laggier + # when tested manually staging an order.. + # I would assume it's to do either with come kinda + # conflict of the ``QGraphicsPathItem`` with the custom + # object or that those types are just slower in general... + # Pretty annoying to say the least. + # line.add_marker(mk_marker(marker_style)) - rlabel.orient_v = orient_v - rlabel.render() - rlabel.show() + line.addMarker( + marker_style, + # the "position" here is now ignored since we modified + # internals to pin markers to the right end of the line + 0.9, + marker_size + ) + + orient_v = 'top' if action == 'sell' else 'bottom' + + if action == 'alert': + # completely different labelling for alerts + fmt_str = 'alert => {level}' + + # for now, we're just duplicating the label contents i guess.. + llabel = line.add_label( + side='left', + fmt_str=fmt_str, + ) + llabel.fields = { + 'level': level, + 'level_digits': level_digits, + } + llabel.orient_v = orient_v + llabel.render() + llabel.show() + + # right before L1 label + rlabel = line.add_label( + side='right', + side_of_axis='left', + fmt_str=fmt_str, + ) + rlabel.fields = { + 'level': level, + 'level_digits': level_digits, + } + + rlabel.orient_v = orient_v + rlabel.render() + rlabel.show() + + else: + # left side label + llabel = line.add_label( + side='left', + fmt_str='{exec_type}-{order_type}: ${$value}', + ) + llabel.fields = { + 'order_type': order_type, + 'level': level, + '$value': lambda f: f['level'] * f['size'], + 'size': size, + 'exec_type': exec_type, + } + llabel.orient_v = orient_v + llabel.render() + llabel.show() + + # right before L1 label + rlabel = line.add_label( + side='right', + side_of_axis='left', + fmt_str=( + '{size:.{size_digits}f} x' + ), + ) + rlabel.fields = { + 'size': size, + 'size_digits': size_digits, + } + + rlabel.orient_v = orient_v + rlabel.render() + rlabel.show() + + # axis_label = line.add_label( + # side='right', + # side_of_axis='left', + # x_offset=0, + # avoid_book=False, + # fmt_str=( + # '{level:,.{level_digits}f}' + # ), + # ) + # axis_label.fields = { + # 'level': level, + # 'level_digits': level_digits, + # } + + # axis_label.orient_v = orient_v + # axis_label.render() + # axis_label.show() # sanity check line.update_labels({'level': level}) @@ -691,10 +829,12 @@ def position_line( hide_xhair_on_hover=False, ) if size > 0: - line.addMarker('|<', 0.9, 20) + arrow_path = mk_marker('|<') elif size < 0: - line.addMarker('>|', 0.9, 20) + arrow_path = mk_marker('>|') + + line.add_marker(arrow_path) rlabel = line.add_label( side='left', From c75dacb23964d8fae7f8beb7bfc46d5cada8a3ca Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 13 Mar 2021 19:29:52 -0500 Subject: [PATCH 120/139] Support func ops on field data, extend anchor logics --- piker/ui/_label.py | 60 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/piker/ui/_label.py b/piker/ui/_label.py index 20638c2e..fe629c23 100644 --- a/piker/ui/_label.py +++ b/piker/ui/_label.py @@ -18,13 +18,13 @@ Non-shitty labels that don't re-invent the wheel. """ +from inspect import isfunction from typing import Callable import pyqtgraph as pg from PyQt5 import QtGui from PyQt5.QtCore import QPointF, QRectF - from ._style import ( DpiAwareFont, hcolor, @@ -37,18 +37,16 @@ def vbr_left(label) -> Callable[..., float]: leftmost point of the containing view box. """ - - def viewbox_left(): - return label.vbr().left() - - return viewbox_left + return label.vbr().left def right_axis( chart: 'ChartPlotWidget', # noqa label: 'Label', # noqa + side: str = 'left', offset: float = 10, + avoid_book: bool = True, width: float = None, ) -> Callable[..., float]: @@ -59,17 +57,35 @@ def right_axis( """ ryaxis = chart.getAxis('right') - def right_axis_offset_by_w() -> float: + if side == 'left': - # l1 spread graphics x-size - l1_len = chart._max_l1_line_len + if avoid_book: + def right_axis_offset_by_w() -> float: - # sum of all distances "from" the y-axis - right_offset = l1_len + label.w + offset + # l1 spread graphics x-size + l1_len = chart._max_l1_line_len - return ryaxis.pos().x() - right_offset + # sum of all distances "from" the y-axis + right_offset = l1_len + label.w + offset - return right_axis_offset_by_w + return ryaxis.pos().x() - right_offset + + else: + def right_axis_offset_by_w() -> float: + + return ryaxis.pos().x() - (label.w + offset) + + return right_axis_offset_by_w + + elif 'right': + + # axis_offset = ryaxis.style['tickTextOffset'][0] + + def on_axis() -> float: + + return ryaxis.pos().x() # + axis_offset - 2 + + return on_axis class Label: @@ -200,7 +216,23 @@ class Label: self._fmt_str = fmt_str def format(self, **fields: dict) -> str: - text = self._fmt_str.format(**fields) + + out = {} + + # this is hacky support for single depth + # calcs of field data from field data + # ex. to calculate a $value = price * size + for k, v in fields.items(): + if isfunction(v): + out[k] = v(fields) + else: + out[k] = v + + text = self._fmt_str.format(**out) + + # for large numbers with a thousands place + text = text.replace(',', ' ') + self.txt.setPlainText(text) def render(self) -> None: From 776395791abfbbcb0cd81d20b679a45c6e8fe728 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 13 Mar 2021 19:31:03 -0500 Subject: [PATCH 121/139] Pass action to line editor --- piker/ui/_interaction.py | 8 ++++++++ piker/ui/order_mode.py | 23 +++++++++++++++-------- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 41bc8571..0764f880 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -217,6 +217,7 @@ class LineEditor: def stage_line( self, + action: str, color: str = 'alert_yellow', hl_on_hover: bool = False, @@ -263,7 +264,11 @@ class LineEditor: # don't highlight the "staging" line hl_on_hover=hl_on_hover, dotted=dotted, + exec_type='dark' if dotted else 'live', + action=action, + show_markers=True, ) + self._active_staged_line = line # hide crosshair y-line and label @@ -299,6 +304,7 @@ class LineEditor: level: float, chart: 'ChartPlotWidget', # noqa size: float, + action: str, ) -> LevelLine: line = self._active_staged_line @@ -320,6 +326,8 @@ class LineEditor: # LevelLine kwargs color=line.color, dotted=line._dotted, + + action=action, ) # for now, until submission reponse arrives diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index 8c643953..c36474e7 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -39,7 +39,6 @@ from ..log import get_logger log = get_logger(__name__) - class Position(BaseModel): symbol: Symbol size: float @@ -84,16 +83,19 @@ class OrderMode: if msg['symbol'].lower() not in sym.key: return + size = msg['size'] + self._position.update(msg) if self._position_line: self._position_line.delete() - line = self._position_line = position_line( - self.chart, - level=msg['avg_price'], - size=msg['size'], - ) - line.show() + if size != 0.0: + line = self._position_line = position_line( + self.chart, + level=msg['avg_price'], + size=size, + ) + line.show() def uuid(self) -> str: return str(uuid.uuid4()) @@ -108,10 +110,12 @@ class OrderMode: """ 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, + action=action, ) def on_submit(self, uuid: str) -> dict: @@ -206,6 +210,8 @@ class OrderMode: symbol = self.chart._lc._symbol + action = self._action + # send order cmd to ems self.book.send( uuid=uid, @@ -213,7 +219,7 @@ class OrderMode: brokers=symbol.brokers, price=y, size=size, - action=self._action, + action=action, exec_mode=self._exec_mode, ) @@ -224,6 +230,7 @@ class OrderMode: level=y, chart=chart, size=size, + action=action, ) line.oid = uid From cf2f001bcc5695a618f00d44c197fb4147ba16ea Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 13 Mar 2021 21:06:49 -0500 Subject: [PATCH 122/139] Add save last datum toggle to y-label --- piker/ui/_axes.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/piker/ui/_axes.py b/piker/ui/_axes.py index c70578d5..72de9f48 100644 --- a/piker/ui/_axes.py +++ b/piker/ui/_axes.py @@ -438,12 +438,15 @@ class YAxisLabel(AxisLabel): self, index: int, value: float, + _save_last: bool = True, ) -> None: """Update the label's text contents **and** position from a view box coordinate datum. """ - self._last_datum = (index, value) + if _save_last: + self._last_datum = (index, value) + self.update_label( self._chart.mapFromView(QPointF(index, value)), value From 61198818dc921cee33c49863ecb6a13dda592915 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sun, 14 Mar 2021 12:28:11 -0400 Subject: [PATCH 123/139] Fix label snap on line highlight bug --- piker/ui/_graphics/_cursor.py | 25 ++++++++++++++++++------- piker/ui/_graphics/_lines.py | 9 +++++---- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/piker/ui/_graphics/_cursor.py b/piker/ui/_graphics/_cursor.py index cab01856..d65c5dce 100644 --- a/piker/ui/_graphics/_cursor.py +++ b/piker/ui/_graphics/_cursor.py @@ -377,15 +377,16 @@ class Cursor(pg.GraphicsObject): # px perfect... line_offset = self._lw / 2 - if iy != last_iy and self._y_label_update: + if iy != last_iy: # update y-range items self.graphics[plot]['hl'].setY(iy + line_offset) - self.graphics[self.active_plot]['yl'].update_label( - abs_pos=plot.mapFromView(QPointF(ix, iy + line_offset)), - value=iy - ) + if self._y_label_update: + self.graphics[self.active_plot]['yl'].update_label( + abs_pos=plot.mapFromView(QPointF(ix, iy + line_offset)), + value=iy + ) # update all trackers for item in self._trackers: @@ -425,15 +426,22 @@ class Cursor(pg.GraphicsObject): except AttributeError: return self.plots[0].boundingRect() - def show_xhair(self) -> None: + def show_xhair( + self, + y_label_level: float = None, + ) -> None: g = self.graphics[self.active_plot] # show horiz line and y-label g['hl'].show() g['vl'].show() + self._y_label_update = True yl = g['yl'] # yl.fg_color = pg.mkColor(hcolor('black')) # yl.bg_color = pg.mkColor(hcolor(self.label_color)) + if y_label_level: + yl.update_from_data(0, y_label_level, _save_last=False) + yl.show() def hide_xhair( @@ -447,12 +455,15 @@ class Cursor(pg.GraphicsObject): g['hl'].hide() g['vl'].hide() + # only disable cursor y-label updates + # if we're highlighting a line yl = g['yl'] if hide_label: yl.hide() + elif y_label_level: - yl.update_from_data(0, y_label_level) + yl.update_from_data(0, y_label_level, _save_last=False) if fg_color is not None: yl.fg_color = pg.mkColor(hcolor(fg_color)) diff --git a/piker/ui/_graphics/_lines.py b/piker/ui/_graphics/_lines.py index 95e24b82..5ac174aa 100644 --- a/piker/ui/_graphics/_lines.py +++ b/piker/ui/_graphics/_lines.py @@ -320,9 +320,10 @@ class LevelLine(pg.InfiniteLine): self.currentPen = self.hoverPen - # only disable cursor y-label updates - # if we're highlighting a line - cur._y_label_update = False + if self not in cur._trackers: + # only disable cursor y-label updates + # if we're highlighting a line + cur._y_label_update = False # add us to cursor state cur.add_hovered(self) @@ -348,7 +349,7 @@ class LevelLine(pg.InfiniteLine): cur._hovered.remove(self) if self not in cur._trackers: - cur.show_xhair() + cur.show_xhair(y_label_level=self.value()) if not self._always_show_labels: for at, label in self._labels: From adf643744913552fd752a673fc64794b3ebf779d Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 16 Mar 2021 15:58:17 -0400 Subject: [PATCH 124/139] Move margin settings to class scope --- piker/ui/_graphics/_cursor.py | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/piker/ui/_graphics/_cursor.py b/piker/ui/_graphics/_cursor.py index d65c5dce..3afc2723 100644 --- a/piker/ui/_graphics/_cursor.py +++ b/piker/ui/_graphics/_cursor.py @@ -113,21 +113,8 @@ class LineDot(pg.CurvePoint): return False -_corner_anchors = { - 'top': 0, - 'left': 0, - 'bottom': 1, - 'right': 1, -} +# TODO: likely will need to tweak this based on dpi... _y_margin = 5 -# XXX: fyi naming here is confusing / opposite to coords -_corner_margins = { - ('top', 'left'): (-4, -_y_margin), - ('top', 'right'): (4, -_y_margin), - - ('bottom', 'left'): (-4, lambda font_size: font_size + 2*_y_margin), - ('bottom', 'right'): (4, lambda font_size: font_size + 2*_y_margin), -} # TODO: change this into our own ``Label`` @@ -136,6 +123,22 @@ class ContentsLabel(pg.LabelItem): datum-wise points from the "viewed" contents. """ + _corner_anchors = { + 'top': 0, + 'left': 0, + 'bottom': 1, + 'right': 1, + } + + # XXX: fyi naming here is confusing / opposite to coords + _corner_margins = { + ('top', 'left'): (-4, -_y_margin), + ('top', 'right'): (4, -_y_margin), + + ('bottom', 'left'): (-4, lambda font_size: font_size + 2*_y_margin), + ('bottom', 'right'): (4, lambda font_size: font_size + 2*_y_margin), + } + def __init__( self, chart: 'ChartPlotWidget', # noqa @@ -155,8 +158,8 @@ class ContentsLabel(pg.LabelItem): self.chart = chart v, h = anchor_at - index = (_corner_anchors[h], _corner_anchors[v]) - margins = _corner_margins[(v, h)] + index = (self._corner_anchors[h], self._corner_anchors[v]) + margins = self._corner_margins[(v, h)] ydim = margins[1] if inspect.isfunction(margins[1]): From d1c8c2a072a450b0164b0c789e1446cd0530b69f Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Tue, 16 Mar 2021 19:36:05 -0400 Subject: [PATCH 125/139] More level line reworking - break (custom) graphics item style marker drawing into separate func but keep using it since it still seems oddly faster then the QGraphicsPathItem thing.. - unfactor hover handler; it was uncessary - make both the graphics path item and custom graphics items approaches both work inside ``.paint()`` --- piker/ui/_graphics/_lines.py | 428 +++++++++++++++++++---------------- 1 file changed, 230 insertions(+), 198 deletions(-) diff --git a/piker/ui/_graphics/_lines.py b/piker/ui/_graphics/_lines.py index 5ac174aa..39380e3f 100644 --- a/piker/ui/_graphics/_lines.py +++ b/piker/ui/_graphics/_lines.py @@ -35,14 +35,15 @@ from .._style import ( def mk_marker( - marker, - size: float = 20.0 + style, + size: float = 20.0, + use_qgpath: bool = True, ) -> QGraphicsPathItem: """Add a marker to be displayed on the line wrapped in a ``QGraphicsPathItem`` ready to be placed using scene coordinates (not view). **Arguments** - marker String indicating the style of marker to add: + style String indicating the style of marker to add: ``'<|'``, ``'|>'``, ``'>|'``, ``'|<'``, ``'<|>'``, ``'>|<'``, ``'^'``, ``'v'``, ``'o'`` size Size of the marker in pixels. Default is 10.0. @@ -50,46 +51,104 @@ def mk_marker( """ path = QtGui.QPainterPath() - if marker == 'o': + if style == 'o': path.addEllipse(QtCore.QRectF(-0.5, -0.5, 1, 1)) # arrow pointing away-from the top of line - if '<|' in marker: + if '<|' in style: p = QtGui.QPolygonF([Point(0.5, 0), Point(0, -0.5), Point(-0.5, 0)]) path.addPolygon(p) path.closeSubpath() # arrow pointing away-from the bottom of line - if '|>' in marker: + if '|>' in style: p = QtGui.QPolygonF([Point(0.5, 0), Point(0, 0.5), Point(-0.5, 0)]) path.addPolygon(p) path.closeSubpath() # arrow pointing in-to the top of line - if '>|' in marker: + if '>|' in style: p = QtGui.QPolygonF([Point(0.5, -0.5), Point(0, 0), Point(-0.5, -0.5)]) path.addPolygon(p) path.closeSubpath() # arrow pointing in-to the bottom of line - if '|<' in marker: + if '|<' in style: p = QtGui.QPolygonF([Point(0.5, 0.5), Point(0, 0), Point(-0.5, 0.5)]) path.addPolygon(p) path.closeSubpath() - if '^' in marker: + if '^' in style: p = QtGui.QPolygonF([Point(0, -0.5), Point(0.5, 0), Point(0, 0.5)]) path.addPolygon(p) path.closeSubpath() - if 'v' in marker: + if 'v' in style: p = QtGui.QPolygonF([Point(0, -0.5), Point(-0.5, 0), Point(0, 0.5)]) path.addPolygon(p) path.closeSubpath() # self._maxMarkerSize = max([m[2] / 2. for m in self.markers]) - return QGraphicsPathItem(path) + if use_qgpath: + path = QGraphicsPathItem(path) + path.scale(size, size) + + return path + + +def draw_markers( + markers: list, + color: pg.Color, + p: QtGui.QPainter, + left: float, + right: float, + right_offset: float, +) -> None: + """Pain markers in ``pg.GraphicsItem`` style by first + removing the view transform for the painter, drawing the markers + in scene coords, then restoring the view coords. + + """ + # paint markers in native coordinate system + orig_tr = p.transform() + + start = orig_tr.map(Point(left, 0)) + end = orig_tr.map(Point(right, 0)) + up = orig_tr.map(Point(left, 1)) + + dif = end - start + # length = Point(dif).length() + angle = np.arctan2(dif.y(), dif.x()) * 180 / np.pi + + p.resetTransform() + + p.translate(start) + p.rotate(angle) + + up = up - start + det = up.x() * dif.y() - dif.x() * up.y() + p.scale(1, 1 if det > 0 else -1) + + p.setBrush(fn.mkBrush(color)) + # p.setBrush(fn.mkBrush(self.currentPen.color())) + tr = p.transform() + + sizes = [] + for path, pos, size in markers: + p.setTransform(tr) + + # XXX: we drop the "scale / %" placement + # x = length * pos + x = right_offset + + p.translate(x, 0) + p.scale(size, size) + p.drawPath(path) + sizes.append(size) + + p.setTransform(orig_tr) + return max(sizes) # TODO: probably worth investigating if we can @@ -164,6 +223,8 @@ class LevelLine(pg.InfiniteLine): self._y_incr_mult = 1 / chart._lc._symbol.tick_size + self._right_end_sc: float = 0 + def txt_offsets(self) -> Tuple[int, int]: return 0, 0 @@ -300,67 +361,6 @@ class LevelLine(pg.InfiniteLine): self.movable = True self.set_level(y) # implictly calls reposition handler - # TODO: just put this in the hoverEvent handler - def set_mouser_hover(self, hover: bool) -> None: - """Mouse hover callback. - - """ - # XXX: currently we'll just return if _hoh is False - if self.mouseHovering == hover: - return - - self.mouseHovering = hover - - chart = self._chart - cur = chart._cursor - - if hover: - # highlight if so configured - if self._hoh: - - self.currentPen = self.hoverPen - - if self not in cur._trackers: - # only disable cursor y-label updates - # if we're highlighting a line - cur._y_label_update = False - - # add us to cursor state - cur.add_hovered(self) - - if self._hide_xhair_on_hover: - cur.hide_xhair( - # set y-label to current value - y_label_level=self.value(), - - # fg_color=self._hcolor, - # bg_color=self._hcolor, - ) - - # if we want highlighting of labels - # it should be delegated into this method - self.show_labels() - - else: - cur._y_label_update = True - - self.currentPen = self.pen - - cur._hovered.remove(self) - - if self not in cur._trackers: - cur.show_xhair(y_label_level=self.value()) - - if not self._always_show_labels: - for at, label in self._labels: - label.hide() - label.txt.update() - # label.unhighlight() - - # highlight any attached label - - self.update() - def mouseDragEvent(self, ev): """Override the ``InfiniteLine`` handler since we need more detailed control and start end signalling. @@ -448,44 +448,6 @@ class LevelLine(pg.InfiniteLine): # TODO: enter labels edit mode print(f'double click {ev}') - def draw_markers( - self, - p: QtGui.QPainter, - left: float, - right: float, - right_offset: float, - ) -> None: - # paint markers in native coordinate system - tr = p.transform() - p.resetTransform() - - start = tr.map(Point(left, 0)) - end = tr.map(Point(right, 0)) - up = tr.map(Point(left, 1)) - dif = end - start - # length = Point(dif).length() - angle = np.arctan2(dif.y(), dif.x()) * 180 / np.pi - - p.translate(start) - p.rotate(angle) - - up = up - start - det = up.x() * dif.y() - dif.x() * up.y() - p.scale(1, 1 if det > 0 else -1) - - p.setBrush(fn.mkBrush(self.currentPen.color())) - tr = p.transform() - for path, pos, size in self.markers: - p.setTransform(tr) - - # XXX: we drop the "scale / %" placement - # x = length * pos - x = right_offset - - p.translate(x, 0) - p.scale(size, size) - p.drawPath(path) - def right_point( self, ) -> float: @@ -493,24 +455,17 @@ class LevelLine(pg.InfiniteLine): chart = self._chart l1_len = chart._max_l1_line_len ryaxis = chart.getAxis('right') + up_to_l1_sc = ryaxis.pos().x() - l1_len - if self.markers: - size = self.markers[0][2] - else: - size = 0 + # right_view_coords = chart._vb.mapToView( + # Point(right_scene_coords, 0)).x() - r_axis_x = ryaxis.pos().x() - right_offset = l1_len + size + 10 - right_scene_coords = r_axis_x - right_offset - - right_view_coords = chart._vb.mapToView( - Point(right_scene_coords, 0)).x() - - return ( - right_scene_coords, - right_view_coords, - right_offset, - ) + return up_to_l1_sc + # return ( + # right_scene_coords, + # right_view_coords, + # right_offset, + # ) def paint( self, @@ -529,32 +484,79 @@ class LevelLine(pg.InfiniteLine): # pen.setJoinStyle(QtCore.Qt.MiterJoin) p.setPen(pen) - rsc, rvc, rosc = self.right_point() + # l1_sc, rvc, rosc = self.right_point() + + chart = self._chart + l1_len = chart._max_l1_line_len + ryaxis = chart.getAxis('right') + + r_axis_x = ryaxis.pos().x() + # right_offset = l1_len # + size #+ 10 + up_to_l1_sc = r_axis_x - l1_len + + vb = self.getViewBox() + + size = 20 # default marker size + marker_right = up_to_l1_sc - (1.375 * size) + + if self.markers: + + # size = self.markers[0][2] + + # # three_m_right_of_last_bar = last_bar_sc + 3*size + + # # marker_right = min( + # # two_m_left_of_l1, + # # three_m_right_of_last_bar + # # ) + + size = draw_markers( + self.markers, + self.currentPen.color(), + p, + vb_left, + # right, + vb_right, + # rsc - 6, + marker_right, + # right_scene_coords, + ) + # marker_size = self.markers[0][2] + self._maxMarkerSize = max([m[2] / 2. for m in self.markers]) + + line_end = marker_right - (6/16 * size) + + # else: + # line_end = last_bar_sc + # line_end_view = rvc + + # # this seems slower when moving around + # # order lines.. not sure wtf is up with that. + # # for now we're just using it on the position line. + elif self._marker: + self._marker.setPos(QPointF(marker_right, self.scene_y())) + line_end = marker_right - (6/16 * size) + else: + # leave small blank gap for style + line_end = r_axis_x - 10 + + line_end_view = vb.mapToView(Point(line_end, 0)).x() + # # somehow this is adding a lot of lag, but without + # # if we're getting weird trail artefacs grrr. + # # gotta be some kinda boundingRect problem yet again + # self._marker.update() p.drawLine( Point(vb_left, 0), - Point(rvc, 0) + # Point(right, 0) + Point(line_end_view, 0) ) + self._right_end_sc = line_end - # this seems slower when moving around - # order lines.. not sure wtf is up with that. - # for now we're just using it on the position line. - if self._marker: - scene_pos = QPointF(rsc, self.scene_y()) - self._marker.setPos(scene_pos) - - # somehow this is adding a lot of lag, but without - # if we're getting weird trail artefacs grrr. - # gotta be some kinda boundingRect problem yet again - # self._marker.update() - - if self.markers: - self.draw_markers( - p, - vb_left, - vb_right, - rsc - ) + def scene_right_xy(self) -> QPointF: + return self.getViewBox().mapFromView( + QPointF(0, self.value()) + ) def scene_y(self) -> float: return self.getViewBox().mapFromView(Point(0, self.value())).y() @@ -570,22 +572,79 @@ class LevelLine(pg.InfiniteLine): self._marker = path - rsc, rvc, rosc = self.right_point() + rsc = self.right_point() self._marker.setPen(self.currentPen) self._marker.setBrush(fn.mkBrush(self.currentPen.color())) - self._marker.scale(20, 20) # y_in_sc = chart._vb.mapFromView(Point(0, self.value())).y() path.setPos(QPointF(rsc, self.scene_y())) + # self.update() + def hoverEvent(self, ev): - """Gawd, basically overriding it all at this point... + """Mouse hover callback. """ + chart = self._chart + cur = chart._cursor + + # hovered if (not ev.isExit()) and ev.acceptDrags(QtCore.Qt.LeftButton): - self.set_mouser_hover(True) - else: - self.set_mouser_hover(False) + + # if already hovered we don't need to run again + if self.mouseHovering is True: + return + + # highlight if so configured + if self._hoh: + + self.currentPen = self.hoverPen + + if self not in cur._trackers: + # only disable cursor y-label updates + # if we're highlighting a line + cur._y_label_update = False + + # add us to cursor state + cur.add_hovered(self) + + if self._hide_xhair_on_hover: + cur.hide_xhair( + # set y-label to current value + y_label_level=self.value(), + + # fg_color=self._hcolor, + # bg_color=self._hcolor, + ) + + # if we want highlighting of labels + # it should be delegated into this method + self.show_labels() + + self.mouseHovering = True + + else: # un-hovered + if self.mouseHovering is False: + return + + cur._y_label_update = True + + self.currentPen = self.pen + + cur._hovered.remove(self) + + if self not in cur._trackers: + cur.show_xhair(y_label_level=self.value()) + + if not self._always_show_labels: + for at, label in self._labels: + label.hide() + label.txt.update() + # label.unhighlight() + + self.mouseHovering = False + + self.update() def level_line( @@ -698,21 +757,25 @@ def order_line( 'alert': ('^', 12), }[action] - # XXX: not sure wtf but this is somehow laggier - # when tested manually staging an order.. - # I would assume it's to do either with come kinda - # conflict of the ``QGraphicsPathItem`` with the custom - # object or that those types are just slower in general... - # Pretty annoying to say the least. - # line.add_marker(mk_marker(marker_style)) + # this fixes it the artifact issue! .. of course, bouding rect stuff + line._maxMarkerSize = marker_size - line.addMarker( + # use ``QPathGraphicsItem``s to draw markers in scene coords + # instead of the old way that was doing the same but by + # resetting the graphics item transform intermittently + # line.add_marker(mk_marker(marker_style, marker_size)) + assert not line.markers + + # the old way which is still somehow faster? + path = mk_marker( marker_style, # the "position" here is now ignored since we modified # internals to pin markers to the right end of the line - 0.9, - marker_size + marker_size, + use_qgpath=False, ) + # manually append for later ``.pain()`` drawing + line.markers.append((path, 0, marker_size)) orient_v = 'top' if action == 'sell' else 'bottom' @@ -733,26 +796,11 @@ def order_line( llabel.render() llabel.show() - # right before L1 label - rlabel = line.add_label( - side='right', - side_of_axis='left', - fmt_str=fmt_str, - ) - rlabel.fields = { - 'level': level, - 'level_digits': level_digits, - } - - rlabel.orient_v = orient_v - rlabel.render() - rlabel.show() - else: # left side label llabel = line.add_label( side='left', - fmt_str='{exec_type}-{order_type}: ${$value}', + fmt_str=' {exec_type}-{order_type}:\n ${$value}', ) llabel.fields = { 'order_type': order_type, @@ -782,24 +830,6 @@ def order_line( rlabel.render() rlabel.show() - # axis_label = line.add_label( - # side='right', - # side_of_axis='left', - # x_offset=0, - # avoid_book=False, - # fmt_str=( - # '{level:,.{level_digits}f}' - # ), - # ) - # axis_label.fields = { - # 'level': level, - # 'level_digits': level_digits, - # } - - # axis_label.orient_v = orient_v - # axis_label.render() - # axis_label.show() - # sanity check line.update_labels({'level': level}) @@ -839,7 +869,7 @@ def position_line( rlabel = line.add_label( side='left', - fmt_str='{direction}: {size}\n${$:.2f}', + fmt_str='{direction}: {size} -> ${$:.2f}', ) rlabel.fields = { 'direction': 'long' if size > 0 else 'short', @@ -850,6 +880,8 @@ def position_line( rlabel.render() rlabel.show() + line.set_level(level) + # sanity check line.update_labels({'level': level}) From 17b66e685f38ff74e05a3ae671c2be93a8c9d5ca Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 17 Mar 2021 08:25:58 -0400 Subject: [PATCH 126/139] Experiment with zoom focal @ L1 edge --- piker/ui/_interaction.py | 44 ++++++++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index 0764f880..a6e70240 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -18,9 +18,11 @@ Chart view box primitives. """ from dataclasses import dataclass, field +from math import floor from typing import Optional, Dict import pyqtgraph as pg +from PyQt5.QtCore import QPointF from pyqtgraph import ViewBox, Point, QtCore, QtGui from pyqtgraph import functions as fn import numpy as np @@ -107,8 +109,8 @@ class SelectRect(QtGui.QGraphicsRectItem): def mouse_drag_released( self, - p1: QtCore.QPointF, - p2: QtCore.QPointF + p1: QPointF, + p2: QPointF ) -> None: """Called on final button release for mouse drag with start and end positions. @@ -118,8 +120,8 @@ class SelectRect(QtGui.QGraphicsRectItem): def set_pos( self, - p1: QtCore.QPointF, - p2: QtCore.QPointF + p1: QPointF, + p2: QPointF ) -> None: """Set position of selection rect and accompanying label, move label to match. @@ -494,15 +496,17 @@ class ChartView(ViewBox): else: mask = self.state['mouseEnabled'][:] + chart = self.linked_charts.chart + # don't zoom more then the min points setting - l, lbar, rbar, r = self.linked_charts.chart.bars_range() + l, lbar, rbar, r = chart.bars_range() vl = r - l if ev.delta() > 0 and vl <= _min_points_to_show: log.debug("Max zoom bruh...") return - if ev.delta() < 0 and vl >= len(self.linked_charts.chart._ohlc) + 666: + if ev.delta() < 0 and vl >= len(chart._ohlc) + 666: log.debug("Min zoom bruh...") return @@ -527,10 +531,34 @@ class ChartView(ViewBox): # This seems like the most "intuitive option, a hybrid of # tws and tv styles - last_bar = pg.Point(int(rbar)) + last_bar = pg.Point(int(rbar)) + 1 + + ryaxis = chart.getAxis('right') + r_axis_x = ryaxis.pos().x() + + end_of_l1 = pg.Point( + round( + chart._vb.mapToView( + pg.Point(r_axis_x - chart._max_l1_line_len) + # QPointF(chart._max_l1_line_len, 0) + ).x() + ) + ) # .x() + + # self.state['viewRange'][0][1] = end_of_l1 + + # focal = pg.Point((last_bar.x() + end_of_l1)/2) + + focal = min( + last_bar, + end_of_l1, + key=lambda p: p.x() + ) + # breakpoint() + # focal = pg.Point(last_bar.x() + end_of_l1) self._resetTarget() - self.scaleBy(s, last_bar) + self.scaleBy(s, focal) ev.accept() self.sigRangeChangedManually.emit(mask) From aaca2b2f3372e4bd27ca2decfcb1e91b9348e5d9 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 17 Mar 2021 08:26:28 -0400 Subject: [PATCH 127/139] Tweak L1 labels to have more spaces --- piker/ui/_l1.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/piker/ui/_l1.py b/piker/ui/_l1.py index ab7f8363..02e3a49a 100644 --- a/piker/ui/_l1.py +++ b/piker/ui/_l1.py @@ -53,7 +53,7 @@ class LevelLabel(YAxisLabel): 'level_digits': 2, } # default label template is just a y-level with so much precision - _fmt_str = '{level:,.{level_digits}f}' + _fmt_str = '{level:,.{level_digits}f} ' def __init__( self, @@ -265,8 +265,8 @@ class L1Labels: } fmt_str = ( - '{size:.{size_digits}f} x ' - '{level:,.{level_digits}f}' + ' {size:.{size_digits}f} x ' + '{level:,.{level_digits}f} ' ) fields = { 'level': 0, From e1dc2b9225c5f70afd235d487353d5244cb91621 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Wed, 17 Mar 2021 08:36:34 -0400 Subject: [PATCH 128/139] Enable daemon debug through top level kwarg --- piker/data/__init__.py | 6 +++--- piker/ui/_chart.py | 4 ++-- piker/ui/order_mode.py | 3 +++ 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/piker/data/__init__.py b/piker/data/__init__.py index b9c09e1c..49b0acb9 100644 --- a/piker/data/__init__.py +++ b/piker/data/__init__.py @@ -91,7 +91,7 @@ async def maybe_spawn_brokerd( # XXX: you should pretty much never want debug mode # for data daemons when running in production. - debug_mode: bool = False, + debug_mode: bool = True, ) -> tractor._portal.Portal: """If no ``brokerd.{brokername}`` daemon-actor can be found, spawn one in a local subactor and return a portal to it. @@ -115,7 +115,7 @@ async def maybe_spawn_brokerd( tractor_kwargs = getattr(brokermod, '_spawn_kwargs', {}) async with tractor.open_nursery( - debug_mode=False, + #debug_mode=debug_mode, ) as nursery: try: # spawn new daemon @@ -244,7 +244,7 @@ async def open_feed( loglevel=loglevel, # TODO: add a cli flag for this - debug_mode=False, + # debug_mode=False, ) as portal: diff --git a/piker/ui/_chart.py b/piker/ui/_chart.py index 950ed010..d62fef5b 100644 --- a/piker/ui/_chart.py +++ b/piker/ui/_chart.py @@ -408,7 +408,7 @@ class ChartPlotWidget(pg.PlotWidget): self.name = name self._lc = linked_charts - # view-local placeholder for book graphics + # scene-local placeholder for book graphics # sizing to avoid overlap with data contents self._max_l1_line_len: float = 0 @@ -448,7 +448,7 @@ class ChartPlotWidget(pg.PlotWidget): # for when the splitter(s) are resized self._vb.sigResized.connect(self._set_yrange) - def last_bar_in_view(self) -> bool: + def last_bar_in_view(self) -> int: self._ohlc[-1]['index'] def update_contents_labels( diff --git a/piker/ui/order_mode.py b/piker/ui/order_mode.py index c36474e7..84806156 100644 --- a/piker/ui/order_mode.py +++ b/piker/ui/order_mode.py @@ -181,10 +181,13 @@ class OrderMode: 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)}' From 2c7d8cdbb0e084017c9ce6bb8f9f8f3e6e817213 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 18 Mar 2021 13:27:49 -0400 Subject: [PATCH 129/139] Support hiding only "half" the xhair --- piker/ui/_graphics/_cursor.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/piker/ui/_graphics/_cursor.py b/piker/ui/_graphics/_cursor.py index 3afc2723..4144e3fd 100644 --- a/piker/ui/_graphics/_cursor.py +++ b/piker/ui/_graphics/_cursor.py @@ -380,17 +380,19 @@ class Cursor(pg.GraphicsObject): # px perfect... line_offset = self._lw / 2 + # update y-range items if iy != last_iy: - # update y-range items - self.graphics[plot]['hl'].setY(iy + line_offset) - if self._y_label_update: self.graphics[self.active_plot]['yl'].update_label( abs_pos=plot.mapFromView(QPointF(ix, iy + line_offset)), value=iy ) + # only update horizontal xhair line if label is enabled + self.graphics[plot]['hl'].setY(iy + line_offset) + + # update all trackers for item in self._trackers: # print(f'setting {item} with {(ix, y)}') @@ -451,11 +453,16 @@ class Cursor(pg.GraphicsObject): self, hide_label: bool = False, y_label_level: float = None, + just_vertical: bool = False, fg_color: str = None, # bg_color: str = 'papas_special', ) -> None: g = self.graphics[self.active_plot] - g['hl'].hide() + + hl = g['hl'] + if not just_vertical: + hl.hide() + g['vl'].hide() # only disable cursor y-label updates @@ -467,6 +474,7 @@ class Cursor(pg.GraphicsObject): elif y_label_level: yl.update_from_data(0, y_label_level, _save_last=False) + hl.setY(y_label_level) if fg_color is not None: yl.fg_color = pg.mkColor(hcolor(fg_color)) From cd5da45abf75e072619af4b0c91b64a8ded4313d Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 18 Mar 2021 13:28:28 -0400 Subject: [PATCH 130/139] Show order line marker on hover --- piker/ui/_graphics/_lines.py | 126 ++++++++++++++++++++--------------- 1 file changed, 73 insertions(+), 53 deletions(-) diff --git a/piker/ui/_graphics/_lines.py b/piker/ui/_graphics/_lines.py index 39380e3f..4dadc033 100644 --- a/piker/ui/_graphics/_lines.py +++ b/piker/ui/_graphics/_lines.py @@ -166,13 +166,19 @@ class LevelLine(pg.InfiniteLine): self, chart: 'ChartPlotWidget', # type: ignore # noqa + # style color: str = 'default', highlight_color: str = 'default_light', - - hl_on_hover: bool = True, dotted: bool = False, + marker_size: int = 20, + + # UX look and feel opts always_show_labels: bool = False, + hl_on_hover: bool = True, hide_xhair_on_hover: bool = True, + only_show_markers_on_hover: bool = True, + use_marker_margin: bool = False, + movable: bool = True, ) -> None: @@ -191,6 +197,13 @@ class LevelLine(pg.InfiniteLine): self._hide_xhair_on_hover = hide_xhair_on_hover self._marker = None + self._default_mkr_size = marker_size + self._moh = only_show_markers_on_hover + self.show_markers: bool = True # presuming the line is hovered at init + + # should line go all the way to far end or leave a "margin" + # space for other graphics (eg. L1 book) + self.use_marker_margin: bool = use_marker_margin if dotted: self._style = QtCore.Qt.DashLine @@ -222,6 +235,7 @@ class LevelLine(pg.InfiniteLine): self._on_drag_end = lambda l: None self._y_incr_mult = 1 / chart._lc._symbol.tick_size + self._last_scene_y: float = 0 self._right_end_sc: float = 0 @@ -259,7 +273,7 @@ class LevelLine(pg.InfiniteLine): ), side: str = 'right', side_of_axis: str = 'left', - x_offset: float = 50, + x_offset: float = 0, font_size_inches: float = _down_2_font_inches_we_like, color: str = None, @@ -457,15 +471,7 @@ class LevelLine(pg.InfiniteLine): ryaxis = chart.getAxis('right') up_to_l1_sc = ryaxis.pos().x() - l1_len - # right_view_coords = chart._vb.mapToView( - # Point(right_scene_coords, 0)).x() - return up_to_l1_sc - # return ( - # right_scene_coords, - # right_view_coords, - # right_offset, - # ) def paint( self, @@ -479,80 +485,67 @@ class LevelLine(pg.InfiniteLine): """ p.setRenderHint(p.Antialiasing) + # these are in viewbox coords vb_left, vb_right = self._endPoints - pen = self.currentPen - # pen.setJoinStyle(QtCore.Qt.MiterJoin) - p.setPen(pen) - - # l1_sc, rvc, rosc = self.right_point() chart = self._chart l1_len = chart._max_l1_line_len ryaxis = chart.getAxis('right') r_axis_x = ryaxis.pos().x() - # right_offset = l1_len # + size #+ 10 up_to_l1_sc = r_axis_x - l1_len vb = self.getViewBox() - size = 20 # default marker size - marker_right = up_to_l1_sc - (1.375 * size) + size = self._default_mkr_size + marker_right = up_to_l1_sc - (1.375 * 2*size) + line_end = marker_right - (6/16 * size) - if self.markers: + if self.show_markers and self.markers: - # size = self.markers[0][2] - - # # three_m_right_of_last_bar = last_bar_sc + 3*size - - # # marker_right = min( - # # two_m_left_of_l1, - # # three_m_right_of_last_bar - # # ) + size = self.markers[0][2] + p.setPen(self.pen) size = draw_markers( self.markers, - self.currentPen.color(), + self.pen.color(), p, vb_left, - # right, vb_right, - # rsc - 6, marker_right, - # right_scene_coords, ) # marker_size = self.markers[0][2] self._maxMarkerSize = max([m[2] / 2. for m in self.markers]) - line_end = marker_right - (6/16 * size) - - # else: - # line_end = last_bar_sc - # line_end_view = rvc - - # # this seems slower when moving around - # # order lines.. not sure wtf is up with that. - # # for now we're just using it on the position line. + # this seems slower when moving around + # order lines.. not sure wtf is up with that. + # for now we're just using it on the position line. elif self._marker: - self._marker.setPos(QPointF(marker_right, self.scene_y())) - line_end = marker_right - (6/16 * size) - else: - # leave small blank gap for style + self._marker.setPos( + QPointF(marker_right, self.scene_y()) + ) + + elif not self.use_marker_margin: + # basically means **don't** shorten the line with normally + # reserved space for a direction marker but, leave small + # blank gap for style line_end = r_axis_x - 10 line_end_view = vb.mapToView(Point(line_end, 0)).x() - # # somehow this is adding a lot of lag, but without - # # if we're getting weird trail artefacs grrr. - # # gotta be some kinda boundingRect problem yet again - # self._marker.update() + # self.currentPen.setJoinStyle(QtCore.Qt.MiterJoin) + p.setPen(self.currentPen) p.drawLine( Point(vb_left, 0), - # Point(right, 0) Point(line_end_view, 0) ) self._right_end_sc = line_end + def hide(self) -> None: + super().hide() + if self._marker: + self._marker.hide() + def scene_right_xy(self) -> QPointF: return self.getViewBox().mapFromView( QPointF(0, self.value()) @@ -595,6 +588,9 @@ class LevelLine(pg.InfiniteLine): if self.mouseHovering is True: return + if self._moh: + self.show_markers = True + # highlight if so configured if self._hoh: @@ -612,6 +608,7 @@ class LevelLine(pg.InfiniteLine): cur.hide_xhair( # set y-label to current value y_label_level=self.value(), + just_vertical=True, # fg_color=self._hcolor, # bg_color=self._hcolor, @@ -623,7 +620,8 @@ class LevelLine(pg.InfiniteLine): self.mouseHovering = True - else: # un-hovered + # un-hovered + else: if self.mouseHovering is False: return @@ -633,6 +631,9 @@ class LevelLine(pg.InfiniteLine): cur._hovered.remove(self) + if self._moh: + self.show_markers = False + if self not in cur._trackers: cur.show_xhair(y_label_level=self.value()) @@ -746,6 +747,8 @@ def order_line( chart, level, add_label=False, + use_marker_margin=True, + # only_show_markers_on_hover=True, **line_kwargs ) @@ -754,7 +757,7 @@ def order_line( marker_style, marker_size = { 'buy': ('|<', 20), 'sell': ('>|', 20), - 'alert': ('^', 12), + 'alert': ('v', 12), }[action] # this fixes it the artifact issue! .. of course, bouding rect stuff @@ -817,8 +820,9 @@ def order_line( rlabel = line.add_label( side='right', side_of_axis='left', + x_offset=3*marker_size + 5, fmt_str=( - '{size:.{size_digits}f} x' + '{size:.{size_digits}f} ' ), ) rlabel.fields = { @@ -858,6 +862,7 @@ def position_line( movable=False, always_show_labels=False, hide_xhair_on_hover=False, + use_marker_margin=True, ) if size > 0: arrow_path = mk_marker('|<') @@ -867,6 +872,21 @@ def position_line( line.add_marker(arrow_path) + # hide position marker when out of view (for now) + vb = line.getViewBox() + + def update_pp_nav(chartview): + vr = vb.state['viewRange'] + ymn, ymx = vr[1] + level = line.value() + + if level > ymx or level < ymn: + line._marker.hide() + else: + line._marker.show() + + vb.sigYRangeChanged.connect(update_pp_nav) + rlabel = line.add_label( side='left', fmt_str='{direction}: {size} -> ${$:.2f}', From 68f75b582ac87c074e171276c78fc565c6732df6 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 18 Mar 2021 13:32:34 -0400 Subject: [PATCH 131/139] More fixes for kivy shit --- piker/ui/cli.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/piker/ui/cli.py b/piker/ui/cli.py index a0912497..387a2b4b 100644 --- a/piker/ui/cli.py +++ b/piker/ui/cli.py @@ -18,7 +18,6 @@ Console interface to UI components. """ -from functools import partial import os import click import tractor @@ -64,10 +63,10 @@ def monitor(config, rate, name, dhost, test, tl): _kivy_import_hack() from .kivy.monitor import _async_main - async def main(tries): + async def main(): async with maybe_spawn_brokerd( brokername=brokermod.name, - tries=tries, loglevel=loglevel + loglevel=loglevel ) as portal: # run app "main" await _async_main( @@ -76,7 +75,7 @@ def monitor(config, rate, name, dhost, test, tl): ) tractor.run( - partial(main, tries=1), + main, name='monitor', loglevel=loglevel if tl else None, rpc_module_paths=['piker.ui.kivy.monitor'], @@ -101,9 +100,9 @@ def optschain(config, symbol, date, rate, test): _kivy_import_hack() from .kivy.option_chain import _async_main - async def main(tries): + async def main(): async with maybe_spawn_brokerd( - tries=tries, loglevel=loglevel + loglevel=loglevel ): # run app "main" await _async_main( @@ -115,9 +114,8 @@ def optschain(config, symbol, date, rate, test): ) tractor.run( - partial(main, tries=1), + main, name='kivy-options-chain', - # loglevel=loglevel if tl else None, ) From 67c5563090afac76cf9f5cf7da4cef95872e1475 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 18 Mar 2021 13:33:10 -0400 Subject: [PATCH 132/139] Enable marker-on-select through order mode --- piker/ui/_interaction.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index a6e70240..a144d999 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -15,10 +15,10 @@ # along with this program. If not, see . """ -Chart view box primitives. +Chart view box primitives + """ from dataclasses import dataclass, field -from math import floor from typing import Optional, Dict import pyqtgraph as pg @@ -269,6 +269,9 @@ class LineEditor: exec_type='dark' if dotted else 'live', action=action, show_markers=True, + + # prevent flickering of marker while moving/tracking cursor + only_show_markers_on_hover=False, ) self._active_staged_line = line @@ -329,6 +332,9 @@ class LineEditor: color=line.color, dotted=line._dotted, + show_markers=True, + only_show_markers_on_hover=True, + action=action, ) From 55de07932095eb65b658d9df361dc4fa69ba559d Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 18 Mar 2021 13:40:31 -0400 Subject: [PATCH 133/139] Add low dpi settings and different default view bars counts --- piker/ui/_style.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/piker/ui/_style.py b/piker/ui/_style.py index fff63ea5..cbadc630 100644 --- a/piker/ui/_style.py +++ b/piker/ui/_style.py @@ -30,15 +30,17 @@ from ._exec import current_screen log = get_logger(__name__) # chart-wide fonts specified in inches -# _default_font_inches_we_like = 0.055 #5 / 96 -_default_font_inches_we_like = 0.0616 #5 / 96 -# _down_2_font_inches_we_like = 0.05 #4 / 96 -_down_2_font_inches_we_like = 0.055 #4 / 96 +_default_font_inches_we_like_low_dpi = 6 / 64 +_down_2_font_inches_we_like_low_dpi = 4 / 64 + +_default_font_inches_we_like = 0.0616 # 5 / 96 +_down_2_font_inches_we_like = 0.055 # 4 / 96 class DpiAwareFont: def __init__( self, + # TODO: move to config name: str = 'Hack', size_in_inches: Optional[float] = None, ) -> None: @@ -48,7 +50,6 @@ class DpiAwareFont: self._qfm = QtGui.QFontMetrics(self._qfont) self._physical_dpi = None self._screen = None - self._dpi_scalar = 1. def _set_qfont_px_size(self, px_size: int) -> None: self._qfont.setPixelSize(px_size) @@ -127,8 +128,8 @@ _xaxis_at = 'bottom' # charting config CHART_MARGINS = (0, 0, 2, 2) _min_points_to_show = 6 -_bars_from_right_in_follow_mode = int(6**2) -_bars_to_left_in_follow_mode = int(6**3) +_bars_from_right_in_follow_mode = int(130) +_bars_to_left_in_follow_mode = int(616) _tina_mode = False From 5610807b8efc1716402f0e13b17d2cc698a047d9 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 18 Mar 2021 16:59:22 -0400 Subject: [PATCH 134/139] Move marker factory funcs to new mod --- piker/ui/_annotate.py | 144 +++++++++++++++++++++++++++++++++++ piker/ui/_graphics/_lines.py | 122 +---------------------------- 2 files changed, 146 insertions(+), 120 deletions(-) create mode 100644 piker/ui/_annotate.py diff --git a/piker/ui/_annotate.py b/piker/ui/_annotate.py new file mode 100644 index 00000000..e1711bf1 --- /dev/null +++ b/piker/ui/_annotate.py @@ -0,0 +1,144 @@ +# 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 . + +""" +Annotations for ur faces. + +""" + +from PyQt5 import QtCore, QtGui +from PyQt5.QtGui import QGraphicsPathItem +from pyqtgraph import Point, functions as fn, Color +import numpy as np + + +def mk_marker( + style, + size: float = 20.0, + use_qgpath: bool = True, +) -> QGraphicsPathItem: + """Add a marker to be displayed on the line wrapped in a ``QGraphicsPathItem`` + ready to be placed using scene coordinates (not view). + + **Arguments** + style String indicating the style of marker to add: + ``'<|'``, ``'|>'``, ``'>|'``, ``'|<'``, ``'<|>'``, + ``'>|<'``, ``'^'``, ``'v'``, ``'o'`` + size Size of the marker in pixels. Default is 10.0. + + """ + path = QtGui.QPainterPath() + + if style == 'o': + path.addEllipse(QtCore.QRectF(-0.5, -0.5, 1, 1)) + + # arrow pointing away-from the top of line + if '<|' in style: + p = QtGui.QPolygonF([Point(0.5, 0), Point(0, -0.5), Point(-0.5, 0)]) + path.addPolygon(p) + path.closeSubpath() + + # arrow pointing away-from the bottom of line + if '|>' in style: + p = QtGui.QPolygonF([Point(0.5, 0), Point(0, 0.5), Point(-0.5, 0)]) + path.addPolygon(p) + path.closeSubpath() + + # arrow pointing in-to the top of line + if '>|' in style: + p = QtGui.QPolygonF([Point(0.5, -0.5), Point(0, 0), Point(-0.5, -0.5)]) + path.addPolygon(p) + path.closeSubpath() + + # arrow pointing in-to the bottom of line + if '|<' in style: + p = QtGui.QPolygonF([Point(0.5, 0.5), Point(0, 0), Point(-0.5, 0.5)]) + path.addPolygon(p) + path.closeSubpath() + + if '^' in style: + p = QtGui.QPolygonF([Point(0, -0.5), Point(0.5, 0), Point(0, 0.5)]) + path.addPolygon(p) + path.closeSubpath() + + if 'v' in style: + p = QtGui.QPolygonF([Point(0, -0.5), Point(-0.5, 0), Point(0, 0.5)]) + path.addPolygon(p) + path.closeSubpath() + + # self._maxMarkerSize = max([m[2] / 2. for m in self.markers]) + + if use_qgpath: + path = QGraphicsPathItem(path) + path.scale(size, size) + + return path + + +def qgo_draw_markers( + + markers: list, + color: Color, + p: QtGui.QPainter, + left: float, + right: float, + right_offset: float, + +) -> float: + """Paint markers in ``pg.GraphicsItem`` style by first + removing the view transform for the painter, drawing the markers + in scene coords, then restoring the view coords. + + """ + # paint markers in native coordinate system + orig_tr = p.transform() + + start = orig_tr.map(Point(left, 0)) + end = orig_tr.map(Point(right, 0)) + up = orig_tr.map(Point(left, 1)) + + dif = end - start + # length = Point(dif).length() + angle = np.arctan2(dif.y(), dif.x()) * 180 / np.pi + + p.resetTransform() + + p.translate(start) + p.rotate(angle) + + up = up - start + det = up.x() * dif.y() - dif.x() * up.y() + p.scale(1, 1 if det > 0 else -1) + + p.setBrush(fn.mkBrush(color)) + # p.setBrush(fn.mkBrush(self.currentPen.color())) + tr = p.transform() + + sizes = [] + for path, pos, size in markers: + p.setTransform(tr) + + # XXX: we drop the "scale / %" placement + # x = length * pos + x = right_offset + + p.translate(x, 0) + p.scale(size, size) + p.drawPath(path) + sizes.append(size) + + p.setTransform(orig_tr) + return max(sizes) diff --git a/piker/ui/_graphics/_lines.py b/piker/ui/_graphics/_lines.py index 4dadc033..63ca4f53 100644 --- a/piker/ui/_graphics/_lines.py +++ b/piker/ui/_graphics/_lines.py @@ -23,10 +23,9 @@ from typing import Tuple, Optional, List import pyqtgraph as pg from pyqtgraph import Point, functions as fn from PyQt5 import QtCore, QtGui, QtWidgets -from PyQt5.QtGui import QGraphicsPathItem from PyQt5.QtCore import QPointF -import numpy as np +from .._annotate import mk_marker, qgo_draw_markers from .._label import Label, vbr_left, right_axis from .._style import ( hcolor, @@ -34,123 +33,6 @@ from .._style import ( ) -def mk_marker( - style, - size: float = 20.0, - use_qgpath: bool = True, -) -> QGraphicsPathItem: - """Add a marker to be displayed on the line wrapped in a ``QGraphicsPathItem`` - ready to be placed using scene coordinates (not view). - - **Arguments** - style String indicating the style of marker to add: - ``'<|'``, ``'|>'``, ``'>|'``, ``'|<'``, ``'<|>'``, - ``'>|<'``, ``'^'``, ``'v'``, ``'o'`` - size Size of the marker in pixels. Default is 10.0. - - """ - path = QtGui.QPainterPath() - - if style == 'o': - path.addEllipse(QtCore.QRectF(-0.5, -0.5, 1, 1)) - - # arrow pointing away-from the top of line - if '<|' in style: - p = QtGui.QPolygonF([Point(0.5, 0), Point(0, -0.5), Point(-0.5, 0)]) - path.addPolygon(p) - path.closeSubpath() - - # arrow pointing away-from the bottom of line - if '|>' in style: - p = QtGui.QPolygonF([Point(0.5, 0), Point(0, 0.5), Point(-0.5, 0)]) - path.addPolygon(p) - path.closeSubpath() - - # arrow pointing in-to the top of line - if '>|' in style: - p = QtGui.QPolygonF([Point(0.5, -0.5), Point(0, 0), Point(-0.5, -0.5)]) - path.addPolygon(p) - path.closeSubpath() - - # arrow pointing in-to the bottom of line - if '|<' in style: - p = QtGui.QPolygonF([Point(0.5, 0.5), Point(0, 0), Point(-0.5, 0.5)]) - path.addPolygon(p) - path.closeSubpath() - - if '^' in style: - p = QtGui.QPolygonF([Point(0, -0.5), Point(0.5, 0), Point(0, 0.5)]) - path.addPolygon(p) - path.closeSubpath() - - if 'v' in style: - p = QtGui.QPolygonF([Point(0, -0.5), Point(-0.5, 0), Point(0, 0.5)]) - path.addPolygon(p) - path.closeSubpath() - - # self._maxMarkerSize = max([m[2] / 2. for m in self.markers]) - - if use_qgpath: - path = QGraphicsPathItem(path) - path.scale(size, size) - - return path - - -def draw_markers( - markers: list, - color: pg.Color, - p: QtGui.QPainter, - left: float, - right: float, - right_offset: float, -) -> None: - """Pain markers in ``pg.GraphicsItem`` style by first - removing the view transform for the painter, drawing the markers - in scene coords, then restoring the view coords. - - """ - # paint markers in native coordinate system - orig_tr = p.transform() - - start = orig_tr.map(Point(left, 0)) - end = orig_tr.map(Point(right, 0)) - up = orig_tr.map(Point(left, 1)) - - dif = end - start - # length = Point(dif).length() - angle = np.arctan2(dif.y(), dif.x()) * 180 / np.pi - - p.resetTransform() - - p.translate(start) - p.rotate(angle) - - up = up - start - det = up.x() * dif.y() - dif.x() * up.y() - p.scale(1, 1 if det > 0 else -1) - - p.setBrush(fn.mkBrush(color)) - # p.setBrush(fn.mkBrush(self.currentPen.color())) - tr = p.transform() - - sizes = [] - for path, pos, size in markers: - p.setTransform(tr) - - # XXX: we drop the "scale / %" placement - # x = length * pos - x = right_offset - - p.translate(x, 0) - p.scale(size, size) - p.drawPath(path) - sizes.append(size) - - p.setTransform(orig_tr) - return max(sizes) - - # TODO: probably worth investigating if we can # make .boundingRect() faster: # https://stackoverflow.com/questions/26156486/determine-bounding-rect-of-line-in-qt @@ -506,7 +388,7 @@ class LevelLine(pg.InfiniteLine): size = self.markers[0][2] p.setPen(self.pen) - size = draw_markers( + size = qgo_draw_markers( self.markers, self.pen.color(), p, From 6fa1d4dc88fb1f44b72538cac8e9273b629784e9 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Thu, 18 Mar 2021 21:57:08 -0400 Subject: [PATCH 135/139] Show xhair even if order is filled while line is hovered --- piker/ui/_interaction.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/piker/ui/_interaction.py b/piker/ui/_interaction.py index a144d999..1f4665f0 100644 --- a/piker/ui/_interaction.py +++ b/piker/ui/_interaction.py @@ -392,11 +392,16 @@ class LineEditor: # try to look up line from our registry line = self._order_lines.pop(uuid, None) if line: + # if hovered remove from cursor set hovered = self.chart._cursor._hovered if line in hovered: hovered.remove(line) + # make sure the xhair doesn't get left off + # just because we never got a un-hover event + self.chart._cursor.show_xhair() + line.delete() return line From 77fbde1115b2b31066c1a577d06b9bd287e84db5 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 19 Mar 2021 00:40:39 -0400 Subject: [PATCH 136/139] Override the inverse transform func from pg --- piker/ui/_exec.py | 5 ++++ piker/ui/_pg_overrides.py | 48 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 piker/ui/_pg_overrides.py diff --git a/piker/ui/_exec.py b/piker/ui/_exec.py index 9fbb3988..eb0d662c 100644 --- a/piker/ui/_exec.py +++ b/piker/ui/_exec.py @@ -42,6 +42,7 @@ import tractor from outcome import Error from ..log import get_logger +from ._pg_overrides import _do_overrides log = get_logger(__name__) @@ -50,6 +51,10 @@ log = get_logger(__name__) pg.useOpenGL = True pg.enableExperimental = True +# engage core tweaks that give us better response +# latency then the average pg user +_do_overrides() + # singleton app per actor _qt_app: QtGui.QApplication = None diff --git a/piker/ui/_pg_overrides.py b/piker/ui/_pg_overrides.py new file mode 100644 index 00000000..71e5f40a --- /dev/null +++ b/piker/ui/_pg_overrides.py @@ -0,0 +1,48 @@ +# 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 . + +""" +Customization of ``pyqtgraph`` core routines to speed up our use mostly +based on not requiring "scentific precision" for pixel perfect view +transforms. + +""" +import pyqtgraph as pg + + +def invertQTransform(tr): + """Return a QTransform that is the inverse of *tr*. + Raises an exception if tr is not invertible. + + Note that this function is preferred over QTransform.inverted() due to + bugs in that method. (specifically, Qt has floating-point precision issues + when determining whether a matrix is invertible) + + """ + # see https://doc.qt.io/qt-5/qtransform.html#inverted + + # NOTE: if ``invertable == False``, ``qt_t`` is an identity + qt_t, invertable = tr.inverted() + + return qt_t + + +def _do_overrides() -> None: + """Dooo eeet. + + """ + # we don't care about potential fp issues inside Qt + pg.functions.invertQTransform = invertQTransform From 07a5bf4b7c813f4b95e2ae59abbfb7724a7e8c84 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 19 Mar 2021 09:33:47 -0400 Subject: [PATCH 137/139] Use low dpi inches on 96 dpi --- piker/ui/_style.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/piker/ui/_style.py b/piker/ui/_style.py index cbadc630..a0232a08 100644 --- a/piker/ui/_style.py +++ b/piker/ui/_style.py @@ -90,11 +90,16 @@ class DpiAwareFont: ldpi = screen.logicalDotsPerInch() dpi = max(pdpi, ldpi) + # for low dpi scale everything down + if dpi <= 96: + self._iwl = _default_font_inches_we_like_low_dpi + font_size = math.floor(self._iwl * dpi) log.info( f"\nscreen:{screen.name()} with DPI: {dpi}" f"\nbest font size is {font_size}\n" ) + self._set_qfont_px_size(font_size) self._physical_dpi = dpi From 7786a8567ec7c25337f302d90389fac315885e99 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 19 Mar 2021 12:07:00 -0400 Subject: [PATCH 138/139] Go back to mainline pyqtgraph --- requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 71fbd571..18ec5994 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ # no pypi package for tractor (yet) # we require the asyncio-via-guest-mode dev branch -e git+git://github.com/goodboy/tractor.git@infect_asyncio#egg=tractor --e git+git://github.com/pikers/pyqtgraph.git@use_qt_inverted#egg=pyqtgraph From 724bb84f6f06929157ba61a89bd584d56d1a349b Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Fri, 19 Mar 2021 12:07:21 -0400 Subject: [PATCH 139/139] Drop travis --- .travis.yml | 24 ------------------------ 1 file changed, 24 deletions(-) delete mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 0e1d3129..00000000 --- a/.travis.yml +++ /dev/null @@ -1,24 +0,0 @@ -language: python - -matrix: - include: - - python: 3.7 - dist: xenial - sudo: required - -before_install: - - sudo apt-get -qq update - # deps to build kivy from sources for use with trio - - sudo apt-get install -y build-essential libav-tools libgles2-mesa-dev libgles2-mesa-dev libsdl2-dev libsdl2-image-dev libsdl2-mixer-dev libsdl2-ttf-dev libportmidi-dev libswscale-dev libavformat-dev libavcodec-dev zlib1g-dev - -install: - - pip install pipenv - - cd $TRAVIS_BUILD_DIR - - pipenv install --dev -e . - -cache: - directories: - - $HOME/.config/piker/ - -script: - - pipenv run pytest tests/