Add operator workspace tools
This commit is contained in:
@@ -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);
|
||||
@@ -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())
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user