Add Review Now mainline gates
This commit is contained in:
+3
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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', '框架执行率门'],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user