phase 14-15: docker build validation and helm deployment
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user