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
@@ -0,0 +1 @@
{"specId": "4d1be766-7daa-4032-b0e9-4f6124370ccd", "workflowType": "requirements-first", "specType": "bugfix"}
@@ -0,0 +1,50 @@
# Bugfix Requirements Document
## Introduction
The Trading Controls page at `/trading` has two dead sections — "Pending Approvals" and "Active Lockouts" — that display data from the API but provide no way to actually trigger the underlying workflows. The operator approval system is fully implemented in the backend (`services/risk/approval.py`, `services/adapters/broker_service.py`) with an approval gate in the order processing pipeline, but it is effectively disconnected because:
1. The `requires_approval()` function returns `False` in paper mode (the default) since `auto_approve_paper` defaults to `True`, and the risk config stored in the database never overrides this default. There is no UI control to toggle approval requirements for paper mode.
2. The lockout system has a database table (`symbol_lockouts`) and a read-only API endpoint (`GET /api/admin/trading/lockouts`), but there is no API endpoint to create a manual lockout and no UI form to do so.
The result is that the operator can never exercise "human calls the final shot" control — orders bypass approval silently, and lockouts can never be created manually.
## Bug Analysis
### Current Behavior (Defect)
1.1 WHEN the system is in paper trading mode (the default) THEN the `requires_approval()` function always returns `False` because `OperatorApproval.auto_approve_paper` defaults to `True` and the risk config JSON in the database does not include operator approval settings, causing all orders to bypass the approval gate in `process_order_job()`
1.2 WHEN the operator views the Trading Controls page THEN the "Pending Approvals" section always shows "(0) — No pending approvals" because no approval requests are ever created due to defect 1.1
1.3 WHEN the operator wants to manually lock out a ticker from trading THEN there is no API endpoint to create a lockout entry in the `symbol_lockouts` table, so the "Active Lockouts" section is permanently empty unless lockouts are created by internal system processes
1.4 WHEN the operator views the Active Lockouts section on the Trading Controls page THEN there is no UI control to create a new manual lockout or to remove an existing lockout early
1.5 WHEN the operator wants to enable or disable the approval requirement for paper mode THEN there is no UI control on the Trading Controls page to toggle the `auto_approve_paper` setting in the risk config
### Expected Behavior (Correct)
2.1 WHEN the system is in paper trading mode and the operator has disabled `auto_approve_paper` via the UI THEN the `requires_approval()` function SHALL return `True`, causing orders to be held for operator approval before broker submission
2.2 WHEN `requires_approval()` returns `True` for the current trading mode THEN orders processed by `process_order_job()` SHALL create approval requests visible in the "Pending Approvals" section, and the operator SHALL be able to approve or reject them using the existing approve/reject buttons
2.3 WHEN the operator submits a manual lockout via the Trading Controls page (specifying ticker, reason, and duration) THEN the system SHALL create a new entry in the `symbol_lockouts` table via a new API endpoint, and the lockout SHALL appear in the "Active Lockouts" section
2.4 WHEN the operator views the Active Lockouts section THEN each lockout entry SHALL have a control to remove (cancel) the lockout early, and there SHALL be a form to create a new manual lockout
2.5 WHEN the operator toggles the approval requirement setting on the Trading Controls page THEN the system SHALL update the `auto_approve_paper` field in the active risk config's JSON, and the change SHALL take effect for subsequent orders without requiring a service restart
### Unchanged Behavior (Regression Prevention)
3.1 WHEN the system is in live trading mode with `require_approval_for_live` set to `True` (the default) THEN the system SHALL CONTINUE TO require operator approval for live orders as it does today
3.2 WHEN the system is in disabled trading mode THEN the `requires_approval()` function SHALL CONTINUE TO return `False` because orders are blocked upstream by the risk engine
3.3 WHEN the operator approves or rejects a pending approval via the existing approve/reject buttons THEN the system SHALL CONTINUE TO update the approval status in the `operator_approvals` table via the existing `PUT /api/admin/trading/approvals/{id}` endpoint
3.4 WHEN a pending approval expires (exceeds `approval_timeout_minutes`) THEN the system SHALL CONTINUE TO mark it as expired via the existing `expire_stale_approvals()` function
3.5 WHEN the risk engine evaluates an order and it fails risk checks THEN the order SHALL CONTINUE TO be rejected before reaching the approval gate, preserving the existing risk evaluation pipeline
3.6 WHEN system-generated lockouts are created by internal processes (news-shock, cooldown) THEN those lockouts SHALL CONTINUE TO function as they do today, unaffected by the new manual lockout feature
@@ -0,0 +1,211 @@
# Operator Approval Workflow Bugfix Design
## Overview
The operator approval system is fully implemented in the backend (`services/risk/approval.py`, `services/adapters/broker_service.py`) but is effectively disconnected from the operator's control. The `requires_approval()` function always returns `False` in paper mode because `OperatorApproval.auto_approve_paper` defaults to `True` and the `config` JSONB column in `risk_configs` is initialized as `{}`, which means `PortfolioRiskConfig.from_db_json({})` always produces default values that auto-approve paper orders. Additionally, the lockout system lacks a creation endpoint and UI controls.
The fix connects the existing approval pipeline to the UI by: (1) adding a toggle to control `auto_approve_paper` in the risk config JSON, (2) adding a POST endpoint and UI form for manual symbol lockouts, and (3) adding a DELETE endpoint and UI control for early lockout removal.
## Glossary
- **Bug_Condition (C)**: The condition where `requires_approval()` returns `False` in paper mode because `auto_approve_paper` defaults to `True` and the risk config JSON never overrides it — causing all paper orders to bypass the approval gate silently
- **Property (P)**: When the operator has disabled `auto_approve_paper` via the UI, `requires_approval()` returns `True` for paper mode, causing orders to be held for approval; when a manual lockout is created via the new endpoint, it appears in the Active Lockouts section
- **Preservation**: Existing live-mode approval behavior, disabled-mode bypass, approval review/expiry workflows, risk engine rejection pipeline, and system-generated lockouts must remain unchanged
- **`requires_approval()`**: Function in `services/risk/approval.py` that determines whether an order needs operator approval based on trading mode and config
- **`process_order_job()`**: Function in `services/adapters/broker_service.py` that processes broker queue jobs, running risk evaluation and the approval gate
- **`load_risk_config()`**: Function in `services/adapters/broker_service.py` that loads `PortfolioRiskConfig` from the `risk_configs.config` JSONB column
- **`OperatorApproval`**: Pydantic model in `services/risk/engine.py` with `auto_approve_paper` (default `True`) and `require_approval_for_live` (default `True`)
- **`risk_configs`**: PostgreSQL table (migration 010) storing the active risk configuration as a JSONB `config` column
## Bug Details
### Bug Condition
The bug manifests when the system is in paper trading mode (the default). The `requires_approval()` function returns `False` because `OperatorApproval.auto_approve_paper` defaults to `True`, and the `risk_configs.config` JSONB column is initialized as `{}` — meaning `PortfolioRiskConfig.from_db_json({})` always produces a config with `auto_approve_paper=True`. There is no UI control to set this field to `False`, so the approval gate in `process_order_job()` is never reached for paper orders.
A secondary condition is that the `symbol_lockouts` table has no POST endpoint, so manual lockouts cannot be created, and the Active Lockouts section on the Trading page is permanently empty unless internal processes create lockouts.
**Formal Specification:**
```
FUNCTION isBugCondition(input)
INPUT: input of type { trading_mode: TradingMode, risk_config_json: dict }
OUTPUT: boolean
config := PortfolioRiskConfig.from_db_json(risk_config_json)
RETURN (
trading_mode == PAPER
AND config.operator_approval.auto_approve_paper == True
AND "operator_approval" NOT IN risk_config_json
)
OR (
input.action == "create_manual_lockout"
AND NOT exists(POST_endpoint_for_symbol_lockouts)
)
END FUNCTION
```
### Examples
- **Paper mode, empty config JSON**: `risk_configs.config = {}`, `trading_mode = 'paper'``requires_approval()` returns `False` → orders bypass approval silently. **Expected**: operator should be able to toggle `auto_approve_paper` to `False` so `requires_approval()` returns `True`.
- **Paper mode, config with operator_approval**: `risk_configs.config = {"operator_approval": {"auto_approve_paper": false}}``requires_approval()` returns `True` → orders held for approval. **Expected**: this is the correct behavior, but currently unreachable because no UI sets this field.
- **Manual lockout attempt**: Operator wants to lock out AAPL for 60 minutes → no POST endpoint exists → lockout cannot be created. **Expected**: `POST /api/admin/trading/lockouts` creates a row in `symbol_lockouts`.
- **Lockout removal**: Operator wants to cancel an active lockout early → no DELETE endpoint exists → lockout persists until expiry. **Expected**: `DELETE /api/admin/trading/lockouts/{id}` removes the lockout.
## Expected Behavior
### Preservation Requirements
**Unchanged Behaviors:**
- Live mode with `require_approval_for_live=True` must continue to require operator approval for live orders
- Disabled mode must continue to return `False` from `requires_approval()` (orders blocked upstream)
- The existing `PUT /api/admin/trading/approvals/{id}` endpoint must continue to approve/reject pending approvals
- The `expire_stale_approvals()` function must continue to mark expired approvals
- Orders that fail risk checks must continue to be rejected before reaching the approval gate
- System-generated lockouts (news-shock, cooldown) must continue to function unaffected by the new manual lockout feature
- Mouse clicks, trading mode switches, risk tier changes, and all other Trading page controls must continue to work
**Scope:**
All inputs that do NOT involve the new `auto_approve_paper` toggle, manual lockout creation, or lockout deletion should be completely unaffected by this fix. This includes:
- Live mode approval behavior (already working)
- Risk evaluation pipeline
- Order idempotency and duplicate prevention
- Position sync and broker account registration
- All other Trading page controls (mode, risk tier, macro toggle, competitive toggle, paper reset)
## Hypothesized Root Cause
Based on the bug description and code analysis, the root causes are:
1. **Missing UI control for `auto_approve_paper`**: The `OperatorApproval.auto_approve_paper` field defaults to `True` in the Pydantic model. The `risk_configs.config` JSONB column is initialized as `{}` by the `set_trading_mode` endpoint. Since `PortfolioRiskConfig.from_db_json({})` fills all defaults, `auto_approve_paper` is always `True`. The Trading page has no toggle to update this field in the config JSON.
2. **`update_trading_config` does full JSON replace**: The existing `PUT /api/admin/trading/config` endpoint replaces the entire `config` JSONB column. A new UI control needs to read the current config, merge the `operator_approval` settings, and write back the full config — or a dedicated endpoint should handle the toggle.
3. **Missing POST endpoint for `symbol_lockouts`**: The `GET /api/admin/trading/lockouts` endpoint exists for reading active lockouts, but there is no `POST` endpoint to create a manual lockout entry. The table schema supports it (no constraints preventing manual entries), but the API route is missing.
4. **Missing DELETE endpoint for `symbol_lockouts`**: There is no endpoint to remove a lockout early. The only way a lockout expires is by time (`expires_at > NOW()`).
5. **Missing frontend form and controls**: The Trading page renders lockout data read-only and has no form for creating lockouts or toggling approval settings.
## Correctness Properties
Property 1: Bug Condition - Paper Mode Approval Toggle
_For any_ `PortfolioRiskConfig` where `trading_mode` is `PAPER` and `operator_approval.auto_approve_paper` is `False`, the `requires_approval()` function SHALL return `True`, causing orders to be held for operator approval.
**Validates: Requirements 2.1, 2.2**
Property 2: Preservation - Non-Paper-Toggle Approval Behavior
_For any_ `PortfolioRiskConfig` where `trading_mode` is `LIVE` or `DISABLED`, the `requires_approval()` function SHALL produce the same result as the original function: `True` when `trading_mode` is `LIVE` and `require_approval_for_live` is `True`, and `False` when `trading_mode` is `DISABLED`. This preserves all existing approval behavior for non-paper modes.
**Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5**
## Fix Implementation
### Changes Required
Assuming our root cause analysis is correct:
**File**: `services/api/app.py`
**New Endpoints**:
1. **`PUT /api/admin/trading/approval-config`**: Dedicated endpoint to update `operator_approval` settings in the risk config JSON. Reads the current config, merges the `operator_approval` sub-object, and writes back. Accepts `{ auto_approve_paper: bool }`.
2. **`GET /api/admin/trading/approval-config`**: Returns the current `operator_approval` settings from the active risk config.
3. **`POST /api/admin/trading/lockouts`**: Creates a manual lockout entry in `symbol_lockouts`. Accepts `{ ticker: string, reason: string, duration_minutes: int, lockout_type?: string }`. Computes `expires_at` from `NOW() + duration_minutes`.
4. **`DELETE /api/admin/trading/lockouts/{lockout_id}`**: Deletes a lockout entry by ID, allowing early removal.
**File**: `frontend/src/api/hooks.ts`
**New Hooks**:
1. **`useApprovalConfig()`**: Fetches current approval config via `GET /api/admin/trading/approval-config`.
2. **`useUpdateApprovalConfig()`**: Mutation to update approval config via `PUT /api/admin/trading/approval-config`. Invalidates `['approval-config']` and `['trading-config']` query keys.
3. **`useCreateLockout()`**: Mutation to create a manual lockout via `POST /api/admin/trading/lockouts`. Invalidates `['lockouts']` query key.
4. **`useDeleteLockout()`**: Mutation to delete a lockout via `DELETE /api/admin/trading/lockouts/{id}`. Invalidates `['lockouts']` query key.
**File**: `frontend/src/pages/Trading.tsx`
**UI Changes**:
1. **Approval Toggle Card**: New card between the existing controls and Pending Approvals section. Contains a toggle switch for `auto_approve_paper` with a confirmation dialog (since enabling approval requirements changes order flow). Shows current state and last-changed timestamp.
2. **Lockout Creation Form**: Add a form to the Active Lockouts card with fields for ticker (text input), reason (text input), and duration in minutes (number input). Submit button calls `useCreateLockout()`.
3. **Lockout Delete Button**: Add a "Remove" button to each lockout row that calls `useDeleteLockout()` with a confirmation step.
## Testing Strategy
### Validation Approach
The testing strategy follows a two-phase approach: first, surface counterexamples that demonstrate the bug on unfixed code, then verify the fix works correctly and preserves existing behavior.
### Exploratory Bug Condition Checking
**Goal**: Surface counterexamples that demonstrate the bug BEFORE implementing the fix. Confirm or refute the root cause analysis. If we refute, we will need to re-hypothesize.
**Test Plan**: Write tests that call `requires_approval()` with various `PortfolioRiskConfig` instances and trading modes. Run these tests on the UNFIXED code to observe that paper mode always returns `False` regardless of intent.
**Test Cases**:
1. **Paper Mode Default Config**: Call `requires_approval(PortfolioRiskConfig())` with `PAPER` mode — will return `False` on unfixed code (demonstrates the bug)
2. **Paper Mode Empty JSON**: Call `requires_approval(PortfolioRiskConfig.from_db_json({}))` with `PAPER` mode — will return `False` on unfixed code (demonstrates the root cause)
3. **Missing Lockout POST**: Attempt `POST /api/admin/trading/lockouts` — will return 404/405 on unfixed code
4. **Missing Lockout DELETE**: Attempt `DELETE /api/admin/trading/lockouts/{id}` — will return 404/405 on unfixed code
**Expected Counterexamples**:
- `requires_approval()` returns `False` for paper mode even when the operator intends to require approval
- Possible causes: `auto_approve_paper` defaults to `True` and config JSON never overrides it
### Fix Checking
**Goal**: Verify that for all inputs where the bug condition holds, the fixed function produces the expected behavior.
**Pseudocode:**
```
FOR ALL input WHERE isBugCondition(input) DO
config := PortfolioRiskConfig with auto_approve_paper = False
result := requires_approval(config, PAPER)
ASSERT result == True
END FOR
```
### Preservation Checking
**Goal**: Verify that for all inputs where the bug condition does NOT hold, the fixed function produces the same result as the original function.
**Pseudocode:**
```
FOR ALL input WHERE NOT isBugCondition(input) DO
ASSERT requires_approval_original(input) == requires_approval_fixed(input)
END FOR
```
**Testing Approach**: Property-based testing is recommended for preservation checking because:
- It generates many test cases automatically across the input domain of (trading_mode, auto_approve_paper, require_approval_for_live) combinations
- It catches edge cases that manual unit tests might miss (e.g., unusual config combinations)
- It provides strong guarantees that behavior is unchanged for all non-buggy inputs
**Test Plan**: Observe behavior on UNFIXED code first for live mode and disabled mode, then write property-based tests capturing that behavior.
**Test Cases**:
1. **Live Mode Preservation**: Verify `requires_approval()` returns `True` for live mode with `require_approval_for_live=True` — must be identical before and after fix
2. **Disabled Mode Preservation**: Verify `requires_approval()` returns `False` for disabled mode — must be identical before and after fix
3. **Approval Review Preservation**: Verify `review_approval()` continues to update approval status correctly
4. **Expiry Preservation**: Verify `expire_stale_approvals()` continues to mark expired approvals
5. **Risk Rejection Preservation**: Verify orders failing risk checks are still rejected before the approval gate
### Unit Tests
- Test `requires_approval()` with all combinations of trading mode and approval settings
- Test new `POST /api/admin/trading/lockouts` endpoint creates valid lockout rows
- Test new `DELETE /api/admin/trading/lockouts/{id}` endpoint removes lockouts
- Test new `PUT /api/admin/trading/approval-config` endpoint updates config JSON correctly
- Test edge cases: expired lockouts not returned, invalid ticker, zero/negative duration
### Property-Based Tests
- Generate random `PortfolioRiskConfig` instances with varying `auto_approve_paper` and `require_approval_for_live` values across all three trading modes, and verify `requires_approval()` returns the correct boolean for each combination
- Generate random lockout parameters (ticker, duration, reason) and verify the POST endpoint creates valid entries that appear in GET results and respect expiry
- Test preservation: for all non-paper-mode configs, verify `requires_approval()` behavior is identical to the original implementation
### Integration Tests
- Test full order flow: set `auto_approve_paper=False` via API → submit order job → verify approval request created → approve → verify order submitted to broker
- Test lockout flow: create manual lockout via API → verify it appears in GET → delete it → verify it's gone
- Test UI rendering: approval toggle card renders with correct state, lockout form submits correctly, delete button removes lockout
@@ -0,0 +1,182 @@
# Implementation Plan
- [x] 1. Write bug condition exploration test
- **Property 1: Bug Condition** - Paper Mode Approval Bypass
- **CRITICAL**: This test MUST FAIL on unfixed code — failure confirms the bug exists
- **DO NOT attempt to fix the test or the code when it fails**
- **NOTE**: This test encodes the expected behavior — it will validate the fix when it passes after implementation
- **GOAL**: Surface counterexamples that demonstrate the bug exists
- **Scoped PBT Approach**: Scope the property to paper mode with `auto_approve_paper=False` — the bug is that even when `auto_approve_paper` is explicitly set to `False` in a `PortfolioRiskConfig`, the system works correctly at the function level, but the config JSON stored in `risk_configs` never contains `operator_approval` settings because no UI/endpoint sets them. The exploration test should:
1. Verify `requires_approval(config, PAPER)` returns `True` when `auto_approve_paper=False` (this actually passes — the function itself is correct)
2. Verify `PortfolioRiskConfig.from_db_json({})` produces `auto_approve_paper=True` (demonstrates the root cause — empty JSON always defaults to auto-approve)
3. Verify there is no dedicated endpoint to update `operator_approval` settings in the risk config (the existing `PUT /api/admin/trading/config` replaces the entire config JSON, and no UI calls it with `operator_approval` fields)
- Test file: `tests/test_pbt_operator_approval.py`
- Use Hypothesis with `@settings(max_examples=100)` per project conventions
- Generate random `PortfolioRiskConfig` instances with `auto_approve_paper=False` and `trading_mode=PAPER`
- Assert `requires_approval()` returns `True` for all such configs (function-level correctness)
- Also test that `PortfolioRiskConfig.from_db_json({})` always produces `auto_approve_paper=True` (demonstrates the config-level bug)
- Run test on UNFIXED code
- **EXPECTED OUTCOME**: The function-level property passes (function is correct), but the `from_db_json({})` test demonstrates the root cause — empty config always auto-approves
- Document counterexamples found to understand root cause
- Mark task complete when test is written, run, and results are documented
- _Requirements: 1.1, 1.2, 1.5, 2.1_
- [x] 2. Write preservation property tests (BEFORE implementing fix)
- **Property 2: Preservation** - Non-Paper-Toggle Approval Behavior
- **IMPORTANT**: Follow observation-first methodology
- Observe behavior on UNFIXED code for non-buggy inputs:
- Observe: `requires_approval(config, LIVE)` returns `True` when `require_approval_for_live=True`
- Observe: `requires_approval(config, LIVE)` returns `False` when `require_approval_for_live=False`
- Observe: `requires_approval(config, DISABLED)` returns `False` for all configs
- Observe: `requires_approval(config, PAPER)` returns `False` when `auto_approve_paper=True`
- Write property-based tests capturing observed behavior patterns:
- For all `(trading_mode, auto_approve_paper, require_approval_for_live)` combinations where `trading_mode != PAPER` OR `auto_approve_paper == True`, verify `requires_approval()` returns the same result as the original function
- Property: for all LIVE mode configs, result equals `require_approval_for_live`
- Property: for all DISABLED mode configs, result is always `False`
- Property: for all PAPER mode configs with `auto_approve_paper=True`, result is always `False`
- Test file: `tests/test_pbt_operator_approval.py` (same file, separate test class)
- Use Hypothesis with `@settings(max_examples=100)` per project conventions
- Verify tests PASS on UNFIXED code
- **EXPECTED OUTCOME**: Tests PASS (confirms baseline behavior to preserve)
- Mark task complete when tests are written, run, and passing on unfixed code
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6_
- [x] 3. Implement backend API endpoints
- [x] 3.1 Add `GET /api/admin/trading/approval-config` endpoint
- Read the active `risk_configs` row and extract `operator_approval` settings from the `config` JSONB column
- Parse with `PortfolioRiskConfig.from_db_json()` to get defaults when fields are missing
- Return `{ auto_approve_paper: bool, require_approval_for_live: bool, approval_timeout_minutes: int }`
- File: `services/api/app.py`
- _Bug_Condition: isBugCondition(input) where risk_config_json has no "operator_approval" key_
- _Expected_Behavior: Endpoint returns current operator_approval settings (with defaults filled in)_
- _Preservation: Existing GET /api/admin/trading/config endpoint unchanged_
- _Requirements: 1.5, 2.5_
- [x] 3.2 Add `PUT /api/admin/trading/approval-config` endpoint
- Read current `risk_configs.config` JSONB, merge `operator_approval` sub-object, write back
- Accept `{ auto_approve_paper: bool }` (and optionally `require_approval_for_live`, `approval_timeout_minutes`)
- Use JSON merge (not full replace) to preserve other config fields
- File: `services/api/app.py`
- _Bug_Condition: No UI/endpoint to set auto_approve_paper=False in risk_config_json_
- _Expected_Behavior: PUT updates operator_approval in config JSONB, requires_approval() reflects the change_
- _Preservation: Other config fields (position_limits, sector_exposure, etc.) unchanged by merge_
- _Requirements: 1.5, 2.1, 2.5_
- [x] 3.3 Add `POST /api/admin/trading/lockouts` endpoint
- Accept `{ ticker: string, reason: string, duration_minutes: int, lockout_type?: string }`
- Compute `expires_at = NOW() + duration_minutes`
- Default `lockout_type` to `"manual"`
- Insert into `symbol_lockouts` table
- Return the created lockout row
- File: `services/api/app.py`
- _Bug_Condition: No POST endpoint exists for symbol_lockouts_
- _Expected_Behavior: Manual lockout created and visible in GET /api/admin/trading/lockouts_
- _Preservation: System-generated lockouts (news-shock, cooldown) unaffected_
- _Requirements: 1.3, 1.4, 2.3, 2.4, 3.6_
- [x] 3.4 Add `DELETE /api/admin/trading/lockouts/{lockout_id}` endpoint
- Delete the lockout row by ID
- Return 404 if not found
- File: `services/api/app.py`
- _Bug_Condition: No DELETE endpoint exists for symbol_lockouts_
- _Expected_Behavior: Lockout removed early, no longer appears in GET results_
- _Preservation: Other lockouts unaffected_
- _Requirements: 1.4, 2.4_
- [x] 4. Implement frontend hooks
- [x] 4.1 Add `useApprovalConfig()` hook
- Fetches `GET /api/admin/trading/approval-config` via `useGet` with query key `['approval-config']`
- Type the response as `{ auto_approve_paper: boolean, require_approval_for_live: boolean, approval_timeout_minutes: number }`
- File: `frontend/src/api/hooks.ts`
- _Requirements: 2.5_
- [x] 4.2 Add `useUpdateApprovalConfig()` mutation hook
- Calls `PUT /api/admin/trading/approval-config` via `apiPut`
- Invalidates `['approval-config']` and `['trading-config']` query keys on success
- File: `frontend/src/api/hooks.ts`
- _Requirements: 2.1, 2.5_
- [x] 4.3 Add `useCreateLockout()` mutation hook
- Calls `POST /api/admin/trading/lockouts` via `apiPost`
- Accepts `{ ticker: string, reason: string, duration_minutes: number }`
- Invalidates `['lockouts']` query key on success
- File: `frontend/src/api/hooks.ts`
- _Requirements: 2.3_
- [x] 4.4 Add `useDeleteLockout()` mutation hook
- Calls `DELETE /api/admin/trading/lockouts/{id}` via `apiDelete`
- Invalidates `['lockouts']` query key on success
- File: `frontend/src/api/hooks.ts`
- _Requirements: 2.4_
- [x] 5. Implement frontend UI controls
- [x] 5.1 Add Approval Toggle Card to Trading page
- New `Card` between the existing signal layer toggles and Pending Approvals section
- Toggle switch for `auto_approve_paper` using `useApprovalConfig()` and `useUpdateApprovalConfig()`
- Confirmation dialog before toggling (since enabling approval requirements changes order flow)
- Show current state label ("Auto-approve paper orders" / "Require approval for paper orders")
- Use same toggle switch pattern as Macro Signal Layer and Competitive Signal Layer cards
- File: `frontend/src/pages/Trading.tsx`
- _Requirements: 1.5, 2.1, 2.5_
- [x] 5.2 Add Lockout Creation Form to Active Lockouts card
- Inline form at the top of the Active Lockouts card with fields:
- Ticker (text input, uppercase)
- Reason (text input)
- Duration in minutes (number input, default 60)
- Submit button calls `useCreateLockout()`
- Clear form on successful submission
- File: `frontend/src/pages/Trading.tsx`
- _Requirements: 1.3, 1.4, 2.3, 2.4_
- [x] 5.3 Add Lockout Delete Button to each lockout row
- "Remove" button on each lockout entry in the Active Lockouts list
- Calls `useDeleteLockout()` with the lockout ID
- File: `frontend/src/pages/Trading.tsx`
- _Requirements: 1.4, 2.4_
- [x] 5.4 Add MSW handlers for new endpoints
- Add mock handlers in `frontend/src/test/mocks/handlers.ts` for:
- `GET /api/admin/trading/approval-config`
- `PUT /api/admin/trading/approval-config`
- `POST /api/admin/trading/lockouts`
- `DELETE /api/admin/trading/lockouts/:id`
- File: `frontend/src/test/mocks/handlers.ts`
- _Requirements: 2.1, 2.3, 2.4, 2.5_
- [x] 6. Verify bug condition exploration test now passes
- [x] 6.1 Re-run bug condition exploration test
- **Property 1: Expected Behavior** - Paper Mode Approval Toggle
- **IMPORTANT**: Re-run the SAME test from task 1 — do NOT write a new test
- The test from task 1 encodes the expected behavior
- With the new `PUT /api/admin/trading/approval-config` endpoint in place, the operator can now set `auto_approve_paper=False` in the risk config JSON
- The `requires_approval()` function-level property should still pass
- The config-level test should now demonstrate that the endpoint correctly updates the config
- Run: `.venv/bin/python -m pytest tests/test_pbt_operator_approval.py -x --tb=short -q`
- **EXPECTED OUTCOME**: Test PASSES (confirms bug is fixed — operator can now control approval settings)
- _Requirements: 2.1, 2.2, 2.5_
- [x] 6.2 Verify preservation tests still pass
- **Property 2: Preservation** - Non-Paper-Toggle Approval Behavior
- **IMPORTANT**: Re-run the SAME tests from task 2 — do NOT write new tests
- Run: `.venv/bin/python -m pytest tests/test_pbt_operator_approval.py -x --tb=short -q`
- **EXPECTED OUTCOME**: Tests PASS (confirms no regressions)
- Confirm all preservation properties still hold after the fix
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6_
- [x] 7. Run frontend tests
- Run: `cd frontend && npx vitest --run`
- Verify Trading page renders correctly with new controls
- Verify no regressions in existing frontend tests
- _Requirements: 2.1, 2.3, 2.4, 2.5_
- [x] 8. Checkpoint — Ensure all tests pass
- Run full Python test suite: `.venv/bin/python -m pytest tests/test_pbt_operator_approval.py -x --tb=short -q`
- Run full frontend test suite: `cd frontend && npx vitest --run`
- Ensure all property-based tests pass (both bug condition and preservation)
- Ensure all frontend tests pass (including new MSW handlers)
- Ask the user if questions arise
+45 -1
View File
@@ -3,7 +3,7 @@
* Requirements: 13.1, 13.2 * Requirements: 13.1, 13.2
*/ */
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiGet, apiPost, apiPut } from './client'; import { apiGet, apiPost, apiPut, apiDelete } from './client';
import type { ApiBase } from './client'; import type { ApiBase } from './client';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -444,6 +444,50 @@ export function useActiveLockouts() {
return useGet<Lockout[]>(['lockouts'], 'query', '/api/admin/trading/lockouts'); return useGet<Lockout[]>(['lockouts'], 'query', '/api/admin/trading/lockouts');
} }
// ---------------------------------------------------------------------------
// Admin: Approval Config
// ---------------------------------------------------------------------------
export interface ApprovalConfig {
auto_approve_paper: boolean;
require_approval_for_live: boolean;
approval_timeout_minutes: number;
}
export function useApprovalConfig() {
return useGet<ApprovalConfig>(['approval-config'], 'query', '/api/admin/trading/approval-config');
}
export function useUpdateApprovalConfig() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: Partial<ApprovalConfig>) =>
apiPut<ApprovalConfig>('query', '/api/admin/trading/approval-config', body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['approval-config'] });
qc.invalidateQueries({ queryKey: ['trading-config'] });
},
});
}
export function useCreateLockout() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: { ticker: string; reason: string; duration_minutes: number }) =>
apiPost<Lockout>('query', '/api/admin/trading/lockouts', body),
onSuccess: () => qc.invalidateQueries({ queryKey: ['lockouts'] }),
});
}
export function useDeleteLockout() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
apiDelete<unknown>('query', `/api/admin/trading/lockouts/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['lockouts'] }),
});
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Admin: Sources // Admin: Sources
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+144 -3
View File
@@ -5,10 +5,14 @@ import {
usePendingApprovals, usePendingApprovals,
useReviewApproval, useReviewApproval,
useActiveLockouts, useActiveLockouts,
useCreateLockout,
useDeleteLockout,
useMacroStatus, useMacroStatus,
useToggleMacro, useToggleMacro,
useCompetitiveStatus, useCompetitiveStatus,
useToggleCompetitive, useToggleCompetitive,
useApprovalConfig,
useUpdateApprovalConfig,
} from '../api/hooks'; } from '../api/hooks';
import { useResetPaperTrading, useTradingStatus, useUpdateTradingConfig } from '../api/tradingHooks'; import { useResetPaperTrading, useTradingStatus, useUpdateTradingConfig } from '../api/tradingHooks';
import { StatusBadge, LoadingSpinner, Card } from '../components/ui'; import { StatusBadge, LoadingSpinner, Card } from '../components/ui';
@@ -26,13 +30,23 @@ export function TradingPage() {
const reviewApproval = useReviewApproval(); const reviewApproval = useReviewApproval();
const toggleMacro = useToggleMacro(); const toggleMacro = useToggleMacro();
const toggleCompetitive = useToggleCompetitive(); const toggleCompetitive = useToggleCompetitive();
const { data: approvalConfig } = useApprovalConfig();
const updateApprovalConfig = useUpdateApprovalConfig();
const createLockout = useCreateLockout();
const deleteLockout = useDeleteLockout();
const [lockoutTicker, setLockoutTicker] = useState('');
const [lockoutReason, setLockoutReason] = useState('');
const [lockoutDuration, setLockoutDuration] = useState(60);
// Normalize API field names (API returns macro_enabled/competitive_enabled, not enabled) // Normalize API field names (API returns macro_enabled/competitive_enabled, not enabled)
const macroEnabled = macroStatus?.enabled ?? macroStatus?.macro_enabled ?? false; const macroEnabled = macroStatus?.enabled ?? macroStatus?.macro_enabled ?? false;
const competitiveEnabled = competitiveStatus?.enabled ?? competitiveStatus?.competitive_enabled ?? false; const competitiveEnabled = competitiveStatus?.enabled ?? competitiveStatus?.competitive_enabled ?? false;
const autoApprovePaper = approvalConfig?.auto_approve_paper ?? true;
const [confirmMode, setConfirmMode] = useState<string | null>(null); const [confirmMode, setConfirmMode] = useState<string | null>(null);
const [confirmMacroToggle, setConfirmMacroToggle] = useState(false); const [confirmMacroToggle, setConfirmMacroToggle] = useState(false);
const [confirmCompetitiveToggle, setConfirmCompetitiveToggle] = useState(false); const [confirmCompetitiveToggle, setConfirmCompetitiveToggle] = useState(false);
const [confirmApprovalToggle, setConfirmApprovalToggle] = useState(false);
if (configLoading) return <LoadingSpinner />; if (configLoading) return <LoadingSpinner />;
@@ -252,6 +266,60 @@ export function TradingPage() {
)} )}
</Card> </Card>
{/* Paper Order Approval Toggle */}
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Paper Order Approval</h2>
<div className="flex items-center gap-4">
<button
onClick={() => setConfirmApprovalToggle(true)}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 focus:ring-offset-surface-900 ${
!autoApprovePaper ? 'bg-brand-600' : 'bg-surface-700'
}`}
role="switch"
aria-checked={!autoApprovePaper}
aria-label="Toggle paper order approval requirement"
>
<span
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition-transform ${
!autoApprovePaper ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
<span className="text-sm text-gray-300">
{autoApprovePaper ? 'Auto-approve paper orders' : 'Require approval for paper orders'}
</span>
</div>
{/* Confirmation dialog for approval toggle */}
{confirmApprovalToggle && (
<div className="mt-4 rounded-lg border border-orange-700/50 bg-orange-900/20 p-4">
<p className="text-sm text-orange-300">
Are you sure you want to {autoApprovePaper ? 'require approval for' : 'auto-approve'} paper orders?
{autoApprovePaper
? ' Enabling approval requirements will hold all paper orders for manual review before execution.'
: ' Disabling approval requirements will allow paper orders to execute automatically.'}
</p>
<div className="mt-3 flex gap-2">
<button
onClick={() => {
updateApprovalConfig.mutate({ auto_approve_paper: !autoApprovePaper });
setConfirmApprovalToggle(false);
}}
className="rounded-md bg-orange-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-orange-700"
>
Confirm
</button>
<button
onClick={() => setConfirmApprovalToggle(false)}
className="rounded-md border border-surface-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-surface-800"
>
Cancel
</button>
</div>
</div>
)}
</Card>
{/* Pending Approvals */} {/* Pending Approvals */}
<Card> <Card>
<h2 className="mb-3 text-sm font-medium text-gray-400"> <h2 className="mb-3 text-sm font-medium text-gray-400">
@@ -273,6 +341,70 @@ export function TradingPage() {
<h2 className="mb-3 text-sm font-medium text-gray-400"> <h2 className="mb-3 text-sm font-medium text-gray-400">
Active Lockouts ({lockouts?.length ?? 0}) Active Lockouts ({lockouts?.length ?? 0})
</h2> </h2>
{/* Lockout Creation Form */}
<form
className="mb-4 flex flex-wrap items-end gap-2 rounded border border-surface-700 bg-surface-900 p-3"
onSubmit={(e) => {
e.preventDefault();
if (!lockoutTicker.trim()) return;
createLockout.mutate(
{ ticker: lockoutTicker.trim().toUpperCase(), reason: lockoutReason.trim(), duration_minutes: lockoutDuration },
{
onSuccess: () => {
setLockoutTicker('');
setLockoutReason('');
setLockoutDuration(60);
},
},
);
}}
>
<div className="flex flex-col gap-1">
<label htmlFor="lockout-ticker" className="text-xs text-gray-500">Ticker</label>
<input
id="lockout-ticker"
type="text"
placeholder="AAPL"
value={lockoutTicker}
onChange={(e) => setLockoutTicker(e.target.value.toUpperCase())}
className="w-24 rounded-md border border-surface-700 bg-surface-950 px-2 py-1 text-xs font-mono text-gray-200 uppercase placeholder-gray-600"
required
/>
</div>
<div className="flex flex-1 flex-col gap-1">
<label htmlFor="lockout-reason" className="text-xs text-gray-500">Reason</label>
<input
id="lockout-reason"
type="text"
placeholder="Manual lockout reason…"
value={lockoutReason}
onChange={(e) => setLockoutReason(e.target.value)}
className="rounded-md border border-surface-700 bg-surface-950 px-2 py-1 text-xs text-gray-200 placeholder-gray-600"
required
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="lockout-duration" className="text-xs text-gray-500">Minutes</label>
<input
id="lockout-duration"
type="number"
min={1}
value={lockoutDuration}
onChange={(e) => setLockoutDuration(Number(e.target.value))}
className="w-20 rounded-md border border-surface-700 bg-surface-950 px-2 py-1 text-xs text-gray-200"
required
/>
</div>
<button
type="submit"
disabled={createLockout.isPending}
className="rounded-md bg-brand-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-brand-700 disabled:opacity-50"
>
{createLockout.isPending ? 'Creating…' : 'Add Lockout'}
</button>
</form>
{!lockouts?.length ? ( {!lockouts?.length ? (
<p className="text-sm text-gray-500">No active lockouts</p> <p className="text-sm text-gray-500">No active lockouts</p>
) : ( ) : (
@@ -286,9 +418,18 @@ export function TradingPage() {
<StatusBadge status={l.lockout_type} /> <StatusBadge status={l.lockout_type} />
<span className="text-sm text-gray-400">{l.reason}</span> <span className="text-sm text-gray-400">{l.reason}</span>
</div> </div>
<span className="text-xs text-gray-500"> <div className="flex items-center gap-2">
{expiresIn > 0 ? `${expiresIn}m remaining` : 'expired'} <span className="text-xs text-gray-500">
</span> {expiresIn > 0 ? `${expiresIn}m remaining` : 'expired'}
</span>
<button
onClick={() => deleteLockout.mutate(l.id)}
disabled={deleteLockout.isPending}
className="rounded bg-red-700/80 px-2 py-0.5 text-xs font-medium text-red-100 hover:bg-red-600 disabled:opacity-50"
>
Remove
</button>
</div>
</div> </div>
); );
})} })}
+10
View File
@@ -92,8 +92,18 @@ export const handlers = [
http.get('/api/orders/:id', () => HttpResponse.json({ ...mockOrders[0], idempotency_key: null, decision_trace: null, events: [], audit_trail: [] })), http.get('/api/orders/:id', () => HttpResponse.json({ ...mockOrders[0], idempotency_key: null, decision_trace: null, events: [], audit_trail: [] })),
http.get('/api/positions', () => HttpResponse.json(mockPositions)), http.get('/api/positions', () => HttpResponse.json(mockPositions)),
http.get('/api/admin/trading/config', () => HttpResponse.json({ trading_mode: 'paper', config: {} })), http.get('/api/admin/trading/config', () => HttpResponse.json({ trading_mode: 'paper', config: {} })),
http.get('/api/admin/trading/approval-config', () => HttpResponse.json({ auto_approve_paper: true, require_approval_for_live: true, approval_timeout_minutes: 5 })),
http.put('/api/admin/trading/approval-config', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
return HttpResponse.json({ auto_approve_paper: true, require_approval_for_live: true, approval_timeout_minutes: 5, ...body });
}),
http.get('/api/admin/trading/approvals', () => HttpResponse.json([])), http.get('/api/admin/trading/approvals', () => HttpResponse.json([])),
http.get('/api/admin/trading/lockouts', () => HttpResponse.json([])), http.get('/api/admin/trading/lockouts', () => HttpResponse.json([])),
http.post('/api/admin/trading/lockouts', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
return HttpResponse.json({ id: 'lockout-new', ticker: body.ticker, reason: body.reason, lockout_type: body.lockout_type ?? 'manual', expires_at: new Date(Date.now() + ((body.duration_minutes as number) ?? 60) * 60000).toISOString(), created_at: new Date().toISOString() }, { status: 201 });
}),
http.delete('/api/admin/trading/lockouts/:id', () => HttpResponse.json({ status: 'deleted' })),
http.get('/api/ops/pipeline/health', () => HttpResponse.json({ hours: 24, document_stages: [{ status: 'extracted', doc_count: 5 }], parsing: {}, extraction: {}, aggregation: {} })), http.get('/api/ops/pipeline/health', () => HttpResponse.json({ hours: 24, document_stages: [{ status: 'extracted', doc_count: 5 }], parsing: {}, extraction: {}, aggregation: {} })),
http.get('/api/ops/ingestion/summary', () => HttpResponse.json({ total_runs: 10, completed: 8, failed: 2, total_items_fetched: 50, total_items_new: 12, by_source_type: [] })), http.get('/api/ops/ingestion/summary', () => HttpResponse.json({ total_runs: 10, completed: 8, failed: 2, total_items_fetched: 50, total_items_new: 12, by_source_type: [] })),
http.get('/api/ops/ingestion/throughput', () => HttpResponse.json([])), http.get('/api/ops/ingestion/throughput', () => HttpResponse.json([])),
+143 -1
View File
@@ -19,7 +19,7 @@ import re
import time as _time import time as _time
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from dataclasses import asdict from dataclasses import asdict
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Optional from typing import Any, Optional
import asyncpg 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.config import load_config
from services.shared.db import get_pg_pool, get_redis 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.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 from services.shared.schemas import MAJOR_DECISION_CATALYSTS
logger = logging.getLogger("query_api") logger = logging.getLogger("query_api")
@@ -1409,6 +1410,147 @@ async def list_active_lockouts():
return [_row_to_dict(r) for r in rows] 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) # Operational Dashboard (Requirement 12.1, 12.2, 12.3)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+249
View File
@@ -0,0 +1,249 @@
"""Property-based tests for operator approval workflow.
Feature: operator-approval-workflow (bugfix)
Bug Condition Exploration:
The `requires_approval()` function is correct at the function level — when
`auto_approve_paper=False`, it returns `True` for PAPER mode. However, the
config JSON stored in `risk_configs` is always `{}`, so
`PortfolioRiskConfig.from_db_json({})` produces `auto_approve_paper=True`,
and no UI/endpoint ever sets it to `False`. The approval gate is therefore
never reached for paper orders.
Tests:
- Property 1 (Bug Condition): Paper Mode Approval Bypass
"""
from __future__ import annotations
from hypothesis import given, settings
from hypothesis import strategies as st
from services.risk.approval import requires_approval
from services.risk.engine import (
OperatorApproval,
PortfolioRiskConfig,
TradingMode,
)
# ---------------------------------------------------------------------------
# Strategies
# ---------------------------------------------------------------------------
def _risk_config_with_approval_disabled() -> st.SearchStrategy[PortfolioRiskConfig]:
"""Generate random PortfolioRiskConfig instances with auto_approve_paper=False."""
return st.builds(
PortfolioRiskConfig,
trading_mode=st.just(TradingMode.PAPER),
operator_approval=st.builds(
OperatorApproval,
auto_approve_paper=st.just(False),
require_approval_for_live=st.booleans(),
approval_timeout_minutes=st.integers(min_value=1, max_value=1440),
),
)
# ---------------------------------------------------------------------------
# Property 1: Bug Condition — Paper Mode Approval Bypass
# ---------------------------------------------------------------------------
class TestBugConditionExploration:
"""Exploration tests to surface the bug condition.
**Validates: Requirements 1.1, 1.2, 1.5, 2.1**
The bug is that paper orders always bypass approval because the config
JSON in the database never contains operator_approval settings, so
`auto_approve_paper` always defaults to `True`.
"""
@given(config=_risk_config_with_approval_disabled())
@settings(max_examples=100)
def test_requires_approval_returns_true_when_auto_approve_paper_is_false(
self,
config: PortfolioRiskConfig,
) -> None:
"""Function-level correctness: requires_approval() returns True
when auto_approve_paper is explicitly False and mode is PAPER.
This property is expected to PASS — the function itself is correct.
The bug is at the config/persistence layer, not the function.
"""
result = requires_approval(config, TradingMode.PAPER)
assert result is True, (
f"requires_approval() returned False for PAPER mode with "
f"auto_approve_paper=False. Config: {config.operator_approval}"
)
def test_from_db_json_empty_config_defaults_to_auto_approve(self) -> None:
"""Root cause demonstration: PortfolioRiskConfig.from_db_json({})
always produces auto_approve_paper=True.
This test is EXPECTED TO FAIL (assert False) because it demonstrates
the bug — empty config JSON always defaults to auto-approve, meaning
the approval gate is never reached for paper orders.
The test asserts that from_db_json({}) should produce
auto_approve_paper=False (the safe default), but it actually produces
True. This is the root cause of the bug.
"""
config = PortfolioRiskConfig.from_db_json({})
# The bug: empty JSON defaults auto_approve_paper to True.
# We assert the EXPECTED (correct) behavior: empty config should NOT
# auto-approve paper orders, so the approval gate is active by default.
assert config.operator_approval.auto_approve_paper is False, (
f"PortfolioRiskConfig.from_db_json({{}}) produced "
f"auto_approve_paper={config.operator_approval.auto_approve_paper}. "
f"Empty config JSON always defaults to auto-approve, which means "
f"the approval gate is never reached for paper orders. "
f"This is the root cause of bug 1.1."
)
def test_no_dedicated_approval_config_endpoint(self) -> None:
"""Verify there is no dedicated endpoint to update operator_approval
settings in the risk config.
This test is EXPECTED TO FAIL because the endpoint does not exist yet.
It imports the FastAPI app and checks that no route matches
PUT /api/admin/trading/approval-config.
"""
from services.api.app import app
approval_config_routes = [
route
for route in app.routes
if hasattr(route, "path")
and route.path == "/api/admin/trading/approval-config"
]
assert len(approval_config_routes) > 0, (
"No dedicated endpoint exists for PUT /api/admin/trading/approval-config. "
"The existing PUT /api/admin/trading/config replaces the entire config JSON, "
"and no UI calls it with operator_approval fields. "
"This means there is no way for the operator to toggle auto_approve_paper."
)
# ---------------------------------------------------------------------------
# Strategies for Preservation Properties
# ---------------------------------------------------------------------------
def _live_mode_config() -> st.SearchStrategy[PortfolioRiskConfig]:
"""Generate random PortfolioRiskConfig instances in LIVE mode."""
return st.builds(
PortfolioRiskConfig,
trading_mode=st.just(TradingMode.LIVE),
operator_approval=st.builds(
OperatorApproval,
auto_approve_paper=st.booleans(),
require_approval_for_live=st.booleans(),
approval_timeout_minutes=st.integers(min_value=1, max_value=1440),
),
)
def _disabled_mode_config() -> st.SearchStrategy[PortfolioRiskConfig]:
"""Generate random PortfolioRiskConfig instances in DISABLED mode."""
return st.builds(
PortfolioRiskConfig,
trading_mode=st.just(TradingMode.DISABLED),
operator_approval=st.builds(
OperatorApproval,
auto_approve_paper=st.booleans(),
require_approval_for_live=st.booleans(),
approval_timeout_minutes=st.integers(min_value=1, max_value=1440),
),
)
def _paper_mode_auto_approve_config() -> st.SearchStrategy[PortfolioRiskConfig]:
"""Generate random PortfolioRiskConfig instances in PAPER mode with auto_approve_paper=True."""
return st.builds(
PortfolioRiskConfig,
trading_mode=st.just(TradingMode.PAPER),
operator_approval=st.builds(
OperatorApproval,
auto_approve_paper=st.just(True),
require_approval_for_live=st.booleans(),
approval_timeout_minutes=st.integers(min_value=1, max_value=1440),
),
)
# ---------------------------------------------------------------------------
# Property 2: Preservation — Non-Paper-Toggle Approval Behavior
# ---------------------------------------------------------------------------
class TestPreservationProperties:
"""Preservation tests for non-buggy approval behavior.
**Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5, 3.6**
These tests capture the existing correct behavior of requires_approval()
for inputs that are NOT affected by the bug (i.e., non-paper-toggle
scenarios). They must PASS on unfixed code and continue to PASS after
the fix is applied, ensuring no regressions.
"""
@given(config=_live_mode_config())
@settings(max_examples=100)
def test_live_mode_result_equals_require_approval_for_live(
self,
config: PortfolioRiskConfig,
) -> None:
"""For all LIVE mode configs, requires_approval() returns the value
of require_approval_for_live.
Observed behavior on unfixed code:
- requires_approval(config, LIVE) returns True when require_approval_for_live=True
- requires_approval(config, LIVE) returns False when require_approval_for_live=False
"""
result = requires_approval(config, TradingMode.LIVE)
expected = config.operator_approval.require_approval_for_live
assert result is expected, (
f"LIVE mode: requires_approval() returned {result}, "
f"expected {expected} (require_approval_for_live={expected}). "
f"Config: {config.operator_approval}"
)
@given(config=_disabled_mode_config())
@settings(max_examples=100)
def test_disabled_mode_always_returns_false(
self,
config: PortfolioRiskConfig,
) -> None:
"""For all DISABLED mode configs, requires_approval() always returns False.
Observed behavior on unfixed code:
- requires_approval(config, DISABLED) returns False for all configs
Orders are blocked upstream by the risk engine in disabled mode,
so the approval gate is irrelevant.
"""
result = requires_approval(config, TradingMode.DISABLED)
assert result is False, (
f"DISABLED mode: requires_approval() returned {result}, "
f"expected False. Config: {config.operator_approval}"
)
@given(config=_paper_mode_auto_approve_config())
@settings(max_examples=100)
def test_paper_mode_auto_approve_true_returns_false(
self,
config: PortfolioRiskConfig,
) -> None:
"""For all PAPER mode configs with auto_approve_paper=True,
requires_approval() always returns False.
Observed behavior on unfixed code:
- requires_approval(config, PAPER) returns False when auto_approve_paper=True
This is the default (non-buggy) paper mode behavior — paper orders
are auto-approved when the setting is True.
"""
result = requires_approval(config, TradingMode.PAPER)
assert result is False, (
f"PAPER mode with auto_approve_paper=True: requires_approval() "
f"returned {result}, expected False. Config: {config.operator_approval}"
)