Close readonly analysis launch readiness
This commit is contained in:
@@ -4,7 +4,11 @@ import unittest
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test")
|
||||
|
||||
from src.api.alerts import (
|
||||
_build_archive_bundle_manifest,
|
||||
_build_archive_selection_provenance_summary,
|
||||
_build_external_context_surface_history_summary,
|
||||
_build_workflow_archive_artifact_bundle_summary,
|
||||
_build_workflow_archive_compare_writeback_summary,
|
||||
_build_workflow_archive_handoff_bundle_summary,
|
||||
_build_workflow_archive_handoff_packet_starter,
|
||||
_build_workflow_archive_replay_note_bridge_summary,
|
||||
@@ -62,6 +66,133 @@ class AlertArchiveHelperTests(unittest.TestCase):
|
||||
self.assertEqual(bundle["status"], "active")
|
||||
self.assertEqual(bundle["day_group_count"], 1)
|
||||
|
||||
def test_workflow_compare_writeback_summary_persists_note_context(self) -> None:
|
||||
result = _build_workflow_archive_compare_writeback_summary(
|
||||
note={
|
||||
"id": 21,
|
||||
"note_scope": "workflow_compare",
|
||||
"author": "local_operator",
|
||||
"tags": ["workflow-compare", "session:LONDON"],
|
||||
"created_ts": "2026-05-16T02:00:00+00:00",
|
||||
},
|
||||
replay_note_bridge_summary={
|
||||
"note_title": "Workflow replay compare #9 vs #8",
|
||||
"note_body": "compare body",
|
||||
"compare_left_snapshot_id": 9,
|
||||
"compare_right_snapshot_id": 8,
|
||||
"changed_phase_count": 2,
|
||||
},
|
||||
compare={"summary": "compare summary"},
|
||||
query_context={"profile_scope": "selected_profile", "left_id": 9, "right_id": 8},
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "persisted")
|
||||
self.assertEqual(result["note_id"], 21)
|
||||
self.assertEqual(result["note_title"], "Workflow replay compare #9 vs #8")
|
||||
self.assertEqual(result["compare_left_snapshot_id"], 9)
|
||||
self.assertEqual(result["changed_phase_count"], 2)
|
||||
|
||||
def test_archive_selection_provenance_summary_tracks_scope_filters_and_compare(self) -> None:
|
||||
result = _build_archive_selection_provenance_summary(
|
||||
archive={
|
||||
"status": "active",
|
||||
"profile_scope": "all_profiles",
|
||||
"query_preset": "recent_7d",
|
||||
"filters": {"query_text": "candidate", "created_after": "", "digest_mode": "high_value_only"},
|
||||
"archive_baseline_state": {"digest_snapshot_id": 9},
|
||||
"item_count": 5,
|
||||
},
|
||||
compare={"left": {"snapshot_id": 4}, "right": {"snapshot_id": 3}},
|
||||
export_scope="selected_compare",
|
||||
selection_kind="digest_archive",
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "active")
|
||||
self.assertEqual(result["export_scope"], "selected_compare")
|
||||
self.assertEqual(result["active_filter_count"], 2)
|
||||
self.assertEqual(result["selected_snapshot_ids"], [4, 3])
|
||||
self.assertEqual(result["baseline_snapshot_id"], 9)
|
||||
|
||||
def test_archive_bundle_manifest_normalizes_export_sections(self) -> None:
|
||||
manifest = _build_archive_bundle_manifest(
|
||||
archive={
|
||||
"status": "active",
|
||||
"profile_scope": "all_profiles",
|
||||
"query_preset": "recent_7d",
|
||||
"item_count": 5,
|
||||
},
|
||||
compare={"status": "active"},
|
||||
generated_ts="2026-05-17T03:00:00+00:00",
|
||||
export_kind="digest_archive_bundle",
|
||||
export_scope="selected_compare",
|
||||
selection_provenance_summary={
|
||||
"active_filter_count": 2,
|
||||
"selected_snapshot_ids": [4, 3],
|
||||
"baseline_snapshot_id": 9,
|
||||
},
|
||||
structured_export_summary={"status": "active"},
|
||||
)
|
||||
|
||||
self.assertEqual(manifest["normalization_status"], "normalized")
|
||||
self.assertEqual(manifest["manifest_version"], "decision_assistant_archive_bundle_manifest_v1")
|
||||
self.assertEqual(manifest["status"], "active")
|
||||
self.assertIn("compare", manifest["included_sections"])
|
||||
self.assertEqual(manifest["baseline_snapshot_id"], 9)
|
||||
|
||||
def test_external_context_surface_history_summary_includes_e30_resolution_follow_up_fields(self) -> None:
|
||||
result = _build_external_context_surface_history_summary(
|
||||
instrument_id=1,
|
||||
profile_key="local_operator",
|
||||
history=[
|
||||
{
|
||||
"surface_id": "okx_help_announcements_page",
|
||||
"title": "okx_help_announcements_page",
|
||||
"source_group": "exchange_context_surface",
|
||||
"selection_kind": "target_url",
|
||||
"target_url": "https://www.okx.com/help",
|
||||
"selected_ts": "2026-05-16T00:00:00+00:00",
|
||||
"revisit_acknowledged": True,
|
||||
"revisit_acknowledged_by": "alice",
|
||||
"revisit_acknowledged_ts": "2026-05-16T08:00:00+00:00",
|
||||
},
|
||||
{
|
||||
"surface_id": "tradingview_public_chart_reference",
|
||||
"title": "tradingview_public_chart_reference",
|
||||
"source_group": "visual_reference_card",
|
||||
"selection_kind": "target_url",
|
||||
"target_url": "https://www.tradingview.com/",
|
||||
"selected_ts": "2026-05-15T00:00:00+00:00",
|
||||
"revisit_acknowledged": True,
|
||||
"revisit_acknowledged_by": "bob",
|
||||
"revisit_acknowledged_ts": "2026-05-16T07:30:00+00:00",
|
||||
"revisit_ack_note": "checked",
|
||||
},
|
||||
{
|
||||
"surface_id": "binance_public_chart_reference",
|
||||
"title": "binance_public_chart_reference",
|
||||
"source_group": "visual_reference_card",
|
||||
"selection_kind": "target_url",
|
||||
"target_url": "https://www.binance.com/en/trade/BTC_USDT?type=spot",
|
||||
"selected_ts": "2026-05-14T00:00:00+00:00",
|
||||
"revisit_acknowledged": True,
|
||||
"revisit_acknowledged_by": "bob",
|
||||
"revisit_acknowledged_ts": "2026-05-16T07:00:00+00:00",
|
||||
"revisit_ack_note": "done",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
resolution = result["resolution_filter_summary"]
|
||||
self.assertIn("resolved_owner_drift_status", resolution)
|
||||
self.assertIn("carry_forward_follow_up_lane_status", resolution)
|
||||
self.assertEqual(resolution["resolved_owner_drift_status"], "drift_detected")
|
||||
self.assertEqual(resolution["carry_forward_follow_up_lane_status"], "active")
|
||||
self.assertEqual(resolution["resolved_owner_drift_review_status"], "review_required")
|
||||
self.assertEqual(resolution["resolved_owner_drift_review_queue_status"], "active")
|
||||
self.assertEqual(resolution["resolved_owner_drift_review_queue_aging_status"], "stale")
|
||||
self.assertEqual(resolution["carry_forward_follow_up_lane_rollover_status"], "rollover_required")
|
||||
self.assertEqual(resolution["carry_forward_follow_up_lane_rollover_handoff_status"], "handoff_required")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -199,6 +199,70 @@ class ApiServerRoutingTests(unittest.TestCase):
|
||||
self.assertEqual(post_status, 200)
|
||||
self.assertEqual(post_payload["workflow_snapshot_id"], 34)
|
||||
|
||||
def test_routes_external_context_surface_history(self) -> None:
|
||||
get_status, get_payload = route_request(
|
||||
"/trading/external-context-surface-history",
|
||||
{"instrument_id": ["1"], "profile_key": ["local_operator"]},
|
||||
dependencies={
|
||||
"trading_external_context_surface_history": lambda **kwargs: {
|
||||
"status": "active",
|
||||
"instrument_id": kwargs["instrument_id"],
|
||||
"profile_key": kwargs["profile_key"],
|
||||
"entry_count": 2,
|
||||
}
|
||||
},
|
||||
)
|
||||
post_status, post_payload = route_request(
|
||||
"/trading/external-context-surface-history",
|
||||
{},
|
||||
method="POST",
|
||||
body={
|
||||
"instrument_id": 1,
|
||||
"profile_key": "local_operator",
|
||||
"surface_id": "binance_public_chart_reference",
|
||||
"title": "binance_public_chart_reference",
|
||||
"source_group": "visual_reference_card",
|
||||
"selection_kind": "target_url",
|
||||
"target_url": "https://www.binance.com/en/trade/BTC_USDT?type=spot",
|
||||
},
|
||||
dependencies={
|
||||
"trading_external_context_surface_history_save": lambda **kwargs: {
|
||||
"status": "saved",
|
||||
"instrument_id": kwargs["instrument_id"],
|
||||
"profile_key": kwargs["profile_key"],
|
||||
"latest_selection": {"surface_id": kwargs["surface_id"]},
|
||||
}
|
||||
},
|
||||
)
|
||||
ack_status, ack_payload = route_request(
|
||||
"/trading/external-context-surface-history/acknowledge",
|
||||
{},
|
||||
method="POST",
|
||||
body={
|
||||
"instrument_id": 1,
|
||||
"profile_key": "local_operator",
|
||||
"surface_id": "binance_public_chart_reference",
|
||||
"selected_ts": "2026-05-12T02:05:30+00:00",
|
||||
"note": "checked and cleared",
|
||||
},
|
||||
dependencies={
|
||||
"trading_external_context_surface_history_ack": lambda **kwargs: {
|
||||
"status": "saved",
|
||||
"instrument_id": kwargs["instrument_id"],
|
||||
"profile_key": kwargs["profile_key"],
|
||||
"latest_acknowledgment": {"surface_id": kwargs["surface_id"], "revisit_ack_note": kwargs["note"]},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(get_status, 200)
|
||||
self.assertEqual(get_payload["entry_count"], 2)
|
||||
self.assertEqual(post_status, 200)
|
||||
self.assertEqual(post_payload["latest_selection"]["surface_id"], "binance_public_chart_reference")
|
||||
self.assertEqual(ack_status, 200)
|
||||
self.assertEqual(ack_payload["latest_acknowledgment"]["surface_id"], "binance_public_chart_reference")
|
||||
self.assertEqual(ack_payload["latest_acknowledgment"]["revisit_ack_note"], "checked and cleared")
|
||||
|
||||
def test_routes_decision_assistant_archive_and_focus(self) -> None:
|
||||
profiles_status, profiles_payload = route_request(
|
||||
"/trading/alerts/digest-preferences/profiles",
|
||||
@@ -224,6 +288,7 @@ class ApiServerRoutingTests(unittest.TestCase):
|
||||
"filters": {"preferred_history_kind": kwargs["preferred_history_kind"], "query_text": kwargs["query_text"]},
|
||||
"long_range_trend_summary": {"status": "active"},
|
||||
"baseline_pin_summary": {"status": "active"},
|
||||
"ranked_aggregation_summary": {"status": "active"},
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -234,6 +299,7 @@ class ApiServerRoutingTests(unittest.TestCase):
|
||||
"trading_alert_operator_digest_history_export": lambda **kwargs: {
|
||||
"status": "active",
|
||||
"export_kind": "digest_archive_bundle",
|
||||
"export_schema_version": "decision_assistant_archive_export_v4",
|
||||
"profile_scope": kwargs["profile_scope"],
|
||||
"query_preset": kwargs["query_preset"],
|
||||
"query_text": kwargs["query_text"],
|
||||
@@ -241,7 +307,10 @@ class ApiServerRoutingTests(unittest.TestCase):
|
||||
"right_id": kwargs["right_id"],
|
||||
"long_range_trend_summary": {"status": "active"},
|
||||
"baseline_pin_summary": {"status": "active"},
|
||||
"ranked_aggregation_summary": {"status": "active"},
|
||||
"selection_provenance_summary": {"status": "active", "export_scope": "selected_compare"},
|
||||
"structured_export_summary": {"status": "active"},
|
||||
"bundle_manifest": {"status": "active", "manifest_version": "decision_assistant_archive_bundle_manifest_v1"},
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -271,28 +340,36 @@ class ApiServerRoutingTests(unittest.TestCase):
|
||||
"filters": {"current_session": kwargs["current_session"], "next_phase": kwargs["next_phase"], "query_text": kwargs["query_text"]},
|
||||
"long_range_trend_summary": {"status": "active"},
|
||||
"baseline_pin_summary": {"status": "active"},
|
||||
"ranked_aggregation_summary": {"status": "active"},
|
||||
}
|
||||
},
|
||||
)
|
||||
workflow_export_status, workflow_export_payload = route_request(
|
||||
"/trading/workflow-archive/export",
|
||||
{"instrument_id": ["1"], "profile_scope": ["all_profiles"], "query_preset": ["intraday_recent"], "query_text": ["handoff"], "left_id": ["9"], "right_id": ["8"]},
|
||||
{"instrument_id": ["1"], "profile_scope": ["all_profiles"], "query_preset": ["intraday_recent"], "query_text": ["handoff"], "left_id": ["9"], "right_id": ["8"], "group_date": ["2026-05-16"], "session_group": ["LONDON"]},
|
||||
dependencies={
|
||||
"trading_workflow_archive_export": lambda **kwargs: {
|
||||
"status": "active",
|
||||
"export_kind": "workflow_archive_bundle",
|
||||
"export_schema_version": "decision_assistant_archive_export_v4",
|
||||
"profile_scope": kwargs["profile_scope"],
|
||||
"query_preset": kwargs["query_preset"],
|
||||
"query_text": kwargs["query_text"],
|
||||
"left_id": kwargs["left_id"],
|
||||
"right_id": kwargs["right_id"],
|
||||
"group_date": kwargs["group_date"],
|
||||
"session_group": kwargs["session_group"],
|
||||
"long_range_trend_summary": {"status": "active"},
|
||||
"baseline_pin_summary": {"status": "active"},
|
||||
"ranked_aggregation_summary": {"status": "active"},
|
||||
"handoff_bundle_summary": {"status": "active"},
|
||||
"handoff_packet_starter": {"status": "active"},
|
||||
"replay_note_bridge_summary": {"status": "active"},
|
||||
"artifact_bundle_summary": {"status": "active"},
|
||||
"group_export_summary": {"status": "active"},
|
||||
"selection_provenance_summary": {"status": "active", "export_scope": "selected_compare"},
|
||||
"structured_export_summary": {"status": "active"},
|
||||
"bundle_manifest": {"status": "active", "manifest_version": "decision_assistant_archive_bundle_manifest_v1"},
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -310,6 +387,21 @@ class ApiServerRoutingTests(unittest.TestCase):
|
||||
}
|
||||
},
|
||||
)
|
||||
workflow_writeback_status, workflow_writeback_payload = route_request(
|
||||
"/trading/workflow-archive/compare-writeback",
|
||||
{},
|
||||
method="POST",
|
||||
body={"instrument_id": 1, "profile_key": "desk_a", "left_id": 9, "right_id": 8, "query_text": "handoff"},
|
||||
dependencies={
|
||||
"trading_workflow_archive_compare_writeback": lambda **kwargs: {
|
||||
"status": "saved",
|
||||
"profile_key": kwargs["profile_key"],
|
||||
"left_id": kwargs["left_id"],
|
||||
"right_id": kwargs["right_id"],
|
||||
"compare_writeback_summary": {"status": "persisted", "note_title": "Workflow replay compare #9 vs #8"},
|
||||
}
|
||||
},
|
||||
)
|
||||
focus_get_status, focus_get_payload = route_request(
|
||||
"/trading/assistant-focus",
|
||||
{"instrument_id": ["1"], "profile_key": ["desk_a"]},
|
||||
@@ -346,13 +438,18 @@ class ApiServerRoutingTests(unittest.TestCase):
|
||||
self.assertEqual(digest_archive_payload["filters"]["preferred_history_kind"], "reminder")
|
||||
self.assertEqual(digest_archive_payload["filters"]["query_text"], "candidate")
|
||||
self.assertEqual(digest_archive_payload["long_range_trend_summary"]["status"], "active")
|
||||
self.assertEqual(digest_archive_payload["ranked_aggregation_summary"]["status"], "active")
|
||||
self.assertEqual(digest_export_status, 200)
|
||||
self.assertEqual(digest_export_payload["export_kind"], "digest_archive_bundle")
|
||||
self.assertEqual(digest_export_payload["export_schema_version"], "decision_assistant_archive_export_v4")
|
||||
self.assertEqual(digest_export_payload["profile_scope"], "all_profiles")
|
||||
self.assertEqual(digest_export_payload["query_preset"], "recent_7d")
|
||||
self.assertEqual(digest_export_payload["left_id"], 4)
|
||||
self.assertEqual(digest_export_payload["baseline_pin_summary"]["status"], "active")
|
||||
self.assertEqual(digest_export_payload["ranked_aggregation_summary"]["status"], "active")
|
||||
self.assertEqual(digest_export_payload["selection_provenance_summary"]["status"], "active")
|
||||
self.assertEqual(digest_export_payload["structured_export_summary"]["status"], "active")
|
||||
self.assertEqual(digest_export_payload["bundle_manifest"]["manifest_version"], "decision_assistant_archive_bundle_manifest_v1")
|
||||
self.assertEqual(digest_compare_status, 200)
|
||||
self.assertEqual(digest_compare_payload["left_id"], 4)
|
||||
self.assertEqual(digest_compare_payload["profile_scope"], "all_profiles")
|
||||
@@ -364,21 +461,32 @@ class ApiServerRoutingTests(unittest.TestCase):
|
||||
self.assertEqual(workflow_archive_payload["filters"]["next_phase"], "intraday")
|
||||
self.assertEqual(workflow_archive_payload["filters"]["query_text"], "handoff")
|
||||
self.assertEqual(workflow_archive_payload["baseline_pin_summary"]["status"], "active")
|
||||
self.assertEqual(workflow_archive_payload["ranked_aggregation_summary"]["status"], "active")
|
||||
self.assertEqual(workflow_export_status, 200)
|
||||
self.assertEqual(workflow_export_payload["export_kind"], "workflow_archive_bundle")
|
||||
self.assertEqual(workflow_export_payload["export_schema_version"], "decision_assistant_archive_export_v4")
|
||||
self.assertEqual(workflow_export_payload["profile_scope"], "all_profiles")
|
||||
self.assertEqual(workflow_export_payload["query_preset"], "intraday_recent")
|
||||
self.assertEqual(workflow_export_payload["group_date"], "2026-05-16")
|
||||
self.assertEqual(workflow_export_payload["session_group"], "LONDON")
|
||||
self.assertEqual(workflow_export_payload["long_range_trend_summary"]["status"], "active")
|
||||
self.assertEqual(workflow_export_payload["ranked_aggregation_summary"]["status"], "active")
|
||||
self.assertEqual(workflow_export_payload["handoff_bundle_summary"]["status"], "active")
|
||||
self.assertEqual(workflow_export_payload["handoff_packet_starter"]["status"], "active")
|
||||
self.assertEqual(workflow_export_payload["replay_note_bridge_summary"]["status"], "active")
|
||||
self.assertEqual(workflow_export_payload["artifact_bundle_summary"]["status"], "active")
|
||||
self.assertEqual(workflow_export_payload["group_export_summary"]["status"], "active")
|
||||
self.assertEqual(workflow_export_payload["selection_provenance_summary"]["status"], "active")
|
||||
self.assertEqual(workflow_export_payload["structured_export_summary"]["status"], "active")
|
||||
self.assertEqual(workflow_export_payload["bundle_manifest"]["manifest_version"], "decision_assistant_archive_bundle_manifest_v1")
|
||||
self.assertEqual(workflow_compare_status, 200)
|
||||
self.assertEqual(workflow_compare_payload["profile_scope"], "all_profiles")
|
||||
self.assertEqual(workflow_compare_payload["query_preset"], "intraday_recent")
|
||||
self.assertEqual(workflow_compare_payload["right_id"], 8)
|
||||
self.assertEqual(workflow_compare_payload["query_text"], "handoff")
|
||||
self.assertEqual(workflow_writeback_status, 200)
|
||||
self.assertEqual(workflow_writeback_payload["profile_key"], "desk_a")
|
||||
self.assertEqual(workflow_writeback_payload["compare_writeback_summary"]["status"], "persisted")
|
||||
self.assertEqual(focus_get_status, 200)
|
||||
self.assertEqual(focus_get_payload["focus_candidate_id"], 18)
|
||||
self.assertEqual(focus_post_status, 200)
|
||||
@@ -634,6 +742,24 @@ class ApiServerRoutingTests(unittest.TestCase):
|
||||
self.assertFalse(payload["ready_for_real_live"])
|
||||
self.assertEqual(payload["timeframe"], "5m")
|
||||
|
||||
def test_routes_trading_okx_sandbox_preflight(self) -> None:
|
||||
status, payload = route_request(
|
||||
"/trading/okx-sandbox-preflight",
|
||||
{"instrument_id": ["1"], "timeframe": ["5m"]},
|
||||
dependencies={
|
||||
"trading_okx_sandbox_preflight": lambda **kwargs: {
|
||||
"status": "sandbox_preflight_ready",
|
||||
"timeframe": kwargs["timeframe"],
|
||||
"next_gate": "sandbox_submit_cancel_reconcile_smoke",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload["status"], "sandbox_preflight_ready")
|
||||
self.assertEqual(payload["timeframe"], "5m")
|
||||
self.assertEqual(payload["next_gate"], "sandbox_submit_cancel_reconcile_smoke")
|
||||
|
||||
def test_routes_trading_launch_readiness(self) -> None:
|
||||
status, payload = route_request(
|
||||
"/trading/launch-readiness",
|
||||
@@ -647,6 +773,56 @@ class ApiServerRoutingTests(unittest.TestCase):
|
||||
self.assertFalse(payload["decision_support_ready"])
|
||||
self.assertEqual(payload["timeframe"], "5m")
|
||||
|
||||
def test_routes_trading_shadow_paper_validation(self) -> None:
|
||||
status, payload = route_request(
|
||||
"/trading/shadow-paper-validation",
|
||||
{"instrument_id": ["1"], "timeframe": ["5m"]},
|
||||
dependencies={"trading_shadow_paper_validation": lambda **kwargs: {"status": "collecting_paper_samples", "timeframe": kwargs["timeframe"]}},
|
||||
)
|
||||
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload["status"], "collecting_paper_samples")
|
||||
self.assertEqual(payload["timeframe"], "5m")
|
||||
|
||||
def test_routes_trading_shadow_paper_observation_record(self) -> None:
|
||||
status, payload = route_request(
|
||||
"/trading/shadow-paper/observe",
|
||||
{},
|
||||
method="POST",
|
||||
body={
|
||||
"instrument_id": 1,
|
||||
"timeframe": "5m",
|
||||
"session_code": "NY_AM",
|
||||
"candidate_id": 7,
|
||||
"paper_ticket_id": 11,
|
||||
"paper_execution_id": 21,
|
||||
"operator_agreement": "agree",
|
||||
"operator_note": "useful paper sample",
|
||||
"created_by": "alice",
|
||||
},
|
||||
dependencies={
|
||||
"trading_shadow_paper_observation_record": lambda **kwargs: {
|
||||
"status": "recorded",
|
||||
"instrument_id": kwargs["instrument_id"],
|
||||
"timeframe": kwargs["timeframe"],
|
||||
"candidate_id": kwargs["candidate_id"],
|
||||
"paper_ticket_id": kwargs["paper_ticket_id"],
|
||||
"paper_execution_id": kwargs["paper_execution_id"],
|
||||
"operator_agreement": kwargs["operator_agreement"],
|
||||
"created_by": kwargs["created_by"],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(status, 201)
|
||||
self.assertEqual(payload["status"], "recorded")
|
||||
self.assertEqual(payload["timeframe"], "5m")
|
||||
self.assertEqual(payload["candidate_id"], 7)
|
||||
self.assertEqual(payload["paper_ticket_id"], 11)
|
||||
self.assertEqual(payload["paper_execution_id"], 21)
|
||||
self.assertEqual(payload["operator_agreement"], "agree")
|
||||
self.assertEqual(payload["created_by"], "alice")
|
||||
|
||||
def test_routes_trading_broker_state(self) -> None:
|
||||
status, payload = route_request(
|
||||
"/trading/broker-state",
|
||||
@@ -694,7 +870,7 @@ class ApiServerRoutingTests(unittest.TestCase):
|
||||
"/trading/candidates/4/build-ticket",
|
||||
{},
|
||||
method="POST",
|
||||
body={"execution_mode": "semi_auto"},
|
||||
body={"execution_mode": "semi_auto", "allow_shadow_paper": False},
|
||||
dependencies={"trading_candidate_build_ticket": lambda **kwargs: {"ticket_id": 11, **kwargs}},
|
||||
)
|
||||
status_get, payload_get = route_request(
|
||||
@@ -719,6 +895,7 @@ class ApiServerRoutingTests(unittest.TestCase):
|
||||
|
||||
self.assertEqual(status_build, 200)
|
||||
self.assertEqual(payload_build["ticket_id"], 11)
|
||||
self.assertFalse(payload_build["allow_shadow_paper"])
|
||||
self.assertEqual(status_get, 200)
|
||||
self.assertEqual(payload_get["status"], "awaiting_confirmation")
|
||||
self.assertEqual(status_risk, 200)
|
||||
|
||||
@@ -155,6 +155,86 @@ class ApiStaticServerTests(unittest.TestCase):
|
||||
"Handoff Bridge",
|
||||
"Clear Workflow Bridge",
|
||||
"provider health cards",
|
||||
"provider health story",
|
||||
"visual bridge story",
|
||||
"surface history",
|
||||
"surface history count",
|
||||
"surface history recency",
|
||||
"surface history cadence",
|
||||
"surface cadence story",
|
||||
"revisit queue count",
|
||||
"cleared queue status",
|
||||
"cleared queue story",
|
||||
"resolution history count",
|
||||
"latest acknowledged by",
|
||||
"latest acknowledged note",
|
||||
"surface revisit prompt",
|
||||
"next revisit prompt",
|
||||
"resolution review prompt",
|
||||
"surface history story",
|
||||
"Reopen Surface",
|
||||
"Reopen Latest Surface",
|
||||
"Reopen Revisit",
|
||||
"acknowledged revisits",
|
||||
"Acknowledge Revisit",
|
||||
"Acknowledge With Note",
|
||||
"Reopen Cleared Surface",
|
||||
"resolution notes",
|
||||
"resolution note gaps",
|
||||
"note gap queue",
|
||||
"note gap oldest",
|
||||
"note gap avg age",
|
||||
"resolution owners",
|
||||
"owner spotlight",
|
||||
"owner spotlight count",
|
||||
"owner cadence",
|
||||
"owner cadence avg gap",
|
||||
"owner cadence story",
|
||||
"owner trend",
|
||||
"owner trend story",
|
||||
"owner handoff",
|
||||
"owner handoff story",
|
||||
"owner handoff follow-up",
|
||||
"owner handoff follow-up story",
|
||||
"resolved owner drift",
|
||||
"resolved owner drift story",
|
||||
"resolved owner drift review",
|
||||
"resolved owner drift review story",
|
||||
"drift review queue",
|
||||
"drift review queue story",
|
||||
"drift review queue aging",
|
||||
"drift review queue aging story",
|
||||
"active resolution filter",
|
||||
"All Resolved",
|
||||
"With Resolution Note",
|
||||
"Without Resolution Note",
|
||||
"Add Resolution Note",
|
||||
"Reopen Note Gap",
|
||||
"owner spotlight prompt",
|
||||
"priority lane status",
|
||||
"priority lane count",
|
||||
"priority lane story",
|
||||
"priority lane carry-forward",
|
||||
"priority lane carry-forward story",
|
||||
"carry-forward aging",
|
||||
"carry-forward aging story",
|
||||
"follow-up lane status",
|
||||
"follow-up lane count",
|
||||
"follow-up lane story",
|
||||
"follow-up lane rollover",
|
||||
"follow-up lane rollover story",
|
||||
"rollover handoff",
|
||||
"rollover handoff story",
|
||||
"priority lane prompt",
|
||||
"rollover prompt",
|
||||
"rollover handoff prompt",
|
||||
"Open Priority Surface",
|
||||
"Open Follow-Up Lane",
|
||||
"Open Rollover Surface",
|
||||
"Open Drift Review",
|
||||
"Open Rollover Handoff",
|
||||
"dominant surface",
|
||||
"dominant surface story",
|
||||
"Open Visual Bridge",
|
||||
"Open Capture Surface",
|
||||
"Open Screenshot Surface",
|
||||
@@ -162,13 +242,85 @@ class ApiStaticServerTests(unittest.TestCase):
|
||||
"Open Embed",
|
||||
"Open Capture Target",
|
||||
"Open Capture Embed",
|
||||
"capture surface story",
|
||||
"screenshot readiness",
|
||||
"screenshot story",
|
||||
"screenshot status",
|
||||
"browser capture",
|
||||
"export provenance",
|
||||
"workflow export provenance",
|
||||
"bundle manifest",
|
||||
"manifest sections",
|
||||
"workflow bundle manifest",
|
||||
"workflow manifest sections",
|
||||
"selection fingerprint",
|
||||
"Read-only Live Analysis",
|
||||
"read-only live analysis",
|
||||
"ready_for_readonly_live_analysis",
|
||||
"must_not_trade",
|
||||
"No order submission",
|
||||
"Not a buy/sell instruction",
|
||||
"Execution remains disabled",
|
||||
"live analysis assistant ready",
|
||||
"readonly live health",
|
||||
"broker/account context",
|
||||
"latest decision packet timestamp",
|
||||
"latest review packet timestamp",
|
||||
"operator boundary",
|
||||
"boundary summary",
|
||||
"current model boundary",
|
||||
"current model reason",
|
||||
"live execution boundary",
|
||||
"live execution reason",
|
||||
"allowed operator actions",
|
||||
"blocked operator actions",
|
||||
"boundary next gate",
|
||||
"boundary gate reason",
|
||||
"decision support promotion",
|
||||
"strict decision support ready",
|
||||
"current decision support ready",
|
||||
"promotion model",
|
||||
"promotion classification",
|
||||
"operator feedback samples",
|
||||
"promotion criteria",
|
||||
"failed promotion criteria",
|
||||
"next promotion gate",
|
||||
"promotion summary",
|
||||
"shadow paper validation",
|
||||
"shadow validation status",
|
||||
"shadow observed days",
|
||||
"shadow observation samples",
|
||||
"shadow observation days",
|
||||
"paper ticket samples",
|
||||
"paper execution samples",
|
||||
"operator observation feedback",
|
||||
"Record Shadow Observation",
|
||||
"Agree Shadow Observation",
|
||||
"Disagree Shadow Observation",
|
||||
"Build Shadow Paper",
|
||||
"allow_shadow_paper",
|
||||
"failed shadow criteria",
|
||||
"next shadow gate",
|
||||
"sample collection next actions",
|
||||
"real live after shadow",
|
||||
"shadow validation summary",
|
||||
"shadow daily coverage",
|
||||
"okx sandbox preflight",
|
||||
"sandbox next gate",
|
||||
"simulated trading header",
|
||||
"submit dry-run",
|
||||
"cancel dry-run",
|
||||
"query order dry-run",
|
||||
"query fills dry-run",
|
||||
"idempotency key",
|
||||
"error normalization",
|
||||
"real live after sandbox",
|
||||
"archive trend",
|
||||
"archive trend story",
|
||||
"archive baseline",
|
||||
"archive baseline persistence",
|
||||
"archive baseline delta",
|
||||
"archive baseline delta story",
|
||||
"Pin Baseline",
|
||||
"Compare To Baseline",
|
||||
"Save Baseline",
|
||||
@@ -181,13 +333,29 @@ class ApiStaticServerTests(unittest.TestCase):
|
||||
"workflow baseline",
|
||||
"workflow trend story",
|
||||
"workflow baseline persistence",
|
||||
"archive operator overview",
|
||||
"overview story",
|
||||
"archive top profile",
|
||||
"archive top kind",
|
||||
"workflow top profile",
|
||||
"workflow top session",
|
||||
"workflow top phase",
|
||||
"workflow baseline delta",
|
||||
"workflow baseline delta story",
|
||||
"digest baseline delta",
|
||||
"digest baseline delta story",
|
||||
"Profile Scope",
|
||||
"Query Preset",
|
||||
"Prefill Compare Replay Note",
|
||||
"Persist Compare Replay Note",
|
||||
"Export Handoff Starter",
|
||||
"Export Artifact Bundle",
|
||||
"Export Day Bundle",
|
||||
"Export Session Bundle",
|
||||
"Persist Bridge State",
|
||||
"Prefill Saved Compare Note",
|
||||
"bridge persistence",
|
||||
"bridge saved compare note",
|
||||
):
|
||||
self.assertIn(expected, app_js)
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ class ArchiveLongRangeProjectionTests(unittest.TestCase):
|
||||
self.assertEqual(baseline["latest_snapshot_id"], 5)
|
||||
self.assertEqual(baseline["baseline_snapshot_id"], 1)
|
||||
self.assertTrue(baseline["compare_ready"])
|
||||
self.assertEqual(baseline["baseline_delta_status"], "changed")
|
||||
self.assertIn("high-value delta", baseline["baseline_delta_story"])
|
||||
|
||||
def test_workflow_long_range_and_baseline_summary(self) -> None:
|
||||
items = [
|
||||
@@ -80,6 +82,8 @@ class ArchiveLongRangeProjectionTests(unittest.TestCase):
|
||||
self.assertEqual(baseline["latest_snapshot_id"], 9)
|
||||
self.assertEqual(baseline["baseline_snapshot_id"], 3)
|
||||
self.assertTrue(baseline["suggested_compare_prompt"])
|
||||
self.assertEqual(baseline["baseline_delta_status"], "changed")
|
||||
self.assertIn("ready delta", baseline["baseline_delta_story"])
|
||||
|
||||
def test_empty_items_return_idle(self) -> None:
|
||||
self.assertEqual(build_digest_archive_long_range_summary([])["status"], "idle")
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import unittest
|
||||
|
||||
from src.services.operator.archive_retrieval_projection import (
|
||||
build_digest_archive_ranked_aggregation,
|
||||
build_workflow_archive_ranked_aggregation,
|
||||
)
|
||||
|
||||
|
||||
class ArchiveRetrievalProjectionTests(unittest.TestCase):
|
||||
def test_digest_ranked_aggregation(self) -> None:
|
||||
items = [
|
||||
{"profile_key": "desk_b", "preferred_history_kind": "note"},
|
||||
{"profile_key": "desk_a", "preferred_history_kind": "reminder"},
|
||||
{"profile_key": "desk_a", "preferred_history_kind": "reminder"},
|
||||
]
|
||||
result = build_digest_archive_ranked_aggregation(items)
|
||||
|
||||
self.assertEqual(result["status"], "active")
|
||||
self.assertEqual(result["top_profile"]["value"], "desk_a")
|
||||
self.assertEqual(result["top_history_kind"]["value"], "reminder")
|
||||
|
||||
def test_workflow_ranked_aggregation(self) -> None:
|
||||
items = [
|
||||
{"profile_key": "desk_a", "current_session": "LONDON", "next_phase": "intraday", "next_artifact_kind": "intraday_digest"},
|
||||
{"profile_key": "desk_a", "current_session": "LONDON", "next_phase": "intraday", "next_artifact_kind": "intraday_digest"},
|
||||
{"profile_key": "desk_b", "current_session": "NY_AM", "next_phase": "post_trade", "next_artifact_kind": "post_trade_recap"},
|
||||
]
|
||||
result = build_workflow_archive_ranked_aggregation(items)
|
||||
|
||||
self.assertEqual(result["status"], "active")
|
||||
self.assertEqual(result["top_profile"]["value"], "desk_a")
|
||||
self.assertEqual(result["top_session"]["value"], "LONDON")
|
||||
self.assertEqual(result["top_phase"]["value"], "intraday")
|
||||
self.assertEqual(result["top_artifact"]["value"], "intraday_digest")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -18,7 +18,9 @@ OLD_TRAINING_DOC = ROOT / "docs" / "training_case_engine_m1_m6_product_readme.md
|
||||
NONEXISTENT_VISUAL_PLAN_NAME = "AI_ICT_" + "视觉盯盘与主动告警增强方案_v1.md"
|
||||
|
||||
ACTIVE_TOP_LEVEL_DOCS = {
|
||||
"AI_ICT_实盘辅助分析决策助手上线必做清单_2026-05-18_v1.md",
|
||||
"AI_ICT_分析助手主线增强开发计划_2026-05-14_v1.md",
|
||||
"AI_ICT_实盘辅助分析决策助手可用性评估与补齐方案_2026-05-17_v1.md",
|
||||
"AI_ICT_全自动开发执行流程_v1.md",
|
||||
"AI_ICT_实盘交易分析决策助手下一阶段开发方案_v1.md",
|
||||
"AI_ICT_实盘交易分析系统开发计划_v1.md",
|
||||
|
||||
@@ -15,6 +15,7 @@ class LaunchReadinessServiceTests(unittest.TestCase):
|
||||
weekly_review_service=_WeeklyReviewService(),
|
||||
trust_service=_TrustService(),
|
||||
live_readiness_service=_LiveReadinessService(),
|
||||
shadow_paper_validation_service=_ShadowPaperValidationService(),
|
||||
)
|
||||
|
||||
with (
|
||||
@@ -24,6 +25,11 @@ class LaunchReadinessServiceTests(unittest.TestCase):
|
||||
payload = service.build_summary(instrument_id=1, timeframe="1m")
|
||||
|
||||
self.assertTrue(payload["analysis_only_ready"])
|
||||
self.assertTrue(payload["live_analysis_assistant_ready"])
|
||||
self.assertEqual(payload["live_analysis_assistant_summary"]["status"], "ready_for_readonly_live_analysis")
|
||||
self.assertTrue(payload["live_analysis_assistant_summary"]["must_not_trade"])
|
||||
self.assertFalse(payload["live_analysis_assistant_summary"]["execution_allowed"])
|
||||
self.assertEqual(payload["readonly_live_health_summary"]["broker_account_context"], "available")
|
||||
self.assertTrue(payload["assisted_trading_ready"])
|
||||
self.assertTrue(payload["operator_soft_rollout_ready"])
|
||||
self.assertEqual(payload["operator_soft_rollout_mode"], "execution_assist")
|
||||
@@ -34,6 +40,20 @@ class LaunchReadinessServiceTests(unittest.TestCase):
|
||||
self.assertIn("real_trade_sample_verified", payload["warning_reasons"])
|
||||
self.assertIn("real_trade_sample_verified", payload["external_waiting_items"])
|
||||
self.assertFalse(payload["real_live_ready"])
|
||||
self.assertEqual(payload["decision_support_boundary_summary"]["status"], "execution_assist_available")
|
||||
self.assertIn("review_market_context", payload["decision_support_boundary_summary"]["allowed_operator_actions"])
|
||||
self.assertIn("submit_live_orders", payload["decision_support_boundary_summary"]["blocked_operator_actions"])
|
||||
self.assertEqual(
|
||||
payload["decision_support_promotion_summary"]["status"],
|
||||
"ready_for_decision_support_non_execution",
|
||||
)
|
||||
self.assertTrue(payload["decision_support_promotion_summary"]["strict_decision_support_ready"])
|
||||
self.assertEqual(
|
||||
payload["decision_support_promotion_summary"]["execution_boundary_after_promotion"],
|
||||
"execution_still_blocked",
|
||||
)
|
||||
self.assertEqual(payload["shadow_paper_validation_summary"]["status"], "collecting_paper_samples")
|
||||
self.assertEqual(payload["shadow_paper_validation_summary"]["next_validation_gate"], "paper_ticket_sample_gate")
|
||||
|
||||
def test_build_summary_defers_real_trade_sample_gate_for_analysis_decision_phase(self) -> None:
|
||||
service = LaunchReadinessService(
|
||||
@@ -42,6 +62,7 @@ class LaunchReadinessServiceTests(unittest.TestCase):
|
||||
weekly_review_service=_WeeklyReviewService(),
|
||||
trust_service=_TrustService(),
|
||||
live_readiness_service=_LiveReadinessService(),
|
||||
shadow_paper_validation_service=_ShadowPaperValidationService(),
|
||||
)
|
||||
|
||||
with (
|
||||
@@ -57,6 +78,36 @@ class LaunchReadinessServiceTests(unittest.TestCase):
|
||||
self.assertNotIn("real_trade_sample_verified", payload["warning_reasons"])
|
||||
self.assertNotIn("real_trade_sample_verified", payload["external_waiting_items"])
|
||||
self.assertNotIn("wait_for_and_verify_real_fill_sample", payload["next_actions"])
|
||||
self.assertEqual(payload["decision_support_boundary_summary"]["live_execution_boundary"], "live_submit_blocked")
|
||||
self.assertIn("decision_support_promotion_summary", payload)
|
||||
self.assertIn("shadow_paper_validation_summary", payload)
|
||||
|
||||
def test_live_analysis_assistant_ready_when_account_context_unavailable(self) -> None:
|
||||
service = LaunchReadinessService(
|
||||
market_state_service=_MarketStateService(),
|
||||
broker_read_model_service=_BrokerReadService(status="broker_state_unavailable"),
|
||||
weekly_review_service=_WeeklyReviewService(),
|
||||
trust_service=_TrustService(),
|
||||
live_readiness_service=_LiveReadinessService(),
|
||||
shadow_paper_validation_service=_ShadowPaperValidationService(),
|
||||
)
|
||||
|
||||
with patch("src.services.readiness.launch_readiness_service.get_trading_runtime_service", return_value=_RuntimeService()):
|
||||
payload = service.build_summary(instrument_id=1, timeframe="1m")
|
||||
|
||||
self.assertFalse(payload["analysis_only_ready"])
|
||||
self.assertTrue(payload["live_analysis_assistant_ready"])
|
||||
self.assertEqual(payload["product_operating_mode"], "readonly_live_analysis")
|
||||
self.assertEqual(payload["readonly_account_context_status"], "unavailable")
|
||||
self.assertIn("broker_state_ready", payload["blocking_reasons"])
|
||||
self.assertIn("account_context_unavailable", payload["live_analysis_assistant_summary"]["warnings"])
|
||||
self.assertEqual(payload["live_analysis_assistant_summary"]["blockers"], [])
|
||||
self.assertEqual(payload["decision_support_boundary_summary"]["status"], "readonly_live_analysis_available")
|
||||
self.assertIn("review_market_context", payload["decision_support_boundary_summary"]["allowed_operator_actions"])
|
||||
self.assertIn("submit_live_orders", payload["live_analysis_assistant_summary"]["blocked_actions"])
|
||||
self.assertFalse(payload["decision_support_ready"])
|
||||
self.assertFalse(payload["execution_assist_ready"])
|
||||
self.assertFalse(payload["real_live_ready"])
|
||||
|
||||
def test_build_summary_exposes_restricted_soft_rollout_when_only_blacklist_models_exist(self) -> None:
|
||||
service = LaunchReadinessService(
|
||||
@@ -65,6 +116,7 @@ class LaunchReadinessServiceTests(unittest.TestCase):
|
||||
weekly_review_service=_WeeklyReviewService(),
|
||||
trust_service=_BlacklistOnlyTrustService(),
|
||||
live_readiness_service=_LiveReadinessService(),
|
||||
shadow_paper_validation_service=_ShadowPaperValidationService(),
|
||||
)
|
||||
|
||||
with patch("src.services.readiness.launch_readiness_service.get_trading_runtime_service", return_value=_RuntimeService()):
|
||||
@@ -78,6 +130,17 @@ class LaunchReadinessServiceTests(unittest.TestCase):
|
||||
self.assertIn("start_restricted_decision_support_soft_rollout", payload["next_actions"])
|
||||
self.assertIn("execution_assist_remains_disabled", payload["operator_soft_rollout_boundary_notes"])
|
||||
self.assertNotIn("trust_model_available", payload["warning_reasons"])
|
||||
self.assertEqual(payload["decision_support_boundary_summary"]["status"], "restricted_assisted_analysis_available")
|
||||
self.assertEqual(payload["decision_support_boundary_summary"]["current_model_boundary"], "observe_only_current_model")
|
||||
self.assertEqual(
|
||||
payload["decision_support_boundary_summary"]["next_boundary_gate"],
|
||||
"promote_model_out_of_observe_only_or_blacklist",
|
||||
)
|
||||
self.assertEqual(payload["decision_support_promotion_summary"]["status"], "needs_trust_promotion")
|
||||
self.assertIn(
|
||||
"decision_support_trust_candidate",
|
||||
payload["decision_support_promotion_summary"]["failed_criteria"],
|
||||
)
|
||||
|
||||
|
||||
class _MarketState:
|
||||
@@ -100,25 +163,46 @@ class _MarketStateService:
|
||||
|
||||
|
||||
class _BrokerState:
|
||||
status = "broker_state_ok"
|
||||
def __init__(self, status: str = "broker_state_ok") -> None:
|
||||
self.status = status
|
||||
|
||||
def to_dict(self):
|
||||
return {"status": "broker_state_ok"}
|
||||
return {"status": self.status}
|
||||
|
||||
|
||||
class _BrokerReadService:
|
||||
def __init__(self, status: str = "broker_state_ok") -> None:
|
||||
self.status = status
|
||||
|
||||
def fetch_snapshot(self, **kwargs):
|
||||
return _BrokerState()
|
||||
return _BrokerState(self.status)
|
||||
|
||||
|
||||
class _WeeklyReviewService:
|
||||
def build_summary(self, **kwargs):
|
||||
return {"real_trade_count": 0}
|
||||
return {
|
||||
"real_trade_count": 0,
|
||||
"entry_count": 5,
|
||||
"framework_execution_rate": 0.8,
|
||||
"risk_violation_count": 0,
|
||||
}
|
||||
|
||||
|
||||
class _TrustService:
|
||||
def build_summary(self, **kwargs):
|
||||
return {
|
||||
"models": [
|
||||
{
|
||||
"model_code": "OSOK",
|
||||
"classification": "whitelist",
|
||||
"sample_size": 6,
|
||||
"quality_gate_passed": True,
|
||||
"expectancy_r": 0.4,
|
||||
"framework_execution_rate": 0.8,
|
||||
"operator_mode": "trade_inside_framework",
|
||||
"reasons": ["backtest_quality_gate_passed"],
|
||||
}
|
||||
],
|
||||
"whitelist": ["OSOK"],
|
||||
"observe_list": [],
|
||||
"blacklist": [],
|
||||
@@ -132,6 +216,18 @@ class _TrustService:
|
||||
class _BlacklistOnlyTrustService:
|
||||
def build_summary(self, **kwargs):
|
||||
return {
|
||||
"models": [
|
||||
{
|
||||
"model_code": "SWEEP_MSS_FVG",
|
||||
"classification": "blacklist",
|
||||
"sample_size": 1,
|
||||
"quality_gate_passed": False,
|
||||
"expectancy_r": 0.0,
|
||||
"framework_execution_rate": 0.2,
|
||||
"operator_mode": "observe_only",
|
||||
"reasons": ["backtest_quality_gate_failed"],
|
||||
}
|
||||
],
|
||||
"whitelist": [],
|
||||
"observe_list": [],
|
||||
"blacklist": ["SWEEP_MSS_FVG"],
|
||||
@@ -152,5 +248,19 @@ class _RuntimeService:
|
||||
return {"scheduler_state": "running", "current_blockers": []}
|
||||
|
||||
|
||||
class _ShadowPaperValidationService:
|
||||
def build_summary(self, **kwargs):
|
||||
return {
|
||||
"status": "collecting_paper_samples",
|
||||
"next_validation_gate": "paper_ticket_sample_gate",
|
||||
"execution_boundary": "real_live_still_blocked",
|
||||
"candidate_count": 3,
|
||||
"recent_samples": {
|
||||
"candidates": [{"created_ts": "2026-05-18T01:00:00+00:00"}],
|
||||
"shadow_observations": [{"observed_ts": "2026-05-18T01:05:00+00:00"}],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -15,6 +15,7 @@ class LiveReadinessServiceTests(unittest.TestCase):
|
||||
execution_repository=_ExecutionRepo(),
|
||||
decision_packet_service=_DecisionPacketService(),
|
||||
trust_service=_TrustService(),
|
||||
okx_sandbox_preflight_service=_OKXSandboxPreflightService(),
|
||||
)
|
||||
|
||||
with patch("src.services.broker.live_readiness_service.get_trading_runtime_service", return_value=_RuntimeService()):
|
||||
@@ -22,6 +23,8 @@ class LiveReadinessServiceTests(unittest.TestCase):
|
||||
|
||||
self.assertFalse(payload["ready_for_real_live"])
|
||||
self.assertIn("adapter_real_live_not_ready", payload["blocking_reasons"])
|
||||
self.assertIn("okx_sandbox_preflight_summary", payload)
|
||||
self.assertEqual(payload["okx_sandbox_preflight_summary"]["status"], "sandbox_preflight_ready")
|
||||
self.assertEqual(payload["operator_confirmation_protocol"]["protocol_version"], "live-confirmation-v1")
|
||||
self.assertEqual(payload["live_order_schema"]["schema_version"], "live-order-v1")
|
||||
|
||||
@@ -79,5 +82,15 @@ class _RuntimeService:
|
||||
return {"scheduler_state": "running", "current_blockers": []}
|
||||
|
||||
|
||||
class _OKXSandboxPreflightService:
|
||||
def build_summary(self, **kwargs):
|
||||
return {
|
||||
"status": "sandbox_preflight_ready",
|
||||
"dry_run_ready": True,
|
||||
"next_gate": "sandbox_submit_cancel_reconcile_smoke",
|
||||
"execution_boundary": "real_live_still_blocked",
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -96,6 +96,42 @@ class MarketDataStreamServiceTests(unittest.TestCase):
|
||||
self.assertTrue(gap_state["is_gap"])
|
||||
self.assertEqual(gap_state["code"], DATA_GAP_CODE_DETECTED)
|
||||
|
||||
def test_stream_service_falls_back_to_direct_when_proxy_attempt_never_connects(self) -> None:
|
||||
calls = []
|
||||
service = None
|
||||
|
||||
class _FakeWebSocket:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def run_forever(self, **kwargs):
|
||||
calls.append(dict(kwargs))
|
||||
if len(calls) >= 2:
|
||||
service._stop_event.set()
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
service = MarketDataStreamService(
|
||||
url="wss://example.test",
|
||||
proxy_url="http://127.0.0.1:1080",
|
||||
reconnect_backoff_seconds=1,
|
||||
ws_factory=_FakeWebSocket,
|
||||
)
|
||||
service.subscribe(
|
||||
instrument_id=1,
|
||||
symbol="BTC-USDT",
|
||||
inst_id="BTC-USDT-SWAP",
|
||||
timeframe="1m",
|
||||
)
|
||||
|
||||
service._run_loop()
|
||||
|
||||
self.assertEqual(len(calls), 2)
|
||||
self.assertEqual(calls[0]["http_proxy_host"], "127.0.0.1")
|
||||
self.assertEqual(calls[0]["http_proxy_port"], 1080)
|
||||
self.assertNotIn("http_proxy_host", calls[1])
|
||||
|
||||
|
||||
class EconomicCalendarProviderTests(unittest.TestCase):
|
||||
def test_provider_marks_active_high_impact_event(self) -> None:
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import os
|
||||
import unittest
|
||||
from io import BytesIO
|
||||
from datetime import datetime, timezone
|
||||
from urllib.error import HTTPError
|
||||
from unittest.mock import patch
|
||||
|
||||
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test")
|
||||
|
||||
from src.config.settings import settings
|
||||
from src.services.broker.okx_sandbox_preflight_service import (
|
||||
OKXSandboxPreflightService,
|
||||
build_okx_client_order_id,
|
||||
normalize_okx_order_error,
|
||||
)
|
||||
|
||||
|
||||
class OKXSandboxPreflightServiceTests(unittest.TestCase):
|
||||
def test_build_summary_waits_for_sandbox_credentials_without_live_submit(self) -> None:
|
||||
service = OKXSandboxPreflightService(api_key="", api_secret="", api_passphrase="")
|
||||
|
||||
with patch.object(settings, "live_trading_enabled", False):
|
||||
payload = service.build_summary(
|
||||
instrument_id=1,
|
||||
timeframe="1m",
|
||||
now=datetime(2026, 5, 17, 1, 2, 3, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
self.assertEqual(payload["status"], "waiting_for_sandbox_credentials")
|
||||
self.assertFalse(payload["dry_run_ready"])
|
||||
self.assertFalse(payload["sandbox_credentials_present"])
|
||||
self.assertEqual(payload["sandbox_header"], {"x-simulated-trading": "1"})
|
||||
self.assertEqual(payload["next_gate"], "okx_sandbox_credentials")
|
||||
self.assertEqual(payload["execution_boundary"], "real_live_still_blocked")
|
||||
self.assertIn("okx_sandbox_credentials_present", payload["failed_criteria"])
|
||||
self.assertEqual(payload["signed_request_previews"][0]["operation"], "submit_order")
|
||||
self.assertFalse(payload["signed_request_previews"][0]["signed"])
|
||||
|
||||
def test_build_summary_builds_signed_dry_run_request_previews(self) -> None:
|
||||
service = OKXSandboxPreflightService(api_key="key", api_secret="secret", api_passphrase="pass")
|
||||
|
||||
with patch.object(settings, "live_trading_enabled", False):
|
||||
payload = service.build_summary(
|
||||
instrument_id=2,
|
||||
timeframe="5m",
|
||||
now=datetime(2026, 5, 17, 1, 2, 3, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
self.assertEqual(payload["status"], "sandbox_preflight_ready")
|
||||
self.assertTrue(payload["dry_run_ready"])
|
||||
self.assertTrue(payload["submit_dry_run_ready"])
|
||||
self.assertTrue(payload["cancel_dry_run_ready"])
|
||||
self.assertTrue(payload["query_order_dry_run_ready"])
|
||||
self.assertTrue(payload["query_fills_dry_run_ready"])
|
||||
self.assertEqual(payload["okx_inst_id"], "ETH-USDT-SWAP")
|
||||
self.assertEqual(payload["next_gate"], "sandbox_submit_cancel_reconcile_smoke")
|
||||
operations = {item["operation"]: item for item in payload["signed_request_previews"]}
|
||||
self.assertIn("OK-ACCESS-SIGN", operations["submit_order"]["header_names"])
|
||||
self.assertIn("x-simulated-trading", operations["cancel_order"]["header_names"])
|
||||
self.assertTrue(operations["query_order"]["request_path"].startswith("/api/v5/trade/order?"))
|
||||
self.assertEqual(payload["bracket_semantics"]["schema_version"], "bracket-v1")
|
||||
self.assertIn("insufficient_balance", payload["error_normalization"])
|
||||
|
||||
def test_live_switch_enabled_keeps_preflight_in_operator_review(self) -> None:
|
||||
service = OKXSandboxPreflightService(api_key="key", api_secret="secret", api_passphrase="pass")
|
||||
|
||||
with patch.object(settings, "live_trading_enabled", True):
|
||||
payload = service.build_summary(
|
||||
instrument_id=1,
|
||||
timeframe="1m",
|
||||
now=datetime(2026, 5, 17, 1, 2, 3, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
self.assertEqual(payload["status"], "live_switch_enabled_requires_operator_review")
|
||||
self.assertIn("okx_live_submit_still_disabled", payload["failed_criteria"])
|
||||
self.assertEqual(payload["execution_boundary"], "real_live_still_blocked")
|
||||
|
||||
def test_client_order_id_is_bounded_and_deterministic(self) -> None:
|
||||
value = build_okx_client_order_id(
|
||||
instrument_id=12,
|
||||
timeframe="15m",
|
||||
observed_ts=datetime(2026, 5, 17, 1, 2, 3, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
self.assertEqual(value, "AI1215M20260517010203")
|
||||
self.assertLessEqual(len(value), 32)
|
||||
|
||||
def test_normalizes_okx_order_errors(self) -> None:
|
||||
self.assertEqual(
|
||||
normalize_okx_order_error(transport_error="connection refused")["category"],
|
||||
"network_error",
|
||||
)
|
||||
self.assertEqual(
|
||||
normalize_okx_order_error(payload={"code": "51008", "msg": "Insufficient balance"})["category"],
|
||||
"insufficient_balance",
|
||||
)
|
||||
self.assertEqual(
|
||||
normalize_okx_order_error(payload={"code": "51001", "msg": "Parameter error"})["category"],
|
||||
"parameter_error",
|
||||
)
|
||||
self.assertEqual(
|
||||
normalize_okx_order_error(payload={"code": "50120", "msg": "This API key doesn't have permission to use this function"})["category"],
|
||||
"permission_error",
|
||||
)
|
||||
self.assertEqual(
|
||||
normalize_okx_order_error(payload={"code": "51100", "msg": "Rejected"})["category"],
|
||||
"exchange_rejected",
|
||||
)
|
||||
|
||||
def test_isolated_smoke_requires_explicit_execute_flag(self) -> None:
|
||||
service = OKXSandboxPreflightService(api_key="key", api_secret="secret", api_passphrase="pass")
|
||||
calls = []
|
||||
|
||||
def opener(*args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
raise AssertionError("opener should not be called when execute is false")
|
||||
|
||||
with patch.object(settings, "live_trading_enabled", False):
|
||||
payload = service.run_isolated_smoke(
|
||||
instrument_id=1,
|
||||
timeframe="1m",
|
||||
execute=False,
|
||||
now=datetime(2026, 5, 17, 1, 2, 3, tzinfo=timezone.utc),
|
||||
opener=opener,
|
||||
)
|
||||
|
||||
self.assertEqual(payload["status"], "blocked")
|
||||
self.assertEqual(payload["blockers"], ["okx_sandbox_smoke_execute_not_enabled"])
|
||||
self.assertEqual(payload["next_gate"], "enable_explicit_sandbox_smoke_execute")
|
||||
self.assertEqual(payload["steps"], [])
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_isolated_smoke_passes_submit_query_cancel_fills_sequence(self) -> None:
|
||||
service = OKXSandboxPreflightService(api_key="key", api_secret="secret", api_passphrase="pass")
|
||||
seen_operations = []
|
||||
|
||||
def opener(request, timeout_seconds):
|
||||
seen_operations.append((request.get_method(), request.selector, timeout_seconds))
|
||||
if request.selector.startswith("/api/v5/trade/order?"):
|
||||
return _FakeHTTPResponse({"code": "0", "msg": "", "data": [{"instId": "BTC-USDT-SWAP", "ordId": "123", "state": "live"}]})
|
||||
if request.selector == "/api/v5/trade/order":
|
||||
return _FakeHTTPResponse({"code": "0", "msg": "", "data": [{"ordId": "123", "clOrdId": "AI0101M20260517010203", "sCode": "0"}]})
|
||||
if request.selector == "/api/v5/trade/cancel-order":
|
||||
return _FakeHTTPResponse({"code": "0", "msg": "", "data": [{"ordId": "123", "clOrdId": "AI0101M20260517010203", "sCode": "0"}]})
|
||||
if request.selector.startswith("/api/v5/trade/fills?"):
|
||||
return _FakeHTTPResponse({"code": "0", "msg": "", "data": []})
|
||||
raise AssertionError(f"unexpected request {request.get_method()} {request.selector}")
|
||||
|
||||
with patch.object(settings, "live_trading_enabled", False):
|
||||
payload = service.run_isolated_smoke(
|
||||
instrument_id=1,
|
||||
timeframe="1m",
|
||||
execute=True,
|
||||
order_price="1",
|
||||
order_size="1",
|
||||
now=datetime(2026, 5, 17, 1, 2, 3, tzinfo=timezone.utc),
|
||||
timeout_seconds=7,
|
||||
opener=opener,
|
||||
)
|
||||
|
||||
self.assertEqual(payload["status"], "passed")
|
||||
self.assertEqual([item["operation"] for item in payload["steps"]], [
|
||||
"submit_order",
|
||||
"query_order_after_submit",
|
||||
"cancel_order",
|
||||
"query_order_after_cancel",
|
||||
"query_fills",
|
||||
])
|
||||
self.assertEqual([item[2] for item in seen_operations], [7, 7, 7, 7, 7])
|
||||
self.assertTrue(all(item["simulated_trading_header"] == "1" for item in payload["steps"]))
|
||||
self.assertEqual(payload["execution_boundary"], "real_live_still_blocked")
|
||||
self.assertEqual(payload["reconcile_check"]["status"], "ready")
|
||||
|
||||
def test_isolated_smoke_normalizes_http_error(self) -> None:
|
||||
service = OKXSandboxPreflightService(api_key="key", api_secret="secret", api_passphrase="pass")
|
||||
|
||||
def opener(request, timeout_seconds):
|
||||
raise HTTPError(
|
||||
url=request.full_url,
|
||||
code=400,
|
||||
msg="Bad Request",
|
||||
hdrs=None,
|
||||
fp=BytesIO(b'{"code":"51001","msg":"Parameter error"}'),
|
||||
)
|
||||
|
||||
with patch.object(settings, "live_trading_enabled", False):
|
||||
payload = service.run_isolated_smoke(
|
||||
instrument_id=1,
|
||||
timeframe="1m",
|
||||
execute=True,
|
||||
now=datetime(2026, 5, 17, 1, 2, 3, tzinfo=timezone.utc),
|
||||
opener=opener,
|
||||
)
|
||||
|
||||
self.assertEqual(payload["status"], "blocked")
|
||||
self.assertEqual(payload["blockers"], ["okx_sandbox_submit_failed"])
|
||||
self.assertEqual(payload["steps"][0]["http_status"], 400)
|
||||
self.assertEqual(payload["steps"][0]["normalized_error"]["category"], "parameter_error")
|
||||
|
||||
|
||||
class _FakeHTTPResponse:
|
||||
def __init__(self, payload: dict, status: int = 200) -> None:
|
||||
self.payload = payload
|
||||
self.status = status
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> bool:
|
||||
return False
|
||||
|
||||
def read(self) -> bytes:
|
||||
import json
|
||||
|
||||
return json.dumps(self.payload).encode("utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,86 @@
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from scripts import run_okx_sandbox_smoke
|
||||
|
||||
|
||||
class OKXSandboxSmokeScriptTests(unittest.TestCase):
|
||||
def test_main_writes_passed_json_artifact(self) -> None:
|
||||
payload = {
|
||||
"status": "passed",
|
||||
"execute_requested": True,
|
||||
"blockers": [],
|
||||
"steps": [{"operation": "submit_order", "ok": True}],
|
||||
}
|
||||
service = Mock()
|
||||
service.run_isolated_smoke.return_value = payload
|
||||
output = io.StringIO()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
artifact = Path(tmpdir) / "smoke.json"
|
||||
with patch("scripts.run_okx_sandbox_smoke.OKXSandboxPreflightService", return_value=service):
|
||||
with contextlib.redirect_stdout(output):
|
||||
exit_code = run_okx_sandbox_smoke.main(
|
||||
env={
|
||||
"OKX_SANDBOX_SMOKE_OUTPUT_JSON": "1",
|
||||
"OKX_SANDBOX_SMOKE_EXECUTE": "1",
|
||||
"OKX_SANDBOX_SMOKE_INSTRUMENT_ID": "2",
|
||||
"OKX_SANDBOX_SMOKE_TIMEFRAME": "5m",
|
||||
"OKX_SANDBOX_SMOKE_PRICE": "1.2",
|
||||
"OKX_SANDBOX_SMOKE_SIZE": "3",
|
||||
"OKX_SANDBOX_SMOKE_SIDE": "sell",
|
||||
"OKX_SANDBOX_SMOKE_ORDER_TYPE": "limit",
|
||||
"OKX_SANDBOX_SMOKE_TD_MODE": "cross",
|
||||
"OKX_SANDBOX_SMOKE_TIMEOUT_SECONDS": "4.5",
|
||||
"OKX_SANDBOX_SMOKE_ARTIFACT_PATH": str(artifact),
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertEqual(json.loads(output.getvalue())["status"], "passed")
|
||||
self.assertEqual(json.loads(artifact.read_text(encoding="utf-8"))["status"], "passed")
|
||||
service.run_isolated_smoke.assert_called_once_with(
|
||||
instrument_id=2,
|
||||
timeframe="5m",
|
||||
execute=True,
|
||||
order_price="1.2",
|
||||
order_size="3",
|
||||
order_side="sell",
|
||||
order_type="limit",
|
||||
td_mode="cross",
|
||||
timeout_seconds=4.5,
|
||||
)
|
||||
|
||||
def test_main_returns_nonzero_for_blocked_smoke(self) -> None:
|
||||
payload = {
|
||||
"status": "blocked",
|
||||
"execute_requested": False,
|
||||
"blockers": ["okx_sandbox_smoke_execute_not_enabled"],
|
||||
"steps": [],
|
||||
}
|
||||
service = Mock()
|
||||
service.run_isolated_smoke.return_value = payload
|
||||
output = io.StringIO()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
artifact = Path(tmpdir) / "smoke.json"
|
||||
with patch("scripts.run_okx_sandbox_smoke.OKXSandboxPreflightService", return_value=service):
|
||||
with contextlib.redirect_stdout(output):
|
||||
exit_code = run_okx_sandbox_smoke.main(
|
||||
env={
|
||||
"OKX_SANDBOX_SMOKE_ARTIFACT_PATH": str(artifact),
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(exit_code, 2)
|
||||
self.assertIn("okx_sandbox_smoke_execute_not_enabled", output.getvalue())
|
||||
self.assertEqual(json.loads(artifact.read_text(encoding="utf-8"))["status"], "blocked")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -203,11 +203,18 @@ class OperatorWorkspaceServiceTests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(summary["workflow_artifact_summary"]["workflow_archive_bridge_summary"]["status"], "active")
|
||||
self.assertEqual(summary["workflow_artifact_summary"]["workflow_archive_bridge_persistence_summary"]["status"], "persisted")
|
||||
self.assertEqual(
|
||||
summary["workflow_artifact_summary"]["workflow_archive_bridge_persistence_summary"]["compare_writeback_summary"]["note_title"],
|
||||
"Saved compare note",
|
||||
)
|
||||
self.assertIn("long_range_trend_summary", summary["workflow_artifact_summary"]["workflow_history_archive"])
|
||||
self.assertIn("baseline_pin_summary", summary["workflow_artifact_summary"]["workflow_history_archive"])
|
||||
self.assertIn("ranked_aggregation_summary", summary["workflow_artifact_summary"]["workflow_history_archive"])
|
||||
self.assertEqual(summary["workflow_artifact_summary"]["workflow_history_archive"]["long_range_trend_summary"]["status"], "active")
|
||||
self.assertEqual(summary["workflow_artifact_summary"]["workflow_history_archive"]["baseline_pin_summary"]["status"], "insufficient_history")
|
||||
self.assertEqual(summary["workflow_artifact_summary"]["workflow_history_archive"]["baseline_pin_summary"]["persisted_baseline_snapshot_id"], 10)
|
||||
self.assertIn("baseline_delta_story", summary["workflow_artifact_summary"]["workflow_history_archive"]["baseline_pin_summary"])
|
||||
self.assertEqual(summary["workflow_artifact_summary"]["workflow_history_archive"]["ranked_aggregation_summary"]["status"], "active")
|
||||
self.assertIn("daily_groups", summary["workflow_artifact_summary"]["workflow_history_archive"])
|
||||
self.assertIn("session_groups", summary["workflow_artifact_summary"]["workflow_history_archive"])
|
||||
self.assertIn("bridge_summary", summary["workflow_artifact_summary"]["workflow_history_archive"])
|
||||
@@ -396,6 +403,8 @@ class OperatorWorkspaceServiceTests(unittest.TestCase):
|
||||
self.assertIn("digest_history_archive", summary["digest_surface"])
|
||||
self.assertIn("digest_history_archive_count", summary["digest_surface"])
|
||||
self.assertIn("digest_archive_compare", summary["digest_surface"])
|
||||
self.assertIn("archive_operator_overview", summary)
|
||||
self.assertEqual(summary["archive_operator_overview"]["status"], "active")
|
||||
self.assertIn("focus_related_reminders", summary["digest_surface"])
|
||||
self.assertGreaterEqual(summary["digest_surface"]["focus_related_reminder_count"], 1)
|
||||
self.assertIsNotNone(summary["digest_surface"]["next_focus_related_reminder"])
|
||||
@@ -403,9 +412,14 @@ class OperatorWorkspaceServiceTests(unittest.TestCase):
|
||||
self.assertIn("next_digest_history_archive_review", summary["digest_surface"])
|
||||
self.assertIn("long_range_trend_summary", summary["digest_surface"]["digest_history_archive"])
|
||||
self.assertIn("baseline_pin_summary", summary["digest_surface"]["digest_history_archive"])
|
||||
self.assertIn("ranked_aggregation_summary", summary["digest_surface"]["digest_history_archive"])
|
||||
self.assertEqual(summary["digest_surface"]["digest_history_archive"]["long_range_trend_summary"]["status"], "active")
|
||||
self.assertEqual(summary["digest_surface"]["digest_history_archive"]["baseline_pin_summary"]["status"], "insufficient_history")
|
||||
self.assertEqual(summary["digest_surface"]["digest_history_archive"]["baseline_pin_summary"]["persisted_baseline_snapshot_id"], 9)
|
||||
self.assertIn("baseline_delta_story", summary["digest_surface"]["digest_history_archive"]["baseline_pin_summary"])
|
||||
self.assertEqual(summary["digest_surface"]["digest_history_archive"]["ranked_aggregation_summary"]["status"], "active")
|
||||
self.assertIn("digest_baseline_delta_story", summary["archive_operator_overview"])
|
||||
self.assertIn("workflow_baseline_delta_story", summary["archive_operator_overview"])
|
||||
self.assertIn("next_high_value_change", summary["digest_surface"])
|
||||
self.assertIn("digest_review_back_actions", summary["digest_surface"])
|
||||
self.assertIn("digest_review_back_action_count", summary["digest_surface"])
|
||||
@@ -653,14 +667,67 @@ class OperatorWorkspaceServiceTests(unittest.TestCase):
|
||||
self.assertGreaterEqual(summary["external_context_summary"]["provider_health_card_count"], 3)
|
||||
self.assertIn("provider_health_summary", summary["external_context_summary"])
|
||||
self.assertEqual(summary["external_context_summary"]["provider_health_summary"]["status"], "active")
|
||||
self.assertIn("health_story", summary["external_context_summary"]["provider_health_summary"])
|
||||
self.assertIn("visual_evidence_bridge_summary", summary["external_context_summary"])
|
||||
self.assertEqual(summary["external_context_summary"]["visual_evidence_bridge_summary"]["status"], "active")
|
||||
self.assertIn("bridge_story", summary["external_context_summary"]["visual_evidence_bridge_summary"])
|
||||
self.assertIn("capture_surface_cards", summary["external_context_summary"])
|
||||
self.assertGreaterEqual(summary["external_context_summary"]["capture_surface_card_count"], 3)
|
||||
self.assertIn("capture_surface_summary", summary["external_context_summary"])
|
||||
self.assertEqual(summary["external_context_summary"]["capture_surface_summary"]["status"], "active")
|
||||
self.assertIn("capture_story", summary["external_context_summary"]["capture_surface_summary"])
|
||||
self.assertIn("decision_support_boundary_summary", summary)
|
||||
self.assertEqual(summary["decision_support_boundary_summary"]["label"], "Decision support available")
|
||||
self.assertIn("operator_summary", summary["decision_support_boundary_summary"])
|
||||
self.assertIn("live_analysis_assistant_summary", summary)
|
||||
self.assertEqual(summary["live_analysis_assistant_summary"]["status"], "ready_for_readonly_live_analysis")
|
||||
self.assertTrue(summary["evidence_summary"]["live_analysis_assistant_ready"])
|
||||
self.assertTrue(summary["evidence_summary"]["must_not_trade"])
|
||||
self.assertEqual(summary["readonly_live_health_summary"]["status"], "usable")
|
||||
self.assertIn("decision_support_promotion_summary", summary)
|
||||
self.assertEqual(
|
||||
summary["decision_support_promotion_summary"]["status"],
|
||||
"ready_for_decision_support_non_execution",
|
||||
)
|
||||
self.assertEqual(summary["evidence_summary"]["promotion_status"], "ready_for_decision_support_non_execution")
|
||||
self.assertIn("shadow_paper_validation_summary", summary)
|
||||
self.assertEqual(summary["shadow_paper_validation_summary"]["status"], "collecting_paper_samples")
|
||||
self.assertEqual(summary["evidence_summary"]["shadow_paper_status"], "collecting_paper_samples")
|
||||
self.assertEqual(summary["evidence_summary"]["next_shadow_paper_gate"], "paper_ticket_sample_gate")
|
||||
self.assertIn("screenshot_readiness_summary", summary["external_context_summary"])
|
||||
self.assertEqual(summary["external_context_summary"]["screenshot_readiness_summary"]["status"], "active")
|
||||
self.assertIn("screenshot_story", summary["external_context_summary"]["screenshot_readiness_summary"])
|
||||
self.assertIn("surface_selection_history_summary", summary["external_context_summary"])
|
||||
self.assertEqual(summary["external_context_summary"]["surface_selection_history_summary"]["status"], "active")
|
||||
self.assertEqual(summary["external_context_summary"]["surface_selection_history_summary"]["entry_count"], 1)
|
||||
self.assertEqual(summary["external_context_summary"]["surface_selection_history_summary"]["dominant_surface_title"], "binance_public_chart_reference")
|
||||
self.assertEqual(summary["external_context_summary"]["surface_selection_history_summary"]["dominant_source_group"], "visual_reference_card")
|
||||
self.assertIn("recency_status", summary["external_context_summary"]["surface_selection_history_summary"])
|
||||
self.assertIn("revisit_prompt", summary["external_context_summary"]["surface_selection_history_summary"])
|
||||
self.assertIn("cadence_status", summary["external_context_summary"]["surface_selection_history_summary"])
|
||||
self.assertIn("revisit_queue_count", summary["external_context_summary"]["surface_selection_history_summary"])
|
||||
self.assertEqual(summary["external_context_summary"]["surface_selection_history_summary"]["acknowledged_count"], 1)
|
||||
self.assertEqual(summary["external_context_summary"]["surface_selection_history_summary"]["resolution_history_count"], 1)
|
||||
self.assertEqual(summary["external_context_summary"]["surface_selection_history_summary"]["cleared_queue_status"], "active")
|
||||
self.assertEqual(summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"]["note_count"], 1)
|
||||
self.assertEqual(summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"]["acknowledged_by_counts"]["local_operator"], 1)
|
||||
self.assertIn("note_gap_queue_count", summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"])
|
||||
self.assertIn("owner_spotlight", summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"])
|
||||
self.assertIn("owner_cadence_status", summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"])
|
||||
self.assertIn("note_gap_priority_lane_status", summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"])
|
||||
self.assertIn("owner_trend_status", summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"])
|
||||
self.assertIn("note_gap_priority_lane_story", summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"])
|
||||
self.assertIn("owner_handoff_status", summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"])
|
||||
self.assertIn("note_gap_priority_lane_carry_forward_status", summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"])
|
||||
self.assertIn("owner_handoff_follow_up_status", summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"])
|
||||
self.assertIn("note_gap_priority_lane_carry_forward_aging_status", summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"])
|
||||
self.assertIn("resolved_owner_drift_status", summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"])
|
||||
self.assertIn("carry_forward_follow_up_lane_status", summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"])
|
||||
self.assertIn("resolved_owner_drift_review_status", summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"])
|
||||
self.assertIn("resolved_owner_drift_review_queue_status", summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"])
|
||||
self.assertIn("resolved_owner_drift_review_queue_aging_status", summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"])
|
||||
self.assertIn("carry_forward_follow_up_lane_rollover_status", summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"])
|
||||
self.assertIn("carry_forward_follow_up_lane_rollover_handoff_status", summary["external_context_summary"]["surface_selection_history_summary"]["resolution_filter_summary"])
|
||||
self.assertIn("visual_reference_cards", summary["external_context_summary"])
|
||||
self.assertGreaterEqual(summary["external_context_summary"]["visual_reference_card_count"], 3)
|
||||
self.assertIsNotNone(summary["external_context_summary"]["visual_reference_next_prompt"])
|
||||
@@ -775,11 +842,92 @@ class _LaunchReadinessService:
|
||||
"instrument_id": instrument_id,
|
||||
"timeframe": timeframe,
|
||||
"analysis_only_ready": True,
|
||||
"live_analysis_assistant_ready": True,
|
||||
"decision_support_ready": True,
|
||||
"blocking_reasons": [],
|
||||
"warning_reasons": ["trust_observe_mode"],
|
||||
"next_actions": ["review_priority_setup"],
|
||||
"product_operating_mode": "decision_support_only",
|
||||
"readonly_account_context_status": "available",
|
||||
"readonly_live_health_summary": {
|
||||
"status": "usable",
|
||||
"api_health": "available",
|
||||
"runtime_health": "available",
|
||||
"market_freshness": "market_data_fresh",
|
||||
"market_stream": "market_stream_connected",
|
||||
"broker_account_context": "available",
|
||||
"latest_decision_packet_timestamp": "2026-05-18T01:05:00+00:00",
|
||||
"latest_review_packet_timestamp": "2026-05-18T01:05:00+00:00",
|
||||
"current_candidate_count": 3,
|
||||
"current_blocker": "",
|
||||
"recommendation": "can_use_for_live_market_analysis",
|
||||
},
|
||||
"live_analysis_assistant_summary": {
|
||||
"status": "ready_for_readonly_live_analysis",
|
||||
"ready_for_readonly_live_analysis": True,
|
||||
"must_not_trade": True,
|
||||
"execution_allowed": False,
|
||||
"launch_boundary": {
|
||||
"mode_label": "Read-only Live Analysis",
|
||||
"not_buy_sell_instruction": True,
|
||||
"no_order_submission": True,
|
||||
"execution_remains_disabled": True,
|
||||
},
|
||||
"health_summary": {
|
||||
"status": "usable",
|
||||
"market_freshness": "market_data_fresh",
|
||||
"broker_account_context": "available",
|
||||
},
|
||||
"allowed_actions": ["review_market_context", "record_shadow_observation_or_operator_note"],
|
||||
"blocked_actions": ["submit_live_orders", "live_execute"],
|
||||
},
|
||||
"decision_support_boundary_summary": {
|
||||
"status": "decision_support_available",
|
||||
"label": "Decision support available",
|
||||
"operator_summary": "Decision support is available for human review, but execution assist and live submit remain gated.",
|
||||
"current_model_boundary": "decision_support_candidate_available",
|
||||
"current_model_reason": "decision_support_list_present",
|
||||
"live_execution_boundary": "live_submit_blocked",
|
||||
"live_execution_reason": "adapter_real_live_not_ready",
|
||||
"allowed_operator_actions": ["review_market_context", "use_decision_support_for_manual_trade_review"],
|
||||
"blocked_operator_actions": ["submit_live_orders", "enable_default_auto_trading"],
|
||||
"next_boundary_gate": "execution_assist_trust_gate",
|
||||
"next_boundary_gate_reason": "execution_candidate_list_missing",
|
||||
},
|
||||
"decision_support_promotion_summary": {
|
||||
"status": "ready_for_decision_support_non_execution",
|
||||
"strict_decision_support_ready": True,
|
||||
"current_decision_support_ready": True,
|
||||
"execution_assist_ready": False,
|
||||
"execution_boundary_after_promotion": "execution_still_blocked",
|
||||
"real_live_boundary_after_promotion": "real_live_still_blocked",
|
||||
"candidate_model_code": "OSOK",
|
||||
"candidate_classification": "observe",
|
||||
"operator_review_sample_count": 5,
|
||||
"passed_criteria_count": 8,
|
||||
"required_criteria_count": 8,
|
||||
"failed_criteria": [],
|
||||
"next_promotion_gate": "decision_support_ready_can_be_true_without_execution",
|
||||
"operator_summary": "Decision support can be marked ready for human review; execution assist and live submit remain blocked.",
|
||||
},
|
||||
"shadow_paper_validation_summary": {
|
||||
"status": "collecting_paper_samples",
|
||||
"observed_shadow_day_count": 3,
|
||||
"window_days": 14,
|
||||
"candidate_count": 6,
|
||||
"paper_ticket_count": 1,
|
||||
"paper_execution_count": 1,
|
||||
"operator_feedback_count": 2,
|
||||
"journal_entry_count": 1,
|
||||
"risk_violation_count": 0,
|
||||
"passed_criteria_count": 4,
|
||||
"required_criteria_count": 8,
|
||||
"failed_criteria": ["paper_ticket_sample_gate"],
|
||||
"next_validation_gate": "paper_ticket_sample_gate",
|
||||
"execution_boundary": "real_live_still_blocked",
|
||||
"operator_summary": "Shadow/paper validation is collecting paper samples.",
|
||||
"daily_coverage": [],
|
||||
},
|
||||
"market_state": {
|
||||
"session": {"session_code": "LONDON", "status": "active"},
|
||||
"freshness": {"code": "market_data_fresh"},
|
||||
@@ -985,9 +1133,24 @@ class _DecisionAssistantRepo:
|
||||
"handoff_packet_starter": {"headline": "Saved handoff starter"},
|
||||
"replay_note_bridge_summary": {"note_title": "Saved replay note"},
|
||||
"artifact_bundle_summary": {"starter_export_prompt": "Saved artifact bundle"},
|
||||
"compare_writeback_summary": {"note_title": "Saved compare note", "status": "persisted"},
|
||||
"query_context": {"profile_scope": "selected_profile", "query_preset": "intraday_recent"},
|
||||
"updated_ts": "2026-05-12T02:04:30+00:00",
|
||||
},
|
||||
"external_context_surface_history": [
|
||||
{
|
||||
"surface_id": "binance_public_chart_reference",
|
||||
"title": "binance_public_chart_reference",
|
||||
"source_group": "visual_reference_card",
|
||||
"selection_kind": "target_url",
|
||||
"target_url": "https://www.binance.com/en/trade/BTC_USDT?type=spot",
|
||||
"selected_ts": "2026-05-12T02:05:30+00:00",
|
||||
"revisit_acknowledged": True,
|
||||
"revisit_acknowledged_ts": "2026-05-12T02:15:30+00:00",
|
||||
"revisit_acknowledged_by": "local_operator",
|
||||
"revisit_ack_note": "checked and cleared",
|
||||
}
|
||||
],
|
||||
},
|
||||
"updated_ts": "2026-05-12T02:05:00+00:00",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from scripts import run_readonly_live_analysis_smoke
|
||||
|
||||
|
||||
class ReadonlyLiveAnalysisSmokeScriptTests(unittest.TestCase):
|
||||
def test_main_writes_passed_json_artifact_without_trade_operations(self) -> None:
|
||||
output = io.StringIO()
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
artifact = Path(tmpdir) / "readonly-smoke.json"
|
||||
opener = _FakeOpener(
|
||||
{
|
||||
"/health": {"app": "ai-ict", "database_configured": True},
|
||||
"/trading/runtime-status": {"scheduler_state": "running", "current_blockers": []},
|
||||
"/trading/launch-readiness?instrument_id=1&timeframe=1m": {
|
||||
"live_analysis_assistant_ready": True,
|
||||
"execution_assist_ready": False,
|
||||
"real_live_ready": False,
|
||||
"live_analysis_assistant_summary": {
|
||||
"ready_for_readonly_live_analysis": True,
|
||||
"must_not_trade": True,
|
||||
},
|
||||
},
|
||||
"/trading/review-packet?instrument_id=1": {"status": "blocked", "action_recommendation": "observe_only"},
|
||||
"/trading/shadow-paper-validation?instrument_id=1&timeframe=1m": {
|
||||
"status": "collecting_paper_samples",
|
||||
"execution_boundary": "real_live_still_blocked",
|
||||
},
|
||||
"/trading/research/weekly-review?instrument_id=1": {
|
||||
"entry_count": 0,
|
||||
"next_week_actions": ["prepare_shadow_paper_weekly_review"],
|
||||
},
|
||||
"/trading/candidates?instrument_id=1&limit=5": {"items": []},
|
||||
"/static/app.js": (
|
||||
"Read-only Live Analysis No order submission Not a buy/sell instruction "
|
||||
"Execution remains disabled ready_for_readonly_live_analysis must_not_trade"
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
original_urlopen = run_readonly_live_analysis_smoke._urlopen_no_proxy
|
||||
run_readonly_live_analysis_smoke._urlopen_no_proxy = opener
|
||||
try:
|
||||
with contextlib.redirect_stdout(output):
|
||||
exit_code = run_readonly_live_analysis_smoke.main(
|
||||
argv=[
|
||||
"--base-url",
|
||||
"http://example.test",
|
||||
"--output-json",
|
||||
"--artifact-path",
|
||||
str(artifact),
|
||||
],
|
||||
env={},
|
||||
)
|
||||
finally:
|
||||
run_readonly_live_analysis_smoke._urlopen_no_proxy = original_urlopen
|
||||
artifact_payload = json.loads(artifact.read_text(encoding="utf-8"))
|
||||
|
||||
payload = json.loads(output.getvalue())
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertEqual(payload["status"], "passed")
|
||||
self.assertTrue(payload["ready_for_readonly_live_analysis"])
|
||||
self.assertTrue(payload["must_not_trade"])
|
||||
self.assertFalse(payload["submit_cancel_or_live_execution_called"])
|
||||
self.assertFalse(payload["trade_permission_required"])
|
||||
self.assertEqual(artifact_payload["status"], "passed")
|
||||
|
||||
def test_run_reports_blocker_when_launch_boundary_allows_execution(self) -> None:
|
||||
payload = run_readonly_live_analysis_smoke.run_readonly_live_analysis_smoke(
|
||||
base_url="http://example.test",
|
||||
attempts=1,
|
||||
sleep_seconds=0,
|
||||
opener=_FakeOpener(
|
||||
{
|
||||
"/health": {"app": "ai-ict"},
|
||||
"/trading/runtime-status": {"scheduler_state": "running"},
|
||||
"/trading/launch-readiness?instrument_id=1&timeframe=1m": {
|
||||
"live_analysis_assistant_ready": True,
|
||||
"execution_assist_ready": True,
|
||||
"real_live_ready": False,
|
||||
"live_analysis_assistant_summary": {
|
||||
"ready_for_readonly_live_analysis": True,
|
||||
"must_not_trade": True,
|
||||
},
|
||||
},
|
||||
"/trading/review-packet?instrument_id=1": {"status": "blocked"},
|
||||
"/trading/shadow-paper-validation?instrument_id=1&timeframe=1m": {
|
||||
"status": "collecting_paper_samples",
|
||||
"execution_boundary": "real_live_still_blocked",
|
||||
},
|
||||
"/trading/research/weekly-review?instrument_id=1": {
|
||||
"entry_count": 0,
|
||||
"next_week_actions": ["prepare_shadow_paper_weekly_review"],
|
||||
},
|
||||
"/trading/candidates?instrument_id=1&limit=5": {"items": []},
|
||||
"/static/app.js": (
|
||||
"Read-only Live Analysis No order submission Not a buy/sell instruction "
|
||||
"Execution remains disabled ready_for_readonly_live_analysis must_not_trade"
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(payload["status"], "blocked")
|
||||
self.assertIn("launch_readiness", payload["blockers"])
|
||||
|
||||
|
||||
class _FakeOpener:
|
||||
def __init__(self, payloads: dict) -> None:
|
||||
self.payloads = payloads
|
||||
|
||||
def __call__(self, request, timeout_seconds: float):
|
||||
path = request.full_url.replace("http://example.test", "")
|
||||
if path not in self.payloads:
|
||||
raise RuntimeError(f"missing fake response for {path}")
|
||||
payload = self.payloads[path]
|
||||
if isinstance(payload, str):
|
||||
raw = payload.encode("utf-8")
|
||||
else:
|
||||
raw = json.dumps(payload).encode("utf-8")
|
||||
return _FakeResponse(raw)
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
status = 200
|
||||
|
||||
def __init__(self, raw: bytes) -> None:
|
||||
self.raw = raw
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, traceback):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return self.raw
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -9,6 +9,7 @@ from tests.unit.test_trading_runtime_service import (
|
||||
FakeMarketStateService,
|
||||
FakeReconciliationService,
|
||||
FakeResearchCouncilService,
|
||||
FakeShadowPaperSampleService,
|
||||
FakeSetupWatcherService,
|
||||
FakeTradingPlanService,
|
||||
)
|
||||
@@ -25,6 +26,7 @@ class ResearchCouncilRuntimeIntegrationTests(unittest.TestCase):
|
||||
decision_packet_service=FakeDecisionPacketService(),
|
||||
alert_dispatcher_service=FakeAlertDispatcherService(),
|
||||
research_council_service=research_council_service,
|
||||
shadow_paper_sample_service=FakeShadowPaperSampleService(),
|
||||
)
|
||||
|
||||
def test_council_task_runs_after_watch_before_decision_packet(self) -> None:
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import unittest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from scripts import run_shadow_paper_observation_backfill
|
||||
|
||||
|
||||
class RunShadowPaperObservationBackfillTests(unittest.TestCase):
|
||||
def test_main_outputs_json_and_defaults_to_dry_run(self) -> None:
|
||||
service = Mock()
|
||||
service.build_backfill_audit.return_value = {
|
||||
"status": "dry_run",
|
||||
"eligible_backfill_dates": ["2026-05-15"],
|
||||
"created_observation_count": 0,
|
||||
"projected_observed_shadow_day_count": 4,
|
||||
"blocked_backfill_items": ["paper_ticket_backfill_not_allowed_without_executable_candidate"],
|
||||
}
|
||||
output = io.StringIO()
|
||||
|
||||
with patch("scripts.run_shadow_paper_observation_backfill.ShadowPaperObservationBackfillService", return_value=service):
|
||||
with contextlib.redirect_stdout(output):
|
||||
exit_code = run_shadow_paper_observation_backfill.main(
|
||||
env={
|
||||
"SHADOW_PAPER_BACKFILL_OUTPUT_JSON": "1",
|
||||
"SHADOW_PAPER_BACKFILL_INSTRUMENT_ID": "2",
|
||||
"SHADOW_PAPER_BACKFILL_TIMEFRAME": "5m",
|
||||
"SHADOW_PAPER_BACKFILL_WINDOW_DAYS": "7",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertEqual(json.loads(output.getvalue())["status"], "dry_run")
|
||||
service.build_backfill_audit.assert_called_once_with(
|
||||
instrument_id=2,
|
||||
timeframe="5m",
|
||||
window_days=7,
|
||||
apply=False,
|
||||
created_by="shadow_paper_backfill_audit",
|
||||
)
|
||||
|
||||
def test_main_honors_apply_flag(self) -> None:
|
||||
service = Mock()
|
||||
service.build_backfill_audit.return_value = {
|
||||
"status": "applied",
|
||||
"eligible_backfill_dates": ["2026-05-15"],
|
||||
"created_observation_count": 1,
|
||||
"projected_observed_shadow_day_count": 5,
|
||||
"blocked_backfill_items": [],
|
||||
}
|
||||
output = io.StringIO()
|
||||
|
||||
with patch("scripts.run_shadow_paper_observation_backfill.ShadowPaperObservationBackfillService", return_value=service):
|
||||
with contextlib.redirect_stdout(output):
|
||||
exit_code = run_shadow_paper_observation_backfill.main(
|
||||
env={
|
||||
"SHADOW_PAPER_BACKFILL_APPLY": "1",
|
||||
"SHADOW_PAPER_BACKFILL_CREATED_BY": "operator",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertIn("applied", output.getvalue())
|
||||
service.build_backfill_audit.assert_called_once_with(
|
||||
instrument_id=1,
|
||||
timeframe="1m",
|
||||
window_days=14,
|
||||
apply=True,
|
||||
created_by="operator",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,123 @@
|
||||
import unittest
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from src.services.readiness.shadow_paper_observation_backfill_service import ShadowPaperObservationBackfillService
|
||||
|
||||
|
||||
class ShadowPaperObservationBackfillServiceTests(unittest.TestCase):
|
||||
def test_dry_run_finds_candidate_days_without_writing(self) -> None:
|
||||
repository = _BackfillRepository()
|
||||
service = ShadowPaperObservationBackfillService(repository=repository)
|
||||
|
||||
payload = service.build_backfill_audit(
|
||||
instrument_id=1,
|
||||
timeframe="1m",
|
||||
now=datetime(2026, 5, 18, 12, 0, tzinfo=timezone.utc),
|
||||
apply=False,
|
||||
)
|
||||
|
||||
self.assertEqual(payload["status"], "dry_run")
|
||||
self.assertEqual(payload["eligible_backfill_dates"], ["2026-05-15", "2026-05-16"])
|
||||
self.assertEqual(payload["created_observation_count"], 0)
|
||||
self.assertEqual(repository.insert_calls, [])
|
||||
self.assertFalse(payload["paper_backfill_allowed"])
|
||||
self.assertFalse(payload["operator_feedback_backfill_allowed"])
|
||||
self.assertIn("paper_ticket_backfill_not_allowed_without_executable_candidate", payload["blocked_backfill_items"])
|
||||
|
||||
def test_apply_creates_only_shadow_observations(self) -> None:
|
||||
repository = _BackfillRepository()
|
||||
service = ShadowPaperObservationBackfillService(repository=repository)
|
||||
|
||||
payload = service.build_backfill_audit(
|
||||
instrument_id=1,
|
||||
timeframe="1m",
|
||||
now=datetime(2026, 5, 18, 12, 0, tzinfo=timezone.utc),
|
||||
apply=True,
|
||||
created_by="test_backfill",
|
||||
)
|
||||
|
||||
self.assertEqual(payload["status"], "applied")
|
||||
self.assertEqual(payload["created_observation_count"], 2)
|
||||
self.assertEqual([call["observation_date"].isoformat() for call in repository.insert_calls], ["2026-05-15", "2026-05-16"])
|
||||
self.assertTrue(all(call["candidate"]["status"] == "blocked" for call in repository.insert_calls))
|
||||
self.assertTrue(all(call["created_by"] == "test_backfill" for call in repository.insert_calls))
|
||||
self.assertTrue(all(row["paper_ticket_id"] is None for row in payload["created_observations"]))
|
||||
self.assertTrue(all(row["paper_execution_id"] is None for row in payload["created_observations"]))
|
||||
self.assertTrue(all(row["operator_agreement"] == "pending" for row in payload["created_observations"]))
|
||||
|
||||
def test_no_candidate_days_has_no_eligible_backfill(self) -> None:
|
||||
repository = _BackfillRepository(candidate_rows=[])
|
||||
service = ShadowPaperObservationBackfillService(repository=repository)
|
||||
|
||||
payload = service.build_backfill_audit(
|
||||
instrument_id=1,
|
||||
timeframe="1m",
|
||||
now=datetime(2026, 5, 18, 12, 0, tzinfo=timezone.utc),
|
||||
apply=True,
|
||||
)
|
||||
|
||||
self.assertEqual(payload["eligible_backfill_dates"], [])
|
||||
self.assertEqual(payload["created_observation_count"], 0)
|
||||
self.assertEqual(repository.insert_calls, [])
|
||||
|
||||
|
||||
class _BackfillRepository:
|
||||
def __init__(self, *, candidate_rows=None) -> None:
|
||||
self.candidate_rows = candidate_rows
|
||||
self.insert_calls = []
|
||||
|
||||
def fetch_candidate_day_summaries(self, **kwargs):
|
||||
if self.candidate_rows is not None:
|
||||
return self.candidate_rows
|
||||
return [
|
||||
{"day": date(2026, 5, 15), "status": "blocked", "count": 3},
|
||||
{"day": date(2026, 5, 16), "status": "blocked", "count": 4},
|
||||
{"day": date(2026, 5, 17), "status": "blocked", "count": 5},
|
||||
]
|
||||
|
||||
def fetch_observation_day_summaries(self, **kwargs):
|
||||
return [{"day": date(2026, 5, 17), "count": 1, "feedback_count": 1}]
|
||||
|
||||
def fetch_latest_candidate_for_day(self, **kwargs):
|
||||
return {
|
||||
"id": int(kwargs["day_start"].strftime("%d")),
|
||||
"instrument_id": kwargs["instrument_id"],
|
||||
"model_code": "SWEEP_MSS_FVG",
|
||||
"status": "blocked",
|
||||
"session_code": "ALL",
|
||||
"created_ts": kwargs["day_start"],
|
||||
"quality_score": 0.5,
|
||||
}
|
||||
|
||||
def fetch_latest_decision_packet_for_day(self, **kwargs):
|
||||
return {
|
||||
"id": int(kwargs["day_start"].strftime("%d")) + 100,
|
||||
"instrument_id": kwargs["instrument_id"],
|
||||
"candidate_id": kwargs["candidate_id"],
|
||||
"status": "blocked",
|
||||
"action_recommendation": "stand_down",
|
||||
"packet_ts": kwargs["day_start"],
|
||||
}
|
||||
|
||||
def insert_shadow_observation(self, **kwargs):
|
||||
self.insert_calls.append(kwargs)
|
||||
return {
|
||||
"id": len(self.insert_calls),
|
||||
"instrument_id": kwargs["instrument_id"],
|
||||
"observation_date": kwargs["observation_date"],
|
||||
"timeframe": kwargs["timeframe"],
|
||||
"session_code": kwargs["session_code"],
|
||||
"candidate_id": kwargs["candidate"]["id"],
|
||||
"decision_packet_id": kwargs["decision_packet"]["id"],
|
||||
"paper_ticket_id": None,
|
||||
"paper_execution_id": None,
|
||||
"observation_status": "candidate_observed",
|
||||
"system_recommendation": "stand_down_blocked_candidate",
|
||||
"operator_agreement": "pending",
|
||||
"reason_codes": ["shadow_observation_backfill"],
|
||||
"observed_ts": kwargs["candidate"]["created_ts"],
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,106 @@
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from src.services.readiness.shadow_paper_sample_collection_service import ShadowPaperSampleCollectionService
|
||||
|
||||
|
||||
class ShadowPaperSampleCollectionServiceTests(unittest.TestCase):
|
||||
def test_records_daily_observation_from_latest_candidate_and_packet(self) -> None:
|
||||
now = datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc)
|
||||
repository = _ObservationRepository()
|
||||
service = ShadowPaperSampleCollectionService(repository=repository)
|
||||
|
||||
payload = service.record_daily_observation(
|
||||
instrument_id=1,
|
||||
timeframe="1m",
|
||||
operator_agreement="agree",
|
||||
operator_note="aligned with operator review",
|
||||
created_by="alice",
|
||||
runtime_status={"current_blockers": []},
|
||||
now=now,
|
||||
)
|
||||
|
||||
self.assertEqual(payload["status"], "recorded")
|
||||
self.assertEqual(payload["observation_status"], "operator_feedback_recorded")
|
||||
self.assertEqual(payload["system_recommendation"], "wait_for_operator_confirmation")
|
||||
self.assertEqual(payload["operator_agreement"], "agree")
|
||||
self.assertEqual(payload["candidate_id"], 7)
|
||||
self.assertEqual(payload["decision_packet_id"], 9)
|
||||
self.assertIn("keep_daily_shadow_observation_running", payload["next_collection_actions"])
|
||||
self.assertIn("build_shadow_paper_ticket_for_latest_candidate", payload["next_collection_actions"])
|
||||
self.assertEqual(repository.upsert_call["created_by"], "alice")
|
||||
self.assertEqual(repository.upsert_call["reason_codes"][0], "operator_feedback_recorded")
|
||||
|
||||
def test_runtime_blocker_records_blocked_observation_without_sample_credit_action(self) -> None:
|
||||
now = datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc)
|
||||
service = ShadowPaperSampleCollectionService(repository=_ObservationRepository())
|
||||
|
||||
payload = service.record_daily_observation(
|
||||
instrument_id=1,
|
||||
runtime_status={"current_blockers": ["broker_sync_degraded:BTC-USDT"]},
|
||||
now=now,
|
||||
)
|
||||
|
||||
self.assertEqual(payload["observation_status"], "runtime_blocked")
|
||||
self.assertEqual(payload["system_recommendation"], "pause_runtime_blocked")
|
||||
self.assertEqual(payload["next_collection_actions"], ["clear_runtime_blockers_before_counting_shadow_day"])
|
||||
|
||||
def test_blocked_candidate_waits_before_paper_ticket_action(self) -> None:
|
||||
now = datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc)
|
||||
service = ShadowPaperSampleCollectionService(repository=_ObservationRepository(candidate_status="blocked"))
|
||||
|
||||
payload = service.record_daily_observation(
|
||||
instrument_id=1,
|
||||
runtime_status={"current_blockers": []},
|
||||
now=now,
|
||||
)
|
||||
|
||||
self.assertIn("wait_for_executable_candidate_before_paper_ticket", payload["next_collection_actions"])
|
||||
self.assertNotIn("build_shadow_paper_ticket_for_latest_candidate", payload["next_collection_actions"])
|
||||
|
||||
|
||||
class _ObservationRepository:
|
||||
def __init__(self, *, candidate_status: str = "executable") -> None:
|
||||
self.upsert_call = None
|
||||
self.candidate_status = candidate_status
|
||||
|
||||
def fetch_latest_candidate(self, **kwargs):
|
||||
return {
|
||||
"id": 7,
|
||||
"model_code": "SWEEP_MSS_FVG",
|
||||
"status": self.candidate_status,
|
||||
"session_code": "NY_AM",
|
||||
"confirmation_state": {"reason_codes": ["mss_confirmed"]},
|
||||
"created_ts": kwargs["since"],
|
||||
}
|
||||
|
||||
def fetch_latest_decision_packet(self, **kwargs):
|
||||
return {
|
||||
"id": 9,
|
||||
"status": "actionable",
|
||||
"action_recommendation": "confirm_ticket",
|
||||
"payload": {"decision": {"reason_codes": ["candidate_actionable"]}},
|
||||
"packet_ts": kwargs["since"],
|
||||
}
|
||||
|
||||
def upsert_daily_observation(self, **kwargs):
|
||||
self.upsert_call = kwargs
|
||||
return {
|
||||
"id": 12,
|
||||
"observation_date": kwargs["observation_date"],
|
||||
"timeframe": kwargs["timeframe"],
|
||||
"session_code": kwargs["session_code"],
|
||||
"observation_status": kwargs["observation_status"],
|
||||
"system_recommendation": kwargs["system_recommendation"],
|
||||
"operator_agreement": kwargs["operator_agreement"],
|
||||
"candidate_id": kwargs["candidate_id"],
|
||||
"decision_packet_id": kwargs["decision_packet_id"],
|
||||
"paper_ticket_id": kwargs["paper_ticket_id"],
|
||||
"paper_execution_id": kwargs["paper_execution_id"],
|
||||
"reason_codes": kwargs["reason_codes"],
|
||||
"observed_ts": kwargs["observed_ts"],
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,146 @@
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from src.services.readiness.shadow_paper_validation_service import ShadowPaperValidationService
|
||||
|
||||
|
||||
class ShadowPaperValidationServiceTests(unittest.TestCase):
|
||||
def test_build_summary_collects_shadow_and_paper_validation_gates(self) -> None:
|
||||
now = datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc)
|
||||
service = ShadowPaperValidationService(repository=_SparseRepository(now))
|
||||
|
||||
summary = service.build_summary(
|
||||
instrument_id=1,
|
||||
timeframe="1m",
|
||||
runtime_status={"current_blockers": []},
|
||||
weekly_review={"entry_count": 1, "risk_violation_count": 0, "framework_execution_rate": 0.8},
|
||||
now=now,
|
||||
)
|
||||
|
||||
self.assertEqual(summary["status"], "collecting_shadow_candidates")
|
||||
self.assertEqual(summary["candidate_count"], 3)
|
||||
self.assertEqual(summary["paper_ticket_count"], 1)
|
||||
self.assertEqual(summary["paper_execution_count"], 1)
|
||||
self.assertEqual(summary["operator_feedback_count"], 1)
|
||||
self.assertIn("shadow_candidate_day_coverage", summary["failed_criteria"])
|
||||
self.assertEqual(summary["next_validation_gate"], "shadow_candidate_day_coverage")
|
||||
self.assertEqual(summary["execution_boundary"], "real_live_still_blocked")
|
||||
self.assertEqual(len(summary["daily_coverage"]), 14)
|
||||
|
||||
def test_build_summary_marks_ready_when_two_week_gates_are_met(self) -> None:
|
||||
now = datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc)
|
||||
service = ShadowPaperValidationService(repository=_ReadyRepository(now))
|
||||
|
||||
summary = service.build_summary(
|
||||
instrument_id=1,
|
||||
runtime_status={"current_blockers": []},
|
||||
weekly_review={"entry_count": 5, "risk_violation_count": 0, "framework_execution_rate": 0.9},
|
||||
now=now,
|
||||
)
|
||||
|
||||
self.assertEqual(summary["status"], "ready_for_shadow_paper_review")
|
||||
self.assertEqual(summary["failed_criteria"], [])
|
||||
self.assertEqual(summary["next_validation_gate"], "shadow_paper_review_ready")
|
||||
self.assertGreaterEqual(summary["observed_shadow_day_count"], 10)
|
||||
self.assertGreaterEqual(summary["paper_execution_count"], 5)
|
||||
|
||||
def test_runtime_blocker_prevents_shadow_validation(self) -> None:
|
||||
now = datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc)
|
||||
service = ShadowPaperValidationService(repository=_ReadyRepository(now))
|
||||
|
||||
summary = service.build_summary(
|
||||
instrument_id=1,
|
||||
runtime_status={"current_blockers": ["market_stream_degraded"]},
|
||||
weekly_review={"entry_count": 5, "risk_violation_count": 0},
|
||||
now=now,
|
||||
)
|
||||
|
||||
self.assertEqual(summary["status"], "blocked_by_runtime")
|
||||
self.assertEqual(summary["next_validation_gate"], "runtime_clear_for_shadow")
|
||||
|
||||
def test_daily_observations_count_toward_shadow_coverage_and_operator_feedback(self) -> None:
|
||||
now = datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc)
|
||||
service = ShadowPaperValidationService(repository=_ObservationOnlyRepository(now))
|
||||
|
||||
summary = service.build_summary(
|
||||
instrument_id=1,
|
||||
runtime_status={"current_blockers": []},
|
||||
weekly_review={"entry_count": 1, "risk_violation_count": 0},
|
||||
now=now,
|
||||
)
|
||||
|
||||
self.assertEqual(summary["shadow_observation_count"], 4)
|
||||
self.assertEqual(summary["shadow_observation_day_count"], 4)
|
||||
self.assertEqual(summary["observed_shadow_day_count"], 4)
|
||||
self.assertEqual(summary["operator_observation_feedback_count"], 1)
|
||||
self.assertEqual(summary["operator_feedback_count"], 1)
|
||||
self.assertEqual(summary["daily_coverage"][-1]["shadow_observation_count"], 1)
|
||||
self.assertEqual(summary["daily_coverage"][-1]["operator_feedback_count"], 1)
|
||||
|
||||
|
||||
class _SparseRepository:
|
||||
def __init__(self, now: datetime) -> None:
|
||||
self.now = now
|
||||
|
||||
def fetch_recent_candidates(self, **kwargs):
|
||||
return [_row(index=i, created_ts=self.now - timedelta(days=i)) for i in range(3)]
|
||||
|
||||
def fetch_recent_tickets(self, **kwargs):
|
||||
return [_row(id=10, execution_mode="paper", created_ts=self.now - timedelta(days=1))]
|
||||
|
||||
def fetch_recent_executions(self, **kwargs):
|
||||
return [_row(id=20, execution_mode="paper", opened_ts=self.now - timedelta(days=1))]
|
||||
|
||||
def fetch_recent_journal_entries(self, **kwargs):
|
||||
return [_row(id=30, journal_ts=self.now - timedelta(days=1), journal_date=(self.now - timedelta(days=1)).date())]
|
||||
|
||||
def fetch_recent_operator_feedback(self, **kwargs):
|
||||
return [_row(id=40, subject_type="alert", label="useful", created_ts=self.now - timedelta(days=1))]
|
||||
|
||||
|
||||
class _ReadyRepository(_SparseRepository):
|
||||
def fetch_recent_candidates(self, **kwargs):
|
||||
return [_row(id=i, created_ts=self.now - timedelta(days=i % 10)) for i in range(12)]
|
||||
|
||||
def fetch_recent_tickets(self, **kwargs):
|
||||
return [_row(id=100 + i, execution_mode="paper", created_ts=self.now - timedelta(days=i)) for i in range(5)]
|
||||
|
||||
def fetch_recent_executions(self, **kwargs):
|
||||
return [_row(id=200 + i, execution_mode="paper", opened_ts=self.now - timedelta(days=i)) for i in range(5)]
|
||||
|
||||
def fetch_recent_operator_feedback(self, **kwargs):
|
||||
return [_row(id=400 + i, subject_type="alert", label="useful", created_ts=self.now - timedelta(days=i)) for i in range(5)]
|
||||
|
||||
|
||||
class _ObservationOnlyRepository(_SparseRepository):
|
||||
def fetch_recent_candidates(self, **kwargs):
|
||||
return []
|
||||
|
||||
def fetch_recent_tickets(self, **kwargs):
|
||||
return []
|
||||
|
||||
def fetch_recent_executions(self, **kwargs):
|
||||
return []
|
||||
|
||||
def fetch_recent_operator_feedback(self, **kwargs):
|
||||
return []
|
||||
|
||||
def fetch_recent_observations(self, **kwargs):
|
||||
return [
|
||||
_row(
|
||||
id=i,
|
||||
observation_date=(self.now - timedelta(days=i)).date(),
|
||||
observed_ts=self.now - timedelta(days=i),
|
||||
observation_status="candidate_observed",
|
||||
operator_agreement="agree" if i == 0 else "pending",
|
||||
)
|
||||
for i in range(4)
|
||||
]
|
||||
|
||||
|
||||
def _row(**kwargs):
|
||||
return dict(kwargs)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -203,10 +203,60 @@ class TradePlanCandidateApiTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ValueError, "candidate_model_not_enabled_for_execution"):
|
||||
build_ticket_from_candidate(candidate_id=77, execution_mode="paper")
|
||||
|
||||
def test_build_ticket_from_candidate_allows_explicit_shadow_paper_sample(self) -> None:
|
||||
candidate_row = {
|
||||
"id": 77,
|
||||
"instrument_id": 1,
|
||||
"trading_plan_id": 10,
|
||||
"model_code": "SWEEP_MSS_FVG",
|
||||
"status": "executable",
|
||||
"entry": {"entry_type": "limit", "entry_low": 100.0, "entry_high": 100.2, "stop_loss": 99.0, "tp1": 101.0, "tp2": 102.0},
|
||||
"confirmation_state": {},
|
||||
"quality_score": 50.0,
|
||||
"ai_explanation": "observe_only",
|
||||
}
|
||||
with unittest.mock.patch("src.api.trading_runtime.TradePlanCandidateRepository.fetch_candidate_by_id", return_value=candidate_row), unittest.mock.patch(
|
||||
"src.api.trading_runtime.normalize_trade_plan_candidate_row",
|
||||
return_value={"id": 77, "instrument_id": 1, "model_code": "SWEEP_MSS_FVG", "executable": True},
|
||||
), unittest.mock.patch(
|
||||
"src.api.trading_runtime.ModelTrustService.build_summary",
|
||||
return_value={"models": [{"model_code": "SWEEP_MSS_FVG", "execution_allowed": False}]},
|
||||
), unittest.mock.patch(
|
||||
"src.api.trading_runtime.ensure_trade_signal_for_candidate",
|
||||
return_value=88,
|
||||
), unittest.mock.patch(
|
||||
"src.api.trading_runtime.build_execution_service",
|
||||
return_value=_ShadowPaperExecutionService(),
|
||||
):
|
||||
payload = build_ticket_from_candidate(candidate_id=77, execution_mode="paper", allow_shadow_paper=True)
|
||||
|
||||
self.assertEqual(payload["ticket_id"], 501)
|
||||
self.assertEqual(payload["execution_mode"], "paper")
|
||||
self.assertEqual(payload["shadow_paper_validation"]["status"], "paper_only_shadow_allowed")
|
||||
self.assertFalse(payload["shadow_paper_validation"]["trust_execution_allowed"])
|
||||
|
||||
|
||||
_DEFAULT = object()
|
||||
|
||||
|
||||
class _ShadowPaperExecutionDecision:
|
||||
def to_dict(self):
|
||||
return {
|
||||
"ticket_id": 501,
|
||||
"status": "awaiting_confirmation",
|
||||
"execution_mode": "paper",
|
||||
"signal_id": 88,
|
||||
"execution_id": None,
|
||||
"reasons": [],
|
||||
"ticket_snapshot": {},
|
||||
}
|
||||
|
||||
|
||||
class _ShadowPaperExecutionService:
|
||||
def create_execution_ticket(self, **kwargs):
|
||||
return _ShadowPaperExecutionDecision()
|
||||
|
||||
|
||||
class _SetupWatcherStubRepository:
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -194,6 +194,23 @@ class FakeResearchCouncilService:
|
||||
}
|
||||
|
||||
|
||||
class FakeShadowPaperSampleService:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def record_daily_observation(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
return {
|
||||
"status": "recorded",
|
||||
"observation_id": 701,
|
||||
"observation_date": kwargs["now"].date().isoformat(),
|
||||
"observation_status": "candidate_observed",
|
||||
"system_recommendation": "continue_shadow_tracking",
|
||||
"operator_agreement": "pending",
|
||||
"next_collection_actions": ["keep_daily_shadow_observation_running"],
|
||||
}
|
||||
|
||||
|
||||
class TradingRuntimeSchedulerTests(unittest.TestCase):
|
||||
def test_run_cycle_records_successful_task_state(self) -> None:
|
||||
scheduler = TradingRuntimeScheduler(
|
||||
@@ -223,6 +240,7 @@ class TradingRuntimeSchedulerTests(unittest.TestCase):
|
||||
),
|
||||
visual_capture_retention_service=FakeVisualCaptureRetentionService(),
|
||||
research_council_service=FakeResearchCouncilService(),
|
||||
shadow_paper_sample_service=FakeShadowPaperSampleService(),
|
||||
)
|
||||
with patch("src.services.runtime.trading_runtime_service.settings.trading_runtime_symbols", "BTC-USDT"):
|
||||
payload = scheduler.run_cycle()
|
||||
@@ -239,6 +257,7 @@ class TradingRuntimeSchedulerTests(unittest.TestCase):
|
||||
self.assertTrue(payload["tasks"]["decision_packet"]["details"]["visual_watch_reserved"])
|
||||
self.assertEqual(payload["tasks"]["decision_packet"]["details"]["visual_trigger_policy_statuses"], ["triggered"])
|
||||
self.assertEqual(payload["tasks"]["decision_packet"]["details"]["visual_triggered_alert_count"], 1)
|
||||
self.assertEqual(payload["tasks"]["shadow_paper_observation"]["details"]["observation_status"], "candidate_observed")
|
||||
self.assertEqual(payload["tasks"]["visual_watch"]["details"]["visual_reviews_created"], 1)
|
||||
self.assertEqual(payload["tasks"]["visual_watch"]["details"]["last_visual_state"], "confirm")
|
||||
self.assertEqual(payload["tasks"]["visual_watch"]["details"]["trigger_type_counts"]["candidate_actionable_watch"], 1)
|
||||
@@ -264,6 +283,7 @@ class TradingRuntimeSchedulerTests(unittest.TestCase):
|
||||
visual_review_service=FakeVisualReviewService(),
|
||||
visual_capture_retention_service=FakeVisualCaptureRetentionService(),
|
||||
research_council_service=FakeResearchCouncilService(),
|
||||
shadow_paper_sample_service=FakeShadowPaperSampleService(),
|
||||
)
|
||||
with patch("src.services.runtime.trading_runtime_service.settings.trading_runtime_symbols", "BTC-USDT"):
|
||||
payload = scheduler.run_cycle()
|
||||
@@ -290,6 +310,7 @@ class TradingRuntimeSchedulerTests(unittest.TestCase):
|
||||
visual_review_service=FakeVisualReviewService(),
|
||||
visual_capture_retention_service=FakeVisualCaptureRetentionService(),
|
||||
research_council_service=FakeResearchCouncilService(),
|
||||
shadow_paper_sample_service=FakeShadowPaperSampleService(),
|
||||
)
|
||||
supervisor = TradingRuntimeSupervisor(scheduler=scheduler)
|
||||
with patch("src.services.runtime.trading_runtime_service.settings.trading_runtime_symbols", "BTC-USDT"):
|
||||
@@ -310,6 +331,7 @@ class TradingRuntimeSchedulerTests(unittest.TestCase):
|
||||
visual_review_service=FakeVisualReviewService(),
|
||||
visual_capture_retention_service=FakeVisualCaptureRetentionService(),
|
||||
research_council_service=FakeResearchCouncilService(),
|
||||
shadow_paper_sample_service=FakeShadowPaperSampleService(),
|
||||
runtime_task_watchdog_service=RuntimeTaskWatchdogService(stale_multiplier=1, min_stuck_seconds=1),
|
||||
)
|
||||
stale_ts = (datetime.now(timezone.utc) - timedelta(seconds=30)).isoformat()
|
||||
|
||||
Reference in New Issue
Block a user