A firm's client used to stay logged in around the clock, renewing on a fixed timer overnight and at weekends. Now, once a firm has nothing open, nothing pending and nothing left to trade before its next session for a random 15–45 minutes, its client disconnects and the dashboard shows it Resting. It logs back in a random 20–50 minutes before the next session opens, at a check-in every 3–7 Central daytime hours, or at once when trading starts, a manual leg, copy trade, manual order or AutoBuyer run needs it. Token renewals fall at random points (Tradovate 15–45 min before expiry, TopstepX every 45–150 min). Settings → Trading → Rest idle broker sessions (session_rest) turns it off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
574 lines
32 KiB
TypeScript
574 lines
32 KiB
TypeScript
'use client';
|
||
|
||
import { useEffect, useState } from 'react';
|
||
import Link from 'next/link';
|
||
|
||
/** GET /api/instruments: each firm's list (from the master) and, for
|
||
* futures firms, how the Auto draw sees each instrument. */
|
||
interface InstrumentsOverview {
|
||
roots: string[];
|
||
maxEntrySpreadDollars?: number;
|
||
quotes?: { source: string | null; platform: string | null; lastQuoteAt: string | null; lastError: string | null };
|
||
firms: {
|
||
id: string;
|
||
name: string;
|
||
platform: string;
|
||
source: 'master' | 'legacy';
|
||
instruments: string[];
|
||
stats?: {
|
||
maxHoldHours: number;
|
||
quoteSource?: string | null;
|
||
sessionProgress: number | null;
|
||
instruments: {
|
||
symbol: string;
|
||
listed: boolean;
|
||
spreadDollars: number;
|
||
spreadLive?: boolean;
|
||
spreadBlocked?: boolean;
|
||
dailyMoveDollars: number | null;
|
||
holdHours: number | null;
|
||
disabled: boolean;
|
||
chance: number | null;
|
||
}[];
|
||
};
|
||
}[];
|
||
}
|
||
|
||
interface ResolvedContract {
|
||
name: string;
|
||
alternative?: string;
|
||
}
|
||
|
||
interface AppSettings {
|
||
max_concurrent_accounts: string | null;
|
||
master_dashboard_url: string | null;
|
||
instance_name: string | null;
|
||
instance_id: string | null;
|
||
trading_hours: string | null;
|
||
session_rest: string | null;
|
||
master_backup_token: string | null;
|
||
proxy_url: string | null;
|
||
user_agent: string | null;
|
||
backup_last_push_at: string | null;
|
||
backup_last_error: string | null;
|
||
}
|
||
|
||
interface BackupMeta {
|
||
id: string;
|
||
name: string;
|
||
savedAt: string;
|
||
sizeBytes: number;
|
||
}
|
||
|
||
export default function SettingsPage() {
|
||
const [overview, setOverview] = useState<InstrumentsOverview | null>(null);
|
||
const [contracts, setContracts] = useState<Record<string, ResolvedContract | null>>({});
|
||
const [maxConcurrent, setMaxConcurrent] = useState<string>('5');
|
||
const [tradingHours, setTradingHours] = useState<string>('full_cme');
|
||
const [sessionRest, setSessionRest] = useState<boolean>(true);
|
||
const [masterUrl, setMasterUrl] = useState<string>('');
|
||
const [catalog, setCatalog] = useState<{
|
||
version: number;
|
||
firmCount: number;
|
||
lastSyncAt: string | null;
|
||
lastError: string | null;
|
||
} | null>(null);
|
||
const [syncing, setSyncing] = useState(false);
|
||
const [instanceName, setInstanceName] = useState<string>('');
|
||
const [instanceId, setInstanceId] = useState<string>('');
|
||
const [saving, setSaving] = useState(false);
|
||
const [saved, setSaved] = useState(false);
|
||
const [backupToken, setBackupToken] = useState<string>('');
|
||
const [proxyUrl, setProxyUrl] = useState<string>('');
|
||
const [userAgent, setUserAgent] = useState<string>('');
|
||
const [backupStatus, setBackupStatus] = useState<{ at: string | null; error: string | null }>({ at: null, error: null });
|
||
const [restoreList, setRestoreList] = useState<BackupMeta[] | null>(null);
|
||
const [restoreBusy, setRestoreBusy] = useState(false);
|
||
const [restoreMsg, setRestoreMsg] = useState<string>('');
|
||
|
||
const fetchCatalog = () => {
|
||
fetch('/api/firm-catalog')
|
||
.then((r) => r.json())
|
||
.then(setCatalog)
|
||
.catch(() => {});
|
||
};
|
||
|
||
const syncCatalogNow = async () => {
|
||
setSyncing(true);
|
||
try {
|
||
await fetch('/api/firm-catalog', { method: 'POST' });
|
||
} catch { /* the error lands in lastError */ }
|
||
fetchCatalog();
|
||
setSyncing(false);
|
||
};
|
||
|
||
useEffect(() => {
|
||
fetch('/api/instruments')
|
||
.then((r) => r.json())
|
||
.then(setOverview)
|
||
.catch(() => {});
|
||
|
||
fetchCatalog();
|
||
|
||
fetch('/api/settings')
|
||
.then((r) => r.json())
|
||
.then((s: AppSettings) => {
|
||
if (s.max_concurrent_accounts != null) {
|
||
setMaxConcurrent(s.max_concurrent_accounts);
|
||
}
|
||
setSessionRest(s.session_rest !== '0');
|
||
if (s.trading_hours != null) {
|
||
setTradingHours(s.trading_hours);
|
||
}
|
||
if (s.master_dashboard_url != null) {
|
||
setMasterUrl(s.master_dashboard_url);
|
||
}
|
||
if (s.instance_name != null) {
|
||
setInstanceName(s.instance_name);
|
||
}
|
||
if (s.instance_id != null) {
|
||
setInstanceId(s.instance_id);
|
||
}
|
||
if (s.master_backup_token != null) {
|
||
setBackupToken(s.master_backup_token);
|
||
}
|
||
if (s.proxy_url != null) {
|
||
setProxyUrl(s.proxy_url);
|
||
}
|
||
setUserAgent(s.user_agent ?? '');
|
||
setBackupStatus({ at: s.backup_last_push_at, error: s.backup_last_error || null });
|
||
});
|
||
|
||
// Load cached contracts; if cache is empty, auto-resolve
|
||
fetch('/api/instruments/contracts')
|
||
.then((r) => r.json())
|
||
.then((data: Record<string, ResolvedContract | null>) => {
|
||
if (data.error) return;
|
||
const hasData = Object.values(data).some((v) => v !== null);
|
||
if (hasData) {
|
||
setContracts(data);
|
||
} else {
|
||
// Cache empty — trigger a fresh resolve automatically
|
||
fetch('/api/instruments/contracts', { method: 'POST' })
|
||
.then((r) => r.json())
|
||
.then((fresh) => { if (!fresh.error) setContracts(fresh); })
|
||
.catch(() => {});
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
}, []);
|
||
|
||
async function saveSettings() {
|
||
setSaving(true);
|
||
setSaved(false);
|
||
await fetch('/api/settings', {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ max_concurrent_accounts: maxConcurrent, trading_hours: tradingHours, session_rest: sessionRest ? '1' : '0', master_dashboard_url: masterUrl, instance_name: instanceName, master_backup_token: backupToken, proxy_url: proxyUrl }),
|
||
});
|
||
setSaving(false);
|
||
setSaved(true);
|
||
setTimeout(() => setSaved(false), 2000);
|
||
}
|
||
|
||
async function loadRestoreList() {
|
||
setRestoreBusy(true);
|
||
setRestoreMsg('');
|
||
try {
|
||
const res = await fetch('/api/backup/restore');
|
||
const body = await res.json();
|
||
if (!res.ok) throw new Error(body?.error ?? `HTTP ${res.status}`);
|
||
setRestoreList(body.backups ?? []);
|
||
} catch (err) {
|
||
setRestoreMsg(err instanceof Error ? err.message : String(err));
|
||
}
|
||
setRestoreBusy(false);
|
||
}
|
||
|
||
async function restoreBackup(b: BackupMeta) {
|
||
const warning =
|
||
`Restore "${b.name}" (saved ${new Date(b.savedAt).toLocaleString()})?\n\n` +
|
||
'This OVERWRITES this machine\'s settings and firm credentials and adopts ' +
|
||
'that instance\'s identity on the master.\n\n' +
|
||
'Make sure the old machine is STOPPED first — two live instances with one ' +
|
||
'identity will fight over the connection.';
|
||
if (!window.confirm(warning)) return;
|
||
setRestoreBusy(true);
|
||
setRestoreMsg('');
|
||
try {
|
||
const res = await fetch('/api/backup/restore', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: b.id }),
|
||
});
|
||
const body = await res.json();
|
||
if (!res.ok) throw new Error(body?.error ?? `HTTP ${res.status}`);
|
||
setRestoreMsg(
|
||
`Restored ${body.restored.settings} settings and ${body.restored.credentials} credential(s) as ` +
|
||
`"${body.adopted.name}". Restart the autotrader now (pm2 restart) to finish.`,
|
||
);
|
||
setRestoreList(null);
|
||
} catch (err) {
|
||
setRestoreMsg(err instanceof Error ? err.message : String(err));
|
||
}
|
||
setRestoreBusy(false);
|
||
}
|
||
|
||
/** >24h old backups get flagged — a dead VPS's last push may be stale. */
|
||
function isStale(savedAt: string): boolean {
|
||
return Date.now() - new Date(savedAt).getTime() > 24 * 60 * 60 * 1000;
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-slate-50 p-8">
|
||
<div className="max-w-7xl mx-auto">
|
||
|
||
<div className="flex items-center gap-3 mb-6">
|
||
<Link
|
||
href="/"
|
||
className="text-slate-400 hover:text-slate-600 text-sm transition-colors"
|
||
>
|
||
← Back
|
||
</Link>
|
||
<h1 className="text-2xl font-bold text-slate-900">Settings</h1>
|
||
</div>
|
||
|
||
{/* ── Trading ── */}
|
||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
|
||
Trading
|
||
</h2>
|
||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm mb-8">
|
||
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100">
|
||
<div>
|
||
<p className="text-sm font-medium text-slate-800">Max concurrent accounts</p>
|
||
<p className="text-xs text-slate-400 mt-0.5">
|
||
How many accounts trade in parallel per /api/trade call
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={500}
|
||
value={maxConcurrent}
|
||
onChange={(e) => setMaxConcurrent(e.target.value)}
|
||
className="w-20 rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-sm text-right font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
<button
|
||
onClick={saveSettings}
|
||
disabled={saving}
|
||
className={`text-sm px-3 py-1.5 rounded-lg font-medium transition-colors ${
|
||
saved
|
||
? 'bg-green-500 text-white'
|
||
: 'bg-blue-500 hover:bg-blue-600 text-white disabled:opacity-50'
|
||
}`}
|
||
>
|
||
{saved ? 'Saved ✓' : saving ? 'Saving…' : 'Save'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center justify-between px-4 py-3">
|
||
<div>
|
||
<p className="text-sm font-medium text-slate-800">Allowed trading hours</p>
|
||
<p className="text-xs text-slate-400 mt-0.5">
|
||
5 min buffer
|
||
</p>
|
||
</div>
|
||
<select
|
||
value={tradingHours}
|
||
onChange={(e) => setTradingHours(e.target.value)}
|
||
className="rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-sm font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
>
|
||
<option value="full_cme">Full CME (5:00 PM – 3:00 PM CT)</option>
|
||
<option value="equity_hours">Equity Hours (8:30 AM – 3:00 PM CT)</option>
|
||
</select>
|
||
</div>
|
||
<div className="flex items-center justify-between gap-4 border-t border-slate-100 px-4 py-3">
|
||
<div>
|
||
<p className="text-sm font-medium text-slate-800">Rest idle broker sessions</p>
|
||
<p className="text-xs text-slate-400 mt-0.5">
|
||
Log a firm out once nothing is open and nothing is left to trade before its next
|
||
session (weekends, the daily break, a finished day), and back in 20–50 min before
|
||
that session opens, with a check-in every few daytime hours. Off keeps every
|
||
session logged in around the clock.
|
||
</p>
|
||
</div>
|
||
<input
|
||
type="checkbox"
|
||
checked={sessionRest}
|
||
onChange={(e) => setSessionRest(e.target.checked)}
|
||
className="h-4 w-4 shrink-0 accent-blue-500"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Master Dashboard ── */}
|
||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
|
||
Master Dashboard
|
||
</h2>
|
||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm mb-8">
|
||
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100">
|
||
<div>
|
||
<p className="text-sm font-medium text-slate-800">Instance Name</p>
|
||
<p className="text-xs text-slate-400 mt-0.5">
|
||
Identifier for this autotrader on the master dashboard.
|
||
Renaming is safe — the master tracks this machine by its
|
||
person ID{instanceId ? (
|
||
<> (<span className="font-mono">{instanceId.slice(0, 8)}</span>)</>
|
||
) : ' once it has connected'}, so history follows the new name.
|
||
</p>
|
||
</div>
|
||
<input
|
||
type="text"
|
||
placeholder="e.g. VPS-1"
|
||
value={instanceName}
|
||
onChange={(e) => setInstanceName(e.target.value)}
|
||
className="w-48 rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-sm font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center justify-between px-4 py-3">
|
||
<div>
|
||
<p className="text-sm font-medium text-slate-800">Dashboard URL</p>
|
||
<p className="text-xs text-slate-400 mt-0.5">
|
||
Reports state here every 30 seconds, and holds a control
|
||
socket open so the dashboard can trigger trades and buys
|
||
</p>
|
||
</div>
|
||
<input
|
||
type="text"
|
||
placeholder="e.g. http://master-ip:4000"
|
||
value={masterUrl}
|
||
onChange={(e) => setMasterUrl(e.target.value)}
|
||
className="w-72 rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-sm font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-100">
|
||
<div>
|
||
<p className="text-sm font-medium text-slate-800">Firm catalog</p>
|
||
<p className="text-xs text-slate-400 mt-0.5">
|
||
{catalog
|
||
? `v${catalog.version} · ${catalog.firmCount} firm${catalog.firmCount === 1 ? '' : 's'} · ${
|
||
catalog.lastSyncAt
|
||
? `last synced ${new Date(catalog.lastSyncAt).toLocaleTimeString()}`
|
||
: 'never synced'
|
||
}`
|
||
: 'Loading…'}
|
||
</p>
|
||
{catalog?.lastError && (
|
||
<p className="text-xs text-red-600 mt-0.5">{catalog.lastError}</p>
|
||
)}
|
||
</div>
|
||
<button
|
||
onClick={syncCatalogNow}
|
||
disabled={syncing}
|
||
className="text-sm px-3 py-1.5 rounded-lg font-medium bg-slate-100 hover:bg-slate-200 text-slate-700 disabled:opacity-50 transition-colors"
|
||
>
|
||
{syncing ? 'Syncing…' : 'Sync now'}
|
||
</button>
|
||
</div>
|
||
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-100">
|
||
<div>
|
||
<p className="text-sm font-medium text-slate-800">Backup token</p>
|
||
<p className="text-xs text-slate-400 mt-0.5">
|
||
Backups push automatically — this token (the master's
|
||
INSTANCE_BACKUP_TOKEN) is only needed to restore one.
|
||
</p>
|
||
<p className="text-xs text-slate-400 mt-0.5">
|
||
{backupStatus.at
|
||
? `Last backed up ${new Date(backupStatus.at).toLocaleString()}`
|
||
: 'Never backed up'}
|
||
</p>
|
||
{backupStatus.error && (
|
||
<p className="text-xs text-red-600 mt-0.5">{backupStatus.error}</p>
|
||
)}
|
||
</div>
|
||
<input
|
||
type="password"
|
||
placeholder="shared token"
|
||
value={backupToken}
|
||
onChange={(e) => setBackupToken(e.target.value)}
|
||
className="w-72 rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-sm font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
</div>
|
||
<div className="px-4 py-3 border-t border-slate-100">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-sm font-medium text-slate-800">Restore from master</p>
|
||
<p className="text-xs text-slate-400 mt-0.5">
|
||
Replace a dead VPS: pull its backup, overwrite this machine
|
||
and adopt its identity. Stop the old machine first.
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={loadRestoreList}
|
||
disabled={restoreBusy}
|
||
className="text-sm px-3 py-1.5 rounded-lg font-medium bg-slate-100 hover:bg-slate-200 text-slate-700 disabled:opacity-50 transition-colors"
|
||
>
|
||
{restoreBusy ? 'Working…' : restoreList ? 'Refresh list' : 'List backups'}
|
||
</button>
|
||
</div>
|
||
{restoreMsg && (
|
||
<p className="text-xs mt-2 text-slate-600">{restoreMsg}</p>
|
||
)}
|
||
{restoreList && (
|
||
<div className="mt-3 border border-slate-200 rounded-lg divide-y divide-slate-100">
|
||
{restoreList.length === 0 ? (
|
||
<p className="px-3 py-2 text-xs text-slate-400 italic">No backups on the master yet</p>
|
||
) : restoreList.map((b) => (
|
||
<div key={b.id} className="flex items-center justify-between px-3 py-2">
|
||
<div>
|
||
<span className="text-sm font-mono text-slate-800">{b.name}</span>
|
||
<span className={`text-xs ml-2 ${isStale(b.savedAt) ? 'text-amber-600 font-semibold' : 'text-slate-400'}`}>
|
||
saved {new Date(b.savedAt).toLocaleString()}
|
||
{isStale(b.savedAt) ? ' — over a day old' : ''}
|
||
</span>
|
||
<span className="text-xs text-slate-300 ml-2 font-mono">{b.id.slice(0, 8)}</span>
|
||
</div>
|
||
<button
|
||
onClick={() => restoreBackup(b)}
|
||
disabled={restoreBusy}
|
||
className="text-sm px-3 py-1 rounded-lg font-medium bg-red-50 hover:bg-red-100 text-red-700 disabled:opacity-50 transition-colors"
|
||
>
|
||
Restore
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Network ── */}
|
||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
|
||
Network
|
||
</h2>
|
||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm mb-8">
|
||
<div className="flex items-center justify-between px-4 py-3">
|
||
<div>
|
||
<p className="text-sm font-medium text-slate-800">Broker proxy</p>
|
||
<p className="text-xs text-slate-400 mt-0.5">
|
||
Routes broker traffic (TopstepX, Tradovate, quotes) through a proxy —
|
||
http://, https:// or socks5:// with optional user:pass. Use socks5h://
|
||
to resolve DNS on the proxy. The master connection stays direct.
|
||
</p>
|
||
<p className="text-xs text-amber-600 mt-0.5">
|
||
Takes effect after a restart (pm2 restart).
|
||
</p>
|
||
</div>
|
||
<input
|
||
type="text"
|
||
placeholder="e.g. socks5://user:pass@1.2.3.4:1080"
|
||
value={proxyUrl}
|
||
onChange={(e) => setProxyUrl(e.target.value)}
|
||
className="w-72 rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-sm font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
</div>
|
||
<div className="border-t border-slate-100 px-4 py-3">
|
||
<p className="text-sm font-medium text-slate-800">User agent</p>
|
||
<p className="text-xs text-slate-400 mt-0.5">
|
||
Chrome, Firefox, or Edge is randomly selected on first load and saved.
|
||
All server Axios requests and WebSocket connections reuse it, including after restarts.
|
||
</p>
|
||
<p className="mt-2 break-all rounded-lg bg-slate-50 px-3 py-2 text-xs font-mono text-slate-700">
|
||
{userAgent || 'Loading…'}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Instruments ── */}
|
||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-1">
|
||
Instruments
|
||
</h2>
|
||
<p className="text-xs text-slate-400 mb-3">
|
||
What each firm trades is set on the master's{' '}
|
||
{masterUrl ? <a className="text-blue-500 hover:underline" href={`${masterUrl.replace(/\/+$/, '')}/firms`} target="_blank" rel="noreferrer">Firms page</a> : 'Firms page'}.
|
||
Futures “Auto” draws one per batch: cheaper per move early in the session (every instrument gets a floor),
|
||
the fastest near the last entry, never one expected to hold 12h or more. Holds below are for a $1,000 leg.
|
||
Spreads are live off one futures client's quotes (a tick when there are none); no entry pays more than
|
||
{overview?.maxEntrySpreadDollars ? ` $${overview.maxEntrySpreadDollars}` : ' the master\'s limit'} per contract to cross the spread.
|
||
</p>
|
||
{overview?.quotes && (
|
||
<p className="text-xs mb-3 text-slate-500">
|
||
{overview.quotes.source
|
||
? <>Live quotes from <span className="font-semibold">{overview.quotes.source}</span> ({overview.quotes.platform})
|
||
{overview.quotes.lastQuoteAt ? `, last ${new Date(overview.quotes.lastQuoteAt).toLocaleTimeString()}` : ', none yet'}</>
|
||
: <span className="text-amber-700">No live quotes — spreads are the typical tick{overview.quotes.lastError ? ` (${overview.quotes.lastError})` : ''}</span>}
|
||
</p>
|
||
)}
|
||
|
||
{!overview ? (
|
||
<div className="bg-white border border-slate-200 rounded-xl px-4 py-8 text-center text-sm text-slate-400 italic shadow-sm">Loading…</div>
|
||
) : overview.firms.map((firm) => (
|
||
<div key={firm.id} className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm mb-3">
|
||
<div className="flex items-center gap-2 px-4 py-2.5 bg-slate-50 border-b border-slate-200">
|
||
<span className="font-semibold text-sm text-slate-800">{firm.name}</span>
|
||
<span className="text-xs text-slate-400">{firm.platform}</span>
|
||
{firm.source === 'legacy' && (
|
||
<span className="text-xs bg-amber-50 text-amber-700 border border-amber-200 rounded px-1.5 py-0.5" title="The master doesn't send instrument lists yet: this machine's enabled instruments, less the firm's banned symbols">
|
||
from this machine (older master)
|
||
</span>
|
||
)}
|
||
{firm.instruments.length === 0 && <span className="text-xs text-amber-700">no instruments — won't trade</span>}
|
||
</div>
|
||
{firm.stats ? (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm border-collapse whitespace-nowrap">
|
||
<thead>
|
||
<tr className="border-b border-slate-100 text-xs uppercase tracking-wider text-slate-400">
|
||
<th className="px-4 py-2 text-left font-semibold">Symbol</th>
|
||
<th className="px-4 py-2 text-left font-semibold">Contract</th>
|
||
<th className="px-4 py-2 text-right font-semibold">Spread</th>
|
||
<th className="px-4 py-2 text-right font-semibold">Daily move</th>
|
||
<th className="px-4 py-2 text-right font-semibold">Hold</th>
|
||
<th className="px-4 py-2 text-right font-semibold">Chance now</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{firm.instruments.map((sym) => {
|
||
const x = firm.stats!.instruments.find((i) => i.symbol === sym);
|
||
const c = contracts[sym];
|
||
return (
|
||
<tr key={sym} className="border-b border-slate-100 last:border-0">
|
||
<td className="px-4 py-2 font-mono font-semibold text-slate-800">{sym}</td>
|
||
<td className="px-4 py-2 font-mono text-slate-600">
|
||
{c?.name ?? '—'}
|
||
{c?.alternative && <span className="ml-2 text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-sans">rolled from {c.alternative}</span>}
|
||
</td>
|
||
<td
|
||
className={`px-4 py-2 text-right tabular-nums ${x?.spreadBlocked ? 'text-amber-600' : 'text-slate-600'}`}
|
||
title="Crossing the spread on one contract — what entering costs, commissions aside. Live off the quote feed, else the typical tick"
|
||
>
|
||
{x ? `$${x.spreadDollars.toFixed(2)}` : '—'}
|
||
{x && <span className="text-xs text-slate-400"> {x.spreadLive ? 'live' : 'typical'}</span>}
|
||
{x?.spreadBlocked && ' · over limit'}
|
||
</td>
|
||
<td className="px-4 py-2 text-right tabular-nums text-slate-600">
|
||
{x?.dailyMoveDollars != null ? `$${x.dailyMoveDollars.toLocaleString()}` : '—'}
|
||
</td>
|
||
<td className={`px-4 py-2 text-right tabular-nums ${x?.disabled ? 'text-amber-600' : 'text-slate-600'}`}>
|
||
{x?.holdHours != null ? (x.holdHours < 48 ? `${x.holdHours.toFixed(1)}h` : `${(x.holdHours / 24).toFixed(1)}d`) : '—'}
|
||
{x?.disabled && ' · disabled'}
|
||
</td>
|
||
<td className="px-4 py-2 text-right tabular-nums font-semibold text-slate-700">
|
||
{x?.chance != null ? `${Math.round(x.chance * 100)}%` : '—'}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-wrap gap-1.5 px-4 py-3">
|
||
{firm.instruments.map((sym) => (
|
||
<span key={sym} className="px-2 py-1 rounded-md text-xs font-mono font-semibold bg-slate-100 text-slate-700">{sym}</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|