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
basic_orders
Tyler Goodlet 2021-02-06 11:35:12 -05:00
parent 03541bd368
commit a8c4829cb6
4 changed files with 92 additions and 69 deletions

View File

@ -114,7 +114,11 @@ def get_book(broker: str) -> _ExecBook:
return _books.setdefault(broker, _ExecBook(broker)) 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 @dataclass
@ -326,7 +330,7 @@ async def process_broker_trades(
'fill' -> 'broker_filled' 'fill' -> 'broker_filled'
Currently accepted status values from IB Currently accepted status values from IB
{'presubmitted', 'submitted', 'cancelled'} {'presubmitted', 'submitted', 'cancelled', 'inactive'}
""" """
broker = feed.mod.name broker = feed.mod.name
@ -352,7 +356,9 @@ async def process_broker_trades(
oid = book._broker2ems_ids.get(reqid) oid = book._broker2ems_ids.get(reqid)
resp = {'oid': oid} resp = {'oid': oid}
if name in ('error',): if name in (
'error',
):
# TODO: figure out how this will interact with EMS clients # TODO: figure out how this will interact with EMS clients
# for ex. on an error do we react with a dark orders # for ex. on an error do we react with a dark orders
# management response, like cancelling all dark orders? # management response, like cancelling all dark orders?
@ -373,8 +379,9 @@ async def process_broker_trades(
# another stupid ib error to handle # another stupid ib error to handle
# if 10147 in message: cancel # if 10147 in message: cancel
elif name in ('status',): elif name in (
'status',
):
# everyone doin camel case # everyone doin camel case
status = msg['status'].lower() status = msg['status'].lower()
@ -397,7 +404,9 @@ async def process_broker_trades(
await ctx.send_yield(resp) await ctx.send_yield(resp)
elif name in ('fill',): elif name in (
'fill',
):
# proxy through the "fill" result(s) # proxy through the "fill" result(s)
resp['resp'] = 'broker_filled' resp['resp'] = 'broker_filled'
resp.update(msg) resp.update(msg)
@ -534,7 +543,7 @@ async def _ems_main(
# the user choose the predicate operator. # the user choose the predicate operator.
pred = mk_check(trigger_price, last) pred = mk_check(trigger_price, last)
mt = feed.symbols[sym].min_tick mt = feed.symbols[sym].tick_size
if action == 'buy': if action == 'buy':
tickfilter = ('ask', 'last', 'trade') tickfilter = ('ask', 'last', 'trade')

View File

@ -80,9 +80,11 @@ class Symbol:
"""I guess this is some kinda container thing for dealing with """I guess this is some kinda container thing for dealing with
all the different meta-data formats from brokers? all the different meta-data formats from brokers?
Yah, i guess dats what it izz.
""" """
key: str = '' 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) broker_info: Dict[str, Dict[str, Any]] = field(default_factory=dict)
deriv: str = '' deriv: str = ''
@ -91,17 +93,17 @@ class Symbol:
return list(self.broker_info.keys()) return list(self.broker_info.keys())
def digits(self) -> int: def digits(self) -> int:
"""Return the trailing number of digits specified by the """Return the trailing number of digits specified by the min
min tick size for the instrument. tick size for the instrument.
""" """
return float_digits(self.min_tick) return float_digits(self.tick_size)
def nearest_tick(self, value: float) -> float: def nearest_tick(self, value: float) -> float:
"""Return the nearest tick value based on mininum increment. """Return the nearest tick value based on mininum increment.
""" """
mult = 1 / self.min_tick mult = 1 / self.tick_size
return round(value * mult) / mult return round(value * mult) / mult

View File

@ -140,7 +140,7 @@ class ChartSpace(QtGui.QWidget):
# XXX: let's see if this causes mem problems # XXX: let's see if this causes mem problems
self.window.setWindowTitle( self.window.setWindowTitle(
f'piker chart {symbol.key}@{symbol.brokers} ' f'piker chart {symbol.key}@{symbol.brokers} '
f'tick:{symbol.min_tick}' f'tick:{symbol.tick_size}'
) )
# TODO: symbol search # TODO: symbol search
@ -195,7 +195,6 @@ class LinkedSplitCharts(QtGui.QWidget):
self._cursor: Cursor = None # crosshair graphics self._cursor: Cursor = None # crosshair graphics
self.chart: ChartPlotWidget = None # main (ohlc) chart self.chart: ChartPlotWidget = None # main (ohlc) chart
self.subplots: Dict[Tuple[str, ...], ChartPlotWidget] = {} self.subplots: Dict[Tuple[str, ...], ChartPlotWidget] = {}
self.digits: int = 2
self.xaxis = DynamicDateAxis( self.xaxis = DynamicDateAxis(
orientation='bottom', orientation='bottom',
@ -245,13 +244,10 @@ class LinkedSplitCharts(QtGui.QWidget):
The data input struct array must include OHLC fields. The data input struct array must include OHLC fields.
""" """
self.min_tick = symbol.min_tick
self.digits = symbol.digits()
# add crosshairs # add crosshairs
self._cursor = Cursor( self._cursor = Cursor(
linkedsplitcharts=self, linkedsplitcharts=self,
digits=self.digits digits=symbol.digits(),
) )
self.chart = self.add_plot( self.chart = self.add_plot(
name=symbol.key, name=symbol.key,
@ -428,7 +424,7 @@ class ChartPlotWidget(pg.PlotWidget):
# Assign callback for rescaling y-axis automatically # Assign callback for rescaling y-axis automatically
# based on data contents and ``ViewBox`` state. # 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 # for mouse wheel which doesn't seem to emit XRangeChanged
self._vb.sigRangeChangedManually.connect(self._set_yrange) self._vb.sigRangeChangedManually.connect(self._set_yrange)
@ -506,6 +502,7 @@ class ChartPlotWidget(pg.PlotWidget):
max=end, max=end,
padding=0, padding=0,
) )
self._set_yrange()
def increment_view( def increment_view(
self, self,
@ -657,7 +654,7 @@ class ChartPlotWidget(pg.PlotWidget):
chart=self, chart=self,
parent=self.getAxis('right'), parent=self.getAxis('right'),
# TODO: pass this from symbol data # TODO: pass this from symbol data
digits=self._lc._symbol.digits(), digits=self._lc.symbol.digits(),
opacity=1, opacity=1,
bg_color=bg_color, bg_color=bg_color,
) )
@ -708,6 +705,7 @@ class ChartPlotWidget(pg.PlotWidget):
self, self,
*, *,
yrange: Optional[Tuple[float, float]] = None, yrange: Optional[Tuple[float, float]] = None,
range_margin: float = 0.04,
) -> None: ) -> None:
"""Set the viewable y-range based on embedded data. """Set the viewable y-range based on embedded data.
@ -767,7 +765,7 @@ class ChartPlotWidget(pg.PlotWidget):
a = self._ohlc a = self._ohlc
ifirst = a[0]['index'] ifirst = a[0]['index']
bars = a[lbar - ifirst:rbar - ifirst] bars = a[lbar - ifirst:rbar - ifirst + 1]
if not len(bars): if not len(bars):
# likely no data loaded yet or extreme scrolling? # likely no data loaded yet or extreme scrolling?
@ -788,8 +786,8 @@ class ChartPlotWidget(pg.PlotWidget):
if set_range: if set_range:
# view margins: stay within a % of the "true range" # view margins: stay within a % of the "true range"
diff = yhigh - ylow diff = yhigh - ylow
ylow = ylow - (diff * 0.04) ylow = ylow - (diff * range_margin)
yhigh = yhigh + (diff * 0.04) yhigh = yhigh + (diff * range_margin)
self.setLimits( self.setLimits(
yMin=ylow, yMin=ylow,
@ -992,20 +990,27 @@ async def _async_main(
resp = msg['resp'] resp = msg['resp']
# response to 'action' request (buy/sell) # 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 # show line label once order is live
line = order_mode.on_submit(oid) line = order_mode.on_submit(oid)
# await tractor.breakpoint()
# resp to 'cancel' request or error condition # resp to 'cancel' request or error condition
# for action request # for action request
elif resp in ('broker_cancelled', 'dark_cancelled'): elif resp in (
'broker_cancelled',
'broker_inactive',
'dark_cancelled'
):
# delete level line from view # delete level line from view
order_mode.on_cancel(oid) order_mode.on_cancel(oid)
elif resp in ('dark_executed'): elif resp in (
'dark_executed'
):
log.info(f'Dark order filled for {fmsg}') log.info(f'Dark order filled for {fmsg}')
# for alerts add a triangle and remove the # for alerts add a triangle and remove the
@ -1021,7 +1026,9 @@ async def _async_main(
line = await order_mode.on_exec(oid, msg) line = await order_mode.on_exec(oid, msg)
# response to completed 'action' request for buy/sell # 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) await order_mode.on_exec(oid, msg)
# each clearing tick is responded individually # each clearing tick is responded individually
@ -1081,7 +1088,7 @@ async def chart_from_quotes(
# sym = chart.name # sym = chart.name
# mx, mn = np.nanmax(in_view[sym]), np.nanmin(in_view[sym]) # 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() chart.default_view()
@ -1104,6 +1111,9 @@ async def chart_from_quotes(
# levels this might be dark volume we need to # levels this might be dark volume we need to
# present differently? # present differently?
tick_size = chart._lc.symbol.tick_size
tick_margin = 2 * tick_size
async for quotes in stream: async for quotes in stream:
for sym, quote in quotes.items(): for sym, quote in quotes.items():
@ -1114,20 +1124,18 @@ async def chart_from_quotes(
price = tick.get('price') price = tick.get('price')
size = tick.get('size') size = tick.get('size')
# compute max and min trade values to display in view if ticktype == 'n/a' or price == -1:
# TODO: we need a streaming minmax algorithm here, see # okkk..
# def above. continue
brange, mx_in_view, mn_in_view = maxmin()
l, lbar, rbar, r = brange
if ticktype in ('trade', 'utrade', 'last'): if ticktype in ('trade', 'utrade', 'last'):
array = ohlcv.array array = ohlcv.array
# update price sticky(s) # update price sticky(s)
last = array[-1] end = array[-1]
last_price_sticky.update_from_data( last_price_sticky.update_from_data(
*last[['index', 'close']] *end[['index', 'close']]
) )
# plot bars # plot bars
@ -1139,7 +1147,17 @@ async def chart_from_quotes(
if wap_in_history: if wap_in_history:
# update vwap overlay line # 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? # XXX: prettty sure this is correct?
# if ticktype in ('trade', 'last'): # if ticktype in ('trade', 'last'):
@ -1162,25 +1180,33 @@ async def chart_from_quotes(
l1.ask_label.size = size l1.ask_label.size = size
l1.ask_label.update_from_data(0, price) 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'): elif ticktype in ('bid', 'bsize'):
l1.bid_label.size = size l1.bid_label.size = size
l1.bid_label.update_from_data(0, price) l1.bid_label.update_from_data(0, price)
# update min price in view to keep bid on screen # update min price in view to keep bid on screen
mn_in_view = min(price, mn_in_view) 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: if (mx > last_mx) or (
chart._set_yrange(yrange=(mn_in_view, mx_in_view)) mn < last_mn
last_mx, last_mn = mx_in_view, mn_in_view ):
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( async def spawn_fsps(
@ -1355,10 +1381,10 @@ async def update_signals(
# add moveable over-[sold/bought] lines # add moveable over-[sold/bought] lines
# and labels only for the 70/30 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, 30, orient_v='top')
level_line(chart, 70, orient_v='bottom') 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() chart._set_yrange()
@ -1395,6 +1421,7 @@ async def update_signals(
async def check_for_new_bars(feed, ohlcv, linked_charts): async def check_for_new_bars(feed, ohlcv, linked_charts):
"""Task which updates from new bars in the shared ohlcv buffer every """Task which updates from new bars in the shared ohlcv buffer every
``delay_s`` seconds. ``delay_s`` seconds.
""" """
# TODO: right now we'll spin printing bars if the last time # TODO: right now we'll spin printing bars if the last time
# stamp is before a large period of no market activity. # 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 # current bar) and then either write the current bar manually
# or place a cursor for visual cue of the current time step. # 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 # XXX: this puts a flat bar on the current time step
# TODO: if we eventually have an x-axis time-step "cursor" # TODO: if we eventually have an x-axis time-step "cursor"
# we can get rid of this since it is extra overhead. # 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, just_history=False,
) )
# resize view
# price_chart._set_yrange()
for name in price_chart._overlays: for name in price_chart._overlays:
price_chart.update_curve_from_array( price_chart.update_curve_from_array(
@ -1447,15 +1465,8 @@ async def check_for_new_bars(feed, ohlcv, linked_charts):
price_chart._arrays[name] 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(): for name, chart in linked_charts.subplots.items():
chart.update_curve_from_array(chart.name, chart._shm.array) chart.update_curve_from_array(chart.name, chart._shm.array)
# chart._set_yrange()
# shift the view if in follow mode # shift the view if in follow mode
price_chart.increment_view() price_chart.increment_view()
@ -1467,6 +1478,7 @@ def _main(
tractor_kwargs, tractor_kwargs,
) -> None: ) -> None:
"""Sync entry point to start a chart app. """Sync entry point to start a chart app.
""" """
# Qt entry point # Qt entry point
run_qtractor( run_qtractor(

View File

@ -228,7 +228,7 @@ class Cursor(pg.GraphicsObject):
# value used for rounding y-axis discreet tick steps # value used for rounding y-axis discreet tick steps
# computing once, up front, here cuz why not # 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( def add_hovered(
self, self,