feat: override trade tab — manual order entry with auto-registration

Backend:
- OverrideOrderRequest/Response Pydantic models with ticker, quantity, price validators
- POST /api/trading/override/order endpoint (enqueue to Redis broker queue)
- auto_register_symbol() module for untracked ticker registration via Symbol Registry
- Unit tests (17) and property-based tests (3 x 100 examples)

Frontend:
- OverrideTradePanel component (order form + positions display)
- Override tab in TradingEngine page with URL search param navigation
- Override Trade button on Trading Controls page
- useSubmitOverrideOrder mutation hook
- MSW handler and 13 component/integration tests

Steering:
- Updated steering docs for Ubuntu dev machine with nvm/Node 24
This commit is contained in:
Celes Renata
2026-04-17 07:02:30 +00:00
parent 7f67725ec8
commit 913fe8b0b3
18 changed files with 3074 additions and 17 deletions
+381
View File
@@ -0,0 +1,381 @@
# Design Document: Override Trade Tab
## Overview
The Override Trade Tab adds a manual order entry interface to the Stonks Oracle trading engine, enabling operators to submit buy/sell orders for any ticker — including symbols not yet tracked by the platform. The feature spans three layers:
1. **Frontend** — A new "Override" tab in the `TradingEngine` page with an order form and positions display, plus a navigation shortcut on the Trading Controls page.
2. **Backend API** — A `POST /api/trading/override/order` endpoint in the trading engine service that validates the order, auto-registers unknown symbols via the Symbol Registry, and enqueues the job to the Redis broker queue.
3. **Broker Pipeline** — The existing `broker_service` processes override orders identically to autonomous ones (risk evaluation → Alpaca submission → persistence), distinguished only by a `source: "manual_override"` marker in the job payload.
No new database tables are required. Override orders flow through the existing `orders`, `order_events`, and `risk_evaluations` tables. The `decision_trace` JSONB column stores the override source marker for audit purposes.
```mermaid
sequenceDiagram
participant Op as Operator
participant FE as Frontend (Override Tab)
participant TE as Trading Engine API
participant SR as Symbol Registry
participant RQ as Redis Broker Queue
participant BS as Broker Service
participant AL as Alpaca
Op->>FE: Fill order form & submit
FE->>TE: POST /api/trading/override/order
TE->>TE: Validate request
alt Ticker not in companies table
TE->>SR: POST /companies (create company)
SR-->>TE: 201 Created / 409 Conflict (OK)
TE->>SR: POST /companies/{id}/sources (market_api)
TE->>SR: POST /companies/{id}/sources (news_api)
TE->>SR: GET /watchlists
alt Active watchlist exists
TE->>SR: POST /watchlists/{id}/members/{company_id}
else No active watchlist
TE->>SR: POST /watchlists (create "Manual Overrides")
TE->>SR: POST /watchlists/{id}/members/{company_id}
end
end
TE->>RQ: RPUSH job to stonks:queue:broker
TE-->>FE: 202 Accepted {job_id, status: "queued"}
FE-->>Op: Success notification
RQ->>BS: LPOP job
BS->>BS: Risk evaluation
BS->>AL: Submit order
AL-->>BS: Fill/reject
BS->>BS: Persist to orders table
```
## Architecture
### Component Boundaries
The feature touches three existing services and the frontend:
| Layer | Component | Changes |
|-------|-----------|---------|
| Frontend | `TradingEngine.tsx` | Add "Override" tab to TABS array, render `OverrideTradePanel` |
| Frontend | `trading/OverrideTradePanel.tsx` | New component: order form + positions display |
| Frontend | `Trading.tsx` | Add "Override Trade" navigation button |
| Frontend | `api/tradingHooks.ts` | Add `useSubmitOverrideOrder` mutation hook |
| Frontend | `test/mocks/handlers.ts` | Add MSW handler for override endpoint |
| Backend | `services/trading/app.py` | Add `POST /api/trading/override/order` endpoint |
| Backend | `services/trading/override.py` | New module: validation, auto-registration, queue enqueue logic |
### Tab Selection via Query Parameter
The current `TradingEngine.tsx` uses `useState` for tab selection. To support direct navigation (Requirement 1.3), the tab state will be driven by a `tab` search parameter using TanStack Router's `useSearch` / `useNavigate`. The URL pattern becomes `/trading/engine?tab=override`.
### Auto-Registration Flow
The auto-registration logic lives in a new `services/trading/override.py` module. It calls the Symbol Registry service via HTTP (internal cluster URL) rather than directly accessing the database, maintaining service boundaries. The flow:
1. Query the Symbol Registry `GET /companies?ticker={ticker}` to check existence.
2. If not found, `POST /companies` to create the company record.
3. If 409 conflict (race condition), treat as success and fetch the existing company.
4. Create two default sources: `market_api` and `news_api`.
5. Fetch active watchlists; add the company to the first one, or create a "Manual Overrides" watchlist if none exist.
### Redis Queue Integration
The override endpoint enqueues jobs to `stonks:queue:broker` (the same queue used by the autonomous trading engine). The job payload matches the existing schema expected by `broker_service.py`:
```json
{
"ticker": "AAPL",
"side": "buy",
"quantity": 10,
"order_type": "market",
"limit_price": null,
"stop_price": null,
"source": "manual_override",
"idempotency_key": "override-<uuid>"
}
```
The `source` field is a new addition that the broker service will pass through to `decision_trace`. The broker service does not need modification — it already persists the full job payload in the `decision_trace` JSONB column.
## Components and Interfaces
### Backend: Override Order Endpoint
**File:** `services/trading/app.py`
```python
# New Pydantic model
class OverrideOrderRequest(BaseModel):
ticker: str # 1-10 uppercase alpha
side: str # "buy" | "sell"
quantity: float # > 0
order_type: str = "market" # "market" | "limit" | "stop" | "stop_limit"
limit_price: Optional[float] = None
stop_price: Optional[float] = None
class OverrideOrderResponse(BaseModel):
job_id: str
status: str # "queued"
ticker: str
side: str
quantity: float
auto_registered: bool # True if symbol was newly registered
```
**Endpoint:** `POST /api/trading/override/order`
- Validates the request body (ticker format, positive quantity, conditional price fields).
- Calls `auto_register_symbol()` if the ticker is untracked.
- Enqueues the job to Redis `stonks:queue:broker`.
- Returns 202 with `OverrideOrderResponse`.
- Returns 422 for validation errors, 503 if Redis is unreachable.
### Backend: Auto-Registration Module
**File:** `services/trading/override.py`
```python
async def auto_register_symbol(
ticker: str,
registry_base_url: str,
) -> tuple[bool, str]:
"""Register an untracked symbol in the Symbol Registry.
Returns (auto_registered: bool, company_id: str).
Calls Symbol Registry HTTP endpoints to create company,
default sources, and watchlist membership.
"""
```
Key design decisions:
- Uses `httpx.AsyncClient` for internal HTTP calls to the Symbol Registry.
- The registry base URL is derived from `services/shared/config.py` (defaults to `http://symbol-registry:8000` in-cluster).
- Handles 409 conflicts gracefully — fetches the existing company and proceeds.
- Source creation failures are logged but do not block order enqueuing (best-effort).
- Watchlist membership failures are logged but do not block order enqueuing (best-effort).
### Frontend: OverrideTradePanel Component
**File:** `frontend/src/pages/trading/OverrideTradePanel.tsx`
The panel has two sections:
1. **Order Form** — Controlled form with fields for ticker, side, quantity, order type, and conditional limit/stop price inputs. Client-side validation mirrors the backend rules. Submits via `useSubmitOverrideOrder` mutation.
2. **Positions Display** — Uses the existing `usePositions()` hook to show current holdings in a table. Read-only context for the operator.
### Frontend: API Hook
**File:** `frontend/src/api/tradingHooks.ts`
```typescript
export interface OverrideOrderRequest {
ticker: string;
side: 'buy' | 'sell';
quantity: number;
order_type: 'market' | 'limit' | 'stop' | 'stop_limit';
limit_price?: number;
stop_price?: number;
}
export interface OverrideOrderResponse {
job_id: string;
status: string;
ticker: string;
side: string;
quantity: number;
auto_registered: boolean;
}
export function useSubmitOverrideOrder() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: OverrideOrderRequest) =>
apiPost<OverrideOrderResponse>('trading', '/api/trading/override/order', body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['orders'] });
qc.invalidateQueries({ queryKey: ['positions'] });
qc.invalidateQueries({ queryKey: ['companies'] });
},
});
}
```
### Frontend: Tab Navigation Update
**File:** `frontend/src/pages/TradingEngine.tsx`
The TABS array gains an `override` entry. Tab state moves from `useState` to URL search params:
```typescript
const TABS = [
{ id: 'overview', label: 'Overview' },
{ id: 'portfolio', label: 'Portfolio' },
{ id: 'history', label: 'Trade History' },
{ id: 'performance', label: 'Performance' },
{ id: 'backtest', label: 'Backtest' },
{ id: 'micro', label: 'Micro-Trading' },
{ id: 'notifications', label: 'Notifications' },
{ id: 'override', label: 'Override' },
] as const;
```
The active tab is read from `useSearch({ from: '/trading/engine' })` and defaults to `'overview'` when no `tab` param is present.
### Frontend: Trading Controls Navigation Button
**File:** `frontend/src/pages/Trading.tsx`
A `Link` component inside the Trading Mode card navigates to `/trading/engine?tab=override`:
```tsx
<Link
to="/trading/engine"
search={{ tab: 'override' }}
className="rounded-md bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700"
>
Override Trade
</Link>
```
## Data Models
### Override Order Job (Redis Queue Payload)
The job enqueued to `stonks:queue:broker` follows the existing broker job schema with one addition:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `ticker` | string | yes | Uppercase ticker symbol |
| `side` | string | yes | `"buy"` or `"sell"` |
| `quantity` | number | yes | Positive share count |
| `order_type` | string | yes | `"market"`, `"limit"`, `"stop"`, `"stop_limit"` |
| `limit_price` | number | no | Required for limit/stop_limit |
| `stop_price` | number | no | Required for stop/stop_limit |
| `source` | string | yes | `"manual_override"` for override orders |
| `idempotency_key` | string | yes | `"override-{uuid}"` format |
### Override Order Request (API)
Pydantic model `OverrideOrderRequest` with validators:
- `ticker`: 1-10 uppercase alpha characters (regex `^[A-Z]{1,10}$`), auto-uppercased.
- `side`: literal `"buy"` or `"sell"`.
- `quantity`: `float > 0`.
- `order_type`: literal `"market"`, `"limit"`, `"stop"`, `"stop_limit"`.
- `limit_price`: required when `order_type` is `"limit"` or `"stop_limit"`, must be `> 0`.
- `stop_price`: required when `order_type` is `"stop"` or `"stop_limit"`, must be `> 0`.
### Existing Models (No Changes)
- **`orders` table** — Override orders are stored with the same schema. The `decision_trace` JSONB column captures `{"source": "manual_override", ...}`.
- **`companies` table** — Auto-registered companies use the existing schema with `legal_name` set to the ticker as placeholder.
- **`sources` table** — Default sources use existing `market_api` and `news_api` types.
## Correctness Properties
*A property is a characteristic or behavior that should hold true across all valid executions of a system — essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
### Property 1: Ticker validation and normalization
*For any* string input, the ticker validation function SHALL accept it if and only if, after uppercasing, it matches the pattern `^[A-Z]{1,10}$`. The normalized output SHALL always be the uppercased version of the input. All other strings (empty, longer than 10 characters, containing digits, spaces, or special characters) SHALL be rejected.
**Validates: Requirements 2.2, 8.1**
### Property 2: Override job payload completeness
*For any* valid override order request (valid ticker, valid side, positive quantity, valid order type with appropriate price fields), the job payload enqueued to the broker queue SHALL contain all of: `ticker` (matching the request), `side` (matching the request), `quantity` (matching the request), `order_type` (matching the request), `source` equal to `"manual_override"`, and a non-empty `idempotency_key` starting with `"override-"`. If `limit_price` or `stop_price` were provided, they SHALL appear in the payload.
**Validates: Requirements 3.2, 9.1**
### Property 3: Invalid override order rejection
*For any* override order request that violates at least one validation rule (invalid ticker format, side not in {"buy", "sell"}, non-positive quantity, missing limit_price when order_type is "limit" or "stop_limit", missing stop_price when order_type is "stop" or "stop_limit"), the endpoint SHALL return a 422 status code and the response body SHALL contain at least one descriptive error message.
**Validates: Requirements 3.5, 2.6**
## Error Handling
### Backend Errors
| Scenario | Response | Behavior |
|----------|----------|----------|
| Invalid request body (bad ticker, missing fields, non-positive qty) | 422 Unprocessable Entity | Return Pydantic validation errors as JSON |
| Redis broker queue unreachable | 503 Service Unavailable | Return `{"detail": "Broker queue unavailable"}` |
| Symbol Registry unreachable during auto-registration | 202 Accepted (best-effort) | Log warning, skip auto-registration, still enqueue the order. The broker will process it; the symbol just won't be tracked yet. |
| Symbol Registry 409 conflict on company creation | Continue normally | Treat as success — another request registered the symbol concurrently |
| Symbol Registry source/watchlist creation failure | Continue normally | Log warning, proceed with order enqueue. Sources and watchlist membership are best-effort. |
### Frontend Errors
| Scenario | UI Behavior |
|----------|-------------|
| 202 response | Green success toast with job ID and "queued" status. Reset form. |
| 422 response | Display validation errors inline on the form fields. Do not reset form. |
| 503 response | Red error banner: "Broker service is unavailable. Please try again later." |
| Network error (fetch failure) | Red error banner: "Unable to connect. Check your network and try again." |
| Positions API loading | Show `LoadingSpinner` in positions section |
| Positions API empty | Show "No current positions" message |
| Positions API error | Show error message in positions section, order form remains functional |
### Idempotency
Override orders use a `"override-{uuid}"` idempotency key format. Since each submission generates a fresh UUID, duplicate prevention is handled at the broker service level (Redis + DB checks). The submit button is disabled during flight to prevent accidental double-clicks.
## Testing Strategy
### Unit Tests (Python — pytest)
| Test | Description | Validates |
|------|-------------|-----------|
| `test_override_order_validation_valid` | Valid order requests pass validation | Req 3.1 |
| `test_override_order_validation_invalid_ticker` | Invalid ticker formats are rejected | Req 3.5, 8.1 |
| `test_override_order_validation_missing_limit_price` | Limit orders without limit_price are rejected | Req 3.5 |
| `test_override_order_validation_missing_stop_price` | Stop orders without stop_price are rejected | Req 3.5 |
| `test_auto_register_new_symbol` | Auto-registration creates company, sources, watchlist membership | Req 4.1, 4.2, 4.3 |
| `test_auto_register_existing_symbol` | Existing symbols skip registration | Req 4.5 |
| `test_auto_register_409_conflict` | 409 conflict treated as success | Req 4.6 |
| `test_override_enqueue_job_structure` | Enqueued job has correct fields and source marker | Req 3.2, 9.1 |
| `test_override_endpoint_202` | Valid order returns 202 with job_id | Req 3.4 |
| `test_override_endpoint_422` | Invalid order returns 422 | Req 3.5 |
| `test_override_endpoint_503_redis_down` | Redis failure returns 503 | Req 3.6 |
### Property-Based Tests (Python — Hypothesis)
Property-based tests use the Hypothesis library with `@settings(max_examples=100)`.
| Test | Property | Tag |
|------|----------|-----|
| `test_pbt_ticker_validation` | Property 1: Ticker validation and normalization | Feature: override-trade-tab, Property 1: Ticker validation and normalization |
| `test_pbt_override_job_payload` | Property 2: Override job payload completeness | Feature: override-trade-tab, Property 2: Override job payload completeness |
| `test_pbt_invalid_order_rejection` | Property 3: Invalid override order rejection | Feature: override-trade-tab, Property 3: Invalid override order rejection |
### Frontend Tests (Vitest + MSW)
| Test | Description | Validates |
|------|-------------|-----------|
| `test override tab renders in tab bar` | Override tab button exists | Req 1.1 |
| `test override tab shows form and positions` | Clicking Override tab renders both sections | Req 1.2 |
| `test override tab accessible via URL param` | `/trading/engine?tab=override` opens Override tab | Req 1.3 |
| `test order form fields present` | All form inputs render | Req 2.1 |
| `test conditional price fields` | Limit/stop price fields show/hide based on order type | Req 2.3, 2.4 |
| `test form validation errors` | Invalid inputs show inline errors | Req 2.6 |
| `test successful order submission` | 202 response shows success toast, resets form | Req 7.1, 7.5 |
| `test 422 error display` | Validation errors from backend are shown | Req 7.2 |
| `test 503 error display` | Service unavailable message shown | Req 7.3 |
| `test submit button loading state` | Button disabled during submission | Req 7.6 |
| `test positions table renders` | Positions display shows data | Req 6.1, 6.2 |
| `test positions loading state` | Loading spinner shown while fetching | Req 6.3 |
| `test positions empty state` | Empty message when no positions | Req 6.4 |
| `test override trade button on Trading page` | Button exists and links correctly | Req 5.1, 5.2 |
### MSW Handler
Add to `frontend/src/test/mocks/handlers.ts`:
```typescript
http.post('/trading/api/trading/override/order', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
return HttpResponse.json(
{ job_id: 'job-test-123', status: 'queued', ticker: body.ticker, side: body.side, quantity: body.quantity, auto_registered: false },
{ status: 202 },
);
}),
```