# piker: trading gear for hackers # Copyright (C) 2018-present Tyler Goodlet (in stewardship of pikers) # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . """ Time-series (remote) annotation APIs. """ from __future__ import annotations from math import copysign from typing import ( Any, TYPE_CHECKING, ) import polars as pl import tractor from piker.data._formatters import BGM from piker.storage import log from piker.ui._style import get_fonts 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 or days == int(days): return f'{int(days)}d' return f'{days:.1f}d' elif abs_secs >= 3600: hours: float = abs_secs / 3600 if hours >= 10 or hours == int(hours): return f'{int(hours)}h' return f'{hours:.1f}h' elif abs_secs >= 60: mins: float = abs_secs / 60 if mins >= 10 or mins == int(mins): return f'{int(mins)}m' return f'{mins:.1f}m' else: if abs_secs >= 10 or abs_secs == int(abs_secs): return f'{int(abs_secs)}s' return f'{abs_secs:.1f}s' async def markup_gaps( fqme: str, timeframe: float, actl: AnnotCtl, wdts: pl.DataFrame, gaps: pl.DataFrame, # XXX, switch on to see txt showing a "humanized" label of each # gap's duration. show_txt: bool = False, ) -> dict[int, dict]: ''' Remote annotate time-gaps in a dt-fielded ts (normally OHLC) 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] = {} for i in range(gaps.height): row: pl.DataFrame = gaps[i] # the gap's RIGHT-most bar's OPEN value # at that time (sample) step. iend: int = row['index'][0] # dt: datetime = row['dt'][0] # dt_prev: datetime = row['dt_prev'][0] # dt_end_t: float = dt.timestamp() # TODO: can we eventually remove this # once we figure out why the epoch cols # don't match? # TODO: FIX HOW/WHY these aren't matching # and are instead off by 4hours (EST # vs. UTC?!?!) # end_t: float = row['time'] # assert ( # dt.timestamp() # == # end_t # ) # the gap's LEFT-most bar's CLOSE value # at that time (sample) step. prev_r: pl.DataFrame = wdts.filter( pl.col('index') == iend - 1 ) # XXX: probably a gap in the (newly sorted or de-duplicated) # dt-df, so we might need to re-index first.. dt: pl.Series = row['dt'] dt_prev: pl.Series = row['dt_prev'] if prev_r.is_empty(): # XXX, filter out any special ignore cases, # - UNIX-epoch stamped datums # - first row if ( dt_prev.dt.epoch()[0] == 0 or dt.dt.epoch()[0] == 0 ): log.warning('Skipping row with UNIX epoch timestamp ??') continue if wdts[0]['index'][0] == iend: # first row log.warning('Skipping first-row (has no previous obvi) !!') continue # XXX, if the previous-row by shm-index is missing, # meaning there is a missing sample (set), get the prior # row by df index and attempt to use it? i_wdts: pl.DataFrame = wdts.with_row_index(name='i') i_row: int = i_wdts.filter(pl.col('index') == iend)['i'][0] prev_row_by_i = wdts[i_row] prev_r: pl.DataFrame = prev_row_by_i # debug any missing pre-row if tractor._state.is_debug_mode(): await tractor.pause() istart: int = prev_r['index'][0] # TODO: implement px-col width measure # and ensure at least as many px-cols # shown per rect as configured by user. # gap_w: float = abs((iend - istart)) # if gap_w < 6: # margin: float = 6 # iend += margin # istart -= margin 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) # 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 # just go slightly "in" from that "between them". from_idx: int = BGM - .06 # = .10 lc: tuple[float, float] = ( istart + 1 - from_idx, cls, ) ro: tuple[float, float] = ( iend + from_idx, opn, ) diff: float = cls - opn sgn: float = copysign(1, diff) up_gap: bool = sgn == -1 down_gap: bool = sgn == 1 flat: bool = sgn == 0 color: str = 'dad_blue' # TODO? mks more sense to have up/down coloring? # color: str = { # -1: 'lilypad_green', # up-gap # 1: 'wine', # down-gap # }[sgn] rect_kwargs: dict[str, Any] = dict( fqme=fqme, timeframe=timeframe, start_pos=lc, end_pos=ro, color=color, start_time=start_time, end_time=end_time, ) # add up/down rects aid: int|None = await actl.add_rect(**rect_kwargs) if aid is None: log.error( f'Failed to add rect for,\n' f'{rect_kwargs!r}\n' f'\n' f'Skipping to next gap!\n' ) continue assert aid aids[aid] = rect_kwargs direction: str = ( 'down' if down_gap else 'up' ) # TODO! mk this a `msgspec.Struct` which we deserialize # 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( fqme=fqme, timeframe=timeframe, x=iend, # fallback if timestamp lookup fails y=cls, time=gap_time, # for server-side index lookup color=color, 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 up_gap: anchor = (0, 0) # ^XXX? i dun get dese dims.. XD elif down_gap: anchor = (0, 1) # XXX y, x? else: # no-gap? assert flat anchor = (0, 0) # up from bottom # use a slightly smaller font for gap label txt. font, small_font = get_fonts() font_size: int = small_font.px_size - 1 assert isinstance(font_size, int) if show_txt: text_aid: int = await actl.add_text( fqme=fqme, timeframe=timeframe, text=gap_label, x=iend + 1, # fallback if timestamp lookup fails y=cls, time=gap_time, # server-side index lookup color=color, anchor=anchor, font_size=font_size, ) aids[text_aid] = {'text': gap_label} # tell chart to redraw all its # graphics view layers Bo await actl.redraw( fqme=fqme, timeframe=timeframe, ) return aids