269 lines
11 KiB
Python
269 lines
11 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 unittest.mock import patch
|
|
|
|
from src.services.decision.decision_packet_service import (
|
|
_select_candidate,
|
|
build_alert_candidates,
|
|
build_decision_packet_from_rows,
|
|
DecisionPacketService,
|
|
)
|
|
|
|
|
|
class StubVisualSnapshotRepository:
|
|
def __init__(self):
|
|
self.snapshot_calls = []
|
|
self.review_calls = []
|
|
|
|
def insert_snapshot(self, **kwargs):
|
|
self.snapshot_calls.append(kwargs)
|
|
return 333
|
|
|
|
def insert_visual_review(self, **kwargs):
|
|
self.review_calls.append(kwargs)
|
|
return 444
|
|
|
|
|
|
class DecisionPacketServiceTests(unittest.TestCase):
|
|
def test_build_packet_recommends_confirm_ticket_when_risk_passes(self) -> None:
|
|
packet = build_decision_packet_from_rows(
|
|
instrument_id=1,
|
|
observed_ts=datetime(2026, 5, 8, 1, 0, tzinfo=timezone.utc),
|
|
plan={
|
|
"id": 10,
|
|
"status": "active",
|
|
"narrative": "active:bullish",
|
|
"primary_draw": {"source": "liquidity_pool", "price_high": 101},
|
|
"bias": "bullish",
|
|
"invalidations": [{"code": "range_low_lost"}],
|
|
},
|
|
candidate={
|
|
"id": 20,
|
|
"trading_plan_id": 10,
|
|
"model_code": "OSOK_DAILY_RANGE_SWEEP_MSS_FVG_RETRACE",
|
|
"status": "executable",
|
|
"entry_low": 100,
|
|
"entry_high": 101,
|
|
"stop_loss": 99,
|
|
"tp1": 102,
|
|
"tp2": 104,
|
|
"quality_score": 0.8,
|
|
"confirmation_state": {"present_conditions": ["sweep", "mss", "fvg"], "reason_codes": ["confirmed"]},
|
|
"missing_confirmations": [],
|
|
"management_rules": [{"rule": "tp1_partial"}],
|
|
"entry": {"target_plan": {"tp1": {"source": "irl"}, "tp2": {"source": "erl"}}},
|
|
},
|
|
signal={"id": 30, "entry_type": "limit", "target_plan": {"tp1": {"source": "irl"}, "tp2": {"source": "erl"}}},
|
|
ticket={"id": 40, "status": "awaiting_confirmation"},
|
|
risk_result={"allowed": True, "status": "allowed", "reasons": []},
|
|
)
|
|
|
|
self.assertEqual(packet.status, "actionable")
|
|
self.assertEqual(packet.action_recommendation, "confirm_ticket")
|
|
self.assertEqual(packet.targets["tp1"]["source"], "irl")
|
|
self.assertIn("risk_governor_passed", packet.why_allowed)
|
|
|
|
def test_build_packet_explains_missing_confirmations(self) -> None:
|
|
packet = build_decision_packet_from_rows(
|
|
instrument_id=1,
|
|
observed_ts=datetime(2026, 5, 8, 1, 0, tzinfo=timezone.utc),
|
|
plan={"id": 10, "status": "active", "bias": "bearish"},
|
|
candidate={
|
|
"id": 20,
|
|
"trading_plan_id": 10,
|
|
"status": "blocked",
|
|
"missing_confirmations": ["mss_confirmed"],
|
|
"confirmation_state": {"rejected_conditions": ["fvg_missing"]},
|
|
},
|
|
signal={},
|
|
ticket={},
|
|
risk_result={"allowed": None, "status": "signal_missing", "reasons": ["signal_missing"]},
|
|
)
|
|
|
|
self.assertEqual(packet.status, "blocked")
|
|
self.assertEqual(packet.action_recommendation, "wait_for_confirmations")
|
|
self.assertIn("mss_confirmed", packet.why_blocked)
|
|
self.assertIn("fvg_missing", packet.why_blocked)
|
|
self.assertIn("signal_missing", packet.why_blocked)
|
|
|
|
def test_alert_candidates_are_stable_for_actionable_packet(self) -> None:
|
|
packet = build_decision_packet_from_rows(
|
|
instrument_id=1,
|
|
observed_ts=datetime(2026, 5, 8, 1, 0, tzinfo=timezone.utc),
|
|
plan={"id": 10, "status": "active"},
|
|
candidate={"id": 20, "trading_plan_id": 10, "status": "executable", "missing_confirmations": []},
|
|
signal={"id": 30},
|
|
ticket={"id": 40, "status": "awaiting_confirmation"},
|
|
risk_result={"allowed": True, "status": "allowed", "reasons": []},
|
|
)
|
|
alerts = build_alert_candidates(packet)
|
|
|
|
self.assertEqual(alerts[0]["alert_type"], "candidate_actionable")
|
|
self.assertIn("confirm_ticket", alerts[0]["alert_key"])
|
|
|
|
def test_blacklist_model_becomes_observe_only_packet(self) -> None:
|
|
packet = build_decision_packet_from_rows(
|
|
instrument_id=1,
|
|
observed_ts=datetime(2026, 5, 8, 1, 0, tzinfo=timezone.utc),
|
|
plan={"id": 10, "status": "active", "bias": "bullish"},
|
|
candidate={"id": 20, "trading_plan_id": 10, "model_code": "SWEEP_MSS_FVG", "status": "executable"},
|
|
signal={"id": 30},
|
|
ticket={},
|
|
risk_result={"allowed": True, "status": "allowed", "reasons": []},
|
|
trust_policy={
|
|
"classification": "blacklist",
|
|
"analysis_only_allowed": True,
|
|
"decision_support_allowed": False,
|
|
"execution_allowed": False,
|
|
"operator_mode": "observe_only",
|
|
"blocking_reasons": ["trust_model_execution_disabled"],
|
|
},
|
|
)
|
|
|
|
self.assertEqual(packet.action_recommendation, "observe_only")
|
|
self.assertEqual(packet.operator_mode, "observe_only")
|
|
self.assertIn("trust_model_execution_disabled", packet.why_blocked)
|
|
|
|
def test_select_candidate_prefers_active_plan_scope(self) -> None:
|
|
selected = _select_candidate(
|
|
[
|
|
{"id": 1, "trading_plan_id": 9, "model_code": "OTHER"},
|
|
{"id": 2, "trading_plan_id": 10, "model_code": "OSOK_DAILY_RANGE_SWEEP_MSS_FVG_RETRACE"},
|
|
],
|
|
plan_id=10,
|
|
model_code="OSOK_DAILY_RANGE_SWEEP_MSS_FVG_RETRACE",
|
|
)
|
|
|
|
self.assertEqual(selected["id"], 2)
|
|
|
|
def test_build_persist_and_alert_attaches_visual_watch_reservation(self) -> None:
|
|
packet = build_decision_packet_from_rows(
|
|
instrument_id=1,
|
|
observed_ts=datetime(2026, 5, 8, 1, 0, tzinfo=timezone.utc),
|
|
plan={"id": 10, "status": "active"},
|
|
candidate={
|
|
"id": 20,
|
|
"trading_plan_id": 10,
|
|
"status": "executable",
|
|
"missing_confirmations": [],
|
|
"model_code": "OSOK_DAILY_RANGE_SWEEP_MSS_FVG_RETRACE",
|
|
"evidence": {"symbol": "BTC-USDT"},
|
|
"trigger_timeframe": "5m",
|
|
},
|
|
signal={"id": 30, "trigger_timeframe": "5m"},
|
|
ticket={"id": 40, "status": "awaiting_confirmation"},
|
|
risk_result={"allowed": True, "status": "allowed", "reasons": []},
|
|
trust_policy={
|
|
"classification": "allowlist",
|
|
"analysis_only_allowed": True,
|
|
"restricted_decision_support_allowed": True,
|
|
"decision_support_allowed": True,
|
|
"execution_allowed": True,
|
|
"operator_mode": "execution_assist",
|
|
"blocking_reasons": [],
|
|
},
|
|
)
|
|
|
|
class StubDecisionRepository:
|
|
def __init__(self):
|
|
self.alert_payloads = []
|
|
|
|
def insert_decision_packet(self, **kwargs):
|
|
self.packet_payload = kwargs["payload"]
|
|
return 901
|
|
|
|
def insert_decision_alert(self, **kwargs):
|
|
self.alert_payloads.append(kwargs)
|
|
return 902
|
|
|
|
class StubVisualWatchService:
|
|
def __init__(self):
|
|
self.requests = []
|
|
|
|
def reserve_watch_request(self, request):
|
|
self.requests.append(dict(request))
|
|
return {
|
|
"status": "reserved",
|
|
"watch_run_id": 777,
|
|
"trigger_type": request["trigger_type"],
|
|
"reserved_ts": "2026-05-08T01:00:00+00:00",
|
|
}
|
|
|
|
repository = StubDecisionRepository()
|
|
visual_watch_service = StubVisualWatchService()
|
|
service = DecisionPacketService(decision_repository=repository)
|
|
|
|
with patch.object(service, "build_packet", return_value=packet), patch(
|
|
"src.services.decision.decision_packet_service.VisualWatchService",
|
|
return_value=visual_watch_service,
|
|
):
|
|
result = service.build_persist_and_alert(instrument_id=1)
|
|
|
|
self.assertEqual(result["id"], 901)
|
|
self.assertEqual(len(result["alerts"]), 1)
|
|
alert_payload = result["alerts"][0]["payload"]
|
|
self.assertEqual(alert_payload["visual_watch_reservation"]["watch_run_id"], 777)
|
|
self.assertEqual(alert_payload["visual_trigger_policy"]["status"], "triggered")
|
|
self.assertEqual(alert_payload["visual_trigger_roi"]["expected_review_value"], "high")
|
|
self.assertEqual(visual_watch_service.requests[0]["decision_packet_id"], 901)
|
|
self.assertEqual(visual_watch_service.requests[0]["signal_id"], 30)
|
|
self.assertEqual(visual_watch_service.requests[0]["visual_trigger_policy"]["primary_trigger_type"], "candidate_actionable_watch")
|
|
self.assertEqual(repository.alert_payloads[0]["payload"]["visual_watch_reservation"]["status"], "reserved")
|
|
|
|
def test_record_snapshot_returns_snapshot_payload(self) -> None:
|
|
from src.services.visual_watch.visual_watch_service import VisualWatchService
|
|
|
|
repository = StubVisualSnapshotRepository()
|
|
service = VisualWatchService(repository=repository)
|
|
|
|
result = service.record_snapshot(
|
|
{
|
|
"watch_run_id": 44,
|
|
"image_path": "captures/btc-1.png",
|
|
"source_name": "capture_bot",
|
|
"captured_ts": "2026-05-10T12:30:00+00:00",
|
|
"status": "captured",
|
|
"payload": {"screen": "main"},
|
|
}
|
|
)
|
|
|
|
self.assertEqual(result["snapshot_id"], 333)
|
|
self.assertEqual(result["watch_run_id"], 44)
|
|
self.assertEqual(result["image_path"], "captures/btc-1.png")
|
|
self.assertEqual(repository.snapshot_calls[0]["source_name"], "capture_bot")
|
|
self.assertEqual(repository.snapshot_calls[0]["status"], "captured")
|
|
|
|
def test_record_visual_review_returns_review_payload(self) -> None:
|
|
from src.services.visual_watch.visual_watch_service import VisualWatchService
|
|
|
|
repository = StubVisualSnapshotRepository()
|
|
service = VisualWatchService(repository=repository)
|
|
|
|
result = service.record_visual_review(
|
|
{
|
|
"watch_run_id": 44,
|
|
"snapshot_id": 333,
|
|
"visual_state": "aligned",
|
|
"visual_confidence": 0.91,
|
|
"visual_notes": "structure intact",
|
|
"ui_anomalies": ["minor_overlay"],
|
|
"reviewed_ts": "2026-05-10T12:35:00+00:00",
|
|
"payload": {"operator": "vision_bot"},
|
|
}
|
|
)
|
|
|
|
self.assertEqual(result["review_id"], 444)
|
|
self.assertEqual(result["watch_run_id"], 44)
|
|
self.assertEqual(result["snapshot_id"], 333)
|
|
self.assertEqual(result["visual_state"], "aligned")
|
|
self.assertEqual(repository.review_calls[0]["visual_notes"], "structure intact")
|
|
self.assertEqual(repository.review_calls[0]["ui_anomalies"], ["minor_overlay"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|