piker/tests/test_tsp_analysis.py

331 lines
7.8 KiB
Python
Raw Permalink Normal View History

'''
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,
detect_time_ordering_errors,
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()
def test_negative_delta_is_an_ordering_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
'''
Reversed timestamps are reported as ordering faults, not gaps.
'''
src = np2pl(mk_ohlcv([120, 60]))
wdts: pl.DataFrame = with_dts(src)
errors = detect_time_ordering_errors(wdts)
assert detect_time_gaps(
wdts,
expect_period=30,
).is_empty()
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]
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]