Add operator workspace tools

This commit is contained in:
Codex
2026-05-08 15:39:02 +08:00
parent 519186a73a
commit 2a21ebb5ff
16 changed files with 838 additions and 7 deletions
+18
View File
@@ -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);
+36
View File
@@ -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())
+8
View File
@@ -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",
+44
View File
@@ -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:
+40
View File
@@ -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 "")
@@ -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(
"""
@@ -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]
@@ -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),
+11
View File
@@ -0,0 +1,11 @@
from src.services.operator.operator_workspace_service import (
OperatorNoteService,
ReviewPacketService,
SessionFocusService,
)
__all__ = [
"OperatorNoteService",
"ReviewPacketService",
"SessionFocusService",
]
@@ -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"
+174 -7
View File
@@ -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();
+33
View File
@@ -37,6 +37,24 @@
<option value="cancelled">Cancelled</option>
</select>
</label>
<label>Session Focus
<select id="sessionFilter">
<option value="all" selected>All Sessions</option>
<option value="LONDON">LONDON</option>
<option value="NY_AM">NY_AM</option>
<option value="NY_PM">NY_PM</option>
<option value="ASIA">ASIA</option>
<option value="OFF_HOURS">OFF_HOURS</option>
</select>
</label>
<label>Trust Class
<select id="trustClassFilter">
<option value="all" selected>All Trust</option>
<option value="whitelist">Whitelist</option>
<option value="observe">Observe</option>
<option value="blacklist">Blacklist</option>
</select>
</label>
<label>Search
<input id="searchText" type="text" placeholder="candidate, ticket, execution, reason" />
</label>
@@ -74,6 +92,11 @@
<div id="trustScorePanel" class="detail-panel"></div>
</section>
<section class="panel panel-wide">
<h2>Session Focus</h2>
<div id="sessionFocusPanel" class="detail-panel"></div>
</section>
<section class="panel panel-wide">
<h2>Pre-Market Plan</h2>
<div id="preMarketPlanPanel" class="detail-panel"></div>
@@ -89,11 +112,21 @@
<div id="executionTicketsPanel" class="detail-panel"></div>
</section>
<section class="panel panel-wide">
<h2>Alert History</h2>
<div id="alertHistoryPanel" class="detail-panel"></div>
</section>
<section class="panel panel-wide">
<h2>Position Management</h2>
<div id="positionManagementPanel" class="detail-panel"></div>
</section>
<section class="panel panel-wide">
<h2>Operator Console</h2>
<div id="operatorConsolePanel" class="detail-panel"></div>
</section>
<section class="panel panel-wide">
<h2>Post-Trade Review</h2>
<div id="postTradeReviewPanel" class="detail-panel"></div>
+14
View File
@@ -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;
+33
View File
@@ -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",
@@ -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()
+11
View File
@@ -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()