Compare commits

...

5 Commits

Author SHA1 Message Date
Gud Boi de5b1737b4 Add humanized duration labels to gap annotations
Introduce `humanize_duration()` helper in `.tsp._annotate` to
convert seconds to short human-readable format (d/h/m/s). Extend
annot-ctl API with `add_text()` method for placing `pg.TextItem`
labels on charts.

Also,
- add duration labels on RHS of gap arrows in `markup_gaps()`
- handle text item removal in `rm_annot()` match block
- expose `TextItem` cmd in `serve_rc_annots()` IPC handler
- use `hcolor()` for named-to-hex color conversion
- set anchor positioning for up vs down gaps

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
2026-01-27 20:51:21 -05:00
Gud Boi 1776242413 .ib.feed: trim bars frame to `start_dt` 2026-01-27 17:37:25 -05:00
Gud Boi 848c8ae533 ib._util: ignore timeout-errs when crash-handling `pyvnc` connects 2026-01-27 17:36:33 -05:00
Gud Boi fdea8556d7 Lul, woops compare against first-dt in `.ib.feed` bars frame.. 2026-01-27 16:21:19 -05:00
Gud Boi be28d083e4 Expose more `pg.ArrowItem` params thru annot-ctl API 2026-01-27 16:20:23 -05:00
6 changed files with 222 additions and 21 deletions

View File

@ -250,7 +250,9 @@ async def vnc_click_hack(
'connection': 'r'
}[reset_type]
with tractor.devx.open_crash_handler():
with tractor.devx.open_crash_handler(
ignore={TimeoutError,},
):
client = await AsyncVNCClient.connect(
VNCConfig(
host=host,

View File

@ -267,19 +267,28 @@ async def open_history_client(
if (
start_dt
and
last_dt < start_dt
first_dt < start_dt
):
bars_array = bars_array[
trimmed_bars = bars_array[
bars_array['time'] >= start_dt.timestamp()
]
if (
trimmed_first_dt := from_timestamp(trimmed_bars['time'][0])
!=
start_dt
):
# TODO! rm this once we're more confident it never hits!
breakpoint()
raise RuntimeError(
f'OHLC-bars array start is gt `start_dt` limit !!\n'
f'start_dt: {start_dt}\n'
f'last_dt: {last_dt}\n'
f'first_dt: {first_dt}\n'
f'trimmed_first_dt: {trimmed_first_dt}\n'
)
# XXX, overwrite with start_dt-limited frame
bars_array = trimmed_bars
return (
bars_array,
first_dt,

View File

@ -35,6 +35,51 @@ if TYPE_CHECKING:
from piker.ui._remote_ctl import AnnotCtl
def humanize_duration(
seconds: float,
) -> str:
'''
Convert duration in seconds to short human-readable form.
Uses smallest appropriate time unit:
- d: days
- h: hours
- m: minutes
- s: seconds
Examples:
- 86400 -> "1d"
- 28800 -> "8h"
- 180 -> "3m"
- 45 -> "45s"
'''
abs_secs: float = abs(seconds)
if abs_secs >= 86400:
days: float = abs_secs / 86400
if days >= 10:
return f'{int(days)}d'
return f'{days:.1f}d'
elif abs_secs >= 3600:
hours: float = abs_secs / 3600
if hours >= 10:
return f'{int(hours)}h'
return f'{hours:.1f}h'
elif abs_secs >= 60:
mins: float = abs_secs / 60
if mins >= 10:
return f'{int(mins)}m'
return f'{mins:.1f}m'
else:
if abs_secs >= 10:
return f'{int(abs_secs)}s'
return f'{abs_secs:.1f}s'
async def markup_gaps(
fqme: str,
timeframe: float,
@ -124,10 +169,13 @@ async def markup_gaps(
opn: float = row['open'][0]
cls: float = prev_r['close'][0]
# get gap duration for humanized label
gap_dur_s: float = row['s_diff'][0]
gap_label: str = humanize_duration(gap_dur_s)
# BGM=0.16 is the normal diff from overlap between bars, SO
# just go slightly "in" from that "between them".
from_idx: int = BGM - .06 # = .10
lc: tuple[float, float] = (
istart + 1 - from_idx,
cls,
@ -162,20 +210,43 @@ async def markup_gaps(
'down' if sgn == 1
else 'up'
)
# TODO! mk this a `msgspec.Struct` which we deserialize
# on the server side!
arrow_kwargs: dict[str, Any] = dict(
fqme=fqme,
timeframe=timeframe,
x=iend,
y=cls,
color=color,
alpha=160,
alpha=169,
pointing=direction,
# TODO: expose these as params to markup_gaps()?
headLen=10,
headWidth=2.222,
pxMode=True,
)
aid: int = await actl.add_arrow(
**arrow_kwargs
)
# add duration label to RHS of arrow
if sgn == -1: # up-gap
anchor = (0, 0) # XXX, i dun get dese dims.. XD
else: # down-gap
anchor = (0, 1) # XXX y, x?
text_aid: int = await actl.add_text(
fqme=fqme,
timeframe=timeframe,
text=gap_label,
x=iend + 1,
y=cls,
color=color,
anchor=anchor,
)
aids[text_aid] = {'text': gap_label}
# tell chart to redraw all its
# graphics view layers Bo
await actl.redraw(

View File

@ -243,8 +243,8 @@ async def maybe_fill_null_segments(
)
if (
from_timestamp(
array['time'][0]
frame_start_dt := (
from_timestamp(array['time'][0])
) < backfill_until_dt
):
await tractor.pause()

View File

@ -94,6 +94,11 @@ class ArrowEditor(Struct):
] = None,
alpha: int = 255,
zval: float = 1e9,
headLen: float|None = None,
headWidth: float|None = None,
tailLen: float|None = None,
tailWidth: float|None = None,
pxMode: bool = True,
) -> pg.ArrowItem:
'''
@ -109,6 +114,15 @@ class ArrowEditor(Struct):
# scale arrow sizing to dpi-aware font
size = _font.font.pixelSize() * 0.8
# allow caller override of head dimensions
if headLen is None:
headLen = size
if headWidth is None:
headWidth = size/2
# tail params default to None (no tail)
if tailWidth is None:
tailWidth = 3
color = color or 'default'
color = QColor(hcolor(color))
color.setAlpha(alpha)
@ -117,10 +131,11 @@ class ArrowEditor(Struct):
arrow = pg.ArrowItem(
angle=angle,
baseAngle=0,
headLen=size,
headWidth=size/2,
tailLen=None,
pxMode=True,
headLen=headLen,
headWidth=headWidth,
tailLen=tailLen,
tailWidth=tailWidth,
pxMode=pxMode,
# coloring
pen=pen,
brush=brush,

View File

@ -48,6 +48,7 @@ from piker.service import find_service
from piker.brokers import SymbolNotFound
from piker.ui.qt import (
QGraphicsItem,
QColor,
)
from ._display import DisplayState
from ._interaction import ChartView
@ -57,6 +58,7 @@ from ._editors import (
)
from ._chart import ChartPlotWidget
from ._dataviz import Viz
from ._style import hcolor
log = get_logger(__name__)
@ -93,7 +95,7 @@ _annots: AnnotsTable = {}
_editors: EditorsTable = {}
def rm_annot(
annot: ArrowEditor|SelectRect
annot: ArrowEditor|SelectRect|pg.TextItem
) -> bool:
global _editors
match annot:
@ -114,6 +116,12 @@ def rm_annot(
annot.delete()
return True
case pg.TextItem():
scene = annot.scene()
if scene:
scene.removeItem(annot)
return True
return False
@ -183,6 +191,11 @@ async def serve_rc_annots(
'color': color,
'aid': str()|None as aid,
'alpha': int(alpha),
'headLen': int()|float()|None as headLen,
'headWidth': int()|float()|None as headWidth,
'tailLen': int()|float()|None as tailLen,
'tailWidth': int()|float()|None as tailWidth,
'pxMode': bool(pxMode),
},
# ?TODO? split based on method fn-sigs?
# 'pointing',
@ -213,6 +226,11 @@ async def serve_rc_annots(
pointing=pointing,
color=color,
alpha=alpha,
headLen=headLen,
headWidth=headWidth,
tailLen=tailLen,
tailWidth=tailWidth,
pxMode=pxMode,
)
annots[aid] = arrow
_editors[aid] = arrows
@ -220,8 +238,41 @@ async def serve_rc_annots(
aids.add(aid)
await annot_req_stream.send(aid)
# TODO, use `pg.TextItem` to put a humaized
# time label beside the arrows
case {
'cmd': 'TextItem',
'fqme': fqme,
'timeframe': timeframe,
'kwargs': {
'text': str(text),
'x': int()|float() as x,
'y': int()|float() as y,
'color': color,
'anchor': list(anchor),
},
}:
ds: DisplayState = _dss[fqme]
chart: ChartPlotWidget = {
60: ds.hist_chart,
1: ds.chart,
}[timeframe]
# convert named color to hex
color_hex: str = hcolor(color)
# create text item
text_item: pg.TextItem = pg.TextItem(
text=text,
color=color_hex,
anchor=anchor,
)
text_item.setPos(x, y)
chart.plotItem.addItem(text_item)
aid: str = str(uuid4())
annots[aid] = text_item
aids: set[int] = ctxs[ipc_key][1]
aids.add(aid)
await annot_req_stream.send(aid)
case {
'cmd': 'remove',
@ -441,6 +492,11 @@ class AnnotCtl(Struct):
# domain: str = 'view', # or 'scene'
color: str = 'dad_blue',
alpha: int = 116,
headLen: float|None = None,
headWidth: float|None = None,
tailLen: float|None = None,
tailWidth: float|None = None,
pxMode: bool = True,
from_acm: bool = False,
@ -464,6 +520,54 @@ class AnnotCtl(Struct):
'pointing': pointing, # up|down
'alpha': alpha,
'aid': None,
'headLen': headLen,
'headWidth': headWidth,
'tailLen': tailLen,
'tailWidth': tailWidth,
'pxMode': pxMode,
},
})
aid: int = await ipc.receive()
self._ipcs[aid] = ipc
if not from_acm:
self._annot_stack.push_async_callback(
partial(
self.remove,
aid,
)
)
return aid
async def add_text(
self,
fqme: str,
timeframe: float,
text: str,
x: float,
y: float,
color: str|tuple = 'dad_blue',
anchor: tuple[float, float] = (0, 1),
from_acm: bool = False,
) -> int:
'''
Add a `pg.TextItem` annotation to the target view.
anchor: (x, y) where (0,0) is upper-left, (1,1) is lower-right
'''
ipc: MsgStream = self._get_ipc(fqme)
await ipc.send({
'fqme': fqme,
'cmd': 'TextItem',
'timeframe': timeframe,
'kwargs': {
'text': text,
'x': float(x),
'y': float(y),
'color': color,
'anchor': tuple(anchor),
},
})
aid: int = await ipc.receive()