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:
@@ -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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 1–10 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&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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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 1–10 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user