231 lines
9.7 KiB
Python
231 lines
9.7 KiB
Python
import os
|
|
import unittest
|
|
|
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test")
|
|
|
|
from src.services.structure import MSSCandidate, MSSService, detect_mss_candidates, evaluate_mss_acceptance
|
|
from src.services.structure.mss_service import mss_event_matches_candidate
|
|
|
|
|
|
class MSSAcceptanceTests(unittest.TestCase):
|
|
def test_bullish_acceptance_requires_retest_and_hold(self) -> None:
|
|
result = evaluate_mss_acceptance(
|
|
direction="bullish",
|
|
broken_level=100.0,
|
|
candles=[
|
|
{"low": 99.8, "high": 101.2, "close": 100.4},
|
|
{"low": 100.1, "high": 102.0, "close": 101.7},
|
|
],
|
|
)
|
|
|
|
self.assertTrue(result["accepted"])
|
|
self.assertTrue(result["has_retest"])
|
|
self.assertTrue(result["held_boundary"])
|
|
self.assertFalse(result["reclaimed_old_control"])
|
|
self.assertEqual(result["state"], "accepted")
|
|
self.assertEqual(result["status_reason"], "retest_held")
|
|
|
|
def test_bearish_acceptance_fails_when_close_reclaims_old_control(self) -> None:
|
|
result = evaluate_mss_acceptance(
|
|
direction="bearish",
|
|
broken_level=100.0,
|
|
candles=[
|
|
{"low": 98.5, "high": 100.2, "close": 99.7},
|
|
{"low": 99.2, "high": 101.0, "close": 100.5},
|
|
],
|
|
)
|
|
|
|
self.assertFalse(result["accepted"])
|
|
self.assertTrue(result["has_retest"])
|
|
self.assertFalse(result["held_boundary"])
|
|
self.assertTrue(result["reclaimed_old_control"])
|
|
self.assertEqual(result["state"], "invalidated")
|
|
self.assertEqual(result["status_reason"], "old_control_reclaimed")
|
|
|
|
def test_acceptance_marks_rejected_when_retest_is_not_observed(self) -> None:
|
|
result = evaluate_mss_acceptance(
|
|
direction="bullish",
|
|
broken_level=100.0,
|
|
candles=[
|
|
{"low": 100.2, "high": 102.0, "close": 101.7},
|
|
{"low": 100.4, "high": 103.1, "close": 102.6},
|
|
],
|
|
)
|
|
|
|
self.assertFalse(result["accepted"])
|
|
self.assertFalse(result["has_retest"])
|
|
self.assertTrue(result["held_boundary"])
|
|
self.assertFalse(result["reclaimed_old_control"])
|
|
self.assertEqual(result["state"], "rejected")
|
|
self.assertEqual(result["status_reason"], "retest_not_observed")
|
|
|
|
def test_detect_mss_candidates_only_returns_confirmed_acceptance(self) -> None:
|
|
swings = [
|
|
{"id": 1, "kind": "swing_high", "price": 100.0, "ts": 2},
|
|
{"id": 2, "kind": "swing_low", "price": 95.0, "ts": 1},
|
|
]
|
|
displacements = [
|
|
{"id": 9, "direction": "bullish", "end_ts": 5, "is_valid": True},
|
|
]
|
|
candles = [
|
|
{"ts_open": 1, "ts_close": 2, "open": 97.0, "high": 100.0, "low": 96.0, "close": 98.0},
|
|
{"ts_open": 2, "ts_close": 3, "open": 98.0, "high": 99.0, "low": 95.0, "close": 96.0},
|
|
{"ts_open": 3, "ts_close": 4, "open": 96.0, "high": 102.0, "low": 95.5, "close": 101.3},
|
|
{"ts_open": 4, "ts_close": 5, "open": 101.3, "high": 103.0, "low": 100.5, "close": 102.2},
|
|
{"ts_open": 5, "ts_close": 6, "open": 102.2, "high": 102.8, "low": 99.9, "close": 100.4},
|
|
{"ts_open": 6, "ts_close": 7, "open": 100.4, "high": 103.5, "low": 100.2, "close": 103.0},
|
|
]
|
|
|
|
candidates = detect_mss_candidates(swings=swings, displacements=displacements, candles=candles, acceptance_window=3)
|
|
|
|
self.assertEqual(len(candidates), 1)
|
|
self.assertTrue(candidates[0].accepted)
|
|
self.assertEqual(candidates[0].direction, "bullish")
|
|
self.assertEqual(candidates[0].meta["retest_count"], 1)
|
|
self.assertEqual(candidates[0].meta["state"], "accepted")
|
|
self.assertEqual(candidates[0].meta["status_reason"], "retest_held")
|
|
|
|
def test_detect_mss_candidates_collapses_duplicate_displacement_context_to_latest_id(self) -> None:
|
|
swings = [
|
|
{"id": 1, "kind": "swing_high", "price": 100.0, "ts": 2},
|
|
{"id": 2, "kind": "swing_low", "price": 95.0, "ts": 1},
|
|
]
|
|
displacements = [
|
|
{"id": 9, "direction": "bullish", "end_ts": 5, "is_valid": True},
|
|
{"id": 11, "direction": "bullish", "end_ts": 5, "is_valid": True},
|
|
]
|
|
candles = [
|
|
{"ts_open": 1, "ts_close": 2, "open": 97.0, "high": 100.0, "low": 96.0, "close": 98.0},
|
|
{"ts_open": 2, "ts_close": 3, "open": 98.0, "high": 99.0, "low": 95.0, "close": 96.0},
|
|
{"ts_open": 3, "ts_close": 4, "open": 96.0, "high": 102.0, "low": 95.5, "close": 101.3},
|
|
{"ts_open": 4, "ts_close": 5, "open": 101.3, "high": 103.0, "low": 100.5, "close": 102.2},
|
|
{"ts_open": 5, "ts_close": 6, "open": 102.2, "high": 102.8, "low": 99.9, "close": 100.4},
|
|
{"ts_open": 6, "ts_close": 7, "open": 100.4, "high": 103.5, "low": 100.2, "close": 103.0},
|
|
]
|
|
|
|
candidates = detect_mss_candidates(swings=swings, displacements=displacements, candles=candles, acceptance_window=3)
|
|
|
|
self.assertEqual(len(candidates), 1)
|
|
self.assertEqual(candidates[0].displacement_event_id, 11)
|
|
self.assertEqual(candidates[0].direction, "bullish")
|
|
|
|
def test_detect_mss_candidates_returns_latest_rejected_state_when_retest_is_missing(self) -> None:
|
|
swings = [
|
|
{"id": 1, "kind": "swing_high", "price": 100.0, "ts": 2},
|
|
{"id": 2, "kind": "swing_low", "price": 95.0, "ts": 1},
|
|
]
|
|
displacements = [
|
|
{"id": 9, "direction": "bullish", "end_ts": 5, "is_valid": True},
|
|
]
|
|
candles = [
|
|
{"ts_open": 1, "ts_close": 2, "open": 97.0, "high": 100.0, "low": 96.0, "close": 98.0},
|
|
{"ts_open": 2, "ts_close": 3, "open": 98.0, "high": 99.0, "low": 95.0, "close": 96.0},
|
|
{"ts_open": 3, "ts_close": 4, "open": 96.0, "high": 102.0, "low": 95.5, "close": 101.3},
|
|
{"ts_open": 4, "ts_close": 5, "open": 101.3, "high": 103.0, "low": 100.5, "close": 102.2},
|
|
{"ts_open": 5, "ts_close": 6, "open": 102.2, "high": 103.4, "low": 100.3, "close": 102.9},
|
|
{"ts_open": 6, "ts_close": 7, "open": 102.9, "high": 104.1, "low": 100.2, "close": 103.6},
|
|
]
|
|
|
|
candidates = detect_mss_candidates(swings=swings, displacements=displacements, candles=candles, acceptance_window=3)
|
|
|
|
self.assertEqual(len(candidates), 1)
|
|
self.assertFalse(candidates[0].accepted)
|
|
self.assertEqual(candidates[0].meta["state"], "rejected")
|
|
self.assertEqual(candidates[0].meta["status_reason"], "retest_not_observed")
|
|
|
|
|
|
class MSSIdempotencyTests(unittest.TestCase):
|
|
def test_mss_event_matches_candidate_when_same_structure(self) -> None:
|
|
candidate = MSSCandidate(
|
|
direction="bullish",
|
|
broken_level=100.0,
|
|
reference_swing_id=10,
|
|
displacement_event_id=20,
|
|
accepted=True,
|
|
ts="2026-04-24T00:00:00+00:00",
|
|
meta={"has_retest": True, "retest_count": 1},
|
|
)
|
|
latest = {
|
|
"direction": "bullish",
|
|
"broken_level": 100.0,
|
|
"reference_swing_id": 10,
|
|
"displacement_event_id": 20,
|
|
"accepted": True,
|
|
"meta": {"has_retest": True, "retest_count": 1},
|
|
}
|
|
|
|
self.assertTrue(mss_event_matches_candidate(latest, candidate))
|
|
|
|
def test_persist_mss_skips_equivalent_latest_event(self) -> None:
|
|
candidate = MSSCandidate(
|
|
direction="bearish",
|
|
broken_level=99.5,
|
|
reference_swing_id=11,
|
|
displacement_event_id=21,
|
|
accepted=True,
|
|
ts="t",
|
|
meta={"has_retest": True, "retest_count": 2},
|
|
)
|
|
|
|
class StubRepo:
|
|
def __init__(self):
|
|
self.insert_calls = 0
|
|
|
|
def fetch_latest_mss_event(self, instrument_id, timeframe):
|
|
return {
|
|
"direction": "bearish",
|
|
"broken_level": 99.5,
|
|
"reference_swing_id": 11,
|
|
"displacement_event_id": 21,
|
|
"accepted": True,
|
|
"meta": {"has_retest": True, "retest_count": 2},
|
|
}
|
|
|
|
def insert_mss_event(self, **kwargs):
|
|
self.insert_calls += 1
|
|
|
|
repo = StubRepo()
|
|
service = MSSService(repository=repo)
|
|
|
|
persisted = service.persist_mss(instrument_id=1, timeframe="1m", candidates=[candidate])
|
|
|
|
self.assertEqual(persisted, 0)
|
|
self.assertEqual(repo.insert_calls, 0)
|
|
|
|
def test_persist_mss_records_rejected_state_for_downstream_consumers(self) -> None:
|
|
candidate = MSSCandidate(
|
|
direction="bullish",
|
|
broken_level=100.0,
|
|
reference_swing_id=10,
|
|
displacement_event_id=20,
|
|
accepted=False,
|
|
ts="t",
|
|
meta={"state": "rejected", "status_reason": "retest_not_observed"},
|
|
)
|
|
|
|
class StubRepo:
|
|
def __init__(self):
|
|
self.insert_calls = 0
|
|
self.last_insert = None
|
|
|
|
def fetch_latest_mss_event(self, instrument_id, timeframe):
|
|
return None
|
|
|
|
def insert_mss_event(self, **kwargs):
|
|
self.insert_calls += 1
|
|
self.last_insert = kwargs
|
|
|
|
repo = StubRepo()
|
|
service = MSSService(repository=repo)
|
|
|
|
persisted = service.persist_mss(instrument_id=1, timeframe="1m", candidates=[candidate])
|
|
|
|
self.assertEqual(persisted, 1)
|
|
self.assertEqual(repo.insert_calls, 1)
|
|
self.assertFalse(repo.last_insert["accepted"])
|
|
self.assertEqual(repo.last_insert["meta"]["state"], "rejected")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|