913fe8b0b3
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
382 lines
18 KiB
Markdown
382 lines
18 KiB
Markdown
# 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 },
|
|
);
|
|
}),
|
|
```
|