Merge pull request #452 from pikers/l1_compaction

Compact L1 labels
backend_spec
goodboy 2023-02-13 11:21:26 -05:00 committed by GitHub
commit 42d2f9e461
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 225 additions and 156 deletions

View File

@ -306,7 +306,10 @@ class DynamicDateAxis(Axis):
times = array['time']
i_0, i_l = times[0], times[-1]
# edge cases
if (
not indexes
or
(indexes[0] < i_0
and indexes[-1] < i_l)
or

View File

@ -829,8 +829,6 @@ class ChartPlotWidget(pg.PlotWidget):
sig_mouse_leave = QtCore.pyqtSignal(object)
sig_mouse_enter = QtCore.pyqtSignal(object)
_l1_labels: L1Labels = None
mode_name: str = 'view'
# TODO: can take a ``background`` color setting - maybe there's
@ -986,13 +984,15 @@ class ChartPlotWidget(pg.PlotWidget):
'''
# TODO: compute some sensible maximum value here
# and use a humanized scheme to limit to that length.
l1_len = self._max_l1_line_len
from ._l1 import L1Label
l1_len = abs(L1Label._x_br_offset)
ryaxis = self.getAxis('right')
r_axis_x = ryaxis.pos().x()
up_to_l1_sc = r_axis_x - l1_len
marker_right = up_to_l1_sc - (1.375 * 2 * marker_size)
line_end = marker_right - (6/16 * marker_size)
# line_end = marker_right - (6/16 * marker_size)
line_end = marker_right - marker_size
# print(
# f'r_axis_x: {r_axis_x}\n'
@ -1231,7 +1231,8 @@ class ChartPlotWidget(pg.PlotWidget):
# (we need something that avoids clutter on x-axis).
axis.add_sticky(
pi=pi,
bg_color=color,
fg_color='black',
# bg_color=color,
digits=digits,
)

View File

@ -71,7 +71,7 @@ class LineDot(pg.CurvePoint):
plot: ChartPlotWidget, # type: ingore # noqa
pos=None,
color: str = 'default_light',
color: str = 'bracket',
) -> None:
# scale from dpi aware font size
@ -349,7 +349,7 @@ class Cursor(pg.GraphicsObject):
# XXX: not sure why these are instance variables?
# It's not like we can change them on the fly..?
self.pen = pg.mkPen(
color=hcolor('default'),
color=hcolor('bracket'),
style=QtCore.Qt.DashLine,
)
self.lines_pen = pg.mkPen(
@ -365,7 +365,7 @@ class Cursor(pg.GraphicsObject):
self._lw = self.pixelWidth() * self.lines_pen.width()
# xhair label's color name
self.label_color: str = 'default'
self.label_color: str = 'bracket'
self._y_label_update: bool = True

View File

@ -217,6 +217,9 @@ def render_baritems(
)
_sample_rates: set[float] = {1, 60}
class Viz(msgspec.Struct): # , frozen=True):
'''
(Data) "Visualization" compound type which wraps a real-time
@ -284,15 +287,33 @@ class Viz(msgspec.Struct): # , frozen=True):
reset: bool = False,
) -> float:
# attempt to dectect the best step size by scanning a sample of
# the source data.
if self._index_step is None:
index = self.shm.array[self.index_field]
isample = index[:16]
mxdiff = np.diff(isample).max()
mxdiff: None | float = None
for step in np.diff(isample):
if step in _sample_rates:
if (
mxdiff is not None
and step != mxdiff
):
raise ValueError(
f'Multiple step sizes detected? {mxdiff}, {step}'
)
mxdiff = step
self._index_step = max(mxdiff, 1)
if (
mxdiff < 1
or 1 < mxdiff < 60
):
# TODO: remove this once we're sure the above scan loop
# is rock solid.
breakpoint()
return self._index_step

View File

@ -328,10 +328,6 @@ async def graphics_update_loop(
digits=symbol.tick_size_digits,
size_digits=symbol.lot_size_digits,
)
# TODO: this is just wrong now since we can have multiple L1-label
# sets, so instead we should have the l1 associated with the
# plotitem or y-axis likely?
# fast_chart._l1_labels = l1
# TODO:
# - in theory we should be able to read buffer data faster
@ -416,11 +412,11 @@ async def graphics_update_loop(
last_quote_s = time.time()
for sym, quote in quotes.items():
ds = dss[sym]
for fqsn, quote in quotes.items():
ds = dss[fqsn]
ds.quotes = quote
rt_pi, hist_pi = pis[sym]
rt_pi, hist_pi = pis[fqsn]
# chart isn't active/shown so skip render cycle and
# pause feed(s)
@ -449,16 +445,21 @@ async def graphics_update_loop(
def graphics_update_cycle(
ds: DisplayState,
quote: dict,
wap_in_history: bool = False,
trigger_all: bool = False, # flag used by prepend history updates
prepend_update_index: Optional[int] = None,
) -> None:
# TODO: eventually optimize this whole graphics stack with ``numba``
# hopefully XD
# TODO: SPEEDing this all up..
# - optimize this whole graphics stack with ``numba`` hopefully
# or at least a little `mypyc` B)
# - pass more direct refs as input to avoid so many attr accesses?
# - use a streaming minmax algo and drop the use of the
# state-tracking ``chart_maxmin()`` routine from above?
chart = ds.chart
# TODO: just pass this as a direct ref to avoid so many attr accesses?
hist_chart = ds.godwidget.hist_linked.chart
flume = ds.flume
@ -471,10 +472,7 @@ def graphics_update_cycle(
msg=f'Graphics loop cycle for: `{chart.name}`',
delayed=True,
disabled=not pg_profile_enabled(),
# disabled=True,
ms_threshold=ms_slower_then,
# ms_threshold=1/12 * 1e3,
)
# unpack multi-referenced components
@ -554,48 +552,34 @@ def graphics_update_cycle(
profiler('view incremented')
# from pprint import pformat
# frame_counts = {
# typ: len(frame) for typ, frame in frames_by_type.items()
# }
# print(
# f'{pformat(frame_counts)}\n'
# f'framed: {pformat(frames_by_type)}\n'
# f'lasts: {pformat(lasts)}\n'
# )
# for typ, tick in lasts.items():
# ticks_frame = quote.get('ticks', ())
# iterate frames of ticks-by-type such that we only update graphics
# using the last update per type where possible.
ticks_by_type = quote.get('tbt', {})
# for tick in ticks_frame:
for typ, ticks in ticks_by_type.items():
# NOTE: ticks are `.append()`-ed to the `ticks_by_type: dict` by the
# `._sampling.uniform_rate_send()` loop
tick = ticks[-1]
# typ = tick.get('type')
tick = ticks[-1] # get most recent value
price = tick.get('price')
size = tick.get('size')
# compute max and min prices (including bid/ask) from
# tick frames to determine the y-range for chart
# auto-scaling.
# TODO: we need a streaming minmax algo here, see def above.
if liv:
if (
liv
# TODO: make sure IB doesn't send ``-1``!
and price > 0
):
mx = max(price + tick_margin, mx)
mn = min(price - tick_margin, mn)
# clearing price update:
# generally, we only want to update grahpics from the *last*
# tick event once - thus showing the most recent state.
if typ in clear_types:
# XXX: if we only wanted to update graphics from the
# "current"/"latest received" clearing price tick
# once (see alt iteration order above).
# if last_clear_updated:
# continue
# last_clear_updated = True
# we only want to update grahpics from the *last*
# tick event that falls under the "clearing price"
# set.
# update price sticky(s)
end_ic = array[-1][[
@ -610,10 +594,7 @@ def graphics_update_cycle(
chart.update_graphics_from_flow('bar_wap')
# L1 book label-line updates
# XXX: is this correct for ib?
# if ticktype in ('trade', 'last'):
# if ticktype in ('last',): # 'size'):
if typ in ('last',): # 'size'):
if typ in ('last',):
label = {
l1.ask_label.fields['level']: l1.ask_label,
@ -629,40 +610,64 @@ def graphics_update_cycle(
)
# TODO: on trades should we be knocking down
# the relevant L1 queue?
# the relevant L1 queue manually ourselves?
# label.size -= size
# NOTE: right now we always update the y-axis labels
# despite the last datum not being in view. Ideally
# we have a guard for this when we detect that the range
# of those values is not in view and then we disable these
# blocks.
elif (
typ in _tick_groups['asks']
# TODO: instead we could check if the price is in the
# y-view-range?
and liv
):
l1.ask_label.update_fields({'level': price, 'size': size})
elif (
typ in _tick_groups['bids']
# TODO: instead we could check if the price is in the
# y-view-range?
and liv
):
l1.bid_label.update_fields({'level': price, 'size': size})
# check for y-range re-size
if (mx > varz['last_mx']) or (mn < varz['last_mn']):
# check for y-autorange re-size
lmx = varz['last_mx']
lmn = varz['last_mn']
mx_diff = mx - lmx
mn_diff = mn - lmn
# fast chart resize case
if (
mx_diff
or mn_diff
):
if (
abs(mx_diff) > .25 * lmx
or
abs(mn_diff) > .25 * lmn
):
log.error(
f'WTF MN/MX IS WAY OFF:\n'
f'lmn: {lmn}\n'
f'mn: {mn}\n'
f'lmx: {lmx}\n'
f'mx: {mx}\n'
f'mx_diff: {mx_diff}\n'
f'mn_diff: {mn_diff}\n'
)
# fast chart resize case
elif (
liv
and not chart._static_yrange == 'axis'
):
# main_vb = chart.view
main_vb = chart._vizs[fqsn].plot.vb
if (
main_vb._ic is None
or not main_vb._ic.is_set()
):
# print(f'updating range due to mxmn')
yr = (mn, mx)
# print(
# f'updating y-range due to mxmn\n'
# f'{fqsn}: {yr}'
# )
main_vb._set_yrange(
# TODO: we should probably scale
# the view margin based on the size
@ -670,11 +675,10 @@ def graphics_update_cycle(
# slap in orders outside the current
# L1 (only) book range.
# range_margin=0.1,
# yrange=(mn, mx),
yrange=yr
)
# check if slow chart needs a resize
hist_viz = hist_chart._vizs[fqsn]
(
_,
@ -691,7 +695,7 @@ def graphics_update_cycle(
if hist_liv:
hist_viz.plot.vb._set_yrange()
# XXX: update this every draw cycle to make L1-always-in-view work.
# XXX: update this every draw cycle to make
varz['last_mx'], varz['last_mn'] = mx, mn
# run synchronous update on all linked viz
@ -767,10 +771,8 @@ def graphics_update_cycle(
if (
mx_vlm_in_view != varz['last_mx_vlm']
):
yrange = (0, mx_vlm_in_view * 1.375)
vlm_chart.view._set_yrange(
yrange=yrange,
)
vlm_yr = (0, mx_vlm_in_view * 1.375)
vlm_chart.view._set_yrange(yrange=vlm_yr)
profiler('`vlm_chart.view._set_yrange()`')
# print(f'mx vlm: {last_mx_vlm} -> {mx_vlm_in_view}')
varz['last_mx_vlm'] = mx_vlm_in_view
@ -1047,6 +1049,10 @@ async def display_symbol_data(
group_key=True
)
# (TODO: make this not so shit XD)
# close group status once a symbol feed fully loads to view.
# sbar._status_groups[loading_sym_key][1]()
# TODO: ctl over update loop's maximum frequency.
# - load this from a config.toml!
# - allow dyanmic configuration from chart UI?
@ -1129,6 +1135,8 @@ async def display_symbol_data(
# and sub-charts for FSPs
fqsn, flume = fitems[0]
# TODO NOTE: THIS CONTROLS WHAT SYMBOL IS USED FOR ORDER MODE
# SUBMISSIONS, we need to make this switch based on selection.
rt_linked._symbol = flume.symbol
hist_linked._symbol = flume.symbol
@ -1219,9 +1227,6 @@ async def display_symbol_data(
# get a new color from the palette
bg_chart_color, bg_last_bar_color = next(palette)
rt_linked._symbol = flume.symbol
hist_linked._symbol = flume.symbol
ohlcv: ShmArray = flume.rt_shm
hist_ohlcv: ShmArray = flume.hist_shm
@ -1232,9 +1237,14 @@ async def display_symbol_data(
name=fqsn,
axis_title=fqsn,
)
hist_pi.hideAxis('left')
# only show a singleton bottom-bottom axis by default.
hist_pi.hideAxis('bottom')
# XXX: TODO: THIS WILL CAUSE A GAP ON OVERLAYS,
# i think it needs to be "removed" instead when there
# are none?
hist_pi.hideAxis('left')
viz = hist_chart.draw_curve(
fqsn,
hist_ohlcv,
@ -1311,36 +1321,14 @@ async def display_symbol_data(
# XXX: if we wanted it at the bottom?
# rt_linked.splitter.addWidget(hist_linked)
rt_linked.focus()
godwidget.resize_all()
# add all additional symbols as overlays
# greedily do a view range default and pane resizing
# on startup before loading the order-mode machinery.
for fqsn, flume in feed.flumes.items():
# size view to data prior to order mode init
rt_chart.default_view()
rt_linked.graphics_cycle()
await trio.sleep(0)
hist_chart.default_view(
bars_from_y=int(len(hist_ohlcv.array)), # size to data
y_offset=6116*2, # push it a little away from the y-axis
)
hist_linked.graphics_cycle()
await trio.sleep(0)
godwidget.resize_all()
# trigger another view reset if no sub-chart
hist_chart.default_view()
rt_chart.default_view()
# let qt run to render all widgets and make sure the
# sidepanes line up vertically.
await trio.sleep(0)
# dynamic resize steps
godwidget.resize_all()
# TODO: look into this because not sure why it was
# commented out / we ever needed it XD
@ -1350,21 +1338,11 @@ async def display_symbol_data(
# determine if auto-range adjustements should be made.
# rt_linked.subplots.pop('volume', None)
# TODO: make this not so shit XD
# close group status
# sbar._status_groups[loading_sym_key][1]()
hist_chart.default_view()
hist_linked.graphics_cycle()
rt_chart.default_view()
await trio.sleep(0)
bars_in_mem = int(len(hist_ohlcv.array))
hist_chart.default_view(
bars_from_y=bars_in_mem, # size to data
# push it 1/16th away from the y-axis
y_offset=round(bars_in_mem / 16),
)
godwidget.resize_all()
await trio.sleep(0)
await link_views_with_region(
rt_chart,
@ -1372,7 +1350,7 @@ async def display_symbol_data(
flume,
)
# start graphics update loop after receiving first live quote
# start update loop task
ln.start_soon(
graphics_update_loop,
ln,
@ -1383,20 +1361,31 @@ async def display_symbol_data(
vlm_charts,
)
# boot order-mode
order_ctl_symbol: str = fqsns[0]
mode: OrderMode
async with (
open_order_mode(
feed,
godwidget,
fqsns[-1],
fqsns[0],
order_mode_started
) as mode
):
rt_linked.mode = mode
viz = rt_chart.get_viz(order_ctl_symbol)
viz.plot.setFocus()
# default view adjuments and sidepane alignment
# as final default UX touch.
rt_chart.default_view()
rt_chart.view.enable_auto_yrange()
hist_chart.default_view()
hist_chart.view.enable_auto_yrange()
godwidget.resize_all()
await trio.sleep_forever() # let the app run.. bby

View File

@ -661,6 +661,12 @@ async def open_vlm_displays(
# str(period_param.default)
# )
# use slightly less light (then bracket) gray
# for volume from "main exchange" and a more "bluey"
# gray for "dark" vlm.
vlm_color = 'i3'
dark_vlm_color = 'charcoal'
# built-in vlm which we plot ASAP since it's
# usually data provided directly with OHLC history.
shm = ohlcv
@ -755,7 +761,7 @@ async def open_vlm_displays(
{ # fsp engine conf
'func_name': 'dolla_vlm',
'zero_on_step': True,
'zero_on_step': False,
'params': {
'price_func': {
'default_value': 'chl3',
@ -769,7 +775,7 @@ async def open_vlm_displays(
# FIXME: we should error on starting the same fsp right
# since it might collide with existing shm.. or wait we
# had this before??
# dolla_vlm,
# dolla_vlm
tasks_ready.append(started)
# profiler(f'created shm for fsp actor: {display_name}')
@ -786,19 +792,24 @@ async def open_vlm_displays(
dvlm_pi = vlm_chart.overlay_plotitem(
'dolla_vlm',
index=0, # place axis on inside (nearest to chart)
axis_title=' $vlm',
axis_side='right',
axis_side='left',
axis_kwargs={
'typical_max_str': ' 100.0 M ',
'formatter': partial(
humanize,
digits=2,
),
'text_color': vlm_color,
},
)
dvlm_pi.hideAxis('left')
# TODO: should this maybe be implicit based on input args to
# `.overlay_plotitem()` above?
dvlm_pi.hideAxis('bottom')
# all to be overlayed curve names
fields = [
'dolla_vlm',
@ -823,12 +834,6 @@ async def open_vlm_displays(
# add custom auto range handler
dvlm_pi.vb._maxmin = group_mxmn
# use slightly less light (then bracket) gray
# for volume from "main exchange" and a more "bluey"
# gray for "dark" vlm.
vlm_color = 'i3'
dark_vlm_color = 'charcoal'
# add dvlm (step) curves to common view
def chart_curves(
names: list[str],
@ -879,7 +884,7 @@ async def open_vlm_displays(
flow_rates,
{ # fsp engine conf
'func_name': 'flow_rates',
'zero_on_step': False,
'zero_on_step': True,
},
# loglevel,
)
@ -913,8 +918,8 @@ async def open_vlm_displays(
# TODO: dynamically update period (and thus this axis?)
# title from user input.
axis_title='clears',
axis_side='left',
axis_kwargs={
'typical_max_str': ' 10.0 M ',
'formatter': partial(

View File

@ -30,19 +30,20 @@ from ._pg_overrides import PlotItem
class LevelLabel(YAxisLabel):
"""Y-axis (vertically) oriented, horizontal label that sticks to
'''
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
'''
_x_br_offset: float = -16
_y_txt_h_scaling: float = 2
# adjustment "further away from" anchor point
_x_offset = 9
_x_offset = 0
_y_offset = 0
# fields to be displayed in the label string
@ -58,12 +59,12 @@ class LevelLabel(YAxisLabel):
chart,
parent,
color: str = 'bracket',
color: str = 'default_light',
orient_v: str = 'bottom',
orient_h: str = 'left',
orient_h: str = 'right',
opacity: float = 0,
opacity: float = 1,
# makes order line labels offset from their parent axis
# such that they don't collide with the L1/L2 lines/prices
@ -99,13 +100,15 @@ class LevelLabel(YAxisLabel):
self._h_shift = {
'left': -1.,
'right': 0.
'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)
self.setZValue(10)
@property
def color(self):
return self._hcolor
@ -113,7 +116,10 @@ class LevelLabel(YAxisLabel):
@color.setter
def color(self, color: str) -> None:
self._hcolor = color
self._pen = self.pen = pg.mkPen(hcolor(color))
self._pen = self.pen = pg.mkPen(
hcolor(color),
width=3,
)
def update_on_resize(self, vr, r):
"""Tiis is a ``.sigRangeChanged()`` handler.
@ -125,10 +131,11 @@ class LevelLabel(YAxisLabel):
self,
fields: dict = None,
) -> None:
"""Update the label's text contents **and** position from
'''
Update the label's text contents **and** position from
a view box coordinate datum.
"""
'''
self.fields.update(fields)
level = self.fields['level']
@ -175,7 +182,8 @@ class LevelLabel(YAxisLabel):
fields: dict,
):
# use space as e3 delim
self.label_str = self._fmt_str.format(**fields).replace(',', ' ')
self.label_str = self._fmt_str.format(
**fields).replace(',', ' ')
br = self.boundingRect()
h, w = br.height(), br.width()
@ -188,14 +196,14 @@ class LevelLabel(YAxisLabel):
self,
p: QtGui.QPainter,
rect: QtCore.QRectF
) -> None:
p.setPen(self._pen)
) -> 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()
@ -209,6 +217,11 @@ class LevelLabel(YAxisLabel):
])
)
p.fillRect(
self.rect,
self.bg_color,
)
def highlight(self, pen) -> None:
self._pen = pen
self.update()
@ -247,9 +260,10 @@ class L1Label(LevelLabel):
class L1Labels:
"""Level 1 bid ask labels for dynamic update on price-axis.
'''
Level 1 bid ask labels for dynamic update on price-axis.
"""
'''
def __init__(
self,
plotitem: PlotItem,
@ -265,15 +279,17 @@ class L1Labels:
'chart': plotitem,
'parent': raxis,
'opacity': 1,
'opacity': .9,
'font_size': font_size,
'fg_color': chart.pen_color,
'bg_color': chart.view_color,
'fg_color': 'default_light',
'bg_color': chart.view_color, # normally 'papas_special'
}
# TODO: add humanized source-asset
# info format.
fmt_str = (
' {size:.{size_digits}f} x '
'{level:,.{level_digits}f} '
' {size:.{size_digits}f} u'
# '{level:,.{level_digits}f} '
)
fields = {
'level': 0,
@ -286,12 +302,17 @@ class L1Labels:
orient_v='bottom',
**kwargs,
)
bid.set_fmt_str(fmt_str=fmt_str, fields=fields)
bid.set_fmt_str(
fmt_str='\n' + 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.set_fmt_str(
fmt_str=fmt_str,
fields=fields)
ask.show()

View File

@ -233,6 +233,36 @@ class Label:
def delete(self) -> None:
self.vb.scene().removeItem(self.txt)
# NOTE: pulled out from ``ChartPlotWidget`` from way way old code.
# def _label_h(self, yhigh: float, ylow: float) -> float:
# # compute contents label "height" in view terms
# # to avoid having data "contents" overlap with them
# if self._labels:
# label = self._labels[self.name][0]
# rect = label.itemRect()
# tl, br = rect.topLeft(), rect.bottomRight()
# vb = self.plotItem.vb
# try:
# # on startup labels might not yet be rendered
# top, bottom = (vb.mapToView(tl).y(), vb.mapToView(br).y())
# # XXX: magic hack, how do we compute exactly?
# label_h = (top - bottom) * 0.42
# except np.linalg.LinAlgError:
# label_h = 0
# else:
# label_h = 0
# # print(f'label height {self.name}: {label_h}')
# if label_h > yhigh - ylow:
# label_h = 0
# print(f"bounds (ylow, yhigh): {(ylow, yhigh)}")
class FormatLabel(QLabel):
'''

View File

@ -361,8 +361,8 @@ class PlotItemOverlay:
if not sub_handlers:
src_handler = getattr(
root.vb,
ev_name,
root.vb,
ev_name,
)
def broadcast(

View File

@ -349,7 +349,7 @@ class OrderMode:
'''
if not order:
staged = self._staged_order
staged: Order = self._staged_order
# apply order fields for ems
oid = str(uuid.uuid4())
order = staged.copy()
@ -703,7 +703,6 @@ async def open_order_mode(
# symbol id
symbol = chart.linked.symbol
symkey = symbol.front_fqsn()
# map of per-provider account keys to position tracker instances
trackers: dict[str, PositionTracker] = {}
@ -864,7 +863,7 @@ async def open_order_mode(
# the expected symbol key in its positions msg.
for (broker, acctid), msgs in position_msgs.items():
for msg in msgs:
log.info(f'Loading pp for {symkey}:\n{pformat(msg)}')
log.info(f'Loading pp for {acctid}@{broker}:\n{pformat(msg)}')
await process_trade_msg(
mode,
book,