454 lines
20 KiB
Python
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()
|