- lib/instrument-specs.ts (pure, tested): the CME specs — point value, tick size, one-tick spread, Yahoo ticker — replacing POINT_VALUES; the draw's maths; the 12-hour hold cap; the micro for drain and extra-day legs; reading a firm's list (the master's, or the old rules against an older master: this machine's enabled set less bans, the symbol map). - lib/instruments.ts: the one module the trading paths ask. Measures every future's daily move from Yahoo's hourly candles once per trade date, and shows entry cost (a tick of spread plus Tradovate's fee parameters or TopstepX's last measured fill) without ever choosing by it. - Futures Auto draws one root per batch from the batch firm's list: movement per dollar of spread early in the session with a 10% floor each, sliding to the fastest movers by the last entry; nothing expected to hold 12h or more; micros only if nothing else is listed. Every draw logs its odds. - The batch is chosen before its instrument, and a group that can't trade anything yields to the next. Copy trade matches the held contract against the listed roots and won't copy an instrument it can't identify. Manual legs, manual orders and drain/extra-day legs follow the lists. - DXtrade firms rank their own list whatever root was drawn. - Capabilities, the dashboard's trade bar and contract resolves follow the lists; the trade bar and scheduler default to Auto. The master link reconnects when master_dashboard_url changes, and the catalog drops ?since after a switch so two masters' versions can't collide. - Reports carry each futures firm's instrumentStats for the master's Firms page; GET /api/instruments shows them (?simulate=N runs the draw, no orders); the Settings page shows the lists read-only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
520 lines
28 KiB
TypeScript
520 lines
28 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[];
|
||
firms: {
|
||
id: string;
|
||
name: string;
|
||
platform: string;
|
||
source: 'master' | 'legacy';
|
||
instruments: string[];
|
||
stats?: {
|
||
maxHoldHours: number;
|
||
sessionProgress: number | null;
|
||
instruments: {
|
||
symbol: string;
|
||
listed: boolean;
|
||
spreadDollars: number;
|
||
feesPerSide: number | null;
|
||
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;
|
||
master_backup_token: string | null;
|
||
proxy_url: 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 [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 [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);
|
||
}
|
||
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);
|
||
}
|
||
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, 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>
|
||
|
||
{/* ── 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>
|
||
|
||
{/* ── 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.
|
||
</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 ? (
|
||
<table className="w-full text-sm border-collapse">
|
||
<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">Entry</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 text-slate-600" title="Per contract: one tick of spread, plus the firm's fees per side when known">
|
||
{x ? `$${x.spreadDollars.toFixed(2)}${x.feesPerSide !== null ? ` + $${x.feesPerSide.toFixed(2)}` : ''}` : '—'}
|
||
</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 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>
|
||
);
|
||
}
|