Add Review Now mainline gates

This commit is contained in:
Codex
2026-05-21 12:01:50 +08:00
parent af319e9895
commit 3c8613632f
12 changed files with 521 additions and 20 deletions
+3 -2
View File
@@ -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
+2 -1
View File
@@ -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`)
@@ -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 映射到书本错误分类。
+2 -1
View File
@@ -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,
}
+10
View File
@@ -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",
+143
View File
@@ -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)
@@ -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"
+13
View File
@@ -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', '框架执行率门'],
+17 -1
View File
@@ -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",
@@ -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",
+26
View File
@@ -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()
@@ -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):