Improve Trader Mode usefulness readout

This commit is contained in:
Codex
2026-05-20 13:25:16 +08:00
parent 6edf2e39ae
commit 84d6b49be6
16 changed files with 634 additions and 35 deletions
+1
View File
@@ -30,3 +30,4 @@ tmp_*.jsonl
# Large artifacts and local docs copies
*.pdf
.gstack/
+2 -1
View File
@@ -798,11 +798,12 @@ Automation execution-layer PXX remains closed and its known scope stays frozen.
- 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.
- Trader Mode usefulness compression passed on 2026-05-20: `/trader-mode` and the embedded Decision Assistant watchlist now prioritize trader-language first-screen answers: `Why No Trade`, `Watch Next`, `Focus Reason`, `Data State`, and `No Candidate`. Watchlist queue cards now say why not to trade, what to watch next, and why the unique focus deserves attention instead of exposing internal reason buckets first. The stale/freshness gate no longer freezes the page just because an old candidate timestamp exists when fresh market/review timestamps are available, and read-only runtime fallback now treats market stream / broker sync degradation as warnings for analysis-only mode while strict execution/readiness gates stay unchanged. Profile filtering now preserves the server-generated readout when the current focus already matches the trader profile, avoiding fallback text that degraded `FVG retrace` into generic `confirmation`. State-change alerts now show readable trader messages instead of raw internal signatures. Validation passed 23 focused operator/static tests, `node --check`, Python compile checks, full unit discovery (`787` tests), secret static checks, script static checks, `git diff --check` with only LF/CRLF warnings, deploy smoke over 11 endpoints, standard remote ops smoke including `unattended_harness`, remote `run_readonly_live_analysis_smoke.py` with `ready_for_readonly_live_analysis=true` and `must_not_trade=true`, and gstack browser screenshots at `output/playwright/trader-mode-usefulness-readout-remote-final3-390x844.png` plus `output/playwright/trader-mode-usefulness-readout-remote-final3-1366x768.png`.
- 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
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.
Next run a real intraday operator session with Trader Mode as the primary screen and collect R2 evidence: paper tickets only from real executable candidates, paper executions only when a paper ticket exists, operator feedback from actual Agree/Disagree/No Trade/Need More Evidence actions, and weekly-review entries from real session notes. 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.
## Validation Commands Used Recently
+1
View File
@@ -917,6 +917,7 @@
- [x] P0-4 one-screen cockpit health: `/trader-mode` shows API/runtime/freshness/latest packet/stale seconds/focus/next action/boundary
- [x] P0-6 read-only launch smoke command: health, runtime, launch-readiness, review-packet, trader-mode payload, shadow validation, candidates, static app, `/trader-mode`, and static trader script all passed
- [x] P0-5 minimal operations loop: `/trader-mode` supports one-click daily observation, operator agreement/disagreement/no-trade/more-evidence, and quick notes without requiring paper ticket samples
- [x] Trader Mode usefulness compression: first screen and watchlist now answer why not to trade, what to watch next, why the unique focus matters, whether data is stale, and what to wait for when there is no candidate; remote deploy, ops smoke, read-only live analysis smoke, and 390/1366 gstack screenshots passed
- [ ] R2 paper-ticket sample gate is waiting on a real executable candidate; do not fabricate paper tickets from blocked candidates
- [x] R3 OKX sandbox isolated smoke tooling: guarded script and service can call OKX demo submit/query/cancel/query/fills only when `OKX_SANDBOX_SMOKE_EXECUTE=1`; local validation, deploy smoke, and remote ops smoke passed
- [ ] R3 sandbox credential permission gate deferred: current phase is assisted analysis / decision support only, so do not request trade-permission credentials or make submit/cancel smoke the next gate
Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

@@ -233,7 +233,12 @@ 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)]
return [
blocker
for blocker in blockers
if not _is_readonly_stream_fallback_blocker(blocker, runtime_status)
and not _is_readonly_broker_context_blocker(blocker)
]
def _is_readonly_stream_fallback_blocker(blocker: str, runtime_status: dict) -> bool:
@@ -248,6 +253,10 @@ def _is_readonly_stream_fallback_blocker(blocker: str, runtime_status: dict) ->
return freshness_code == "market_data_fresh" and gap_code == "market_data_gap_clear"
def _is_readonly_broker_context_blocker(blocker: str) -> bool:
return blocker.startswith("broker_sync_degraded:")
def _safe_section(fetcher, *, warning_code: str, warnings: list[str], fallback):
try:
return fetcher()
@@ -2550,6 +2550,23 @@ def _build_candidate_watchlist_tiers(*, candidates: list[dict], session_focus: d
"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 []),
"trader_focus_reason": _build_watchlist_trader_focus_reason(
tier=tier,
tier_reason=tier_reason,
session_code=candidate.get("session_code") or "OFF_HOURS",
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_no_trade_reason": _build_watchlist_trader_no_trade_reason(
tier=tier,
missing_confirmations=candidate.get("missing_confirmations") or [],
),
"trader_watch_next": _build_watchlist_trader_watch_next(
title=f"Candidate #{candidate.get('id')}",
missing_confirmations=candidate.get("missing_confirmations") or [],
tier=tier,
),
}
)
for key, rows in tiers.items():
@@ -2687,6 +2704,33 @@ def _candidate_tier_action_method(*, tier: str) -> str:
return "NONE"
def _build_watchlist_trader_focus_reason(*, tier: str, tier_reason: str, session_code: str, model_code: str, quality_score) -> str:
structure = _short_structure_label(model_code)
quality = f", q{_format_trader_quality(quality_score)}" if quality_score is not None else ""
return f"Focus: {tier.replace('_', ' ')} {session_code}; {structure}{quality}."
def _build_watchlist_trader_wait_for(missing_confirmations: list) -> str:
tokens = [str(item) for item in list(missing_confirmations or []) if item]
if not tokens:
return "one clean confirmation that keeps the setup aligned"
return _humanize_confirmation_wait(tokens)
def _build_watchlist_trader_no_trade_reason(*, tier: str, missing_confirmations: list) -> str:
if tier == "review_now" and not list(missing_confirmations or []):
return "No trade: review only, execution disabled."
wait_for = _short_trader_wait_label(missing_confirmations)
return f"No trade: wait {wait_for}."
def _build_watchlist_trader_watch_next(*, title: str, missing_confirmations: list, tier: str) -> str:
wait_for = _short_trader_wait_label(missing_confirmations)
if tier == "archive_only":
return f"Background only: revisit {title} if structure returns."
return f"Watch {title}: {wait_for}."
def _build_trader_mode_summary(
*,
launch_readiness: dict,
@@ -2705,13 +2749,13 @@ def _build_trader_mode_summary(
weekly_review: dict,
external_context_summary: dict,
) -> dict:
launch_blockers = [str(item) for item in list(launch_readiness.get("blocking_reasons") or []) if item]
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 "")
market_freshness = str(readonly_live_health_summary.get("market_freshness") or "")
market_stream = str(readonly_live_health_summary.get("market_stream") or "")
market_gap = str(readonly_live_health_summary.get("market_data_gap") or "")
current_blocker = str(readonly_live_health_summary.get("current_blocker") or "").strip()
readout_blockers = [*live_blockers, current_blocker]
readonly_ready = bool(
launch_readiness.get("live_analysis_assistant_ready")
or live_analysis_assistant_summary.get("ready_for_readonly_live_analysis")
@@ -2728,7 +2772,7 @@ def _build_trader_mode_summary(
market_freshness=market_freshness,
market_stream=market_stream,
market_gap=market_gap,
blockers=[*launch_blockers, *live_blockers, current_blocker],
blockers=readout_blockers,
freshness_stale=bool(freshness_summary.get("is_stale")),
)
focus = _resolve_trader_mode_focus_candidate(
@@ -2744,7 +2788,7 @@ def _build_trader_mode_summary(
]
visible_reasons = _build_trader_mode_reasons(
data_confidence=data_confidence,
blockers=[*launch_blockers, *live_blockers, current_blocker],
blockers=readout_blockers,
no_trade_reason_items=no_trade_reason_items,
focus=focus,
decision_packet=decision_packet,
@@ -2803,6 +2847,19 @@ def _build_trader_mode_summary(
readonly_live_health_summary=readonly_live_health_summary,
freshness_summary=freshness_summary,
)
trader_readout = _build_trader_mode_live_readout(
status_label=status_label,
status_tone=status_tone,
data_confidence=data_confidence,
focus=focus,
visible_reasons=visible_reasons,
plain_language_reasons=plain_language_reasons,
next_action=next_action,
freshness_summary=freshness_summary,
current_session=current_session,
market_posture=market_posture,
readonly_live_health_summary=readonly_live_health_summary,
)
return {
"mode": "trader_mode_live_brief",
"status": status,
@@ -2818,6 +2875,11 @@ def _build_trader_mode_summary(
"key_reasons": visible_reasons[:3],
"plain_language_reasons": plain_language_reasons[:3],
"next_action": next_action,
"trader_readout": trader_readout,
"why_no_trade": trader_readout.get("why_no_trade"),
"watch_next": trader_readout.get("watch_next"),
"data_state": trader_readout.get("data_state"),
"no_candidate_state": trader_readout.get("no_candidate_state"),
"data_confidence": data_confidence,
"freshness_summary": freshness_summary,
"freshness_freeze": bool(freshness_summary.get("is_stale") or status_tone == "do_not_use"),
@@ -2826,7 +2888,7 @@ def _build_trader_mode_summary(
data_confidence=data_confidence,
freshness_summary=freshness_summary,
readonly_live_health_summary=readonly_live_health_summary,
blockers=[*launch_blockers, *live_blockers, current_blocker],
blockers=readout_blockers,
),
"keyboard_shortcuts": [
{"key": "R", "action": "review_focus"},
@@ -3042,11 +3104,11 @@ def _resolve_trader_mode_data_confidence(
return "blocked"
if freshness_stale:
return "stale"
runtime_usable_for_readout = runtime_health in {"available", "ok", ""} or not normalized_blockers
if (
market_freshness == "market_data_fresh"
and market_stream == "market_stream_connected"
and market_gap in {"market_data_gap_clear", ""}
and runtime_health in {"available", "ok", ""}
and runtime_usable_for_readout
):
return "fresh"
return "stale"
@@ -3060,9 +3122,9 @@ def _build_trader_mode_freshness_summary(
) -> dict:
stale_after_seconds = 180
market_last_updated_ts = (
readonly_live_health_summary.get("latest_candidate_timestamp")
or readonly_live_health_summary.get("latest_market_timestamp")
readonly_live_health_summary.get("latest_market_timestamp")
or readonly_live_health_summary.get("latest_decision_packet_timestamp")
or readonly_live_health_summary.get("latest_review_packet_timestamp")
)
review_packet_updated_ts = (
readonly_live_health_summary.get("latest_review_packet_timestamp")
@@ -3074,13 +3136,17 @@ def _build_trader_mode_freshness_summary(
market_freshness = str(readonly_live_health_summary.get("market_freshness") or "")
market_stream = str(readonly_live_health_summary.get("market_stream") or "")
market_gap = str(readonly_live_health_summary.get("market_data_gap") or "")
runtime_online = runtime_health in {"available", "ok", ""}
current_blocker = str(readonly_live_health_summary.get("current_blocker") or "").strip()
runtime_online = runtime_health in {"available", "ok", ""} or (
str(readonly_live_health_summary.get("status") or "") == "usable" and not current_blocker
)
stale_reasons: list[str] = []
if not runtime_online:
stale_reasons.append("runtime_offline")
if market_freshness and market_freshness != "market_data_fresh":
stale_reasons.append(market_freshness)
if market_stream and market_stream != "market_stream_connected":
readonly_rest_fallback = market_freshness == "market_data_fresh" and market_gap in {"market_data_gap_clear", ""}
if market_stream and market_stream != "market_stream_connected" and not readonly_rest_fallback:
stale_reasons.append(market_stream)
if market_gap and market_gap != "market_data_gap_clear":
stale_reasons.append(market_gap)
@@ -3106,6 +3172,7 @@ def _build_trader_mode_freshness_summary(
"is_stale": bool(stale_reasons),
"freeze_reason": stale_reasons[0] if stale_reasons else "",
"stale_reasons": stale_reasons,
"stream_warning": market_stream if market_stream and market_stream != "market_stream_connected" and readonly_rest_fallback else "",
}
@@ -3239,6 +3306,180 @@ def _build_trader_mode_primary_conclusion(
return f"No clean live setup. Continue observation in {current_session}; {market_posture}."
def _build_trader_mode_live_readout(
*,
status_label: str,
status_tone: str,
data_confidence: str,
focus: dict,
visible_reasons: list[str],
plain_language_reasons: list[str],
next_action: str,
freshness_summary: dict,
current_session: str,
market_posture: str,
readonly_live_health_summary: dict,
) -> dict:
why_no_trade = _build_trader_readout_why_no_trade(
status_tone=status_tone,
data_confidence=data_confidence,
focus=focus,
freshness_summary=freshness_summary,
plain_language_reasons=plain_language_reasons,
visible_reasons=visible_reasons,
)
watch_next = _build_trader_readout_watch_next(
status_tone=status_tone,
focus=focus,
visible_reasons=visible_reasons,
next_action=next_action,
current_session=current_session,
market_posture=market_posture,
)
focus_reason = _build_trader_readout_focus_reason(focus=focus, visible_reasons=visible_reasons)
data_state = _build_trader_readout_data_state(
data_confidence=data_confidence,
freshness_summary=freshness_summary,
readonly_live_health_summary=readonly_live_health_summary,
)
no_candidate_state = _build_trader_readout_no_candidate_state(
focus=focus,
current_session=current_session,
market_posture=market_posture,
)
headline = _build_trader_readout_headline(
status_label=status_label,
status_tone=status_tone,
focus=focus,
watch_next=watch_next,
no_candidate_state=no_candidate_state,
)
cards = [
{"label": "Why No Trade", "value": why_no_trade, "tone": "caution"},
{"label": "Watch Next", "value": watch_next, "tone": "focus"},
{"label": "Focus Reason", "value": focus_reason, "tone": "focus" if focus else "muted"},
{"label": "Data State", "value": data_state, "tone": "blocked" if status_tone == "do_not_use" else "neutral"},
{"label": "No Candidate", "value": no_candidate_state, "tone": "muted" if focus else "caution"},
]
return {
"mode": "trader_one_screen_readout",
"headline": headline,
"why_no_trade": why_no_trade,
"watch_next": watch_next,
"focus_reason": focus_reason,
"data_state": data_state,
"no_candidate_state": no_candidate_state,
"one_screen_cards": cards,
"operator_summary": (
"This readout is the first-screen answer: no trade from the system, what to watch next, why this focus, and whether data is fresh."
),
}
def _build_trader_readout_headline(*, status_label: str, status_tone: str, focus: dict, watch_next: str, no_candidate_state: str) -> str:
if status_tone == "do_not_use":
return "Do Not Use: restore freshness/readiness before reading this market."
if focus:
return f"{status_label}: {watch_next}"
return f"{status_label}: {no_candidate_state}"
def _build_trader_readout_why_no_trade(
*,
status_tone: str,
data_confidence: str,
focus: dict,
freshness_summary: dict,
plain_language_reasons: list[str],
visible_reasons: list[str],
) -> str:
if status_tone == "do_not_use":
freeze_reason = str(freshness_summary.get("freeze_reason") or data_confidence or "readiness_not_safe")
return f"No trade: restore {_short_trader_wait_label([freeze_reason])} first."
if focus:
wait_for = _short_trader_wait_label(
list(focus.get("missing_confirmations") or [])
or visible_reasons
or plain_language_reasons
)
return f"No trade: wait {wait_for}."
reason = plain_language_reasons[0] if plain_language_reasons else "No single candidate is clean enough for the first screen."
if len(reason) > 80:
reason = "no clean first-screen setup"
return f"No trade: {reason.rstrip('.') }."
def _build_trader_readout_watch_next(
*,
status_tone: str,
focus: dict,
visible_reasons: list[str],
next_action: str,
current_session: str,
market_posture: str,
) -> str:
if status_tone == "do_not_use":
return "Refresh runtime/feed first; ignore setup output until status leaves Do Not Use."
if focus:
wait_for = _short_trader_wait_label(list(focus.get("missing_confirmations") or []) or visible_reasons)
return f"Watch {focus.get('title') or 'current focus'}: {wait_for}."
if next_action:
return f"Watch for the next clean setup; current action is {next_action.replace('_', ' ')}."
return f"Keep observing {current_session}; market posture is {market_posture}."
def _build_trader_readout_focus_reason(*, focus: dict, visible_reasons: list[str]) -> str:
if not focus:
return "No unique focus: nothing has enough alignment to deserve the trader's first glance."
quality = f", q{_format_trader_quality(focus.get('quality_score'))}" if focus.get("quality_score") is not None else ""
tier = str(focus.get("tier") or "watch").replace("_", " ")
session = str(focus.get("session_code") or "OFF_HOURS")
reason = _short_trader_wait_label(visible_reasons[:1]) if visible_reasons else "top watchlist rank"
return f"Focus: {tier} {session}{quality}; wait {reason}."
def _build_trader_readout_data_state(*, data_confidence: str, freshness_summary: dict, readonly_live_health_summary: dict) -> str:
market = _short_runtime_state(readonly_live_health_summary.get("market_freshness") or "unknown")
stream = _short_runtime_state(readonly_live_health_summary.get("market_stream") or "unknown")
runtime = _short_runtime_state(readonly_live_health_summary.get("runtime_health") or "unknown")
stale_seconds = freshness_summary.get("stale_seconds")
stale_text = f"{int(stale_seconds)}s" if isinstance(stale_seconds, (int, float)) else "n/a"
if bool(freshness_summary.get("is_stale")):
reason = str(freshness_summary.get("freeze_reason") or "stale")
return f"Frozen: {_short_trader_wait_label([reason])}; stale {stale_text}."
return f"{data_confidence}: market {market}, stream {stream}, runtime {runtime}, stale {stale_text}."
def _build_trader_readout_no_candidate_state(*, focus: dict, current_session: str, market_posture: str) -> str:
if focus:
return "Candidate present; drill down only if needed."
return f"No clean candidate in {current_session}; wait sweep/MSS/FVG alignment."
def _short_runtime_state(value) -> str:
text = str(value or "unknown")
mapping = {
"market_data_fresh": "ok",
"market_data_gap_clear": "ok",
"market_stream_connected": "connected",
"market_stream_connecting": "connecting",
"available": "ok",
"blocked": "strict-blocked",
"unknown": "unknown",
}
return mapping.get(text, text.replace("market_", "").replace("_", " ")[:24])
def _format_trader_quality(value) -> str:
try:
score = float(value)
except (TypeError, ValueError):
return str(value)
if score.is_integer():
return str(int(score))
return f"{score:.1f}"
def _build_trader_mode_focus_quality_explanation(
*,
status_tone: str,
@@ -3372,6 +3613,18 @@ def _humanize_setup_structure(model_code: str) -> str:
return "Structure is still forming"
def _short_structure_label(model_code: str) -> str:
text = str(model_code or "").upper()
parts: list[str] = []
if "SWEEP" in text:
parts.append("sweep")
if "MSS" in text:
parts.append("MSS")
if "FVG" in text:
parts.append("FVG")
return " -> ".join(parts) if parts else "structure forming"
def _humanize_confirmation_wait(values: list) -> str:
tokens = [str(item or "").strip() for item in values if str(item or "").strip()]
joined = " ".join(tokens).lower()
@@ -3384,6 +3637,28 @@ def _humanize_confirmation_wait(values: list) -> str:
return "one clean confirmation before doing anything"
def _short_trader_wait_label(values: list) -> str:
tokens = [str(item or "").strip() for item in list(values or []) if str(item or "").strip()]
joined = " ".join(tokens).lower()
if not tokens:
return "confirmation"
if "runtime_offline" in joined or "runtime" in joined:
return "runtime"
if "review_packet" in joined:
return "review packet"
if "market" in joined or "stream" in joined or "gap" in joined:
return "market feed"
if "fvg" in joined and "retrace" in joined:
return "FVG retrace"
if "direction_mismatch" in joined:
return "direction alignment"
if "signal_missing" in joined or "signal" in joined:
return "clean signal"
if "trust" in joined:
return "trust gate"
return _humanize_trader_reason(tokens[0]).rstrip(".").lower()[:56]
def _humanize_caution_reason(*, visible_reasons: list[str], decision_packet: dict) -> str:
reasons = [str(item or "") for item in visible_reasons if str(item or "")]
rejected = list((decision_packet.get("confirmation_chain") or {}).get("rejected") or [])
@@ -159,7 +159,11 @@ class LaunchReadinessService:
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")
runtime_blockers = [str(item) for item in list(runtime_status.get("current_blockers") or []) if item]
if any(item.startswith("market_stream_degraded:") for item in runtime_blockers):
live_analysis_warnings.append("runtime_stream_degraded_rest_fallback")
if any(item.startswith("broker_sync_degraded:") for item in runtime_blockers):
live_analysis_warnings.append("runtime_broker_sync_degraded_readonly_warning")
if not account_context_available:
live_analysis_warnings.append("account_context_unavailable")
live_analysis_assistant_ready = not live_analysis_blockers
@@ -304,7 +308,7 @@ def _runtime_readonly_usable(runtime_status: dict) -> bool:
blockers = [str(item) for item in list(runtime_status.get("current_blockers") or [])]
if not blockers:
return True
allowed_prefixes = ("market_stream_degraded:",)
allowed_prefixes = ("market_stream_degraded:", "broker_sync_degraded:")
return all(any(blocker.startswith(prefix) for prefix in allowed_prefixes) for blocker in blockers)
+10 -8
View File
@@ -1318,17 +1318,19 @@ function renderDecisionAssistant(summary) {
if (traderModeSummary) {
appendSectionTitle(decisionAssistantPanel, 'Trader Mode');
const opportunity = traderModeSummary.primary_opportunity || {};
const traderReadout = traderModeSummary.trader_readout || {};
const traderCard = buildItemCard(
'Trader Mode / Live Brief',
traderModeSummary.status_label || traderModeSummary.status || 'unknown',
[
['decision now', traderModeSummary.primary_conclusion || 'n/a'],
['decision now', traderReadout.headline || traderModeSummary.primary_conclusion || 'n/a'],
['why no trade', traderReadout.why_no_trade || traderModeSummary.why_no_trade || 'analysis only'],
['watch next', traderReadout.watch_next || traderModeSummary.watch_next || traderModeSummary.next_action || 'continue_observation'],
['focus', `${opportunity.title || 'no clean opportunity'} / ${opportunity.tier || 'n/a'} / ${opportunity.status || 'n/a'}`],
['three key reasons', formatList(traderModeSummary.key_reasons || [])],
['next action', traderModeSummary.next_action || 'continue_observation_and_record_reason'],
['data confidence', `${traderModeSummary.data_confidence || 'unknown'} / ${traderModeSummary.status_color || 'unknown'}`],
['focus reason', traderReadout.focus_reason || traderModeSummary.focus_quality_explanation || 'n/a'],
['data state', traderReadout.data_state || `${traderModeSummary.data_confidence || 'unknown'} / ${traderModeSummary.status_color || 'unknown'}`],
['no candidate state', traderReadout.no_candidate_state || traderModeSummary.no_candidate_state || 'n/a'],
['trade action', traderModeSummary.primary_question_trade_action || 'not_a_buy_sell_instruction'],
['boundary badges', formatList(traderModeSummary.boundary_badges || [])],
],
(traderModeSummary.drilldown_sections || []).slice(0, 6).map((section) => ({
label: `Open ${section}`,
@@ -3984,9 +3986,9 @@ function renderDecisionAssistant(summary) {
['session', item.session_code || 'n/a'],
['model', item.model_code || 'n/a'],
['quality', String(item.quality_score ?? 'n/a')],
['tier reason', item.tier_reason || 'n/a'],
['suggested action', item.suggested_action || 'n/a'],
['detail', item.detail || 'n/a'],
['why this focus', item.trader_focus_reason || item.tier_reason || 'n/a'],
['why no trade', item.trader_no_trade_reason || 'read-only analysis'],
['watch next', item.trader_watch_next || item.suggested_action || 'n/a'],
['missing confirmations', formatList(item.missing_confirmations || [])],
],
item.action_path && item.action_method === 'POST'
+60
View File
@@ -371,6 +371,7 @@ th { color: #9db0d3; }
.trader-mode-page {
background: #090d18;
color: #e5ecff;
overflow-x: hidden;
}
.trader-mode-topbar {
border-radius: 0;
@@ -396,6 +397,12 @@ th { color: #9db0d3; }
display: grid;
gap: 12px;
padding: 0 16px 16px;
min-width: 0;
max-width: 100%;
}
.trader-mode-shell > * {
min-width: 0;
max-width: 100%;
}
.trader-change-alert {
padding: 12px 14px;
@@ -404,6 +411,8 @@ th { color: #9db0d3; }
background: rgba(56, 189, 248, 0.12);
color: #dbeafe;
font-weight: 700;
overflow-wrap: anywhere;
word-break: break-word;
}
.hidden {
display: none;
@@ -446,6 +455,47 @@ th { color: #9db0d3; }
margin: 0 0 12px;
font-size: 16px;
}
.trader-readout-band {
padding-top: 14px;
}
.trader-readout-grid {
display: grid;
grid-template-columns: 1.25fr 1.25fr 1fr 1fr 1fr;
gap: 10px;
min-width: 0;
}
.trader-readout-card {
min-width: 0;
max-width: 100%;
min-height: 118px;
overflow: hidden;
padding: 12px;
border: 1px solid #31436c;
border-radius: 8px;
background: #0f1530;
}
.trader-readout-focus {
border-color: #38bdf8;
}
.trader-readout-caution {
border-color: #f59e0b;
}
.trader-readout-blocked {
border-color: #ef4444;
}
.trader-readout-muted {
opacity: 0.86;
}
.trader-readout-value {
margin-top: 8px;
font-size: 15px;
line-height: 1.35;
font-weight: 700;
max-width: 100%;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.trader-mode-two-col {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -609,6 +659,16 @@ th { color: #9db0d3; }
.trader-mode-controls {
grid-template-columns: 1fr;
}
.trader-readout-grid {
grid-template-columns: 1fr;
}
.trader-readout-card {
min-height: 106px;
padding: 10px;
}
.trader-readout-value {
font-size: 14px;
}
.trader-action-bar button {
flex: 1 1 140px;
}
+5
View File
@@ -36,6 +36,11 @@
</div>
</section>
<section class="trader-mode-band trader-readout-band">
<h2>Live Readout</h2>
<div id="traderReadoutPanel" class="trader-readout-grid"></div>
</section>
<section class="trader-mode-band">
<h2>Fast Queue</h2>
<div id="traderQueuePanel" class="trader-queue-panel"></div>
+112 -10
View File
@@ -17,6 +17,7 @@ const traderStatusPanel = document.getElementById('traderStatusPanel');
const traderStatusLabel = document.getElementById('traderStatusLabel');
const traderConclusion = document.getElementById('traderConclusion');
const traderNextAction = document.getElementById('traderNextAction');
const traderReadoutPanel = document.getElementById('traderReadoutPanel');
const traderQueuePanel = document.getElementById('traderQueuePanel');
const traderFreshnessGrid = document.getElementById('traderFreshnessGrid');
const traderChartBridgePanel = document.getElementById('traderChartBridgePanel');
@@ -315,6 +316,10 @@ function normalizeCandidate(candidate) {
quality_score: item.quality_score,
detail: item.detail || item.narrative || item.ai_explanation || 'candidate_context',
missing_confirmations: item.missing_confirmations || item.missing_conditions || [],
trader_focus_reason: item.trader_focus_reason || '',
trader_wait_for: item.trader_wait_for || '',
trader_no_trade_reason: item.trader_no_trade_reason || '',
trader_watch_next: item.trader_watch_next || '',
};
}
@@ -350,15 +355,65 @@ function buildClientFocusExplanation(focus) {
modelText.includes('FVG') ? 'FVG retrace' : '',
].filter(Boolean).join(' into ') || 'structure setup';
const missing = (focus.missing_confirmations || [])[0];
const waitFor = missing ? String(missing).replace(/_/g, ' ') : 'one clean confirmation';
return `Structure is ${structure}; wait for ${waitFor}; do not act from this brief alone.`;
const waitFor = missing ? String(missing).replace(/_/g, ' ') : 'confirmation';
return `Focus: ${structure}; wait ${waitFor}.`;
}
function buildClientReadout(trader, focus, overrides = {}) {
const freshness = trader.freshness_summary || {};
const focusTitle = focus?.title || 'current focus';
const waitFor = (focus?.missing_confirmations || [])[0]
? String((focus.missing_confirmations || [])[0]).replace(/_/g, ' ')
: 'confirmation';
const whyNoTrade = overrides.why_no_trade
|| trader.why_no_trade
|| trader.trader_readout?.why_no_trade
|| (focus?.candidate_id
? `No trade: wait ${waitFor}.`
: 'No trade: no single candidate is clean enough for the first screen.');
const watchNext = overrides.watch_next
|| trader.watch_next
|| trader.trader_readout?.watch_next
|| (focus?.candidate_id
? `Watch ${focusTitle}: ${waitFor}.`
: 'Wait for a fresh sweep/MSS/FVG setup to enter the queue.');
const focusReason = overrides.focus_reason
|| trader.trader_readout?.focus_reason
|| trader.focus_quality_explanation
|| (focus?.candidate_id ? `Focus: ${focusTitle}.` : 'No unique focus yet.');
const dataState = overrides.data_state
|| trader.data_state
|| trader.trader_readout?.data_state
|| (freshness.is_stale ? `Frozen: ${freshness.freeze_reason || 'stale'}.` : `${trader.data_confidence || 'unknown'} data.`);
const noCandidateState = overrides.no_candidate_state
|| trader.no_candidate_state
|| trader.trader_readout?.no_candidate_state
|| (focus?.candidate_id ? 'Candidate present; drill down only if needed.' : 'No matching candidate. Stay observe-only.');
const headline = overrides.headline || trader.trader_readout?.headline || `${trader.status_label || 'Observe Only'}: ${watchNext}`;
return {
mode: 'trader_one_screen_readout',
headline,
why_no_trade: whyNoTrade,
watch_next: watchNext,
focus_reason: focusReason,
data_state: dataState,
no_candidate_state: noCandidateState,
one_screen_cards: [
{ label: 'Why No Trade', value: whyNoTrade, tone: 'caution' },
{ label: 'Watch Next', value: watchNext, tone: 'focus' },
{ label: 'Focus Reason', value: focusReason, tone: focus?.candidate_id ? 'focus' : 'muted' },
{ label: 'Data State', value: dataState, tone: freshness.is_stale ? 'blocked' : 'neutral' },
{ label: 'No Candidate', value: noCandidateState, tone: focus?.candidate_id ? 'muted' : 'caution' },
],
};
}
function applyTraderProfile(payload, profile) {
const trader = { ...(payload.trader || {}) };
const freshness = trader.freshness_summary || {};
if (freshness.is_stale || trader.status === 'do_not_use') {
return { ...payload, trader: { ...trader, active_profile: profile, profile_summary: buildProfileSummary(profile) } };
const readout = buildClientReadout(trader, trader.primary_opportunity || {});
return { ...payload, trader: { ...trader, active_profile: profile, profile_summary: buildProfileSummary(profile), trader_readout: readout } };
}
const currentFocus = trader.primary_opportunity || {};
const pool = candidatePoolFromPayload(payload);
@@ -367,6 +422,14 @@ function applyTraderProfile(payload, profile) {
if (matchingFocus) {
const nextFocus = normalizeCandidate(matchingFocus);
const reviewNow = nextFocus.tier === 'review_now';
const readout = currentMatches && trader.trader_readout
? trader.trader_readout
: buildClientReadout(trader, nextFocus, {
headline: reviewNow ? `Review Now: watch ${nextFocus.title}, no execution.` : `Observe Only: watch ${nextFocus.title}.`,
why_no_trade: nextFocus.trader_no_trade_reason || 'No trade: wait confirmation.',
watch_next: nextFocus.trader_watch_next || `Watch ${nextFocus.title}: confirmation.`,
focus_reason: nextFocus.trader_focus_reason || buildClientFocusExplanation(nextFocus),
});
return {
...payload,
trader: {
@@ -380,9 +443,10 @@ function applyTraderProfile(payload, profile) {
status_color: reviewNow ? 'review' : 'observe',
primary_opportunity: nextFocus,
primary_conclusion: reviewNow
? `${nextFocus.title} matches ${profile.label}; review only, no trade instruction.`
: `${nextFocus.title} matches ${profile.label}; observe until it becomes the single review focus.`,
? `Review ${nextFocus.title}; no trade until the missing confirmation is visible.`
: `Observe ${nextFocus.title}; wait for profile confirmation before treating it as the focus.`,
focus_quality_explanation: buildClientFocusExplanation(nextFocus),
trader_readout: readout,
primary_question_opportunity: 'one_profile_focus_candidate',
},
};
@@ -400,8 +464,15 @@ function applyTraderProfile(payload, profile) {
status_color: 'observe',
primary_opportunity: {},
primary_question_opportunity: 'no_profile_matching_opportunity',
primary_conclusion: `No focus matches ${profile.label}; keep observing this profile only.`,
primary_conclusion: `No focus matches ${profile.label}; stay observe-only and wait for a matching setup.`,
focus_quality_explanation: 'Profile filters excluded the current candidate from the fast-read view.',
trader_readout: buildClientReadout(trader, {}, {
headline: `Observe Only: no ${profile.label} focus.`,
why_no_trade: `No trade: no candidate matches ${profile.label}.`,
watch_next: `Wait for a ${profile.sessions.join('/') || 'selected-session'} ${profile.setups.join('/') || 'selected-setup'} candidate to enter the queue.`,
focus_reason: 'No unique focus because profile filters removed the current candidate.',
no_candidate_state: `No profile-matching candidate; keep the queue open and do not use another profile's setup.`,
}),
plain_language_reasons: [
'The current candidate is outside this trader profile.',
'Stay in observe-only mode until a matching setup appears.',
@@ -428,12 +499,23 @@ function traderSignature(trader) {
].join('|');
}
function traderChangeMessage(trader) {
const focus = trader.primary_opportunity || {};
const freshness = trader.freshness_summary || {};
const readout = trader.trader_readout || {};
const status = SAFE_STATUS_LABELS.includes(trader.status_label) ? trader.status_label : 'Observe Only';
const freshnessLabel = freshness.is_stale ? 'stale' : (freshness.status || trader.data_confidence || 'fresh');
const focusLabel = focus.candidate_id ? `Candidate #${focus.candidate_id}` : 'no focus';
const watch = readout.watch_next || (focus.candidate_id ? `watch ${focusLabel}` : 'wait for a clean setup');
return `Trader Mode changed: ${status}; ${freshnessLabel}; ${watch}.`;
}
function updateChangeAlert(trader) {
const signature = traderSignature(trader);
const previous = localStorage.getItem(TRADER_SIGNATURE_KEY);
localStorage.setItem(TRADER_SIGNATURE_KEY, signature);
if (!previous || previous === signature) return;
const message = `Trader Mode changed: ${previous} -> ${signature}`;
const message = traderChangeMessage(trader);
traderChangeAlert.classList.remove('hidden');
setText(traderChangeAlert, message);
if (traderAlertsEnabled) {
@@ -512,7 +594,9 @@ async function fetchTraderQueue(profile) {
status_tone: trader.status_tone || trader.status_color || trader.status || 'observe',
candidate_id: focus.candidate_id,
focus_title: focus.title || 'No clean focus',
reason: trader.focus_quality_explanation || trader.primary_conclusion || 'No readable brief yet.',
reason: trader.trader_readout?.watch_next || trader.focus_quality_explanation || trader.primary_conclusion || 'No readable brief yet.',
why_no_trade: trader.trader_readout?.why_no_trade || trader.why_no_trade || 'No trade from queue status.',
data_state: trader.trader_readout?.data_state || trader.data_state || trader.data_confidence || 'unknown',
stale_seconds: trader.freshness_summary?.stale_seconds,
};
} catch (error) {
@@ -552,10 +636,26 @@ function renderTraderQueuePanel(queueItems) {
card.appendChild(createElement('div', 'trader-queue-status', item.status_label || 'Observe Only'));
card.appendChild(createElement('div', 'metric', item.candidate_id ? `Candidate #${item.candidate_id}` : item.focus_title || 'No focus'));
card.appendChild(createElement('div', 'metric', item.reason || 'Read-only queue item.'));
card.appendChild(createElement('div', 'metric', item.why_no_trade || 'No trade from queue status.'));
traderQueuePanel.appendChild(card);
});
}
function renderTraderReadoutPanel(trader) {
clearNode(traderReadoutPanel);
const focus = trader.primary_opportunity || {};
const readout = trader.trader_readout || buildClientReadout(trader, focus);
const cards = Array.isArray(readout.one_screen_cards) && readout.one_screen_cards.length
? readout.one_screen_cards
: buildClientReadout(trader, focus).one_screen_cards;
cards.slice(0, 5).forEach((card) => {
const item = createElement('div', `trader-readout-card trader-readout-${normalizeTone(card.tone || card.label)}`);
item.appendChild(createElement('div', 'metric', card.label || 'Readout'));
item.appendChild(createElement('div', 'trader-readout-value', card.value || 'n/a'));
traderReadoutPanel.appendChild(item);
});
}
function renderChartBridgePanel(trader, candles) {
clearNode(traderChartBridgePanel);
const bridge = trader.chart_context_bridge || {};
@@ -605,11 +705,13 @@ function renderTraderMode(payload) {
const evidence = trader.r2_evidence_window || profiledPayload.shadowPaper || {};
const tone = freshness.is_stale ? 'do-not-use' : normalizeTone(trader.status_tone || trader.status_color || trader.status);
const safeStatusLabel = SAFE_STATUS_LABELS.includes(trader.status_label) ? trader.status_label : 'Observe Only';
const readout = trader.trader_readout || buildClientReadout(trader, focus);
traderStatusPanel.className = `trader-status-panel trader-status-${tone}`;
setText(traderStatusLabel, freshness.is_stale ? 'Do Not Use' : safeStatusLabel);
setText(traderConclusion, trader.primary_conclusion || 'No live brief is available.');
setText(traderNextAction, humanizeNextAction(trader.next_action || 'continue_observation_and_record_reason'));
setText(traderConclusion, readout.headline || trader.primary_conclusion || 'No live brief is available.');
setText(traderNextAction, readout.watch_next || humanizeNextAction(trader.next_action || 'continue_observation_and_record_reason'));
setText(traderHealth, profiledPayload.health?.app ? `API online / ${profiledPayload.health.app}` : 'API online');
renderTraderReadoutPanel(trader);
renderTraderQueuePanel(profiledPayload.queue);
clearNode(traderFreshnessGrid);
traderFreshnessGrid.appendChild(createMetric('Market Last Updated', formatTimestamp(freshness.market_last_updated_ts)));
+12 -3
View File
@@ -206,11 +206,13 @@ class ApiStaticServerTests(unittest.TestCase):
for expected in (
"Trader Mode / Live Brief",
"decision now",
"three key reasons",
"data confidence",
"why no trade",
"watch next",
"focus reason",
"data state",
"no candidate state",
"trade action",
"not_a_buy_sell_instruction",
"boundary badges",
"trader-mode-card",
"workflow operations",
"workflow drift",
@@ -453,6 +455,7 @@ class ApiStaticServerTests(unittest.TestCase):
"Status",
"Decision Now",
"Next Action",
"Live Readout",
"Market Last Updated",
"Review Packet Updated",
"Runtime Online",
@@ -501,6 +504,12 @@ class ApiStaticServerTests(unittest.TestCase):
"aria-keyshortcuts",
"pause_or_resume_auto_refresh",
"profile_filter_status",
"trader_readout",
"Why No Trade",
"Watch Next",
"Focus Reason",
"Data State",
"No Candidate",
):
self.assertIn(expected, js_text)
@@ -136,6 +136,28 @@ class LaunchReadinessServiceTests(unittest.TestCase):
self.assertFalse(payload["execution_assist_ready"])
self.assertFalse(payload["real_live_ready"])
def test_live_analysis_treats_broker_sync_degraded_as_readonly_warning(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=_RuntimeServiceWithBrokerSyncBlocker()):
payload = service.build_summary(instrument_id=1, timeframe="1m")
self.assertTrue(payload["live_analysis_assistant_ready"])
self.assertEqual(payload["product_operating_mode"], "readonly_live_analysis")
self.assertIn("runtime_ready", payload["blocking_reasons"])
self.assertIn("runtime_broker_sync_degraded_readonly_warning", payload["live_analysis_assistant_summary"]["warnings"])
self.assertIn("account_context_unavailable", payload["live_analysis_assistant_summary"]["warnings"])
self.assertEqual(payload["live_analysis_assistant_summary"]["blockers"], [])
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(),
@@ -306,6 +328,14 @@ class _RuntimeServiceWithStreamBlocker:
}
class _RuntimeServiceWithBrokerSyncBlocker:
def status(self):
return {
"scheduler_state": "running",
"current_blockers": ["broker_sync_degraded:BTC-USDT:okx_read_credentials_missing"],
}
class _ShadowPaperValidationService:
def build_summary(self, **kwargs):
return {
@@ -144,6 +144,36 @@ class MaintenanceSummaryServiceTests(unittest.TestCase):
self.assertEqual(summary["runtime"]["current_blockers"], [])
self.assertEqual(summary["runtime"]["suppressed_blockers"], ["market_stream_degraded:BTC-USDT:market_stream_connecting"])
def test_build_summary_suppresses_broker_sync_blocker_for_readonly(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": ["broker_sync_degraded:BTC-USDT:okx_read_credentials_missing"],
"current_warnings": [],
"runtime_watchdog": {"stalled_task_count": 0},
"runtime_health": {"level": "blocked"},
},
)
summary = service.build_summary(instrument_id=1)
self.assertEqual(summary["status"], "healthy")
self.assertEqual(summary["runtime"]["current_blockers"], [])
self.assertEqual(summary["runtime"]["suppressed_blockers"], ["broker_sync_degraded:BTC-USDT:okx_read_credentials_missing"])
if __name__ == "__main__":
unittest.main()
@@ -171,6 +171,9 @@ class OperatorWorkspaceServiceTests(unittest.TestCase):
self.assertEqual(summary["candidate_watchlist_tiers"]["soft_watch"][0]["candidate_id"], 22)
self.assertEqual(summary["candidate_watchlist_tiers"]["review_now"][0]["action_method"], "POST")
self.assertIn("/trading/candidates/18/confirm-check", summary["candidate_watchlist_tiers"]["review_now"][0]["action_path"])
self.assertIn("trader_focus_reason", summary["candidate_watchlist_tiers"]["review_now"][0])
self.assertIn("trader_no_trade_reason", summary["candidate_watchlist_tiers"]["review_now"][0])
self.assertIn("trader_watch_next", summary["candidate_watchlist_tiers"]["review_now"][0])
trader_mode_summary = summary["trader_mode_summary"]
self.assertEqual(trader_mode_summary["mode"], "trader_mode_live_brief")
self.assertEqual(trader_mode_summary["status"], "review_now")
@@ -189,6 +192,12 @@ class OperatorWorkspaceServiceTests(unittest.TestCase):
self.assertIn("focus_quality_explanation", trader_mode_summary)
self.assertIn("plain_language_reasons", trader_mode_summary)
self.assertLessEqual(len(trader_mode_summary["plain_language_reasons"]), 3)
self.assertEqual(trader_mode_summary["trader_readout"]["mode"], "trader_one_screen_readout")
self.assertIn("No trade", trader_mode_summary["trader_readout"]["why_no_trade"])
self.assertIn("Watch", trader_mode_summary["trader_readout"]["watch_next"])
self.assertIn("Focus:", trader_mode_summary["trader_readout"]["focus_reason"])
self.assertIn("market", trader_mode_summary["trader_readout"]["data_state"])
self.assertIn("No Candidate", [item["label"] for item in trader_mode_summary["trader_readout"]["one_screen_cards"]])
self.assertIn("recovery_actions", trader_mode_summary)
self.assertIn("keyboard_shortcuts", trader_mode_summary)
self.assertIn({"key": "Space", "action": "pause_or_resume_auto_refresh"}, trader_mode_summary["keyboard_shortcuts"])
@@ -817,6 +826,48 @@ class OperatorWorkspaceServiceTests(unittest.TestCase):
self.assertTrue(trader_mode_summary["freshness_summary"]["is_stale"])
self.assertTrue(trader_mode_summary["freshness_freeze"])
self.assertEqual(trader_mode_summary["primary_question_can_watch_now"], "no")
self.assertIn("Do Not Use", trader_mode_summary["trader_readout"]["headline"])
self.assertIn("restore", trader_mode_summary["trader_readout"]["why_no_trade"].lower())
def test_trader_mode_uses_readonly_rest_fallback_without_do_not_use(self) -> None:
service = DecisionAssistantSummaryService(
launch_readiness_service=_ReadonlyFallbackLaunchReadinessService(),
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(),
)
summary = service.build_summary(
instrument_id=1,
timeframe="1m",
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=[],
)
trader_mode_summary = summary["trader_mode_summary"]
self.assertEqual(trader_mode_summary["status_label"], "Review Now")
self.assertEqual(trader_mode_summary["data_confidence"], "fresh")
self.assertFalse(trader_mode_summary["freshness_summary"]["is_stale"])
self.assertEqual(trader_mode_summary["freshness_summary"]["stream_warning"], "market_stream_connecting")
self.assertIn("stream connecting", trader_mode_summary["trader_readout"]["data_state"])
self.assertIn("No trade", trader_mode_summary["trader_readout"]["why_no_trade"])
class _NoteRepo:
@@ -1035,6 +1086,25 @@ class _StaleLaunchReadinessService(_LaunchReadinessService):
return payload
class _ReadonlyFallbackLaunchReadinessService(_LaunchReadinessService):
def build_summary(self, *, instrument_id: int, timeframe: str = "1m"):
payload = super().build_summary(instrument_id=instrument_id, timeframe=timeframe)
payload["decision_support_ready"] = False
payload["blocking_reasons"] = ["market_truth_ready", "broker_state_ready"]
payload["readonly_live_health_summary"]["latest_candidate_timestamp"] = "2026-01-01T00:00:00+00:00"
payload["readonly_live_health_summary"]["market_stream"] = "market_stream_connecting"
payload["readonly_live_health_summary"]["runtime_health"] = "blocked"
payload["readonly_live_health_summary"]["current_blocker"] = ""
payload["readonly_live_health_summary"]["broker_account_context"] = "unavailable"
payload["live_analysis_assistant_summary"]["ready_for_readonly_live_analysis"] = True
payload["live_analysis_assistant_summary"]["blockers"] = []
payload["live_analysis_assistant_summary"]["warnings"] = [
"market_stream_not_connected_rest_fallback",
"account_context_unavailable",
]
return payload
class _SessionFocusSummaryService:
def build_summary(self, *, instrument_id: int, limit: int = 6):
return {