Vest: record every trade, the bot's and outside ones, and list them
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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
29eaf64a6b
commit
95cc3bd23f
@@ -18,6 +18,7 @@ import type { FirmConfig, FirmState, AccountState, AccountConfig } from '@/types
|
||||
import { stagePillClasses } from '@/lib/stage-colors';
|
||||
import { projectPerfectPath } from '@/lib/trading-logic';
|
||||
import { RulesDate } from '@/app/components/RulesDate';
|
||||
import { TradesTable } from '@/app/components/TradesTable';
|
||||
|
||||
/** Reassign this account to another of its firm's account types, or hand it
|
||||
* back to the auto-resolver. Mirrors the control on the dashboard. */
|
||||
@@ -838,6 +839,8 @@ export default function AccountPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TradesTable query={`accountId=${accountId}`} title="Trades" />
|
||||
|
||||
{/* Cash History Table */}
|
||||
{(displayPnL.length > 0 || fundTransactions.length > 0) && (
|
||||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-6 mt-5">
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getClients } from '@/lib/clients';
|
||||
import { loadTrades, getSetting, TRADES_SINCE_SETTING } from '@/lib/db';
|
||||
import type { TradeView } from '@/types';
|
||||
|
||||
/**
|
||||
* GET /api/trades?accountId=… | ?firmId=… [&limit=…] — recorded positions,
|
||||
* newest first: one account, a firm's current accounts, or all of them.
|
||||
*
|
||||
* Who opened and closed each one: the bot marks its own orders as it sends
|
||||
* them, so after marking began (TRADES_SINCE_SETTING) anything unmarked was
|
||||
* someone else's. A close Vest calls "user_requested" is an order on the
|
||||
* account — the bot's if it marked it, else outside.
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
const url = new URL(req.url);
|
||||
const accountId = Number(url.searchParams.get('accountId'));
|
||||
const firmId = url.searchParams.get('firmId');
|
||||
const limit = Math.min(Math.max(Math.floor(Number(url.searchParams.get('limit'))) || 200, 1), 1000);
|
||||
|
||||
let accountIds: number[] | undefined;
|
||||
if (Number.isFinite(accountId) && accountId > 0) accountIds = [accountId];
|
||||
else if (firmId) accountIds = getClients().get(firmId)?.accountList.map((a) => a.id) ?? [];
|
||||
|
||||
const since = Number(getSetting(TRADES_SINCE_SETTING) ?? NaN);
|
||||
const tracked = (at: number) => Number.isFinite(since) && at >= since;
|
||||
|
||||
const trades: TradeView[] = loadTrades({ accountIds, limit }).map((t) => {
|
||||
const open = t.closedAt === null && t.closeReason === null && t.closedBy === null;
|
||||
const closedBy = open ? null
|
||||
: t.closedBy === 'bot' ? 'bot'
|
||||
: t.closeReason === 'user_requested' ? (tracked(t.closedAt ?? t.openedAt) ? 'outside' : 'user_requested')
|
||||
: t.closeReason ?? 'unknown';
|
||||
const net = open || t.pnl === null ? null
|
||||
: Math.round((t.pnl - Math.abs(t.fee ?? 0) + (t.funding ?? 0)) * 100) / 100;
|
||||
return {
|
||||
positionId: t.positionId,
|
||||
accountId: t.accountId,
|
||||
accountName: t.accountName,
|
||||
symbol: t.symbol,
|
||||
side: t.side,
|
||||
quantity: t.quantity,
|
||||
openPrice: t.openPrice,
|
||||
closePrice: t.closePrice,
|
||||
pnl: t.pnl,
|
||||
fee: t.fee,
|
||||
funding: t.funding,
|
||||
net,
|
||||
openedAt: t.openedAt,
|
||||
closedAt: t.closedAt,
|
||||
openedBy: t.openedBy === 'bot' ? 'bot' : tracked(t.openedAt) ? 'outside' : null,
|
||||
closedBy,
|
||||
};
|
||||
});
|
||||
return NextResponse.json(trades);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import Link from 'next/link';
|
||||
import type { FirmConfig, FirmState, AccountState } from '@/types';
|
||||
import { stagePillClasses } from '@/lib/stage-colors';
|
||||
import { RulesDate } from '@/app/components/RulesDate';
|
||||
import { TradesTable } from '@/app/components/TradesTable';
|
||||
|
||||
type SortKey = 'name' | 'balance' | 'dayPnL' | 'daysTraded' | 'target' | 'status';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
@@ -533,6 +534,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
||||
onStateChanged: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [showTrades, setShowTrades] = useState(false);
|
||||
const [confirmCreds, setConfirmCreds] = useState(false);
|
||||
const [removingCreds, setRemovingCreds] = useState(false);
|
||||
const hasCredentials = firm.hasCredentials;
|
||||
@@ -650,6 +652,15 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
||||
)}
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{firm.platform === 'vest' && hasCredentials && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowTrades((v) => !v); }}
|
||||
className={`px-2 py-0.5 text-xs font-semibold rounded-md transition-colors ${showTrades ? 'bg-violet-100 text-violet-700' : 'text-slate-500 hover:bg-slate-200'}`}
|
||||
title="Every trade on this firm's accounts — the bot's and anyone else's — with how each one closed"
|
||||
>
|
||||
Trades
|
||||
</button>
|
||||
)}
|
||||
{hasCredentials && !confirmCreds && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setConfirmCreds(true); }}
|
||||
@@ -672,6 +683,14 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{showTrades && hasCredentials && (
|
||||
<tr className="border-b border-slate-100">
|
||||
<td colSpan={7} className="px-4 py-3 pl-12 bg-white">
|
||||
<TradesTable query={`firmId=${encodeURIComponent(firm.id)}`} showAccount formatAccount={(n) => maskName(n, privacy)} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{open && hasCredentials && (
|
||||
state.accounts.length === 0 ? (
|
||||
<tr>
|
||||
|
||||
Reference in New Issue
Block a user