913fe8b0b3
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
236 lines
9.2 KiB
Python
236 lines
9.2 KiB
Python
"""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
|