TypeScript 5.9 erasableSyntaxOnly rejects enum declarations in .ts files (they emit runtime code). Switch protoc-gen-es from target=ts to target=js+dts which generates .js runtime + .d.ts type declarations. Also fix type mismatches in useApi.ts (OrderSide/OrderType enum types, StartBacktestRequest field mapping) and MLDashboard signal defaulting. Add web-dashboard network policy for MinIO egress + proxy ingress. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
214 lines
7.6 KiB
TypeScript
214 lines
7.6 KiB
TypeScript
import { useState, type FormEvent } from 'react';
|
|
import { useSubmitOrder } from '../../hooks/useApi';
|
|
import { OrderSide, OrderType } from '../../gen/trading_pb';
|
|
|
|
interface ValidationErrors {
|
|
symbol?: string;
|
|
quantity?: string;
|
|
price?: string;
|
|
}
|
|
|
|
export function OrderForm() {
|
|
const [symbol, setSymbol] = useState('ES.FUT');
|
|
const [side, setSide] = useState<'buy' | 'sell'>('buy');
|
|
const [orderType, setOrderType] = useState<'market' | 'limit'>('market');
|
|
const [quantity, setQuantity] = useState('1');
|
|
const [price, setPrice] = useState('');
|
|
const [validationErrors, setValidationErrors] = useState<ValidationErrors>({});
|
|
|
|
const submitOrder = useSubmitOrder();
|
|
|
|
const symbolValid = /^[A-Z0-9.]+$/i.test(symbol);
|
|
|
|
function clearFieldError(field: keyof ValidationErrors) {
|
|
setValidationErrors((prev) => {
|
|
const next = { ...prev };
|
|
delete next[field];
|
|
return next;
|
|
});
|
|
}
|
|
|
|
function handleSubmit(e: FormEvent) {
|
|
e.preventDefault();
|
|
|
|
const errors: ValidationErrors = {};
|
|
|
|
if (!symbol.trim()) {
|
|
errors.symbol = 'Symbol is required';
|
|
} else if (!/^[A-Z0-9.]+$/i.test(symbol)) {
|
|
errors.symbol = 'Symbol must be alphanumeric (dots allowed)';
|
|
}
|
|
|
|
const qty = parseFloat(quantity);
|
|
if (isNaN(qty) || qty <= 0) {
|
|
errors.quantity = 'Quantity must be greater than 0';
|
|
}
|
|
|
|
if (orderType === 'limit') {
|
|
const p = parseFloat(price);
|
|
if (isNaN(p) || p <= 0) {
|
|
errors.price = 'Price must be greater than 0 for limit orders';
|
|
}
|
|
}
|
|
|
|
setValidationErrors(errors);
|
|
if (Object.keys(errors).length > 0) return;
|
|
|
|
submitOrder.mutate({
|
|
symbol,
|
|
side: side === 'buy' ? OrderSide.BUY : OrderSide.SELL,
|
|
orderType: orderType === 'market' ? OrderType.MARKET : OrderType.LIMIT,
|
|
quantity: qty,
|
|
price: orderType === 'limit' ? parseFloat(price) : undefined,
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="px-3 py-1.5 border-b border-[var(--color-border)]">
|
|
<span className="text-sm font-medium">New Order</span>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="p-3 space-y-3">
|
|
{/* Symbol */}
|
|
<div>
|
|
<label htmlFor="order-symbol" className="block text-xs text-[var(--color-text-secondary)] mb-1">Symbol</label>
|
|
<input
|
|
id="order-symbol"
|
|
type="text"
|
|
value={symbol}
|
|
onChange={(e) => {
|
|
setSymbol(e.target.value.toUpperCase());
|
|
clearFieldError('symbol');
|
|
}}
|
|
maxLength={20}
|
|
pattern="[A-Z0-9.]+"
|
|
className={`w-full px-2 py-1.5 text-sm rounded border bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] outline-none ${
|
|
symbolValid && !validationErrors.symbol
|
|
? 'border-[var(--color-border)] focus:border-[var(--color-accent)]'
|
|
: 'border-[var(--color-red)]'
|
|
}`}
|
|
/>
|
|
{validationErrors.symbol && (
|
|
<p className="text-xs text-[var(--color-red)] mt-0.5" role="alert">{validationErrors.symbol}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Side */}
|
|
<div>
|
|
<label className="block text-xs text-[var(--color-text-secondary)] mb-1">Side</label>
|
|
<div className="grid grid-cols-2 gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setSide('buy')}
|
|
aria-label="Set order side to buy"
|
|
aria-pressed={side === 'buy'}
|
|
className={`py-1.5 text-sm rounded font-medium transition-colors ${
|
|
side === 'buy'
|
|
? 'bg-[var(--color-green)] text-white'
|
|
: 'bg-[var(--color-bg-primary)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'
|
|
}`}
|
|
>
|
|
BUY
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSide('sell')}
|
|
aria-label="Set order side to sell"
|
|
aria-pressed={side === 'sell'}
|
|
className={`py-1.5 text-sm rounded font-medium transition-colors ${
|
|
side === 'sell'
|
|
? 'bg-[var(--color-red)] text-white'
|
|
: 'bg-[var(--color-bg-primary)] text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'
|
|
}`}
|
|
>
|
|
SELL
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Type */}
|
|
<div>
|
|
<label htmlFor="order-type" className="block text-xs text-[var(--color-text-secondary)] mb-1">Type</label>
|
|
<select
|
|
id="order-type"
|
|
value={orderType}
|
|
onChange={(e) => setOrderType(e.target.value as 'market' | 'limit')}
|
|
className="w-full px-2 py-1.5 text-sm rounded border border-[var(--color-border)] bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] outline-none"
|
|
>
|
|
<option value="market">Market</option>
|
|
<option value="limit">Limit</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Quantity */}
|
|
<div>
|
|
<label htmlFor="order-quantity" className="block text-xs text-[var(--color-text-secondary)] mb-1">Quantity</label>
|
|
<input
|
|
id="order-quantity"
|
|
type="number"
|
|
min="0"
|
|
step="1"
|
|
value={quantity}
|
|
onChange={(e) => {
|
|
setQuantity(e.target.value);
|
|
clearFieldError('quantity');
|
|
}}
|
|
className={`w-full px-2 py-1.5 text-sm rounded border bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] outline-none ${
|
|
validationErrors.quantity
|
|
? 'border-[var(--color-red)]'
|
|
: 'border-[var(--color-border)] focus:border-[var(--color-accent)]'
|
|
}`}
|
|
/>
|
|
{validationErrors.quantity && (
|
|
<p className="text-xs text-[var(--color-red)] mt-0.5" role="alert">{validationErrors.quantity}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Price (limit only) */}
|
|
{orderType === 'limit' && (
|
|
<div>
|
|
<label htmlFor="order-price" className="block text-xs text-[var(--color-text-secondary)] mb-1">Price</label>
|
|
<input
|
|
id="order-price"
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
value={price}
|
|
onChange={(e) => {
|
|
setPrice(e.target.value);
|
|
clearFieldError('price');
|
|
}}
|
|
className={`w-full px-2 py-1.5 text-sm rounded border bg-[var(--color-bg-primary)] text-[var(--color-text-primary)] outline-none ${
|
|
validationErrors.price
|
|
? 'border-[var(--color-red)]'
|
|
: 'border-[var(--color-border)] focus:border-[var(--color-accent)]'
|
|
}`}
|
|
/>
|
|
{validationErrors.price && (
|
|
<p className="text-xs text-[var(--color-red)] mt-0.5" role="alert">{validationErrors.price}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Submit */}
|
|
<button
|
|
type="submit"
|
|
disabled={submitOrder.isPending}
|
|
className={`w-full py-2 text-sm font-medium rounded transition-colors ${
|
|
side === 'buy'
|
|
? 'bg-[var(--color-green)] hover:bg-green-600'
|
|
: 'bg-[var(--color-red)] hover:bg-red-600'
|
|
} text-white disabled:opacity-50`}
|
|
>
|
|
{submitOrder.isPending ? 'Submitting...' : `${side.toUpperCase()} ${symbol}`}
|
|
</button>
|
|
|
|
{submitOrder.isError && (
|
|
<p className="text-xs text-[var(--color-red)]" role="alert">{submitOrder.error.message}</p>
|
|
)}
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|