Files
ai-exchange/tests/unit/test_signal_service.py
T
2026-05-08 08:09:00 +08:00

454 lines
20 KiB
Python

import contextlib
import io
import json
import os
import unittest
from unittest.mock import patch
from sqlalchemy.exc import OperationalError
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test")
from src.services.signals import (
STRICT_SIGNAL_EVIDENCE_KEYS,
classify_session_for_signal,
normalize_signal_invalidations,
select_preferred_fvg,
trade_signal_matches_candidate,
validate_rr_thresholds,
validate_signal_evidence,
validate_signal_context,
)
from src.services.signals.signal_service import SignalCandidate, SignalService
from scripts import run_signals
from scripts.run_signals import build_signal_evidence_keys_from_env, build_signal_timeframe_set_from_env
class SignalSessionFilterTests(unittest.TestCase):
def test_london_allows_confirmation(self) -> None:
decision = classify_session_for_signal("LONDON")
self.assertEqual(decision["status"], "awaiting_confirmation")
self.assertTrue(decision["execution_permission"])
self.assertEqual(decision["reason"], "allowed_session")
def test_lunch_is_downgraded_to_context(self) -> None:
decision = classify_session_for_signal("NY_LUNCH")
self.assertEqual(decision["status"], "awaiting_context")
self.assertFalse(decision["execution_permission"])
self.assertEqual(decision["reason"], "observation_or_management_session")
def test_ny_pm_is_downgraded_to_context(self) -> None:
decision = classify_session_for_signal("NY_PM")
self.assertEqual(decision["status"], "awaiting_context")
self.assertFalse(decision["execution_permission"])
self.assertEqual(decision["reason"], "observation_or_management_session")
def test_ny_am_stays_allowed_for_confirmation(self) -> None:
decision = classify_session_for_signal("NY_AM")
self.assertEqual(decision["status"], "awaiting_confirmation")
self.assertTrue(decision["execution_permission"])
self.assertEqual(decision["reason"], "allowed_session")
def test_missing_session_is_off_hours(self) -> None:
decision = classify_session_for_signal(None)
self.assertEqual(decision["status"], "awaiting_context")
self.assertFalse(decision["execution_permission"])
self.assertEqual(decision["reason"], "off_hours")
class SignalStructureAlignmentTests(unittest.TestCase):
def test_select_preferred_fvg_prefers_exact_mss_displacement_match_over_newer_unlinked_candidate(self) -> None:
fvgs = [
{"id": 11, "direction": "bullish", "status": "open", "related_displacement_id": None, "end_ts": 20, "meta": {}},
{"id": 9, "direction": "bullish", "status": "open", "related_displacement_id": 7, "end_ts": 18, "meta": {}},
]
selected = select_preferred_fvg(
fvgs=fvgs,
mss={"direction": "bullish", "accepted": True, "displacement_event_id": 7, "meta": {"state": "accepted", "status_reason": "retest_held"}},
)
self.assertIsNotNone(selected)
assert selected is not None
self.assertEqual(selected["id"], 9)
def test_select_preferred_fvg_prefers_newest_same_direction_when_no_exact_context_match_exists(self) -> None:
fvgs = [
{"id": 15, "direction": "bearish", "status": "open", "related_displacement_id": None, "end_ts": 30, "meta": {}},
{"id": 13, "direction": "bullish", "status": "open", "related_displacement_id": 5, "end_ts": 25, "meta": {}},
{"id": 12, "direction": "bullish", "status": "open", "related_displacement_id": None, "end_ts": 24, "meta": {}},
]
selected = select_preferred_fvg(
fvgs=fvgs,
mss={"direction": "bullish", "accepted": True, "displacement_event_id": 7, "meta": {"state": "accepted", "status_reason": "retest_held"}},
)
self.assertIsNotNone(selected)
assert selected is not None
self.assertEqual(selected["id"], 13)
def test_rejects_rejected_mss_with_explicit_state_reason(self) -> None:
result = validate_signal_context(
bias={"draw_side": "buy_side"},
fvg={"direction": "bullish", "status": "open", "meta": {}},
mss={"direction": "bullish", "accepted": False, "meta": {"state": "rejected", "status_reason": "retest_not_observed"}},
)
self.assertFalse(result["is_actionable"])
self.assertEqual(result["reason"], "mss_rejected")
self.assertEqual(result["structure_state"], "mss_rejected")
self.assertEqual(result["mss_state"], "rejected")
self.assertEqual(result["mss_status_reason"], "retest_not_observed")
def test_rejects_invalidated_mss_with_explicit_state_reason(self) -> None:
result = validate_signal_context(
bias={"draw_side": "buy_side"},
fvg={"direction": "bullish", "status": "open", "meta": {}},
mss={"direction": "bullish", "accepted": False, "meta": {"state": "invalidated", "status_reason": "old_control_reclaimed"}},
)
self.assertFalse(result["is_actionable"])
self.assertEqual(result["reason"], "mss_invalidated")
self.assertEqual(result["structure_state"], "mss_invalidated")
self.assertEqual(result["mss_state"], "invalidated")
self.assertEqual(result["mss_status_reason"], "old_control_reclaimed")
def test_rejects_mitigated_or_invalidated_fvg(self) -> None:
mitigated = validate_signal_context(
bias={"draw_side": "buy_side"},
fvg={"direction": "bullish", "status": "mitigated", "meta": {"status_reason": "mitigation_threshold_reached"}},
mss={"direction": "bullish", "accepted": True, "meta": {"state": "accepted", "status_reason": "retest_held"}},
)
invalidated = validate_signal_context(
bias={"draw_side": "buy_side"},
fvg={"direction": "bullish", "status": "touched", "meta": {"invalidated": True, "status_reason": "structure_break"}},
mss={"direction": "bullish", "accepted": True, "meta": {"state": "accepted", "status_reason": "retest_held"}},
)
self.assertEqual(mitigated["reason"], "fvg_mitigated")
self.assertEqual(mitigated["structure_state"], "fvg_mitigated")
self.assertEqual(mitigated["fvg_status_reason"], "mitigation_threshold_reached")
self.assertEqual(invalidated["reason"], "fvg_invalidated")
self.assertEqual(invalidated["structure_state"], "fvg_invalidated")
self.assertEqual(invalidated["fvg_status_reason"], "structure_break")
def test_accepts_aligned_structure(self) -> None:
result = validate_signal_context(
bias={"draw_side": "sell_side"},
fvg={
"direction": "bearish",
"status": "touched",
"meta": {"status_reason": "partial_fill", "touch_count": 1},
"related_displacement_id": 7,
},
mss={
"direction": "bearish",
"accepted": True,
"displacement_event_id": 7,
"meta": {"state": "accepted", "status_reason": "retest_held"},
},
)
self.assertTrue(result["is_actionable"])
self.assertEqual(result["side"], "sell")
self.assertEqual(result["reason"], "aligned")
self.assertEqual(result["structure_state"], "aligned")
self.assertEqual(result["mss_state"], "accepted")
self.assertEqual(result["mss_status_reason"], "retest_held")
self.assertEqual(result["fvg_status"], "touched")
self.assertEqual(result["fvg_status_reason"], "partial_fill")
class SignalRRFilterTests(unittest.TestCase):
def test_rejects_rr_below_mvp_thresholds(self) -> None:
result = validate_rr_thresholds(rr_tp1=0.8, rr_tp2=1.9)
self.assertFalse(result["is_actionable"])
self.assertEqual(result["reasons"], ["tp1_rr_too_low", "tp2_rr_too_low"])
def test_build_signal_drops_low_rr_candidate_without_database(self) -> None:
service = SignalService(
repository=_SignalStubRepository(
fvg={"id": 2, "direction": "bullish", "lower": 100.0, "upper": 101.0, "status": "open", "meta": {}},
)
)
candidate = service.build_signal(instrument_id=1, setup_tf="1m", trigger_tf="1m")
self.assertIsNone(candidate)
def test_build_signal_keeps_candidate_when_rr_passes_without_database(self) -> None:
service = SignalService(
repository=_SignalStubRepository(
fvg={"id": 2, "direction": "bullish", "lower": 100.0, "upper": 100.1, "status": "open", "meta": {}},
),
required_evidence_keys=("bias_snapshot_id", "fvg_id", "mss_id"),
)
candidate = service.build_signal(instrument_id=1, setup_tf="1m", trigger_tf="1m")
self.assertIsNotNone(candidate)
assert candidate is not None
self.assertGreaterEqual(candidate.rr_tp1, 1.0)
self.assertGreaterEqual(candidate.rr_tp2, 2.0)
self.assertTrue(candidate.meta["evidence_quality"]["is_complete"])
self.assertEqual(candidate.meta["rr_filter"]["reason"], "rr_ok")
self.assertEqual(candidate.meta["structure_state"], "aligned")
self.assertEqual(candidate.meta["mss_state"], "accepted")
self.assertEqual(candidate.meta["mss_status_reason"], "retest_held")
self.assertEqual(candidate.meta["fvg_status"], "open")
self.assertEqual(candidate.meta["fvg_status_reason"], "no_touch")
self.assertEqual(candidate.meta["fvg_touch_count"], 0)
self.assertEqual(candidate.meta["fvg_data_quality_warnings"], [])
self.assertEqual(candidate.invalidations, ["mss_invalidated", "fvg_mitigated"])
self.assertEqual(candidate.meta["target_selection_status"], "ready")
self.assertEqual(candidate.target_plan["tp1"]["objective_layer"], "IRL")
self.assertEqual(candidate.target_plan["tp2"]["objective_layer"], "ERL")
self.assertTrue(candidate.target_plan["management_rules"])
def test_build_signal_uses_wider_stop_buffer_for_buy_side(self) -> None:
service = SignalService(
repository=_SignalStubRepository(
fvg={"id": 2, "direction": "bullish", "lower": 100.0, "upper": 100.1, "status": "open", "meta": {}},
),
required_evidence_keys=("bias_snapshot_id", "fvg_id", "mss_id"),
)
candidate = service.build_signal(instrument_id=1, setup_tf="1m", trigger_tf="1m")
self.assertIsNotNone(candidate)
assert candidate is not None
self.assertAlmostEqual(candidate.stop_loss, 99.8)
def test_build_signal_rejects_candidate_when_required_evidence_is_missing(self) -> None:
service = SignalService(
repository=_SignalStubRepository(
fvg={"id": 2, "direction": "bullish", "lower": 100.0, "upper": 100.1, "status": "open", "meta": {}},
),
required_evidence_keys=STRICT_SIGNAL_EVIDENCE_KEYS,
)
candidate = service.build_signal(instrument_id=1, setup_tf="1m", trigger_tf="1m")
self.assertIsNone(candidate)
def test_build_signal_keeps_candidate_when_strict_evidence_is_available(self) -> None:
service = SignalService(
repository=_SignalStubRepository(
fvg={"id": 2, "direction": "bullish", "lower": 100.0, "upper": 100.1, "status": "open", "meta": {}},
sweep={"id": 41, "pool_id": 17, "side_swept": "sell_side", "sweep_ts": "2026-04-29T10:00:00+00:00"},
),
required_evidence_keys=STRICT_SIGNAL_EVIDENCE_KEYS,
)
candidate = service.build_signal(instrument_id=1, setup_tf="1m", trigger_tf="1m")
self.assertIsNotNone(candidate)
assert candidate is not None
self.assertEqual(candidate.evidence["sweep_id"], 41)
self.assertEqual(candidate.evidence["liquidity_target_id"], 17)
self.assertEqual(candidate.evidence["displacement_id"], 7)
self.assertTrue(candidate.meta["evidence_quality"]["is_complete"])
self.assertEqual(candidate.target_plan["tp1"]["source"]["table"], "market_sessions")
class SignalEvidenceTests(unittest.TestCase):
def test_normalizes_legacy_signal_invalidations_to_machine_readable_codes(self) -> None:
result = normalize_signal_invalidations(["mss invalidated", "fvg fully mitigated", "session mismatch", "mss_invalidated"])
self.assertEqual(result, ["mss_invalidated", "fvg_mitigated", "session_mismatch"])
def test_minimal_evidence_passes_for_current_signal_chain(self) -> None:
result = validate_signal_evidence({"bias_snapshot_id": 1, "fvg_id": 2, "mss_id": 3})
self.assertTrue(result["is_complete"])
self.assertEqual(result["missing_keys"], [])
def test_strict_evidence_reports_missing_future_chain_links(self) -> None:
result = validate_signal_evidence(
{"bias_snapshot_id": 1, "fvg_id": 2, "mss_id": 3},
required_keys=STRICT_SIGNAL_EVIDENCE_KEYS,
)
self.assertFalse(result["is_complete"])
self.assertEqual(result["missing_keys"], ["sweep_id", "displacement_id", "liquidity_target_id"])
def test_signal_evidence_mode_env_selects_required_keys(self) -> None:
self.assertEqual(build_signal_evidence_keys_from_env({}), STRICT_SIGNAL_EVIDENCE_KEYS)
self.assertEqual(build_signal_evidence_keys_from_env({"SIGNAL_EVIDENCE_MODE": "strict"}), STRICT_SIGNAL_EVIDENCE_KEYS)
self.assertEqual(build_signal_evidence_keys_from_env({"SIGNAL_EVIDENCE_MODE": "minimal"}), ("bias_snapshot_id", "fvg_id", "mss_id"))
def test_signal_evidence_mode_env_rejects_unknown_mode(self) -> None:
with self.assertRaisesRegex(ValueError, "unsupported_signal_evidence_mode"):
build_signal_evidence_keys_from_env({"SIGNAL_EVIDENCE_MODE": "loose"})
def test_signal_timeframe_set_from_env_defaults_and_reads_overrides(self) -> None:
self.assertEqual(
build_signal_timeframe_set_from_env({}),
{"bias_tf": "1m", "setup_tf": "1m", "trigger_tf": "1m"},
)
self.assertEqual(
build_signal_timeframe_set_from_env(
{
"AI_ICT_BIAS_TIMEFRAME": "1h",
"AI_ICT_SETUP_TIMEFRAME": "15m",
"AI_ICT_TRIGGER_TIMEFRAME": "5m",
}
),
{"bias_tf": "1h", "setup_tf": "15m", "trigger_tf": "5m"},
)
class SignalScriptTests(unittest.TestCase):
def test_run_signals_reports_database_unavailable_json(self) -> None:
output = io.StringIO()
with patch("scripts.run_signals.BiasService.build_bias", side_effect=OperationalError("stmt", {}, Exception())), contextlib.redirect_stdout(output):
exit_code = run_signals.main(env={"SIGNALS_OUTPUT_JSON": "1"})
payload = json.loads(output.getvalue())
self.assertEqual(exit_code, 2)
self.assertFalse(payload["passed"])
self.assertEqual(payload["error_type"], "OperationalError")
self.assertEqual(payload["timeframe"], "1m")
class _SignalStubRepository:
def __init__(self, fvg: dict, sweep: dict = None) -> None:
self.fvg = fvg
self.sweep = sweep
def fetch_latest_bias_snapshot(self, instrument_id: int, setup_tf: str, trigger_tf: str):
return {"id": 1, "draw_side": "buy_side"}
def fetch_latest_fvg(self, instrument_id: int, timeframe: str):
return self.fvg
def fetch_recent_fvgs_for_signal(self, instrument_id: int, timeframe: str, limit: int = 20):
return [self.fvg]
def fetch_latest_mss(self, instrument_id: int, timeframe: str):
return {
"id": 3,
"direction": "bullish",
"accepted": True,
"broken_level": 100.0,
"displacement_event_id": 7,
"meta": {"state": "accepted", "status_reason": "retest_held"},
}
def fetch_session_at(self, instrument_id: int, ts):
return {"session_code": "LONDON"}
def fetch_latest_sweep_for_signal(self, instrument_id: int, timeframe: str, side_swept: str, before_ts):
if self.sweep is None:
return None
return self.sweep
def fetch_latest_range(self, instrument_id: int, timeframe: str):
return {"id": 10, "low": 99.0, "high": 103.0, "equilibrium": 101.0, "state": "valid"}
def fetch_recent_liquidity_pools(self, instrument_id: int, timeframe: str, limit: int = 50):
return [
{"id": 17, "side": "buy_side", "price_low": 100.8, "price_high": 101.0, "status": "untouched"},
{"id": 18, "side": "buy_side", "price_low": 103.5, "price_high": 104.0, "status": "untouched"},
]
def fetch_recent_sessions_for_targets(self, instrument_id: int, limit: int = 10):
return [{"id": 11, "session_code": "LONDON", "high": 102.0, "low": 99.4, "midpoint": 100.6}]
def fetch_recent_candles_for_targets(self, instrument_id: int, timeframe: str, limit: int = 100):
return [
{"high": 101.0, "low": 100.0, "close": 100.5},
{"high": 102.0, "low": 100.5, "close": 101.2},
{"high": 102.8, "low": 101.0, "close": 102.2},
]
class SignalIdempotencyTests(unittest.TestCase):
def test_detects_equivalent_trade_signal(self) -> None:
candidate = SignalCandidate(
side="buy",
status="awaiting_confirmation",
entry_low=100.0,
entry_high=101.0,
stop_loss=99.0,
tp1=102.0,
tp2=104.0,
rr_tp1=1.0,
rr_tp2=2.0,
session_code="LONDON",
created_ts="now",
expires_ts="later",
invalidations=["mss_invalidated"],
evidence={"bias_snapshot_id": 1, "fvg_id": 2, "mss_id": 3},
meta={"execution_permission": True},
)
latest = {
"side": "buy",
"status": "awaiting_confirmation",
"bias_snapshot_id": 9,
"entry_type": "limit",
"entry_low": 100.0,
"entry_high": 101.0,
"stop_loss": 99.0,
"tp1": 102.0,
"tp2": 104.0,
"rr_tp1": 1.0,
"rr_tp2": 2.0,
"session_code": "LONDON",
"invalidations": ["mss invalidated"],
"evidence": {"bias_snapshot_id": 1, "fvg_id": 2, "mss_id": 3},
"meta": {"execution_permission": True},
}
self.assertTrue(trade_signal_matches_candidate(latest, candidate, bias_snapshot_id=9))
def test_detects_equivalent_trade_signal_with_rounding_differences(self) -> None:
candidate = SignalCandidate(
side="buy",
status="awaiting_confirmation",
entry_low=77200.0,
entry_high=77223.3,
stop_loss=77122.8,
tp1=77300.5233,
tp2=77454.9699,
rr_tp1=1.00033764772,
rr_tp2=2.73854755205,
session_code="LONDON",
created_ts="now",
expires_ts="later",
invalidations=["mss_invalidated", "fvg_mitigated"],
evidence={"bias_snapshot_id": 8, "fvg_id": 100, "mss_id": 3},
meta={"execution_permission": True, "fvg_fill_pct": 0.0},
)
latest = {
"side": "buy",
"status": "awaiting_confirmation",
"bias_snapshot_id": 8,
"entry_type": "limit",
"entry_low": "77200.0000000000",
"entry_high": "77223.3000000000",
"stop_loss": "77122.8000000000",
"tp1": "77300.5233000000",
"tp2": "77454.9699000000",
"rr_tp1": "1.0003",
"rr_tp2": "2.7385",
"session_code": "LONDON",
"invalidations": ["mss invalidated", "fvg fully mitigated"],
"evidence": {"bias_snapshot_id": 8, "fvg_id": 100, "mss_id": 3},
"meta": {"execution_permission": True, "fvg_fill_pct": 0.0},
}
self.assertTrue(trade_signal_matches_candidate(latest, candidate, bias_snapshot_id=8))
if __name__ == "__main__":
unittest.main()