From 2a21ebb5ffdc24b3c9b077b1cf2cddc29f3c4053 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 8 May 2026 15:39:02 +0800 Subject: [PATCH] Add operator workspace tools --- migrations/032_create_operator_notes.sql | 18 ++ scripts/run_review_packet.py | 36 ++++ src/api/__init__.py | 8 + src/api/server.py | 44 ++++ src/api/trading_runtime.py | 40 ++++ src/repositories/market_session_repository.py | 14 ++ src/repositories/operator_note_repository.py | 87 ++++++++ .../decision/decision_packet_service.py | 5 + src/services/operator/__init__.py | 11 + .../operator/operator_workspace_service.py | 189 ++++++++++++++++++ src/web/app.js | 181 ++++++++++++++++- src/web/index.html | 33 +++ src/web/styles.css | 14 ++ tests/unit/test_api_server.py | 33 +++ tests/unit/test_operator_workspace_service.py | 121 +++++++++++ tests/unit/test_runtime_script_json.py | 11 + 16 files changed, 838 insertions(+), 7 deletions(-) create mode 100644 migrations/032_create_operator_notes.sql create mode 100644 scripts/run_review_packet.py create mode 100644 src/repositories/operator_note_repository.py create mode 100644 src/services/operator/__init__.py create mode 100644 src/services/operator/operator_workspace_service.py create mode 100644 tests/unit/test_operator_workspace_service.py diff --git a/migrations/032_create_operator_notes.sql b/migrations/032_create_operator_notes.sql new file mode 100644 index 0000000..9caab3e --- /dev/null +++ b/migrations/032_create_operator_notes.sql @@ -0,0 +1,18 @@ +create table if not exists operator_notes ( + id bigserial primary key, + instrument_id bigint not null references instruments(id), + note_scope text not null, + session_code text, + execution_id bigint references trade_executions(id), + signal_id bigint references trade_signals(id), + candidate_id bigint references trade_plan_candidates(id), + decision_packet_id bigint references decision_packets(id), + author text not null, + body text not null, + tags jsonb not null default '[]'::jsonb, + created_ts timestamptz not null, + updated_at timestamptz not null default now() +); + +create index if not exists idx_operator_notes_instrument_created_desc +on operator_notes (instrument_id, created_ts desc, id desc); diff --git a/scripts/run_review_packet.py b/scripts/run_review_packet.py new file mode 100644 index 0000000..b6a76e6 --- /dev/null +++ b/scripts/run_review_packet.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from pathlib import Path +import json +import os +import sys + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from src.services.operator import ReviewPacketService + + +def main(env=None) -> int: + env = os.environ if env is None else env + instrument_id = int(env.get("REVIEW_PACKET_INSTRUMENT_ID", "1")) + output_json = _get_bool(env, "REVIEW_PACKET_OUTPUT_JSON", False) + payload = ReviewPacketService().build_packet(instrument_id=instrument_id) + if output_json: + print(json.dumps(payload, ensure_ascii=False, sort_keys=True)) + else: + print(f"Review packet generated for instrument {instrument_id}") + print(f"Executions: {len(payload.get('recent_executions') or [])}") + return 0 + + +def _get_bool(env, key: str, default: bool = False) -> bool: + value = env.get(key) + if value is None or value == "": + return default + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/api/__init__.py b/src/api/__init__.py index f66e1de..2ba819c 100644 --- a/src/api/__init__.py +++ b/src/api/__init__.py @@ -25,13 +25,17 @@ from src.api.trading_runtime import ( get_latest_trading_plan, get_trading_candidate_by_id, get_trading_market_state, + get_trading_operator_notes, get_trading_plan_by_id, + get_trading_review_packet, get_trading_runtime_status, + get_trading_session_view, get_trading_ticket_by_id, get_trading_trust_score, get_weekly_review, rerun_ticket_risk_check, run_candidate_confirm_check, + create_trading_operator_note, run_trading_reconciliation, run_trading_runtime_once, run_trading_watch, @@ -67,9 +71,13 @@ __all__ = [ "get_trading_decision_alerts", "activate_trading_plan", "cancel_trading_plan", + "get_trading_session_view", + "get_trading_operator_notes", + "get_trading_review_packet", "run_trading_watch", "get_trading_candidate_by_id", "run_candidate_confirm_check", + "create_trading_operator_note", "run_trading_reconciliation", "run_trading_runtime_once", "build_ticket_from_candidate", diff --git a/src/api/server.py b/src/api/server.py index c42f200..79c2fbd 100644 --- a/src/api/server.py +++ b/src/api/server.py @@ -26,14 +26,18 @@ from src.api.trading_runtime import ( get_latest_trading_plan, get_trading_candidate_by_id, get_trading_market_state, + get_trading_operator_notes, get_trading_plan_by_id, + get_trading_review_packet, get_trading_runtime_status, + get_trading_session_view, get_trading_ticket_by_id, get_trading_trust_score, get_trading_executions, get_weekly_review, rerun_ticket_risk_check, run_candidate_confirm_check, + create_trading_operator_note, run_trading_reconciliation, run_trading_runtime_once, run_trading_watch, @@ -79,6 +83,10 @@ def build_api_dependencies() -> dict: "trading_runtime_status": get_trading_runtime_status, "trading_runtime_run_once": run_trading_runtime_once, "trading_trust_score": get_trading_trust_score, + "trading_session_view": get_trading_session_view, + "trading_operator_notes": get_trading_operator_notes, + "trading_operator_note_create": create_trading_operator_note, + "trading_review_packet": get_trading_review_packet, "trading_watch_run": run_trading_watch, "trading_candidate_by_id": get_trading_candidate_by_id, "trading_candidate_confirm_check": run_candidate_confirm_check, @@ -196,6 +204,31 @@ def route_request( if method == "GET" and path == "/trading/trust-score": instrument_id = _require_int(query, "instrument_id") return 200, deps["trading_trust_score"](instrument_id=instrument_id) + if method == "GET" and path == "/trading/session-view": + instrument_id = _require_int(query, "instrument_id") + limit = _get_int(query, "limit", 12) + return 200, deps["trading_session_view"](instrument_id=instrument_id, limit=limit) + if method == "GET" and path == "/trading/operator-notes": + instrument_id = _require_int(query, "instrument_id") + limit = _get_int(query, "limit", 20) + return 200, {"items": deps["trading_operator_notes"](instrument_id=instrument_id, limit=limit)} + if method == "POST" and path == "/trading/operator-notes": + instrument_id = _require_json_int(body or {}, "instrument_id") + return 201, deps["trading_operator_note_create"]( + instrument_id=instrument_id, + note_scope=_get_body_value(body, "note_scope", "general"), + author=_get_body_value(body, "author", "local_operator"), + body=_get_body_value(body, "body", ""), + session_code=_get_body_value(body, "session_code"), + execution_id=_get_body_optional_int(body, "execution_id"), + signal_id=_get_body_optional_int(body, "signal_id"), + candidate_id=_get_body_optional_int(body, "candidate_id"), + decision_packet_id=_get_body_optional_int(body, "decision_packet_id"), + tags=_get_body_list(body, "tags"), + ) + if method == "GET" and path == "/trading/review-packet": + instrument_id = _require_int(query, "instrument_id") + return 200, deps["trading_review_packet"](instrument_id=instrument_id) if method == "POST" and path == "/trading/watch/run": instrument_id = _require_json_int(body or {}, "instrument_id") candidate = deps["trading_watch_run"]( @@ -525,6 +558,17 @@ def _get_body_optional_int(body: Optional[dict], key: str): return int(value) +def _get_body_list(body: Optional[dict], key: str) -> list: + if not isinstance(body, dict): + return [] + value = body.get(key) + if value is None: + return [] + if isinstance(value, list): + return value + return [value] + + def _get_single(query: dict, key: str, default: Optional[str] = None) -> Optional[str]: values = query.get(key) if not values: diff --git a/src/api/trading_runtime.py b/src/api/trading_runtime.py index 1198fe4..00324f3 100644 --- a/src/api/trading_runtime.py +++ b/src/api/trading_runtime.py @@ -12,6 +12,7 @@ from src.services.decision import DecisionPacketService from src.services.execution import ExecutionService from src.services.journal import TradingJournalService, WeeklyReviewService from src.services.market_state import MarketStateService, get_market_truth_supervisor +from src.services.operator import OperatorNoteService, ReviewPacketService, SessionFocusService from src.services.reconciliation import BrokerExecutionReconciliationService from src.services.runtime import get_trading_runtime_service from src.services.trade_plan_candidates import SetupWatcherService @@ -425,6 +426,45 @@ def get_trading_trust_score(instrument_id: int) -> dict: return ModelTrustService().build_summary(instrument_id=instrument_id) +def get_trading_session_view(instrument_id: int, limit: int = 12) -> dict: + return SessionFocusService().build_summary(instrument_id=instrument_id, limit=limit) + + +def get_trading_operator_notes(instrument_id: int, limit: int = 20) -> list[dict]: + return OperatorNoteService().get_recent_notes(instrument_id=instrument_id, limit=limit) + + +def create_trading_operator_note( + *, + instrument_id: int, + note_scope: str, + author: str, + body: str, + session_code: Optional[str] = None, + execution_id: Optional[int] = None, + signal_id: Optional[int] = None, + candidate_id: Optional[int] = None, + decision_packet_id: Optional[int] = None, + tags: Optional[list[str]] = None, +) -> dict: + return OperatorNoteService().create_note( + instrument_id=instrument_id, + note_scope=note_scope, + author=author, + body=body, + session_code=session_code, + execution_id=execution_id, + signal_id=signal_id, + candidate_id=candidate_id, + decision_packet_id=decision_packet_id, + tags=tags, + ) + + +def get_trading_review_packet(instrument_id: int) -> dict: + return ReviewPacketService().build_packet(instrument_id=instrument_id) + + def market_truth_degraded(market_state) -> bool: payload = market_state.to_dict() if hasattr(market_state, "to_dict") else dict(market_state or {}) freshness_code = str(((payload.get("freshness") or {}).get("code")) or "") diff --git a/src/repositories/market_session_repository.py b/src/repositories/market_session_repository.py index 4801795..b6b30fe 100644 --- a/src/repositories/market_session_repository.py +++ b/src/repositories/market_session_repository.py @@ -7,6 +7,20 @@ from src.db.session import SessionLocal class MarketSessionRepository: + def fetch_recent_sessions(self, instrument_id: int, limit: int = 20) -> list[dict]: + sql = text( + """ + select id, instrument_id, session_date, timezone, session_code, start_ts, end_ts, high, low, midpoint, status, meta + from market_sessions + where instrument_id = :instrument_id + order by session_date desc, start_ts desc + limit :limit + """ + ) + with SessionLocal() as session: + rows = session.execute(sql, {"instrument_id": instrument_id, "limit": limit}).mappings().all() + return [dict(row) for row in rows] + def fetch_candles_for_window(self, instrument_id: int, timeframe: str, start_ts: datetime, end_ts: datetime) -> list[dict]: sql = text( """ diff --git a/src/repositories/operator_note_repository.py b/src/repositories/operator_note_repository.py new file mode 100644 index 0000000..f6c4d34 --- /dev/null +++ b/src/repositories/operator_note_repository.py @@ -0,0 +1,87 @@ +import json +from typing import Optional + +from sqlalchemy import text + +from src.db.session import SessionLocal + + +class OperatorNoteRepository: + def insert_note( + self, + *, + instrument_id: int, + note_scope: str, + session_code: Optional[str], + execution_id: Optional[int], + signal_id: Optional[int], + candidate_id: Optional[int], + decision_packet_id: Optional[int], + author: str, + body: str, + tags: list[str], + created_ts, + ) -> int: + sql = text( + """ + insert into operator_notes ( + instrument_id, + note_scope, + session_code, + execution_id, + signal_id, + candidate_id, + decision_packet_id, + author, + body, + tags, + created_ts + ) values ( + :instrument_id, + :note_scope, + :session_code, + :execution_id, + :signal_id, + :candidate_id, + :decision_packet_id, + :author, + :body, + cast(:tags as jsonb), + :created_ts + ) + returning id + """ + ) + with SessionLocal.begin() as session: + result = session.execute( + sql, + { + "instrument_id": instrument_id, + "note_scope": note_scope, + "session_code": session_code, + "execution_id": execution_id, + "signal_id": signal_id, + "candidate_id": candidate_id, + "decision_packet_id": decision_packet_id, + "author": author, + "body": body, + "tags": json.dumps(tags, default=str), + "created_ts": created_ts, + }, + ) + return int(result.scalar_one()) + + def fetch_recent_notes(self, *, instrument_id: int, limit: int = 20) -> list[dict]: + sql = text( + """ + select id, instrument_id, note_scope, session_code, execution_id, signal_id, + candidate_id, decision_packet_id, author, body, tags, created_ts + from operator_notes + where instrument_id = :instrument_id + order by created_ts desc, id desc + limit :limit + """ + ) + with SessionLocal() as session: + rows = session.execute(sql, {"instrument_id": instrument_id, "limit": limit}).mappings().all() + return [dict(row) for row in rows] diff --git a/src/services/decision/decision_packet_service.py b/src/services/decision/decision_packet_service.py index 0347fa6..853cc1f 100644 --- a/src/services/decision/decision_packet_service.py +++ b/src/services/decision/decision_packet_service.py @@ -293,11 +293,16 @@ def _target_item(name: str, *, signal: dict, candidate: dict, target_plan: dict) def _alert(key: str, alert_type: str, message: str, packet: DecisionPacket) -> dict: + signal = dict(packet.raw.get("signal") or {}) + candidate = dict(packet.raw.get("candidate") or {}) return { "alert_key": f"{key}:{alert_type}", "alert_type": alert_type, "message": message, "payload": { + "session_code": signal.get("session_code") or candidate.get("session_code"), + "candidate_id": packet.candidate_id, + "signal_id": packet.signal_id, "action_recommendation": packet.action_recommendation, "status": packet.status, "why_blocked": list(packet.why_blocked), diff --git a/src/services/operator/__init__.py b/src/services/operator/__init__.py new file mode 100644 index 0000000..7464ced --- /dev/null +++ b/src/services/operator/__init__.py @@ -0,0 +1,11 @@ +from src.services.operator.operator_workspace_service import ( + OperatorNoteService, + ReviewPacketService, + SessionFocusService, +) + +__all__ = [ + "OperatorNoteService", + "ReviewPacketService", + "SessionFocusService", +] diff --git a/src/services/operator/operator_workspace_service.py b/src/services/operator/operator_workspace_service.py new file mode 100644 index 0000000..b19fb59 --- /dev/null +++ b/src/services/operator/operator_workspace_service.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Optional + +from src.repositories.decision_packet_repository import DecisionPacketRepository +from src.repositories.market_session_repository import MarketSessionRepository +from src.repositories.operator_note_repository import OperatorNoteRepository +from src.repositories.trade_plan_candidate_repository import TradePlanCandidateRepository +from src.services.decision import DecisionPacketService +from src.services.execution import ExecutionService +from src.services.journal import WeeklyReviewService +from src.services.trust import ModelTrustService + + +class OperatorNoteService: + def __init__(self, repository: Optional[OperatorNoteRepository] = None) -> None: + self.repository = repository or OperatorNoteRepository() + + def create_note( + self, + *, + instrument_id: int, + note_scope: str, + author: str, + body: str, + session_code: Optional[str] = None, + execution_id: Optional[int] = None, + signal_id: Optional[int] = None, + candidate_id: Optional[int] = None, + decision_packet_id: Optional[int] = None, + tags: Optional[list[str]] = None, + ) -> dict: + created_ts = datetime.now(timezone.utc) + note_id = self.repository.insert_note( + instrument_id=instrument_id, + note_scope=note_scope, + session_code=session_code, + execution_id=execution_id, + signal_id=signal_id, + candidate_id=candidate_id, + decision_packet_id=decision_packet_id, + author=author, + body=body, + tags=list(tags or []), + created_ts=created_ts, + ) + return { + "id": note_id, + "instrument_id": instrument_id, + "note_scope": note_scope, + "session_code": session_code, + "execution_id": execution_id, + "signal_id": signal_id, + "candidate_id": candidate_id, + "decision_packet_id": decision_packet_id, + "author": author, + "body": body, + "tags": list(tags or []), + "created_ts": created_ts.isoformat(), + } + + def get_recent_notes(self, *, instrument_id: int, limit: int = 20) -> list[dict]: + return self.repository.fetch_recent_notes(instrument_id=instrument_id, limit=limit) + + +class SessionFocusService: + def __init__( + self, + *, + candidate_repository: Optional[TradePlanCandidateRepository] = None, + execution_service: Optional[ExecutionService] = None, + decision_repository: Optional[DecisionPacketRepository] = None, + session_repository: Optional[MarketSessionRepository] = None, + trust_service: Optional[ModelTrustService] = None, + ) -> None: + self.candidate_repository = candidate_repository or TradePlanCandidateRepository() + self.execution_service = execution_service or ExecutionService() + self.decision_repository = decision_repository or DecisionPacketRepository() + self.session_repository = session_repository or MarketSessionRepository() + self.trust_service = trust_service or ModelTrustService() + + def build_summary(self, *, instrument_id: int, limit: int = 12) -> dict: + sessions = self.session_repository.fetch_recent_sessions(instrument_id=instrument_id, limit=limit) + candidates = self.candidate_repository.fetch_recent_candidates(instrument_id=instrument_id, limit=100) + executions = self.execution_service.build_execution_dashboard_items(instrument_id=instrument_id, limit=100) + alerts = self.decision_repository.fetch_recent_decision_alerts(instrument_id=instrument_id, limit=100) + trust_score = self.trust_service.build_summary(instrument_id=instrument_id) + session_recommendations = aggregate_session_recommendations(trust_score.get("models") or []) + items = [] + for session in sessions: + session_code = str(session.get("session_code") or "OFF_HOURS") + session_candidates = [item for item in candidates if str(item.get("session_code") or "") == session_code] + session_executions = [item for item in executions if str(item.get("session_code") or "") == session_code] + session_alerts = [item for item in alerts if str((item.get("payload") or {}).get("session_code") or "") == session_code] + recommendation = session_recommendations.get(session_code, {}) + items.append( + { + **session, + "candidate_count": len(session_candidates), + "executable_candidate_count": sum(1 for item in session_candidates if item.get("status") == "executable" or item.get("executable")), + "open_execution_count": sum(1 for item in session_executions if item.get("execution_status") == "open"), + "alert_count": len(session_alerts), + "session_recommendation": recommendation, + "action_hint": build_session_action_hint(session=session, recommendation=recommendation, alerts=session_alerts), + } + ) + return { + "instrument_id": instrument_id, + "items": items, + } + + +class ReviewPacketService: + def __init__( + self, + *, + note_service: Optional[OperatorNoteService] = None, + decision_service: Optional[DecisionPacketService] = None, + execution_service: Optional[ExecutionService] = None, + trust_service: Optional[ModelTrustService] = None, + weekly_review_service: Optional[WeeklyReviewService] = None, + decision_repository: Optional[DecisionPacketRepository] = None, + ) -> None: + self.note_service = note_service or OperatorNoteService() + self.decision_service = decision_service or DecisionPacketService() + self.execution_service = execution_service or ExecutionService() + self.trust_service = trust_service or ModelTrustService() + self.weekly_review_service = weekly_review_service or WeeklyReviewService() + self.decision_repository = decision_repository or DecisionPacketRepository() + + def build_packet(self, *, instrument_id: int) -> dict: + return { + "instrument_id": instrument_id, + "generated_ts": datetime.now(timezone.utc).isoformat(), + "decision_packet": self.decision_service.build_preview(instrument_id=instrument_id), + "recent_alerts": self.decision_repository.fetch_recent_decision_alerts(instrument_id=instrument_id, limit=10), + "recent_notes": self.note_service.get_recent_notes(instrument_id=instrument_id, limit=10), + "recent_executions": self.execution_service.build_execution_dashboard_items(instrument_id=instrument_id, limit=10), + "weekly_review": self.weekly_review_service.build_summary(instrument_id=instrument_id), + "trust_score": self.trust_service.build_summary(instrument_id=instrument_id), + } + + +def aggregate_session_recommendations(models: list[dict]) -> dict[str, dict]: + buckets: dict[str, dict] = {} + for model in models: + for session in list(model.get("session_recommendations") or []): + session_code = str(session.get("session_code") or "") + if not session_code: + continue + current = buckets.setdefault( + session_code, + { + "session_code": session_code, + "score_total": 0.0, + "count": 0, + "classifications": [], + }, + ) + current["score_total"] += float(session.get("score") or 0.0) + current["count"] += 1 + current["classifications"].append(str(session.get("classification") or "observe")) + result = {} + for session_code, bucket in buckets.items(): + avg_score = bucket["score_total"] / bucket["count"] if bucket["count"] else 0.0 + classification = "observe" + if any(item == "avoid" for item in bucket["classifications"]): + classification = "avoid" + elif any(item == "prefer" for item in bucket["classifications"]): + classification = "prefer" + result[session_code] = { + "session_code": session_code, + "score": avg_score, + "classification": classification, + } + return result + + +def build_session_action_hint(*, session: dict, recommendation: dict, alerts: list[dict]) -> str: + status = str(session.get("status") or "") + classification = str(recommendation.get("classification") or "observe") + if alerts: + return "review_alerts" + if status == "active" and classification == "prefer": + return "focus_now" + if classification == "avoid": + return "avoid_new_risk" + return "observe" diff --git a/src/web/app.js b/src/web/app.js index bd268a2..093942a 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -5,7 +5,9 @@ const timeframeInput = document.getElementById('timeframe'); const limitInput = document.getElementById('limit'); const autoRefreshSecondsInput = document.getElementById('autoRefreshSeconds'); const executionStatusFilterInput = document.getElementById('executionStatusFilter'); +const sessionFilterInput = document.getElementById('sessionFilter'); const searchTextInput = document.getElementById('searchText'); +const trustClassFilterInput = document.getElementById('trustClassFilter'); const lastUpdated = document.getElementById('lastUpdated'); const activeFilters = document.getElementById('activeFilters'); const overviewCards = document.getElementById('overviewCards'); @@ -13,10 +15,13 @@ const marketStatePanel = document.getElementById('marketStatePanel'); const runtimeStatusPanel = document.getElementById('runtimeStatusPanel'); const decisionPacketPanel = document.getElementById('decisionPacketPanel'); const trustScorePanel = document.getElementById('trustScorePanel'); +const sessionFocusPanel = document.getElementById('sessionFocusPanel'); const preMarketPlanPanel = document.getElementById('preMarketPlanPanel'); const tradeCandidatesPanel = document.getElementById('tradeCandidatesPanel'); const executionTicketsPanel = document.getElementById('executionTicketsPanel'); +const alertHistoryPanel = document.getElementById('alertHistoryPanel'); const positionManagementPanel = document.getElementById('positionManagementPanel'); +const operatorConsolePanel = document.getElementById('operatorConsolePanel'); const postTradeReviewPanel = document.getElementById('postTradeReviewPanel'); const canvas = document.getElementById('candlesChart'); const ctx = canvas.getContext('2d'); @@ -162,8 +167,12 @@ function getSearchText() { function updateActiveFilters() { const active = []; const executionStatus = executionStatusFilterInput.value || 'all'; + const session = sessionFilterInput.value || 'all'; const search = getSearchText(); + const trustClass = trustClassFilterInput.value || 'all'; if (executionStatus !== 'all') active.push(`execution=${executionStatus}`); + if (session !== 'all') active.push(`session=${session}`); + if (trustClass !== 'all') active.push(`trust=${trustClass}`); if (search) active.push(`search=${search}`); setText(activeFilters, `Filters: ${active.length ? active.join(', ') : 'none'}`); } @@ -176,8 +185,10 @@ function filterBySearch(items, projector) { function filterExecutions(items) { const statusFilter = executionStatusFilterInput.value || 'all'; + const sessionFilter = sessionFilterInput.value || 'all'; return filterBySearch( - items.filter((item) => statusFilter === 'all' || item.execution_status === statusFilter), + items.filter((item) => (statusFilter === 'all' || item.execution_status === statusFilter) + && (sessionFilter === 'all' || item.session_code === sessionFilter)), (item) => [ item.execution_id, item.execution_status, @@ -190,6 +201,12 @@ function filterExecutions(items) { ); } +function filterBySession(items, accessor) { + const sessionFilter = sessionFilterInput.value || 'all'; + if (sessionFilter === 'all') return items; + return items.filter((item) => String(accessor(item) || '') === sessionFilter); +} + function queryString(params) { const search = new URLSearchParams(); Object.entries(params).forEach(([key, value]) => { @@ -355,7 +372,8 @@ function renderDecisionPacket(packet, alertRows) { function renderTrustScore(payload) { clearNode(trustScorePanel); - const models = payload?.models || []; + const trustClass = trustClassFilterInput.value || 'all'; + const models = (payload?.models || []).filter((model) => trustClass === 'all' || model.classification === trustClass); trustScorePanel.appendChild( buildInfoGrid([ ['whitelist', formatList(payload?.whitelist || [])], @@ -394,6 +412,137 @@ function renderTrustScore(payload) { trustScorePanel.appendChild(grid); } +function renderSessionFocus(payload) { + clearNode(sessionFocusPanel); + const items = payload?.items || []; + const filtered = filterBySession(items, (item) => item.session_code); + if (!filtered.length) { + sessionFocusPanel.appendChild(createElement('div', 'empty-state', 'No session focus rows available.')); + return; + } + const grid = createElement('div', 'cards'); + filtered.forEach((item) => { + const recommendation = item.session_recommendation || {}; + grid.appendChild( + buildItemCard( + item.session_code || 'session', + recommendation.classification || item.status || 'observe', + [ + ['status', item.status || 'unknown'], + ['window', `${formatTimestamp(item.start_ts)} -> ${formatTimestamp(item.end_ts)}`], + ['candidates', String(item.candidate_count ?? 0)], + ['executable', String(item.executable_candidate_count ?? 0)], + ['open executions', String(item.open_execution_count ?? 0)], + ['alerts', String(item.alert_count ?? 0)], + ['recommendation', `${recommendation.classification || 'observe'} @ ${formatNumber(recommendation.score, 1)}`], + ['action hint', item.action_hint || 'observe'], + ] + ) + ); + }); + sessionFocusPanel.appendChild(grid); +} + +function renderAlertHistory(alertRows) { + clearNode(alertHistoryPanel); + const items = filterBySession(alertRows?.items || [], (item) => (item.payload || {}).session_code); + if (!items.length) { + alertHistoryPanel.appendChild(createElement('div', 'empty-state', 'No alert history for current filters.')); + return; + } + const grid = createElement('div', 'cards'); + items.slice(0, 8).forEach((alert) => { + grid.appendChild( + buildItemCard( + alert.alert_type || 'alert', + alert.status || 'queued', + [ + ['message', alert.message || 'n/a'], + ['created', formatTimestamp(alert.created_ts)], + ['channel', alert.channel || 'workbench'], + ['payload', formatJsonInline(alert.payload || {})], + ] + ) + ); + }); + alertHistoryPanel.appendChild(grid); +} + +function renderOperatorConsole(noteRows, reviewPacket) { + clearNode(operatorConsolePanel); + const authorInput = createElement('input'); + authorInput.type = 'text'; + authorInput.placeholder = 'operator'; + authorInput.value = 'local_operator'; + const bodyInput = createElement('textarea'); + bodyInput.placeholder = 'Add a session, execution, or decision note.'; + bodyInput.rows = 4; + bodyInput.className = 'operator-note-textarea'; + const controls = createElement('div', 'actions'); + const saveButton = createElement('button', '', 'Save Note'); + saveButton.type = 'button'; + saveButton.addEventListener('click', async () => { + try { + await postJson('/trading/operator-notes', { + instrument_id: Number(instrumentIdInput.value || 1), + note_scope: 'workbench', + author: authorInput.value || 'local_operator', + body: bodyInput.value || '', + session_code: sessionFilterInput.value === 'all' ? null : sessionFilterInput.value, + tags: ['workbench'], + }); + bodyInput.value = ''; + await refreshDashboard(); + } catch (error) { + showErrorPanel(String(error)); + } + }); + const packetButton = createElement('button', '', 'Generate Review Packet'); + packetButton.type = 'button'; + packetButton.addEventListener('click', async () => { + try { + await fetchJson(`/trading/review-packet?${queryString({ instrument_id: instrumentIdInput.value || '1' })}`); + await refreshDashboard(); + } catch (error) { + showErrorPanel(String(error)); + } + }); + controls.appendChild(saveButton); + controls.appendChild(packetButton); + operatorConsolePanel.appendChild(authorInput); + operatorConsolePanel.appendChild(bodyInput); + operatorConsolePanel.appendChild(controls); + operatorConsolePanel.appendChild( + buildInfoGrid([ + ['review packet', formatTimestamp(reviewPacket?.generated_ts)], + ['packet executions', String((reviewPacket?.recent_executions || []).length)], + ['packet alerts', String((reviewPacket?.recent_alerts || []).length)], + ['packet notes', String((reviewPacket?.recent_notes || []).length)], + ]) + ); + const notes = noteRows?.items || []; + const grid = createElement('div', 'cards'); + notes.slice(0, 6).forEach((note) => { + grid.appendChild( + buildItemCard( + note.author || 'operator', + note.note_scope || 'general', + [ + ['session', note.session_code || 'n/a'], + ['created', formatTimestamp(note.created_ts)], + ['tags', formatList(note.tags || [])], + ['body', note.body || ''], + ] + ) + ); + }); + if (!notes.length) { + operatorConsolePanel.appendChild(createElement('div', 'empty-state', 'No operator notes yet.')); + } else { + operatorConsolePanel.appendChild(grid); + } +} + function renderPreMarketPlan(plan) { clearNode(preMarketPlanPanel); preMarketPlanPanel.appendChild( @@ -446,7 +595,7 @@ function renderCandidates(items) { ]) ); - const filtered = filterBySearch(items, (item) => [ + const filtered = filterBySearch(filterBySession(items, (item) => item.session_code), (item) => [ item.id, item.status, item.side, @@ -509,7 +658,7 @@ function renderCandidates(items) { function renderTickets(items) { clearNode(executionTicketsPanel); - const filtered = filterBySearch(items, (item) => { + const filtered = filterBySearch(filterBySession(items, (item) => (item.ticket_snapshot || {}).signal?.session_code), (item) => { const snapshot = item.ticket_snapshot || {}; return [ item.id, @@ -676,13 +825,14 @@ function renderPostTradeReview(summary, journalItems, weeklyReview) { ]); postTradeReviewPanel.appendChild(top); - if (!journalItems.length) { + const filteredJournal = filterBySession(journalItems, (item) => item.session_code); + if (!filteredJournal.length) { postTradeReviewPanel.appendChild(createElement('div', 'empty-state', 'No journal entries available for this instrument.')); return; } const grid = createElement('div', 'cards'); - journalItems.slice(0, 6).forEach((entry) => { + filteredJournal.slice(0, 6).forEach((entry) => { grid.appendChild( buildItemCard( `Journal #${entry.id}`, @@ -759,10 +909,13 @@ function showErrorPanel(message) { runtimeStatusPanel, decisionPacketPanel, trustScorePanel, + sessionFocusPanel, preMarketPlanPanel, tradeCandidatesPanel, executionTicketsPanel, + alertHistoryPanel, positionManagementPanel, + operatorConsolePanel, postTradeReviewPanel, ].forEach((panel) => { clearNode(panel); @@ -783,10 +936,13 @@ function renderDashboardData({ decisionPacket, decisionAlerts, trustScore, + sessionView, tradeCandidates, executionTickets, tradingExecutions, journalSummary, + operatorNotes, + reviewPacket, journalItems, weeklyReview, }) { @@ -796,10 +952,13 @@ function renderDashboardData({ renderRuntimeStatus(runtimeStatus); renderDecisionPacket(decisionPacket, decisionAlerts); renderTrustScore(trustScore); + renderSessionFocus(sessionView); renderPreMarketPlan(tradingPlan); renderCandidates(tradeCandidates.items || []); renderTickets(executionTickets.items || []); + renderAlertHistory(decisionAlerts); renderExecutions(tradingExecutions.items || []); + renderOperatorConsole(operatorNotes, reviewPacket); renderPostTradeReview(journalSummary, journalItems.items || [], weeklyReview); drawCandles(candles.items || []); updateActiveFilters(); @@ -812,7 +971,7 @@ async function refreshDashboard() { const limit = limitInput.value || '20'; try { - const [health, candles, tradingPlan, marketState, runtimeStatus, decisionPacket, decisionAlerts, trustScore, tradeCandidates, executionTickets, tradingExecutions, journalSummary, journalItems, weeklyReview] = await Promise.all([ + const [health, candles, tradingPlan, marketState, runtimeStatus, decisionPacket, decisionAlerts, trustScore, sessionView, tradeCandidates, executionTickets, tradingExecutions, journalSummary, operatorNotes, reviewPacket, journalItems, weeklyReview] = await Promise.all([ fetchJson('/health'), fetchJson(`/candles?${queryString({ instrument_id: instrumentId, timeframe, limit })}`), fetchJson(`/trading/plans/latest?${queryString({ instrument_id: instrumentId })}`).catch(() => null), @@ -821,10 +980,13 @@ async function refreshDashboard() { fetchJson(`/trading/decision-packet?${queryString({ instrument_id: instrumentId })}`).catch(() => ({ status: 'unavailable', action_recommendation: 'monitor' })), fetchJson(`/trading/alerts?${queryString({ instrument_id: instrumentId, limit: 10 })}`).catch(() => ({ items: [] })), fetchJson(`/trading/trust-score?${queryString({ instrument_id: instrumentId })}`).catch(() => ({ models: [], whitelist: [], observe_list: [], blacklist: [], next_actions: [] })), + fetchJson(`/trading/session-view?${queryString({ instrument_id: instrumentId, limit: 12 })}`).catch(() => ({ items: [] })), fetchJson(`/trading/candidates?${queryString({ instrument_id: instrumentId, limit })}`).catch(() => ({ items: [] })), fetchJson(`/trading/tickets?${queryString({ instrument_id: instrumentId, limit })}`).catch(() => ({ items: [] })), fetchJson(`/trading/executions?${queryString({ instrument_id: instrumentId, limit })}`).catch(() => ({ items: [] })), fetchJson(`/trading/journal/summary?${queryString({ instrument_id: instrumentId })}`).catch(() => ({ entry_count: 0, next_week_actions: [] })), + fetchJson(`/trading/operator-notes?${queryString({ instrument_id: instrumentId, limit: 10 })}`).catch(() => ({ items: [] })), + fetchJson(`/trading/review-packet?${queryString({ instrument_id: instrumentId })}`).catch(() => ({ generated_ts: null, recent_executions: [], recent_alerts: [], recent_notes: [] })), fetchJson(`/trading/journal?${queryString({ instrument_id: instrumentId, limit })}`).catch(() => ({ items: [] })), fetchJson(`/trading/research/weekly-review?${queryString({ instrument_id: instrumentId })}`).catch(() => ({ entry_count: 0, next_week_actions: [] })), ]); @@ -838,10 +1000,13 @@ async function refreshDashboard() { decisionPacket, decisionAlerts, trustScore, + sessionView, tradeCandidates, executionTickets, tradingExecutions, journalSummary, + operatorNotes, + reviewPacket, journalItems, weeklyReview, }); @@ -863,7 +1028,9 @@ function configureAutoRefresh() { refreshBtn.addEventListener('click', refreshDashboard); executionStatusFilterInput.addEventListener('change', refreshDashboard); +sessionFilterInput.addEventListener('change', refreshDashboard); searchTextInput.addEventListener('input', refreshDashboard); +trustClassFilterInput.addEventListener('change', refreshDashboard); autoRefreshSecondsInput.addEventListener('change', configureAutoRefresh); configureAutoRefresh(); diff --git a/src/web/index.html b/src/web/index.html index 2199ccc..b387b94 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -37,6 +37,24 @@ + + @@ -74,6 +92,11 @@
+
+

Session Focus

+
+
+

Pre-Market Plan

@@ -89,11 +112,21 @@
+
+

Alert History

+
+
+

Position Management

+
+

Operator Console

+
+
+

Post-Trade Review

diff --git a/src/web/styles.css b/src/web/styles.css index 70435d9..737525a 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -217,6 +217,20 @@ th { color: #9db0d3; } color: #fff; cursor: pointer; } +.detail-panel input, +.detail-panel textarea { + width: 100%; + margin: 0 0 10px; + padding: 10px 12px; + border: 1px solid #31436c; + border-radius: 8px; + background: #0d1430; + color: #fff; +} +.detail-panel textarea { + min-height: 88px; + resize: vertical; +} .empty-state { color: #cbd5e1; font-size: 13px; diff --git a/tests/unit/test_api_server.py b/tests/unit/test_api_server.py index fdfa7d1..4e8fb41 100644 --- a/tests/unit/test_api_server.py +++ b/tests/unit/test_api_server.py @@ -90,6 +90,39 @@ class ApiServerRoutingTests(unittest.TestCase): self.assertEqual(status, 200) self.assertEqual(payload["whitelist"], ["OSOK"]) + def test_routes_trading_session_view_operator_notes_and_review_packet(self) -> None: + session_status, session_payload = route_request( + "/trading/session-view", + {"instrument_id": ["1"], "limit": ["2"]}, + dependencies={"trading_session_view": lambda **kwargs: {"items": [{"session_code": "LONDON"}]}}, + ) + notes_status, notes_payload = route_request( + "/trading/operator-notes", + {"instrument_id": ["1"], "limit": ["2"]}, + dependencies={"trading_operator_notes": lambda **kwargs: [{"id": 1, "author": "alice"}]}, + ) + create_status, create_payload = route_request( + "/trading/operator-notes", + {}, + method="POST", + body={"instrument_id": 1, "author": "alice", "body": "note"}, + dependencies={"trading_operator_note_create": lambda **kwargs: {"id": 2, **kwargs}}, + ) + packet_status, packet_payload = route_request( + "/trading/review-packet", + {"instrument_id": ["1"]}, + dependencies={"trading_review_packet": lambda **kwargs: {"instrument_id": 1, "generated_ts": "now"}}, + ) + + self.assertEqual(session_status, 200) + self.assertEqual(session_payload["items"][0]["session_code"], "LONDON") + self.assertEqual(notes_status, 200) + self.assertEqual(notes_payload["items"][0]["author"], "alice") + self.assertEqual(create_status, 201) + self.assertEqual(create_payload["author"], "alice") + self.assertEqual(packet_status, 200) + self.assertEqual(packet_payload["instrument_id"], 1) + def test_routes_trading_broker_state(self) -> None: status, payload = route_request( "/trading/broker-state", diff --git a/tests/unit/test_operator_workspace_service.py b/tests/unit/test_operator_workspace_service.py new file mode 100644 index 0000000..72bc776 --- /dev/null +++ b/tests/unit/test_operator_workspace_service.py @@ -0,0 +1,121 @@ +import os +import unittest +from unittest.mock import patch + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") + +from src.services.operator import OperatorNoteService, ReviewPacketService, SessionFocusService + + +class OperatorWorkspaceServiceTests(unittest.TestCase): + def test_create_note_returns_payload(self) -> None: + service = OperatorNoteService(repository=_NoteRepo()) + + payload = service.create_note( + instrument_id=1, + note_scope="session", + author="alice", + body="Watch NY_AM sweep quality.", + session_code="NY_AM", + tags=["handoff"], + ) + + self.assertEqual(payload["author"], "alice") + self.assertEqual(payload["session_code"], "NY_AM") + + def test_session_focus_aggregates_candidates_executions_and_alerts(self) -> None: + service = SessionFocusService( + candidate_repository=_CandidateRepo(), + execution_service=_ExecutionService(), + decision_repository=_DecisionRepo(), + session_repository=_SessionRepo(), + trust_service=_TrustService(), + ) + + summary = service.build_summary(instrument_id=1, limit=2) + + self.assertEqual(summary["items"][0]["candidate_count"], 1) + self.assertEqual(summary["items"][0]["open_execution_count"], 1) + self.assertEqual(summary["items"][0]["alert_count"], 1) + self.assertEqual(summary["items"][0]["action_hint"], "review_alerts") + + def test_review_packet_collects_core_sections(self) -> None: + service = ReviewPacketService( + note_service=_NoteService(), + decision_service=_DecisionService(), + execution_service=_ExecutionService(), + trust_service=_TrustService(), + weekly_review_service=_WeeklyReviewService(), + decision_repository=_DecisionRepo(), + ) + + packet = service.build_packet(instrument_id=1) + + self.assertIn("decision_packet", packet) + self.assertIn("recent_notes", packet) + self.assertIn("weekly_review", packet) + self.assertIn("trust_score", packet) + + +class _NoteRepo: + def insert_note(self, **kwargs): + return 11 + + def fetch_recent_notes(self, **kwargs): + return [{"id": 11, "author": "alice"}] + + +class _CandidateRepo: + def fetch_recent_candidates(self, **kwargs): + return [{"session_code": "LONDON", "status": "executable", "executable": True}] + + +class _ExecutionService: + def build_execution_dashboard_items(self, **kwargs): + return [{"session_code": "LONDON", "execution_status": "open"}] + + +class _DecisionRepo: + def fetch_recent_decision_alerts(self, **kwargs): + return [{"payload": {"session_code": "LONDON"}, "alert_type": "candidate_actionable"}] + + +class _TrustService: + def build_summary(self, **kwargs): + return { + "models": [ + { + "session_recommendations": [ + {"session_code": "LONDON", "score": 80.0, "classification": "prefer"}, + {"session_code": "NY_AM", "score": 30.0, "classification": "avoid"}, + ] + } + ] + } + + +class _NoteService: + def get_recent_notes(self, **kwargs): + return [{"id": 1, "author": "alice"}] + + +class _DecisionService: + def build_preview(self, **kwargs): + return {"status": "actionable", "action_recommendation": "confirm_ticket"} + + +class _WeeklyReviewService: + def build_summary(self, **kwargs): + return {"entry_count": 2} + + +class _SessionRepo: + def fetch_recent_sessions(self, **kwargs): + return [ + {"session_code": "LONDON", "status": "active", "start_ts": None, "end_ts": None}, + {"session_code": "NY_AM", "status": "closed", "start_ts": None, "end_ts": None}, + ] + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/test_runtime_script_json.py b/tests/unit/test_runtime_script_json.py index 34debbd..7300a17 100644 --- a/tests/unit/test_runtime_script_json.py +++ b/tests/unit/test_runtime_script_json.py @@ -23,6 +23,7 @@ from scripts import ( run_mss, run_ranges, run_decision_packet, + run_review_packet, run_session_generator, run_trading_runtime, run_sweeps, @@ -159,6 +160,16 @@ class RuntimeScriptJsonTests(unittest.TestCase): self.assertEqual(exit_code, 0) self.assertEqual(payload["instrument_id"], 2) + def test_run_review_packet_outputs_json(self) -> None: + service = type("Service", (), {"build_packet": lambda self, instrument_id: {"instrument_id": instrument_id, "generated_ts": "now"}})() + output = io.StringIO() + with patch("scripts.run_review_packet.ReviewPacketService", return_value=service), contextlib.redirect_stdout(output): + exit_code = run_review_packet.main(env={"REVIEW_PACKET_OUTPUT_JSON": "1", "REVIEW_PACKET_INSTRUMENT_ID": "2"}) + + payload = json.loads(output.getvalue()) + self.assertEqual(exit_code, 0) + self.assertEqual(payload["instrument_id"], 2) + if __name__ == "__main__": unittest.main()