Files
stonks-oracle/tests/test_pbt_override.py
Celes Renata 913fe8b0b3 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
2026-04-17 07:02:30 +00:00

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)