fix: operator approval workflow — add approval toggle, lockout CRUD, and PBT tests

- Add GET/PUT /api/admin/trading/approval-config endpoints
- Add POST/DELETE /api/admin/trading/lockouts endpoints
- Add useApprovalConfig, useUpdateApprovalConfig, useCreateLockout, useDeleteLockout hooks
- Add Paper Order Approval toggle card with confirmation dialog
- Add lockout creation form and delete button to Active Lockouts card
- Add MSW handlers for all new endpoints
- Add property-based tests for bug condition exploration and preservation
This commit is contained in:
Celes Renata
2026-04-17 06:14:46 +00:00
parent 3b7ded37cc
commit b149f70507
9 changed files with 1035 additions and 5 deletions
+143 -1
View File
@@ -19,7 +19,7 @@ import re
import time as _time
from contextlib import asynccontextmanager
from dataclasses import asdict
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
import asyncpg
@@ -40,6 +40,7 @@ from services.shared.audit import get_entity_audit_trail, get_order_audit_trail,
from services.shared.config import load_config
from services.shared.db import get_pg_pool, get_redis
from services.shared.logging import new_trace_id, set_trace_context, setup_logging
from services.risk.engine import PortfolioRiskConfig
from services.shared.schemas import MAJOR_DECISION_CATALYSTS
logger = logging.getLogger("query_api")
@@ -1409,6 +1410,147 @@ async def list_active_lockouts():
return [_row_to_dict(r) for r in rows]
@app.post("/api/admin/trading/lockouts")
async def create_lockout(body: dict[str, Any]):
"""Create a manual symbol lockout.
Accepts: { ticker: string, reason: string, duration_minutes: int, lockout_type?: string }
Computes expires_at = NOW() + duration_minutes.
Defaults lockout_type to "manual".
"""
ticker = body.get("ticker")
reason = body.get("reason")
duration_minutes = body.get("duration_minutes")
if not ticker or not isinstance(ticker, str):
raise HTTPException(400, "ticker is required and must be a string")
if not reason or not isinstance(reason, str):
raise HTTPException(400, "reason is required and must be a string")
if duration_minutes is None or not isinstance(duration_minutes, (int, float)) or duration_minutes <= 0:
raise HTTPException(400, "duration_minutes is required and must be a positive number")
lockout_type = body.get("lockout_type", "manual")
duration = timedelta(minutes=int(duration_minutes))
row = await pool.fetchrow(
"""INSERT INTO symbol_lockouts (ticker, lockout_type, reason, expires_at)
VALUES ($1, $2, $3, NOW() + $4::interval)
RETURNING id, ticker, lockout_type, reason, expires_at, created_at""",
ticker.upper(), lockout_type, reason, duration,
)
return _row_to_dict(row)
@app.delete("/api/admin/trading/lockouts/{lockout_id}")
async def delete_lockout(lockout_id: str):
"""Delete a symbol lockout by ID, allowing early removal."""
result = await pool.execute(
"DELETE FROM symbol_lockouts WHERE id = $1::uuid", lockout_id,
)
if result == "DELETE 0":
raise HTTPException(404, "Lockout not found")
return {"status": "deleted"}
@app.get("/api/admin/trading/approval-config")
async def get_approval_config():
"""Get the current operator approval settings from the active risk config.
Reads the active risk_configs row, parses the config JSONB with
PortfolioRiskConfig.from_db_json() to fill defaults for missing fields,
and returns the operator_approval sub-object.
"""
row = await pool.fetchrow(
"""SELECT config
FROM risk_configs
WHERE active = TRUE
ORDER BY updated_at DESC
LIMIT 1""",
)
config_json = _parse_jsonb(row["config"]) if row else {}
if not isinstance(config_json, dict):
config_json = {}
risk_config = PortfolioRiskConfig.from_db_json(config_json)
approval = risk_config.operator_approval
return {
"auto_approve_paper": approval.auto_approve_paper,
"require_approval_for_live": approval.require_approval_for_live,
"approval_timeout_minutes": approval.approval_timeout_minutes,
}
@app.put("/api/admin/trading/approval-config")
async def update_approval_config(body: dict[str, Any]):
"""Update operator approval settings in the active risk config.
Reads the current config JSONB, merges the operator_approval sub-object,
and writes back the full config. This preserves all other config fields
(position_limits, sector_exposure, etc.) while only updating approval settings.
Accepts: { auto_approve_paper: bool, require_approval_for_live?: bool, approval_timeout_minutes?: int }
Bug fix: This endpoint allows the operator to set auto_approve_paper=False,
which was previously impossible because no UI/endpoint wrote operator_approval
into the risk config JSON.
"""
# Read current config
row = await pool.fetchrow(
"""SELECT config
FROM risk_configs
WHERE active = TRUE
ORDER BY updated_at DESC
LIMIT 1""",
)
current_config = _parse_jsonb(row["config"]) if row else {}
if not isinstance(current_config, dict):
current_config = {}
# Build the operator_approval sub-object by merging with existing values
existing_approval = current_config.get("operator_approval", {})
if not isinstance(existing_approval, dict):
existing_approval = {}
# Only update fields that are provided in the request body
allowed_fields = {"auto_approve_paper", "require_approval_for_live", "approval_timeout_minutes"}
for field in allowed_fields:
if field in body:
existing_approval[field] = body[field]
# Merge back into the full config (preserves all other config fields)
current_config["operator_approval"] = existing_approval
config_json = json.dumps(current_config)
# Write back
updated_row = await pool.fetchrow(
"""UPDATE risk_configs SET config = $1::jsonb, updated_at = NOW()
WHERE active = TRUE
RETURNING id, name, trading_mode, config""",
config_json,
)
if not updated_row:
updated_row = await pool.fetchrow(
"""INSERT INTO risk_configs (name, trading_mode, config, active)
VALUES ('default', 'paper', $1::jsonb, TRUE)
RETURNING id, name, trading_mode, config""",
config_json,
)
# Return the updated approval settings
updated_config = _parse_jsonb(updated_row["config"]) if updated_row else current_config
if not isinstance(updated_config, dict):
updated_config = current_config
risk_config = PortfolioRiskConfig.from_db_json(updated_config)
approval = risk_config.operator_approval
return {
"auto_approve_paper": approval.auto_approve_paper,
"require_approval_for_live": approval.require_approval_for_live,
"approval_timeout_minutes": approval.approval_timeout_minutes,
}
# ---------------------------------------------------------------------------
# Operational Dashboard (Requirement 12.1, 12.2, 12.3)
# ---------------------------------------------------------------------------