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
+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');
});
});