Do time-based shm-index lookup for annots on server

Fix annotation misalignment during backfill by switching from
client-computed indices to server-side timestamp lookups against
current shm state. Store absolute coords on annotations and
reposition on viz redraws.

Lowlevel impl deats,
- add `time` param to `.add_arrow()`, `.add_text()`, `.add_rect()`
- lookup indices from shm via timestamp matching in IPC handlers
- force chart redraw before `markup_gaps()` annotation creation
- wrap IPC send/receive in `trio.fail_after(3)` for timeout when
  server fails to respond, likely hangs on no-case-match/error.
- cache `_meth`/`_kwargs` on rects, `_abs_x`/`_abs_y` on arrows
- auto-reposition all annotations after viz reset in redraw cmd

Also,
- handle `KeyError` for missing timeframes in chart lookup
- return `-1` aid on annotation creation failures (lol oh `claude`..)
- reconstruct rect positions from timestamps + BGM offset logic
- log repositioned annotation counts on viz redraw

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
multiaddrs
Gud Boi 2026-01-28 12:48:26 -05:00
parent 76f199df3b
commit 51d109f7e7
2 changed files with 291 additions and 87 deletions

View File

@ -94,6 +94,15 @@ async def markup_gaps(
with rectangles. with rectangles.
''' '''
# XXX: force chart redraw FIRST to ensure PlotItem coordinate
# system is properly initialized before we position annotations!
# Without this, annotations may be misaligned on first creation
# due to Qt/pyqtgraph initialization race conditions.
await actl.redraw(
fqme=fqme,
timeframe=timeframe,
)
aids: dict[int] = {} aids: dict[int] = {}
for i in range(gaps.height): for i in range(gaps.height):
row: pl.DataFrame = gaps[i] row: pl.DataFrame = gaps[i]
@ -101,6 +110,7 @@ async def markup_gaps(
# the gap's RIGHT-most bar's OPEN value # the gap's RIGHT-most bar's OPEN value
# at that time (sample) step. # at that time (sample) step.
iend: int = row['index'][0] iend: int = row['index'][0]
# dt: datetime = row['dt'][0] # dt: datetime = row['dt'][0]
# dt_prev: datetime = row['dt_prev'][0] # dt_prev: datetime = row['dt_prev'][0]
# dt_end_t: float = dt.timestamp() # dt_end_t: float = dt.timestamp()
@ -174,6 +184,10 @@ async def markup_gaps(
gap_dur_s: float = row['s_diff'][0] gap_dur_s: float = row['s_diff'][0]
gap_label: str = humanize_duration(gap_dur_s) gap_label: str = humanize_duration(gap_dur_s)
# XXX: get timestamps for server-side index lookup
start_time: float = prev_r['time'][0]
end_time: float = row['time'][0]
# BGM=0.16 is the normal diff from overlap between bars, SO # BGM=0.16 is the normal diff from overlap between bars, SO
# just go slightly "in" from that "between them". # just go slightly "in" from that "between them".
from_idx: int = BGM - .06 # = .10 from_idx: int = BGM - .06 # = .10
@ -201,6 +215,8 @@ async def markup_gaps(
start_pos=lc, start_pos=lc,
end_pos=ro, end_pos=ro,
color=color, color=color,
start_time=start_time,
end_time=end_time,
) )
# add up/down rects # add up/down rects
@ -213,11 +229,15 @@ async def markup_gaps(
) )
# TODO! mk this a `msgspec.Struct` which we deserialize # TODO! mk this a `msgspec.Struct` which we deserialize
# on the server side! # on the server side!
# XXX: send timestamp for server-side index lookup
# to ensure alignment with current shm state
gap_time: float = row['time'][0]
arrow_kwargs: dict[str, Any] = dict( arrow_kwargs: dict[str, Any] = dict(
fqme=fqme, fqme=fqme,
timeframe=timeframe, timeframe=timeframe,
x=iend, x=iend, # fallback if timestamp lookup fails
y=cls, y=cls,
time=gap_time, # for server-side index lookup
color=color, color=color,
alpha=169, alpha=169,
pointing=direction, pointing=direction,
@ -249,8 +269,9 @@ async def markup_gaps(
fqme=fqme, fqme=fqme,
timeframe=timeframe, timeframe=timeframe,
text=gap_label, text=gap_label,
x=iend + 1, x=iend + 1, # fallback if timestamp lookup fails
y=cls, y=cls,
time=gap_time, # server-side index lookup
color=color, color=color,
anchor=anchor, anchor=anchor,
font_size=font_size, font_size=font_size,

View File

@ -149,12 +149,72 @@ async def serve_rc_annots(
'kwargs': dict(kwargs), 'kwargs': dict(kwargs),
}: }:
ds: DisplayState = _dss[fqme] ds: DisplayState = _dss[fqme]
try:
chart: ChartPlotWidget = { chart: ChartPlotWidget = {
60: ds.hist_chart, 60: ds.hist_chart,
1: ds.chart, 1: ds.chart,
}[timeframe] }[timeframe]
except KeyError:
log.warning(
f'No chart for timeframe={timeframe}s, '
f'skipping rect annotation'
)
await annot_req_stream.send(-1)
continue
cv: ChartView = chart.cv cv: ChartView = chart.cv
# NEW: if timestamps provided, lookup current indices
# from shm to ensure alignment with current buffer
# state
start_time = kwargs.pop('start_time', None)
end_time = kwargs.pop('end_time', None)
if (
start_time is not None
and end_time is not None
):
viz: Viz = chart.get_viz(fqme)
shm = viz.shm
arr = shm.array
# lookup start index
start_matches = arr[arr['time'] == start_time]
if len(start_matches) == 0:
log.error(
f'No shm entry for start_time='
f'{start_time}, skipping rect'
)
await annot_req_stream.send(-1)
continue
# lookup end index
end_matches = arr[arr['time'] == end_time]
if len(end_matches) == 0:
log.error(
f'No shm entry for end_time={end_time}, '
f'skipping rect'
)
await annot_req_stream.send(-1)
continue
# get close price from start bar, open from end
# bar
start_idx = float(start_matches[0]['index'])
end_idx = float(end_matches[0]['index'])
start_close = float(start_matches[0]['close'])
end_open = float(end_matches[0]['open'])
# reconstruct start_pos and end_pos with
# looked-up indices
from_idx: float = 0.16 - 0.06 # BGM offset
kwargs['start_pos'] = (
start_idx + 1 - from_idx,
start_close,
)
kwargs['end_pos'] = (
end_idx + from_idx,
end_open,
)
# annot type lookup from cmd # annot type lookup from cmd
rect = SelectRect( rect = SelectRect(
viewbox=cv, viewbox=cv,
@ -173,6 +233,12 @@ async def serve_rc_annots(
# delegate generically to the requested method # delegate generically to the requested method
getattr(rect, meth)(**kwargs) getattr(rect, meth)(**kwargs)
rect.show() rect.show()
# XXX: store absolute coords for repositioning
# during viz redraws (eg backfill updates)
rect._meth = meth
rect._kwargs = kwargs
aid: int = id(rect) aid: int = id(rect)
annots[aid] = rect annots[aid] = rect
aids: set[int] = ctxs[ipc_key][1] aids: set[int] = ctxs[ipc_key][1]
@ -196,18 +262,47 @@ async def serve_rc_annots(
'tailLen': int()|float()|None as tailLen, 'tailLen': int()|float()|None as tailLen,
'tailWidth': int()|float()|None as tailWidth, 'tailWidth': int()|float()|None as tailWidth,
'pxMode': bool(pxMode), 'pxMode': bool(pxMode),
'time': int()|float()|None as timestamp,
}, },
# ?TODO? split based on method fn-sigs? # ?TODO? split based on method fn-sigs?
# 'pointing', # 'pointing',
}: }:
ds: DisplayState = _dss[fqme] ds: DisplayState = _dss[fqme]
try:
chart: ChartPlotWidget = { chart: ChartPlotWidget = {
60: ds.hist_chart, 60: ds.hist_chart,
1: ds.chart, 1: ds.chart,
}[timeframe] }[timeframe]
except KeyError:
log.warning(
f'No chart for timeframe={timeframe}s, '
f'skipping arrow annotation'
)
# return -1 to indicate failure
await annot_req_stream.send(-1)
continue
cv: ChartView = chart.cv cv: ChartView = chart.cv
godw = chart.linked.godwidget godw = chart.linked.godwidget
# NEW: if timestamp provided, lookup current index
# from shm to ensure alignment with current buffer
# state
if timestamp is not None:
viz: Viz = chart.get_viz(fqme)
shm = viz.shm
arr = shm.array
# find index where time matches timestamp
matches = arr[arr['time'] == timestamp]
if len(matches) == 0:
log.error(
f'No shm entry for timestamp={timestamp}, '
f'skipping arrow annotation'
)
await annot_req_stream.send(-1)
continue
# use the matched row's index as x
x = float(matches[0]['index'])
arrows = ArrowEditor(godw=godw) arrows = ArrowEditor(godw=godw)
# `.add/.remove()` API # `.add/.remove()` API
if meth != 'add': if meth != 'add':
@ -232,6 +327,11 @@ async def serve_rc_annots(
tailWidth=tailWidth, tailWidth=tailWidth,
pxMode=pxMode, pxMode=pxMode,
) )
# XXX: store absolute coords for repositioning
# during viz redraws (eg backfill updates)
arrow._abs_x = x
arrow._abs_y = y
annots[aid] = arrow annots[aid] = arrow
_editors[aid] = arrows _editors[aid] = arrows
aids: set[int] = ctxs[ipc_key][1] aids: set[int] = ctxs[ipc_key][1]
@ -249,13 +349,42 @@ async def serve_rc_annots(
'color': color, 'color': color,
'anchor': list(anchor), 'anchor': list(anchor),
'font_size': int()|None as font_size, 'font_size': int()|None as font_size,
'time': int()|float()|None as timestamp,
}, },
}: }:
ds: DisplayState = _dss[fqme] ds: DisplayState = _dss[fqme]
try:
chart: ChartPlotWidget = { chart: ChartPlotWidget = {
60: ds.hist_chart, 60: ds.hist_chart,
1: ds.chart, 1: ds.chart,
}[timeframe] }[timeframe]
except KeyError:
log.warning(
f'No chart for timeframe={timeframe}s, '
f'skipping text annotation'
)
await annot_req_stream.send(-1)
continue
# NEW: if timestamp provided, lookup current index
# from shm to ensure alignment with current buffer
# state
if timestamp is not None:
viz: Viz = chart.get_viz(fqme)
shm = viz.shm
arr = shm.array
# find index where time matches timestamp
matches = arr[arr['time'] == timestamp]
if len(matches) == 0:
log.error(
f'No shm entry for timestamp={timestamp}, '
f'skipping text annotation'
)
await annot_req_stream.send(-1)
continue
# use the matched row's index as x, +1 for text
# offset
x = float(matches[0]['index']) + 1
# convert named color to hex # convert named color to hex
color_hex: str = hcolor(color) color_hex: str = hcolor(color)
@ -284,6 +413,11 @@ async def serve_rc_annots(
text_item.setPos(x, y) text_item.setPos(x, y)
chart.plotItem.addItem(text_item) chart.plotItem.addItem(text_item)
# XXX: store absolute coords for repositioning
# during viz redraws (eg backfill updates)
text_item._abs_x = x
text_item._abs_y = y
aid: str = str(uuid4()) aid: str = str(uuid4())
annots[aid] = text_item annots[aid] = text_item
aids: set[int] = ctxs[ipc_key][1] aids: set[int] = ctxs[ipc_key][1]
@ -329,6 +463,38 @@ async def serve_rc_annots(
) )
viz.reset_graphics() viz.reset_graphics()
# XXX: reposition all annotations to ensure they
# stay aligned with viz data after reset (eg during
# backfill when abs-index range changes)
n_repositioned: int = 0
for aid, annot in annots.items():
# arrows and text items use abs x,y coords
if (
hasattr(annot, '_abs_x')
and
hasattr(annot, '_abs_y')
):
annot.setPos(
annot._abs_x,
annot._abs_y,
)
n_repositioned += 1
# rects use method + kwargs
elif (
hasattr(annot, '_meth')
and
hasattr(annot, '_kwargs')
):
getattr(annot, annot._meth)(**annot._kwargs)
n_repositioned += 1
if n_repositioned:
log.info(
f'Repositioned {n_repositioned} annotation(s) '
f'after viz redraw'
)
case _: case _:
log.error( log.error(
'Unknown remote annotation cmd:\n' 'Unknown remote annotation cmd:\n'
@ -417,6 +583,10 @@ class AnnotCtl(Struct):
from_acm: bool = False, from_acm: bool = False,
# NEW: optional timestamps for server-side index lookup
start_time: float|None = None,
end_time: float|None = None,
) -> int: ) -> int:
''' '''
Add a `SelectRect` annotation to the target view, return Add a `SelectRect` annotation to the target view, return
@ -424,6 +594,7 @@ class AnnotCtl(Struct):
''' '''
ipc: MsgStream = self._get_ipc(fqme) ipc: MsgStream = self._get_ipc(fqme)
with trio.fail_after(3):
await ipc.send({ await ipc.send({
'fqme': fqme, 'fqme': fqme,
'cmd': 'SelectRect', 'cmd': 'SelectRect',
@ -435,6 +606,8 @@ class AnnotCtl(Struct):
'end_pos': tuple(end_pos), 'end_pos': tuple(end_pos),
'color': color, 'color': color,
'update_label': False, 'update_label': False,
'start_time': start_time,
'end_time': end_time,
}, },
}) })
aid: int = await ipc.receive() aid: int = await ipc.receive()
@ -516,6 +689,9 @@ class AnnotCtl(Struct):
from_acm: bool = False, from_acm: bool = False,
# NEW: optional timestamp for server-side index lookup
time: float|None = None,
) -> int: ) -> int:
''' '''
Add a `SelectRect` annotation to the target view, return Add a `SelectRect` annotation to the target view, return
@ -523,6 +699,7 @@ class AnnotCtl(Struct):
''' '''
ipc: MsgStream = self._get_ipc(fqme) ipc: MsgStream = self._get_ipc(fqme)
with trio.fail_after(3):
await ipc.send({ await ipc.send({
'fqme': fqme, 'fqme': fqme,
'cmd': 'ArrowEditor', 'cmd': 'ArrowEditor',
@ -541,6 +718,7 @@ class AnnotCtl(Struct):
'tailLen': tailLen, 'tailLen': tailLen,
'tailWidth': tailWidth, 'tailWidth': tailWidth,
'pxMode': pxMode, 'pxMode': pxMode,
'time': time, # for server-side index lookup
}, },
}) })
aid: int = await ipc.receive() aid: int = await ipc.receive()
@ -567,6 +745,9 @@ class AnnotCtl(Struct):
from_acm: bool = False, from_acm: bool = False,
# NEW: optional timestamp for server-side index lookup
time: float|None = None,
) -> int: ) -> int:
''' '''
Add a `pg.TextItem` annotation to the target view. Add a `pg.TextItem` annotation to the target view.
@ -576,6 +757,7 @@ class AnnotCtl(Struct):
''' '''
ipc: MsgStream = self._get_ipc(fqme) ipc: MsgStream = self._get_ipc(fqme)
with trio.fail_after(3):
await ipc.send({ await ipc.send({
'fqme': fqme, 'fqme': fqme,
'cmd': 'TextItem', 'cmd': 'TextItem',
@ -587,6 +769,7 @@ class AnnotCtl(Struct):
'color': color, 'color': color,
'anchor': tuple(anchor), 'anchor': tuple(anchor),
'font_size': font_size, 'font_size': font_size,
'time': time, # for server-side index lookup
}, },
}) })
aid: int = await ipc.receive() aid: int = await ipc.receive()