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 @@
|
||||
{"specId": "6864b7d1-ab86-473f-b6ad-7091eaabac76", "workflowType": "requirements-first", "specType": "feature"}
|
||||
@@ -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 },
|
||||
);
|
||||
}),
|
||||
```
|
||||
@@ -0,0 +1,122 @@
|
||||
# Requirements Document
|
||||
|
||||
## Introduction
|
||||
|
||||
The Override Trade Tab adds a manual trading interface to the Stonks Oracle trading engine, allowing operators to buy or sell any ticker symbol — including symbols not currently tracked by the platform. When a previously untracked symbol is traded, the system automatically registers it in the companies table, creates default data sources, and adds it to the active watchlist so the full intelligence pipeline can begin tracking it. A navigation button on the Trading Controls page provides quick access to this new tab.
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Trading_Engine**: The FastAPI backend service at `services/trading/app.py` that manages autonomous and manual trading operations, exposed via the `/trading/` proxy.
|
||||
- **Override_Tab**: A new tab within the Trading Engine frontend page (`TradingEnginePage`) that provides a manual order entry form.
|
||||
- **Symbol_Registry**: The FastAPI service at `services/symbol_registry/app.py` that manages the companies table, aliases, watchlists, and data sources.
|
||||
- **Broker_Service**: The order execution worker at `services/adapters/broker_service.py` that processes order jobs from the Redis broker queue, runs risk evaluation, and submits to Alpaca.
|
||||
- **Broker_Queue**: The Redis list (`stonks:queue:broker`) where order jobs are enqueued for the Broker_Service to process.
|
||||
- **Order_Form**: The UI component within the Override_Tab that collects ticker, side, quantity, order type, and optional limit/stop prices from the operator.
|
||||
- **Auto_Registration**: The backend process that creates a new company record, default data sources, and watchlist membership when a trade is submitted for an untracked ticker.
|
||||
- **Positions_Display**: A read-only view of current broker positions shown within the Override_Tab for context.
|
||||
- **Trading_Controls_Page**: The existing frontend page at `/trading` (`Trading.tsx`) that manages trading mode, risk tier, approvals, and lockouts.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement 1: Override Tab in Trading Engine
|
||||
|
||||
**User Story:** As an operator, I want a dedicated "Override" tab in the trading engine, so that I can manually submit buy and sell orders outside the autonomous trading loop.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Trading_Engine frontend SHALL display an "Override" tab in the Trading Engine page tab bar alongside existing tabs (Overview, Portfolio, Trade History, Performance, Backtest, Micro-Trading, Notifications).
|
||||
2. WHEN the operator selects the Override tab, THE Override_Tab SHALL render the Order_Form and the Positions_Display.
|
||||
3. THE Override_Tab SHALL be accessible via a URL query parameter or hash fragment so that external navigation links can open it directly.
|
||||
|
||||
### Requirement 2: Manual Order Entry Form
|
||||
|
||||
**User Story:** As an operator, I want to enter buy or sell orders for any ticker symbol with configurable order parameters, so that I can execute trades that the autonomous engine would not initiate on its own.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Order_Form SHALL accept the following inputs: ticker symbol (text, required), side (buy or sell, required), quantity (positive number, required), order type (market, limit, stop, or stop_limit), limit price (required when order type is limit or stop_limit), and stop price (required when order type is stop or stop_limit).
|
||||
2. WHEN the operator enters a ticker symbol, THE Order_Form SHALL normalize the ticker to uppercase and validate that it contains only 1 to 10 alphabetic characters.
|
||||
3. WHEN the operator selects order type "limit" or "stop_limit", THE Order_Form SHALL display the limit price input field.
|
||||
4. WHEN the operator selects order type "stop" or "stop_limit", THE Order_Form SHALL display the stop price input field.
|
||||
5. WHEN the operator submits the Order_Form with valid inputs, THE Override_Tab SHALL send a POST request to the Trading_Engine backend override order endpoint.
|
||||
6. IF the operator submits the Order_Form with missing or invalid inputs, THEN THE Order_Form SHALL display inline validation errors and prevent submission.
|
||||
|
||||
### Requirement 3: Backend Override Order Endpoint
|
||||
|
||||
**User Story:** As the frontend, I want a backend API endpoint to accept manual override orders, so that the order can be enqueued for execution through the existing broker pipeline.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Trading_Engine SHALL expose a POST endpoint at `/api/trading/override/order` that accepts a JSON body with fields: ticker (string), side (string: "buy" or "sell"), quantity (number), order_type (string: "market", "limit", "stop", or "stop_limit"), limit_price (optional number), and stop_price (optional number).
|
||||
2. WHEN the endpoint receives a valid order request, THE Trading_Engine SHALL enqueue a job on the Broker_Queue with the order parameters, a generated idempotency key, and the source marked as "manual_override".
|
||||
3. WHEN the endpoint receives a valid order request for a ticker that does not exist in the companies table, THE Trading_Engine SHALL invoke the Auto_Registration process before enqueuing the order.
|
||||
4. WHEN the endpoint successfully enqueues the order, THE Trading_Engine SHALL return a 202 response with the generated order job ID and a status of "queued".
|
||||
5. IF the endpoint receives an invalid request (missing required fields, invalid ticker format, non-positive quantity, missing limit_price for limit orders), THEN THE Trading_Engine SHALL return a 422 response with descriptive validation errors.
|
||||
6. IF the Broker_Queue is unreachable, THEN THE Trading_Engine SHALL return a 503 response with an error message indicating the broker queue is unavailable.
|
||||
|
||||
### Requirement 4: Auto-Registration of Untracked Symbols
|
||||
|
||||
**User Story:** As an operator, I want the system to automatically register a new symbol when I trade it for the first time, so that the platform begins tracking, assessing, and monitoring the new symbol without manual setup.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the Trading_Engine receives an override order for a ticker not present in the companies table, THE Auto_Registration process SHALL create a new company record in the companies table with the ticker, a legal name set to the ticker as a placeholder, and active status set to true.
|
||||
2. WHEN the Auto_Registration process creates a new company, THE Auto_Registration process SHALL create default data sources for the company: a "market_api" source for price data and a "news_api" source for news coverage.
|
||||
3. WHEN the Auto_Registration process creates a new company, THE Auto_Registration process SHALL add the company to the first active watchlist (or create a "Manual Overrides" watchlist if none exists).
|
||||
4. THE Auto_Registration process SHALL use the Symbol_Registry service endpoints to create the company, sources, and watchlist membership.
|
||||
5. IF the ticker already exists in the companies table, THEN THE Auto_Registration process SHALL skip registration and proceed with order enqueuing.
|
||||
6. IF the Symbol_Registry returns a 409 conflict (company already exists due to a race condition), THEN THE Auto_Registration process SHALL treat the conflict as a successful registration and proceed with order enqueuing.
|
||||
|
||||
### Requirement 5: Navigation from Trading Controls Page
|
||||
|
||||
**User Story:** As an operator, I want a button on the Trading Controls page that navigates to the Override tab in the trading engine, so that I can quickly access manual trading from the controls dashboard.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Trading_Controls_Page SHALL display a navigation button labeled "Override Trade" within the Trading Mode card section.
|
||||
2. WHEN the operator clicks the "Override Trade" button, THE Trading_Controls_Page SHALL navigate to the Trading Engine page with the Override tab selected.
|
||||
3. THE navigation button SHALL use the TanStack Router Link component for client-side navigation.
|
||||
|
||||
### Requirement 6: Current Positions Display
|
||||
|
||||
**User Story:** As an operator, I want to see my current positions when placing an override trade, so that I have context about existing holdings before buying or selling.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. THE Override_Tab SHALL display a table of current positions fetched from the existing positions API endpoint (`/api/positions`).
|
||||
2. THE Positions_Display SHALL show for each position: ticker, quantity, average entry price, current price, and unrealized P&L.
|
||||
3. WHEN the positions data is loading, THE Positions_Display SHALL show a loading indicator.
|
||||
4. IF no positions exist, THEN THE Positions_Display SHALL show a message indicating no current positions.
|
||||
|
||||
### Requirement 7: Order Submission Feedback
|
||||
|
||||
**User Story:** As an operator, I want clear feedback after submitting an override order, so that I know whether the order was accepted, queued, or rejected.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the Trading_Engine backend returns a 202 response, THE Override_Tab SHALL display a success notification with the order job ID and "queued" status.
|
||||
2. WHEN the Trading_Engine backend returns a 422 response, THE Override_Tab SHALL display the validation error messages from the response body.
|
||||
3. WHEN the Trading_Engine backend returns a 503 response, THE Override_Tab SHALL display an error message indicating the broker service is unavailable.
|
||||
4. IF a network error occurs during order submission, THEN THE Override_Tab SHALL display a generic connectivity error message.
|
||||
5. WHEN an order is successfully submitted, THE Override_Tab SHALL reset the Order_Form fields to their default values.
|
||||
6. THE Override_Tab SHALL disable the submit button and show a loading state while the order request is in flight to prevent duplicate submissions.
|
||||
|
||||
### Requirement 8: Error Handling for Invalid Tickers
|
||||
|
||||
**User Story:** As an operator, I want the system to handle invalid or non-existent tickers gracefully, so that I receive clear feedback when a trade cannot be processed.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the operator enters a ticker that does not match the pattern of 1 to 10 uppercase alphabetic characters, THE Order_Form SHALL display a validation error before submission.
|
||||
2. IF the Broker_Service rejects the order because the broker (Alpaca) does not recognize the ticker, THEN THE Broker_Service SHALL persist the order with a "rejected" status and the rejection reason.
|
||||
3. THE operator SHALL be able to view rejected override orders in the existing Trade History tab with the rejection reason visible.
|
||||
|
||||
### Requirement 9: Override Order Audit Trail
|
||||
|
||||
**User Story:** As an operator, I want override orders to be fully auditable, so that I can distinguish manual trades from autonomous ones and review the decision history.
|
||||
|
||||
#### Acceptance Criteria
|
||||
|
||||
1. WHEN the Trading_Engine enqueues an override order, THE order job SHALL include a `source` field set to "manual_override" to distinguish it from autonomous orders.
|
||||
2. THE Broker_Service SHALL persist override orders in the orders table with the same schema as autonomous orders, including the decision_trace field containing the override source marker.
|
||||
3. WHEN the operator views order details for an override order, THE order detail view SHALL display the "manual_override" source in the decision trace.
|
||||
@@ -0,0 +1,153 @@
|
||||
# Implementation Plan: Override Trade Tab
|
||||
|
||||
## Overview
|
||||
|
||||
Add a manual order entry interface to the Stonks Oracle trading engine. The implementation spans backend (FastAPI endpoint + auto-registration module), frontend (Override tab with order form and positions display, navigation button, API hook), MSW test handlers, property-based tests for the 3 correctness properties, and frontend component tests. Each task builds incrementally, wiring components together as they are created.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Backend: Override order validation model and endpoint
|
||||
- [x] 1.1 Add `OverrideOrderRequest` and `OverrideOrderResponse` Pydantic models to `services/trading/app.py`
|
||||
- `OverrideOrderRequest` with fields: `ticker` (str), `side` (Literal["buy","sell"]), `quantity` (float > 0), `order_type` (Literal["market","limit","stop","stop_limit"], default "market"), `limit_price` (Optional[float]), `stop_price` (Optional[float])
|
||||
- Add a `@field_validator("ticker")` that uppercases and validates against `^[A-Z]{1,10}$`
|
||||
- Add a `@model_validator(mode="after")` that enforces `limit_price` required when order_type is "limit" or "stop_limit", and `stop_price` required when order_type is "stop" or "stop_limit"
|
||||
- `OverrideOrderResponse` with fields: `job_id` (str), `status` (str), `ticker` (str), `side` (str), `quantity` (float), `auto_registered` (bool)
|
||||
- _Requirements: 2.1, 2.2, 3.1, 3.5_
|
||||
|
||||
- [x] 1.2 Add `POST /api/trading/override/order` endpoint to `services/trading/app.py`
|
||||
- Accept `OverrideOrderRequest` body
|
||||
- Call `auto_register_symbol()` from `services/trading/override.py` if ticker is untracked (check via Symbol Registry `GET /companies?ticker=...`)
|
||||
- Generate idempotency key as `"override-{uuid4()}"`
|
||||
- Build job payload with `ticker`, `side`, `quantity`, `order_type`, `limit_price`, `stop_price`, `source: "manual_override"`, and `idempotency_key`
|
||||
- Enqueue job to Redis `stonks:queue:broker` via `RPUSH`
|
||||
- Return 202 with `OverrideOrderResponse`
|
||||
- Return 503 if Redis is unreachable
|
||||
- Use `engine.redis` for Redis access and `config` for registry base URL
|
||||
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 9.1_
|
||||
|
||||
- [x] 2. Backend: Auto-registration module
|
||||
- [x] 2.1 Create `services/trading/override.py` with `auto_register_symbol()` function
|
||||
- Use `httpx.AsyncClient` to call Symbol Registry HTTP endpoints
|
||||
- Check if ticker exists via `GET /companies?ticker={ticker}`
|
||||
- If not found, `POST /companies` to create company (legal_name = ticker, active = true)
|
||||
- Handle 409 conflict gracefully (fetch existing company and proceed)
|
||||
- Create two default sources: `market_api` and `news_api` via `POST /companies/{id}/sources`
|
||||
- Fetch active watchlists via `GET /watchlists`; add company to first active watchlist, or create "Manual Overrides" watchlist if none exist
|
||||
- Source and watchlist failures are logged but do not block order enqueuing (best-effort)
|
||||
- Return `(auto_registered: bool, company_id: str)`
|
||||
- Registry base URL derived from env var or defaults to `http://symbol-registry:8000`
|
||||
- _Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6_
|
||||
|
||||
- [x] 2.2 Write unit tests for `auto_register_symbol()` in `tests/test_override.py`
|
||||
- Mock httpx calls to Symbol Registry
|
||||
- Test new symbol registration (company + sources + watchlist)
|
||||
- Test existing symbol skip
|
||||
- Test 409 conflict handling
|
||||
- Test source/watchlist failure tolerance
|
||||
- _Requirements: 4.1, 4.2, 4.3, 4.5, 4.6_
|
||||
|
||||
- [x] 3. Backend: Override endpoint unit tests and property-based tests
|
||||
- [x] 3.1 Write unit tests for override endpoint in `tests/test_override.py`
|
||||
- Test valid order returns 202 with correct response shape
|
||||
- Test invalid ticker returns 422
|
||||
- Test missing limit_price for limit order returns 422
|
||||
- Test missing stop_price for stop order returns 422
|
||||
- Test non-positive quantity returns 422
|
||||
- Test enqueued job has correct structure and `source: "manual_override"`
|
||||
- _Requirements: 3.1, 3.2, 3.4, 3.5, 9.1_
|
||||
|
||||
- [x] 3.2 Write property test for ticker validation and normalization in `tests/test_pbt_override.py`
|
||||
- **Property 1: Ticker validation and normalization**
|
||||
- Use Hypothesis `@settings(max_examples=100)` to generate arbitrary strings
|
||||
- Assert: after uppercasing, accepted iff matches `^[A-Z]{1,10}$`; normalized output is always uppercased input
|
||||
- **Validates: Requirements 2.2, 8.1**
|
||||
|
||||
- [x] 3.3 Write property test for override job payload completeness in `tests/test_pbt_override.py`
|
||||
- **Property 2: Override job payload completeness**
|
||||
- Use Hypothesis to generate valid override order requests
|
||||
- Assert: enqueued payload contains all required fields, `source == "manual_override"`, `idempotency_key` starts with `"override-"`
|
||||
- **Validates: Requirements 3.2, 9.1**
|
||||
|
||||
- [x] 3.4 Write property test for invalid override order rejection in `tests/test_pbt_override.py`
|
||||
- **Property 3: Invalid override order rejection**
|
||||
- Use Hypothesis to generate orders violating at least one validation rule
|
||||
- Assert: endpoint returns 422 with at least one descriptive error message
|
||||
- **Validates: Requirements 3.5, 2.6**
|
||||
|
||||
- [x] 4. Checkpoint — Backend tests
|
||||
- Ensure all backend tests pass with `.venv/bin/python -m pytest tests/test_override.py tests/test_pbt_override.py -x --tb=short -q`, ask the user if questions arise.
|
||||
|
||||
- [x] 5. Frontend: API hook for submitting override orders
|
||||
- [x] 5.1 Add `useSubmitOverrideOrder` mutation hook to `frontend/src/api/tradingHooks.ts`
|
||||
- Define `OverrideOrderRequest` interface: `ticker`, `side` ("buy"|"sell"), `quantity`, `order_type` ("market"|"limit"|"stop"|"stop_limit"), optional `limit_price`, optional `stop_price`
|
||||
- Define `OverrideOrderResponse` interface: `job_id`, `status`, `ticker`, `side`, `quantity`, `auto_registered`
|
||||
- Use `apiPost<OverrideOrderResponse>('trading', '/api/trading/override/order', body)`
|
||||
- On success, invalidate `orders`, `positions`, and `companies` query keys
|
||||
- _Requirements: 2.5, 3.4, 7.1_
|
||||
|
||||
- [x] 6. Frontend: OverrideTradePanel component
|
||||
- [x] 6.1 Create `frontend/src/pages/trading/OverrideTradePanel.tsx`
|
||||
- Order form section with controlled inputs: ticker (text, auto-uppercase), side (buy/sell toggle), quantity (number), order type (select: market/limit/stop/stop_limit), conditional limit_price and stop_price fields
|
||||
- Client-side validation: ticker 1-10 alpha chars, positive quantity, required price fields based on order type
|
||||
- Inline validation error display on invalid inputs
|
||||
- Submit via `useSubmitOverrideOrder` mutation
|
||||
- Success: show green toast/banner with job_id and "queued" status, reset form
|
||||
- 422 error: display validation errors inline
|
||||
- 503 error: show "Broker service is unavailable" banner
|
||||
- Network error: show connectivity error banner
|
||||
- Submit button disabled during flight with loading state
|
||||
- Positions display section using existing `usePositions()` hook from `api/hooks.ts`
|
||||
- Positions table showing: ticker, quantity, avg entry price, current price, unrealized P&L
|
||||
- Loading spinner while positions load, "No current positions" message when empty
|
||||
- _Requirements: 1.2, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 6.1, 6.2, 6.3, 6.4, 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 8.1_
|
||||
|
||||
- [x] 7. Frontend: Override tab integration in TradingEnginePage
|
||||
- [x] 7.1 Update `frontend/src/pages/TradingEngine.tsx` to add Override tab
|
||||
- Add `{ id: 'override', label: 'Override' }` to the TABS array
|
||||
- Move tab state from `useState` to URL search params using TanStack Router `useSearch` / `useNavigate` for the `/trading/engine` route
|
||||
- Default to `'overview'` when no `tab` param is present
|
||||
- Import and render `OverrideTradePanel` when `activeTab === 'override'`
|
||||
- Update the route definition in `frontend/src/routes.tsx` to accept `tab` search param via `validateSearch`
|
||||
- _Requirements: 1.1, 1.2, 1.3_
|
||||
|
||||
- [x] 8. Frontend: Navigation button on Trading Controls page
|
||||
- [x] 8.1 Add "Override Trade" button to `frontend/src/pages/Trading.tsx`
|
||||
- Add a TanStack Router `Link` component inside the Trading Mode card section
|
||||
- Navigate to `/trading/engine` with search param `{ tab: 'override' }`
|
||||
- Style with `rounded-md bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700`
|
||||
- _Requirements: 5.1, 5.2, 5.3_
|
||||
|
||||
- [x] 9. Frontend: MSW handlers for testing
|
||||
- [x] 9.1 Add MSW handler for `POST /trading/api/trading/override/order` in `frontend/src/test/mocks/handlers.ts`
|
||||
- Return 202 with mock `OverrideOrderResponse` containing `job_id`, `status: "queued"`, echoed `ticker`, `side`, `quantity`, and `auto_registered: false`
|
||||
- _Requirements: 3.4, 7.1_
|
||||
|
||||
- [x] 10. Frontend: Component and integration tests
|
||||
- [x] 10.1 Write frontend tests for Override tab in `frontend/src/test/override.test.tsx`
|
||||
- Test override tab renders in tab bar
|
||||
- Test override tab shows form and positions sections
|
||||
- Test override tab accessible via URL param `?tab=override`
|
||||
- Test order form fields are present (ticker, side, quantity, order type)
|
||||
- Test conditional price fields show/hide based on order type
|
||||
- Test form validation errors for invalid inputs
|
||||
- Test successful order submission shows success message and resets form
|
||||
- Test 422 error display
|
||||
- Test submit button loading state during submission
|
||||
- Test positions table renders with mock data
|
||||
- Test positions loading state
|
||||
- Test positions empty state
|
||||
- Test "Override Trade" button on Trading page exists and links correctly
|
||||
- _Requirements: 1.1, 1.2, 1.3, 2.1, 2.3, 2.4, 2.6, 5.1, 5.2, 6.1, 6.2, 6.3, 6.4, 7.1, 7.2, 7.5, 7.6_
|
||||
|
||||
- [x] 11. Final checkpoint
|
||||
- Ensure all tests pass: backend with `.venv/bin/python -m pytest tests/test_override.py tests/test_pbt_override.py -x --tb=short -q` and frontend with `cd frontend && npx vitest --run`, ask the user if questions arise.
|
||||
|
||||
## Notes
|
||||
|
||||
- Tasks marked with `*` are optional and can be skipped for faster MVP
|
||||
- Each task references specific requirements for traceability
|
||||
- Checkpoints ensure incremental validation
|
||||
- Property tests validate universal correctness properties from the design document
|
||||
- Unit tests validate specific examples and edge cases
|
||||
- The broker_service does not need modification — it already processes the full job payload and persists `decision_trace` JSONB
|
||||
- No new database migrations are required; override orders use existing `orders`, `order_events`, and `risk_evaluations` tables
|
||||
@@ -1,12 +1,13 @@
|
||||
# Development Process — Test-Develop-Debug
|
||||
|
||||
## Local Environment
|
||||
- Python 3.12 via NixOS, virtualenv at `.venv/`
|
||||
- Ubuntu dev machine, Python 3.12, virtualenv at `.venv/`
|
||||
- Always use `.venv/bin/python` or activate with `source .venv/bin/activate` before running Python commands
|
||||
- For tools not available in `.venv/` (ruff, gh, etc.), use `nix-shell -p <pkg> --run "<cmd>"`
|
||||
- Node.js 24 for frontend; `frontend/` has its own `node_modules/`
|
||||
- Frontend tests: `cd frontend && npx vitest --run`
|
||||
- Python tests: `nix-shell -p ruff --run "ruff check services/"` then `python -m pytest tests/ -x --tb=short -q`
|
||||
- Node.js 24 via nvm — always load nvm before running Node/npm/npx commands:
|
||||
`export NVM_DIR="$HOME/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && nvm use 24`
|
||||
- For tools not available in `.venv/` (ruff, gh, etc.), install via pip or apt as needed
|
||||
- Frontend tests: load nvm first, then `cd frontend && npx vitest --run`
|
||||
- Python tests: `.venv/bin/ruff check services/` then `.venv/bin/python -m pytest tests/ -x --tb=short -q`
|
||||
|
||||
## Workflow
|
||||
1. Write or update tests for the target behavior
|
||||
@@ -22,7 +23,7 @@
|
||||
- Frontend: Vitest + MSW (Mock Service Worker) for deterministic API mocking, tests in `frontend/src/test/`
|
||||
- Run Python tests: `python -m pytest tests/ -x --tb=short -q`
|
||||
- Run frontend tests: `cd frontend && npx vitest --run`
|
||||
- Lint Python: `nix-shell -p ruff --run "ruff check services/"` (or `.venv/bin/ruff check services/`)
|
||||
- Lint Python: `.venv/bin/ruff check services/`
|
||||
- Ruff is pinned to `ruff==0.15.10` in `requirements.txt` — CI uses the same version
|
||||
- Ruff config: `ruff.toml` with `known-first-party = ["services"]` for consistent import sorting
|
||||
- Pre-existing test failures (not regressions): `test_extractor_prompts.py`, `test_extractor_schemas.py`, `test_filings_adapter.py`, `test_ollama_client.py`
|
||||
@@ -36,8 +37,8 @@
|
||||
- `build-dashboard`: frontend/Dockerfile → GHCR (TypeScript strict mode — catches unused imports)
|
||||
- `build-superset`: docker/Dockerfile.superset → GHCR
|
||||
- CI handles all image builds and pushes — do NOT manually docker push
|
||||
- Check CI: `nix-shell -p gh --run "gh run list -L 3"` or `gh run list -L 3`
|
||||
- Re-run failed: `nix-shell -p gh --run "gh run rerun <id> --failed"`
|
||||
- Check CI: `gh run list -L 3`
|
||||
- Re-run failed: `gh run rerun <id> --failed`
|
||||
- View failure logs: `gh run view <id> --log-failed`
|
||||
|
||||
## Deploy
|
||||
|
||||
@@ -19,6 +19,7 @@ fileMatchPattern: "frontend/**"
|
||||
|
||||
## Testing
|
||||
- Vitest + MSW (Mock Service Worker) for deterministic tests
|
||||
- Requires Node.js 24 via nvm — load before running: `export NVM_DIR="$HOME/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && nvm use 24`
|
||||
- Test setup: `src/test/setup.ts` starts MSW server
|
||||
- Mock handlers: `src/test/mocks/handlers.ts`
|
||||
- Test helper: `src/test/render.tsx` provides `renderRoute(path)` with QueryClient + Router
|
||||
|
||||
@@ -15,10 +15,11 @@ Three-layer signal aggregation engine:
|
||||
- Seed script: `python -m services.symbol_registry.seed`
|
||||
|
||||
## Local Dev Environment
|
||||
- NixOS dev environment, Python 3.12
|
||||
- Ubuntu dev machine, Python 3.12
|
||||
- Virtual environment at `.venv/` — always use it for Python commands
|
||||
- For tools not in `.venv/` (like `ruff`, `gh`), use `nix-shell -p <pkg> --run "<cmd>"`
|
||||
- Node.js 24 for frontend (`frontend/` directory)
|
||||
- Node.js 24 via nvm — always load nvm before running Node/npm commands:
|
||||
`export NVM_DIR="$HOME/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && nvm use 24`
|
||||
- For tools not in `.venv/` (like `ruff`, `gh`), install via pip or apt as needed
|
||||
- Docker available locally for image builds (but let CI handle pushes)
|
||||
|
||||
## Live Endpoints
|
||||
@@ -44,7 +45,7 @@ Three-layer signal aggregation engine:
|
||||
- Superset image: `docker/Dockerfile.superset` (apache/superset + trino + psycopg2)
|
||||
- Python service images: `docker/Dockerfile` with `SERVICE_CMD` build arg
|
||||
- Let CI handle image builds and pushes — do NOT manually `docker build && docker push`
|
||||
- Check CI status: `nix-shell -p gh --run "gh run list -L 3"`
|
||||
- Check CI status: `gh run list -L 3`
|
||||
|
||||
## Deployment Scripts
|
||||
- `~/sources/kube/stonks-oracle/runmefirst.sh` — full deploy: DB setup, migrations, Helm install, rolling restart (runs from gremlin-1 at 192.168.42.254 where secrets are available)
|
||||
|
||||
@@ -336,3 +336,39 @@ export function useUpdateNotificationConfig() {
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['notification-config'] }),
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Override Order (manual trade submission)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** Submit a manual override order to the trading engine. */
|
||||
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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import {
|
||||
useTradingConfig,
|
||||
useSetTradingMode,
|
||||
@@ -78,6 +79,14 @@ export function TradingPage() {
|
||||
{mode}
|
||||
</button>
|
||||
))}
|
||||
<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"
|
||||
data-testid="override-trade-button"
|
||||
>
|
||||
Override Trade
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Confirmation dialog for live mode */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearch, useNavigate } from '@tanstack/react-router';
|
||||
import { TradingOverview } from './trading/TradingOverview';
|
||||
import { PortfolioComposition } from './trading/PortfolioComposition';
|
||||
import { TradeHistory } from './trading/TradeHistory';
|
||||
@@ -6,6 +6,7 @@ import { PerformanceCharts } from './trading/PerformanceCharts';
|
||||
import { BacktestPanel } from './trading/BacktestPanel';
|
||||
import { MicroTradingPanel } from './trading/MicroTradingPanel';
|
||||
import { NotificationPreferences } from './trading/NotificationPreferences';
|
||||
import OverrideTradePanel from './trading/OverrideTradePanel';
|
||||
import { ErrorBoundary } from '../components/ui';
|
||||
|
||||
const TABS = [
|
||||
@@ -16,12 +17,21 @@ const TABS = [
|
||||
{ id: 'backtest', label: 'Backtest' },
|
||||
{ id: 'micro', label: 'Micro-Trading' },
|
||||
{ id: 'notifications', label: 'Notifications' },
|
||||
{ id: 'override', label: 'Override' },
|
||||
] as const;
|
||||
|
||||
type TabId = (typeof TABS)[number]['id'];
|
||||
|
||||
const VALID_TAB_IDS = new Set<string>(TABS.map((t) => t.id));
|
||||
|
||||
export function TradingEnginePage() {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('overview');
|
||||
const { tab } = useSearch({ from: '/trading/engine' });
|
||||
const navigate = useNavigate();
|
||||
const activeTab: TabId = (tab && VALID_TAB_IDS.has(tab) ? tab : 'overview') as TabId;
|
||||
|
||||
const setActiveTab = (newTab: TabId) => {
|
||||
navigate({ to: '/trading/engine', search: { tab: newTab }, replace: true });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -57,6 +67,7 @@ export function TradingEnginePage() {
|
||||
{activeTab === 'backtest' && <BacktestPanel />}
|
||||
{activeTab === 'micro' && <MicroTradingPanel />}
|
||||
{activeTab === 'notifications' && <NotificationPreferences />}
|
||||
{activeTab === 'override' && <OverrideTradePanel />}
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
import { useState, type FormEvent, type ChangeEvent } from 'react';
|
||||
import { usePositions } from '../../api/hooks';
|
||||
import {
|
||||
useSubmitOverrideOrder,
|
||||
type OverrideOrderRequest,
|
||||
type OverrideOrderResponse,
|
||||
} from '../../api/tradingHooks';
|
||||
import { ApiError } from '../../api/client';
|
||||
import { Card, LoadingSpinner } from '../../components/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type OrderType = OverrideOrderRequest['order_type'];
|
||||
type Side = OverrideOrderRequest['side'];
|
||||
|
||||
interface FormState {
|
||||
ticker: string;
|
||||
side: Side;
|
||||
quantity: string;
|
||||
order_type: OrderType;
|
||||
limit_price: string;
|
||||
stop_price: string;
|
||||
}
|
||||
|
||||
interface ValidationErrors {
|
||||
ticker?: string;
|
||||
quantity?: string;
|
||||
limit_price?: string;
|
||||
stop_price?: string;
|
||||
}
|
||||
|
||||
const INITIAL_FORM: FormState = {
|
||||
ticker: '',
|
||||
side: 'buy',
|
||||
quantity: '',
|
||||
order_type: 'market',
|
||||
limit_price: '',
|
||||
stop_price: '',
|
||||
};
|
||||
|
||||
const ORDER_TYPES: { value: OrderType; label: string }[] = [
|
||||
{ value: 'market', label: 'Market' },
|
||||
{ value: 'limit', label: 'Limit' },
|
||||
{ value: 'stop', label: 'Stop' },
|
||||
{ value: 'stop_limit', label: 'Stop Limit' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TICKER_RE = /^[A-Z]{1,10}$/;
|
||||
|
||||
function needsLimitPrice(orderType: OrderType): boolean {
|
||||
return orderType === 'limit' || orderType === 'stop_limit';
|
||||
}
|
||||
|
||||
function needsStopPrice(orderType: OrderType): boolean {
|
||||
return orderType === 'stop' || orderType === 'stop_limit';
|
||||
}
|
||||
|
||||
function validate(form: FormState): ValidationErrors {
|
||||
const errors: ValidationErrors = {};
|
||||
const ticker = form.ticker.toUpperCase();
|
||||
if (!TICKER_RE.test(ticker)) {
|
||||
errors.ticker = 'Ticker must be 1–10 alphabetic characters';
|
||||
}
|
||||
const qty = Number(form.quantity);
|
||||
if (!form.quantity || isNaN(qty) || qty <= 0) {
|
||||
errors.quantity = 'Quantity must be a positive number';
|
||||
}
|
||||
if (needsLimitPrice(form.order_type)) {
|
||||
const lp = Number(form.limit_price);
|
||||
if (!form.limit_price || isNaN(lp) || lp <= 0) {
|
||||
errors.limit_price = 'Limit price is required and must be positive';
|
||||
}
|
||||
}
|
||||
if (needsStopPrice(form.order_type)) {
|
||||
const sp = Number(form.stop_price);
|
||||
if (!form.stop_price || isNaN(sp) || sp <= 0) {
|
||||
errors.stop_price = 'Stop price is required and must be positive';
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function fmtUsd(v: number | null | undefined) {
|
||||
if (v == null) return '—';
|
||||
return v.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function pnlColor(v: number | null | undefined) {
|
||||
if (v == null || v === 0) return 'text-gray-400';
|
||||
return v > 0 ? 'text-green-400' : 'text-red-400';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function OverrideTradePanel() {
|
||||
const [form, setForm] = useState<FormState>(INITIAL_FORM);
|
||||
const [errors, setErrors] = useState<ValidationErrors>({});
|
||||
const [serverErrors, setServerErrors] = useState<string[]>([]);
|
||||
const [banner, setBanner] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||
const [successData, setSuccessData] = useState<OverrideOrderResponse | null>(null);
|
||||
|
||||
const submitOrder = useSubmitOverrideOrder();
|
||||
const { data: positions, isLoading: positionsLoading } = usePositions();
|
||||
|
||||
// --- Form field handlers ---
|
||||
|
||||
function updateField(field: keyof FormState, value: string) {
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
// Clear field-level error on change
|
||||
if (field in errors) {
|
||||
setErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[field as keyof ValidationErrors];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
// Clear server errors and banner on any change
|
||||
if (serverErrors.length) setServerErrors([]);
|
||||
if (banner) setBanner(null);
|
||||
if (successData) setSuccessData(null);
|
||||
}
|
||||
|
||||
function handleTickerChange(e: ChangeEvent<HTMLInputElement>) {
|
||||
updateField('ticker', e.target.value.toUpperCase());
|
||||
}
|
||||
|
||||
function handleQuantityChange(e: ChangeEvent<HTMLInputElement>) {
|
||||
updateField('quantity', e.target.value);
|
||||
}
|
||||
|
||||
function handleOrderTypeChange(e: ChangeEvent<HTMLSelectElement>) {
|
||||
const newType = e.target.value as OrderType;
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
order_type: newType,
|
||||
limit_price: needsLimitPrice(newType) ? prev.limit_price : '',
|
||||
stop_price: needsStopPrice(newType) ? prev.stop_price : '',
|
||||
}));
|
||||
// Clear price errors when switching order type
|
||||
setErrors((prev) => {
|
||||
const next = { ...prev };
|
||||
if (!needsLimitPrice(newType)) delete next.limit_price;
|
||||
if (!needsStopPrice(newType)) delete next.stop_price;
|
||||
return next;
|
||||
});
|
||||
if (serverErrors.length) setServerErrors([]);
|
||||
if (banner) setBanner(null);
|
||||
}
|
||||
|
||||
function handleSideToggle(side: Side) {
|
||||
updateField('side', side);
|
||||
}
|
||||
|
||||
// --- Submit ---
|
||||
|
||||
function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
const validationErrors = validate(form);
|
||||
setErrors(validationErrors);
|
||||
if (Object.keys(validationErrors).length > 0) return;
|
||||
|
||||
setServerErrors([]);
|
||||
setBanner(null);
|
||||
setSuccessData(null);
|
||||
|
||||
const body: OverrideOrderRequest = {
|
||||
ticker: form.ticker.toUpperCase(),
|
||||
side: form.side,
|
||||
quantity: Number(form.quantity),
|
||||
order_type: form.order_type,
|
||||
};
|
||||
if (needsLimitPrice(form.order_type)) {
|
||||
body.limit_price = Number(form.limit_price);
|
||||
}
|
||||
if (needsStopPrice(form.order_type)) {
|
||||
body.stop_price = Number(form.stop_price);
|
||||
}
|
||||
|
||||
submitOrder.mutate(body, {
|
||||
onSuccess: (data) => {
|
||||
setSuccessData(data);
|
||||
setBanner({ type: 'success', message: `Order queued — Job ID: ${data.job_id}` });
|
||||
setForm(INITIAL_FORM);
|
||||
setErrors({});
|
||||
setServerErrors([]);
|
||||
},
|
||||
onError: (err) => {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.status === 422) {
|
||||
// Extract validation errors from response body
|
||||
const body = err.body as { detail?: Array<{ msg: string; loc?: string[] }> | string } | null;
|
||||
if (body?.detail && Array.isArray(body.detail)) {
|
||||
const msgs = body.detail.map((d) => d.msg ?? String(d));
|
||||
setServerErrors(msgs);
|
||||
} else if (typeof body?.detail === 'string') {
|
||||
setServerErrors([body.detail]);
|
||||
} else {
|
||||
setServerErrors(['Validation failed']);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (err.status === 503) {
|
||||
setBanner({ type: 'error', message: 'Broker service is unavailable. Please try again later.' });
|
||||
return;
|
||||
}
|
||||
setBanner({ type: 'error', message: `Server error (${err.status})` });
|
||||
return;
|
||||
}
|
||||
// Network / fetch error
|
||||
setBanner({ type: 'error', message: 'Unable to connect. Check your network and try again.' });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- Render ---
|
||||
|
||||
const showLimitPrice = needsLimitPrice(form.order_type);
|
||||
const showStopPrice = needsStopPrice(form.order_type);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Order Form */}
|
||||
<Card>
|
||||
<h2 className="mb-4 text-sm font-medium text-gray-400">Submit Override Order</h2>
|
||||
|
||||
{/* Success banner */}
|
||||
{banner?.type === 'success' && (
|
||||
<div
|
||||
className="mb-4 rounded-md border border-green-700/50 bg-green-900/20 px-4 py-3 text-sm text-green-400"
|
||||
role="alert"
|
||||
data-testid="success-banner"
|
||||
>
|
||||
{banner.message}
|
||||
{successData && (
|
||||
<span className="ml-2 text-xs text-green-500">
|
||||
Status: {successData.status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error banner (503 / network) */}
|
||||
{banner?.type === 'error' && (
|
||||
<div
|
||||
className="mb-4 rounded-md border border-red-700/50 bg-red-900/20 px-4 py-3 text-sm text-red-400"
|
||||
role="alert"
|
||||
data-testid="error-banner"
|
||||
>
|
||||
{banner.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Server validation errors (422) */}
|
||||
{serverErrors.length > 0 && (
|
||||
<div
|
||||
className="mb-4 rounded-md border border-red-700/50 bg-red-900/20 px-4 py-3 text-sm text-red-400"
|
||||
role="alert"
|
||||
data-testid="validation-errors"
|
||||
>
|
||||
<ul className="list-inside list-disc space-y-1">
|
||||
{serverErrors.map((msg, i) => (
|
||||
<li key={i}>{msg}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Ticker */}
|
||||
<div>
|
||||
<label htmlFor="override-ticker" className="mb-1 block text-xs text-gray-500">
|
||||
Ticker
|
||||
</label>
|
||||
<input
|
||||
id="override-ticker"
|
||||
type="text"
|
||||
data-testid="ticker-input"
|
||||
value={form.ticker}
|
||||
onChange={handleTickerChange}
|
||||
placeholder="AAPL"
|
||||
className={`w-40 rounded-md border bg-surface-950 px-3 py-2 font-mono text-sm text-gray-200 uppercase placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-brand-500 ${
|
||||
errors.ticker ? 'border-red-500' : 'border-surface-700'
|
||||
}`}
|
||||
aria-label="Ticker symbol"
|
||||
aria-invalid={!!errors.ticker}
|
||||
/>
|
||||
{errors.ticker && (
|
||||
<p className="mt-1 text-xs text-red-400" role="alert">{errors.ticker}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Side toggle */}
|
||||
<div>
|
||||
<span className="mb-1 block text-xs text-gray-500">Side</span>
|
||||
<div className="inline-flex rounded-md border border-surface-700" role="group" aria-label="Order side">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="side-buy"
|
||||
onClick={() => handleSideToggle('buy')}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors first:rounded-l-md last:rounded-r-md ${
|
||||
form.side === 'buy'
|
||||
? 'bg-green-700 text-white'
|
||||
: 'bg-surface-900 text-gray-400 hover:bg-surface-800'
|
||||
}`}
|
||||
aria-pressed={form.side === 'buy'}
|
||||
>
|
||||
Buy
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="side-sell"
|
||||
onClick={() => handleSideToggle('sell')}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors first:rounded-l-md last:rounded-r-md ${
|
||||
form.side === 'sell'
|
||||
? 'bg-red-700 text-white'
|
||||
: 'bg-surface-900 text-gray-400 hover:bg-surface-800'
|
||||
}`}
|
||||
aria-pressed={form.side === 'sell'}
|
||||
>
|
||||
Sell
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quantity */}
|
||||
<div>
|
||||
<label htmlFor="override-quantity" className="mb-1 block text-xs text-gray-500">
|
||||
Quantity
|
||||
</label>
|
||||
<input
|
||||
id="override-quantity"
|
||||
type="number"
|
||||
data-testid="quantity-input"
|
||||
value={form.quantity}
|
||||
onChange={handleQuantityChange}
|
||||
placeholder="10"
|
||||
min="0"
|
||||
step="any"
|
||||
className={`w-40 rounded-md border bg-surface-950 px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-brand-500 ${
|
||||
errors.quantity ? 'border-red-500' : 'border-surface-700'
|
||||
}`}
|
||||
aria-label="Quantity"
|
||||
aria-invalid={!!errors.quantity}
|
||||
/>
|
||||
{errors.quantity && (
|
||||
<p className="mt-1 text-xs text-red-400" role="alert">{errors.quantity}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Order Type */}
|
||||
<div>
|
||||
<label htmlFor="override-order-type" className="mb-1 block text-xs text-gray-500">
|
||||
Order Type
|
||||
</label>
|
||||
<select
|
||||
id="override-order-type"
|
||||
data-testid="order-type-select"
|
||||
value={form.order_type}
|
||||
onChange={handleOrderTypeChange}
|
||||
className="w-40 rounded-md border border-surface-700 bg-surface-950 px-3 py-2 text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-brand-500"
|
||||
aria-label="Order type"
|
||||
>
|
||||
{ORDER_TYPES.map((ot) => (
|
||||
<option key={ot.value} value={ot.value}>
|
||||
{ot.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Limit Price (conditional) */}
|
||||
{showLimitPrice && (
|
||||
<div>
|
||||
<label htmlFor="override-limit-price" className="mb-1 block text-xs text-gray-500">
|
||||
Limit Price
|
||||
</label>
|
||||
<input
|
||||
id="override-limit-price"
|
||||
type="number"
|
||||
data-testid="limit-price-input"
|
||||
value={form.limit_price}
|
||||
onChange={(e) => updateField('limit_price', e.target.value)}
|
||||
placeholder="0.00"
|
||||
min="0"
|
||||
step="any"
|
||||
className={`w-40 rounded-md border bg-surface-950 px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-brand-500 ${
|
||||
errors.limit_price ? 'border-red-500' : 'border-surface-700'
|
||||
}`}
|
||||
aria-label="Limit price"
|
||||
aria-invalid={!!errors.limit_price}
|
||||
/>
|
||||
{errors.limit_price && (
|
||||
<p className="mt-1 text-xs text-red-400" role="alert">{errors.limit_price}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stop Price (conditional) */}
|
||||
{showStopPrice && (
|
||||
<div>
|
||||
<label htmlFor="override-stop-price" className="mb-1 block text-xs text-gray-500">
|
||||
Stop Price
|
||||
</label>
|
||||
<input
|
||||
id="override-stop-price"
|
||||
type="number"
|
||||
data-testid="stop-price-input"
|
||||
value={form.stop_price}
|
||||
onChange={(e) => updateField('stop_price', e.target.value)}
|
||||
placeholder="0.00"
|
||||
min="0"
|
||||
step="any"
|
||||
className={`w-40 rounded-md border bg-surface-950 px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-brand-500 ${
|
||||
errors.stop_price ? 'border-red-500' : 'border-surface-700'
|
||||
}`}
|
||||
aria-label="Stop price"
|
||||
aria-invalid={!!errors.stop_price}
|
||||
/>
|
||||
{errors.stop_price && (
|
||||
<p className="mt-1 text-xs text-red-400" role="alert">{errors.stop_price}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="submit-order"
|
||||
disabled={submitOrder.isPending}
|
||||
className="rounded-md bg-brand-600 px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
aria-label="Submit order"
|
||||
>
|
||||
{submitOrder.isPending ? 'Submitting…' : 'Submit Order'}
|
||||
</button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{/* Positions Display */}
|
||||
<Card>
|
||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Current Positions</h2>
|
||||
|
||||
{positionsLoading ? (
|
||||
<LoadingSpinner />
|
||||
) : !positions?.length ? (
|
||||
<p className="text-sm text-gray-500" data-testid="no-positions">No current positions</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm" role="table" data-testid="positions-table">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-700 text-left text-gray-500">
|
||||
<th className="px-3 py-2">Ticker</th>
|
||||
<th className="px-3 py-2">Quantity</th>
|
||||
<th className="px-3 py-2">Avg Entry</th>
|
||||
<th className="px-3 py-2">Current</th>
|
||||
<th className="px-3 py-2">Unrealized P&L</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{positions.map((pos) => (
|
||||
<tr key={pos.id} className="border-b border-surface-700/50">
|
||||
<td className="px-3 py-2 font-mono font-semibold text-brand-300">{pos.ticker}</td>
|
||||
<td className="px-3 py-2 text-gray-300">{pos.quantity}</td>
|
||||
<td className="px-3 py-2 text-gray-300">${fmtUsd(pos.avg_entry_price)}</td>
|
||||
<td className="px-3 py-2 text-gray-300">
|
||||
{pos.current_price != null ? `$${fmtUsd(pos.current_price)}` : '—'}
|
||||
</td>
|
||||
<td className={`px-3 py-2 font-medium ${pnlColor(pos.unrealized_pnl)}`}>
|
||||
{pos.unrealized_pnl != null ? `$${fmtUsd(pos.unrealized_pnl)}` : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -115,6 +115,9 @@ const tradingEngineRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/trading/engine',
|
||||
component: TradingEnginePage,
|
||||
validateSearch: (search: Record<string, unknown>): { tab?: string } => ({
|
||||
tab: typeof search.tab === 'string' ? search.tab : undefined,
|
||||
}),
|
||||
});
|
||||
const opsPipelineRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
|
||||
@@ -219,6 +219,15 @@ export const handlers = [
|
||||
http.get('/api/agents/:agent_id/variants/:variant_id/performance', () => HttpResponse.json(mockVariantPerformance)),
|
||||
http.get('/api/agents/:agent_id/variants/:variant_id/performance/history', () => HttpResponse.json(mockVariantPerfHistory)),
|
||||
|
||||
// Trading Engine: Override order
|
||||
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 },
|
||||
);
|
||||
}),
|
||||
|
||||
// Competitive intelligence endpoints
|
||||
http.get('/registry/companies/:id/competitors', () => HttpResponse.json(mockCompetitors)),
|
||||
http.post('/registry/companies/:id/competitors/infer', () => HttpResponse.json(mockCompetitors)),
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { renderRoute } from './render';
|
||||
import { server } from './mocks/server';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Override tab renders in tab bar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Override tab in Trading Engine', () => {
|
||||
it('renders Override tab in the tab bar', async () => {
|
||||
renderRoute('/trading/engine?tab=override');
|
||||
await waitFor(() => {
|
||||
const tab = screen.getByRole('tab', { name: 'Override' });
|
||||
expect(tab).toBeInTheDocument();
|
||||
expect(tab).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Override tab shows form and positions sections
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('shows order form and positions sections', async () => {
|
||||
renderRoute('/trading/engine?tab=override');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Submit Override Order')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Current Positions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Override tab accessible via URL param ?tab=override
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('is accessible via URL param ?tab=override', async () => {
|
||||
renderRoute('/trading/engine?tab=override');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Submit Override Order')).toBeInTheDocument();
|
||||
});
|
||||
// The Override tab should be selected
|
||||
const tab = screen.getByRole('tab', { name: 'Override' });
|
||||
expect(tab).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Order form fields are present
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('renders all order form fields (ticker, side, quantity, order type)', async () => {
|
||||
renderRoute('/trading/engine?tab=override');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('ticker-input')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByTestId('side-buy')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('side-sell')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('quantity-input')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('order-type-select')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('submit-order')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Conditional price fields show/hide based on order type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('shows limit price field when order type is limit', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRoute('/trading/engine?tab=override');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('order-type-select')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Market order — no price fields
|
||||
expect(screen.queryByTestId('limit-price-input')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('stop-price-input')).not.toBeInTheDocument();
|
||||
|
||||
// Switch to limit
|
||||
await user.selectOptions(screen.getByTestId('order-type-select'), 'limit');
|
||||
expect(screen.getByTestId('limit-price-input')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('stop-price-input')).not.toBeInTheDocument();
|
||||
|
||||
// Switch to stop
|
||||
await user.selectOptions(screen.getByTestId('order-type-select'), 'stop');
|
||||
expect(screen.queryByTestId('limit-price-input')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('stop-price-input')).toBeInTheDocument();
|
||||
|
||||
// Switch to stop_limit
|
||||
await user.selectOptions(screen.getByTestId('order-type-select'), 'stop_limit');
|
||||
expect(screen.getByTestId('limit-price-input')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('stop-price-input')).toBeInTheDocument();
|
||||
|
||||
// Switch back to market
|
||||
await user.selectOptions(screen.getByTestId('order-type-select'), 'market');
|
||||
expect(screen.queryByTestId('limit-price-input')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('stop-price-input')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Form validation errors for invalid inputs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('shows validation errors for invalid inputs', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRoute('/trading/engine?tab=override');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('submit-order')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Submit empty form
|
||||
await user.click(screen.getByTestId('submit-order'));
|
||||
|
||||
await waitFor(() => {
|
||||
// Ticker and quantity should show errors
|
||||
const alerts = screen.getAllByRole('alert');
|
||||
expect(alerts.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Ticker must be 1–10 alphabetic characters/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Quantity must be a positive number/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows validation error for missing limit price on limit order', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRoute('/trading/engine?tab=override');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('order-type-select')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.type(screen.getByTestId('ticker-input'), 'AAPL');
|
||||
await user.type(screen.getByTestId('quantity-input'), '10');
|
||||
await user.selectOptions(screen.getByTestId('order-type-select'), 'limit');
|
||||
|
||||
// Submit without limit price
|
||||
await user.click(screen.getByTestId('submit-order'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Limit price is required/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Successful order submission shows success message and resets form
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('shows success message and resets form on successful submission', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderRoute('/trading/engine?tab=override');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('ticker-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.type(screen.getByTestId('ticker-input'), 'AAPL');
|
||||
await user.type(screen.getByTestId('quantity-input'), '10');
|
||||
await user.click(screen.getByTestId('submit-order'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('success-banner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Order queued — Job ID: job-test-123/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Status: queued/)).toBeInTheDocument();
|
||||
|
||||
// Form should be reset
|
||||
expect(screen.getByTestId('ticker-input')).toHaveValue('');
|
||||
expect(screen.getByTestId('quantity-input')).toHaveValue(null);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 8. 422 error display
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('displays 422 validation errors from the server', async () => {
|
||||
// Override the handler to return 422
|
||||
server.use(
|
||||
http.post('/trading/api/trading/override/order', () => {
|
||||
return HttpResponse.json(
|
||||
{ detail: [{ msg: 'Ticker not recognized by broker' }] },
|
||||
{ status: 422 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderRoute('/trading/engine?tab=override');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('ticker-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.type(screen.getByTestId('ticker-input'), 'AAPL');
|
||||
await user.type(screen.getByTestId('quantity-input'), '10');
|
||||
await user.click(screen.getByTestId('submit-order'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('validation-errors')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Ticker not recognized by broker')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 9. Submit button loading state during submission
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('disables submit button and shows loading state during submission', async () => {
|
||||
// Use a delayed handler to observe loading state
|
||||
server.use(
|
||||
http.post('/trading/api/trading/override/order', async ({ request }) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
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 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderRoute('/trading/engine?tab=override');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('ticker-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.type(screen.getByTestId('ticker-input'), 'AAPL');
|
||||
await user.type(screen.getByTestId('quantity-input'), '10');
|
||||
await user.click(screen.getByTestId('submit-order'));
|
||||
|
||||
// Button should be disabled and show loading text
|
||||
await waitFor(() => {
|
||||
const btn = screen.getByTestId('submit-order');
|
||||
expect(btn).toBeDisabled();
|
||||
expect(btn).toHaveTextContent('Submitting…');
|
||||
});
|
||||
|
||||
// Eventually resolves to success
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('success-banner')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10. Positions table renders with mock data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('renders positions table with mock data', async () => {
|
||||
renderRoute('/trading/engine?tab=override');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('positions-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const table = screen.getByTestId('positions-table');
|
||||
expect(within(table).getByText('AAPL')).toBeInTheDocument();
|
||||
expect(within(table).getByText('10')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 11. Positions loading state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('shows loading state while positions are loading', async () => {
|
||||
// Delay the positions response to observe loading state
|
||||
server.use(
|
||||
http.get('/api/positions', async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
);
|
||||
|
||||
renderRoute('/trading/engine?tab=override');
|
||||
|
||||
// The form should render while positions are loading
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Submit Override Order')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The positions section should show a loading indicator (LoadingSpinner renders a role="status" element)
|
||||
expect(screen.getByText('Current Positions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 12. Positions empty state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('shows empty state when no positions exist', async () => {
|
||||
server.use(
|
||||
http.get('/api/positions', () => HttpResponse.json([])),
|
||||
);
|
||||
|
||||
renderRoute('/trading/engine?tab=override');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('no-positions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('No current positions')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 13. "Override Trade" button on Trading page exists and links correctly
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Override Trade button on Trading page', () => {
|
||||
it('renders Override Trade button that links to override tab', async () => {
|
||||
renderRoute('/trading');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('override-trade-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const link = screen.getByTestId('override-trade-button');
|
||||
expect(link).toHaveTextContent('Override Trade');
|
||||
expect(link).toHaveAttribute('href', '/trading/engine?tab=override');
|
||||
});
|
||||
});
|
||||
+150
-3
@@ -4,26 +4,31 @@ Feature: autonomous-trading-engine
|
||||
|
||||
Exposes health/readiness probes, engine control (pause/resume),
|
||||
configuration management, decision audit trail, performance metrics,
|
||||
backtesting, and notification configuration endpoints.
|
||||
backtesting, notification configuration, and manual override order endpoints.
|
||||
|
||||
Requirements: 1.7, 5.6, 6.6, 15.5, 16.2, 16.3, 16.4, 17.3, 19.9
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import date, datetime, timezone
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
import asyncpg
|
||||
import redis.asyncio as aioredis
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, field_validator, model_validator
|
||||
|
||||
from services.shared.config import load_config
|
||||
from services.shared.redis_keys import QUEUE_BROKER, queue_key
|
||||
from services.trading.engine import TradingEngine
|
||||
from services.trading.override import auto_register_symbol
|
||||
|
||||
logger = logging.getLogger("trading_engine")
|
||||
|
||||
@@ -83,6 +88,63 @@ class NotificationConfigRequest(BaseModel):
|
||||
email_recipient: Optional[str] = None
|
||||
|
||||
|
||||
class OverrideOrderRequest(BaseModel):
|
||||
"""Body for POST /api/trading/override/order.
|
||||
|
||||
Requirements: 2.1, 2.2, 3.1, 3.5
|
||||
"""
|
||||
|
||||
ticker: str
|
||||
side: Literal["buy", "sell"]
|
||||
quantity: float
|
||||
order_type: Literal["market", "limit", "stop", "stop_limit"] = "market"
|
||||
limit_price: Optional[float] = None
|
||||
stop_price: Optional[float] = None
|
||||
|
||||
@field_validator("ticker")
|
||||
@classmethod
|
||||
def validate_ticker(cls, v: str) -> str:
|
||||
v = v.upper()
|
||||
if not re.match(r"^[A-Z]{1,10}$", v):
|
||||
raise ValueError(
|
||||
"Ticker must be 1-10 alphabetic characters"
|
||||
)
|
||||
return v
|
||||
|
||||
@field_validator("quantity")
|
||||
@classmethod
|
||||
def validate_quantity(cls, v: float) -> float:
|
||||
if v <= 0:
|
||||
raise ValueError("Quantity must be positive")
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_prices(self) -> OverrideOrderRequest:
|
||||
if self.order_type in ("limit", "stop_limit") and self.limit_price is None:
|
||||
raise ValueError(
|
||||
"limit_price is required for limit and stop_limit orders"
|
||||
)
|
||||
if self.order_type in ("stop", "stop_limit") and self.stop_price is None:
|
||||
raise ValueError(
|
||||
"stop_price is required for stop and stop_limit orders"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class OverrideOrderResponse(BaseModel):
|
||||
"""Response for POST /api/trading/override/order.
|
||||
|
||||
Requirements: 3.4
|
||||
"""
|
||||
|
||||
job_id: str
|
||||
status: str
|
||||
ticker: str
|
||||
side: str
|
||||
quantity: float
|
||||
auto_registered: bool
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifespan
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -713,3 +775,88 @@ async def notification_history(
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return recent notifications (placeholder)."""
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Override Order
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.post(
|
||||
"/api/trading/override/order",
|
||||
response_model=OverrideOrderResponse,
|
||||
status_code=202,
|
||||
)
|
||||
async def submit_override_order(body: OverrideOrderRequest) -> OverrideOrderResponse:
|
||||
"""Submit a manual override order to the broker queue.
|
||||
|
||||
Checks whether the ticker is tracked in the Symbol Registry and
|
||||
auto-registers it if not. Then enqueues the order job to Redis
|
||||
for the broker service to pick up.
|
||||
|
||||
Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 9.1
|
||||
"""
|
||||
if engine is None:
|
||||
raise HTTPException(503, "Engine not initialised")
|
||||
|
||||
registry_base_url = os.getenv(
|
||||
"SYMBOL_REGISTRY_URL", "http://symbol-registry:8000"
|
||||
)
|
||||
|
||||
# --- Check if ticker is tracked & auto-register if needed -----------
|
||||
auto_registered = False
|
||||
try:
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(
|
||||
f"{registry_base_url}/companies", params={"active": "true"}
|
||||
)
|
||||
companies = resp.json() if resp.status_code == 200 else []
|
||||
tracked = any(c.get("ticker") == body.ticker for c in companies)
|
||||
|
||||
if not tracked:
|
||||
auto_registered, _company_id = await auto_register_symbol(
|
||||
body.ticker, registry_base_url
|
||||
)
|
||||
except Exception:
|
||||
# Auto-registration is best-effort; log and continue
|
||||
logger.warning(
|
||||
"Auto-registration check failed for %s — proceeding with enqueue",
|
||||
body.ticker,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# --- Build job payload ------------------------------------------------
|
||||
idempotency_key = f"override-{uuid.uuid4()}"
|
||||
job_payload = {
|
||||
"ticker": body.ticker,
|
||||
"side": body.side,
|
||||
"quantity": body.quantity,
|
||||
"order_type": body.order_type,
|
||||
"limit_price": body.limit_price,
|
||||
"stop_price": body.stop_price,
|
||||
"source": "manual_override",
|
||||
"idempotency_key": idempotency_key,
|
||||
}
|
||||
|
||||
# --- Enqueue to Redis broker queue ------------------------------------
|
||||
try:
|
||||
if engine.redis is None:
|
||||
raise HTTPException(503, detail="Broker queue unavailable")
|
||||
broker_queue = queue_key(QUEUE_BROKER)
|
||||
await engine.redis.rpush(broker_queue, json.dumps(job_payload))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
logger.error("Failed to enqueue override order to Redis", exc_info=True)
|
||||
raise HTTPException(503, detail="Broker queue unavailable")
|
||||
|
||||
return OverrideOrderResponse(
|
||||
job_id=idempotency_key,
|
||||
status="queued",
|
||||
ticker=body.ticker,
|
||||
side=body.side,
|
||||
quantity=body.quantity,
|
||||
auto_registered=auto_registered,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Override trade helpers — auto-registration of untracked symbols.
|
||||
|
||||
Feature: override-trade-tab
|
||||
|
||||
Requirements: 4.1, 4.2, 4.3, 4.4, 4.5, 4.6
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger("trading_engine.override")
|
||||
|
||||
|
||||
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, company_id)``.
|
||||
|
||||
Calls Symbol Registry HTTP endpoints to create the company,
|
||||
default sources, and watchlist membership. Handles 409 conflicts
|
||||
gracefully. Source and watchlist failures are logged but do not
|
||||
block order enqueuing (best-effort).
|
||||
"""
|
||||
ticker = ticker.upper()
|
||||
base = registry_base_url.rstrip("/")
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
# ------------------------------------------------------------------
|
||||
# 1. Check if ticker already exists (GET /companies?active=true)
|
||||
# The Symbol Registry list endpoint only filters by active flag,
|
||||
# so we fetch all active companies and search client-side.
|
||||
# ------------------------------------------------------------------
|
||||
try:
|
||||
resp = await client.get(f"{base}/companies", params={"active": "true"})
|
||||
if resp.status_code == 200:
|
||||
companies = resp.json()
|
||||
for company in companies:
|
||||
if company.get("ticker") == ticker:
|
||||
logger.info(
|
||||
"Ticker %s already exists (company_id=%s), skipping registration",
|
||||
ticker,
|
||||
company["id"],
|
||||
)
|
||||
return (False, str(company["id"]))
|
||||
else:
|
||||
logger.warning(
|
||||
"Symbol Registry GET /companies returned %d — proceeding with creation",
|
||||
resp.status_code,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning(
|
||||
"Failed to query Symbol Registry for existing companies: %s — proceeding with creation",
|
||||
exc,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. Create company (POST /companies)
|
||||
# Requirement 4.1: legal_name = ticker, active = true (default)
|
||||
# ------------------------------------------------------------------
|
||||
company_id: str | None = None
|
||||
try:
|
||||
resp = await client.post(
|
||||
f"{base}/companies",
|
||||
json={"ticker": ticker, "legal_name": ticker},
|
||||
)
|
||||
if resp.status_code == 201:
|
||||
data = resp.json()
|
||||
company_id = str(data["id"])
|
||||
logger.info(
|
||||
"Created company for ticker %s (company_id=%s)",
|
||||
ticker,
|
||||
company_id,
|
||||
)
|
||||
elif resp.status_code == 409:
|
||||
# Requirement 4.6: 409 conflict — fetch existing company
|
||||
logger.info(
|
||||
"Company %s already exists (409 conflict) — fetching existing record",
|
||||
ticker,
|
||||
)
|
||||
company_id = await _fetch_company_id_by_ticker(client, base, ticker)
|
||||
if company_id is None:
|
||||
logger.error(
|
||||
"409 conflict for %s but could not find existing company",
|
||||
ticker,
|
||||
)
|
||||
return (False, "")
|
||||
else:
|
||||
logger.error(
|
||||
"Failed to create company for %s: HTTP %d — %s",
|
||||
ticker,
|
||||
resp.status_code,
|
||||
resp.text,
|
||||
)
|
||||
return (False, "")
|
||||
except httpx.HTTPError as exc:
|
||||
logger.error("HTTP error creating company for %s: %s", ticker, exc)
|
||||
return (False, "")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. Create default sources (best-effort)
|
||||
# Requirement 4.2: market_api + news_api
|
||||
# ------------------------------------------------------------------
|
||||
for source_type, source_name in [
|
||||
("market_api", f"{ticker} Market Data"),
|
||||
("news_api", f"{ticker} News"),
|
||||
]:
|
||||
try:
|
||||
resp = await client.post(
|
||||
f"{base}/companies/{company_id}/sources",
|
||||
json={
|
||||
"source_type": source_type,
|
||||
"source_name": source_name,
|
||||
},
|
||||
)
|
||||
if resp.status_code == 201:
|
||||
logger.info(
|
||||
"Created %s source for %s (company_id=%s)",
|
||||
source_type,
|
||||
ticker,
|
||||
company_id,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to create %s source for %s: HTTP %d",
|
||||
source_type,
|
||||
ticker,
|
||||
resp.status_code,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Exception creating %s source for %s — skipping",
|
||||
source_type,
|
||||
ticker,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 4. Add to watchlist (best-effort)
|
||||
# Requirement 4.3: first active watchlist, or create
|
||||
# "Manual Overrides" if none exist
|
||||
# ------------------------------------------------------------------
|
||||
try:
|
||||
resp = await client.get(f"{base}/watchlists")
|
||||
watchlists = resp.json() if resp.status_code == 200 else []
|
||||
|
||||
active_watchlists = [w for w in watchlists if w.get("active", False)]
|
||||
|
||||
if active_watchlists:
|
||||
watchlist_id = str(active_watchlists[0]["id"])
|
||||
else:
|
||||
# Create "Manual Overrides" watchlist
|
||||
create_resp = await client.post(
|
||||
f"{base}/watchlists",
|
||||
json={
|
||||
"name": "Manual Overrides",
|
||||
"description": "Auto-created watchlist for manually traded symbols",
|
||||
},
|
||||
)
|
||||
if create_resp.status_code == 201:
|
||||
watchlist_id = str(create_resp.json()["id"])
|
||||
logger.info("Created 'Manual Overrides' watchlist (id=%s)", watchlist_id)
|
||||
elif create_resp.status_code == 409:
|
||||
# Watchlist already exists — find it in the list
|
||||
resp2 = await client.get(f"{base}/watchlists")
|
||||
wl_list = resp2.json() if resp2.status_code == 200 else []
|
||||
manual_wl = next(
|
||||
(w for w in wl_list if w.get("name") == "Manual Overrides"),
|
||||
None,
|
||||
)
|
||||
if manual_wl:
|
||||
watchlist_id = str(manual_wl["id"])
|
||||
else:
|
||||
logger.warning("Could not find 'Manual Overrides' watchlist after 409")
|
||||
watchlist_id = None
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to create 'Manual Overrides' watchlist: HTTP %d",
|
||||
create_resp.status_code,
|
||||
)
|
||||
watchlist_id = None
|
||||
|
||||
if watchlist_id:
|
||||
member_resp = await client.post(
|
||||
f"{base}/watchlists/{watchlist_id}/members/{company_id}",
|
||||
)
|
||||
if member_resp.status_code == 201:
|
||||
logger.info(
|
||||
"Added %s to watchlist %s",
|
||||
ticker,
|
||||
watchlist_id,
|
||||
)
|
||||
elif member_resp.status_code == 409:
|
||||
logger.info(
|
||||
"%s already a member of watchlist %s",
|
||||
ticker,
|
||||
watchlist_id,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Failed to add %s to watchlist %s: HTTP %d",
|
||||
ticker,
|
||||
watchlist_id,
|
||||
member_resp.status_code,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Exception during watchlist operations for %s — skipping",
|
||||
ticker,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
return (True, company_id)
|
||||
|
||||
|
||||
async def _fetch_company_id_by_ticker(
|
||||
client: httpx.AsyncClient,
|
||||
base: str,
|
||||
ticker: str,
|
||||
) -> str | None:
|
||||
"""Fetch the company ID for a ticker from the Symbol Registry."""
|
||||
try:
|
||||
resp = await client.get(f"{base}/companies", params={"active": "true"})
|
||||
if resp.status_code == 200:
|
||||
for company in resp.json():
|
||||
if company.get("ticker") == ticker:
|
||||
return str(company["id"])
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("Failed to fetch company by ticker %s: %s", ticker, exc)
|
||||
return None
|
||||
@@ -0,0 +1,812 @@
|
||||
"""Unit tests for auto_register_symbol() in services/trading/override.py.
|
||||
|
||||
Validates:
|
||||
- New symbol registration (company + sources + watchlist)
|
||||
- Existing symbol skip
|
||||
- 409 conflict handling
|
||||
- Source/watchlist failure tolerance
|
||||
|
||||
Requirements: 4.1, 4.2, 4.3, 4.5, 4.6
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from contextlib import asynccontextmanager
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from services.trading.override import auto_register_symbol
|
||||
|
||||
REGISTRY_BASE = "http://symbol-registry:8000"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _json_response(status_code: int, json_data) -> httpx.Response:
|
||||
"""Build an httpx.Response with JSON body for MockTransport."""
|
||||
return httpx.Response(
|
||||
status_code,
|
||||
content=json.dumps(json_data).encode(),
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
|
||||
|
||||
def _text_response(status_code: int, text: str = "") -> httpx.Response:
|
||||
"""Build an httpx.Response with text body for MockTransport."""
|
||||
return httpx.Response(status_code, text=text)
|
||||
|
||||
|
||||
def _patched_client(handler):
|
||||
"""Return a patch context manager that replaces httpx.AsyncClient with a mock-transport client.
|
||||
|
||||
The handler is a sync callable: ``(request: httpx.Request) -> httpx.Response``.
|
||||
"""
|
||||
original_init = httpx.AsyncClient.__init__
|
||||
|
||||
def patched_init(self, *args, **kwargs):
|
||||
kwargs.pop("timeout", None)
|
||||
kwargs["transport"] = httpx.MockTransport(handler)
|
||||
original_init(self, *args, **kwargs)
|
||||
|
||||
return patch.object(httpx.AsyncClient, "__init__", patched_init)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. New symbol registration — full happy path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestAutoRegisterNewSymbol:
|
||||
"""Requirement 4.1, 4.2, 4.3: New symbol creates company, sources, watchlist membership."""
|
||||
|
||||
async def test_new_symbol_full_registration(self):
|
||||
"""GET /companies returns empty → POST /companies 201 → sources → watchlist → (True, id)."""
|
||||
company_id = "aaaaaaaa-1111-2222-3333-444444444444"
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
url = str(request.url)
|
||||
method = request.method
|
||||
|
||||
if method == "GET" and "/companies" in url and "/watchlists" not in url:
|
||||
return _json_response(200, [])
|
||||
|
||||
if method == "POST" and url.endswith("/companies"):
|
||||
return _json_response(201, {"id": company_id, "ticker": "TSLA"})
|
||||
|
||||
if method == "POST" and "/sources" in url:
|
||||
return _json_response(201, {"id": "src-1"})
|
||||
|
||||
if method == "GET" and "/watchlists" in url:
|
||||
return _json_response(200, [{"id": "wl-1", "name": "Default", "active": True}])
|
||||
|
||||
if method == "POST" and "/members/" in url:
|
||||
return _json_response(201, {})
|
||||
|
||||
return _text_response(404)
|
||||
|
||||
with _patched_client(handler):
|
||||
auto_registered, cid = await auto_register_symbol("TSLA", REGISTRY_BASE)
|
||||
|
||||
assert auto_registered is True
|
||||
assert cid == company_id
|
||||
|
||||
async def test_new_symbol_creates_watchlist_when_none_exist(self):
|
||||
"""When no active watchlists exist, creates 'Manual Overrides' watchlist."""
|
||||
company_id = "bbbbbbbb-1111-2222-3333-444444444444"
|
||||
watchlist_id = "wl-new-1"
|
||||
call_log: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
url = str(request.url)
|
||||
method = request.method
|
||||
call_log.append(f"{method} {url}")
|
||||
|
||||
if method == "GET" and "/companies" in url and "/watchlists" not in url:
|
||||
return _json_response(200, [])
|
||||
|
||||
if method == "POST" and url.endswith("/companies"):
|
||||
return _json_response(201, {"id": company_id, "ticker": "NVDA"})
|
||||
|
||||
if method == "POST" and "/sources" in url:
|
||||
return _json_response(201, {"id": "src-1"})
|
||||
|
||||
if method == "GET" and "/watchlists" in url:
|
||||
return _json_response(200, []) # No active watchlists
|
||||
|
||||
if method == "POST" and url.endswith("/watchlists"):
|
||||
return _json_response(201, {"id": watchlist_id, "name": "Manual Overrides"})
|
||||
|
||||
if method == "POST" and "/members/" in url:
|
||||
return _json_response(201, {})
|
||||
|
||||
return _text_response(404)
|
||||
|
||||
with _patched_client(handler):
|
||||
auto_registered, cid = await auto_register_symbol("NVDA", REGISTRY_BASE)
|
||||
|
||||
assert auto_registered is True
|
||||
assert cid == company_id
|
||||
assert any("POST" in c and "/watchlists" in c and "/members" not in c for c in call_log)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Existing symbol skip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestAutoRegisterExistingSymbol:
|
||||
"""Requirement 4.5: Existing symbols skip registration."""
|
||||
|
||||
async def test_existing_symbol_returns_false(self):
|
||||
"""GET /companies returns list containing the ticker → (False, company_id)."""
|
||||
existing_id = "cccccccc-1111-2222-3333-444444444444"
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
url = str(request.url)
|
||||
if request.method == "GET" and "/companies" in url:
|
||||
return _json_response(200, [
|
||||
{"id": existing_id, "ticker": "AAPL", "legal_name": "Apple Inc"},
|
||||
{"id": "other-id", "ticker": "MSFT", "legal_name": "Microsoft"},
|
||||
])
|
||||
return _text_response(404)
|
||||
|
||||
with _patched_client(handler):
|
||||
auto_registered, cid = await auto_register_symbol("AAPL", REGISTRY_BASE)
|
||||
|
||||
assert auto_registered is False
|
||||
assert cid == existing_id
|
||||
|
||||
async def test_existing_symbol_no_post_calls(self):
|
||||
"""When ticker exists, no POST calls should be made."""
|
||||
existing_id = "dddddddd-1111-2222-3333-444444444444"
|
||||
post_calls: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
url = str(request.url)
|
||||
if request.method == "POST":
|
||||
post_calls.append(url)
|
||||
if request.method == "GET" and "/companies" in url:
|
||||
return _json_response(200, [{"id": existing_id, "ticker": "GOOG"}])
|
||||
return _text_response(404)
|
||||
|
||||
with _patched_client(handler):
|
||||
await auto_register_symbol("GOOG", REGISTRY_BASE)
|
||||
|
||||
assert post_calls == [], f"Unexpected POST calls: {post_calls}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. 409 conflict handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestAutoRegister409Conflict:
|
||||
"""Requirement 4.6: 409 conflict treated as success."""
|
||||
|
||||
async def test_409_conflict_fetches_existing_company(self):
|
||||
"""POST /companies 409 → fetches existing company → returns with company_id."""
|
||||
existing_id = "eeeeeeee-1111-2222-3333-444444444444"
|
||||
get_call_count = 0
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal get_call_count
|
||||
url = str(request.url)
|
||||
method = request.method
|
||||
|
||||
if method == "GET" and "/companies" in url and "/watchlists" not in url:
|
||||
get_call_count += 1
|
||||
if get_call_count == 1:
|
||||
return _json_response(200, [])
|
||||
else:
|
||||
return _json_response(200, [{"id": existing_id, "ticker": "AMD"}])
|
||||
|
||||
if method == "POST" and url.endswith("/companies"):
|
||||
return _json_response(409, {"detail": "Company already exists"})
|
||||
|
||||
if method == "POST" and "/sources" in url:
|
||||
return _json_response(201, {"id": "src-1"})
|
||||
|
||||
if method == "GET" and "/watchlists" in url:
|
||||
return _json_response(200, [{"id": "wl-1", "active": True}])
|
||||
|
||||
if method == "POST" and "/members/" in url:
|
||||
return _json_response(201, {})
|
||||
|
||||
return _text_response(404)
|
||||
|
||||
with _patched_client(handler):
|
||||
auto_registered, cid = await auto_register_symbol("AMD", REGISTRY_BASE)
|
||||
|
||||
assert auto_registered is True
|
||||
assert cid == existing_id
|
||||
|
||||
async def test_409_conflict_but_cannot_find_company_returns_false(self):
|
||||
"""POST /companies 409 but re-fetch can't find company → (False, '')."""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
url = str(request.url)
|
||||
|
||||
if request.method == "GET" and "/companies" in url:
|
||||
return _json_response(200, [])
|
||||
|
||||
if request.method == "POST" and url.endswith("/companies"):
|
||||
return _json_response(409, {"detail": "Conflict"})
|
||||
|
||||
return _text_response(404)
|
||||
|
||||
with _patched_client(handler):
|
||||
auto_registered, cid = await auto_register_symbol("XYZ", REGISTRY_BASE)
|
||||
|
||||
assert auto_registered is False
|
||||
assert cid == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Source creation failure tolerance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestSourceFailureTolerance:
|
||||
"""Source creation failures are best-effort — should not block registration."""
|
||||
|
||||
async def test_source_creation_500_still_succeeds(self):
|
||||
"""POST /companies/{id}/sources returns 500 → still returns (True, company_id)."""
|
||||
company_id = "ffffffff-1111-2222-3333-444444444444"
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
url = str(request.url)
|
||||
method = request.method
|
||||
|
||||
if method == "GET" and "/companies" in url and "/watchlists" not in url:
|
||||
return _json_response(200, [])
|
||||
|
||||
if method == "POST" and url.endswith("/companies"):
|
||||
return _json_response(201, {"id": company_id, "ticker": "PLTR"})
|
||||
|
||||
if method == "POST" and "/sources" in url:
|
||||
return _text_response(500, "Internal Server Error")
|
||||
|
||||
if method == "GET" and "/watchlists" in url:
|
||||
return _json_response(200, [{"id": "wl-1", "active": True}])
|
||||
|
||||
if method == "POST" and "/members/" in url:
|
||||
return _json_response(201, {})
|
||||
|
||||
return _text_response(404)
|
||||
|
||||
with _patched_client(handler):
|
||||
auto_registered, cid = await auto_register_symbol("PLTR", REGISTRY_BASE)
|
||||
|
||||
assert auto_registered is True
|
||||
assert cid == company_id
|
||||
|
||||
async def test_source_creation_network_error_still_succeeds(self):
|
||||
"""Source creation raises network error → still returns successfully."""
|
||||
company_id = "11111111-aaaa-bbbb-cccc-dddddddddddd"
|
||||
source_call_count = 0
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
nonlocal source_call_count
|
||||
url = str(request.url)
|
||||
method = request.method
|
||||
|
||||
if method == "GET" and "/companies" in url and "/watchlists" not in url:
|
||||
return _json_response(200, [])
|
||||
|
||||
if method == "POST" and url.endswith("/companies"):
|
||||
return _json_response(201, {"id": company_id, "ticker": "RIVN"})
|
||||
|
||||
if method == "POST" and "/sources" in url:
|
||||
source_call_count += 1
|
||||
raise httpx.ConnectError("connection refused")
|
||||
|
||||
if method == "GET" and "/watchlists" in url:
|
||||
return _json_response(200, [{"id": "wl-1", "active": True}])
|
||||
|
||||
if method == "POST" and "/members/" in url:
|
||||
return _json_response(201, {})
|
||||
|
||||
return _text_response(404)
|
||||
|
||||
with _patched_client(handler):
|
||||
auto_registered, cid = await auto_register_symbol("RIVN", REGISTRY_BASE)
|
||||
|
||||
assert auto_registered is True
|
||||
assert cid == company_id
|
||||
assert source_call_count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Watchlist failure tolerance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestWatchlistFailureTolerance:
|
||||
"""Watchlist failures are best-effort — should not block registration."""
|
||||
|
||||
async def test_watchlist_get_fails_still_succeeds(self):
|
||||
"""GET /watchlists raises exception → still returns (True, company_id)."""
|
||||
company_id = "22222222-aaaa-bbbb-cccc-dddddddddddd"
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
url = str(request.url)
|
||||
method = request.method
|
||||
|
||||
if method == "GET" and "/companies" in url and "/watchlists" not in url:
|
||||
return _json_response(200, [])
|
||||
|
||||
if method == "POST" and url.endswith("/companies"):
|
||||
return _json_response(201, {"id": company_id, "ticker": "SOFI"})
|
||||
|
||||
if method == "POST" and "/sources" in url:
|
||||
return _json_response(201, {"id": "src-1"})
|
||||
|
||||
if method == "GET" and "/watchlists" in url:
|
||||
raise httpx.ConnectError("connection refused")
|
||||
|
||||
return _text_response(404)
|
||||
|
||||
with _patched_client(handler):
|
||||
auto_registered, cid = await auto_register_symbol("SOFI", REGISTRY_BASE)
|
||||
|
||||
assert auto_registered is True
|
||||
assert cid == company_id
|
||||
|
||||
async def test_watchlist_member_add_fails_still_succeeds(self):
|
||||
"""POST /watchlists/{id}/members/{cid} returns 500 → still succeeds."""
|
||||
company_id = "33333333-aaaa-bbbb-cccc-dddddddddddd"
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
url = str(request.url)
|
||||
method = request.method
|
||||
|
||||
if method == "GET" and "/companies" in url and "/watchlists" not in url:
|
||||
return _json_response(200, [])
|
||||
|
||||
if method == "POST" and url.endswith("/companies"):
|
||||
return _json_response(201, {"id": company_id, "ticker": "COIN"})
|
||||
|
||||
if method == "POST" and "/sources" in url:
|
||||
return _json_response(201, {"id": "src-1"})
|
||||
|
||||
if method == "GET" and "/watchlists" in url:
|
||||
return _json_response(200, [{"id": "wl-1", "active": True}])
|
||||
|
||||
if method == "POST" and "/members/" in url:
|
||||
return _text_response(500, "Internal Server Error")
|
||||
|
||||
return _text_response(404)
|
||||
|
||||
with _patched_client(handler):
|
||||
auto_registered, cid = await auto_register_symbol("COIN", REGISTRY_BASE)
|
||||
|
||||
assert auto_registered is True
|
||||
assert cid == company_id
|
||||
|
||||
async def test_ticker_is_uppercased(self):
|
||||
"""Lowercase ticker input is normalized to uppercase."""
|
||||
company_id = "44444444-aaaa-bbbb-cccc-dddddddddddd"
|
||||
received_tickers: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
url = str(request.url)
|
||||
method = request.method
|
||||
|
||||
if method == "GET" and "/companies" in url and "/watchlists" not in url:
|
||||
return _json_response(200, [])
|
||||
|
||||
if method == "POST" and url.endswith("/companies"):
|
||||
body = json.loads(request.content)
|
||||
received_tickers.append(body.get("ticker", ""))
|
||||
return _json_response(201, {"id": company_id, "ticker": body.get("ticker", "")})
|
||||
|
||||
if method == "POST" and "/sources" in url:
|
||||
return _json_response(201, {"id": "src-1"})
|
||||
|
||||
if method == "GET" and "/watchlists" in url:
|
||||
return _json_response(200, [{"id": "wl-1", "active": True}])
|
||||
|
||||
if method == "POST" and "/members/" in url:
|
||||
return _json_response(201, {})
|
||||
|
||||
return _text_response(404)
|
||||
|
||||
with _patched_client(handler):
|
||||
auto_registered, cid = await auto_register_symbol("tsla", REGISTRY_BASE)
|
||||
|
||||
assert auto_registered is True
|
||||
assert received_tickers == ["TSLA"]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Override Endpoint Tests (Task 3.1)
|
||||
# ===========================================================================
|
||||
#
|
||||
# Unit tests for POST /api/trading/override/order endpoint.
|
||||
# Requirements: 3.1, 3.2, 3.4, 3.5, 9.1
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch as _patch
|
||||
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
import services.trading.app as _app_module
|
||||
from services.trading.app import app
|
||||
|
||||
|
||||
def _make_fake_engine(redis_rpush_side_effect=None):
|
||||
"""Create a fake engine object with a mock Redis client."""
|
||||
fake_engine = MagicMock()
|
||||
fake_engine.running = True
|
||||
fake_engine.redis = AsyncMock()
|
||||
fake_engine.redis.rpush = AsyncMock(side_effect=redis_rpush_side_effect)
|
||||
return fake_engine
|
||||
|
||||
|
||||
def _override_client(fake_engine):
|
||||
"""Return a TestClient with the module-level engine replaced."""
|
||||
original = _app_module.engine
|
||||
_app_module.engine = fake_engine
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
return client, original
|
||||
|
||||
|
||||
def _restore_engine(original):
|
||||
_app_module.engine = original
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Valid order returns 202 with correct response shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOverrideEndpointValid:
|
||||
"""Requirement 3.1, 3.4: Valid order returns 202 with correct response shape."""
|
||||
|
||||
def test_valid_market_order_returns_202(self):
|
||||
"""A valid market order returns 202 with job_id, status, ticker, side, quantity, auto_registered."""
|
||||
fake_engine = _make_fake_engine()
|
||||
client, original = _override_client(fake_engine)
|
||||
try:
|
||||
with _patch.object(_app_module, "auto_register_symbol", new_callable=AsyncMock, return_value=(False, "comp-1")):
|
||||
with _patched_client(lambda req: _json_response(200, [{"id": "comp-1", "ticker": "AAPL"}])):
|
||||
resp = client.post(
|
||||
"/api/trading/override/order",
|
||||
json={
|
||||
"ticker": "AAPL",
|
||||
"side": "buy",
|
||||
"quantity": 10.0,
|
||||
"order_type": "market",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 202
|
||||
data = resp.json()
|
||||
assert "job_id" in data
|
||||
assert data["status"] == "queued"
|
||||
assert data["ticker"] == "AAPL"
|
||||
assert data["side"] == "buy"
|
||||
assert data["quantity"] == 10.0
|
||||
assert isinstance(data["auto_registered"], bool)
|
||||
assert data["job_id"].startswith("override-")
|
||||
finally:
|
||||
_restore_engine(original)
|
||||
|
||||
def test_valid_limit_order_returns_202(self):
|
||||
"""A valid limit order with limit_price returns 202."""
|
||||
fake_engine = _make_fake_engine()
|
||||
client, original = _override_client(fake_engine)
|
||||
try:
|
||||
with _patch.object(_app_module, "auto_register_symbol", new_callable=AsyncMock, return_value=(False, "comp-1")):
|
||||
with _patched_client(lambda req: _json_response(200, [{"id": "comp-1", "ticker": "TSLA"}])):
|
||||
resp = client.post(
|
||||
"/api/trading/override/order",
|
||||
json={
|
||||
"ticker": "TSLA",
|
||||
"side": "sell",
|
||||
"quantity": 5.0,
|
||||
"order_type": "limit",
|
||||
"limit_price": 150.0,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 202
|
||||
data = resp.json()
|
||||
assert data["ticker"] == "TSLA"
|
||||
assert data["side"] == "sell"
|
||||
finally:
|
||||
_restore_engine(original)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Invalid ticker returns 422
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOverrideEndpointInvalidTicker:
|
||||
"""Requirement 3.5: Invalid ticker format returns 422."""
|
||||
|
||||
def test_numeric_ticker_returns_422(self):
|
||||
fake_engine = _make_fake_engine()
|
||||
client, original = _override_client(fake_engine)
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/trading/override/order",
|
||||
json={"ticker": "123", "side": "buy", "quantity": 1.0},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
finally:
|
||||
_restore_engine(original)
|
||||
|
||||
def test_empty_ticker_returns_422(self):
|
||||
fake_engine = _make_fake_engine()
|
||||
client, original = _override_client(fake_engine)
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/trading/override/order",
|
||||
json={"ticker": "", "side": "buy", "quantity": 1.0},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
finally:
|
||||
_restore_engine(original)
|
||||
|
||||
def test_ticker_with_spaces_returns_422(self):
|
||||
fake_engine = _make_fake_engine()
|
||||
client, original = _override_client(fake_engine)
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/trading/override/order",
|
||||
json={"ticker": "AA BB", "side": "buy", "quantity": 1.0},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
finally:
|
||||
_restore_engine(original)
|
||||
|
||||
def test_ticker_too_long_returns_422(self):
|
||||
fake_engine = _make_fake_engine()
|
||||
client, original = _override_client(fake_engine)
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/trading/override/order",
|
||||
json={"ticker": "ABCDEFGHIJK", "side": "buy", "quantity": 1.0},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
finally:
|
||||
_restore_engine(original)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Missing limit_price for limit order returns 422
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOverrideEndpointMissingLimitPrice:
|
||||
"""Requirement 3.5: Missing limit_price for limit/stop_limit orders returns 422."""
|
||||
|
||||
def test_limit_order_without_limit_price_returns_422(self):
|
||||
fake_engine = _make_fake_engine()
|
||||
client, original = _override_client(fake_engine)
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/trading/override/order",
|
||||
json={
|
||||
"ticker": "AAPL",
|
||||
"side": "buy",
|
||||
"quantity": 10.0,
|
||||
"order_type": "limit",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
finally:
|
||||
_restore_engine(original)
|
||||
|
||||
def test_stop_limit_order_without_limit_price_returns_422(self):
|
||||
fake_engine = _make_fake_engine()
|
||||
client, original = _override_client(fake_engine)
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/trading/override/order",
|
||||
json={
|
||||
"ticker": "AAPL",
|
||||
"side": "buy",
|
||||
"quantity": 10.0,
|
||||
"order_type": "stop_limit",
|
||||
"stop_price": 100.0,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
finally:
|
||||
_restore_engine(original)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. Missing stop_price for stop order returns 422
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOverrideEndpointMissingStopPrice:
|
||||
"""Requirement 3.5: Missing stop_price for stop/stop_limit orders returns 422."""
|
||||
|
||||
def test_stop_order_without_stop_price_returns_422(self):
|
||||
fake_engine = _make_fake_engine()
|
||||
client, original = _override_client(fake_engine)
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/trading/override/order",
|
||||
json={
|
||||
"ticker": "AAPL",
|
||||
"side": "buy",
|
||||
"quantity": 10.0,
|
||||
"order_type": "stop",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
finally:
|
||||
_restore_engine(original)
|
||||
|
||||
def test_stop_limit_order_without_stop_price_returns_422(self):
|
||||
fake_engine = _make_fake_engine()
|
||||
client, original = _override_client(fake_engine)
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/trading/override/order",
|
||||
json={
|
||||
"ticker": "AAPL",
|
||||
"side": "buy",
|
||||
"quantity": 10.0,
|
||||
"order_type": "stop_limit",
|
||||
"limit_price": 150.0,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
finally:
|
||||
_restore_engine(original)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10. Non-positive quantity returns 422
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOverrideEndpointNonPositiveQuantity:
|
||||
"""Requirement 3.5: Non-positive quantity returns 422."""
|
||||
|
||||
def test_zero_quantity_returns_422(self):
|
||||
fake_engine = _make_fake_engine()
|
||||
client, original = _override_client(fake_engine)
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/trading/override/order",
|
||||
json={"ticker": "AAPL", "side": "buy", "quantity": 0.0},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
finally:
|
||||
_restore_engine(original)
|
||||
|
||||
def test_negative_quantity_returns_422(self):
|
||||
fake_engine = _make_fake_engine()
|
||||
client, original = _override_client(fake_engine)
|
||||
try:
|
||||
resp = client.post(
|
||||
"/api/trading/override/order",
|
||||
json={"ticker": "AAPL", "side": "buy", "quantity": -5.0},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
finally:
|
||||
_restore_engine(original)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 11. Enqueued job has correct structure and source: "manual_override"
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOverrideEndpointJobStructure:
|
||||
"""Requirement 3.2, 9.1: Enqueued job has correct structure and source marker."""
|
||||
|
||||
def test_enqueued_job_has_correct_fields(self):
|
||||
"""The JSON payload RPUSH'd to Redis contains all required fields."""
|
||||
captured_payloads: list[str] = []
|
||||
|
||||
async def capture_rpush(key, value):
|
||||
captured_payloads.append(value)
|
||||
|
||||
fake_engine = _make_fake_engine()
|
||||
fake_engine.redis.rpush = AsyncMock(side_effect=capture_rpush)
|
||||
client, original = _override_client(fake_engine)
|
||||
try:
|
||||
with _patch.object(_app_module, "auto_register_symbol", new_callable=AsyncMock, return_value=(False, "comp-1")):
|
||||
with _patched_client(lambda req: _json_response(200, [{"id": "comp-1", "ticker": "MSFT"}])):
|
||||
resp = client.post(
|
||||
"/api/trading/override/order",
|
||||
json={
|
||||
"ticker": "MSFT",
|
||||
"side": "buy",
|
||||
"quantity": 25.0,
|
||||
"order_type": "market",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 202
|
||||
assert len(captured_payloads) == 1
|
||||
|
||||
payload = json.loads(captured_payloads[0])
|
||||
assert payload["ticker"] == "MSFT"
|
||||
assert payload["side"] == "buy"
|
||||
assert payload["quantity"] == 25.0
|
||||
assert payload["order_type"] == "market"
|
||||
assert payload["source"] == "manual_override"
|
||||
assert payload["idempotency_key"].startswith("override-")
|
||||
assert payload["limit_price"] is None
|
||||
assert payload["stop_price"] is None
|
||||
finally:
|
||||
_restore_engine(original)
|
||||
|
||||
def test_enqueued_job_includes_limit_and_stop_prices(self):
|
||||
"""When limit_price and stop_price are provided, they appear in the payload."""
|
||||
captured_payloads: list[str] = []
|
||||
|
||||
async def capture_rpush(key, value):
|
||||
captured_payloads.append(value)
|
||||
|
||||
fake_engine = _make_fake_engine()
|
||||
fake_engine.redis.rpush = AsyncMock(side_effect=capture_rpush)
|
||||
client, original = _override_client(fake_engine)
|
||||
try:
|
||||
with _patch.object(_app_module, "auto_register_symbol", new_callable=AsyncMock, return_value=(True, "comp-2")):
|
||||
with _patched_client(lambda req: _json_response(200, [])):
|
||||
resp = client.post(
|
||||
"/api/trading/override/order",
|
||||
json={
|
||||
"ticker": "GOOG",
|
||||
"side": "sell",
|
||||
"quantity": 3.0,
|
||||
"order_type": "stop_limit",
|
||||
"limit_price": 140.0,
|
||||
"stop_price": 135.0,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 202
|
||||
assert len(captured_payloads) == 1
|
||||
|
||||
payload = json.loads(captured_payloads[0])
|
||||
assert payload["source"] == "manual_override"
|
||||
assert payload["limit_price"] == 140.0
|
||||
assert payload["stop_price"] == 135.0
|
||||
finally:
|
||||
_restore_engine(original)
|
||||
|
||||
def test_job_id_matches_response(self):
|
||||
"""The job_id in the response matches the idempotency_key in the enqueued payload."""
|
||||
captured_payloads: list[str] = []
|
||||
|
||||
async def capture_rpush(key, value):
|
||||
captured_payloads.append(value)
|
||||
|
||||
fake_engine = _make_fake_engine()
|
||||
fake_engine.redis.rpush = AsyncMock(side_effect=capture_rpush)
|
||||
client, original = _override_client(fake_engine)
|
||||
try:
|
||||
with _patch.object(_app_module, "auto_register_symbol", new_callable=AsyncMock, return_value=(False, "comp-1")):
|
||||
with _patched_client(lambda req: _json_response(200, [{"id": "comp-1", "ticker": "AMD"}])):
|
||||
resp = client.post(
|
||||
"/api/trading/override/order",
|
||||
json={
|
||||
"ticker": "AMD",
|
||||
"side": "buy",
|
||||
"quantity": 1.0,
|
||||
"order_type": "market",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 202
|
||||
data = resp.json()
|
||||
payload = json.loads(captured_payloads[0])
|
||||
assert data["job_id"] == payload["idempotency_key"]
|
||||
finally:
|
||||
_restore_engine(original)
|
||||
@@ -0,0 +1,334 @@
|
||||
"""Property-based tests for the Override Trade Tab.
|
||||
|
||||
Feature: override-trade-tab
|
||||
|
||||
Property 1: Ticker validation and normalization
|
||||
Property 2: Override job payload completeness
|
||||
Property 3: Invalid override order rejection
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
from pydantic import ValidationError
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
import services.trading.app as _app_module
|
||||
from services.trading.app import OverrideOrderRequest, app
|
||||
|
||||
TICKER_PATTERN = re.compile(r"^[A-Z]{1,10}$")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_fake_engine(redis_rpush_side_effect=None):
|
||||
"""Create a fake engine object with a mock Redis client."""
|
||||
fake_engine = MagicMock()
|
||||
fake_engine.running = True
|
||||
fake_engine.redis = AsyncMock()
|
||||
fake_engine.redis.rpush = AsyncMock(side_effect=redis_rpush_side_effect)
|
||||
return fake_engine
|
||||
|
||||
|
||||
def _override_client(fake_engine):
|
||||
"""Return a TestClient with the module-level engine replaced."""
|
||||
original = _app_module.engine
|
||||
_app_module.engine = fake_engine
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
return client, original
|
||||
|
||||
|
||||
def _restore_engine(original):
|
||||
_app_module.engine = original
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Strategies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Strategy for valid tickers: 1-10 uppercase alpha characters
|
||||
valid_ticker_st = st.from_regex(r"[A-Za-z]{1,10}", fullmatch=True)
|
||||
|
||||
# Strategy for valid sides
|
||||
valid_side_st = st.sampled_from(["buy", "sell"])
|
||||
|
||||
# Strategy for valid positive quantities
|
||||
valid_quantity_st = st.floats(min_value=0.01, max_value=1_000_000.0, allow_nan=False, allow_infinity=False)
|
||||
|
||||
# Strategy for valid order types
|
||||
valid_order_type_st = st.sampled_from(["market", "limit", "stop", "stop_limit"])
|
||||
|
||||
# Strategy for valid positive prices
|
||||
valid_price_st = st.floats(min_value=0.01, max_value=1_000_000.0, allow_nan=False, allow_infinity=False)
|
||||
|
||||
|
||||
@st.composite
|
||||
def valid_override_order_st(draw):
|
||||
"""Generate a valid override order request dict."""
|
||||
ticker = draw(valid_ticker_st)
|
||||
side = draw(valid_side_st)
|
||||
quantity = draw(valid_quantity_st)
|
||||
order_type = draw(valid_order_type_st)
|
||||
|
||||
order = {
|
||||
"ticker": ticker,
|
||||
"side": side,
|
||||
"quantity": quantity,
|
||||
"order_type": order_type,
|
||||
}
|
||||
|
||||
if order_type in ("limit", "stop_limit"):
|
||||
order["limit_price"] = draw(valid_price_st)
|
||||
if order_type in ("stop", "stop_limit"):
|
||||
order["stop_price"] = draw(valid_price_st)
|
||||
|
||||
return order
|
||||
|
||||
|
||||
@st.composite
|
||||
def invalid_override_order_st(draw):
|
||||
"""Generate an override order request that violates at least one validation rule.
|
||||
|
||||
Possible violations:
|
||||
- Invalid ticker format (digits, spaces, special chars, empty, too long)
|
||||
- Non-positive quantity (zero or negative)
|
||||
- Missing limit_price for limit/stop_limit orders
|
||||
- Missing stop_price for stop/stop_limit orders
|
||||
"""
|
||||
violation = draw(st.sampled_from([
|
||||
"bad_ticker",
|
||||
"non_positive_quantity",
|
||||
"missing_limit_price",
|
||||
"missing_stop_price",
|
||||
]))
|
||||
|
||||
if violation == "bad_ticker":
|
||||
# Generate a ticker that does NOT match ^[A-Z]{1,10}$ after uppercasing
|
||||
bad_ticker = draw(st.sampled_from([
|
||||
"", # empty
|
||||
"ABCDEFGHIJK", # 11 chars — too long
|
||||
draw(st.from_regex(r"[0-9]{1,5}", fullmatch=True)), # digits
|
||||
draw(st.from_regex(r"[A-Z]{1,5} [A-Z]{1,5}", fullmatch=True)), # spaces
|
||||
draw(st.from_regex(r"[A-Z]{1,5}[^A-Za-z0-9]", fullmatch=True)), # special char
|
||||
]))
|
||||
return {
|
||||
"ticker": bad_ticker,
|
||||
"side": "buy",
|
||||
"quantity": 10.0,
|
||||
"order_type": "market",
|
||||
}
|
||||
|
||||
if violation == "non_positive_quantity":
|
||||
qty = draw(st.floats(min_value=-1_000_000.0, max_value=0.0, allow_nan=False, allow_infinity=False))
|
||||
return {
|
||||
"ticker": "AAPL",
|
||||
"side": "buy",
|
||||
"quantity": qty,
|
||||
"order_type": "market",
|
||||
}
|
||||
|
||||
if violation == "missing_limit_price":
|
||||
order_type = draw(st.sampled_from(["limit", "stop_limit"]))
|
||||
order = {
|
||||
"ticker": "AAPL",
|
||||
"side": "buy",
|
||||
"quantity": 10.0,
|
||||
"order_type": order_type,
|
||||
}
|
||||
# For stop_limit, provide stop_price but NOT limit_price
|
||||
if order_type == "stop_limit":
|
||||
order["stop_price"] = 100.0
|
||||
return order
|
||||
|
||||
# violation == "missing_stop_price"
|
||||
order_type = draw(st.sampled_from(["stop", "stop_limit"]))
|
||||
order = {
|
||||
"ticker": "AAPL",
|
||||
"side": "buy",
|
||||
"quantity": 10.0,
|
||||
"order_type": order_type,
|
||||
}
|
||||
# For stop_limit, provide limit_price but NOT stop_price
|
||||
if order_type == "stop_limit":
|
||||
order["limit_price"] = 150.0
|
||||
return order
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Property 1: Ticker validation and normalization
|
||||
# **Validates: Requirements 2.2, 8.1**
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestProperty1TickerValidationAndNormalization:
|
||||
"""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 ^[A-Z]{1,10}$.
|
||||
The normalized output SHALL always be the uppercased version of the input.
|
||||
|
||||
**Validates: Requirements 2.2, 8.1**
|
||||
"""
|
||||
|
||||
@settings(max_examples=100)
|
||||
@given(ticker=st.text(min_size=0, max_size=20))
|
||||
def test_ticker_accepted_iff_matches_pattern(self, ticker: str) -> None:
|
||||
"""After uppercasing, accepted iff matches ^[A-Z]{1,10}$;
|
||||
normalized output is always uppercased input."""
|
||||
uppercased = ticker.upper()
|
||||
should_accept = bool(TICKER_PATTERN.match(uppercased))
|
||||
|
||||
try:
|
||||
req = OverrideOrderRequest(
|
||||
ticker=ticker,
|
||||
side="buy",
|
||||
quantity=1.0,
|
||||
order_type="market",
|
||||
)
|
||||
# Accepted — verify it should have been accepted
|
||||
assert should_accept, (
|
||||
f"Ticker {ticker!r} was accepted but uppercased form "
|
||||
f"{uppercased!r} does not match ^[A-Z]{{1,10}}$"
|
||||
)
|
||||
# Normalized output is always uppercased
|
||||
assert req.ticker == uppercased, (
|
||||
f"Expected normalized ticker {uppercased!r}, got {req.ticker!r}"
|
||||
)
|
||||
except ValidationError:
|
||||
# Rejected — verify it should have been rejected
|
||||
assert not should_accept, (
|
||||
f"Ticker {ticker!r} was rejected but uppercased form "
|
||||
f"{uppercased!r} matches ^[A-Z]{{1,10}}$"
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Property 2: Override job payload completeness
|
||||
# **Validates: Requirements 3.2, 9.1**
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestProperty2OverrideJobPayloadCompleteness:
|
||||
"""Property 2: Override job payload completeness.
|
||||
|
||||
For any valid override order request, the job payload enqueued to the
|
||||
broker queue SHALL contain all required fields, source == "manual_override",
|
||||
and idempotency_key starts with "override-".
|
||||
|
||||
**Validates: Requirements 3.2, 9.1**
|
||||
"""
|
||||
|
||||
@settings(max_examples=100)
|
||||
@given(order=valid_override_order_st())
|
||||
def test_enqueued_payload_has_all_required_fields(self, order: dict) -> None:
|
||||
"""Enqueued payload contains all required fields with correct values."""
|
||||
captured_payloads: list[str] = []
|
||||
|
||||
async def capture_rpush(key, value):
|
||||
captured_payloads.append(value)
|
||||
|
||||
fake_engine = _make_fake_engine()
|
||||
fake_engine.redis.rpush = AsyncMock(side_effect=capture_rpush)
|
||||
client, original = _override_client(fake_engine)
|
||||
|
||||
try:
|
||||
with patch.object(
|
||||
_app_module,
|
||||
"auto_register_symbol",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(False, "comp-1"),
|
||||
):
|
||||
import httpx
|
||||
|
||||
def _mock_handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
200,
|
||||
content=json.dumps([{"id": "comp-1", "ticker": order["ticker"].upper()}]).encode(),
|
||||
headers={"content-type": "application/json"},
|
||||
)
|
||||
|
||||
original_init = httpx.AsyncClient.__init__
|
||||
|
||||
def patched_init(self, *args, **kwargs):
|
||||
kwargs.pop("timeout", None)
|
||||
kwargs["transport"] = httpx.MockTransport(_mock_handler)
|
||||
original_init(self, *args, **kwargs)
|
||||
|
||||
with patch.object(httpx.AsyncClient, "__init__", patched_init):
|
||||
resp = client.post("/api/trading/override/order", json=order)
|
||||
|
||||
assert resp.status_code == 202, (
|
||||
f"Expected 202 for valid order {order!r}, got {resp.status_code}: {resp.text}"
|
||||
)
|
||||
assert len(captured_payloads) == 1, "Expected exactly one RPUSH call"
|
||||
|
||||
payload = json.loads(captured_payloads[0])
|
||||
|
||||
# All required fields present
|
||||
expected_ticker = order["ticker"].upper()
|
||||
assert payload["ticker"] == expected_ticker
|
||||
assert payload["side"] == order["side"]
|
||||
assert payload["quantity"] == order["quantity"]
|
||||
assert payload["order_type"] == order["order_type"]
|
||||
assert payload["source"] == "manual_override"
|
||||
assert isinstance(payload["idempotency_key"], str)
|
||||
assert payload["idempotency_key"].startswith("override-")
|
||||
|
||||
# Conditional price fields
|
||||
if "limit_price" in order:
|
||||
assert payload["limit_price"] == order["limit_price"]
|
||||
if "stop_price" in order:
|
||||
assert payload["stop_price"] == order["stop_price"]
|
||||
finally:
|
||||
_restore_engine(original)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Property 3: Invalid override order rejection
|
||||
# **Validates: Requirements 3.5, 2.6**
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestProperty3InvalidOverrideOrderRejection:
|
||||
"""Property 3: Invalid override order rejection.
|
||||
|
||||
For any override order request that violates at least one validation rule,
|
||||
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**
|
||||
"""
|
||||
|
||||
@settings(max_examples=100)
|
||||
@given(order=invalid_override_order_st())
|
||||
def test_invalid_order_returns_422_with_error_message(self, order: dict) -> None:
|
||||
"""Invalid orders return 422 with at least one descriptive error."""
|
||||
fake_engine = _make_fake_engine()
|
||||
client, original = _override_client(fake_engine)
|
||||
|
||||
try:
|
||||
resp = client.post("/api/trading/override/order", json=order)
|
||||
|
||||
assert resp.status_code == 422, (
|
||||
f"Expected 422 for invalid order {order!r}, got {resp.status_code}: {resp.text}"
|
||||
)
|
||||
|
||||
body = resp.json()
|
||||
# FastAPI returns validation errors in a "detail" field
|
||||
assert "detail" in body, (
|
||||
f"Expected 'detail' in 422 response body, got: {body}"
|
||||
)
|
||||
# At least one error message
|
||||
detail = body["detail"]
|
||||
assert isinstance(detail, list) and len(detail) >= 1, (
|
||||
f"Expected at least one validation error, got: {detail}"
|
||||
)
|
||||
finally:
|
||||
_restore_engine(original)
|
||||
Reference in New Issue
Block a user