"""Operator approval workflow for live trading mode. When live trading is enabled and operator approval is required, orders are held in a pending state until an operator explicitly approves or rejects them. Expired approvals are treated as rejections. Requirements: 8.2 Design: Section 4.8 - Risk Engine (operator approval rules) """ from __future__ import annotations import json import logging import uuid from datetime import datetime, timedelta, timezone from enum import Enum from typing import Any import asyncpg from services.risk.engine import ( PortfolioRiskConfig, TradingMode, ) logger = logging.getLogger("operator_approval") # --------------------------------------------------------------------------- # Enums # --------------------------------------------------------------------------- class ApprovalStatus(str, Enum): PENDING = "pending" APPROVED = "approved" REJECTED = "rejected" EXPIRED = "expired" # --------------------------------------------------------------------------- # Core logic: does this order need approval? # --------------------------------------------------------------------------- def requires_approval( config: PortfolioRiskConfig, trading_mode: TradingMode | None = None, ) -> bool: """Determine whether an order requires operator approval. Paper orders are auto-approved when auto_approve_paper is True. Live orders require approval when require_approval_for_live is True. Disabled mode always returns False (orders are blocked upstream). """ mode = trading_mode or config.trading_mode if mode == TradingMode.DISABLED: return False if mode == TradingMode.PAPER: return not config.operator_approval.auto_approve_paper # Live mode return config.operator_approval.require_approval_for_live def compute_expiry( config: PortfolioRiskConfig, now: datetime | None = None, ) -> datetime: """Compute the expiry timestamp for a new approval request.""" now = now or datetime.now(timezone.utc) return now + timedelta(minutes=config.operator_approval.approval_timeout_minutes) # --------------------------------------------------------------------------- # Approval request model (in-memory representation) # --------------------------------------------------------------------------- class ApprovalRequest: """Represents a pending operator approval request.""" def __init__( self, approval_id: str | None = None, order_job: dict[str, Any] | None = None, recommendation_id: str | None = None, ticker: str = "", side: str = "buy", quantity: float = 0.0, estimated_value: float = 0.0, risk_evaluation_id: str | None = None, status: ApprovalStatus = ApprovalStatus.PENDING, requested_by: str = "system", reviewed_by: str | None = None, review_note: str | None = None, expires_at: datetime | None = None, requested_at: datetime | None = None, reviewed_at: datetime | None = None, ) -> None: self.approval_id = approval_id or str(uuid.uuid4()) self.order_job = order_job or {} self.recommendation_id = recommendation_id self.ticker = ticker self.side = side self.quantity = quantity self.estimated_value = estimated_value self.risk_evaluation_id = risk_evaluation_id self.status = status self.requested_by = requested_by self.reviewed_by = reviewed_by self.review_note = review_note self.expires_at = expires_at or (datetime.now(timezone.utc) + timedelta(minutes=30)) self.requested_at = requested_at or datetime.now(timezone.utc) self.reviewed_at = reviewed_at @property def is_pending(self) -> bool: return self.status == ApprovalStatus.PENDING @property def is_expired(self) -> bool: if self.status == ApprovalStatus.EXPIRED: return True if self.status == ApprovalStatus.PENDING: return datetime.now(timezone.utc) >= self.expires_at return False def to_dict(self) -> dict[str, Any]: return { "approval_id": self.approval_id, "recommendation_id": self.recommendation_id, "ticker": self.ticker, "side": self.side, "quantity": self.quantity, "estimated_value": self.estimated_value, "risk_evaluation_id": self.risk_evaluation_id, "status": self.status.value, "requested_by": self.requested_by, "reviewed_by": self.reviewed_by, "review_note": self.review_note, "expires_at": self.expires_at.isoformat() if self.expires_at else None, "requested_at": self.requested_at.isoformat() if self.requested_at else None, "reviewed_at": self.reviewed_at.isoformat() if self.reviewed_at else None, } # --------------------------------------------------------------------------- # DB persistence # --------------------------------------------------------------------------- _INSERT_APPROVAL = """ INSERT INTO operator_approvals ( id, order_job, recommendation_id, ticker, side, quantity, estimated_value, status, risk_evaluation_id, requested_by, expires_at, requested_at ) VALUES ( $1::uuid, $2::jsonb, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 ) """ _UPDATE_APPROVAL_STATUS = """ UPDATE operator_approvals SET status = $2, reviewed_by = $3, review_note = $4, reviewed_at = $5, updated_at = NOW() WHERE id = $1::uuid AND status = 'pending' RETURNING id, status """ _EXPIRE_STALE_APPROVALS = """ UPDATE operator_approvals SET status = 'expired', updated_at = NOW() WHERE status = 'pending' AND expires_at <= $1 RETURNING id, ticker """ _FETCH_PENDING_APPROVALS = """ SELECT id, order_job, recommendation_id, ticker, side, quantity, estimated_value, status, risk_evaluation_id, requested_by, reviewed_by, review_note, expires_at, requested_at, reviewed_at FROM operator_approvals WHERE status = 'pending' ORDER BY requested_at ASC """ _FETCH_APPROVAL_BY_ID = """ SELECT id, order_job, recommendation_id, ticker, side, quantity, estimated_value, status, risk_evaluation_id, requested_by, reviewed_by, review_note, expires_at, requested_at, reviewed_at FROM operator_approvals WHERE id = $1::uuid """ def _row_to_request(row: Any) -> ApprovalRequest: """Convert a DB row to an ApprovalRequest.""" order_job = row["order_job"] if isinstance(order_job, str): order_job = json.loads(order_job) return ApprovalRequest( approval_id=str(row["id"]), order_job=order_job, recommendation_id=str(row["recommendation_id"]) if row["recommendation_id"] else None, ticker=row["ticker"], side=row["side"], quantity=float(row["quantity"]), estimated_value=float(row["estimated_value"]), risk_evaluation_id=str(row["risk_evaluation_id"]) if row.get("risk_evaluation_id") else None, status=ApprovalStatus(row["status"]), requested_by=row["requested_by"], reviewed_by=row["reviewed_by"], review_note=row["review_note"], expires_at=row["expires_at"], requested_at=row["requested_at"], reviewed_at=row["reviewed_at"], ) async def create_approval_request( pool: asyncpg.Pool, request: ApprovalRequest, ) -> str: """Persist a new approval request. Returns the approval ID.""" await pool.execute( _INSERT_APPROVAL, request.approval_id, json.dumps(request.order_job, default=str), request.recommendation_id, request.ticker, request.side, request.quantity, request.estimated_value, request.status.value, request.risk_evaluation_id, request.requested_by, request.expires_at, request.requested_at, ) return request.approval_id async def review_approval( pool: asyncpg.Pool, approval_id: str, approved: bool, reviewed_by: str = "operator", review_note: str = "", ) -> ApprovalStatus | None: """Approve or reject a pending approval request. Returns the new status, or None if the approval was not found or was no longer pending (already expired/reviewed). """ now = datetime.now(timezone.utc) new_status = ApprovalStatus.APPROVED if approved else ApprovalStatus.REJECTED row = await pool.fetchrow( _UPDATE_APPROVAL_STATUS, approval_id, new_status.value, reviewed_by, review_note, now, ) if row: return ApprovalStatus(row["status"]) return None async def expire_stale_approvals( pool: asyncpg.Pool, now: datetime | None = None, ) -> list[dict[str, str]]: """Mark all expired pending approvals. Returns list of expired items.""" now = now or datetime.now(timezone.utc) rows = await pool.fetch(_EXPIRE_STALE_APPROVALS, now) return [{"id": str(r["id"]), "ticker": r["ticker"]} for r in rows] async def get_pending_approvals( pool: asyncpg.Pool, ) -> list[ApprovalRequest]: """Fetch all pending approval requests, oldest first.""" rows = await pool.fetch(_FETCH_PENDING_APPROVALS) return [_row_to_request(r) for r in rows] async def get_approval_by_id( pool: asyncpg.Pool, approval_id: str, ) -> ApprovalRequest | None: """Fetch a single approval request by ID.""" row = await pool.fetchrow(_FETCH_APPROVAL_BY_ID, approval_id) if row: return _row_to_request(row) return None