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:
+143
-1
@@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user