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
@@ -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>
);
}