2026-07-22 02:07:13 +00:00
|
|
|
'''
|
|
|
|
|
Deterministic time-series analysis regressions.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
import numpy as np
|
|
|
|
|
import polars as pl
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from piker.data._source import def_iohlcv_fields
|
|
|
|
|
from piker.tsp import _anal
|
|
|
|
|
from piker.tsp._anal import (
|
|
|
|
|
dedupe,
|
|
|
|
|
detect_time_gaps,
|
2026-07-22 02:47:14 +00:00
|
|
|
detect_time_ordering_errors,
|
2026-07-22 02:07:13 +00:00
|
|
|
get_null_segs,
|
|
|
|
|
iter_null_segs,
|
|
|
|
|
np2pl,
|
|
|
|
|
with_dts,
|
|
|
|
|
)
|
|
|
|
|
from piker.tsp._dedupe_smart import dedupe_ohlcv_smart
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def mk_ohlcv(
|
|
|
|
|
times: list[int],
|
|
|
|
|
indexes: list[int]|None = None,
|
|
|
|
|
) -> np.ndarray:
|
|
|
|
|
'''
|
|
|
|
|
Build a synthetic OHLCV frame from timestamps.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
size: int = len(times)
|
|
|
|
|
frame = np.zeros(
|
|
|
|
|
size,
|
|
|
|
|
dtype=np.dtype(def_iohlcv_fields),
|
|
|
|
|
)
|
|
|
|
|
frame['index'] = (
|
|
|
|
|
np.arange(size)
|
|
|
|
|
if indexes is None
|
|
|
|
|
else indexes
|
|
|
|
|
)
|
|
|
|
|
frame['time'] = times
|
|
|
|
|
frame['open'] = np.arange(size) + 10
|
|
|
|
|
frame['high'] = frame['open'] + 2
|
|
|
|
|
frame['low'] = frame['open'] - 2
|
|
|
|
|
frame['close'] = frame['open'] + 1
|
|
|
|
|
frame['volume'] = np.arange(size) + 100
|
|
|
|
|
return frame
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def expected_null_segments(
|
|
|
|
|
null_positions: list[int],
|
|
|
|
|
size: int,
|
|
|
|
|
offset: int,
|
|
|
|
|
margin: int,
|
|
|
|
|
|
|
|
|
|
) -> list[list[int]]:
|
|
|
|
|
'''
|
|
|
|
|
Group relative null positions with a simple state machine.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
runs: list[list[int]] = []
|
|
|
|
|
for pos in null_positions:
|
|
|
|
|
if (
|
|
|
|
|
not runs
|
|
|
|
|
or
|
|
|
|
|
pos != runs[-1][1] + 1
|
|
|
|
|
):
|
|
|
|
|
runs.append([pos, pos])
|
|
|
|
|
else:
|
|
|
|
|
runs[-1][1] = pos
|
|
|
|
|
|
|
|
|
|
frame_end: int = offset + size - 1
|
|
|
|
|
return [
|
|
|
|
|
[
|
|
|
|
|
max(offset, offset + start - margin),
|
|
|
|
|
min(frame_end, offset + end + margin),
|
|
|
|
|
]
|
|
|
|
|
for start, end in runs
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_dedupe_recomputes_dts_after_sort() -> None:
|
|
|
|
|
'''
|
|
|
|
|
Derived columns describe final sorted, unique rows.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
src: pl.DataFrame = np2pl(mk_ohlcv([
|
|
|
|
|
60,
|
|
|
|
|
180,
|
|
|
|
|
120,
|
|
|
|
|
180,
|
|
|
|
|
]))
|
|
|
|
|
|
|
|
|
|
_, deduped, diff = dedupe(src)
|
|
|
|
|
|
|
|
|
|
assert diff == 1
|
|
|
|
|
assert deduped['time'].to_list() == [60, 120, 180]
|
|
|
|
|
assert deduped['time_prev'].to_list() == [None, 60, 120]
|
|
|
|
|
assert deduped['s_diff'].to_list() == [None, 60, 60]
|
|
|
|
|
assert detect_time_gaps(
|
|
|
|
|
deduped,
|
|
|
|
|
expect_period=60,
|
|
|
|
|
).is_empty()
|
|
|
|
|
|
|
|
|
|
|
2026-07-22 02:47:14 +00:00
|
|
|
def test_negative_delta_is_an_ordering_error(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
2026-07-22 02:07:13 +00:00
|
|
|
'''
|
2026-07-22 02:47:14 +00:00
|
|
|
Reversed timestamps are reported as ordering faults, not gaps.
|
2026-07-22 02:07:13 +00:00
|
|
|
|
|
|
|
|
'''
|
2026-07-22 02:47:14 +00:00
|
|
|
src = np2pl(mk_ohlcv([120, 60]))
|
|
|
|
|
wdts: pl.DataFrame = with_dts(src)
|
|
|
|
|
errors = detect_time_ordering_errors(wdts)
|
2026-07-22 02:07:13 +00:00
|
|
|
|
|
|
|
|
assert detect_time_gaps(
|
|
|
|
|
wdts,
|
|
|
|
|
expect_period=30,
|
|
|
|
|
).is_empty()
|
2026-07-22 02:47:14 +00:00
|
|
|
assert errors['time_prev'].to_list() == [120]
|
|
|
|
|
assert errors['time'].to_list() == [60]
|
|
|
|
|
assert errors['s_diff'].to_list() == [-60]
|
|
|
|
|
|
|
|
|
|
warnings: list[str] = []
|
|
|
|
|
monkeypatch.setattr(_anal.log, 'warning', warnings.append)
|
|
|
|
|
dedupe(src)
|
|
|
|
|
assert len(warnings) == 1
|
|
|
|
|
assert 'non-positive timestamp step' in warnings[0]
|
2026-07-22 02:07:13 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_single_null_row_is_a_segment() -> None:
|
|
|
|
|
'''
|
|
|
|
|
A one-row null segment remains repairable.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
frame: np.ndarray = mk_ohlcv([
|
|
|
|
|
60,
|
|
|
|
|
120,
|
|
|
|
|
0,
|
|
|
|
|
240,
|
|
|
|
|
300,
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
nulls = get_null_segs(frame, period=60)
|
|
|
|
|
|
|
|
|
|
assert nulls is not None
|
|
|
|
|
segments, zero_indexes, zero_rows = nulls
|
|
|
|
|
assert segments == [[1, 3]]
|
|
|
|
|
assert zero_indexes.tolist() == [2]
|
|
|
|
|
assert zero_rows['index'].tolist() == [2]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
('times', 'expected'),
|
|
|
|
|
[
|
|
|
|
|
([0, 0, 180, 240, 300, 360], [[10, 12]]),
|
|
|
|
|
([60, 0, 0, 240, 0, 360], [[10, 13], [13, 15]]),
|
|
|
|
|
([60, 120, 180, 240, 0, 0], [[13, 15]]),
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
def test_null_segment_boundaries_match_numpy_and_polars(
|
|
|
|
|
times: list[int],
|
|
|
|
|
expected: list[list[int]],
|
|
|
|
|
) -> None:
|
|
|
|
|
'''
|
|
|
|
|
Segments have exact, inclusive, margin-expanded endpoints.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
frame: np.ndarray = mk_ohlcv(
|
|
|
|
|
times,
|
|
|
|
|
indexes=list(range(10, 16)),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
np_nulls = get_null_segs(frame, period=60)
|
|
|
|
|
pl_nulls = get_null_segs(np2pl(frame), period=60)
|
|
|
|
|
|
|
|
|
|
assert np_nulls is not None
|
|
|
|
|
assert pl_nulls is not None
|
|
|
|
|
assert np_nulls[0] == expected
|
|
|
|
|
assert pl_nulls[0] == expected
|
|
|
|
|
assert np_nulls[1].tolist() == pl_nulls[1].to_list()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_null_segment_grouping_exhaustive() -> None:
|
|
|
|
|
'''
|
|
|
|
|
Every null layout through six rows matches an independent oracle.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
offset: int = 10
|
|
|
|
|
for size in range(1, 7):
|
|
|
|
|
for mask in range(1, 1 << size):
|
|
|
|
|
null_positions: list[int] = [
|
|
|
|
|
pos
|
|
|
|
|
for pos in range(size)
|
|
|
|
|
if mask & (1 << pos)
|
|
|
|
|
]
|
|
|
|
|
frame = mk_ohlcv(
|
|
|
|
|
[
|
|
|
|
|
0
|
|
|
|
|
if pos in null_positions
|
|
|
|
|
else (pos + 1) * 60
|
|
|
|
|
for pos in range(size)
|
|
|
|
|
],
|
|
|
|
|
indexes=list(range(offset, offset + size)),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
for margin in range(3):
|
|
|
|
|
expected = expected_null_segments(
|
|
|
|
|
null_positions,
|
|
|
|
|
size,
|
|
|
|
|
offset,
|
|
|
|
|
margin,
|
|
|
|
|
)
|
|
|
|
|
for candidate in (frame, np2pl(frame)):
|
|
|
|
|
nulls = get_null_segs(
|
|
|
|
|
candidate,
|
|
|
|
|
period=60,
|
|
|
|
|
imargin=margin,
|
|
|
|
|
)
|
|
|
|
|
assert nulls is not None
|
|
|
|
|
assert nulls[0] == expected
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_null_segment_margin_and_column() -> None:
|
|
|
|
|
'''
|
|
|
|
|
The requested column and index margin define each segment.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
frame: np.ndarray = mk_ohlcv(
|
|
|
|
|
[60, 120, 180, 240, 300],
|
|
|
|
|
indexes=[10, 11, 12, 13, 14],
|
|
|
|
|
)
|
|
|
|
|
frame['volume'][1:3] = 0
|
|
|
|
|
|
|
|
|
|
for candidate in (frame, np2pl(frame)):
|
|
|
|
|
no_margin = get_null_segs(
|
|
|
|
|
candidate,
|
|
|
|
|
period=60,
|
|
|
|
|
imargin=0,
|
|
|
|
|
col='volume',
|
|
|
|
|
)
|
|
|
|
|
wide_margin = get_null_segs(
|
|
|
|
|
candidate,
|
|
|
|
|
period=60,
|
|
|
|
|
imargin=2,
|
|
|
|
|
col='volume',
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert no_margin is not None
|
|
|
|
|
assert wide_margin is not None
|
|
|
|
|
assert no_margin[0] == [[11, 12]]
|
|
|
|
|
assert wide_margin[0] == [[10, 14]]
|
|
|
|
|
assert list(no_margin[1]) == [11, 12]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_null_segment_input_invariants() -> None:
|
|
|
|
|
'''
|
|
|
|
|
Empty results and invalid index/margin inputs are explicit.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
frame = mk_ohlcv([60, 120, 180])
|
|
|
|
|
assert get_null_segs(frame, period=60) is None
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValueError, match='imargin'):
|
|
|
|
|
get_null_segs(
|
|
|
|
|
mk_ohlcv([60, 0, 180]),
|
|
|
|
|
period=60,
|
|
|
|
|
imargin=-1,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(ValueError, match='contiguous'):
|
|
|
|
|
get_null_segs(
|
|
|
|
|
mk_ohlcv(
|
|
|
|
|
[60, 0, 180],
|
|
|
|
|
indexes=[10, 12, 13],
|
|
|
|
|
),
|
|
|
|
|
period=60,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_iter_null_segs_uses_precomputed_segments(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
'''
|
|
|
|
|
Iteration preserves inclusive boundary rows without rescanning.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
frame = mk_ohlcv([60, 120, 0, 240, 300])
|
|
|
|
|
nulls = get_null_segs(frame, period=60)
|
|
|
|
|
assert nulls is not None
|
|
|
|
|
|
|
|
|
|
def fail_rescan(*args, **kwargs):
|
|
|
|
|
raise AssertionError('precomputed segments were ignored')
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(_anal, 'get_null_segs', fail_rescan)
|
|
|
|
|
segments = list(iter_null_segs(
|
|
|
|
|
timeframe=60,
|
|
|
|
|
frame=frame,
|
|
|
|
|
null_segs=nulls,
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
assert len(segments) == 1
|
|
|
|
|
assert segments[0][:6] == (
|
|
|
|
|
1,
|
|
|
|
|
3,
|
|
|
|
|
1,
|
|
|
|
|
3,
|
|
|
|
|
120,
|
|
|
|
|
240,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_smart_dedupe_honors_sort_without_dupes() -> None:
|
|
|
|
|
'''
|
|
|
|
|
The sort option applies even when no duplicates exist.
|
|
|
|
|
|
|
|
|
|
'''
|
|
|
|
|
src: pl.DataFrame = np2pl(mk_ohlcv([
|
|
|
|
|
180,
|
|
|
|
|
60,
|
|
|
|
|
120,
|
|
|
|
|
]))
|
|
|
|
|
|
|
|
|
|
_, deduped, diff, _, issues = dedupe_ohlcv_smart(src)
|
|
|
|
|
|
|
|
|
|
assert diff == 0
|
|
|
|
|
assert issues is None
|
|
|
|
|
assert deduped['time'].to_list() == [60, 120, 180]
|
|
|
|
|
assert deduped['s_diff'].to_list() == [None, 60, 60]
|