353 lines
9.5 KiB
Python
353 lines
9.5 KiB
Python
"""Tests for gap detection and filling."""
|
|
|
|
import pytest
|
|
|
|
from exchange_data_manager.candles.models import Candle
|
|
from exchange_data_manager.cache.gaps import (
|
|
find_gaps,
|
|
count_missing_candles,
|
|
has_gaps,
|
|
GapRange,
|
|
)
|
|
from exchange_data_manager.cache.gap_filler import (
|
|
fill_gaps,
|
|
fill_gaps_backward,
|
|
fill_to_range,
|
|
)
|
|
|
|
|
|
def make_candle(time: int, close: float = 50000.0) -> Candle:
|
|
"""Helper to create candles with defaults."""
|
|
return Candle(
|
|
time=time,
|
|
open=close,
|
|
high=close + 100,
|
|
low=close - 100,
|
|
close=close,
|
|
volume=10.0,
|
|
)
|
|
|
|
|
|
class TestFindGaps:
|
|
"""Tests for find_gaps function."""
|
|
|
|
def test_no_gaps(self):
|
|
"""Test no gaps when data is continuous."""
|
|
candles = [
|
|
make_candle(1709337600),
|
|
make_candle(1709337660),
|
|
make_candle(1709337720),
|
|
]
|
|
|
|
gaps = find_gaps(candles, "1m")
|
|
|
|
assert len(gaps) == 0
|
|
|
|
def test_single_candle_gap(self):
|
|
"""Test detects single missing candle (critical regression test)."""
|
|
candles = [
|
|
make_candle(1709337600), # 0:00
|
|
# Missing: 1709337660 (0:01)
|
|
make_candle(1709337720), # 0:02
|
|
]
|
|
|
|
gaps = find_gaps(candles, "1m")
|
|
|
|
assert len(gaps) == 1
|
|
assert gaps[0].start_ms == 1709337660000
|
|
assert gaps[0].end_ms == 1709337660000
|
|
|
|
def test_multi_candle_gap(self):
|
|
"""Test detects multiple missing candles."""
|
|
candles = [
|
|
make_candle(1709337600), # 0:00
|
|
# Missing: 0:01, 0:02, 0:03
|
|
make_candle(1709337840), # 0:04
|
|
]
|
|
|
|
gaps = find_gaps(candles, "1m")
|
|
|
|
assert len(gaps) == 1
|
|
assert gaps[0].start_ms == 1709337660000
|
|
assert gaps[0].end_ms == 1709337780000 # 3 minutes of gap
|
|
|
|
def test_multiple_gaps(self):
|
|
"""Test detects multiple separate gaps."""
|
|
candles = [
|
|
make_candle(1709337600), # 0:00
|
|
# Gap 1
|
|
make_candle(1709337720), # 0:02
|
|
# Gap 2
|
|
make_candle(1709337840), # 0:04
|
|
]
|
|
|
|
gaps = find_gaps(candles, "1m")
|
|
|
|
assert len(gaps) == 2
|
|
|
|
def test_empty_candles(self):
|
|
"""Test empty candles returns no gaps (unless start/end provided)."""
|
|
gaps = find_gaps([], "1m")
|
|
assert len(gaps) == 0
|
|
|
|
def test_empty_with_range(self):
|
|
"""Test empty candles with range returns full range as gap."""
|
|
gaps = find_gaps(
|
|
[],
|
|
"1m",
|
|
start_ms=1709337600000,
|
|
end_ms=1709337720000,
|
|
)
|
|
|
|
assert len(gaps) == 1
|
|
assert gaps[0].start_ms == 1709337600000
|
|
assert gaps[0].end_ms == 1709337720000
|
|
|
|
def test_gap_at_start(self):
|
|
"""Test detects gap at start of range."""
|
|
candles = [
|
|
make_candle(1709337660), # Missing first
|
|
make_candle(1709337720),
|
|
]
|
|
|
|
gaps = find_gaps(
|
|
candles,
|
|
"1m",
|
|
start_ms=1709337600000,
|
|
)
|
|
|
|
assert len(gaps) == 1
|
|
assert gaps[0].start_ms == 1709337600000
|
|
|
|
def test_gap_at_end(self):
|
|
"""Test detects gap at end of range."""
|
|
candles = [
|
|
make_candle(1709337600),
|
|
make_candle(1709337660),
|
|
# Missing at end
|
|
]
|
|
|
|
gaps = find_gaps(
|
|
candles,
|
|
"1m",
|
|
end_ms=1709337780000,
|
|
)
|
|
|
|
assert len(gaps) == 1
|
|
assert gaps[0].start_ms == 1709337720000
|
|
|
|
def test_different_timeframes(self):
|
|
"""Test gap detection works with different timeframes."""
|
|
# 5m candles
|
|
candles = [
|
|
make_candle(1709337600), # 0:00
|
|
# Missing: 0:05
|
|
make_candle(1709338200), # 0:10
|
|
]
|
|
|
|
gaps = find_gaps(candles, "5m")
|
|
|
|
assert len(gaps) == 1
|
|
assert gaps[0].start_ms == 1709337900000
|
|
|
|
|
|
class TestCountMissingCandles:
|
|
"""Tests for count_missing_candles function."""
|
|
|
|
def test_no_missing(self):
|
|
"""Test count is 0 when no gaps."""
|
|
candles = [
|
|
make_candle(1709337600),
|
|
make_candle(1709337660),
|
|
make_candle(1709337720),
|
|
]
|
|
|
|
count = count_missing_candles(candles, "1m")
|
|
|
|
assert count == 0
|
|
|
|
def test_single_missing(self):
|
|
"""Test counts single missing candle."""
|
|
candles = [
|
|
make_candle(1709337600),
|
|
make_candle(1709337720), # Gap of 1
|
|
]
|
|
|
|
count = count_missing_candles(candles, "1m")
|
|
|
|
assert count == 1
|
|
|
|
def test_multiple_missing(self):
|
|
"""Test counts multiple missing candles."""
|
|
candles = [
|
|
make_candle(1709337600),
|
|
make_candle(1709337840), # Gap of 3
|
|
]
|
|
|
|
count = count_missing_candles(candles, "1m")
|
|
|
|
assert count == 3
|
|
|
|
|
|
class TestHasGaps:
|
|
"""Tests for has_gaps quick check."""
|
|
|
|
def test_no_gaps_returns_false(self):
|
|
"""Test returns False when no gaps."""
|
|
candles = [
|
|
make_candle(1709337600),
|
|
make_candle(1709337660),
|
|
make_candle(1709337720),
|
|
]
|
|
|
|
assert has_gaps(candles, "1m") is False
|
|
|
|
def test_with_gaps_returns_true(self):
|
|
"""Test returns True when gaps exist."""
|
|
candles = [
|
|
make_candle(1709337600),
|
|
make_candle(1709337720), # Gap
|
|
]
|
|
|
|
assert has_gaps(candles, "1m") is True
|
|
|
|
def test_single_candle_returns_false(self):
|
|
"""Test single candle returns False (can't have gaps)."""
|
|
candles = [make_candle(1709337600)]
|
|
|
|
assert has_gaps(candles, "1m") is False
|
|
|
|
|
|
class TestFillGaps:
|
|
"""Tests for fill_gaps function."""
|
|
|
|
def test_no_gaps_returns_same(self):
|
|
"""Test no-gap data returns unchanged."""
|
|
candles = [
|
|
make_candle(1709337600, close=50000),
|
|
make_candle(1709337660, close=50100),
|
|
make_candle(1709337720, close=50200),
|
|
]
|
|
|
|
filled = fill_gaps(candles, "1m")
|
|
|
|
assert len(filled) == 3
|
|
|
|
def test_fills_single_gap(self):
|
|
"""Test fills single missing candle."""
|
|
candles = [
|
|
make_candle(1709337600, close=50000),
|
|
make_candle(1709337720, close=50200), # Gap at 1709337660
|
|
]
|
|
|
|
filled = fill_gaps(candles, "1m")
|
|
|
|
assert len(filled) == 3
|
|
assert filled[1].time == 1709337660
|
|
# Forward-fill: uses next candle's values
|
|
assert filled[1].close == 50200
|
|
|
|
def test_fills_multiple_gaps(self):
|
|
"""Test fills multiple missing candles."""
|
|
candles = [
|
|
make_candle(1709337600, close=50000),
|
|
make_candle(1709337840, close=50400), # 3 missing
|
|
]
|
|
|
|
filled = fill_gaps(candles, "1m")
|
|
|
|
assert len(filled) == 5 # Original 2 + 3 filled
|
|
assert filled[1].time == 1709337660
|
|
assert filled[2].time == 1709337720
|
|
assert filled[3].time == 1709337780
|
|
|
|
def test_copy_volume_true(self):
|
|
"""Test volume is copied when copy_volume=True."""
|
|
candles = [
|
|
Candle(time=1709337600, open=50000, high=50100, low=49900, close=50000, volume=100.0),
|
|
Candle(time=1709337720, open=50200, high=50300, low=50100, close=50200, volume=200.0),
|
|
]
|
|
|
|
filled = fill_gaps(candles, "1m", copy_volume=True)
|
|
|
|
assert filled[1].volume == 200.0 # Copied from next
|
|
|
|
def test_copy_volume_false(self):
|
|
"""Test volume is 0 when copy_volume=False."""
|
|
candles = [
|
|
Candle(time=1709337600, open=50000, high=50100, low=49900, close=50000, volume=100.0),
|
|
Candle(time=1709337720, open=50200, high=50300, low=50100, close=50200, volume=200.0),
|
|
]
|
|
|
|
filled = fill_gaps(candles, "1m", copy_volume=False)
|
|
|
|
assert filled[1].volume == 0.0
|
|
|
|
def test_preserves_order(self):
|
|
"""Test result is sorted by time."""
|
|
candles = [
|
|
make_candle(1709337720), # Out of order
|
|
make_candle(1709337600),
|
|
make_candle(1709337840),
|
|
]
|
|
|
|
filled = fill_gaps(candles, "1m")
|
|
|
|
times = [c.time for c in filled]
|
|
assert times == sorted(times)
|
|
|
|
|
|
class TestFillGapsBackward:
|
|
"""Tests for backward-fill strategy."""
|
|
|
|
def test_backward_fill_uses_previous_close(self):
|
|
"""Test backward fill uses previous candle's close."""
|
|
candles = [
|
|
make_candle(1709337600, close=50000),
|
|
make_candle(1709337720, close=50200), # Gap
|
|
]
|
|
|
|
filled = fill_gaps_backward(candles, "1m")
|
|
|
|
assert len(filled) == 3
|
|
# Backward fill: uses previous candle's close
|
|
assert filled[1].close == 50000
|
|
|
|
|
|
class TestFillToRange:
|
|
"""Tests for fill_to_range function."""
|
|
|
|
def test_extends_to_start(self):
|
|
"""Test extends data backward to cover start."""
|
|
candles = [
|
|
make_candle(1709337660, close=50100),
|
|
make_candle(1709337720, close=50200),
|
|
]
|
|
|
|
filled = fill_to_range(
|
|
candles,
|
|
"1m",
|
|
start_ms=1709337600000,
|
|
end_ms=1709337720000,
|
|
)
|
|
|
|
assert len(filled) == 3
|
|
assert filled[0].time == 1709337600
|
|
|
|
def test_extends_to_end(self):
|
|
"""Test extends data forward to cover end."""
|
|
candles = [
|
|
make_candle(1709337600, close=50000),
|
|
make_candle(1709337660, close=50100),
|
|
]
|
|
|
|
filled = fill_to_range(
|
|
candles,
|
|
"1m",
|
|
start_ms=1709337600000,
|
|
end_ms=1709337780000,
|
|
)
|
|
|
|
assert len(filled) == 4
|
|
assert filled[-1].time == 1709337780
|