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
335 lines
12 KiB
Python
335 lines
12 KiB
Python
"""Property-based tests for the Override Trade Tab.
|
|
|
|
Feature: override-trade-tab
|
|
|
|
Property 1: Ticker validation and normalization
|
|
Property 2: Override job payload completeness
|
|
Property 3: Invalid override order rejection
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from hypothesis import given, settings
|
|
from hypothesis import strategies as st
|
|
from pydantic import ValidationError
|
|
from starlette.testclient import TestClient
|
|
|
|
import services.trading.app as _app_module
|
|
from services.trading.app import OverrideOrderRequest, app
|
|
|
|
TICKER_PATTERN = re.compile(r"^[A-Z]{1,10}$")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_fake_engine(redis_rpush_side_effect=None):
|
|
"""Create a fake engine object with a mock Redis client."""
|
|
fake_engine = MagicMock()
|
|
fake_engine.running = True
|
|
fake_engine.redis = AsyncMock()
|
|
fake_engine.redis.rpush = AsyncMock(side_effect=redis_rpush_side_effect)
|
|
return fake_engine
|
|
|
|
|
|
def _override_client(fake_engine):
|
|
"""Return a TestClient with the module-level engine replaced."""
|
|
original = _app_module.engine
|
|
_app_module.engine = fake_engine
|
|
client = TestClient(app, raise_server_exceptions=False)
|
|
return client, original
|
|
|
|
|
|
def _restore_engine(original):
|
|
_app_module.engine = original
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Strategies
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Strategy for valid tickers: 1-10 uppercase alpha characters
|
|
valid_ticker_st = st.from_regex(r"[A-Za-z]{1,10}", fullmatch=True)
|
|
|
|
# Strategy for valid sides
|
|
valid_side_st = st.sampled_from(["buy", "sell"])
|
|
|
|
# Strategy for valid positive quantities
|
|
valid_quantity_st = st.floats(min_value=0.01, max_value=1_000_000.0, allow_nan=False, allow_infinity=False)
|
|
|
|
# Strategy for valid order types
|
|
valid_order_type_st = st.sampled_from(["market", "limit", "stop", "stop_limit"])
|
|
|
|
# Strategy for valid positive prices
|
|
valid_price_st = st.floats(min_value=0.01, max_value=1_000_000.0, allow_nan=False, allow_infinity=False)
|
|
|
|
|
|
@st.composite
|
|
def valid_override_order_st(draw):
|
|
"""Generate a valid override order request dict."""
|
|
ticker = draw(valid_ticker_st)
|
|
side = draw(valid_side_st)
|
|
quantity = draw(valid_quantity_st)
|
|
order_type = draw(valid_order_type_st)
|
|
|
|
order = {
|
|
"ticker": ticker,
|
|
"side": side,
|
|
"quantity": quantity,
|
|
"order_type": order_type,
|
|
}
|
|
|
|
if order_type in ("limit", "stop_limit"):
|
|
order["limit_price"] = draw(valid_price_st)
|
|
if order_type in ("stop", "stop_limit"):
|
|
order["stop_price"] = draw(valid_price_st)
|
|
|
|
return order
|
|
|
|
|
|
@st.composite
|
|
def invalid_override_order_st(draw):
|
|
"""Generate an override order request that violates at least one validation rule.
|
|
|
|
Possible violations:
|
|
- Invalid ticker format (digits, spaces, special chars, empty, too long)
|
|
- Non-positive quantity (zero or negative)
|
|
- Missing limit_price for limit/stop_limit orders
|
|
- Missing stop_price for stop/stop_limit orders
|
|
"""
|
|
violation = draw(st.sampled_from([
|
|
"bad_ticker",
|
|
"non_positive_quantity",
|
|
"missing_limit_price",
|
|
"missing_stop_price",
|
|
]))
|
|
|
|
if violation == "bad_ticker":
|
|
# Generate a ticker that does NOT match ^[A-Z]{1,10}$ after uppercasing
|
|
bad_ticker = draw(st.sampled_from([
|
|
"", # empty
|
|
"ABCDEFGHIJK", # 11 chars — too long
|
|
draw(st.from_regex(r"[0-9]{1,5}", fullmatch=True)), # digits
|
|
draw(st.from_regex(r"[A-Z]{1,5} [A-Z]{1,5}", fullmatch=True)), # spaces
|
|
draw(st.from_regex(r"[A-Z]{1,5}[^A-Za-z0-9]", fullmatch=True)), # special char
|
|
]))
|
|
return {
|
|
"ticker": bad_ticker,
|
|
"side": "buy",
|
|
"quantity": 10.0,
|
|
"order_type": "market",
|
|
}
|
|
|
|
if violation == "non_positive_quantity":
|
|
qty = draw(st.floats(min_value=-1_000_000.0, max_value=0.0, allow_nan=False, allow_infinity=False))
|
|
return {
|
|
"ticker": "AAPL",
|
|
"side": "buy",
|
|
"quantity": qty,
|
|
"order_type": "market",
|
|
}
|
|
|
|
if violation == "missing_limit_price":
|
|
order_type = draw(st.sampled_from(["limit", "stop_limit"]))
|
|
order = {
|
|
"ticker": "AAPL",
|
|
"side": "buy",
|
|
"quantity": 10.0,
|
|
"order_type": order_type,
|
|
}
|
|
# For stop_limit, provide stop_price but NOT limit_price
|
|
if order_type == "stop_limit":
|
|
order["stop_price"] = 100.0
|
|
return order
|
|
|
|
# violation == "missing_stop_price"
|
|
order_type = draw(st.sampled_from(["stop", "stop_limit"]))
|
|
order = {
|
|
"ticker": "AAPL",
|
|
"side": "buy",
|
|
"quantity": 10.0,
|
|
"order_type": order_type,
|
|
}
|
|
# For stop_limit, provide limit_price but NOT stop_price
|
|
if order_type == "stop_limit":
|
|
order["limit_price"] = 150.0
|
|
return order
|
|
|
|
|
|
# ===========================================================================
|
|
# Property 1: Ticker validation and normalization
|
|
# **Validates: Requirements 2.2, 8.1**
|
|
# ===========================================================================
|
|
|
|
|
|
class TestProperty1TickerValidationAndNormalization:
|
|
"""Property 1: Ticker validation and normalization.
|
|
|
|
For any string input, the ticker validation function SHALL accept it
|
|
if and only if, after uppercasing, it matches ^[A-Z]{1,10}$.
|
|
The normalized output SHALL always be the uppercased version of the input.
|
|
|
|
**Validates: Requirements 2.2, 8.1**
|
|
"""
|
|
|
|
@settings(max_examples=100)
|
|
@given(ticker=st.text(min_size=0, max_size=20))
|
|
def test_ticker_accepted_iff_matches_pattern(self, ticker: str) -> None:
|
|
"""After uppercasing, accepted iff matches ^[A-Z]{1,10}$;
|
|
normalized output is always uppercased input."""
|
|
uppercased = ticker.upper()
|
|
should_accept = bool(TICKER_PATTERN.match(uppercased))
|
|
|
|
try:
|
|
req = OverrideOrderRequest(
|
|
ticker=ticker,
|
|
side="buy",
|
|
quantity=1.0,
|
|
order_type="market",
|
|
)
|
|
# Accepted — verify it should have been accepted
|
|
assert should_accept, (
|
|
f"Ticker {ticker!r} was accepted but uppercased form "
|
|
f"{uppercased!r} does not match ^[A-Z]{{1,10}}$"
|
|
)
|
|
# Normalized output is always uppercased
|
|
assert req.ticker == uppercased, (
|
|
f"Expected normalized ticker {uppercased!r}, got {req.ticker!r}"
|
|
)
|
|
except ValidationError:
|
|
# Rejected — verify it should have been rejected
|
|
assert not should_accept, (
|
|
f"Ticker {ticker!r} was rejected but uppercased form "
|
|
f"{uppercased!r} matches ^[A-Z]{{1,10}}$"
|
|
)
|
|
|
|
|
|
# ===========================================================================
|
|
# Property 2: Override job payload completeness
|
|
# **Validates: Requirements 3.2, 9.1**
|
|
# ===========================================================================
|
|
|
|
|
|
class TestProperty2OverrideJobPayloadCompleteness:
|
|
"""Property 2: Override job payload completeness.
|
|
|
|
For any valid override order request, the job payload enqueued to the
|
|
broker queue SHALL contain all required fields, source == "manual_override",
|
|
and idempotency_key starts with "override-".
|
|
|
|
**Validates: Requirements 3.2, 9.1**
|
|
"""
|
|
|
|
@settings(max_examples=100)
|
|
@given(order=valid_override_order_st())
|
|
def test_enqueued_payload_has_all_required_fields(self, order: dict) -> None:
|
|
"""Enqueued payload contains all required fields with correct values."""
|
|
captured_payloads: list[str] = []
|
|
|
|
async def capture_rpush(key, value):
|
|
captured_payloads.append(value)
|
|
|
|
fake_engine = _make_fake_engine()
|
|
fake_engine.redis.rpush = AsyncMock(side_effect=capture_rpush)
|
|
client, original = _override_client(fake_engine)
|
|
|
|
try:
|
|
with patch.object(
|
|
_app_module,
|
|
"auto_register_symbol",
|
|
new_callable=AsyncMock,
|
|
return_value=(False, "comp-1"),
|
|
):
|
|
import httpx
|
|
|
|
def _mock_handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(
|
|
200,
|
|
content=json.dumps([{"id": "comp-1", "ticker": order["ticker"].upper()}]).encode(),
|
|
headers={"content-type": "application/json"},
|
|
)
|
|
|
|
original_init = httpx.AsyncClient.__init__
|
|
|
|
def patched_init(self, *args, **kwargs):
|
|
kwargs.pop("timeout", None)
|
|
kwargs["transport"] = httpx.MockTransport(_mock_handler)
|
|
original_init(self, *args, **kwargs)
|
|
|
|
with patch.object(httpx.AsyncClient, "__init__", patched_init):
|
|
resp = client.post("/api/trading/override/order", json=order)
|
|
|
|
assert resp.status_code == 202, (
|
|
f"Expected 202 for valid order {order!r}, got {resp.status_code}: {resp.text}"
|
|
)
|
|
assert len(captured_payloads) == 1, "Expected exactly one RPUSH call"
|
|
|
|
payload = json.loads(captured_payloads[0])
|
|
|
|
# All required fields present
|
|
expected_ticker = order["ticker"].upper()
|
|
assert payload["ticker"] == expected_ticker
|
|
assert payload["side"] == order["side"]
|
|
assert payload["quantity"] == order["quantity"]
|
|
assert payload["order_type"] == order["order_type"]
|
|
assert payload["source"] == "manual_override"
|
|
assert isinstance(payload["idempotency_key"], str)
|
|
assert payload["idempotency_key"].startswith("override-")
|
|
|
|
# Conditional price fields
|
|
if "limit_price" in order:
|
|
assert payload["limit_price"] == order["limit_price"]
|
|
if "stop_price" in order:
|
|
assert payload["stop_price"] == order["stop_price"]
|
|
finally:
|
|
_restore_engine(original)
|
|
|
|
|
|
# ===========================================================================
|
|
# Property 3: Invalid override order rejection
|
|
# **Validates: Requirements 3.5, 2.6**
|
|
# ===========================================================================
|
|
|
|
|
|
class TestProperty3InvalidOverrideOrderRejection:
|
|
"""Property 3: Invalid override order rejection.
|
|
|
|
For any override order request that violates at least one validation rule,
|
|
the endpoint SHALL return a 422 status code and the response body SHALL
|
|
contain at least one descriptive error message.
|
|
|
|
**Validates: Requirements 3.5, 2.6**
|
|
"""
|
|
|
|
@settings(max_examples=100)
|
|
@given(order=invalid_override_order_st())
|
|
def test_invalid_order_returns_422_with_error_message(self, order: dict) -> None:
|
|
"""Invalid orders return 422 with at least one descriptive error."""
|
|
fake_engine = _make_fake_engine()
|
|
client, original = _override_client(fake_engine)
|
|
|
|
try:
|
|
resp = client.post("/api/trading/override/order", json=order)
|
|
|
|
assert resp.status_code == 422, (
|
|
f"Expected 422 for invalid order {order!r}, got {resp.status_code}: {resp.text}"
|
|
)
|
|
|
|
body = resp.json()
|
|
# FastAPI returns validation errors in a "detail" field
|
|
assert "detail" in body, (
|
|
f"Expected 'detail' in 422 response body, got: {body}"
|
|
)
|
|
# At least one error message
|
|
detail = body["detail"]
|
|
assert isinstance(detail, list) and len(detail) >= 1, (
|
|
f"Expected at least one validation error, got: {detail}"
|
|
)
|
|
finally:
|
|
_restore_engine(original)
|