175 lines
6.8 KiB
Python
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()
|