From a8c4829cb6c6ada33c35059355b043594ca731eb Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Sat, 6 Feb 2021 11:35:12 -0500 Subject: [PATCH] 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,