Files
ai-exchange/tests/unit/test_fvg_service.py
T

175 lines
6.8 KiB
Python

from datetime import datetime, timezone
import os
import unittest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test")
from src.services.fvg import compute_fvg_fill_state, fvg_matches_candidate, fvg_state_matches_update
from src.services.fvg.fvg_service import FVGCandidate, FVGService
class FVGStateTests(unittest.TestCase):
def test_marks_fvg_touched_when_partially_filled(self) -> None:
fill_pct, status, meta = compute_fvg_fill_state(
direction="bullish",
upper=105.0,
lower=100.0,
candles=[{"ts_open": 1, "high": 106.0, "low": 104.0, "close": 105.5}],
)
self.assertEqual(status, "touched")
self.assertAlmostEqual(fill_pct, 0.2)
self.assertEqual(meta["first_touch_ts"], 1)
self.assertEqual(meta["last_touch_ts"], 1)
self.assertEqual(meta["touch_count"], 1)
self.assertFalse(meta["invalidated"])
self.assertEqual(meta["status_reason"], "partial_fill")
self.assertFalse(meta["has_data_gap"])
self.assertEqual(meta["gap_count"], 0)
self.assertEqual(meta["data_quality_warnings"], [])
def test_normalizes_datetime_touch_timestamp_for_meta(self) -> None:
fill_pct, status, meta = compute_fvg_fill_state(
direction="bullish",
upper=105.0,
lower=100.0,
candles=[{"ts_open": datetime(2026, 4, 24, 12, 0, tzinfo=timezone.utc), "high": 106.0, "low": 104.0, "close": 105.5}],
)
self.assertEqual(status, "touched")
self.assertAlmostEqual(fill_pct, 0.2)
self.assertEqual(meta["first_touch_ts"], "2026-04-24T12:00:00+00:00")
self.assertEqual(meta["last_touch_ts"], "2026-04-24T12:00:00+00:00")
def test_marks_fvg_mitigated_when_fully_filled(self) -> None:
fill_pct, status, meta = compute_fvg_fill_state(
direction="bearish",
upper=110.0,
lower=100.0,
candles=[{"ts_open": 1, "high": 111.0, "low": 99.0, "close": 101.0}],
)
self.assertEqual(status, "mitigated")
self.assertEqual(fill_pct, 1.0)
self.assertFalse(meta["invalidated"])
self.assertEqual(meta["status_reason"], "mitigation_threshold_reached")
self.assertEqual(meta["touch_count"], 1)
def test_marks_fvg_invalidated_when_structure_breaks_through_zone(self) -> None:
fill_pct, status, meta = compute_fvg_fill_state(
direction="bullish",
upper=105.0,
lower=100.0,
candles=[{"ts_open": 1, "high": 103.0, "low": 99.0, "close": 99.5}],
)
self.assertEqual(status, "invalidated")
self.assertGreater(fill_pct, 0.0)
self.assertTrue(meta["invalidated"])
self.assertEqual(meta["status_reason"], "structure_break")
def test_records_data_gap_warning_when_post_fvg_candles_skip_forward(self) -> None:
fill_pct, status, meta = compute_fvg_fill_state(
direction="bullish",
upper=105.0,
lower=100.0,
candles=[
{"ts_open": 1, "ts_close": 2, "high": 104.0, "low": 103.0, "close": 103.5},
{"ts_open": 4, "ts_close": 5, "high": 104.5, "low": 103.5, "close": 104.0},
],
)
self.assertEqual(status, "touched")
self.assertAlmostEqual(fill_pct, 0.2)
self.assertEqual(meta["first_touch_ts"], 1)
self.assertEqual(meta["last_touch_ts"], 4)
self.assertEqual(meta["touch_count"], 2)
self.assertTrue(meta["has_data_gap"])
self.assertEqual(meta["gap_count"], 1)
self.assertEqual(meta["data_quality_warnings"], ["candle_gap_detected"])
class FVGIdempotencyTests(unittest.TestCase):
def test_matches_candidate_without_requiring_same_runtime_status(self) -> None:
candidate = FVGCandidate(
direction="bearish",
upper=78158.0,
lower=78148.5,
start_ts="s",
end_ts="e",
fill_pct=0.0,
status="open",
related_displacement_id=None,
meta={"width": 9.5, "has_displacement_context": False},
)
latest = {
"direction": "bearish",
"upper": "78158.0000000000",
"lower": "78148.5000000000",
"start_ts": "s",
"end_ts": "e",
"fill_pct": "1.0000",
"status": "invalidated",
"related_displacement_id": None,
"meta": {
"width": 9.5,
"invalidated": True,
"first_touch_ts": "2026-04-23T03:03:00-04:00",
"mitigation_threshold": 1.0,
"has_displacement_context": False,
},
}
self.assertTrue(fvg_matches_candidate(latest, candidate))
def test_state_update_match_tolerates_fill_rounding(self) -> None:
row = {
"fill_pct": "0.9327",
"status": "touched",
"meta": {
"width": 22.29999999998836,
"invalidated": False,
"first_touch_ts": "2026-04-23T04:16:00-04:00",
"mitigation_threshold": 1.0,
"has_displacement_context": False,
},
}
meta = {
"first_touch_ts": "2026-04-23T04:16:00-04:00",
"mitigation_threshold": 1.0,
"invalidated": False,
"width": 22.29999999998836,
}
self.assertTrue(fvg_state_matches_update(row, 0.9327354260089334, "touched", meta))
class FVGDetectionTests(unittest.TestCase):
def test_detect_fvg_collapses_duplicate_displacement_context_to_latest_id(self) -> None:
class StubRepo:
def fetch_recent_candles(self, instrument_id, timeframe, limit=200):
return [
{"ts_open": 1, "ts_close": 2, "open": 100.0, "high": 101.0, "low": 99.0, "close": 100.5},
{"ts_open": 2, "ts_close": 3, "open": 100.5, "high": 103.0, "low": 100.2, "close": 102.8},
{"ts_open": 3, "ts_close": 4, "open": 103.5, "high": 106.0, "low": 104.0, "close": 105.5},
]
def fetch_recent_displacement(self, instrument_id, timeframe, limit=50):
return [
{"id": 7, "direction": "bullish", "end_ts": 3, "is_valid": True},
{"id": 9, "direction": "bullish", "end_ts": 3, "is_valid": True},
]
service = FVGService(repository=StubRepo())
candidates = service.detect_fvg(instrument_id=1, timeframe="1m")
self.assertEqual(len(candidates), 1)
self.assertEqual(candidates[0].direction, "bullish")
self.assertEqual(candidates[0].related_displacement_id, 9)
self.assertTrue(candidates[0].meta["has_displacement_context"])
if __name__ == "__main__":
unittest.main()