Files
stonks-oracle/services/risk/approval.py
T

301 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 (
OperatorApproval,
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