Files
stonks-oracle/frontend/src/pages/Trading.tsx
T

374 lines
16 KiB
TypeScript

import { useState } from 'react';
import {
useTradingConfig,
useSetTradingMode,
useSetTradingCapital,
usePendingApprovals,
useReviewApproval,
useActiveLockouts,
useMacroStatus,
useToggleMacro,
useCompetitiveStatus,
useToggleCompetitive,
} from '../api/hooks';
import { StatusBadge, LoadingSpinner, Card } from '../components/ui';
export function TradingPage() {
const { data: config, isLoading: configLoading } = useTradingConfig();
const { data: approvals } = usePendingApprovals();
const { data: lockouts } = useActiveLockouts();
const { data: macroStatus } = useMacroStatus();
const { data: competitiveStatus } = useCompetitiveStatus();
const setMode = useSetTradingMode();
const setCapital = useSetTradingCapital();
const reviewApproval = useReviewApproval();
const toggleMacro = useToggleMacro();
const toggleCompetitive = useToggleCompetitive();
// Normalize API field names (API returns macro_enabled/competitive_enabled, not enabled)
const macroEnabled = macroStatus?.enabled ?? macroStatus?.macro_enabled ?? false;
const competitiveEnabled = competitiveStatus?.enabled ?? competitiveStatus?.competitive_enabled ?? false;
const [confirmMode, setConfirmMode] = useState<string | null>(null);
const [confirmMacroToggle, setConfirmMacroToggle] = useState(false);
const [confirmCompetitiveToggle, setConfirmCompetitiveToggle] = useState(false);
if (configLoading) return <LoadingSpinner />;
const currentMode = config?.trading_mode ?? 'paper';
return (
<div className="space-y-6">
<h1 className="text-xl font-semibold text-gray-100">Trading Controls</h1>
{/* Trading Mode */}
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Trading Mode</h2>
<div className="flex items-center gap-3">
{['paper', 'live', 'disabled'].map((mode) => (
<button
key={mode}
onClick={() => {
if (mode === currentMode) return;
if (mode === 'live') { setConfirmMode(mode); return; }
setMode.mutate(mode);
}}
className={`rounded-md px-4 py-2 text-sm font-medium capitalize transition-colors ${
currentMode === mode
? 'bg-brand-600 text-white'
: 'border border-surface-700 bg-surface-900 text-gray-400 hover:bg-surface-800'
}`}
aria-pressed={currentMode === mode}
>
{mode}
</button>
))}
</div>
{/* Confirmation dialog for live mode */}
{confirmMode && (
<div className="mt-4 rounded-lg border border-orange-700/50 bg-orange-900/20 p-4">
<p className="text-sm text-orange-300">
Are you sure you want to switch to <span className="font-semibold">{confirmMode}</span> mode?
This enables real order execution.
</p>
<div className="mt-3 flex gap-2">
<button
onClick={() => { setMode.mutate(confirmMode); setConfirmMode(null); }}
className="rounded-md bg-orange-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-orange-700"
>
Confirm
</button>
<button
onClick={() => setConfirmMode(null)}
className="rounded-md border border-surface-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-surface-800"
>
Cancel
</button>
</div>
</div>
)}
</Card>
{/* Paper Trading Capital */}
<CapitalCard onSetCapital={(amount) => setCapital.mutate(amount)} isPending={setCapital.isPending} />
{/* Macro Signal Layer Toggle */}
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Macro Signal Layer</h2>
<div className="flex items-center gap-4">
<button
onClick={() => setConfirmMacroToggle(true)}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 focus:ring-offset-surface-900 ${
macroEnabled ? 'bg-brand-600' : 'bg-surface-700'
}`}
role="switch"
aria-checked={macroEnabled ?? false}
aria-label="Toggle macro signal layer"
>
<span
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition-transform ${
macroEnabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
<span className="text-sm text-gray-300">
{macroEnabled ? 'Enabled' : 'Disabled'}
</span>
{macroStatus?.toggled_at && (
<span className="text-xs text-gray-500">
Last changed: {new Date(macroStatus.toggled_at).toLocaleString()}
{macroStatus.toggled_by && ` by ${macroStatus.toggled_by}`}
</span>
)}
</div>
{/* Confirmation dialog for macro toggle */}
{confirmMacroToggle && (
<div className="mt-4 rounded-lg border border-orange-700/50 bg-orange-900/20 p-4">
<p className="text-sm text-orange-300">
Are you sure you want to {macroEnabled ? 'disable' : 'enable'} the macro signal layer?
{macroEnabled
? ' Disabling will exclude macro signals from trend summaries and recommendations.'
: ' Enabling will include global event macro signals in trend summaries and recommendations.'}
</p>
<div className="mt-3 flex gap-2">
<button
onClick={() => {
toggleMacro.mutate(!macroEnabled);
setConfirmMacroToggle(false);
}}
className="rounded-md bg-orange-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-orange-700"
>
Confirm
</button>
<button
onClick={() => setConfirmMacroToggle(false)}
className="rounded-md border border-surface-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-surface-800"
>
Cancel
</button>
</div>
</div>
)}
</Card>
{/* Competitive Signal Layer Toggle */}
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Competitive Signal Layer</h2>
<div className="flex items-center gap-4">
<button
onClick={() => setConfirmCompetitiveToggle(true)}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 focus:ring-offset-surface-900 ${
competitiveEnabled ? 'bg-brand-600' : 'bg-surface-700'
}`}
role="switch"
aria-checked={competitiveEnabled ?? false}
aria-label="Toggle competitive signal layer"
>
<span
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition-transform ${
competitiveEnabled ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
<span className="text-sm text-gray-300">
{competitiveEnabled ? 'Enabled' : 'Disabled'}
</span>
{competitiveStatus?.toggled_at && (
<span className="text-xs text-gray-500">
Last changed: {new Date(competitiveStatus.toggled_at).toLocaleString()}
{competitiveStatus.toggled_by && ` by ${competitiveStatus.toggled_by}`}
</span>
)}
</div>
{/* Confirmation dialog for competitive toggle */}
{confirmCompetitiveToggle && (
<div className="mt-4 rounded-lg border border-orange-700/50 bg-orange-900/20 p-4">
<p className="text-sm text-orange-300">
Are you sure you want to {competitiveEnabled ? 'disable' : 'enable'} the competitive signal layer?
{competitiveEnabled
? ' Disabling will exclude historical pattern and competitive signals from trend summaries and recommendations.'
: ' Enabling will include historical pattern and competitive signals in trend summaries and recommendations.'}
</p>
<div className="mt-3 flex gap-2">
<button
onClick={() => {
toggleCompetitive.mutate(!competitiveEnabled);
setConfirmCompetitiveToggle(false);
}}
className="rounded-md bg-orange-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-orange-700"
>
Confirm
</button>
<button
onClick={() => setConfirmCompetitiveToggle(false)}
className="rounded-md border border-surface-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-surface-800"
>
Cancel
</button>
</div>
</div>
)}
</Card>
{/* Pending Approvals */}
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">
Pending Approvals ({approvals?.length ?? 0})
</h2>
{!approvals?.length ? (
<p className="text-sm text-gray-500">No pending approvals</p>
) : (
<div className="space-y-2">
{approvals.map((a) => (
<ApprovalRow key={a.id} approval={a} onReview={(approved, note) => reviewApproval.mutate({ id: a.id, approved, review_note: note })} />
))}
</div>
)}
</Card>
{/* Active Lockouts */}
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">
Active Lockouts ({lockouts?.length ?? 0})
</h2>
{!lockouts?.length ? (
<p className="text-sm text-gray-500">No active lockouts</p>
) : (
<div className="space-y-2">
{lockouts.map((l) => {
const expiresIn = l.expires_at ? Math.max(0, Math.round((new Date(l.expires_at).getTime() - Date.now()) / 60000)) : 0;
return (
<div key={l.id} className="flex items-center justify-between rounded border border-surface-700 bg-surface-950 p-3">
<div className="flex items-center gap-3">
<span className="font-mono font-semibold text-brand-300">{l.ticker}</span>
<StatusBadge status={l.lockout_type} />
<span className="text-sm text-gray-400">{l.reason}</span>
</div>
<span className="text-xs text-gray-500">
{expiresIn > 0 ? `${expiresIn}m remaining` : 'expired'}
</span>
</div>
);
})}
</div>
)}
</Card>
</div>
);
}
function ApprovalRow({ approval, onReview }: {
approval: { id: string; ticker: string; side: string; quantity: number; estimated_value: number | null; requested_at: string };
onReview: (approved: boolean, note: string) => void;
}) {
const [note, setNote] = useState('');
return (
<div className="rounded border border-surface-700 bg-surface-950 p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="font-mono font-semibold text-brand-300">{approval.ticker}</span>
<StatusBadge status={approval.side} />
<span className="text-sm text-gray-300">qty: {approval.quantity}</span>
{approval.estimated_value != null && (
<span className="text-sm text-gray-500">${approval.estimated_value.toFixed(2)}</span>
)}
</div>
<span className="text-xs text-gray-500">{new Date(approval.requested_at).toLocaleString()}</span>
</div>
<div className="mt-2 flex items-center gap-2">
<input
type="text"
placeholder="Review note…"
value={note}
onChange={(e) => setNote(e.target.value)}
className="flex-1 rounded-md border border-surface-700 bg-surface-900 px-2 py-1 text-xs text-gray-200 placeholder-gray-500"
aria-label="Review note"
/>
<button onClick={() => onReview(true, note)} className="rounded bg-green-700 px-3 py-1 text-xs font-medium text-white hover:bg-green-600">
Approve
</button>
<button onClick={() => onReview(false, note)} className="rounded bg-red-700 px-3 py-1 text-xs font-medium text-white hover:bg-red-600">
Reject
</button>
</div>
</div>
);
}
function CapitalCard({ onSetCapital, isPending }: { onSetCapital: (amount: number) => void; isPending: boolean }) {
const [amount, setAmount] = useState('100000');
const [showConfirm, setShowConfirm] = useState(false);
const presets = [10000, 50000, 100000, 500000];
return (
<Card>
<h2 className="mb-3 text-sm font-medium text-gray-400">Paper Trading Capital</h2>
<div className="flex flex-wrap items-end gap-3">
<div>
<label htmlFor="capital-input" className="mb-1 block text-xs text-gray-500">Initial Capital ($)</label>
<input
id="capital-input"
type="number"
min="100"
step="1000"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="w-40 rounded-md border border-surface-700 bg-surface-900 px-3 py-1.5 text-sm text-gray-200 focus:border-brand-500 focus:outline-none"
/>
</div>
<div className="flex gap-2">
{presets.map((p) => (
<button
key={p}
onClick={() => setAmount(String(p))}
className={`rounded-md border px-2 py-1.5 text-xs font-medium ${
amount === String(p)
? 'border-brand-500 bg-brand-600/20 text-brand-300'
: 'border-surface-700 bg-surface-900 text-gray-400 hover:bg-surface-800'
}`}
>
${(p / 1000).toFixed(0)}k
</button>
))}
</div>
<button
onClick={() => setShowConfirm(true)}
disabled={isPending || !amount || Number(amount) <= 0}
className="rounded-md bg-brand-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
>
Set Capital
</button>
</div>
{showConfirm && (
<div className="mt-4 rounded-lg border border-orange-700/50 bg-orange-900/20 p-4">
<p className="text-sm text-orange-300">
This will reset the paper trading pools to <span className="font-semibold">${Number(amount).toLocaleString()}</span>.
Any existing pool balances will be overwritten.
</p>
<div className="mt-3 flex gap-2">
<button
onClick={() => { onSetCapital(Number(amount)); setShowConfirm(false); }}
disabled={isPending}
className="rounded-md bg-orange-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-orange-700 disabled:opacity-50"
>
{isPending ? 'Setting…' : 'Confirm'}
</button>
<button
onClick={() => setShowConfirm(false)}
className="rounded-md border border-surface-700 px-3 py-1.5 text-sm text-gray-400 hover:bg-surface-800"
>
Cancel
</button>
</div>
</div>
)}
</Card>
);
}