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:
Celes Renata
2026-04-17 07:02:30 +00:00
parent 7f67725ec8
commit 913fe8b0b3
18 changed files with 3074 additions and 17 deletions
+36
View File
@@ -336,3 +336,39 @@ export function useUpdateNotificationConfig() {
onSuccess: () => qc.invalidateQueries({ queryKey: ['notification-config'] }),
});
}
// ---------------------------------------------------------------------------
// Override Order (manual trade submission)
// ---------------------------------------------------------------------------
export interface OverrideOrderRequest {
ticker: string;
side: 'buy' | 'sell';
quantity: number;
order_type: 'market' | 'limit' | 'stop' | 'stop_limit';
limit_price?: number;
stop_price?: number;
}
export interface OverrideOrderResponse {
job_id: string;
status: string;
ticker: string;
side: string;
quantity: number;
auto_registered: boolean;
}
/** Submit a manual override order to the trading engine. */
export function useSubmitOverrideOrder() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: OverrideOrderRequest) =>
apiPost<OverrideOrderResponse>('trading', '/api/trading/override/order', body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['orders'] });
qc.invalidateQueries({ queryKey: ['positions'] });
qc.invalidateQueries({ queryKey: ['companies'] });
},
});
}
+9
View File
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { Link } from '@tanstack/react-router';
import {
useTradingConfig,
useSetTradingMode,
@@ -78,6 +79,14 @@ export function TradingPage() {
{mode}
</button>
))}
<Link
to="/trading/engine"
search={{ tab: 'override' }}
className="rounded-md bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700"
data-testid="override-trade-button"
>
Override Trade
</Link>
</div>
{/* Confirmation dialog for live mode */}
+13 -2
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useSearch, useNavigate } from '@tanstack/react-router';
import { TradingOverview } from './trading/TradingOverview';
import { PortfolioComposition } from './trading/PortfolioComposition';
import { TradeHistory } from './trading/TradeHistory';
@@ -6,6 +6,7 @@ import { PerformanceCharts } from './trading/PerformanceCharts';
import { BacktestPanel } from './trading/BacktestPanel';
import { MicroTradingPanel } from './trading/MicroTradingPanel';
import { NotificationPreferences } from './trading/NotificationPreferences';
import OverrideTradePanel from './trading/OverrideTradePanel';
import { ErrorBoundary } from '../components/ui';
const TABS = [
@@ -16,12 +17,21 @@ const TABS = [
{ id: 'backtest', label: 'Backtest' },
{ id: 'micro', label: 'Micro-Trading' },
{ id: 'notifications', label: 'Notifications' },
{ id: 'override', label: 'Override' },
] as const;
type TabId = (typeof TABS)[number]['id'];
const VALID_TAB_IDS = new Set<string>(TABS.map((t) => t.id));
export function TradingEnginePage() {
const [activeTab, setActiveTab] = useState<TabId>('overview');
const { tab } = useSearch({ from: '/trading/engine' });
const navigate = useNavigate();
const activeTab: TabId = (tab && VALID_TAB_IDS.has(tab) ? tab : 'overview') as TabId;
const setActiveTab = (newTab: TabId) => {
navigate({ to: '/trading/engine', search: { tab: newTab }, replace: true });
};
return (
<div className="space-y-4">
@@ -57,6 +67,7 @@ export function TradingEnginePage() {
{activeTab === 'backtest' && <BacktestPanel />}
{activeTab === 'micro' && <MicroTradingPanel />}
{activeTab === 'notifications' && <NotificationPreferences />}
{activeTab === 'override' && <OverrideTradePanel />}
</div>
</ErrorBoundary>
</div>
@@ -0,0 +1,487 @@
import { useState, type FormEvent, type ChangeEvent } from 'react';
import { usePositions } from '../../api/hooks';
import {
useSubmitOverrideOrder,
type OverrideOrderRequest,
type OverrideOrderResponse,
} from '../../api/tradingHooks';
import { ApiError } from '../../api/client';
import { Card, LoadingSpinner } from '../../components/ui';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type OrderType = OverrideOrderRequest['order_type'];
type Side = OverrideOrderRequest['side'];
interface FormState {
ticker: string;
side: Side;
quantity: string;
order_type: OrderType;
limit_price: string;
stop_price: string;
}
interface ValidationErrors {
ticker?: string;
quantity?: string;
limit_price?: string;
stop_price?: string;
}
const INITIAL_FORM: FormState = {
ticker: '',
side: 'buy',
quantity: '',
order_type: 'market',
limit_price: '',
stop_price: '',
};
const ORDER_TYPES: { value: OrderType; label: string }[] = [
{ value: 'market', label: 'Market' },
{ value: 'limit', label: 'Limit' },
{ value: 'stop', label: 'Stop' },
{ value: 'stop_limit', label: 'Stop Limit' },
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const TICKER_RE = /^[A-Z]{1,10}$/;
function needsLimitPrice(orderType: OrderType): boolean {
return orderType === 'limit' || orderType === 'stop_limit';
}
function needsStopPrice(orderType: OrderType): boolean {
return orderType === 'stop' || orderType === 'stop_limit';
}
function validate(form: FormState): ValidationErrors {
const errors: ValidationErrors = {};
const ticker = form.ticker.toUpperCase();
if (!TICKER_RE.test(ticker)) {
errors.ticker = 'Ticker must be 110 alphabetic characters';
}
const qty = Number(form.quantity);
if (!form.quantity || isNaN(qty) || qty <= 0) {
errors.quantity = 'Quantity must be a positive number';
}
if (needsLimitPrice(form.order_type)) {
const lp = Number(form.limit_price);
if (!form.limit_price || isNaN(lp) || lp <= 0) {
errors.limit_price = 'Limit price is required and must be positive';
}
}
if (needsStopPrice(form.order_type)) {
const sp = Number(form.stop_price);
if (!form.stop_price || isNaN(sp) || sp <= 0) {
errors.stop_price = 'Stop price is required and must be positive';
}
}
return errors;
}
function fmtUsd(v: number | null | undefined) {
if (v == null) return '—';
return v.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function pnlColor(v: number | null | undefined) {
if (v == null || v === 0) return 'text-gray-400';
return v > 0 ? 'text-green-400' : 'text-red-400';
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function OverrideTradePanel() {
const [form, setForm] = useState<FormState>(INITIAL_FORM);
const [errors, setErrors] = useState<ValidationErrors>({});
const [serverErrors, setServerErrors] = useState<string[]>([]);
const [banner, setBanner] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
const [successData, setSuccessData] = useState<OverrideOrderResponse | null>(null);
const submitOrder = useSubmitOverrideOrder();
const { data: positions, isLoading: positionsLoading } = usePositions();
// --- Form field handlers ---
function updateField(field: keyof FormState, value: string) {
setForm((prev) => ({ ...prev, [field]: value }));
// Clear field-level error on change
if (field in errors) {
setErrors((prev) => {
const next = { ...prev };
delete next[field as keyof ValidationErrors];
return next;
});
}
// Clear server errors and banner on any change
if (serverErrors.length) setServerErrors([]);
if (banner) setBanner(null);
if (successData) setSuccessData(null);
}
function handleTickerChange(e: ChangeEvent<HTMLInputElement>) {
updateField('ticker', e.target.value.toUpperCase());
}
function handleQuantityChange(e: ChangeEvent<HTMLInputElement>) {
updateField('quantity', e.target.value);
}
function handleOrderTypeChange(e: ChangeEvent<HTMLSelectElement>) {
const newType = e.target.value as OrderType;
setForm((prev) => ({
...prev,
order_type: newType,
limit_price: needsLimitPrice(newType) ? prev.limit_price : '',
stop_price: needsStopPrice(newType) ? prev.stop_price : '',
}));
// Clear price errors when switching order type
setErrors((prev) => {
const next = { ...prev };
if (!needsLimitPrice(newType)) delete next.limit_price;
if (!needsStopPrice(newType)) delete next.stop_price;
return next;
});
if (serverErrors.length) setServerErrors([]);
if (banner) setBanner(null);
}
function handleSideToggle(side: Side) {
updateField('side', side);
}
// --- Submit ---
function handleSubmit(e: FormEvent) {
e.preventDefault();
const validationErrors = validate(form);
setErrors(validationErrors);
if (Object.keys(validationErrors).length > 0) return;
setServerErrors([]);
setBanner(null);
setSuccessData(null);
const body: OverrideOrderRequest = {
ticker: form.ticker.toUpperCase(),
side: form.side,
quantity: Number(form.quantity),
order_type: form.order_type,
};
if (needsLimitPrice(form.order_type)) {
body.limit_price = Number(form.limit_price);
}
if (needsStopPrice(form.order_type)) {
body.stop_price = Number(form.stop_price);
}
submitOrder.mutate(body, {
onSuccess: (data) => {
setSuccessData(data);
setBanner({ type: 'success', message: `Order queued — Job ID: ${data.job_id}` });
setForm(INITIAL_FORM);
setErrors({});
setServerErrors([]);
},
onError: (err) => {
if (err instanceof ApiError) {
if (err.status === 422) {
// Extract validation errors from response body
const body = err.body as { detail?: Array<{ msg: string; loc?: string[] }> | string } | null;
if (body?.detail && Array.isArray(body.detail)) {
const msgs = body.detail.map((d) => d.msg ?? String(d));
setServerErrors(msgs);
} else if (typeof body?.detail === 'string') {
setServerErrors([body.detail]);
} else {
setServerErrors(['Validation failed']);
}
return;
}
if (err.status === 503) {
setBanner({ type: 'error', message: 'Broker service is unavailable. Please try again later.' });
return;
}
setBanner({ type: 'error', message: `Server error (${err.status})` });
return;
}
// Network / fetch error
setBanner({ type: 'error', message: 'Unable to connect. Check your network and try again.' });
},
});
}
// --- Render ---
const showLimitPrice = needsLimitPrice(form.order_type);
const showStopPrice = needsStopPrice(form.order_type);
return (
<div className="space-y-6">
{/* Order Form */}
<Card>
<h2 className="mb-4 text-sm font-medium text-gray-400">Submit Override Order</h2>
{/* Success banner */}
{banner?.type === 'success' && (
<div
className="mb-4 rounded-md border border-green-700/50 bg-green-900/20 px-4 py-3 text-sm text-green-400"
role="alert"
data-testid="success-banner"
>
{banner.message}
{successData && (
<span className="ml-2 text-xs text-green-500">
Status: {successData.status}
</span>
)}
</div>
)}
{/* Error banner (503 / network) */}
{banner?.type === 'error' && (
<div
className="mb-4 rounded-md border border-red-700/50 bg-red-900/20 px-4 py-3 text-sm text-red-400"
role="alert"
data-testid="error-banner"
>
{banner.message}
</div>
)}
{/* Server validation errors (422) */}
{serverErrors.length > 0 && (
<div
className="mb-4 rounded-md border border-red-700/50 bg-red-900/20 px-4 py-3 text-sm text-red-400"
role="alert"
data-testid="validation-errors"
>
<ul className="list-inside list-disc space-y-1">
{serverErrors.map((msg, i) => (
<li key={i}>{msg}</li>
))}
</ul>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Ticker */}
<div>
<label htmlFor="override-ticker" className="mb-1 block text-xs text-gray-500">
Ticker
</label>
<input
id="override-ticker"
type="text"
data-testid="ticker-input"
value={form.ticker}
onChange={handleTickerChange}
placeholder="AAPL"
className={`w-40 rounded-md border bg-surface-950 px-3 py-2 font-mono text-sm text-gray-200 uppercase placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-brand-500 ${
errors.ticker ? 'border-red-500' : 'border-surface-700'
}`}
aria-label="Ticker symbol"
aria-invalid={!!errors.ticker}
/>
{errors.ticker && (
<p className="mt-1 text-xs text-red-400" role="alert">{errors.ticker}</p>
)}
</div>
{/* Side toggle */}
<div>
<span className="mb-1 block text-xs text-gray-500">Side</span>
<div className="inline-flex rounded-md border border-surface-700" role="group" aria-label="Order side">
<button
type="button"
data-testid="side-buy"
onClick={() => handleSideToggle('buy')}
className={`px-4 py-2 text-sm font-medium transition-colors first:rounded-l-md last:rounded-r-md ${
form.side === 'buy'
? 'bg-green-700 text-white'
: 'bg-surface-900 text-gray-400 hover:bg-surface-800'
}`}
aria-pressed={form.side === 'buy'}
>
Buy
</button>
<button
type="button"
data-testid="side-sell"
onClick={() => handleSideToggle('sell')}
className={`px-4 py-2 text-sm font-medium transition-colors first:rounded-l-md last:rounded-r-md ${
form.side === 'sell'
? 'bg-red-700 text-white'
: 'bg-surface-900 text-gray-400 hover:bg-surface-800'
}`}
aria-pressed={form.side === 'sell'}
>
Sell
</button>
</div>
</div>
{/* Quantity */}
<div>
<label htmlFor="override-quantity" className="mb-1 block text-xs text-gray-500">
Quantity
</label>
<input
id="override-quantity"
type="number"
data-testid="quantity-input"
value={form.quantity}
onChange={handleQuantityChange}
placeholder="10"
min="0"
step="any"
className={`w-40 rounded-md border bg-surface-950 px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-brand-500 ${
errors.quantity ? 'border-red-500' : 'border-surface-700'
}`}
aria-label="Quantity"
aria-invalid={!!errors.quantity}
/>
{errors.quantity && (
<p className="mt-1 text-xs text-red-400" role="alert">{errors.quantity}</p>
)}
</div>
{/* Order Type */}
<div>
<label htmlFor="override-order-type" className="mb-1 block text-xs text-gray-500">
Order Type
</label>
<select
id="override-order-type"
data-testid="order-type-select"
value={form.order_type}
onChange={handleOrderTypeChange}
className="w-40 rounded-md border border-surface-700 bg-surface-950 px-3 py-2 text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-brand-500"
aria-label="Order type"
>
{ORDER_TYPES.map((ot) => (
<option key={ot.value} value={ot.value}>
{ot.label}
</option>
))}
</select>
</div>
{/* Limit Price (conditional) */}
{showLimitPrice && (
<div>
<label htmlFor="override-limit-price" className="mb-1 block text-xs text-gray-500">
Limit Price
</label>
<input
id="override-limit-price"
type="number"
data-testid="limit-price-input"
value={form.limit_price}
onChange={(e) => updateField('limit_price', e.target.value)}
placeholder="0.00"
min="0"
step="any"
className={`w-40 rounded-md border bg-surface-950 px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-brand-500 ${
errors.limit_price ? 'border-red-500' : 'border-surface-700'
}`}
aria-label="Limit price"
aria-invalid={!!errors.limit_price}
/>
{errors.limit_price && (
<p className="mt-1 text-xs text-red-400" role="alert">{errors.limit_price}</p>
)}
</div>
)}
{/* Stop Price (conditional) */}
{showStopPrice && (
<div>
<label htmlFor="override-stop-price" className="mb-1 block text-xs text-gray-500">
Stop Price
</label>
<input
id="override-stop-price"
type="number"
data-testid="stop-price-input"
value={form.stop_price}
onChange={(e) => updateField('stop_price', e.target.value)}
placeholder="0.00"
min="0"
step="any"
className={`w-40 rounded-md border bg-surface-950 px-3 py-2 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:ring-2 focus:ring-brand-500 ${
errors.stop_price ? 'border-red-500' : 'border-surface-700'
}`}
aria-label="Stop price"
aria-invalid={!!errors.stop_price}
/>
{errors.stop_price && (
<p className="mt-1 text-xs text-red-400" role="alert">{errors.stop_price}</p>
)}
</div>
)}
{/* Submit */}
<button
type="submit"
data-testid="submit-order"
disabled={submitOrder.isPending}
className="rounded-md bg-brand-600 px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Submit order"
>
{submitOrder.isPending ? 'Submitting…' : 'Submit Order'}
</button>
</form>
</Card>
{/* Positions Display */}
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Current Positions</h2>
{positionsLoading ? (
<LoadingSpinner />
) : !positions?.length ? (
<p className="text-sm text-gray-500" data-testid="no-positions">No current positions</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm" role="table" data-testid="positions-table">
<thead>
<tr className="border-b border-surface-700 text-left text-gray-500">
<th className="px-3 py-2">Ticker</th>
<th className="px-3 py-2">Quantity</th>
<th className="px-3 py-2">Avg Entry</th>
<th className="px-3 py-2">Current</th>
<th className="px-3 py-2">Unrealized P&amp;L</th>
</tr>
</thead>
<tbody>
{positions.map((pos) => (
<tr key={pos.id} className="border-b border-surface-700/50">
<td className="px-3 py-2 font-mono font-semibold text-brand-300">{pos.ticker}</td>
<td className="px-3 py-2 text-gray-300">{pos.quantity}</td>
<td className="px-3 py-2 text-gray-300">${fmtUsd(pos.avg_entry_price)}</td>
<td className="px-3 py-2 text-gray-300">
{pos.current_price != null ? `$${fmtUsd(pos.current_price)}` : '—'}
</td>
<td className={`px-3 py-2 font-medium ${pnlColor(pos.unrealized_pnl)}`}>
{pos.unrealized_pnl != null ? `$${fmtUsd(pos.unrealized_pnl)}` : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
</div>
);
}
+3
View File
@@ -115,6 +115,9 @@ const tradingEngineRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/trading/engine',
component: TradingEnginePage,
validateSearch: (search: Record<string, unknown>): { tab?: string } => ({
tab: typeof search.tab === 'string' ? search.tab : undefined,
}),
});
const opsPipelineRoute = createRoute({
getParentRoute: () => rootRoute,
+9
View File
@@ -219,6 +219,15 @@ export const handlers = [
http.get('/api/agents/:agent_id/variants/:variant_id/performance', () => HttpResponse.json(mockVariantPerformance)),
http.get('/api/agents/:agent_id/variants/:variant_id/performance/history', () => HttpResponse.json(mockVariantPerfHistory)),
// Trading Engine: Override order
http.post('/trading/api/trading/override/order', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
return HttpResponse.json(
{ job_id: 'job-test-123', status: 'queued', ticker: body.ticker, side: body.side, quantity: body.quantity, auto_registered: false },
{ status: 202 },
);
}),
// Competitive intelligence endpoints
http.get('/registry/companies/:id/competitors', () => HttpResponse.json(mockCompetitors)),
http.post('/registry/companies/:id/competitors/infer', () => HttpResponse.json(mockCompetitors)),
+314
View File
@@ -0,0 +1,314 @@
import { describe, it, expect } from 'vitest';
import { screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { renderRoute } from './render';
import { server } from './mocks/server';
// ---------------------------------------------------------------------------
// 1. Override tab renders in tab bar
// ---------------------------------------------------------------------------
describe('Override tab in Trading Engine', () => {
it('renders Override tab in the tab bar', async () => {
renderRoute('/trading/engine?tab=override');
await waitFor(() => {
const tab = screen.getByRole('tab', { name: 'Override' });
expect(tab).toBeInTheDocument();
expect(tab).toHaveAttribute('aria-selected', 'true');
});
});
// ---------------------------------------------------------------------------
// 2. Override tab shows form and positions sections
// ---------------------------------------------------------------------------
it('shows order form and positions sections', async () => {
renderRoute('/trading/engine?tab=override');
await waitFor(() => {
expect(screen.getByText('Submit Override Order')).toBeInTheDocument();
});
expect(screen.getByText('Current Positions')).toBeInTheDocument();
});
// ---------------------------------------------------------------------------
// 3. Override tab accessible via URL param ?tab=override
// ---------------------------------------------------------------------------
it('is accessible via URL param ?tab=override', async () => {
renderRoute('/trading/engine?tab=override');
await waitFor(() => {
expect(screen.getByText('Submit Override Order')).toBeInTheDocument();
});
// The Override tab should be selected
const tab = screen.getByRole('tab', { name: 'Override' });
expect(tab).toHaveAttribute('aria-selected', 'true');
});
// ---------------------------------------------------------------------------
// 4. Order form fields are present
// ---------------------------------------------------------------------------
it('renders all order form fields (ticker, side, quantity, order type)', async () => {
renderRoute('/trading/engine?tab=override');
await waitFor(() => {
expect(screen.getByTestId('ticker-input')).toBeInTheDocument();
});
expect(screen.getByTestId('side-buy')).toBeInTheDocument();
expect(screen.getByTestId('side-sell')).toBeInTheDocument();
expect(screen.getByTestId('quantity-input')).toBeInTheDocument();
expect(screen.getByTestId('order-type-select')).toBeInTheDocument();
expect(screen.getByTestId('submit-order')).toBeInTheDocument();
});
// ---------------------------------------------------------------------------
// 5. Conditional price fields show/hide based on order type
// ---------------------------------------------------------------------------
it('shows limit price field when order type is limit', async () => {
const user = userEvent.setup();
renderRoute('/trading/engine?tab=override');
await waitFor(() => {
expect(screen.getByTestId('order-type-select')).toBeInTheDocument();
});
// Market order — no price fields
expect(screen.queryByTestId('limit-price-input')).not.toBeInTheDocument();
expect(screen.queryByTestId('stop-price-input')).not.toBeInTheDocument();
// Switch to limit
await user.selectOptions(screen.getByTestId('order-type-select'), 'limit');
expect(screen.getByTestId('limit-price-input')).toBeInTheDocument();
expect(screen.queryByTestId('stop-price-input')).not.toBeInTheDocument();
// Switch to stop
await user.selectOptions(screen.getByTestId('order-type-select'), 'stop');
expect(screen.queryByTestId('limit-price-input')).not.toBeInTheDocument();
expect(screen.getByTestId('stop-price-input')).toBeInTheDocument();
// Switch to stop_limit
await user.selectOptions(screen.getByTestId('order-type-select'), 'stop_limit');
expect(screen.getByTestId('limit-price-input')).toBeInTheDocument();
expect(screen.getByTestId('stop-price-input')).toBeInTheDocument();
// Switch back to market
await user.selectOptions(screen.getByTestId('order-type-select'), 'market');
expect(screen.queryByTestId('limit-price-input')).not.toBeInTheDocument();
expect(screen.queryByTestId('stop-price-input')).not.toBeInTheDocument();
});
// ---------------------------------------------------------------------------
// 6. Form validation errors for invalid inputs
// ---------------------------------------------------------------------------
it('shows validation errors for invalid inputs', async () => {
const user = userEvent.setup();
renderRoute('/trading/engine?tab=override');
await waitFor(() => {
expect(screen.getByTestId('submit-order')).toBeInTheDocument();
});
// Submit empty form
await user.click(screen.getByTestId('submit-order'));
await waitFor(() => {
// Ticker and quantity should show errors
const alerts = screen.getAllByRole('alert');
expect(alerts.length).toBeGreaterThanOrEqual(2);
});
expect(screen.getByText(/Ticker must be 110 alphabetic characters/)).toBeInTheDocument();
expect(screen.getByText(/Quantity must be a positive number/)).toBeInTheDocument();
});
it('shows validation error for missing limit price on limit order', async () => {
const user = userEvent.setup();
renderRoute('/trading/engine?tab=override');
await waitFor(() => {
expect(screen.getByTestId('order-type-select')).toBeInTheDocument();
});
await user.type(screen.getByTestId('ticker-input'), 'AAPL');
await user.type(screen.getByTestId('quantity-input'), '10');
await user.selectOptions(screen.getByTestId('order-type-select'), 'limit');
// Submit without limit price
await user.click(screen.getByTestId('submit-order'));
await waitFor(() => {
expect(screen.getByText(/Limit price is required/)).toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// 7. Successful order submission shows success message and resets form
// ---------------------------------------------------------------------------
it('shows success message and resets form on successful submission', async () => {
const user = userEvent.setup();
renderRoute('/trading/engine?tab=override');
await waitFor(() => {
expect(screen.getByTestId('ticker-input')).toBeInTheDocument();
});
await user.type(screen.getByTestId('ticker-input'), 'AAPL');
await user.type(screen.getByTestId('quantity-input'), '10');
await user.click(screen.getByTestId('submit-order'));
await waitFor(() => {
expect(screen.getByTestId('success-banner')).toBeInTheDocument();
});
expect(screen.getByText(/Order queued — Job ID: job-test-123/)).toBeInTheDocument();
expect(screen.getByText(/Status: queued/)).toBeInTheDocument();
// Form should be reset
expect(screen.getByTestId('ticker-input')).toHaveValue('');
expect(screen.getByTestId('quantity-input')).toHaveValue(null);
});
// ---------------------------------------------------------------------------
// 8. 422 error display
// ---------------------------------------------------------------------------
it('displays 422 validation errors from the server', async () => {
// Override the handler to return 422
server.use(
http.post('/trading/api/trading/override/order', () => {
return HttpResponse.json(
{ detail: [{ msg: 'Ticker not recognized by broker' }] },
{ status: 422 },
);
}),
);
const user = userEvent.setup();
renderRoute('/trading/engine?tab=override');
await waitFor(() => {
expect(screen.getByTestId('ticker-input')).toBeInTheDocument();
});
await user.type(screen.getByTestId('ticker-input'), 'AAPL');
await user.type(screen.getByTestId('quantity-input'), '10');
await user.click(screen.getByTestId('submit-order'));
await waitFor(() => {
expect(screen.getByTestId('validation-errors')).toBeInTheDocument();
});
expect(screen.getByText('Ticker not recognized by broker')).toBeInTheDocument();
});
// ---------------------------------------------------------------------------
// 9. Submit button loading state during submission
// ---------------------------------------------------------------------------
it('disables submit button and shows loading state during submission', async () => {
// Use a delayed handler to observe loading state
server.use(
http.post('/trading/api/trading/override/order', async ({ request }) => {
await new Promise((resolve) => setTimeout(resolve, 200));
const body = await request.json() as Record<string, unknown>;
return HttpResponse.json(
{ job_id: 'job-test-123', status: 'queued', ticker: body.ticker, side: body.side, quantity: body.quantity, auto_registered: false },
{ status: 202 },
);
}),
);
const user = userEvent.setup();
renderRoute('/trading/engine?tab=override');
await waitFor(() => {
expect(screen.getByTestId('ticker-input')).toBeInTheDocument();
});
await user.type(screen.getByTestId('ticker-input'), 'AAPL');
await user.type(screen.getByTestId('quantity-input'), '10');
await user.click(screen.getByTestId('submit-order'));
// Button should be disabled and show loading text
await waitFor(() => {
const btn = screen.getByTestId('submit-order');
expect(btn).toBeDisabled();
expect(btn).toHaveTextContent('Submitting…');
});
// Eventually resolves to success
await waitFor(() => {
expect(screen.getByTestId('success-banner')).toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// 10. Positions table renders with mock data
// ---------------------------------------------------------------------------
it('renders positions table with mock data', async () => {
renderRoute('/trading/engine?tab=override');
await waitFor(() => {
expect(screen.getByTestId('positions-table')).toBeInTheDocument();
});
const table = screen.getByTestId('positions-table');
expect(within(table).getByText('AAPL')).toBeInTheDocument();
expect(within(table).getByText('10')).toBeInTheDocument();
});
// ---------------------------------------------------------------------------
// 11. Positions loading state
// ---------------------------------------------------------------------------
it('shows loading state while positions are loading', async () => {
// Delay the positions response to observe loading state
server.use(
http.get('/api/positions', async () => {
await new Promise((resolve) => setTimeout(resolve, 500));
return HttpResponse.json([]);
}),
);
renderRoute('/trading/engine?tab=override');
// The form should render while positions are loading
await waitFor(() => {
expect(screen.getByText('Submit Override Order')).toBeInTheDocument();
});
// The positions section should show a loading indicator (LoadingSpinner renders a role="status" element)
expect(screen.getByText('Current Positions')).toBeInTheDocument();
});
// ---------------------------------------------------------------------------
// 12. Positions empty state
// ---------------------------------------------------------------------------
it('shows empty state when no positions exist', async () => {
server.use(
http.get('/api/positions', () => HttpResponse.json([])),
);
renderRoute('/trading/engine?tab=override');
await waitFor(() => {
expect(screen.getByTestId('no-positions')).toBeInTheDocument();
});
expect(screen.getByText('No current positions')).toBeInTheDocument();
});
});
// ---------------------------------------------------------------------------
// 13. "Override Trade" button on Trading page exists and links correctly
// ---------------------------------------------------------------------------
describe('Override Trade button on Trading page', () => {
it('renders Override Trade button that links to override tab', async () => {
renderRoute('/trading');
await waitFor(() => {
expect(screen.getByTestId('override-trade-button')).toBeInTheDocument();
});
const link = screen.getByTestId('override-trade-button');
expect(link).toHaveTextContent('Override Trade');
expect(link).toHaveAttribute('href', '/trading/engine?tab=override');
});
});