Improve workbench UX and read-only live readiness

This commit is contained in:
Codex
2026-05-20 11:45:06 +08:00
parent 63b3d11b19
commit 6edf2e39ae
18 changed files with 542 additions and 94 deletions
+2 -1
View File
@@ -797,11 +797,12 @@ Automation execution-layer PXX remains closed and its known scope stays frozen.
- Added `docs/AI_ICT_实盘辅助分析决策助手上线必做清单_2026-05-18_v1.md` for the urgent launch target: read-only live analysis assistant, no order submission, no trading, no direct buy/sell instruction. The must-do launch queue is P0-1 read-only market data recovery, P0-2 `live_analysis_assistant_ready` narrow readiness, P0-3 forced boundary and de-traded UI, P0-4 one-screen cockpit health, P0-6 read-only launch smoke, and P0-5 minimal observation/note/weekly-review operations loop.
- Trader Mode P1/P2 live gate recovered on 2026-05-18: used `yuqei` SSH + sudo with the user-provided temporary password, copied the complete existing root deploy `.env` into `/home/yuqei/project/ai-exchange/.env` without exposing secret values, redeployed via `scripts/deploy_with_password.py --host 192.168.3.90 --user yuqei --port 22 --remote-path /home/yuqei/project/ai-exchange --remote-archive /home/yuqei/ai-ict-deploy.tgz`, deploy smoke passed 11 endpoints, standard `run_remote_ops_smoke.py` passed all checks including `unattended_harness`, and `run_readonly_live_analysis_smoke.py --base-url http://192.168.3.90:8000 --attempts 2 --timeout-seconds 30 --output-json` passed with `ready_for_readonly_live_analysis=true`, `must_not_trade=true`, `trade_permission_required=false`, and `submit_cancel_or_live_execution_called=false`. Remote readiness now reports `market_stream_connected`, `market_data_gap_clear`, `readonly_account_context_status=available`, `product_operating_mode=restricted_decision_support`, and `real_live_ready=false`.
- Low-risk sensitive credential cleanup passed on 2026-05-18 without rotating or changing any live credentials: removed tracked legacy expect scripts that embedded password sends, replaced rollback docs/checker with `deploy_with_password.py` plus runtime env injection, added redacted `scripts/run_secret_static_checks.py`, wired it into `run_script_static_checks.py` and the Trading Delivery Checklist, ignored `tmp_*.jsonl` / `*_capture.jsonl`, and documented the boundary in `docs/AI_ICT_敏感凭据低风险清理记录_2026-05-18_v1.md`. Validation passed secret static checks, script static checks, 35 focused tests, `git diff --check` with only LF/CRLF warnings, and full unit discovery (`779` tests). Current tree secret scan passed; Git history was not rewritten, so any historical exposure should be handled by a future explicit rotation window after backup login and smoke paths are confirmed.
- Workbench UX + read-only live usefulness recovery passed on 2026-05-20 after real intraday testing showed the root workbench was too long, hard to use, and runtime-dependent panels could appear ineffective. `/` now uses a left-menu/right-workspace layout with hash/localStorage section switching, default `Decision Assistant`, grouped `Readiness` and `Alerts` pages, and `/trader-mode` remains the fast-read entry. Frontend `fetchJson` now has request timeout fallback so slow review/readiness endpoints no longer keep the entire workbench stuck on Loading. Remote blockers were also recovered: PostgreSQL `pg_hba` accepted the Docker network, compose now maps `host.docker.internal`, non-root deploy env backup stays inside the remote app path, DB object ownership was reassigned to the migration/app user, the local proxy was relaunched, remote runtime env was restored with `TRADING_RUNTIME_ENABLED=1` / market and broker proxy URLs / `LIVE_TRADING_ENABLED=0`, and read-only launch readiness now treats fresh REST market data plus gap-clear state as usable while surfacing stream/news/account issues as warnings. Validation passed focused workbench/readiness/maintenance/deploy tests, secret/static script checks, Playwright screenshots at 1366x768 and 390x844 plus an Alerts hash view, full unit discovery, deploy smoke over 11 endpoints, standard remote ops smoke including `unattended_harness`, and remote `run_readonly_live_analysis_smoke.py` with `ready_for_readonly_live_analysis=true`, `must_not_trade=true`, `trade_permission_required=false`, and `submit_cancel_or_live_execution_called=false`. Current warning state: market stream may still be connecting while REST candles are fresh, news provider may be stale, and OKX account context may be unavailable; these warnings do not change `execution_allowed`, `decision_support_ready`, `real_live_ready`, risk governor, trust bucket, or operator confirmation protocol.
- Current assisted-analysis next gate: keep R1/R2 focused on `decision_support_ready` trust/sample/quality evidence and shadow/paper/operator feedback collection. OKX trade-permission sandbox smoke is deferred until the project explicitly re-enters execution adapter or real-trading preparation.
## Exact Next Step
Use `docs/AI_ICT_实盘辅助分析决策助手上线必做清单_2026-05-18_v1.md` as the urgent launch anchor. The product goal remains read-only live analysis assistant: no orders, no trading, no direct trade instruction. P0, P1, and the currently feasible P2 enhancements are implemented, deployed, and remotely smoke-validated. The next real gate is R2 evidence collection: wait for or surface real executable candidates, then collect paper ticket, paper execution, operator feedback, and weekly review samples without fabricating evidence. Keep `execution_allowed`, `real_live_ready`, risk governor, trust bucket, and operator confirmation protocol unchanged.
Next improve actual trader usefulness now that the frontend and remote runtime are usable: compress Trader Mode and watchlist copy around `why no trade`, `what to watch next`, unique focus quality, and stale/no-candidate states, then continue R2 paper-ticket / paper-execution / operator-feedback / weekly-review evidence collection without fabricating samples. Keep `execution_allowed`, `real_live_ready`, risk governor, trust bucket, and operator confirmation protocol unchanged.
## Validation Commands Used Recently
+4 -1
View File
@@ -160,6 +160,9 @@
- [x] Trader Mode P2 local enhancement closure: chart context bridge starter, BTC/ETH/SOL fast queue, after-close review draft, and ops duty panel implemented and locally validated without connecting real TradingView MCP or changing execution/readiness gates
- [x] Trader Mode P1/P2 remote deploy/smoke closure: used `yuqei` SSH + sudo to migrate the complete read-only `.env` into `/home/yuqei/project/ai-exchange/.env`, redeployed current worktree, and passed deploy smoke, standard remote ops smoke, `unattended_harness`, and readonly live analysis smoke
- [x] Low-risk sensitive credential cleanup complete: removed tracked expect scripts with embedded password sends, switched rollback docs/checker to `deploy_with_password.py` + runtime env, added redacted `run_secret_static_checks.py`, wired it into script static checks and Trading Delivery Checklist, and kept all real remote/runtime credentials unchanged
- [x] Workbench frontend split-navigation closure: converted the long root workbench into a left-menu/right-workspace layout, added hash/localStorage section switching, added frontend request timeout fallback so slow review/readiness APIs no longer freeze the page, and validated desktop/narrow rendering
- [x] Live usefulness blocker recovery: root workbench split-navigation is usable, slow frontend requests time out instead of freezing the page, remote DB/compose/deploy/migration/proxy/runtime blockers were recovered, standard remote ops smoke including `unattended_harness` passed, and readonly live analysis smoke passed without enabling order submission
- [ ] Next trader-usefulness refinement: compress Trader Mode / watchlist language around why no trade, what to watch next, unique focus quality, stale data, and no-candidate states so the first screen answers live operator questions faster
- [ ] R2 next collection action after readonly launch: wait for or surface a real executable candidate, then build shadow paper ticket / confirm paper execution / record operator feedback / add weekly review evidence without changing execution/live gates
- [x] R3 OKX sandbox preflight starter implemented and deployed: signed dry-run request previews, idempotency key preview, error normalization catalog, `/trading/okx-sandbox-preflight`, live-readiness summary, cockpit surface, deploy smoke, remote ops smoke, and remote field/UI smoke
- [x] R3 OKX sandbox isolated smoke tooling implemented and deployed: explicit `OKX_SANDBOX_SMOKE_EXECUTE` gate, submit/query/cancel/query/fills script, JSON artifact, proxy reuse, permission-error normalization, focused tests, full unit, deploy smoke, and remote ops smoke
@@ -906,7 +909,7 @@
- [x] R2 historical shadow observation backfill audit: prior 14-day candidate history scanned, 10 blocked-candidate observation rows safely created, and paper/operator backfill correctly refused without executable candidates or human review
- [ ] R2 continuous evidence collection gate: `shadow_candidate_day_coverage` has been cleared; now reach enough executable-candidate paper tickets, paper executions, operator feedback, and weekly review samples before claiming R2 phase evidence passed
- [x] Remote API/SSH recovery gate: `ai-ict` health, SSH command execution, and no-proxy remote ops smoke recovered
- [ ] Proxy/runtime recovery gate: restore configured OKX read-only proxy reachability, then rerun `POST /trading/runtime/run-once`, no-proxy remote ops smoke, and live readiness checks before making any new R2 sample writes
- [x] Proxy/runtime recovery gate: configured OKX read-only proxy reachability recovered, remote runtime run-once succeeded, market REST candles became fresh with gap clear, standard remote ops smoke passed, and readonly live analysis smoke passed while keeping stream/news/account issues as warnings
- [x] Create urgent read-only live analysis launch must-do checklist: `docs/AI_ICT_实盘辅助分析决策助手上线必做清单_2026-05-18_v1.md`
- [x] P0-1 read-only market data recovery: live smoke confirms `market_data_fresh`, `market_stream_connected`, and `market_data_gap_clear` without enabling trading
- [x] P0-2 narrow `live_analysis_assistant_ready` readiness: read-only analysis launches while `decision_support_ready=false`, `execution_assist_ready=false`, and `real_live_ready=false`
+2
View File
@@ -6,6 +6,8 @@ services:
container_name: ai-ict
env_file:
- .env
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "8000:8000"
volumes:
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+1 -1
View File
@@ -120,7 +120,7 @@ def run_remote(client, command: str, *, timeout: int = 120, sudo_password: Optio
def build_deploy_command(*, remote_path: str, remote_archive: str) -> str:
remote_path_q = _sh_quote(remote_path)
remote_archive_q = _sh_quote(remote_archive)
env_backup = "/root/ai-ict-deploy.env.bak"
env_backup = f"{remote_path.rstrip('/')}/.env.deploy.bak"
return " && ".join(
[
f"mkdir -p {remote_path_q}",
@@ -60,6 +60,10 @@ class MaintenanceSummaryService:
warnings=warnings,
fallback={"runtime_health": {}, "runtime_watchdog": {}, "current_blockers": []},
)
runtime_blockers = effective_runtime_blockers_for_readonly(runtime_status)
suppressed_runtime_blockers = [
blocker for blocker in list(runtime_status.get("current_blockers") or []) if blocker not in runtime_blockers
]
checks = _build_checks(
storage=storage,
alert_quality=alert_quality,
@@ -94,7 +98,9 @@ class MaintenanceSummaryService:
"runtime_health": dict(runtime_status.get("runtime_health") or {}),
"runtime_watchdog": dict(runtime_status.get("runtime_watchdog") or {}),
"incident_summary": dict(incident_summary or {}),
"current_blockers": list(runtime_status.get("current_blockers") or []),
"current_blockers": runtime_blockers,
"raw_current_blockers": list(runtime_status.get("current_blockers") or []),
"suppressed_blockers": suppressed_runtime_blockers,
"current_warnings": list(runtime_status.get("current_warnings") or []),
},
"checks": checks,
@@ -185,12 +191,13 @@ def _build_checks(*, storage: dict, alert_quality: dict, incident_summary: dict,
"automation": "operator_review_required",
},
]
if runtime_status.get("current_blockers"):
runtime_blockers = effective_runtime_blockers_for_readonly(runtime_status)
if runtime_blockers:
checks.append(
{
"name": "runtime.current_blockers",
"status": "blocked",
"detail": ", ".join(runtime_status.get("current_blockers") or []),
"detail": ", ".join(runtime_blockers),
"automation": "external_environment_or_runtime_recovery_required",
}
)
@@ -211,7 +218,7 @@ def _build_actions(*, storage: dict, alert_quality: dict, incident_summary: dict
for action in list(incident_summary.get("recommended_actions") or []):
if action not in operator_actions:
operator_actions.append(action)
if runtime_status.get("current_blockers") and "resolve_runtime_blockers" not in operator_actions:
if effective_runtime_blockers_for_readonly(runtime_status) and "resolve_runtime_blockers" not in operator_actions:
operator_actions.append("resolve_runtime_blockers")
return {
"automatic_actions": automatic_actions,
@@ -224,6 +231,23 @@ def _build_actions(*, storage: dict, alert_quality: dict, incident_summary: dict
}
def effective_runtime_blockers_for_readonly(runtime_status: dict) -> list[str]:
blockers = [str(item) for item in list(runtime_status.get("current_blockers") or []) if item]
return [blocker for blocker in blockers if not _is_readonly_stream_fallback_blocker(blocker, runtime_status)]
def _is_readonly_stream_fallback_blocker(blocker: str, runtime_status: dict) -> bool:
if not blocker.startswith("market_stream_degraded:"):
return False
parts = blocker.split(":")
symbol = parts[1] if len(parts) > 1 else ""
states = dict(runtime_status.get("latest_market_state_by_symbol") or {})
market_state = dict(states.get(symbol) or {})
freshness_code = str(((market_state.get("freshness") or {}).get("code")) or "")
gap_code = str(((market_state.get("data_gap_state") or {}).get("code")) or "")
return freshness_code == "market_data_fresh" and gap_code == "market_data_gap_clear"
def _safe_section(fetcher, *, warning_code: str, warnings: list[str], fallback):
try:
return fetcher()
@@ -6,7 +6,10 @@ from typing import Callable, Optional
from src.config.settings import settings
from src.services.alerts import AlertQualitySummaryService, OperatorDigestService
from src.services.alerts.delivery_policy_service import evaluate_alert_delivery_policy
from src.services.maintenance.maintenance_summary_service import MaintenanceSummaryService
from src.services.maintenance.maintenance_summary_service import (
MaintenanceSummaryService,
effective_runtime_blockers_for_readonly,
)
from src.services.runtime.runtime_task_watchdog_service import RuntimeTaskWatchdogService
from src.services.visual_watch import VisualSnapshotCaptureService
@@ -195,7 +198,7 @@ def _check_maintenance_summary(payload: dict) -> dict:
def _check_runtime_environment(payload: dict) -> dict:
blockers = list(payload.get("current_blockers") or [])
blockers = effective_runtime_blockers_for_readonly(payload)
if blockers:
return _blocked("runtime_environment", f"runtime blockers: {blockers}", environment=True)
if payload.get("status") == "unavailable":
@@ -76,11 +76,12 @@ class LaunchReadinessService:
required=real_trade_sample_gate_required,
real_trade_count=real_trade_count,
)
market_state_payload = market_state.to_dict()
checklist = [
LaunchReadinessCheck(
code="market_truth_ready",
passed=_market_truth_ready(market_state.to_dict()),
passed=_market_truth_ready(market_state_payload),
severity="blocker",
detail=f"freshness={market_state.freshness.code}, stream={market_state.stream_state.code}, gap={market_state.data_gap_state.code}",
),
@@ -140,14 +141,25 @@ class LaunchReadinessService:
blockers = [item.code for item in checklist if not item.passed and item.severity == "blocker"]
warnings = [item.code for item in checklist if not item.passed and item.severity == "warning"]
account_context_available = broker_state.status == BROKER_STATE_OK
readonly_market_usable = _market_truth_readonly_usable(market_state_payload)
readonly_runtime_usable = _runtime_readonly_usable(runtime_status)
live_analysis_blockers = [
item.code
for item in checklist
if not item.passed
and item.severity == "blocker"
and item.code != "broker_state_ready"
and not (item.code == "market_truth_ready" and readonly_market_usable)
and not (item.code == "news_provider_usable" and readonly_market_usable)
and not (item.code == "runtime_ready" and readonly_market_usable and readonly_runtime_usable)
]
live_analysis_warnings = list(warnings)
if "market_truth_ready" in blockers and readonly_market_usable:
live_analysis_warnings.append("market_stream_not_connected_rest_fallback")
if "news_provider_usable" in blockers and readonly_market_usable:
live_analysis_warnings.append("news_provider_unavailable_context_warning")
if "runtime_ready" in blockers and readonly_market_usable and readonly_runtime_usable:
live_analysis_warnings.append("runtime_stream_degraded_rest_fallback")
if not account_context_available:
live_analysis_warnings.append("account_context_unavailable")
live_analysis_assistant_ready = not live_analysis_blockers
@@ -280,6 +292,22 @@ def _market_truth_ready(payload: dict) -> bool:
return freshness_code == "market_data_fresh" and stream_code == "market_stream_connected" and gap_code == "market_data_gap_clear"
def _market_truth_readonly_usable(payload: dict) -> bool:
freshness_code = str(((payload.get("freshness") or {}).get("code")) or "")
gap_code = str(((payload.get("data_gap_state") or {}).get("code")) or "")
return freshness_code == "market_data_fresh" and gap_code == "market_data_gap_clear"
def _runtime_readonly_usable(runtime_status: dict) -> bool:
if runtime_status.get("scheduler_state") != "running":
return False
blockers = [str(item) for item in list(runtime_status.get("current_blockers") or [])]
if not blockers:
return True
allowed_prefixes = ("market_stream_degraded:",)
return all(any(blocker.startswith(prefix) for prefix in allowed_prefixes) for blocker in blockers)
def _resolve_real_trade_sample_gate_status(*, required: bool, real_trade_count: int) -> str:
if not required:
return "deferred_analysis_decision_only"
+107 -7
View File
@@ -10,6 +10,11 @@ const searchTextInput = document.getElementById('searchText');
const trustClassFilterInput = document.getElementById('trustClassFilter');
const lastUpdated = document.getElementById('lastUpdated');
const activeFilters = document.getElementById('activeFilters');
const workbenchNav = document.getElementById('workbenchNav');
const workbenchContent = document.getElementById('workbenchContent');
const workbenchViewLabel = document.getElementById('workbenchViewLabel');
const workbenchSections = Array.from(document.querySelectorAll('[data-workbench-section]'));
const workbenchNavButtons = Array.from(document.querySelectorAll('[data-workbench-target]'));
const overviewCards = document.getElementById('overviewCards');
const decisionAssistantPanel = document.getElementById('decisionAssistantPanel');
const marketStatePanel = document.getElementById('marketStatePanel');
@@ -82,20 +87,36 @@ let assistantSurfaceResolutionFilter = 'all';
async function fetchJson(url, options = {}) {
const fetchOptions = { ...options };
const timeoutMs = Number(fetchOptions.timeoutMs || 6000);
delete fetchOptions.timeoutMs;
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), timeoutMs);
if (!fetchOptions.signal) {
fetchOptions.signal = controller.signal;
}
if (fetchOptions.body) {
fetchOptions.headers = {
'Content-Type': 'application/json',
...(fetchOptions.headers || {}),
};
}
const response = await fetch(url, fetchOptions);
if (!response.ok) {
throw new Error(`${response.status} ${url}`);
try {
const response = await fetch(url, fetchOptions);
if (!response.ok) {
throw new Error(`${response.status} ${url}`);
}
if (response.status === 204) {
return null;
}
return response.json();
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(`request_timeout ${url}`);
}
throw error;
} finally {
window.clearTimeout(timeout);
}
if (response.status === 204) {
return null;
}
return response.json();
}
async function postJson(url, body = {}) {
@@ -129,8 +150,81 @@ function clearNode(node) {
}
}
function getStoredWorkbenchSection() {
try {
return window.localStorage?.getItem('aiIctActiveWorkbenchSection') || '';
} catch {
return '';
}
}
function storeWorkbenchSection(sectionId) {
try {
window.localStorage?.setItem('aiIctActiveWorkbenchSection', sectionId);
} catch {
// Local storage can be disabled in hardened browser sessions.
}
}
function getWorkbenchSection(sectionId) {
return workbenchSections.find((section) => section.dataset.workbenchSection === sectionId) || null;
}
function activateWorkbenchSection(sectionId, options = {}) {
if (!workbenchSections.length) return;
const fallback = getWorkbenchSection('decision-assistant') || workbenchSections[0];
const target = getWorkbenchSection(sectionId) || fallback;
const activeId = target.dataset.workbenchSection;
workbenchSections.forEach((section) => {
const isActive = section === target;
section.classList.toggle('is-active', isActive);
section.toggleAttribute('hidden', !isActive);
});
workbenchNavButtons.forEach((button) => {
const isActive = button.dataset.workbenchTarget === activeId;
button.classList.toggle('is-active', isActive);
button.setAttribute('aria-current', isActive ? 'page' : 'false');
});
setText(workbenchViewLabel, target.dataset.workbenchLabel || activeId);
if (options.persist !== false) {
storeWorkbenchSection(activeId);
}
if (options.updateHash) {
window.history.replaceState(null, '', `#${activeId}`);
}
if (options.scrollToTop && workbenchContent) {
workbenchContent.scrollTo({ top: 0, behavior: options.smooth ? 'smooth' : 'auto' });
}
}
function initializeWorkbenchNavigation() {
if (!workbenchNav || !workbenchSections.length) return;
workbenchNav.addEventListener('click', (event) => {
const button = event.target.closest?.('[data-workbench-target]');
if (!button) return;
activateWorkbenchSection(button.dataset.workbenchTarget, {
scrollToTop: true,
smooth: true,
updateHash: true,
});
});
const hashSection = window.location.hash ? window.location.hash.replace(/^#\/?/, '') : '';
activateWorkbenchSection(hashSection || getStoredWorkbenchSection() || 'decision-assistant', {
persist: false,
scrollToTop: false,
});
}
function revealPanel(node) {
const panel = node?.closest?.('.panel') || node;
const workbenchSection = panel?.closest?.('[data-workbench-section]') || panel;
if (workbenchSection?.dataset?.workbenchSection) {
activateWorkbenchSection(workbenchSection.dataset.workbenchSection, {
scrollToTop: false,
persist: true,
updateHash: true,
});
}
if (panel && typeof panel.scrollIntoView === 'function') {
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
@@ -585,6 +679,11 @@ function normalizeAssistantSectionId(title) {
function revealAssistantSection(sectionId) {
if (!decisionAssistantPanel) return;
activateWorkbenchSection('decision-assistant', {
scrollToTop: false,
persist: true,
updateHash: true,
});
const aliases = {
watchlist: 'watchlist-review-now',
notes: 'review-entry',
@@ -6488,5 +6587,6 @@ searchTextInput.addEventListener('input', refreshDashboard);
trustClassFilterInput.addEventListener('change', refreshDashboard);
autoRefreshSecondsInput.addEventListener('change', configureAutoRefresh);
initializeWorkbenchNavigation();
configureAutoRefresh();
refreshDashboard();
+98 -77
View File
@@ -6,7 +6,7 @@
<title>AI ICT Trading Workbench</title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
<body class="workbench-page">
<header class="topbar">
<div>
<h1>AI ICT Trading Workbench</h1>
@@ -18,153 +18,174 @@
</div>
</header>
<section class="controls">
<label>Instrument ID <input id="instrumentId" type="number" value="1" min="1" /></label>
<label>Timeframe <input id="timeframe" type="text" value="1m" /></label>
<label>Limit <input id="limit" type="number" value="20" min="1" max="200" /></label>
<label>Auto Refresh
<select id="autoRefreshSeconds">
<option value="0">Off</option>
<option value="5">5s</option>
<option value="10" selected>10s</option>
<option value="30">30s</option>
</select>
</label>
<label>Execution Status
<select id="executionStatusFilter">
<option value="all" selected>All</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
<option value="pending">Pending</option>
<option value="rejected">Rejected</option>
<option value="cancelled">Cancelled</option>
</select>
</label>
<label>Session Focus
<select id="sessionFilter">
<option value="all" selected>All Sessions</option>
<option value="LONDON">LONDON</option>
<option value="NY_AM">NY_AM</option>
<option value="NY_PM">NY_PM</option>
<option value="ASIA">ASIA</option>
<option value="OFF_HOURS">OFF_HOURS</option>
</select>
</label>
<label>Trust Class
<select id="trustClassFilter">
<option value="all" selected>All Trust</option>
<option value="whitelist">Whitelist</option>
<option value="observe">Observe</option>
<option value="blacklist">Blacklist</option>
</select>
</label>
<label>Search
<input id="searchText" type="text" placeholder="candidate, ticket, execution, reason" />
</label>
<button id="refreshBtn" type="button">Refresh</button>
<div id="lastUpdated" class="last-updated">Waiting for first refresh</div>
</section>
<div class="workbench-layout">
<aside class="workbench-sidebar" aria-label="Workbench navigation">
<div class="workbench-sidebar-header">
<div class="metric">Workspace</div>
<div id="workbenchViewLabel" class="workbench-view-label">Decision Assistant</div>
</div>
<nav id="workbenchNav" class="workbench-nav">
<a class="workbench-nav-link workbench-nav-primary" href="/trader-mode">Trader Mode</a>
<button type="button" data-workbench-target="overview">Overview</button>
<button type="button" data-workbench-target="decision-assistant">Decision Assistant</button>
<button type="button" data-workbench-target="trading-status">Trading Status</button>
<button type="button" data-workbench-target="runtime-supervisor">Runtime Supervisor</button>
<button type="button" data-workbench-target="decision-packet">Decision Packet</button>
<button type="button" data-workbench-target="readiness">Readiness</button>
<button type="button" data-workbench-target="session-focus">Session Focus</button>
<button type="button" data-workbench-target="pre-market-plan">Pre-Market Plan</button>
<button type="button" data-workbench-target="intraday-candidates">Intraday Candidates</button>
<button type="button" data-workbench-target="execution-tickets">Execution Tickets</button>
<button type="button" data-workbench-target="alerts">Alerts</button>
<button type="button" data-workbench-target="visual-reviews">Visual Reviews</button>
<button type="button" data-workbench-target="position-management">Position Management</button>
<button type="button" data-workbench-target="operator-console">Operator Console</button>
<button type="button" data-workbench-target="post-trade-review">Post-Trade Review</button>
<button type="button" data-workbench-target="candles">Candles</button>
</nav>
</aside>
<section class="filters-bar">
<div id="activeFilters" class="metric">Filters: none</div>
</section>
<div id="workbenchContent" class="workbench-content">
<section class="controls">
<label>Instrument ID <input id="instrumentId" type="number" value="1" min="1" /></label>
<label>Timeframe <input id="timeframe" type="text" value="1m" /></label>
<label>Limit <input id="limit" type="number" value="20" min="1" max="200" /></label>
<label>Auto Refresh
<select id="autoRefreshSeconds">
<option value="0">Off</option>
<option value="5">5s</option>
<option value="10" selected>10s</option>
<option value="30">30s</option>
</select>
</label>
<label>Execution Status
<select id="executionStatusFilter">
<option value="all" selected>All</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
<option value="pending">Pending</option>
<option value="rejected">Rejected</option>
<option value="cancelled">Cancelled</option>
</select>
</label>
<label>Session Focus
<select id="sessionFilter">
<option value="all" selected>All Sessions</option>
<option value="LONDON">LONDON</option>
<option value="NY_AM">NY_AM</option>
<option value="NY_PM">NY_PM</option>
<option value="ASIA">ASIA</option>
<option value="OFF_HOURS">OFF_HOURS</option>
</select>
</label>
<label>Trust Class
<select id="trustClassFilter">
<option value="all" selected>All Trust</option>
<option value="whitelist">Whitelist</option>
<option value="observe">Observe</option>
<option value="blacklist">Blacklist</option>
</select>
</label>
<label>Search
<input id="searchText" type="text" placeholder="candidate, ticket, execution, reason" />
</label>
<button id="refreshBtn" type="button">Refresh</button>
<div id="lastUpdated" class="last-updated">Waiting for first refresh</div>
</section>
<main class="grid">
<section class="panel panel-wide">
<section class="filters-bar">
<div id="activeFilters" class="metric">Filters: none</div>
</section>
<main id="workbenchMain" class="grid workbench-main">
<section class="panel panel-wide workspace-section" data-workbench-section="overview" data-workbench-label="Overview">
<h2>Overview</h2>
<div id="overviewCards" class="cards overview-cards"></div>
</section>
<section class="panel panel-wide">
<section class="panel panel-wide workspace-section" data-workbench-section="decision-assistant" data-workbench-label="Decision Assistant">
<h2>Decision Assistant</h2>
<div id="decisionAssistantPanel" class="detail-panel"></div>
</section>
<section class="panel panel-wide">
<section class="panel panel-wide workspace-section" data-workbench-section="trading-status" data-workbench-label="Trading Status">
<h2>Trading Status</h2>
<div id="marketStatePanel" class="detail-panel"></div>
</section>
<section class="panel panel-wide">
<section class="panel panel-wide workspace-section" data-workbench-section="runtime-supervisor" data-workbench-label="Runtime Supervisor">
<h2>Runtime Supervisor</h2>
<div id="runtimeStatusPanel" class="detail-panel"></div>
</section>
<section class="panel panel-wide">
<section class="panel panel-wide workspace-section" data-workbench-section="decision-packet" data-workbench-label="Decision Packet">
<h2>Decision Packet</h2>
<div id="decisionPacketPanel" class="detail-panel"></div>
</section>
<section class="panel panel-wide">
<section class="panel panel-wide workspace-section" data-workbench-section="readiness" data-workbench-label="Readiness">
<h2>Model Trust</h2>
<div id="trustScorePanel" class="detail-panel"></div>
</section>
<section class="panel panel-wide">
<h2>Live Readiness</h2>
<div id="liveReadinessPanel" class="detail-panel"></div>
</section>
<section class="panel panel-wide">
<h2>Launch Readiness</h2>
<div id="launchReadinessPanel" class="detail-panel"></div>
</section>
<section class="panel panel-wide">
<section class="panel panel-wide workspace-section" data-workbench-section="session-focus" data-workbench-label="Session Focus">
<h2>Session Focus</h2>
<div id="sessionFocusPanel" class="detail-panel"></div>
</section>
<section class="panel panel-wide">
<section class="panel panel-wide workspace-section" data-workbench-section="pre-market-plan" data-workbench-label="Pre-Market Plan">
<h2>Pre-Market Plan</h2>
<div id="preMarketPlanPanel" class="detail-panel"></div>
</section>
<section class="panel panel-wide">
<section class="panel panel-wide workspace-section" data-workbench-section="intraday-candidates" data-workbench-label="Intraday Candidates">
<h2>Intraday Candidates</h2>
<div id="tradeCandidatesPanel" class="detail-panel"></div>
</section>
<section class="panel panel-wide">
<section class="panel panel-wide workspace-section" data-workbench-section="execution-tickets" data-workbench-label="Execution Tickets">
<h2>Execution Tickets</h2>
<div id="executionTicketsPanel" class="detail-panel"></div>
</section>
<section class="panel panel-wide">
<section class="panel panel-wide workspace-section" data-workbench-section="alerts" data-workbench-label="Alerts">
<h2>Alert History</h2>
<div id="alertHistoryPanel" class="detail-panel"></div>
</section>
<section class="panel panel-wide">
<h2>Alert Quality</h2>
<div id="alertQualityPanel" class="detail-panel"></div>
</section>
<section class="panel panel-wide">
<section class="panel panel-wide workspace-section" data-workbench-section="visual-reviews" data-workbench-label="Visual Reviews">
<h2>Visual Reviews</h2>
<div id="visualReviewsPanel" class="detail-panel"></div>
</section>
<section class="panel panel-wide">
<section class="panel panel-wide workspace-section" data-workbench-section="position-management" data-workbench-label="Position Management">
<h2>Position Management</h2>
<div id="positionManagementPanel" class="detail-panel"></div>
</section>
<section class="panel panel-wide">
<section class="panel panel-wide workspace-section" data-workbench-section="operator-console" data-workbench-label="Operator Console">
<h2>Operator Console</h2>
<div id="operatorConsolePanel" class="detail-panel"></div>
</section>
<section class="panel panel-wide">
<section class="panel panel-wide workspace-section" data-workbench-section="post-trade-review" data-workbench-label="Post-Trade Review">
<h2>Post-Trade Review</h2>
<div id="postTradeReviewPanel" class="detail-panel"></div>
</section>
<section class="panel panel-wide">
<section class="panel panel-wide workspace-section" data-workbench-section="candles" data-workbench-label="Candles">
<h2>Candles</h2>
<canvas id="candlesChart" width="960" height="260"></canvas>
</section>
</main>
</main>
</div>
</div>
<script src="/static/app.js"></script>
</body>
+101
View File
@@ -88,6 +88,90 @@ body {
color: #9db0d3;
font-size: 13px;
}
.workbench-layout {
display: grid;
grid-template-columns: 248px minmax(0, 1fr);
gap: 16px;
margin: 0 16px 16px;
align-items: start;
}
.workbench-sidebar {
position: sticky;
top: 16px;
max-height: calc(100vh - 128px);
overflow: auto;
padding: 14px;
border: 1px solid #24304f;
border-radius: 14px;
background: #121933;
}
.workbench-sidebar-header {
padding: 4px 4px 12px;
border-bottom: 1px solid #24304f;
margin-bottom: 12px;
}
.workbench-view-label {
margin-top: 4px;
color: #e5e7eb;
font-size: 16px;
font-weight: 700;
}
.workbench-nav {
display: grid;
gap: 6px;
}
.workbench-nav button,
.workbench-nav-link {
display: flex;
align-items: center;
width: 100%;
min-height: 38px;
padding: 8px 10px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: #aabbe0;
font: inherit;
font-size: 13px;
text-align: left;
text-decoration: none;
cursor: pointer;
}
.workbench-nav button:hover,
.workbench-nav-link:hover {
border-color: #31436c;
background: #0f1530;
color: #e5e7eb;
}
.workbench-nav button.is-active {
border-color: #4f7cff;
background: rgba(79, 124, 255, 0.18);
color: #ffffff;
}
.workbench-nav-primary {
margin-bottom: 8px;
border-color: #31436c;
background: #0f1530;
}
.workbench-content {
min-width: 0;
}
.workbench-content .controls,
.workbench-content .filters-bar {
margin-left: 0;
margin-right: 0;
}
.workspace-section {
display: none;
}
.workspace-section.is-active {
display: block;
}
.workbench-main .panel h2 + h2 {
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid #24304f;
}
.status-pill {
display: inline-block;
padding: 4px 8px;
@@ -118,11 +202,18 @@ body {
gap: 16px;
padding: 0 16px 16px;
}
.grid.workbench-main {
grid-template-columns: minmax(0, 1fr);
padding: 0 0 16px;
}
.panel {
padding: 16px;
overflow: hidden;
}
.panel-wide { grid-column: span 2; }
.workbench-main .panel-wide {
grid-column: 1;
}
.panel h2 { margin: 0 0 12px; font-size: 18px; }
.table-wrap { overflow: auto; }
table {
@@ -836,6 +927,16 @@ textarea:focus-visible {
font-size: 13px;
}
@media (max-width: 960px) {
.workbench-layout {
grid-template-columns: 1fr;
}
.workbench-sidebar {
position: static;
max-height: none;
}
.workbench-nav {
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
.grid { grid-template-columns: 1fr; }
.panel-wide { grid-column: span 1; }
.training-toolbar { grid-template-columns: 1fr; }
+37
View File
@@ -117,6 +117,43 @@ class ApiStaticServerTests(unittest.TestCase):
self.assertNotIn('id="trainingSection"', index_html)
self.assertNotIn("TRAINING_PUBLIC_ROUTES", app_js)
def test_dashboard_uses_split_workspace_navigation(self) -> None:
index_file = _resolve_static_file("/")
self.assertIsNotNone(index_file)
index_html = index_file.read_text(encoding="utf-8")
app_file = _resolve_static_file("/static/app.js")
self.assertIsNotNone(app_file)
app_js = app_file.read_text(encoding="utf-8")
styles_file = _resolve_static_file("/static/styles.css")
self.assertIsNotNone(styles_file)
styles_css = styles_file.read_text(encoding="utf-8")
for expected in (
'class="workbench-layout"',
'id="workbenchNav"',
'data-workbench-target="decision-assistant"',
'data-workbench-section="decision-assistant"',
'data-workbench-section="readiness"',
'data-workbench-section="alerts"',
):
self.assertIn(expected, index_html)
for expected in (
"activateWorkbenchSection",
"initializeWorkbenchNavigation",
"aiIctActiveWorkbenchSection",
"request_timeout",
"AbortController",
):
self.assertIn(expected, app_js)
for expected in (
".workbench-sidebar",
".workbench-nav button.is-active",
".workspace-section.is-active",
):
self.assertIn(expected, styles_css)
def test_dashboard_uses_canonical_trading_routes(self) -> None:
app_file = _resolve_static_file("/static/app.js")
self.assertIsNotNone(app_file)
+10
View File
@@ -27,6 +27,7 @@ class DeployWithPasswordTests(unittest.TestCase):
compose = Path("docker-compose.yml").read_text(encoding="utf-8")
self.assertIn("./.claude:/app/.claude", compose)
self.assertIn("host.docker.internal:host-gateway", compose)
def test_deploy_smoke_includes_p24_execution_layer_endpoints(self) -> None:
smoke_source = Path("scripts/deploy_with_password.py").read_text(encoding="utf-8")
@@ -44,6 +45,15 @@ class DeployWithPasswordTests(unittest.TestCase):
self.assertIn("scripts/apply_migrations.py", command)
self.assertIn("docker rm -f ai-ict", command)
def test_deploy_env_backup_stays_inside_remote_path_for_non_root_user(self) -> None:
command = deploy_with_password.build_deploy_command(
remote_path="/home/yuqei/project/ai-exchange",
remote_archive="/home/yuqei/ai-ict-deploy.tgz",
)
self.assertIn("/home/yuqei/project/ai-exchange/.env.deploy.bak", command)
self.assertNotIn("/root/ai-ict-deploy.env.bak", command)
def test_run_remote_can_wrap_remote_step_with_sudo_without_leaking_password_in_command(self) -> None:
class _FakeStdin:
def __init__(self) -> None:
@@ -109,6 +109,33 @@ class LaunchReadinessServiceTests(unittest.TestCase):
self.assertFalse(payload["execution_assist_ready"])
self.assertFalse(payload["real_live_ready"])
def test_live_analysis_allows_rest_fresh_market_when_stream_and_news_are_degraded(self) -> None:
service = LaunchReadinessService(
market_state_service=_RestFreshMarketStateService(),
broker_read_model_service=_BrokerReadService(status="broker_state_unavailable"),
weekly_review_service=_WeeklyReviewService(),
trust_service=_TrustService(),
live_readiness_service=_LiveReadinessService(),
shadow_paper_validation_service=_ShadowPaperValidationService(),
)
with patch("src.services.readiness.launch_readiness_service.get_trading_runtime_service", return_value=_RuntimeServiceWithStreamBlocker()):
payload = service.build_summary(instrument_id=1, timeframe="1m")
self.assertFalse(payload["analysis_only_ready"])
self.assertTrue(payload["live_analysis_assistant_ready"])
self.assertEqual(payload["product_operating_mode"], "readonly_live_analysis")
self.assertIn("market_truth_ready", payload["blocking_reasons"])
self.assertIn("news_provider_usable", payload["blocking_reasons"])
self.assertIn("runtime_ready", payload["blocking_reasons"])
self.assertIn("market_stream_not_connected_rest_fallback", payload["live_analysis_assistant_summary"]["warnings"])
self.assertIn("news_provider_unavailable_context_warning", payload["live_analysis_assistant_summary"]["warnings"])
self.assertIn("runtime_stream_degraded_rest_fallback", payload["live_analysis_assistant_summary"]["warnings"])
self.assertEqual(payload["live_analysis_assistant_summary"]["blockers"], [])
self.assertFalse(payload["decision_support_ready"])
self.assertFalse(payload["execution_assist_ready"])
self.assertFalse(payload["real_live_ready"])
def test_build_summary_exposes_restricted_soft_rollout_when_only_blacklist_models_exist(self) -> None:
service = LaunchReadinessService(
market_state_service=_MarketStateService(),
@@ -162,6 +189,29 @@ class _MarketStateService:
return _MarketState()
class _RestFreshMarketState:
freshness = type("Freshness", (), {"code": "market_data_fresh"})()
stream_state = type("Stream", (), {"code": "market_stream_connecting"})()
data_gap_state = type("Gap", (), {"code": "market_data_gap_clear"})()
news_window = type(
"News",
(),
{"provider_name": "news_provider_chain", "freshness_code": "news_provider_stale", "error": "snapshot_missing"},
)()
def to_dict(self):
return {
"freshness": {"code": "market_data_fresh"},
"stream_state": {"code": "market_stream_connecting"},
"data_gap_state": {"code": "market_data_gap_clear"},
}
class _RestFreshMarketStateService:
def build_market_state(self, **kwargs):
return _RestFreshMarketState()
class _BrokerState:
def __init__(self, status: str = "broker_state_ok") -> None:
self.status = status
@@ -248,6 +298,14 @@ class _RuntimeService:
return {"scheduler_state": "running", "current_blockers": []}
class _RuntimeServiceWithStreamBlocker:
def status(self):
return {
"scheduler_state": "running",
"current_blockers": ["market_stream_degraded:BTC-USDT:market_stream_connecting"],
}
class _ShadowPaperValidationService:
def build_summary(self, **kwargs):
return {
@@ -108,6 +108,42 @@ class MaintenanceSummaryServiceTests(unittest.TestCase):
self.assertEqual(summary["blockers"], [])
self.assertEqual(summary["maintenance_actions"]["automatic_actions"], [])
def test_build_summary_suppresses_stream_blocker_when_rest_market_is_fresh(self) -> None:
service = MaintenanceSummaryService(
capture_retention_service=_CaptureRetentionService(
{"status": "completed", "eligible_file_count": 0, "deleted_file_count": 0}
),
alert_quality_service=_AlertQualityService(
{
"delivery_summary": {"failed_count": 0, "retry_exhausted_skip_count": 0, "channel_disabled_count": 0},
"alert_feedback_summary": {"count": 1},
"visual_feedback_summary": {"count": 1},
"quality_flags": [],
}
),
incident_journal_service=_IncidentJournalService(
{"status": "idle", "incident_count": 0, "severity_counts": {}, "recommended_actions": []}
),
runtime_status_provider=lambda: {
"current_blockers": ["market_stream_degraded:BTC-USDT:market_stream_connecting"],
"current_warnings": [],
"runtime_watchdog": {"stalled_task_count": 0},
"runtime_health": {"level": "blocked"},
"latest_market_state_by_symbol": {
"BTC-USDT": {
"freshness": {"code": "market_data_fresh"},
"data_gap_state": {"code": "market_data_gap_clear"},
}
},
},
)
summary = service.build_summary(instrument_id=1)
self.assertEqual(summary["status"], "healthy")
self.assertEqual(summary["runtime"]["current_blockers"], [])
self.assertEqual(summary["runtime"]["suppressed_blockers"], ["market_stream_degraded:BTC-USDT:market_stream_connecting"])
if __name__ == "__main__":
unittest.main()
@@ -85,6 +85,30 @@ class UnattendedHarnessServiceTests(unittest.TestCase):
runtime_check = [item for item in payload["checks"] if item["name"] == "runtime_environment"][0]
self.assertEqual(runtime_check["blocker_type"], "environment")
def test_run_harness_suppresses_stream_blocker_when_rest_market_is_fresh(self) -> None:
service = UnattendedHarnessService(
maintenance_summary_service=_MaintenanceSummaryService(),
alert_quality_service=_AlertQualityService(),
operator_digest_service=_OperatorDigestService(),
visual_capture_service=_VisualCaptureService(),
runtime_status_provider=lambda: {
"current_blockers": ["market_stream_degraded:BTC-USDT:market_stream_connecting"],
"runtime_health": {"level": "blocked"},
"latest_market_state_by_symbol": {
"BTC-USDT": {
"freshness": {"code": "market_data_fresh"},
"data_gap_state": {"code": "market_data_gap_clear"},
}
},
},
)
payload = service.run_harness(instrument_id=1)
self.assertEqual(payload["status"], "passed")
self.assertIn("runtime_environment", payload["passed_checks"])
self.assertEqual(payload["blockers"], [])
if __name__ == "__main__":
unittest.main()