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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user