diff --git a/SESSION.md b/SESSION.md index c9fc40e..e738d32 100644 --- a/SESSION.md +++ b/SESSION.md @@ -832,11 +832,12 @@ Automation execution-layer PXX remains closed and its known scope stays frozen. - Existing-UI i18n/R2 gate polish follow-up passed on 2026-05-20 without adding product features: expanded the existing Chinese translation map for live-readiness, launch-readiness, shadow/paper validation, runtime states, OKX sandbox preflight labels, and Trader Mode first-screen wording. Remote browser audit on `/trader-mode?audit=20260520k#trader-mode` confirmed `lang=zh-CN`, no `为什么不交易不交易`, no `下一步观察观察候选`, no `操作员反馈 count`, and no old English labels such as `Auto refresh`, `All Trust`, `Incident Journal`, `Maintenance Rollup`, `Plan Context`, `Model Trust`, `Live Readiness`, or `Launch Readiness`; the remaining English-token count in the active Trader Mode view is `13`, mostly `English`, `ai-ict`, timezone/profile names, strategy codes, and feedback enum values. Trader Mode R2 evidence display now follows `shadow_paper_validation_summary.failed_criteria`, so the evidence window no longer says operator feedback is complete while the canonical shadow validation gate still requires more samples. Current live evidence is still collecting: `operator_feedback_count=3/5`, `paper_ticket_count=0`, `paper_execution_count=0`, and `weekly_review_entry_count=0`. Validation passed focused i18n/operator/static tests, secret static checks, script static checks, full unit discovery (`790` tests), deploy smoke over 11 endpoints, `run_remote_ops_smoke.py`, `run_readonly_live_analysis_smoke.py`, and gstack browser console check with no errors. Evidence screenshot saved at `output/playwright/i18n-existing-ui-polish-zh-followup-1366x768.png`. No change was made to `execution_allowed`, `decision_support_ready`, `real_live_ready`, risk governor, trust bucket, or operator confirmation protocol. - Session Focus Chinese dynamic-label follow-up passed on 2026-05-20 without adding product features: fixed the existing i18n layer for dynamic session-focus cards so session names and lower-case backend labels now render in Chinese (`ASIA`, `LONDON`, `NY_AM`, `NY_LUNCH`, `NY_PM`, `status`, `window`, `candidates`, `executable`, `open executions`, `alerts`, `recommendation`, `action hint`, `incomplete`, `closed`, `observe`, `avoid`, `review_alerts`, `avoid_new_risk`). Remote browser audit on `/trader-mode?i18n=20260520-session#session-focus` confirmed `lang=zh-CN`, `active=session-focus`, no English leftovers from the reported list, and visible Chinese replacements such as `亚洲盘`, `纽约下午盘`, `状态`, `时间窗口`, `候选数`, `可执行数`, `未平仓执行`, `提醒数`, `建议`, `动作提示`, `观察 @ 无`, `回避 @ 30.0`, `回避新增风险`, and `复核提醒`; console errors were empty. Validation passed `node --check src/web/i18n.js`, focused static/operator/read-only tests, secret static checks, script static checks, full unit discovery (`790` tests), deploy smoke over 11 endpoints, `run_remote_ops_smoke.py`, and `run_readonly_live_analysis_smoke.py`. Evidence screenshot saved at `output/playwright/i18n-session-focus-zh-1366x768.png`. No change was made to `execution_allowed`, `decision_support_ready`, `real_live_ready`, risk governor, trust bucket, or operator confirmation protocol. - S2 ICT canonical taxonomy/rulebook registry is implemented locally: added `src/services/ict_mainline/taxonomy.py`, `docs/AI_ICT_ICT主线概念字典_S2_2026-05-21_v1.md`, and `ict_rulebook_mapping` on Trader Cockpit, Strategy Backtest, Trading Plan, and Journal outputs. The registry binds trader-facing analysis to the book chain `Range / Draw / Location -> Liquidity / Sweep / Inducement / Path -> Displacement / MSS / FVG / Retest / Session -> backtest evidence -> pre-market plan -> intraday unique focus or no-trade reason -> journal / study-notes feedback`, while preserving read-only/no-order boundaries. -- Current assisted-analysis next gate: S2 taxonomy is the active rulebook for subsequent work. Proceed to S3/S4 only: make Trading Plan a hard upstream gate for `Review Now`, then deepen Strategy Backtest so quality-gate failure blocks weak setups from live focus. Do not restart external-context/archive/digest long-tail work, do not physically delete S1 deprecated surfaces yet, and keep R2 sample collection honest. +- S3/S4 Review Now mainline gate is implemented locally: added `src/services/ict_mainline/gates.py`, `docs/AI_ICT_ReviewNow主线Gate_S3S4_2026-05-21_v1.md`, `mainline_focus_gate` on Decision Assistant / Trader Cockpit, and `cockpit_promotion_gate` on Strategy Backtest. `Review Now` now requires active Trading Plan + primary draw + invalidation + no blocking plan condition + passed Strategy Backtest quality gate + candidate link to the active plan; otherwise candidates are downgraded to watch / observe and Trader Cockpit remains `Observe Only` or lower. +- Current assisted-analysis next gate: proceed to S5/S6 only: compress Trader Cockpit language into the ICT chain sentence and map operator feedback / Journal / Study Notes to book-rule error classes. Do not restart external-context/archive/digest long-tail work, do not physically delete S1 deprecated surfaces yet, and keep R2 sample collection honest. ## Exact Next Step -Use `docs/AI_ICT_实盘辅助分析决策助手ICT主线开发方案_2026-05-21_v1.md`, `docs/AI_ICT_主线裁剪审计_S1_2026-05-21_v1.md`, and `docs/AI_ICT_ICT主线概念字典_S2_2026-05-21_v1.md` as the active anchors. Continue with S3/S4: make Trading Plan a hard upstream gate for `Review Now`, then deepen Strategy Backtest so quality-gate failure blocks weak setups from live focus. Do not restart external-context/archive/digest long-tail work, do not create order-submission features, do not physically delete S1 deprecated surfaces yet, and do not fabricate R2 paper samples. Keep `execution_allowed`, `decision_support_ready`, `real_live_ready`, risk governor, trust bucket, and operator confirmation protocol unchanged. +Use `docs/AI_ICT_实盘辅助分析决策助手ICT主线开发方案_2026-05-21_v1.md`, `docs/AI_ICT_ICT主线概念字典_S2_2026-05-21_v1.md`, and `docs/AI_ICT_ReviewNow主线Gate_S3S4_2026-05-21_v1.md` as the active anchors. Continue with S5/S6: compress Trader Cockpit language into the ICT chain sentence, then map operator feedback / Journal / Study Notes to book-rule error classes. Do not restart external-context/archive/digest long-tail work, do not create order-submission features, do not physically delete S1 deprecated surfaces yet, and do not fabricate R2 paper samples. Keep `execution_allowed`, `decision_support_ready`, `real_live_ready`, risk governor, trust bucket, and operator confirmation protocol unchanged. ## Validation Commands Used Recently diff --git a/TASKS.md b/TASKS.md index ac30dd1..b4617c7 100644 --- a/TASKS.md +++ b/TASKS.md @@ -936,7 +936,8 @@ - [x] Trader mainline Step 5 deletion readiness audit: added `scripts/audit_trader_mainline_surfaces.py`, classified 5 top-nav targets, 19 workspace sections, 80 API routes, 43 frontend API references, and 108 test endpoint references before any deletion; diagnostics stay under `System Ops` - [x] S1 mainline deletion readiness audit under the new plan: created `docs/AI_ICT_主线裁剪审计_S1_2026-05-21_v1.md`; current top navigation is compliant, Research Only and Deprecated/Deferred Execution surfaces are documented, and physical deletion remains blocked until dependency/API/test/remote-smoke impact is proven safe - [x] S2 ICT canonical taxonomy/rulebook registry: require every Trader Cockpit focus, Strategy Backtest result, Trading Plan item, and Journal note to map to the book chain -- [ ] S3/S4 next gate: make Trading Plan a hard upstream gate for `Review Now`, then deepen Strategy Backtest so quality-gate failure blocks weak setups from live focus +- [x] S3/S4 Review Now mainline gate: Trading Plan is now the hard upstream gate, Strategy Backtest quality-gate failure blocks weak setups from live focus, and Strategy Backtest exposes `cockpit_promotion_gate` +- [ ] S5/S6 next gate: compress Trader Cockpit language into the ICT chain sentence, then map operator feedback / Journal / Study Notes to book-rule error classes - [ ] R2 paper-ticket sample gate is waiting on a real executable candidate; do not fabricate paper tickets from blocked candidates - [ ] R2 operator feedback sample gate still needs 2 more real feedback samples (`operator_feedback_count=3/5`) - [ ] R2 weekly review visible gate still needs a real execution-derived journal/weekly review entry (`weekly_review_entry_count=0`) diff --git a/docs/AI_ICT_ReviewNow主线Gate_S3S4_2026-05-21_v1.md b/docs/AI_ICT_ReviewNow主线Gate_S3S4_2026-05-21_v1.md new file mode 100644 index 0000000..c625f61 --- /dev/null +++ b/docs/AI_ICT_ReviewNow主线Gate_S3S4_2026-05-21_v1.md @@ -0,0 +1,75 @@ +# AI_ICT Review Now 主线 Gate S3/S4 2026-05-21 v1 + +## 定位 + +本文件是 S3/S4 的闭环记录,承接: + +- `docs/AI_ICT_实盘辅助分析决策助手ICT主线开发方案_2026-05-21_v1.md` +- `docs/AI_ICT_ICT主线概念字典_S2_2026-05-21_v1.md` + +目标不是增加新交易功能,而是把 `Review Now` 从“候选分数 / decision packet action 触发”改成“必须先通过 ICT 主线证据门”。 + +## 新增主线 Gate + +代码入口: + +- `src/services/ict_mainline/gates.py` +- `DecisionAssistantSummaryService` 输出的 `mainline_focus_gate` +- Trader Cockpit 输出的 `trader_mode_summary.mainline_focus_gate` +- Strategy Backtest 输出的 `cockpit_promotion_gate` + +`Review Now` 现在必须同时满足: + +1. 存在 active Trading Plan。 +2. Trading Plan 有 primary draw。 +3. Trading Plan 有 invalidation。 +4. Trading Plan 没有关键阻塞 no-trade condition。 +5. 最新 Strategy Backtest quality gate passed。 +6. 候选必须关联到当前 active Trading Plan。 + +任一条件不满足,候选仍可进入 watch / observe 队列,但不能进入 `review_now` tier,Trader Cockpit 只能显示 `Observe Only` 或更低状态。 + +## 阻断原因 + +当前主线 gate 会显式返回: + +- `missing_active_trading_plan` +- `trading_plan_not_active` +- `missing_primary_draw` +- `missing_invalidation` +- `mss_not_accepted` +- `backtest_quality_gate_missing` +- `backtest_quality_gate_failed` +- `sample_size_too_small` +- `executed_sample_too_small` +- `expectancy_below_threshold` +- `profit_factor_below_threshold` +- `consecutive_losses_above_threshold` +- `candidate_plan_link_missing` +- `candidate_plan_mismatch` + +这些原因可以显示在 Trader Mode / Strategy Backtest / System Ops 中,但不能被解释成买卖信号。 + +## 边界 + +本轮没有改变: + +- `execution_allowed` +- `decision_support_ready` +- `real_live_ready` +- risk governor +- trust bucket +- operator confirmation protocol +- OKX submit / cancel / fills +- R2 paper ticket / paper execution / weekly review 样本 + +## 验收结论 + +S3/S4 的最小闭环是: + +- 没有合格 Trading Plan 时,`Review Now` 被降级。 +- Backtest quality gate 失败时,`Review Now` 被降级。 +- 合格 Trading Plan + 合格 Backtest 时,原有 `Review Now` 行为保留。 +- Strategy Backtest 页面输出 `cockpit_promotion_gate`,明确告诉交易员该策略是否允许进入盘中关注。 + +下一步只能继续 S5/S6:进一步压缩 Trader Cockpit 语言,并把 operator feedback / Journal / Study Notes 映射到书本错误分类。 diff --git a/src/api/backtest.py b/src/api/backtest.py index e84ef03..a1248ad 100644 --- a/src/api/backtest.py +++ b/src/api/backtest.py @@ -5,7 +5,7 @@ from sqlalchemy import text from src.db.session import SessionLocal from src.services.backtest import evaluate_backtest_quality -from src.services.ict_mainline import build_ict_rulebook_mapping +from src.services.ict_mainline import build_backtest_quality_gate, build_ict_rulebook_mapping def get_strategy_backtest_surface(instrument_id: Optional[int] = None, limit: int = 10) -> dict: @@ -30,6 +30,7 @@ def get_strategy_backtest_surface(instrument_id: Optional[int] = None, limit: in "latest": latest, "quality_gate": quality_gate, "operator_summary": _build_operator_summary(latest), + "cockpit_promotion_gate": build_backtest_quality_gate(quality_gate), "ict_rulebook_mapping": ict_rulebook_mapping, "items": items, } diff --git a/src/services/ict_mainline/__init__.py b/src/services/ict_mainline/__init__.py index d0ab6b0..602cd64 100644 --- a/src/services/ict_mainline/__init__.py +++ b/src/services/ict_mainline/__init__.py @@ -6,11 +6,21 @@ from src.services.ict_mainline.taxonomy import ( get_ict_concept, list_ict_concepts, ) +from src.services.ict_mainline.gates import ( + build_backtest_quality_gate, + build_review_now_mainline_gate, + build_trading_plan_gate, + candidate_mainline_blockers, +) __all__ = [ "ICT_MAINLINE_CHAIN", "build_ict_rulebook_mapping", "build_ict_rulebook_summary", + "build_backtest_quality_gate", + "build_review_now_mainline_gate", + "build_trading_plan_gate", + "candidate_mainline_blockers", "classify_ict_concepts", "get_ict_concept", "list_ict_concepts", diff --git a/src/services/ict_mainline/gates.py b/src/services/ict_mainline/gates.py new file mode 100644 index 0000000..8b94260 --- /dev/null +++ b/src/services/ict_mainline/gates.py @@ -0,0 +1,143 @@ +from __future__ import annotations + + +PLAN_BLOCKING_CONDITIONS = { + "missing_primary_draw", + "missing_invalidation", + "missing_directional_bias", + "mss_not_accepted", +} + + +def build_trading_plan_gate(active_trading_plan: dict | None) -> dict: + if not active_trading_plan: + return { + "status": "blocked", + "passed": False, + "reason": "missing_active_trading_plan", + "reasons": ["missing_active_trading_plan"], + "operator_summary": "Review Now is blocked until today's active ICT Trading Plan exists.", + } + plan = dict(active_trading_plan or {}) + reasons: list[str] = [] + if str(plan.get("status") or "") != "active": + reasons.append("trading_plan_not_active") + if not dict(plan.get("primary_draw") or {}): + reasons.append("missing_primary_draw") + if not list(plan.get("invalidations") or []): + reasons.append("missing_invalidation") + no_trade_conditions = [str(item) for item in list(plan.get("no_trade_conditions") or []) if item] + blocking_conditions = [item for item in no_trade_conditions if item in PLAN_BLOCKING_CONDITIONS] + reasons.extend(blocking_conditions) + if reasons: + return { + "status": "blocked", + "passed": False, + "reason": reasons[0], + "reasons": _dedupe(reasons), + "plan_id": plan.get("id"), + "plan_date": _string_or_empty(plan.get("plan_date")), + "operator_summary": "Review Now is blocked because the active ICT Trading Plan is incomplete.", + } + return { + "status": "passed", + "passed": True, + "reason": "active_plan_ready", + "reasons": [], + "plan_id": plan.get("id"), + "plan_date": _string_or_empty(plan.get("plan_date")), + "bias": plan.get("bias"), + "primary_draw": dict(plan.get("primary_draw") or {}), + "operator_summary": "Active ICT Trading Plan is ready for read-only candidate review.", + } + + +def build_backtest_quality_gate(quality_gate: dict | None) -> dict: + if not quality_gate: + return { + "status": "blocked", + "passed": False, + "reason": "backtest_quality_gate_missing", + "reasons": ["backtest_quality_gate_missing"], + "operator_summary": "Review Now is blocked until stored Strategy Backtest evidence is available.", + } + gate = dict(quality_gate or {}) + passed = bool(gate.get("passed")) + reasons = [str(item) for item in list(gate.get("reasons") or []) if item] + if not passed: + if not reasons: + reasons = ["backtest_quality_gate_failed"] + return { + "status": "blocked", + "passed": False, + "reason": reasons[0], + "reasons": reasons, + "backtest_run_id": gate.get("backtest_run_id"), + "observed_ts": _string_or_empty(gate.get("observed_ts")), + "operator_summary": "Review Now is blocked because historical edge is not strong enough yet.", + } + return { + "status": "passed", + "passed": True, + "reason": "backtest_quality_gate_passed", + "reasons": [], + "backtest_run_id": gate.get("backtest_run_id"), + "observed_ts": _string_or_empty(gate.get("observed_ts")), + "operator_summary": "Strategy Backtest quality gate passed for read-only candidate review.", + } + + +def build_review_now_mainline_gate( + *, + active_trading_plan: dict | None, + backtest_quality_gate: dict | None, +) -> dict: + plan_gate = build_trading_plan_gate(active_trading_plan) + backtest_gate = build_backtest_quality_gate(backtest_quality_gate) + blockers = [ + *[str(item) for item in list(plan_gate.get("reasons") or []) if item], + *[str(item) for item in list(backtest_gate.get("reasons") or []) if item], + ] + review_now_allowed = bool(plan_gate.get("passed") and backtest_gate.get("passed")) + return { + "status": "passed" if review_now_allowed else "blocked", + "review_now_allowed": review_now_allowed, + "blockers": _dedupe(blockers), + "trading_plan_gate": plan_gate, + "backtest_quality_gate": backtest_gate, + "execution_boundary": "read_only_analysis_no_order_submission", + "operator_summary": ( + "Review Now may be shown only after the active ICT Trading Plan and Strategy Backtest quality gate both pass." + if review_now_allowed + else "Review Now is downgraded to Observe Only until Trading Plan and Strategy Backtest evidence both pass." + ), + } + + +def candidate_mainline_blockers(candidate: dict, mainline_gate: dict) -> list[str]: + blockers = [str(item) for item in list(mainline_gate.get("blockers") or []) if item] + plan_gate = dict(mainline_gate.get("trading_plan_gate") or {}) + plan_id = plan_gate.get("plan_id") + candidate_plan_id = candidate.get("trading_plan_id") + if plan_gate.get("passed") and plan_id is not None: + if candidate_plan_id is None: + blockers.append("candidate_plan_link_missing") + elif str(candidate_plan_id) != str(plan_id): + blockers.append("candidate_plan_mismatch") + return _dedupe(blockers) + + +def _dedupe(values: list[str]) -> list[str]: + result = [] + seen = set() + for value in values: + key = str(value or "").strip() + if not key or key in seen: + continue + seen.add(key) + result.append(key) + return result + + +def _string_or_empty(value) -> str: + return "" if value is None else str(value) diff --git a/src/services/operator/operator_workspace_service.py b/src/services/operator/operator_workspace_service.py index 12e6747..67290f7 100644 --- a/src/services/operator/operator_workspace_service.py +++ b/src/services/operator/operator_workspace_service.py @@ -10,11 +10,16 @@ from src.config.settings import settings from src.repositories.decision_packet_repository import DecisionPacketRepository from src.repositories.decision_assistant_repository import DecisionAssistantRepository from src.repositories.market_session_repository import MarketSessionRepository +from src.repositories.market_state_repository import MarketStateRepository from src.repositories.operator_note_repository import OperatorNoteRepository from src.repositories.trade_plan_candidate_repository import TradePlanCandidateRepository from src.services.decision import DecisionPacketService from src.services.execution import ExecutionService -from src.services.ict_mainline import build_ict_rulebook_mapping +from src.services.ict_mainline import ( + build_ict_rulebook_mapping, + build_review_now_mainline_gate, + candidate_mainline_blockers, +) from src.services.journal import WeeklyReviewService from src.services.alerts import AlertQualitySummaryService, OperatorDigestService from src.services.market_state.external_chart_provider import BinanceChartReferenceProvider @@ -158,6 +163,7 @@ class DecisionAssistantSummaryService: announcements_provider: Optional[OkxAnnouncementsProvider] = None, external_chart_provider: Optional[BinanceChartReferenceProvider] = None, decision_assistant_repository: Optional[DecisionAssistantRepository] = None, + market_state_repository: Optional[MarketStateRepository] = None, ) -> None: self.launch_readiness_service = launch_readiness_service or LaunchReadinessService() self.session_focus_service = session_focus_service or SessionFocusService() @@ -169,6 +175,7 @@ class DecisionAssistantSummaryService: self.announcements_provider = announcements_provider or OkxAnnouncementsProvider() self.external_chart_provider = external_chart_provider or BinanceChartReferenceProvider() self.decision_assistant_repository = decision_assistant_repository or DecisionAssistantRepository() + self.market_state_repository = market_state_repository or MarketStateRepository() def build_summary( self, @@ -182,7 +189,7 @@ class DecisionAssistantSummaryService: recent_notes: Optional[list[dict]] = None, recent_executions: Optional[list[dict]] = None, ) -> dict: - with ThreadPoolExecutor(max_workers=6) as executor: + with ThreadPoolExecutor(max_workers=8) as executor: launch_readiness_future = executor.submit( _build_launch_readiness_summary, self.launch_readiness_service, @@ -208,6 +215,16 @@ class DecisionAssistantSummaryService: instrument_id=instrument_id, limit=20, ) + active_trading_plan_future = executor.submit( + _safe_fetch_active_trading_plan, + self.candidate_repository, + instrument_id, + ) + backtest_quality_gate_future = executor.submit( + _safe_fetch_latest_backtest_quality_gate, + self.market_state_repository, + instrument_id, + ) exchange_status_future = executor.submit(self.exchange_status_provider.fetch_snapshot) announcements_future = executor.submit(self.announcements_provider.fetch_snapshot) launch_readiness = launch_readiness_future.result() @@ -215,6 +232,8 @@ class DecisionAssistantSummaryService: operator_digest = operator_digest_future.result() previous_packet = previous_packet_future.result() recent_candidates = recent_candidates_future.result() + active_trading_plan = active_trading_plan_future.result() + backtest_quality_gate = backtest_quality_gate_future.result() exchange_status = exchange_status_future.result() announcements_snapshot = announcements_future.result() symbol_hint = _decision_assistant_symbol_hint( @@ -284,6 +303,8 @@ class DecisionAssistantSummaryService: operator_digest=dict(operator_digest or {}), previous_packet=dict(previous_packet or {}), recent_candidates=list(recent_candidates or []), + active_trading_plan=dict(active_trading_plan or {}), + backtest_quality_gate=dict(backtest_quality_gate or {}), exchange_status=dict(exchange_status or {}), announcements_snapshot=dict(announcements_snapshot or {}), external_chart_snapshot=dict(external_chart_snapshot or {}), @@ -511,6 +532,20 @@ def _safe_fetch_workflow_history( return [] +def _safe_fetch_active_trading_plan(candidate_repository: TradePlanCandidateRepository, instrument_id: int) -> dict: + try: + return dict(candidate_repository.fetch_active_trading_plan(instrument_id=instrument_id) or {}) + except Exception: + return {} + + +def _safe_fetch_latest_backtest_quality_gate(repository: MarketStateRepository, instrument_id: int) -> dict: + try: + return dict(repository.fetch_latest_backtest_quality_gate(instrument_id=instrument_id) or {}) + except Exception: + return {} + + def _safe_resolve_focus_candidate( *, candidate_repository: TradePlanCandidateRepository, @@ -1583,6 +1618,8 @@ def build_decision_assistant_summary( operator_digest: dict, previous_packet: dict, recent_candidates: list[dict], + active_trading_plan: dict, + backtest_quality_gate: dict, exchange_status: dict, announcements_snapshot: dict, external_chart_snapshot: dict, @@ -1657,10 +1694,15 @@ def build_decision_assistant_summary( operator_digest=operator_digest, persisted_focus_candidate=persisted_focus_candidate, ) + mainline_focus_gate = build_review_now_mainline_gate( + active_trading_plan=active_trading_plan, + backtest_quality_gate=backtest_quality_gate, + ) candidate_watchlist_tiers = _build_candidate_watchlist_tiers( candidates=recent_candidates, session_focus=session_focus, current_candidate_id=persisted_focus_candidate.get("id") or decision_packet.get("candidate_id"), + mainline_focus_gate=mainline_focus_gate, ) candidate_watchlist_summary = _build_candidate_watchlist_summary( candidate_watchlist_tiers, @@ -1864,7 +1906,8 @@ def build_decision_assistant_summary( next_actions=next_actions, shadow_paper_validation_summary=shadow_paper_validation_summary, weekly_review=weekly_review, - external_context_summary=external_context_summary, + external_context_summary=external_context_summary, + mainline_focus_gate=mainline_focus_gate, ) return { "instrument_id": instrument_id, @@ -1888,6 +1931,9 @@ def build_decision_assistant_summary( "top_opportunities": assistant_top_opportunities, "candidate_watchlist_tiers": candidate_watchlist_tiers, "candidate_watchlist_summary": candidate_watchlist_summary, + "mainline_focus_gate": mainline_focus_gate, + "active_trading_plan": active_trading_plan, + "backtest_quality_gate": backtest_quality_gate, "workflow_digest": workflow_digest, "workflow_rhythm_summary": workflow_rhythm_summary, "workflow_session_completion_summary": workflow_session_completion_summary, @@ -1942,6 +1988,10 @@ def build_decision_assistant_summary( "must_not_trade": bool(live_analysis_assistant_summary.get("must_not_trade", True)), "trader_mode_status": trader_mode_summary.get("status"), "trader_mode_color": trader_mode_summary.get("status_color"), + "mainline_focus_gate_status": mainline_focus_gate.get("status"), + "review_now_allowed_by_mainline": bool(mainline_focus_gate.get("review_now_allowed")), + "active_trading_plan_id": (mainline_focus_gate.get("trading_plan_gate") or {}).get("plan_id"), + "backtest_quality_gate_status": (mainline_focus_gate.get("backtest_quality_gate") or {}).get("status"), "boundary_status": decision_support_boundary_summary.get("status"), "boundary_label": decision_support_boundary_summary.get("label"), "promotion_status": decision_support_promotion_summary.get("status"), @@ -2520,7 +2570,13 @@ def _build_decision_assistant_priority_items( )[:5] -def _build_candidate_watchlist_tiers(*, candidates: list[dict], session_focus: dict, current_candidate_id) -> dict: +def _build_candidate_watchlist_tiers( + *, + candidates: list[dict], + session_focus: dict, + current_candidate_id, + mainline_focus_gate: dict, +) -> dict: session_items = { str(item.get("session_code") or ""): dict(item) for item in list(session_focus.get("items") or []) @@ -2534,18 +2590,29 @@ def _build_candidate_watchlist_tiers(*, candidates: list[dict], session_focus: d } for candidate in candidates: session_item = session_items.get(str(candidate.get("session_code") or ""), {}) - tier, tier_reason, priority_score = _classify_candidate_watchlist_tier( + tier, tier_reason, priority_score, mainline_blockers = _classify_candidate_watchlist_tier( candidate=candidate, session_item=session_item, current_candidate_id=current_candidate_id, + mainline_focus_gate=mainline_focus_gate, + ) + missing_confirmations = _dedupe( + [ + *[str(item) for item in list(candidate.get("missing_confirmations") or []) if item], + *[str(item) for item in mainline_blockers if item], + ] ) tiers[tier].append( { "candidate_id": candidate.get("id"), + "trading_plan_id": candidate.get("trading_plan_id"), "title": f"Candidate #{candidate.get('id')}", "tier": tier, "tier_reason": tier_reason, "priority_score": priority_score, + "review_now_allowed": tier == "review_now" and not mainline_blockers, + "mainline_gate": mainline_focus_gate, + "mainline_gate_blockers": mainline_blockers, "suggested_action": _candidate_tier_action_label(tier=tier, candidate_id=candidate.get("id")), "action_path": _candidate_tier_action_path(tier=tier, candidate_id=candidate.get("id")), "action_method": _candidate_tier_action_method(tier=tier), @@ -2554,7 +2621,7 @@ def _build_candidate_watchlist_tiers(*, candidates: list[dict], session_focus: d "quality_score": candidate.get("quality_score"), "model_code": candidate.get("model_code") or "n/a", "detail": candidate.get("ai_explanation") or candidate.get("narrative") or "candidate_available", - "missing_confirmations": list(candidate.get("missing_confirmations") or []), + "missing_confirmations": missing_confirmations, "trader_focus_reason": _build_watchlist_trader_focus_reason( tier=tier, tier_reason=tier_reason, @@ -2562,14 +2629,14 @@ def _build_candidate_watchlist_tiers(*, candidates: list[dict], session_focus: d model_code=candidate.get("model_code") or "n/a", quality_score=candidate.get("quality_score"), ), - "trader_wait_for": _build_watchlist_trader_wait_for(candidate.get("missing_confirmations") or []), + "trader_wait_for": _build_watchlist_trader_wait_for(missing_confirmations), "trader_no_trade_reason": _build_watchlist_trader_no_trade_reason( tier=tier, - missing_confirmations=candidate.get("missing_confirmations") or [], + missing_confirmations=missing_confirmations, ), "trader_watch_next": _build_watchlist_trader_watch_next( title=f"Candidate #{candidate.get('id')}", - missing_confirmations=candidate.get("missing_confirmations") or [], + missing_confirmations=missing_confirmations, tier=tier, ), } @@ -2660,29 +2727,40 @@ def _build_candidate_watchlist_focus_matrix(tiers: dict, *, preferred_candidate_ } -def _classify_candidate_watchlist_tier(*, candidate: dict, session_item: dict, current_candidate_id) -> tuple[str, str, float]: +def _classify_candidate_watchlist_tier( + *, + candidate: dict, + session_item: dict, + current_candidate_id, + mainline_focus_gate: dict, +) -> tuple[str, str, float, list[str]]: score = float(candidate.get("quality_score") or 0.0) status = str(candidate.get("status") or candidate.get("candidate_state") or "") action_hint = str(session_item.get("action_hint") or "") recommendation = dict(session_item.get("session_recommendation") or {}) session_score = float(recommendation.get("score") or 0.0) missing_count = len(list(candidate.get("missing_confirmations") or [])) + mainline_blockers = candidate_mainline_blockers(candidate, mainline_focus_gate) priority_score = score if candidate.get("id") == current_candidate_id: priority_score += 120.0 - return "review_now", "current_decision_packet_candidate", priority_score + if mainline_blockers: + return "strong_watch", mainline_blockers[0], priority_score, mainline_blockers + return "review_now", "current_decision_packet_candidate", priority_score, [] if status == "executable" or bool(candidate.get("executable")): priority_score += 100.0 - return "review_now", "candidate_executable", priority_score + if mainline_blockers: + return "strong_watch", mainline_blockers[0], priority_score, mainline_blockers + return "review_now", "candidate_executable", priority_score, [] if action_hint in {"review_alerts", "focus_now"}: priority_score += 20.0 if recommendation.get("classification") == "prefer": priority_score += 10.0 if score >= 70.0 or (score >= 55.0 and action_hint == "focus_now"): - return "strong_watch", "quality_or_session_focus", priority_score + return "strong_watch", "quality_or_session_focus", priority_score, mainline_blockers if score >= 40.0 or missing_count > 0 or recommendation.get("classification") == "prefer": - return "soft_watch", "needs_more_confirmation", priority_score - return "archive_only", "low_priority_or_low_context", priority_score + return "soft_watch", "needs_more_confirmation", priority_score, mainline_blockers + return "archive_only", "low_priority_or_low_context", priority_score, mainline_blockers def _candidate_tier_action_label(*, tier: str, candidate_id) -> str: @@ -2753,6 +2831,7 @@ def _build_trader_mode_summary( shadow_paper_validation_summary: dict, weekly_review: dict, external_context_summary: dict, + mainline_focus_gate: dict, ) -> dict: live_blockers = [str(item) for item in list(live_analysis_assistant_summary.get("blockers") or []) if item] runtime_health = str(readonly_live_health_summary.get("runtime_health") or "") @@ -2797,6 +2876,7 @@ def _build_trader_mode_summary( no_trade_reason_items=no_trade_reason_items, focus=focus, decision_packet=decision_packet, + mainline_focus_gate=mainline_focus_gate, market_freshness=market_freshness, market_stream=market_stream, market_gap=market_gap, @@ -2808,6 +2888,7 @@ def _build_trader_mode_summary( hard_no_trade_reasons=hard_no_trade_reasons, focus=focus, decision_packet=decision_packet, + mainline_focus_gate=mainline_focus_gate, ) primary_conclusion = _build_trader_mode_primary_conclusion( status_tone=status_tone, @@ -2883,6 +2964,7 @@ def _build_trader_mode_summary( "decision_packet": decision_packet, "shadow_paper_validation_summary": shadow_paper_validation_summary, "weekly_review": weekly_review, + "mainline_focus_gate": mainline_focus_gate, }, ) return { @@ -2901,6 +2983,7 @@ def _build_trader_mode_summary( "plain_language_reasons": plain_language_reasons[:3], "next_action": next_action, "trader_readout": trader_readout, + "mainline_focus_gate": mainline_focus_gate, "ict_rulebook_mapping": ict_rulebook_mapping, "why_no_trade": trader_readout.get("why_no_trade"), "watch_next": trader_readout.get("watch_next"), @@ -3252,6 +3335,9 @@ def _resolve_trader_mode_focus_candidate( "quality_score": focus.get("quality_score"), "detail": focus.get("detail") or "candidate_context", "missing_confirmations": list(focus.get("missing_confirmations") or [])[:3], + "review_now_allowed": bool(focus.get("review_now_allowed")), + "mainline_gate": dict(focus.get("mainline_gate") or {}), + "mainline_gate_blockers": list(focus.get("mainline_gate_blockers") or [])[:5], } @@ -3262,6 +3348,7 @@ def _build_trader_mode_reasons( no_trade_reason_items: list[dict], focus: dict, decision_packet: dict, + mainline_focus_gate: dict, market_freshness: str, market_stream: str, market_gap: str, @@ -3283,6 +3370,8 @@ def _build_trader_mode_reasons( if item.get("reason") ] ) + reasons.extend([str(item) for item in list(mainline_focus_gate.get("blockers") or []) if item]) + reasons.extend([str(item) for item in list(focus.get("mainline_gate_blockers") or []) if item]) reasons.extend([f"waiting_for_{item}" for item in list(focus.get("missing_confirmations") or [])[:3]]) confirmation_chain = dict(decision_packet.get("confirmation_chain") or {}) reasons.extend([f"missing_{item}" for item in list(confirmation_chain.get("missing") or [])[:3]]) @@ -3302,6 +3391,7 @@ def _resolve_trader_mode_status( hard_no_trade_reasons: list[str], focus: dict, decision_packet: dict, + mainline_focus_gate: dict, ) -> tuple[str, str, str]: if not readonly_ready or data_confidence == "blocked": return "do_not_use", "do_not_use", "Do Not Use" @@ -3309,6 +3399,8 @@ def _resolve_trader_mode_status( return "do_not_use", "do_not_use", "Do Not Use" action = str(decision_packet.get("action_recommendation") or "") focus_tier = str(focus.get("tier") or "") + if focus and (list(focus.get("mainline_gate_blockers") or []) or list(mainline_focus_gate.get("blockers") or [])): + return "observe", "observe_only", "Observe Only" if focus and (focus_tier == "review_now" or action in {"confirm_ticket", "build_execution_ticket", "manage_ticket"}): return "review", "review_now", "Review Now" return "observe", "observe_only", "Observe Only" diff --git a/src/web/i18n.js b/src/web/i18n.js index 2f6625f..aa408eb 100644 --- a/src/web/i18n.js +++ b/src/web/i18n.js @@ -645,6 +645,19 @@ ['decision_support_trust_candidate', '辅助决策信任候选'], ['model_not_blacklist', '模型不在黑名单'], ['backtest_quality_gate_passed', '回测质量门已通过'], + ['backtest_quality_gate_missing', '缺少回测质量门'], + ['backtest_quality_gate_failed', '回测质量门未通过'], + ['sample_size_too_small', '回测样本太少'], + ['executed_sample_too_small', '执行样本太少'], + ['expectancy_below_threshold', '期望值低于阈值'], + ['profit_factor_below_threshold', '利润因子低于阈值'], + ['consecutive_losses_above_threshold', '连续亏损过高'], + ['missing_active_trading_plan', '缺少有效盘前交易计划'], + ['trading_plan_not_active', '盘前交易计划未激活'], + ['candidate_plan_link_missing', '候选缺少盘前计划关联'], + ['candidate_plan_mismatch', '候选与当前盘前计划不匹配'], + ['active_plan_ready', '盘前计划已就绪'], + ['Review Now is downgraded to Observe Only until Trading Plan and Strategy Backtest evidence both pass.', '盘前计划和回测证据同时通过前,Review Now 降级为仅观察。'], ['positive_expectancy', '正期望'], ['operator_feedback_sample_gate', '操作员反馈样本门'], ['framework_execution_rate_gate', '框架执行率门'], diff --git a/tests/unit/test_api_and_main.py b/tests/unit/test_api_and_main.py index e04e5a1..33d301f 100644 --- a/tests/unit/test_api_and_main.py +++ b/tests/unit/test_api_and_main.py @@ -6,7 +6,7 @@ from unittest.mock import patch os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") from src.api import get_health -from src.api.backtest import get_recent_backtest_summaries, normalize_backtest_summary_row +from src.api.backtest import get_recent_backtest_summaries, get_strategy_backtest_surface, normalize_backtest_summary_row from src.api.server import route_request from src.api.signals import get_recent_signals from src.services.backtest import BacktestCostConfig, BacktestQualityGate, BacktestReplayConfig @@ -102,6 +102,22 @@ class ApiTests(unittest.TestCase): self.assertEqual(result["ict_rulebook_mapping"]["surface"], "strategy_backtest") self.assertIn("backtest_evidence", result["ict_rulebook_mapping"]["present_concepts"]) + def test_strategy_backtest_surface_exposes_cockpit_promotion_gate(self) -> None: + rows = [ + { + "id": 10, + "summary": { + "total_signals": 3, + "quality_gate": {"passed": False, "reasons": ["sample_size_too_small"]}, + }, + } + ] + with patch("src.api.backtest.SessionLocal", return_value=FakeSession(rows)): + result = get_strategy_backtest_surface(instrument_id=1, limit=1) + + self.assertEqual(result["cockpit_promotion_gate"]["status"], "blocked") + self.assertIn("sample_size_too_small", result["cockpit_promotion_gate"]["reasons"]) + def test_route_request_trading_candidates_returns_items_payload(self) -> None: status, payload = route_request( "/trading/candidates", diff --git a/tests/unit/test_documentation_consistency.py b/tests/unit/test_documentation_consistency.py index 122083c..0935d52 100644 --- a/tests/unit/test_documentation_consistency.py +++ b/tests/unit/test_documentation_consistency.py @@ -22,6 +22,7 @@ ACTIVE_TOP_LEVEL_DOCS = { "AI_ICT_实盘辅助分析决策助手ICT主线开发方案_2026-05-21_v1.md", "AI_ICT_主线裁剪审计_S1_2026-05-21_v1.md", "AI_ICT_ICT主线概念字典_S2_2026-05-21_v1.md", + "AI_ICT_ReviewNow主线Gate_S3S4_2026-05-21_v1.md", "AI_ICT_主线纠偏与功能裁剪方案_2026-05-20_v1.md", "AI_ICT_实盘辅助分析决策助手上线必做清单_2026-05-18_v1.md", "AI_ICT_实盘辅助分析决策助手使用说明_2026-05-18_v1.md", diff --git a/tests/unit/test_ict_mainline_taxonomy.py b/tests/unit/test_ict_mainline_taxonomy.py index deb2219..cfd0b97 100644 --- a/tests/unit/test_ict_mainline_taxonomy.py +++ b/tests/unit/test_ict_mainline_taxonomy.py @@ -4,6 +4,7 @@ from src.services.ict_mainline import ( ICT_MAINLINE_CHAIN, build_ict_rulebook_mapping, build_ict_rulebook_summary, + build_review_now_mainline_gate, classify_ict_concepts, get_ict_concept, ) @@ -114,6 +115,31 @@ class IctMainlineTaxonomyTests(unittest.TestCase): self.assertIn("session", concepts) self.assertIn("no_trade_conditions", concepts) + def test_review_now_mainline_gate_requires_plan_and_backtest(self) -> None: + blocked = build_review_now_mainline_gate( + active_trading_plan=None, + backtest_quality_gate={"passed": False, "reasons": ["sample_size_too_small"]}, + ) + + self.assertEqual(blocked["status"], "blocked") + self.assertFalse(blocked["review_now_allowed"]) + self.assertIn("missing_active_trading_plan", blocked["blockers"]) + self.assertIn("sample_size_too_small", blocked["blockers"]) + + passed = build_review_now_mainline_gate( + active_trading_plan={ + "id": 10, + "status": "active", + "primary_draw": {"side": "buy_side"}, + "invalidations": [{"code": "range_low_lost"}], + "no_trade_conditions": [], + }, + backtest_quality_gate={"passed": True, "reasons": []}, + ) + + self.assertEqual(passed["status"], "passed") + self.assertTrue(passed["review_now_allowed"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_operator_workspace_service.py b/tests/unit/test_operator_workspace_service.py index 069b014..ab761b6 100644 --- a/tests/unit/test_operator_workspace_service.py +++ b/tests/unit/test_operator_workspace_service.py @@ -129,6 +129,7 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): announcements_provider=_AnnouncementsProvider(), external_chart_provider=_ExternalChartProvider(), decision_assistant_repository=_DecisionAssistantRepo(), + market_state_repository=_BacktestQualityGateRepo(passed=True), ) summary = service.build_summary( @@ -195,6 +196,9 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): self.assertEqual(summary["top_opportunities"][0]["kind"], "focus_candidate") self.assertTrue(any(item["kind"] == "decision_packet" for item in summary["top_priority_items"])) self.assertEqual(summary["candidate_watchlist_tiers"]["review_now"][0]["candidate_id"], 18) + self.assertTrue(summary["candidate_watchlist_tiers"]["review_now"][0]["review_now_allowed"]) + self.assertEqual(summary["mainline_focus_gate"]["status"], "passed") + self.assertTrue(summary["mainline_focus_gate"]["review_now_allowed"]) self.assertEqual(summary["candidate_watchlist_tiers"]["strong_watch"][0]["candidate_id"], 21) self.assertEqual(summary["candidate_watchlist_tiers"]["soft_watch"][0]["candidate_id"], 22) self.assertEqual(summary["candidate_watchlist_tiers"]["review_now"][0]["action_method"], "POST") @@ -247,6 +251,8 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): self.assertIn("Read-only Live Analysis", trader_mode_summary["drilldown_sections"]) self.assertEqual(summary["evidence_summary"]["trader_mode_status"], "review_now") self.assertEqual(summary["evidence_summary"]["trader_mode_color"], "review") + self.assertEqual(summary["evidence_summary"]["mainline_focus_gate_status"], "passed") + self.assertTrue(summary["evidence_summary"]["review_now_allowed_by_mainline"]) self.assertIn("focus_matrix", summary["candidate_watchlist_summary"]) self.assertIn("next_watchlist_focus", summary["candidate_watchlist_summary"]) self.assertIn("next_watchlist_focus_prompt", summary["candidate_watchlist_summary"]) @@ -817,6 +823,85 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): self.assertIn("action:observe_only->confirm_ticket", summary["changed_since_last"]["items"]) self.assertIn("candidate:none->18", summary["changed_since_last"]["items"]) + def test_decision_assistant_blocks_review_now_without_active_plan(self) -> None: + service = DecisionAssistantSummaryService( + launch_readiness_service=_LaunchReadinessService(), + session_focus_service=_SessionFocusSummaryService(), + operator_digest_service=_OperatorDigestSummaryService(), + decision_repository=_PreviousPacketRepo(), + candidate_repository=_NoActivePlanCandidateWatchlistRepo(), + research_council_service=_ResearchCouncilSummaryService(), + exchange_status_provider=_ExchangeStatusProvider(), + announcements_provider=_AnnouncementsProvider(), + external_chart_provider=_ExternalChartProvider(), + decision_assistant_repository=_DecisionAssistantRepo(), + market_state_repository=_BacktestQualityGateRepo(passed=True), + ) + + summary = service.build_summary( + instrument_id=1, + timeframe="5m", + decision_packet={ + "status": "actionable", + "action_recommendation": "confirm_ticket", + "candidate_id": 18, + "packet_ts": datetime.now(timezone.utc).isoformat(), + "setup_type": "SWEEP_MSS_FVG", + "why_allowed": ["candidate_confirmed"], + "why_blocked": [], + }, + weekly_review={"next_week_actions": [], "session_performance": {}}, + recent_alerts=[], + recent_notes=[], + recent_executions=[], + ) + + self.assertEqual(summary["mainline_focus_gate"]["status"], "blocked") + self.assertIn("missing_active_trading_plan", summary["mainline_focus_gate"]["blockers"]) + self.assertEqual(summary["candidate_watchlist_tiers"]["review_now"], []) + self.assertEqual(summary["candidate_watchlist_tiers"]["strong_watch"][0]["candidate_id"], 18) + self.assertIn("missing_active_trading_plan", summary["candidate_watchlist_tiers"]["strong_watch"][0]["mainline_gate_blockers"]) + self.assertEqual(summary["trader_mode_summary"]["status"], "observe_only") + self.assertEqual(summary["trader_mode_summary"]["status_label"], "Observe Only") + + def test_decision_assistant_blocks_review_now_when_backtest_quality_gate_fails(self) -> None: + service = DecisionAssistantSummaryService( + launch_readiness_service=_LaunchReadinessService(), + session_focus_service=_SessionFocusSummaryService(), + operator_digest_service=_OperatorDigestSummaryService(), + decision_repository=_PreviousPacketRepo(), + candidate_repository=_CandidateWatchlistRepo(), + research_council_service=_ResearchCouncilSummaryService(), + exchange_status_provider=_ExchangeStatusProvider(), + announcements_provider=_AnnouncementsProvider(), + external_chart_provider=_ExternalChartProvider(), + decision_assistant_repository=_DecisionAssistantRepo(), + market_state_repository=_BacktestQualityGateRepo(passed=False, reasons=["sample_size_too_small"]), + ) + + summary = service.build_summary( + instrument_id=1, + timeframe="5m", + decision_packet={ + "status": "actionable", + "action_recommendation": "confirm_ticket", + "candidate_id": 18, + "packet_ts": datetime.now(timezone.utc).isoformat(), + "setup_type": "SWEEP_MSS_FVG", + "why_allowed": ["candidate_confirmed"], + "why_blocked": [], + }, + weekly_review={"next_week_actions": [], "session_performance": {}}, + recent_alerts=[], + recent_notes=[], + recent_executions=[], + ) + + self.assertEqual(summary["mainline_focus_gate"]["status"], "blocked") + self.assertIn("sample_size_too_small", summary["mainline_focus_gate"]["blockers"]) + self.assertEqual(summary["candidate_watchlist_tiers"]["review_now"], []) + self.assertEqual(summary["trader_mode_summary"]["status"], "observe_only") + self.assertIn("sample_size_too_small", summary["trader_mode_summary"]["key_reasons"]) def test_decision_assistant_trader_mode_freezes_stale_payload(self) -> None: service = DecisionAssistantSummaryService( @@ -830,6 +915,7 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): announcements_provider=_AnnouncementsProvider(), external_chart_provider=_ExternalChartProvider(), decision_assistant_repository=_DecisionAssistantRepo(), + market_state_repository=_BacktestQualityGateRepo(passed=True), ) summary = service.build_summary( @@ -873,6 +959,7 @@ class OperatorWorkspaceServiceTests(unittest.TestCase): announcements_provider=_AnnouncementsProvider(), external_chart_provider=_ExternalChartProvider(), decision_assistant_repository=_DecisionAssistantRepo(), + market_state_repository=_BacktestQualityGateRepo(passed=True), ) summary = service.build_summary( @@ -1180,6 +1267,7 @@ class _CandidateWatchlistRepo: return [ { "id": 18, + "trading_plan_id": 101, "session_code": "LONDON", "status": "candidate", "quality_score": 81.0, @@ -1189,6 +1277,7 @@ class _CandidateWatchlistRepo: }, { "id": 21, + "trading_plan_id": 101, "session_code": "LONDON", "status": "candidate", "quality_score": 72.0, @@ -1198,6 +1287,7 @@ class _CandidateWatchlistRepo: }, { "id": 22, + "trading_plan_id": 101, "session_code": "NY_AM", "status": "candidate", "quality_score": 44.0, @@ -1207,6 +1297,7 @@ class _CandidateWatchlistRepo: }, { "id": 23, + "trading_plan_id": 101, "session_code": "OFF_HOURS", "status": "candidate", "quality_score": 12.0, @@ -1222,6 +1313,37 @@ class _CandidateWatchlistRepo: return {"instrument_id": 1, **item} return None + def fetch_active_trading_plan(self, *, instrument_id: int, plan_date=None): + return { + "id": 101, + "instrument_id": instrument_id, + "plan_date": "2026-05-12", + "status": "active", + "primary_draw": {"side": "buy_side", "source": "liquidity_pool"}, + "invalidations": [{"code": "range_low_lost"}], + "no_trade_conditions": [], + "bias": "bullish", + } + + +class _NoActivePlanCandidateWatchlistRepo(_CandidateWatchlistRepo): + def fetch_active_trading_plan(self, *, instrument_id: int, plan_date=None): + return None + + +class _BacktestQualityGateRepo: + def __init__(self, *, passed: bool, reasons=None) -> None: + self.passed = passed + self.reasons = list(reasons or []) + + def fetch_latest_backtest_quality_gate(self, *, instrument_id: int): + return { + "passed": self.passed, + "reasons": self.reasons, + "backtest_run_id": 489, + "observed_ts": "2026-05-12T01:00:00+00:00", + } + class _PreviousPacketRepo: def fetch_latest_decision_packet(self, *, instrument_id: int):