5acb2fb43e
1. patterns endpoint: fix query referencing non-existent column di.catalyst_type → dir.catalyst_type (column is on document_impact_records) 2. lockouts seed: use relative timestamps (now + 7d) so active lockout is always in the future regardless of when tests run 3. create_agent: make slug optional with auto-generation from name 4. create_source: json.dumps(config) + ::jsonb cast for asyncpg JSONB compat 5. approval_expiry: return count as int (len(expired)) not the list itself 6. metrics_consistency: fix test assertion to match API contract (total >= active + reserve, not total == active + reserve + unrealized)
102 lines
2.8 KiB
Python
102 lines
2.8 KiB
Python
"""Risk Engine API - FastAPI application for order risk evaluation and approval workflow."""
|
|
from __future__ import annotations
|
|
|
|
from contextlib import asynccontextmanager
|
|
|
|
import asyncpg
|
|
from fastapi import FastAPI, HTTPException
|
|
from pydantic import BaseModel
|
|
|
|
from services.risk.approval import (
|
|
expire_stale_approvals,
|
|
get_approval_by_id,
|
|
get_pending_approvals,
|
|
review_approval,
|
|
)
|
|
from services.risk.engine import (
|
|
AccountRiskState,
|
|
PortfolioRiskConfig,
|
|
ProposedOrder,
|
|
RiskEvaluation,
|
|
evaluate_order,
|
|
)
|
|
from services.shared.config import load_config
|
|
from services.shared.logging import setup_logging
|
|
|
|
config = load_config()
|
|
pool: asyncpg.Pool | None = None
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
global pool
|
|
setup_logging("risk_engine", level=config.log_level, json_output=config.json_logs)
|
|
pool = await asyncpg.create_pool(dsn=config.postgres.dsn, min_size=2, max_size=8)
|
|
yield
|
|
if pool:
|
|
await pool.close()
|
|
|
|
|
|
app = FastAPI(title="Stonks Oracle - Risk Engine", lifespan=lifespan)
|
|
|
|
|
|
class EvaluateRequest(BaseModel):
|
|
order: ProposedOrder
|
|
config: PortfolioRiskConfig | None = None
|
|
state: AccountRiskState | None = None
|
|
|
|
|
|
@app.post("/evaluate", response_model=RiskEvaluation)
|
|
async def evaluate(req: EvaluateRequest) -> RiskEvaluation:
|
|
risk_config = req.config or PortfolioRiskConfig()
|
|
return evaluate_order(req.order, risk_config, req.state)
|
|
|
|
|
|
@app.get("/health")
|
|
async def health():
|
|
return {"status": "ok"}
|
|
|
|
|
|
class ReviewRequest(BaseModel):
|
|
approved: bool
|
|
reviewed_by: str = "operator"
|
|
review_note: str = ""
|
|
|
|
|
|
@app.get("/approvals/pending")
|
|
async def list_pending():
|
|
if not pool:
|
|
raise HTTPException(503, "Database not ready")
|
|
requests = await get_pending_approvals(pool)
|
|
return [r.to_dict() for r in requests]
|
|
|
|
|
|
@app.get("/approvals/{approval_id}")
|
|
async def get_approval(approval_id: str):
|
|
if not pool:
|
|
raise HTTPException(503, "Database not ready")
|
|
req = await get_approval_by_id(pool, approval_id)
|
|
if not req:
|
|
raise HTTPException(404, "Approval not found")
|
|
return req.to_dict()
|
|
|
|
|
|
@app.post("/approvals/{approval_id}/review")
|
|
async def review(approval_id: str, body: ReviewRequest):
|
|
if not pool:
|
|
raise HTTPException(503, "Database not ready")
|
|
status = await review_approval(
|
|
pool, approval_id, body.approved, body.reviewed_by, body.review_note,
|
|
)
|
|
if status is None:
|
|
raise HTTPException(404, "Approval not found or no longer pending")
|
|
return {"approval_id": approval_id, "status": status.value}
|
|
|
|
|
|
@app.post("/approvals/expire")
|
|
async def expire():
|
|
if not pool:
|
|
raise HTTPException(503, "Database not ready")
|
|
expired = await expire_stale_approvals(pool)
|
|
return {"expired": len(expired), "items": expired}
|