300 lines
9.4 KiB
Python
300 lines
9.4 KiB
Python
"""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
|