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,
)
+235
View File
@@ -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