Vest's position history (GET /v3/positions) gives each position's size, open and close price, P&L, fees, funding and why it closed. The client reads it hourly and a minute after a position closes (or a flat balance moves) into a new trades table, and stores open positions as they appear. Vest can't tell an API order from a click, so the bot marks its own opens and closes as it sends them; an unmarked trade since marking began was someone else's. The dashboard's Vest firm row gets a Trades button listing every trade on its accounts with who opened it and how it closed; the account page shows its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
177 lines
10 KiB
TypeScript
177 lines
10 KiB
TypeScript
'use client';
|
||
|
||
import { useCallback, useEffect, useState } from 'react';
|
||
import type { TradeView } from '@/types';
|
||
|
||
/**
|
||
* Recorded positions from /api/trades — every trade on the accounts, the
|
||
* bot's and anyone else's, with who opened it and how it closed. On the
|
||
* dashboard under a Vest firm (inline) and on the account page (as a card,
|
||
* hidden while there's nothing to show).
|
||
*/
|
||
|
||
const REFRESH_MS = 30_000;
|
||
|
||
const PILL = 'inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold whitespace-nowrap';
|
||
|
||
const CLOSED_BY: Record<string, { label: string; cls: string; title: string }> = {
|
||
bot: { label: 'Bot', cls: 'bg-violet-100 text-violet-700', title: "The bot's own close order" },
|
||
outside: { label: 'Outside', cls: 'bg-amber-100 text-amber-700', title: 'Closed by an order the bot did not send — by hand on Vest, or another tool' },
|
||
stop_loss: { label: 'Stop loss', cls: 'bg-red-100 text-red-700', title: 'Its stop-loss triggered' },
|
||
take_profit: { label: 'Take profit', cls: 'bg-green-100 text-green-700', title: 'Its take-profit triggered' },
|
||
liquidation: { label: 'Liquidation', cls: 'bg-red-200 text-red-800', title: 'Liquidated by Vest' },
|
||
auto_deleverage: { label: 'Auto-deleverage', cls: 'bg-red-100 text-red-700', title: 'Closed by Vest’s auto-deleverage' },
|
||
temporary_leverage_limit: { label: 'Leverage limit', cls: 'bg-orange-100 text-orange-700', title: 'Reduced by Vest’s temporary (weekend) leverage limit' },
|
||
corporate_action: { label: 'Corporate action', cls: 'bg-slate-100 text-slate-600', title: 'Closed by a corporate action' },
|
||
user_requested: { label: 'Self-requested', cls: 'bg-slate-100 text-slate-600', title: 'An order on the account closed it — from before the bot marked its own closes, so whose is unknown' },
|
||
};
|
||
|
||
function money(v: number): string {
|
||
return v.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
}
|
||
|
||
function signed(v: number): string {
|
||
return `${v >= 0 ? '+' : '−'}$${money(Math.abs(v))}`;
|
||
}
|
||
|
||
function price(v: number | null): string {
|
||
return v === null ? '—' : v.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 });
|
||
}
|
||
|
||
function when(ms: number): string {
|
||
return new Date(ms).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
||
}
|
||
|
||
function OpenedBy({ by }: { by: TradeView['openedBy'] }) {
|
||
if (by === 'bot') return <span className={`${PILL} bg-violet-100 text-violet-700`} title="Opened by the bot">Bot</span>;
|
||
if (by === 'outside') return <span className={`${PILL} bg-amber-100 text-amber-700`} title="Opened by an order the bot did not send — by hand on Vest, or another tool">Outside</span>;
|
||
return <span className="text-slate-400" title="Opened before the bot began marking its own trades">—</span>;
|
||
}
|
||
|
||
function ClosedBy({ by }: { by: string | null }) {
|
||
if (by === null) return <span className={`${PILL} bg-sky-100 text-sky-700`}>Open</span>;
|
||
const c = CLOSED_BY[by] ?? { label: by, cls: 'bg-slate-100 text-slate-600', title: by };
|
||
return <span className={`${PILL} ${c.cls}`} title={c.title}>{c.label}</span>;
|
||
}
|
||
|
||
export function TradesTable({ query, title, showAccount = false, formatAccount = (n) => n, limit = 200 }: {
|
||
/** accountId=… or firmId=… */
|
||
query: string;
|
||
/** Set: render as a titled card, and nothing at all while empty. */
|
||
title?: string;
|
||
showAccount?: boolean;
|
||
formatAccount?: (name: string) => string;
|
||
limit?: number;
|
||
}) {
|
||
const [trades, setTrades] = useState<TradeView[] | null>(null);
|
||
const [failed, setFailed] = useState(false);
|
||
|
||
const load = useCallback(async () => {
|
||
try {
|
||
const res = await fetch(`/api/trades?${query}&limit=${limit}`);
|
||
if (!res.ok) throw new Error(String(res.status));
|
||
setTrades(await res.json());
|
||
setFailed(false);
|
||
} catch {
|
||
setFailed(true);
|
||
}
|
||
}, [query, limit]);
|
||
|
||
useEffect(() => {
|
||
void load();
|
||
const t = setInterval(() => { void load(); }, REFRESH_MS);
|
||
return () => clearInterval(t);
|
||
}, [load]);
|
||
|
||
if (title && (!trades || trades.length === 0)) return null;
|
||
|
||
const closed = (trades ?? []).filter((t) => t.net !== null);
|
||
const net = closed.reduce((s, t) => s + (t.net ?? 0), 0);
|
||
const fees = closed.reduce((s, t) => s + Math.abs(t.fee ?? 0), 0);
|
||
|
||
const summary = trades && trades.length > 0 && (
|
||
<span className="text-xs text-slate-400">
|
||
{trades.length} trade{trades.length === 1 ? '' : 's'}{trades.length >= limit ? ` (latest ${limit})` : ''}
|
||
{closed.length > 0 && (
|
||
<>
|
||
{' · net '}
|
||
<span className={net >= 0 ? 'text-green-600' : 'text-red-500'}>{signed(net)}</span>
|
||
{` · fees $${money(fees)}`}
|
||
</>
|
||
)}
|
||
</span>
|
||
);
|
||
|
||
const table = !trades
|
||
? <div className="text-sm text-slate-400 italic py-2">{failed ? 'Couldn’t load trades' : 'Loading trades…'}</div>
|
||
: trades.length === 0
|
||
? <div className="text-sm text-slate-400 italic py-2">No trades recorded yet — they appear after the next history read (within the hour, or a minute after a position closes)</div>
|
||
: (
|
||
<div className="overflow-x-auto max-h-[28rem] overflow-y-auto">
|
||
<table className="w-full text-xs">
|
||
<thead className="sticky top-0 bg-white">
|
||
<tr className="border-b border-slate-100 text-slate-400">
|
||
<th className="text-left font-medium py-2 pr-3">Opened</th>
|
||
<th className="text-left font-medium py-2 pr-3">Closed</th>
|
||
{showAccount && <th className="text-left font-medium py-2 pr-3">Account</th>}
|
||
<th className="text-left font-medium py-2 pr-3">Market</th>
|
||
<th className="text-left font-medium py-2 pr-3">Side</th>
|
||
<th className="text-right font-medium py-2 pr-3">Size</th>
|
||
<th className="text-right font-medium py-2 pr-3">Open</th>
|
||
<th className="text-right font-medium py-2 pr-3">Close</th>
|
||
<th className="text-right font-medium py-2 pr-3" title="Realized P&L before fees and funding">P&L</th>
|
||
<th className="text-right font-medium py-2 pr-3">Fees</th>
|
||
<th className="text-right font-medium py-2 pr-3" title="P&L − fees + funding">Net</th>
|
||
<th className="text-left font-medium py-2 pr-3">Opened by</th>
|
||
<th className="text-left font-medium py-2">Closed by</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{trades.map((t) => (
|
||
<tr key={t.positionId} className="border-b border-slate-50 last:border-0 hover:bg-slate-50">
|
||
<td className="py-1.5 pr-3 text-slate-500 tabular-nums whitespace-nowrap">{when(t.openedAt)}</td>
|
||
<td className="py-1.5 pr-3 text-slate-500 tabular-nums whitespace-nowrap">{t.closedAt !== null ? when(t.closedAt) : '—'}</td>
|
||
{showAccount && <td className="py-1.5 pr-3 font-mono text-slate-700 whitespace-nowrap">{formatAccount(t.accountName)}</td>}
|
||
<td className="py-1.5 pr-3 text-slate-700 whitespace-nowrap">{t.symbol}</td>
|
||
<td className={`py-1.5 pr-3 font-semibold ${t.side === 'long' ? 'text-green-600' : 'text-red-500'}`}>{t.side === 'long' ? 'Long' : 'Short'}</td>
|
||
<td className="py-1.5 pr-3 text-right tabular-nums text-slate-600">{t.quantity > 0 ? t.quantity.toLocaleString(undefined, { maximumFractionDigits: 4 }) : '—'}</td>
|
||
<td className="py-1.5 pr-3 text-right tabular-nums text-slate-600">{price(t.openPrice)}</td>
|
||
<td className="py-1.5 pr-3 text-right tabular-nums text-slate-600">{price(t.closePrice)}</td>
|
||
<td className={`py-1.5 pr-3 text-right tabular-nums ${t.pnl === null ? 'text-slate-400' : t.pnl >= 0 ? 'text-green-600' : 'text-red-500'}`}>
|
||
{t.pnl === null ? '—' : signed(t.pnl)}
|
||
</td>
|
||
<td className="py-1.5 pr-3 text-right tabular-nums text-slate-500">{t.fee === null ? '—' : `$${money(Math.abs(t.fee))}`}</td>
|
||
<td className={`py-1.5 pr-3 text-right tabular-nums font-semibold ${t.net === null ? 'text-slate-400' : t.net >= 0 ? 'text-green-600' : 'text-red-500'}`}>
|
||
{t.net === null ? '—' : signed(t.net)}
|
||
</td>
|
||
<td className="py-1.5 pr-3"><OpenedBy by={t.openedBy} /></td>
|
||
<td className="py-1.5"><ClosedBy by={t.closedBy} /></td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
|
||
if (title) {
|
||
return (
|
||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-6 mt-5">
|
||
<div className="flex items-baseline justify-between mb-4">
|
||
<h2 className="text-slate-900 font-bold text-base">{title}</h2>
|
||
{summary}
|
||
</div>
|
||
{table}
|
||
</div>
|
||
);
|
||
}
|
||
return (
|
||
<div>
|
||
<div className="flex items-baseline justify-between mb-1">
|
||
<span className="text-xs font-semibold text-slate-600">Trades</span>
|
||
{summary}
|
||
</div>
|
||
{table}
|
||
</div>
|
||
);
|
||
}
|