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:
@@ -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 },
|
||||
);
|
||||
}),
|
||||
```
|
||||
Reference in New Issue
Block a user