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))
_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')

View File

@ -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

View File

@ -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)
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(

View File

@ -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,