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

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

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

Steering:
- Updated steering docs for Ubuntu dev machine with nvm/Node 24
This commit is contained in:
Celes Renata
2026-04-17 07:02:30 +00:00
parent 7f67725ec8
commit 913fe8b0b3
18 changed files with 3074 additions and 17 deletions
+150 -3
View File
@@ -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,
)