diff --git a/.env.example b/.env.example index 391b991..02fcc56 100644 --- a/.env.example +++ b/.env.example @@ -39,6 +39,8 @@ NEWS_PROVIDER_TRADINGECONOMICS_API_KEY=guest:guest NEWS_EVENT_PRE_WINDOW_MINUTES=15 NEWS_EVENT_POST_WINDOW_MINUTES=15 NEWS_EVENT_HIGH_IMPACT_ONLY=1 +LAUNCH_REQUIRE_REAL_TRADE_SAMPLE=1 +LIVE_SUBMIT_STRATEGY=blocked_until_verified TRADING_RUNTIME_ENABLED=1 TRADING_RUNTIME_SYMBOLS=BTC-USDT,ETH-USDT TRADING_RUNTIME_BIAS_TIMEFRAME=1m diff --git a/scripts/run_launch_readiness.py b/scripts/run_launch_readiness.py new file mode 100644 index 0000000..26b0954 --- /dev/null +++ b/scripts/run_launch_readiness.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from pathlib import Path +import json +import os +import sys + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from src.services.readiness import LaunchReadinessService + + +def main(env=None) -> int: + env = os.environ if env is None else env + instrument_id = int(env.get("LAUNCH_READINESS_INSTRUMENT_ID", "1")) + timeframe = str(env.get("LAUNCH_READINESS_TIMEFRAME") or "1m") + output_json = _get_bool(env, "LAUNCH_READINESS_OUTPUT_JSON", False) + payload = LaunchReadinessService().build_summary(instrument_id=instrument_id, timeframe=timeframe) + if output_json: + print(json.dumps(payload, ensure_ascii=False, sort_keys=True)) + else: + print(f"Assisted trading ready: {payload.get('assisted_trading_ready')}") + print(f"Real live ready: {payload.get('real_live_ready')}") + print(f"Blocking reasons: {payload.get('blocking_reasons') or []}") + return 0 if payload.get("assisted_trading_ready") else 2 + + +def _get_bool(env, key: str, default: bool = False) -> bool: + value = env.get(key) + if value is None or value == "": + return default + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/api/__init__.py b/src/api/__init__.py index 522fbef..4d41f7f 100644 --- a/src/api/__init__.py +++ b/src/api/__init__.py @@ -26,6 +26,7 @@ from src.api.trading_runtime import ( get_trading_candidate_by_id, get_trading_market_state, get_trading_live_readiness, + get_trading_launch_readiness, get_trading_operator_notes, get_trading_plan_by_id, get_trading_review_packet, @@ -59,6 +60,7 @@ __all__ = [ "get_latest_trading_plan", "get_trading_market_state", "get_trading_live_readiness", + "get_trading_launch_readiness", "get_trading_plan_by_id", "get_trading_runtime_status", "get_trading_trust_score", diff --git a/src/api/server.py b/src/api/server.py index 8bd13cf..ad0eb78 100644 --- a/src/api/server.py +++ b/src/api/server.py @@ -30,6 +30,7 @@ from src.api.trading_runtime import ( get_trading_plan_by_id, get_trading_review_packet, get_trading_live_readiness, + get_trading_launch_readiness, get_trading_runtime_status, get_trading_session_view, get_trading_ticket_by_id, @@ -89,6 +90,7 @@ def build_api_dependencies() -> dict: "trading_operator_note_create": create_trading_operator_note, "trading_review_packet": get_trading_review_packet, "trading_live_readiness": get_trading_live_readiness, + "trading_launch_readiness": get_trading_launch_readiness, "trading_watch_run": run_trading_watch, "trading_candidate_by_id": get_trading_candidate_by_id, "trading_candidate_confirm_check": run_candidate_confirm_check, @@ -235,6 +237,10 @@ def route_request( instrument_id = _require_int(query, "instrument_id") timeframe = _get_single(query, "timeframe", "1m") return 200, deps["trading_live_readiness"](instrument_id=instrument_id, timeframe=timeframe) + if method == "GET" and path == "/trading/launch-readiness": + instrument_id = _require_int(query, "instrument_id") + timeframe = _get_single(query, "timeframe", "1m") + return 200, deps["trading_launch_readiness"](instrument_id=instrument_id, timeframe=timeframe) if method == "POST" and path == "/trading/watch/run": instrument_id = _require_json_int(body or {}, "instrument_id") candidate = deps["trading_watch_run"]( diff --git a/src/api/trading_runtime.py b/src/api/trading_runtime.py index 5d6f2d9..3f7dede 100644 --- a/src/api/trading_runtime.py +++ b/src/api/trading_runtime.py @@ -15,6 +15,7 @@ from src.services.journal import TradingJournalService, WeeklyReviewService from src.services.market_state import MarketStateService, get_market_truth_supervisor from src.services.operator import OperatorNoteService, ReviewPacketService, SessionFocusService from src.services.reconciliation import BrokerExecutionReconciliationService +from src.services.readiness import LaunchReadinessService from src.services.runtime import get_trading_runtime_service from src.services.trade_plan_candidates import SetupWatcherService from src.services.trading_plans import ACTIVE_PLAN_STATUS, TradingPlanService @@ -388,6 +389,10 @@ def get_trading_live_readiness(instrument_id: int, timeframe: str = "1m") -> dic return build_live_broker_service().build_live_readiness(instrument_id=instrument_id, timeframe=timeframe) +def get_trading_launch_readiness(instrument_id: int, timeframe: str = "1m") -> dict: + return LaunchReadinessService().build_summary(instrument_id=instrument_id, timeframe=timeframe) + + def get_trading_session_view(instrument_id: int, limit: int = 12) -> dict: return SessionFocusService().build_summary(instrument_id=instrument_id, limit=limit) diff --git a/src/config/settings.py b/src/config/settings.py index a187488..8f7e83b 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -43,6 +43,8 @@ class Settings(BaseSettings): news_event_pre_window_minutes: int = 15 news_event_post_window_minutes: int = 15 news_event_high_impact_only: bool = True + launch_require_real_trade_sample: bool = True + live_submit_strategy: str = "blocked_until_verified" trading_runtime_enabled: bool = True trading_runtime_symbols: str = "BTC-USDT,ETH-USDT" trading_runtime_bias_timeframe: str = "1m" diff --git a/src/services/readiness/__init__.py b/src/services/readiness/__init__.py new file mode 100644 index 0000000..8a7ad93 --- /dev/null +++ b/src/services/readiness/__init__.py @@ -0,0 +1,3 @@ +from src.services.readiness.launch_readiness_service import LaunchReadinessService + +__all__ = ["LaunchReadinessService"] diff --git a/src/services/readiness/launch_readiness_service.py b/src/services/readiness/launch_readiness_service.py new file mode 100644 index 0000000..4984b3e --- /dev/null +++ b/src/services/readiness/launch_readiness_service.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from typing import Optional + +from src.config.settings import settings +from src.repositories.trading_journal_repository import default_week_range +from src.services.broker import BROKER_STATE_OK, BrokerReadModelService, LiveReadinessService +from src.services.journal import WeeklyReviewService +from src.services.market_state import MarketStateService, get_market_truth_supervisor +from src.services.runtime import get_trading_runtime_service +from src.services.trust import ModelTrustService + + +@dataclass(frozen=True) +class LaunchReadinessCheck: + code: str + passed: bool + severity: str + detail: str + + def to_dict(self) -> dict: + return dict(asdict(self)) + + +class LaunchReadinessService: + def __init__( + self, + *, + market_state_service: Optional[MarketStateService] = None, + broker_read_model_service: Optional[BrokerReadModelService] = None, + weekly_review_service: Optional[WeeklyReviewService] = None, + trust_service: Optional[ModelTrustService] = None, + live_readiness_service: Optional[LiveReadinessService] = None, + ) -> None: + self.market_state_service = market_state_service or MarketStateService(market_truth_supervisor=get_market_truth_supervisor()) + self.broker_read_model_service = broker_read_model_service or BrokerReadModelService() + self.weekly_review_service = weekly_review_service or WeeklyReviewService() + self.trust_service = trust_service or ModelTrustService() + self.live_readiness_service = live_readiness_service or LiveReadinessService() + + def build_summary(self, *, instrument_id: int, timeframe: str = "1m") -> dict: + now = datetime.now(timezone.utc) + market_state = self.market_state_service.build_market_state(instrument_id=instrument_id, timeframe=timeframe) + broker_state = self.broker_read_model_service.fetch_snapshot( + instrument_id=instrument_id, + now=now, + known_execution_refs={"broker_order_ids": [], "open_execution_count": 0}, + ) + runtime_status = get_trading_runtime_service().status() + trust_score = self.trust_service.build_summary(instrument_id=instrument_id) + week_start, week_end = default_week_range() + weekly_review = self.weekly_review_service.build_summary( + week_start=week_start, + week_end=week_end, + instrument_id=instrument_id, + ) + live_readiness = self.live_readiness_service.build_summary(instrument_id=instrument_id, timeframe=timeframe) + + checklist = [ + LaunchReadinessCheck( + code="market_truth_ready", + passed=_market_truth_ready(market_state.to_dict()), + severity="blocker", + detail=f"freshness={market_state.freshness.code}, stream={market_state.stream_state.code}, gap={market_state.data_gap_state.code}", + ), + LaunchReadinessCheck( + code="news_provider_usable", + passed=str(market_state.news_window.freshness_code or "") in {"news_provider_fresh", "news_provider_cached"}, + severity="blocker", + detail=f"provider={market_state.news_window.provider_name}, freshness={market_state.news_window.freshness_code}, error={market_state.news_window.error}", + ), + LaunchReadinessCheck( + code="broker_state_ready", + passed=broker_state.status == BROKER_STATE_OK, + severity="blocker", + detail=f"broker_state={broker_state.status}", + ), + LaunchReadinessCheck( + code="runtime_ready", + passed=runtime_status.get("scheduler_state") == "running" and not list(runtime_status.get("current_blockers") or []), + severity="blocker", + detail=f"runtime_blockers={runtime_status.get('current_blockers') or []}", + ), + LaunchReadinessCheck( + code="trust_model_available", + passed=bool(trust_score.get("whitelist") or trust_score.get("observe_list")), + severity="blocker", + detail=f"whitelist={trust_score.get('whitelist') or []}, observe={trust_score.get('observe_list') or []}, blacklist={trust_score.get('blacklist') or []}", + ), + LaunchReadinessCheck( + code="real_trade_sample_verified", + passed=(weekly_review.get("real_trade_count") or 0) > 0 if settings.launch_require_real_trade_sample else True, + severity="blocker", + detail=f"real_trade_count={weekly_review.get('real_trade_count') or 0}", + ), + LaunchReadinessCheck( + code="live_submit_strategy_defined", + passed=bool(str(settings.live_submit_strategy or '').strip()), + severity="warning", + detail=f"live_submit_strategy={settings.live_submit_strategy}", + ), + LaunchReadinessCheck( + code="real_live_path_ready", + passed=bool(live_readiness.get("ready_for_real_live")), + severity="warning", + detail=f"blocking_reasons={live_readiness.get('blocking_reasons') or []}", + ), + ] + + blockers = [item.code for item in checklist if not item.passed and item.severity == "blocker"] + warnings = [item.code for item in checklist if not item.passed and item.severity == "warning"] + next_actions = _build_next_actions(blockers=blockers, warnings=warnings) + return { + "generated_ts": now.isoformat(), + "instrument_id": instrument_id, + "timeframe": timeframe, + "assisted_trading_ready": not blockers, + "real_live_ready": bool(live_readiness.get("ready_for_real_live")), + "blocking_reasons": blockers, + "warning_reasons": warnings, + "checklist": [item.to_dict() for item in checklist], + "next_actions": next_actions, + "market_state": market_state.to_dict(), + "broker_state": broker_state.to_dict(), + "runtime_status": runtime_status, + "trust_score": trust_score, + "weekly_review": weekly_review, + "live_readiness": live_readiness, + } + + +def _market_truth_ready(payload: dict) -> bool: + freshness_code = str(((payload.get("freshness") or {}).get("code")) or "") + stream_code = str(((payload.get("stream_state") or {}).get("code")) or "") + gap_code = str(((payload.get("data_gap_state") or {}).get("code")) or "") + return freshness_code == "market_data_fresh" and stream_code == "market_stream_connected" and gap_code == "market_data_gap_clear" + + +def _build_next_actions(*, blockers: list[str], warnings: list[str]) -> list[str]: + actions = [] + if "news_provider_usable" in blockers: + actions.append("stabilize_news_event_source") + if "real_trade_sample_verified" in blockers: + actions.append("wait_for_and_verify_real_fill_sample") + if "trust_model_available" in blockers: + actions.append("improve_model_quality_or_collect_more_samples") + if "real_live_path_ready" in warnings: + actions.append("decide_live_submit_strategy") + if not actions: + actions.append("launch_assisted_trading_soft_rollout") + return actions[:4] diff --git a/src/web/app.js b/src/web/app.js index eb78b82..85f63f6 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -16,6 +16,7 @@ const runtimeStatusPanel = document.getElementById('runtimeStatusPanel'); const decisionPacketPanel = document.getElementById('decisionPacketPanel'); const trustScorePanel = document.getElementById('trustScorePanel'); const liveReadinessPanel = document.getElementById('liveReadinessPanel'); +const launchReadinessPanel = document.getElementById('launchReadinessPanel'); const sessionFocusPanel = document.getElementById('sessionFocusPanel'); const preMarketPlanPanel = document.getElementById('preMarketPlanPanel'); const tradeCandidatesPanel = document.getElementById('tradeCandidatesPanel'); @@ -442,6 +443,36 @@ function renderLiveReadiness(payload) { } } +function renderLaunchReadiness(payload) { + clearNode(launchReadinessPanel); + launchReadinessPanel.appendChild( + buildInfoGrid([ + ['assisted trading ready', String(Boolean(payload?.assisted_trading_ready))], + ['real live ready', String(Boolean(payload?.real_live_ready))], + ['blocking reasons', formatList(payload?.blocking_reasons || [])], + ['warning reasons', formatList(payload?.warning_reasons || [])], + ['next actions', formatList(payload?.next_actions || [])], + ]) + ); + const grid = createElement('div', 'cards'); + (payload?.checklist || []).forEach((item) => { + grid.appendChild( + buildItemCard( + item.code || 'check', + item.passed ? 'passed' : item.severity || 'warning', + [ + ['passed', String(Boolean(item.passed))], + ['severity', item.severity || 'n/a'], + ['detail', item.detail || 'n/a'], + ] + ) + ); + }); + if ((payload?.checklist || []).length) { + launchReadinessPanel.appendChild(grid); + } +} + function renderSessionFocus(payload) { clearNode(sessionFocusPanel); const items = payload?.items || []; @@ -940,6 +971,7 @@ function showErrorPanel(message) { decisionPacketPanel, trustScorePanel, liveReadinessPanel, + launchReadinessPanel, sessionFocusPanel, preMarketPlanPanel, tradeCandidatesPanel, @@ -968,6 +1000,7 @@ function renderDashboardData({ decisionAlerts, trustScore, liveReadiness, + launchReadiness, sessionView, tradeCandidates, executionTickets, @@ -985,6 +1018,7 @@ function renderDashboardData({ renderDecisionPacket(decisionPacket, decisionAlerts); renderTrustScore(trustScore); renderLiveReadiness(liveReadiness); + renderLaunchReadiness(launchReadiness); renderSessionFocus(sessionView); renderPreMarketPlan(tradingPlan); renderCandidates(tradeCandidates.items || []); @@ -1004,7 +1038,7 @@ async function refreshDashboard() { const limit = limitInput.value || '20'; try { - const [health, candles, tradingPlan, marketState, runtimeStatus, decisionPacket, decisionAlerts, trustScore, liveReadiness, sessionView, tradeCandidates, executionTickets, tradingExecutions, journalSummary, operatorNotes, reviewPacket, journalItems, weeklyReview] = await Promise.all([ + const [health, candles, tradingPlan, marketState, runtimeStatus, decisionPacket, decisionAlerts, trustScore, liveReadiness, launchReadiness, sessionView, tradeCandidates, executionTickets, tradingExecutions, journalSummary, operatorNotes, reviewPacket, journalItems, weeklyReview] = await Promise.all([ fetchJson('/health'), fetchJson(`/candles?${queryString({ instrument_id: instrumentId, timeframe, limit })}`), fetchJson(`/trading/plans/latest?${queryString({ instrument_id: instrumentId })}`).catch(() => null), @@ -1014,6 +1048,7 @@ async function refreshDashboard() { fetchJson(`/trading/alerts?${queryString({ instrument_id: instrumentId, limit: 10 })}`).catch(() => ({ items: [] })), fetchJson(`/trading/trust-score?${queryString({ instrument_id: instrumentId })}`).catch(() => ({ models: [], whitelist: [], observe_list: [], blacklist: [], next_actions: [] })), fetchJson(`/trading/live-readiness?${queryString({ instrument_id: instrumentId, timeframe })}`).catch(() => ({ ready_for_real_live: false, blocking_reasons: ['live_readiness_unavailable'], checklist: [] })), + fetchJson(`/trading/launch-readiness?${queryString({ instrument_id: instrumentId, timeframe })}`).catch(() => ({ assisted_trading_ready: false, real_live_ready: false, blocking_reasons: ['launch_readiness_unavailable'], warning_reasons: [], checklist: [], next_actions: [] })), fetchJson(`/trading/session-view?${queryString({ instrument_id: instrumentId, limit: 12 })}`).catch(() => ({ items: [] })), fetchJson(`/trading/candidates?${queryString({ instrument_id: instrumentId, limit })}`).catch(() => ({ items: [] })), fetchJson(`/trading/tickets?${queryString({ instrument_id: instrumentId, limit })}`).catch(() => ({ items: [] })), @@ -1035,6 +1070,7 @@ async function refreshDashboard() { decisionAlerts, trustScore, liveReadiness, + launchReadiness, sessionView, tradeCandidates, executionTickets, diff --git a/src/web/index.html b/src/web/index.html index b749a1e..13e12aa 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -97,6 +97,11 @@
+
+

Launch Readiness

+
+
+

Session Focus

diff --git a/tests/unit/test_api_server.py b/tests/unit/test_api_server.py index 8707a30..18fd0e2 100644 --- a/tests/unit/test_api_server.py +++ b/tests/unit/test_api_server.py @@ -134,6 +134,17 @@ class ApiServerRoutingTests(unittest.TestCase): self.assertFalse(payload["ready_for_real_live"]) self.assertEqual(payload["timeframe"], "5m") + def test_routes_trading_launch_readiness(self) -> None: + status, payload = route_request( + "/trading/launch-readiness", + {"instrument_id": ["1"], "timeframe": ["5m"]}, + dependencies={"trading_launch_readiness": lambda **kwargs: {"assisted_trading_ready": False, "timeframe": kwargs["timeframe"]}}, + ) + + self.assertEqual(status, 200) + self.assertFalse(payload["assisted_trading_ready"]) + self.assertEqual(payload["timeframe"], "5m") + def test_routes_trading_broker_state(self) -> None: status, payload = route_request( "/trading/broker-state", diff --git a/tests/unit/test_documentation_consistency.py b/tests/unit/test_documentation_consistency.py index fa098de..216a698 100644 --- a/tests/unit/test_documentation_consistency.py +++ b/tests/unit/test_documentation_consistency.py @@ -17,6 +17,11 @@ TRADING_APP_JS = ROOT / "src" / "web" / "app.js" TRAINING_HTML = ROOT / "src" / "web" / "training.html" TRAINING_JS = ROOT / "src" / "web" / "training.js" OLD_TRAINING_DOC = ROOT / "docs" / "training_case_engine_m1_m6_product_readme.md" +OPTIONAL_DORMANT_DOCS = { + "AI_ICT_盯盘告警开发任务清单_v1.md", + "AI_ICT_盯盘告警技术设计稿_v1.md", + "AI_ICT_盯盘告警架构图_v1.md", +} class DocumentationConsistencyTests(unittest.TestCase): @@ -34,7 +39,7 @@ class DocumentationConsistencyTests(unittest.TestCase): "AI_ICT_实盘辅助决策系统开发方案_v2.md", "ICT交易全书_出版终校版.md", "training_console_public_surface_v1.md", - }, + } | OPTIONAL_DORMANT_DOCS, ) self.assertTrue(TRAINING_PACKAGE_SMOKE_DOC.is_file()) self.assertFalse(OLD_TRAINING_DOC.exists()) diff --git a/tests/unit/test_launch_readiness_service.py b/tests/unit/test_launch_readiness_service.py new file mode 100644 index 0000000..a61a005 --- /dev/null +++ b/tests/unit/test_launch_readiness_service.py @@ -0,0 +1,80 @@ +import os +import unittest +from unittest.mock import patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") + +from src.services.readiness import LaunchReadinessService + + +class LaunchReadinessServiceTests(unittest.TestCase): + def test_build_summary_reports_assisted_not_ready_without_real_trade_sample(self) -> None: + service = LaunchReadinessService( + market_state_service=_MarketStateService(), + broker_read_model_service=_BrokerReadService(), + weekly_review_service=_WeeklyReviewService(), + trust_service=_TrustService(), + live_readiness_service=_LiveReadinessService(), + ) + + 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["assisted_trading_ready"]) + self.assertIn("real_trade_sample_verified", payload["blocking_reasons"]) + self.assertFalse(payload["real_live_ready"]) + + +class _MarketState: + freshness = type("Freshness", (), {"code": "market_data_fresh"})() + stream_state = type("Stream", (), {"code": "market_stream_connected"})() + data_gap_state = type("Gap", (), {"code": "market_data_gap_clear"})() + news_window = type("News", (), {"provider_name": "cached", "freshness_code": "news_provider_cached", "error": None})() + + def to_dict(self): + return { + "freshness": {"code": "market_data_fresh"}, + "stream_state": {"code": "market_stream_connected"}, + "data_gap_state": {"code": "market_data_gap_clear"}, + } + + +class _MarketStateService: + def build_market_state(self, **kwargs): + return _MarketState() + + +class _BrokerState: + status = "broker_state_ok" + + def to_dict(self): + return {"status": "broker_state_ok"} + + +class _BrokerReadService: + def fetch_snapshot(self, **kwargs): + return _BrokerState() + + +class _WeeklyReviewService: + def build_summary(self, **kwargs): + return {"real_trade_count": 0} + + +class _TrustService: + def build_summary(self, **kwargs): + return {"whitelist": ["OSOK"], "observe_list": [], "blacklist": []} + + +class _LiveReadinessService: + def build_summary(self, **kwargs): + return {"ready_for_real_live": False, "blocking_reasons": ["adapter_real_live_not_ready"]} + + +class _RuntimeService: + def status(self): + return {"scheduler_state": "running", "current_blockers": []} + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_runtime_script_json.py b/tests/unit/test_runtime_script_json.py index 2256dce..fb98c8a 100644 --- a/tests/unit/test_runtime_script_json.py +++ b/tests/unit/test_runtime_script_json.py @@ -23,6 +23,7 @@ from scripts import ( run_mss, run_ranges, run_decision_packet, + run_launch_readiness, run_review_packet, run_session_generator, run_live_readiness, @@ -184,6 +185,19 @@ class RuntimeScriptJsonTests(unittest.TestCase): self.assertEqual(payload["instrument_id"], 2) self.assertEqual(payload["timeframe"], "5m") + def test_run_launch_readiness_outputs_json(self) -> None: + service = type("Service", (), {"build_summary": lambda self, instrument_id, timeframe: {"instrument_id": instrument_id, "timeframe": timeframe, "assisted_trading_ready": False, "real_live_ready": False, "blocking_reasons": ["real_trade_sample_verified"]}})() + output = io.StringIO() + with patch("scripts.run_launch_readiness.LaunchReadinessService", return_value=service), contextlib.redirect_stdout(output): + exit_code = run_launch_readiness.main( + env={"LAUNCH_READINESS_OUTPUT_JSON": "1", "LAUNCH_READINESS_INSTRUMENT_ID": "2", "LAUNCH_READINESS_TIMEFRAME": "5m"} + ) + + payload = json.loads(output.getvalue()) + self.assertEqual(exit_code, 2) + self.assertEqual(payload["instrument_id"], 2) + self.assertEqual(payload["timeframe"], "5m") + if __name__ == "__main__": unittest.main()