307 lines
8.9 KiB
Python
307 lines
8.9 KiB
Python
# 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 <https://www.gnu.org/licenses/>.
|
|
|
|
"""
|
|
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
|