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:
+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
|
||||
Reference in New Issue
Block a user