From 02c31262f9ff6f0bded33dff1ea1cd74a926eb26 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 21 May 2026 00:12:52 +0800 Subject: [PATCH] Productize strategy backtest surface --- SESSION.md | 8 +- TASKS.md | 2 + ...CT_主线纠偏与功能裁剪方案_2026-05-20_v1.md | 9 +- src/api/__init__.py | 3 +- src/api/backtest.py | 84 ++++++++- src/api/server.py | 6 + src/web/app.js | 162 +++++++++++++++++- src/web/i18n.js | 69 ++++++++ src/web/index.html | 14 +- src/web/styles.css | 31 ++++ tests/unit/test_api_server.py | 23 +++ tests/unit/test_api_static_server.py | 9 +- 12 files changed, 406 insertions(+), 14 deletions(-) diff --git a/SESSION.md b/SESSION.md index 738a212..f971b7b 100644 --- a/SESSION.md +++ b/SESSION.md @@ -2,7 +2,7 @@ ## Current Goal -Automation execution-layer PXX remains closed and its known scope stays frozen. Under explicit user direction, the active product phase is a dense `Decision Assistant` operator workspace with grouped recall, broader replay baselines, note templates, quick-save shortcuts, read-only market context, and a continuous operator cockpit. The previous `鏈畬鎴愰渶姹傛竻鍗昤 has been archived, and the current documentation baseline now uses `docs/AI_ICT_闇€姹傛枃妗e墿浣欏姛鑳戒笌寤舵湡椤规竻鍗昣2026-05-14_v1.md` to distinguish true remaining enhancement work from explicitly deferred real-trading scope. +Automation execution-layer PXX remains closed and its known scope stays frozen. Under explicit user direction, the active product phase has shifted from broad Decision Assistant expansion to mainline correction: follow `docs/AI_ICT_主线纠偏与功能裁剪方案_2026-05-20_v1.md`, keep only Trader Cockpit / Strategy Backtest / Trading Plan / Journal / System Ops in the trader-facing shell, and productize existing ICT backtest evidence before adding any new feature surface. ## Current Branch @@ -128,6 +128,10 @@ Automation execution-layer PXX remains closed and its known scope stays frozen. - latest_frontend_navigation_prune_status: implemented_deployed_remote_smoke_passed - latest_frontend_navigation_prune_closure: trader-facing left nav now only exposes Trader Cockpit, Strategy Backtest, Trading Plan, Journal, and System Ops; previous Decision Assistant/runtime/archive/alert/detail panels remain reachable only from System Ops advanced links; stale legacy localStorage advanced-section defaults fall back to Trader Cockpit unless the operator explicitly opens a hash. - latest_frontend_navigation_prune_validation: node --check app/i18n, focused static/docs tests, script static checks, full unit 790 tests, deploy smoke 11 endpoints, remote ops smoke, readonly live analysis smoke, and Playwright browser audit passed; browser audit confirmed zh-CN nav labels, System Ops advanced drill-down, active nav mapping, hidden generic filters on main slices, and zero console errors. +- latest_strategy_backtest_slice: readonly_strategy_backtest_surface +- latest_strategy_backtest_status: implemented_deployed_remote_smoke_passed +- latest_strategy_backtest_closure: Strategy Backtest now has `/trading/backtests`, a read-only workbench page with instrument/run controls, latest stored backtest, quality gate, sample/executed counts, total R, expectancy, profit factor, drawdown, loss streak, equity curve, top sessions, failure reasons, and recent run list. The page catches candle/backtest endpoint failures so one local DB permission issue does not blank the whole workbench. +- latest_strategy_backtest_validation: node --check app/i18n, focused API/static tests, `npm run build`, `npm run lint` full unit discovery 791 tests, `git diff --check` with only LF/CRLF warnings, deploy smoke 11 endpoints, remote ops smoke, readonly live analysis smoke, remote `/trading/backtests` field smoke, and browser audit on `http://192.168.3.90:8000/#strategy-backtest` passed. Remote latest run `#489` is correctly blocked by quality gate (`sample_size_too_small`, executed sample, expectancy, profit factor, consecutive-loss reasons) and the Chinese UI renders those as trader-readable reasons. - latest_decision_assistant_workflow_queue_slice: assistant_loop_queue - latest_decision_assistant_workspace_loop_slice: workflow_section_navigation_loop_queue - latest_decision_assistant_learning_focus_slice: learning_focus_queue @@ -820,7 +824,7 @@ Automation execution-layer PXX remains closed and its known scope stays frozen. ## Exact Next Step -Next continue the real Trader Mode usage loop over future live sessions: record more actual operator feedback until `operator_feedback_count >= 5`, wait for a real executable candidate before creating any paper ticket, only paper-execute a confirmed paper ticket, and convert only real execution-derived journal entries into weekly review evidence. Do not add broad product features until the trader reports which first-screen phrase still fails during live use. Keep `execution_allowed`, `real_live_ready`, risk governor, trust bucket, and operator confirmation protocol unchanged. +Next continue Step 4 of `docs/AI_ICT_主线纠偏与功能裁剪方案_2026-05-20_v1.md`: compress Trader Cockpit further so the first screen uses the new backtest evidence to answer only `now / reason / wait / risk / historical edge / boundary`. Do not restart external-context/archive/digest long-tail work, do not create order-submission features, and do not fabricate R2 paper samples; R2 evidence collection remains live but secondary to the current trader-facing mainline correction. Keep `execution_allowed`, `decision_support_ready`, `real_live_ready`, risk governor, trust bucket, and operator confirmation protocol unchanged. ## Validation Commands Used Recently diff --git a/TASKS.md b/TASKS.md index 7cb4916..985b9df 100644 --- a/TASKS.md +++ b/TASKS.md @@ -927,6 +927,8 @@ - [x] Existing UI i18n polish: phrase replacement no longer corrupts `not` / `enough`, existing Trader Mode/duty/chart/R2/runtime labels are more readable in Chinese, browser audit dropped visible English remnants from 3396 to 881, and deploy smoke / remote ops / readonly live smoke / gstack console check passed - [x] Existing UI i18n/R2 gate polish follow-up: existing Trader Mode/readiness/shadow-paper labels now translate runtime/gate jargon such as `Auto refresh`, `All Trust`, `Incident Journal`, `Live Readiness`, `paper_ticket_sample_gate`, and `operator_feedback_count`; R2 evidence window now follows canonical `failed_criteria`, remote browser audit has no repeated first-screen Chinese phrases and only 13 active-view English tokens left; deploy smoke, remote ops smoke, read-only live smoke, focused tests, static checks, and full unit (`790`) passed - [x] Session Focus dynamic Chinese label follow-up: existing i18n now translates reported dynamic session-focus labels/values (`ASIA`, `NY_PM`, `status`, `window`, `candidates`, `executable`, `open executions`, `alerts`, `recommendation`, `action hint`, `incomplete`, `observe @ n/a`, `avoid_new_risk`, `review_alerts`); remote browser audit on `/trader-mode#session-focus` passed with no reported English leftovers and no console errors +- [x] Trader mainline Step 3 Strategy Backtest productization: added read-only `/trading/backtests`, Strategy Backtest workbench controls, latest-run summary, quality gate explanation, R metrics, equity curve, top sessions, failure reasons, recent run list, Chinese labels for backtest gate reasons, and resilient candle/backtest failure fallback; full unit (`791`), deploy smoke, remote ops smoke, readonly live smoke, remote backtest endpoint smoke, and browser audit passed +- [ ] Trader mainline Step 4 cockpit recompression: use Strategy Backtest evidence on Trader Cockpit first screen and reduce it to now / reason / wait / risk / historical edge / boundary - [ ] R2 paper-ticket sample gate is waiting on a real executable candidate; do not fabricate paper tickets from blocked candidates - [ ] R2 operator feedback sample gate still needs 2 more real feedback samples (`operator_feedback_count=3/5`) - [ ] R2 weekly review visible gate still needs a real execution-derived journal/weekly review entry (`weekly_review_entry_count=0`) diff --git a/docs/AI_ICT_主线纠偏与功能裁剪方案_2026-05-20_v1.md b/docs/AI_ICT_主线纠偏与功能裁剪方案_2026-05-20_v1.md index e500935..9e77829 100644 --- a/docs/AI_ICT_主线纠偏与功能裁剪方案_2026-05-20_v1.md +++ b/docs/AI_ICT_主线纠偏与功能裁剪方案_2026-05-20_v1.md @@ -276,6 +276,14 @@ ICT 书本规则 - 曲线和样本表 - 回测质量门解释 +状态更新(2026-05-20): + +- [x] 已补 `/trading/backtests` 只读查询面。 +- [x] 已在 `Strategy Backtest` 页面展示最新已存回测、质量门、样本数、执行样本、总 R、期望 R、盈利因子、最大回撤、连续亏损、权益曲线、优势时段、失败原因、最近回测运行。 +- [x] 已补中文/英文前端文案,质量门阻塞原因不再直接暴露 snake_case 给交易员。 +- [x] 已完成本地完整单元测试、远端部署、远端 smoke、只读实盘分析 smoke、远端 backtest endpoint smoke 和浏览器页面审计。 +- [ ] 样本表的逐笔明细仍可后续增强,但不阻塞 Step 3 作为交易员可读回测页面闭环。 + ### Step 4:Trader Cockpit 重新压缩 Trader Cockpit 不再展示复杂系统状态。 @@ -336,4 +344,3 @@ Trader Cockpit 不再展示复杂系统状态。 下一轮应停止继续补 UI 文案和复杂面板,直接进入: > 回到《ICT交易全书》主线,裁剪导航,产品化 Strategy Backtest,并把 Trader Cockpit 压缩成极简交易员读数。 - diff --git a/src/api/__init__.py b/src/api/__init__.py index efa5b3a..d402af9 100644 --- a/src/api/__init__.py +++ b/src/api/__init__.py @@ -1,6 +1,6 @@ """Lightweight internal API query helpers.""" -from src.api.backtest import get_recent_backtest_summaries +from src.api.backtest import get_recent_backtest_summaries, get_strategy_backtest_surface from src.api.broker_state import get_broker_state from src.api.candles import get_recent_candles from src.api.execution_tickets import get_recent_execution_tickets @@ -141,6 +141,7 @@ __all__ = [ "close_execution", "sync_execution", "get_recent_backtest_summaries", + "get_strategy_backtest_surface", "dispatch_pending_alerts", "get_alert_deliveries", "get_alert_quality_summary", diff --git a/src/api/backtest.py b/src/api/backtest.py index 4f23ec3..8829406 100644 --- a/src/api/backtest.py +++ b/src/api/backtest.py @@ -1,3 +1,4 @@ +import json from typing import Optional from sqlalchemy import text @@ -6,11 +7,39 @@ from src.db.session import SessionLocal from src.services.backtest import evaluate_backtest_quality +def get_strategy_backtest_surface(instrument_id: Optional[int] = None, limit: int = 10) -> dict: + """Return the read-only Strategy Backtest cockpit surface.""" + safe_limit = max(1, min(int(limit or 10), 50)) + items = get_recent_backtest_summaries(instrument_id=instrument_id, limit=safe_limit) + latest = items[0] if items else None + latest_summary = dict(latest.get("summary") or {}) if latest else {} + quality_gate = latest_summary.get("quality_gate") if latest else None + return { + "instrument_id": instrument_id, + "item_count": len(items), + "latest": latest, + "quality_gate": quality_gate, + "operator_summary": _build_operator_summary(latest), + "items": items, + } + + def get_recent_backtest_summaries(instrument_id: Optional[int] = None, limit: int = 10) -> list[dict]: where_clause = "where instrument_id = :instrument_id" if instrument_id is not None else "" sql = text( f""" - select id, model_code, instrument_id, timeframe_set, started_ts, ended_ts, config, summary + select + id, + subject_type, + model_code, + instrument_id, + trading_plan_id, + candidate_model_code, + timeframe_set, + started_ts, + ended_ts, + config, + summary from backtest_runs {where_clause} order by started_ts desc @@ -26,7 +55,56 @@ def get_recent_backtest_summaries(instrument_id: Optional[int] = None, limit: in def normalize_backtest_summary_row(row: dict) -> dict: - summary = dict(row.get("summary") or {}) + summary = _coerce_json_dict(row.get("summary")) if "quality_gate" not in summary: summary["quality_gate"] = evaluate_backtest_quality(summary) - return {**row, "summary": summary} + return { + **row, + "timeframe_set": _coerce_json_value(row.get("timeframe_set")), + "config": _coerce_json_dict(row.get("config")), + "summary": summary, + } + + +def _build_operator_summary(latest: Optional[dict]) -> dict: + if not latest: + return { + "status": "missing_backtest", + "decision": "do_not_trust_strategy_yet", + "reason": "no_stored_no_lookahead_replay", + "next_action": "run_backtest_replay_no_lookahead", + } + summary = dict(latest.get("summary") or {}) + quality_gate = dict(summary.get("quality_gate") or {}) + passed = bool(quality_gate.get("passed")) + if passed: + status = "quality_gate_passed" + decision = "eligible_for_trading_plan_review" + reason = "sample_and_risk_metrics_passed" + else: + status = "quality_gate_blocked" + decision = "do_not_promote_strategy" + reasons = quality_gate.get("reasons") or [] + reason = str(reasons[0]) if reasons else "quality_gate_not_passed" + return { + "status": status, + "decision": decision, + "reason": reason, + "next_action": "review_failure_reasons_and_session_edges", + } + + +def _coerce_json_dict(value: object) -> dict: + parsed = _coerce_json_value(value) + return dict(parsed) if isinstance(parsed, dict) else {} + + +def _coerce_json_value(value: object) -> object: + if value is None: + return {} + if isinstance(value, str): + try: + return json.loads(value) + except json.JSONDecodeError: + return value + return value diff --git a/src/api/server.py b/src/api/server.py index ab4cd83..72d6f2a 100644 --- a/src/api/server.py +++ b/src/api/server.py @@ -6,6 +6,7 @@ from typing import Optional from urllib.parse import parse_qs, urlparse from src.api.broker_state import get_broker_state +from src.api.backtest import get_strategy_backtest_surface from src.api.candles import get_recent_candles from src.api.execution_tickets import get_recent_execution_tickets from src.api.health import get_health @@ -219,6 +220,7 @@ def build_api_dependencies() -> dict: "trading_journal_generate": generate_trading_journal, "trading_weekly_review": get_weekly_review, "trading_plans": get_recent_trading_plans, + "trading_backtests": get_strategy_backtest_surface, "training_cases": get_training_cases, "training_case": get_training_case, "training_export_cases": export_training_case_package, @@ -836,6 +838,10 @@ def route_request( instrument_id = _require_int(query, "instrument_id") limit = _get_int(query, "limit", 20) return 200, {"items": deps["trade_plan_candidates"](instrument_id=instrument_id, limit=limit)} + if method == "GET" and path == "/trading/backtests": + instrument_id = _get_optional_int(query, "instrument_id") + limit = _get_int(query, "limit", 10) + return 200, deps["trading_backtests"](instrument_id=instrument_id, limit=limit) if method == "GET" and len(path_parts) == 3 and path_parts[:2] == ["trading", "candidates"]: candidate = deps["trading_candidate_by_id"](_parse_path_int(path_parts[2], "candidate_id")) if candidate is None: diff --git a/src/web/app.js b/src/web/app.js index e8524e9..5fefed8 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -51,6 +51,10 @@ const tradeCandidatesPanel = document.getElementById('tradeCandidatesPanel'); const executionTicketsPanel = document.getElementById('executionTicketsPanel'); const alertHistoryPanel = document.getElementById('alertHistoryPanel'); const alertQualityPanel = document.getElementById('alertQualityPanel'); +const strategyBacktestPanel = document.getElementById('strategyBacktestPanel'); +const backtestInstrumentIdInput = document.getElementById('backtestInstrumentId'); +const backtestLimitInput = document.getElementById('backtestLimit'); +const backtestRefreshBtn = document.getElementById('backtestRefreshBtn'); const visualReviewsPanel = document.getElementById('visualReviewsPanel'); const positionManagementPanel = document.getElementById('positionManagementPanel'); const operatorConsolePanel = document.getElementById('operatorConsolePanel'); @@ -5481,6 +5485,150 @@ function renderTrustScore(payload) { trustScorePanel.appendChild(grid); } +function renderStrategyBacktests(payload) { + if (!strategyBacktestPanel) return; + clearNode(strategyBacktestPanel); + if (payload?.unavailable) { + strategyBacktestPanel.appendChild(createElement('div', 'empty-state error-state', 'Strategy backtest data unavailable.')); + return; + } + const latest = payload?.latest || (payload?.items || [])[0] || null; + if (!latest) { + strategyBacktestPanel.appendChild(createElement('div', 'empty-state', 'No stored backtest runs yet.')); + strategyBacktestPanel.appendChild( + buildInfoGrid([ + ['Next Action', 'Run replay no-lookahead backtest'], + ['Boundary', 'Read-only analysis; no order submission'], + ]) + ); + return; + } + const summary = latest.summary || {}; + const qualityGate = payload?.quality_gate || summary.quality_gate || {}; + const passed = Boolean(qualityGate.passed); + const operatorSummary = payload?.operator_summary || {}; + + strategyBacktestPanel.appendChild( + buildInfoGrid([ + ['Latest Backtest', `#${latest.id ?? 'n/a'} ${latest.model_code || ''}`.trim()], + ['Quality Gate', passed ? 'Passed' : 'Blocked'], + ['Decision', formatBacktestDecision(operatorSummary.decision)], + ['Reason', formatBacktestDecision(operatorSummary.reason)], + ['Instrument ID', latest.instrument_id ?? 'n/a'], + ['Timeframe', formatJsonInline(latest.timeframe_set || {})], + ['Started', formatTimestamp(latest.started_ts)], + ['Ended', formatTimestamp(latest.ended_ts)], + ['Mode', latest.config?.mode || latest.config?.backtest_mode || 'replay_no_lookahead'], + ]) + ); + + const cards = createElement('div', 'cards'); + cards.appendChild( + buildMetricCard('Sample Count', String(summary.total_signals ?? summary.candidate_count ?? 0), 'total replay samples') + ); + cards.appendChild( + buildMetricCard('Executed Samples', String(summary.executed_signals ?? 0), `executed rate ${formatPercent(summary.executed_rate)}`) + ); + cards.appendChild( + buildMetricCard('Total R', formatNumber(summary.total_r, 2), `expectancy ${formatNumber(summary.expectancy_r, 2)} R`) + ); + cards.appendChild( + buildMetricCard('Profit Factor', formatNumber(summary.profit_factor_r, 2), `win rate ${formatPercent(summary.win_rate)}`) + ); + cards.appendChild( + buildMetricCard('Max Drawdown R', formatNumber(summary.max_drawdown_r, 2), `loss streak ${summary.max_consecutive_losses ?? 0}`) + ); + cards.appendChild( + buildMetricCard('Quality Gate', passed ? 'Passed' : 'Blocked', formatList((qualityGate.reasons || []).map(formatBacktestDecision), 'No gate blockers')) + ); + strategyBacktestPanel.appendChild(cards); + + strategyBacktestPanel.appendChild(buildBacktestCurve(summary.cumulative_r_curve || [])); + + const detailGrid = createElement('div', 'cards compact-cards'); + detailGrid.appendChild( + buildItemCard( + 'Top Sessions', + 'review', + topBacktestBuckets(summary.session_performance || summary.by_session || {}).map((item) => [ + item.key, + `${formatNumber(item.expectancy_r, 2)} R / ${formatPercent(item.win_rate)} / ${item.count ?? 0} samples`, + ]) + ) + ); + detailGrid.appendChild( + buildItemCard( + 'Failure Reasons', + 'review', + topCountEntries(summary.failure_reasons || {}).map((item) => [formatBacktestDecision(item.key), String(item.value)]) + ) + ); + detailGrid.appendChild( + buildItemCard( + 'Recent Backtest Runs', + `${payload?.item_count ?? (payload?.items || []).length} runs`, + (payload?.items || []).slice(0, 5).map((item) => [ + `#${item.id}`, + `${item.model_code || 'model'} / ${formatTimestamp(item.started_ts)} / ${item.summary?.quality_gate?.passed ? 'Passed' : 'Blocked'}`, + ]) + ) + ); + strategyBacktestPanel.appendChild(detailGrid); +} + +function buildBacktestCurve(curve) { + const panel = createElement('div', 'backtest-curve-panel'); + panel.appendChild(createElement('div', 'assistant-section-title', 'Equity Curve')); + if (!Array.isArray(curve) || !curve.length) { + panel.appendChild(createElement('div', 'empty-state', 'No equity curve points recorded.')); + return panel; + } + const numbers = curve.map((value) => Number(value)).filter((value) => Number.isFinite(value)); + if (!numbers.length) { + panel.appendChild(createElement('div', 'empty-state', 'No equity curve points recorded.')); + return panel; + } + const min = Math.min(...numbers); + const max = Math.max(...numbers); + const range = Math.max(1, max - min); + const chart = createElement('div', 'backtest-curve'); + numbers.slice(-80).forEach((value) => { + const bar = createElement('span', `backtest-curve-bar${value < 0 ? ' is-negative' : ''}`); + bar.style.height = `${16 + ((value - min) / range) * 64}px`; + bar.title = `${formatNumber(value, 2)} R`; + chart.appendChild(bar); + }); + panel.appendChild(chart); + panel.appendChild( + buildInfoGrid([ + ['Curve Points', String(numbers.length)], + ['Last Total R', formatNumber(numbers[numbers.length - 1], 2)], + ['Best Total R', formatNumber(max, 2)], + ['Worst Total R', formatNumber(min, 2)], + ]) + ); + return panel; +} + +function topBacktestBuckets(bucketMap) { + return Object.entries(bucketMap || {}) + .map(([key, value]) => ({ key, ...(value || {}) })) + .sort((left, right) => Number(right.expectancy_r || 0) - Number(left.expectancy_r || 0)) + .slice(0, 5); +} + +function topCountEntries(countMap) { + return Object.entries(countMap || {}) + .map(([key, value]) => ({ key, value: Number(value || 0) })) + .sort((left, right) => right.value - left.value) + .slice(0, 5); +} + +function formatBacktestDecision(value) { + const text = String(value || 'n/a').replace(/_/g, ' '); + return text.replace(/\b\w/g, (letter) => letter.toUpperCase()); +} + function renderLiveReadiness(payload) { clearNode(liveReadinessPanel); const okxSandboxPreflight = payload?.okx_sandbox_preflight_summary || null; @@ -6485,6 +6633,7 @@ function showErrorPanel(message) { launchReadinessPanel, sessionFocusPanel, preMarketPlanPanel, + strategyBacktestPanel, tradeCandidatesPanel, executionTicketsPanel, alertHistoryPanel, @@ -6521,6 +6670,7 @@ function renderDashboardData({ liveReadiness, launchReadiness, sessionView, + strategyBacktests, tradeCandidates, executionTickets, tradingExecutions, @@ -6541,6 +6691,7 @@ function renderDashboardData({ renderLiveReadiness(liveReadiness); renderLaunchReadiness(launchReadiness); renderSessionFocus(sessionView); + renderStrategyBacktests(strategyBacktests); renderPreMarketPlan(tradingPlan); renderCandidates(tradeCandidates.items || []); renderTickets(executionTickets.items || []); @@ -6559,12 +6710,14 @@ async function refreshDashboard() { const instrumentId = instrumentIdInput.value || '1'; const timeframe = timeframeInput.value || '1m'; const limit = limitInput.value || '20'; + const backtestInstrumentId = backtestInstrumentIdInput?.value || instrumentId; + const backtestLimit = backtestLimitInput?.value || '10'; const profileKey = assistantDigestProfileKey || assistantPreferenceDraft?.profile_key || 'local_operator'; try { - const [health, candles, tradingPlan, marketState, runtimeStatus, runtimeIncidents, maintenanceSummary, decisionPacket, decisionAlerts, alertQualitySummary, operatorDigest, trustScore, liveReadiness, launchReadiness, sessionView, tradeCandidates, executionTickets, tradingExecutions, journalSummary, operatorNotes, reviewPacket, journalItems, weeklyReview] = await Promise.all([ + const [health, candles, tradingPlan, marketState, runtimeStatus, runtimeIncidents, maintenanceSummary, decisionPacket, decisionAlerts, alertQualitySummary, operatorDigest, trustScore, liveReadiness, launchReadiness, sessionView, strategyBacktests, tradeCandidates, executionTickets, tradingExecutions, journalSummary, operatorNotes, reviewPacket, journalItems, weeklyReview] = await Promise.all([ fetchJson('/health'), - fetchJson(`/candles?${queryString({ instrument_id: instrumentId, timeframe, limit })}`), + fetchJson(`/candles?${queryString({ instrument_id: instrumentId, timeframe, limit })}`).catch(() => ({ items: [] })), fetchJson(`/trading/plans/latest?${queryString({ instrument_id: instrumentId })}`).catch(() => null), fetchJson(`/trading/market-state?${queryString({ instrument_id: instrumentId, timeframe })}`).catch(() => ({ market_state: {} })), fetchJson('/trading/runtime-status').catch(() => ({ scheduler_state: 'unavailable', tasks: {}, current_blockers: ['runtime_status_unavailable'] })), @@ -6578,6 +6731,7 @@ async function refreshDashboard() { fetchJson(`/trading/live-readiness?${queryString({ instrument_id: instrumentId, timeframe })}`).catch(() => ({ ready_for_real_live: false, blocking_reasons: ['live_readiness_unavailable'], checklist: [] })), fetchJson(`/trading/launch-readiness?${queryString({ instrument_id: instrumentId, timeframe })}`).catch(() => ({ assisted_trading_ready: false, real_live_ready: false, blocking_reasons: ['launch_readiness_unavailable'], warning_reasons: [], checklist: [], next_actions: [] })), fetchJson(`/trading/session-view?${queryString({ instrument_id: instrumentId, limit: 12 })}`).catch(() => ({ items: [] })), + fetchJson(`/trading/backtests?${queryString({ instrument_id: backtestInstrumentId, limit: backtestLimit })}`).catch(() => ({ unavailable: true, items: [], latest: null })), fetchJson(`/trading/candidates?${queryString({ instrument_id: instrumentId, limit })}`).catch(() => ({ items: [] })), fetchJson(`/trading/tickets?${queryString({ instrument_id: instrumentId, limit })}`).catch(() => ({ items: [] })), fetchJson(`/trading/executions?${queryString({ instrument_id: instrumentId, limit })}`).catch(() => ({ items: [] })), @@ -6610,6 +6764,7 @@ async function refreshDashboard() { liveReadiness, launchReadiness, sessionView, + strategyBacktests, tradeCandidates, executionTickets, tradingExecutions, @@ -6636,6 +6791,9 @@ function configureAutoRefresh() { } refreshBtn.addEventListener('click', refreshDashboard); +backtestRefreshBtn?.addEventListener('click', refreshDashboard); +backtestInstrumentIdInput?.addEventListener('change', refreshDashboard); +backtestLimitInput?.addEventListener('change', refreshDashboard); executionStatusFilterInput.addEventListener('change', refreshDashboard); sessionFilterInput.addEventListener('change', refreshDashboard); searchTextInput.addEventListener('input', refreshDashboard); diff --git a/src/web/i18n.js b/src/web/i18n.js index fb9e528..a6c3f6b 100644 --- a/src/web/i18n.js +++ b/src/web/i18n.js @@ -41,6 +41,46 @@ 'Replay / no-lookahead': '重放 / 无未来函数', 'Primary Metrics': '核心指标', 'Expectancy R, profit factor, drawdown': '期望 R、盈利因子、回撤', + 'Quality Gate': '质量门', + 'Sample, expectancy, drawdown': '样本、期望、回撤', + 'Stored Runs': '已存回测', + 'Latest replay evidence': '最新重放证据', + 'Strategy Backtest Filters': '策略回测筛选', + 'Recent Runs': '最近回测', + 'Reload Backtests': '重新加载回测', + 'Strategy backtest data unavailable.': '策略回测数据不可用。', + 'No stored backtest runs yet.': '还没有已保存的回测运行。', + 'Run replay no-lookahead backtest': '运行无未来函数重放回测', + 'Latest Backtest': '最新回测', + 'Decision': '判断', + 'Reason': '原因', + 'Started': '开始时间', + 'Ended': '结束时间', + 'Mode': '模式', + 'Passed': '已通过', + 'Blocked': '被阻塞', + 'Sample Count': '样本数量', + 'Executed Samples': '执行样本', + 'total replay samples': '重放样本总数', + 'Total R': '总 R', + 'Profit Factor': '盈利因子', + 'Max Drawdown R': '最大回撤 R', + 'No gate blockers': '无质量门阻塞', + 'Equity Curve': '权益曲线', + 'No equity curve points recorded.': '未记录权益曲线点。', + 'Curve Points': '曲线点数', + 'Last Total R': '最新总 R', + 'Best Total R': '最佳总 R', + 'Worst Total R': '最差总 R', + 'Top Sessions': '优势时段', + 'Failure Reasons': '失败原因', + 'Recent Backtest Runs': '最近回测运行', + 'Eligible For Trading Plan Review': '可进入交易计划复核', + 'Do Not Promote Strategy': '不要晋级策略', + 'Do Not Trust Strategy Yet': '暂不信任策略', + 'Quality Gate Not Passed': '质量门未通过', + 'Sample And Risk Metrics Passed': '样本与风险指标已通过', + 'Review Failure Reasons And Session Edges': '复核失败原因与时段优势', 'Next Cut': '下一刀', 'Trader-facing controls': '交易员控制面', 'Instrument ID': '品种 ID', @@ -450,6 +490,35 @@ ['avoid @', '回避 @'], ['review_alerts', '复核提醒'], ['avoid_new_risk', '回避新增风险'], + ['bias_tf', '方向周期'], + ['entry_then_outcome', '入场后结果评估'], + ['Entry Then Outcome', '入场后结果评估'], + ['Sample Size Too Small', '样本数量太少'], + ['Executed Sample Too Small', '执行样本太少'], + ['Expectancy Below Threshold', '期望值低于门槛'], + ['Profit Factor Below Threshold', '盈利因子低于门槛'], + ['Consecutive Losses Above Threshold', '连续亏损超出门槛'], + ['Stopped Out', '止损出场'], + ['Outside Session Window', '不在允许时段'], + ['Same Candle Entry Exit Ambiguous', '同一K线入场出场不清晰'], + ['Blocked', '被阻塞'], + ['Passed', '已通过'], + ['executed rate', '执行率'], + ['expectancy', '期望'], + ['win rate', '胜率'], + ['loss streak', '连续亏损'], + ['samples', '样本'], + ['runs', '次运行'], + ['replay_no_lookahead', '无未来函数重放'], + ['quality gate passed', '质量门已通过'], + ['quality gate blocked', '质量门被阻塞'], + ['quality gate not passed', '质量门未通过'], + ['sample and risk metrics passed', '样本与风险指标已通过'], + ['eligible for trading plan review', '可进入交易计划复核'], + ['do not promote strategy', '不要晋级策略'], + ['do not trust strategy yet', '暂不信任策略'], + ['run backtest replay no lookahead', '运行无未来函数重放回测'], + ['missing backtest', '缺少回测'], ['incomplete', '未完成'], ['open executions', '未平仓执行'], ['action hint', '动作提示'], diff --git a/src/web/index.html b/src/web/index.html index 96e0b01..e1e7e66 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -235,14 +235,20 @@
Replay / no-lookahead
-
Primary Metrics
-
Expectancy R, profit factor, drawdown
+
Quality Gate
+
Sample, expectancy, drawdown
-
Next Cut
-
Trader-facing controls
+
Stored Runs
+
Latest replay evidence
+
+ + + +
+
diff --git a/src/web/styles.css b/src/web/styles.css index d3d5cb5..c15be18 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -374,6 +374,37 @@ th { color: #9db0d3; } grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 9px; } +.strategy-backtest-controls { + margin: 14px 0 12px; +} +.compact-cards { + margin-top: 12px; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); +} +.backtest-curve-panel { + margin-top: 14px; +} +.backtest-curve { + display: flex; + align-items: flex-end; + gap: 3px; + min-height: 92px; + padding: 10px; + border: 1px solid #24304f; + border-radius: 8px; + background: #0b1228; +} +.backtest-curve-bar { + flex: 1 1 4px; + min-width: 4px; + max-width: 18px; + border-radius: 5px 5px 0 0; + background: #4f7cff; + opacity: 0.88; +} +.backtest-curve-bar.is-negative { + background: #eab308; +} .assistant-scan-grid { grid-template-columns: repeat(auto-fit, minmax(145px, 1fr)); } diff --git a/tests/unit/test_api_server.py b/tests/unit/test_api_server.py index 7e1ff95..14145fd 100644 --- a/tests/unit/test_api_server.py +++ b/tests/unit/test_api_server.py @@ -79,6 +79,29 @@ class ApiServerRoutingTests(unittest.TestCase): self.assertEqual(status_alerts, 200) self.assertEqual(payload_alerts["items"], [{"id": 1, "alert_type": "candidate_actionable"}]) + def test_routes_trading_backtests(self) -> None: + captured = {} + + def fake_backtests(**kwargs): + captured.update(kwargs) + return { + "instrument_id": kwargs["instrument_id"], + "latest": {"id": 7, "summary": {"quality_gate": {"passed": True}}}, + "quality_gate": {"passed": True}, + "items": [{"id": 7}], + } + + status, payload = route_request( + "/trading/backtests", + {"instrument_id": ["1"], "limit": ["3"]}, + dependencies={"trading_backtests": fake_backtests}, + ) + + self.assertEqual(status, 200) + self.assertEqual(captured, {"instrument_id": 1, "limit": 3}) + self.assertEqual(payload["latest"]["id"], 7) + self.assertTrue(payload["quality_gate"]["passed"]) + def test_routes_trading_alert_quality_summary(self) -> None: status, payload = route_request( "/trading/alerts/quality-summary", diff --git a/tests/unit/test_api_static_server.py b/tests/unit/test_api_static_server.py index fe3126d..3180081 100644 --- a/tests/unit/test_api_static_server.py +++ b/tests/unit/test_api_static_server.py @@ -156,6 +156,9 @@ class ApiStaticServerTests(unittest.TestCase): 'data-workbench-section="journal"', 'data-workbench-section="system-ops"', 'id="traderStatusPanel"', + 'id="strategyBacktestPanel"', + 'id="backtestInstrumentId"', + 'id="backtestRefreshBtn"', 'data-system-ops-target="decision-assistant"', 'data-workbench-section="decision-assistant"', 'data-workbench-section="readiness"', @@ -202,6 +205,7 @@ class ApiStaticServerTests(unittest.TestCase): "data-active-workbench-section", ".language-switcher", ".system-ops-link-grid", + ".backtest-curve", ): self.assertIn(expected, styles_css) @@ -221,6 +225,8 @@ class ApiStaticServerTests(unittest.TestCase): "交易员模式", "交易员驾驶舱", "策略回测", + "质量门", + "重新加载回测", "系统运维", "决策助手", "仅观察", @@ -299,6 +305,7 @@ class ApiStaticServerTests(unittest.TestCase): "/trading/runtime-incidents", "/trading/maintenance-summary", "/trading/visual-watch/cleanup-captures", + "/trading/backtests", "/trading/tickets", "/trading/executions", "/trading/journal", @@ -313,7 +320,7 @@ class ApiStaticServerTests(unittest.TestCase): "/execution-tickets", "/trading-journal", "/trading-journal-summary", - "/backtests", + "fetchJson('/backtests", "fetchJson('/executions", "/training/", ):