diff --git a/README.md b/README.md
index 5d81dcd..2afe6a8 100644
--- a/README.md
+++ b/README.md
@@ -248,6 +248,14 @@ own traffic since Vest publishes no API):
**Commission %** to the same (`0.0025`) for the pre-trade checks.
- Exits: the take-profit and stop are legs of the position on Vest's side,
and Vest cancels the other when one triggers.
+- Trades: every position on the accounts, the bot's and anyone else's, from
+ Vest's position history (`GET /v3/positions`) — size, open and close price,
+ P&L, fees, funding and why it closed (stop loss, take profit, liquidation,
+ or an order on the account). Read hourly and a minute after a position
+ closes, into the `trades` table. Vest can't tell an API order from a click,
+ so the bot marks its own opens and closes as it sends them; anything
+ unmarked since marking began was someone else's. The **Trades** button on
+ the Vest firm's dashboard row lists them, and the account page shows its own.
`npm run check` includes the Vest helpers (scripts/check-vest.mjs).
diff --git a/app/accounts/[id]/page.tsx b/app/accounts/[id]/page.tsx
index 3b74400..0d5e1ed 100644
--- a/app/accounts/[id]/page.tsx
+++ b/app/accounts/[id]/page.tsx
@@ -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() {
)}
+
+
{/* Cash History Table */}
{(displayPnL.length > 0 || fundTransactions.length > 0) && (
diff --git a/app/api/trades/route.ts b/app/api/trades/route.ts
new file mode 100644
index 0000000..c94d250
--- /dev/null
+++ b/app/api/trades/route.ts
@@ -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);
+}
diff --git a/app/components/TradesTable.tsx b/app/components/TradesTable.tsx
new file mode 100644
index 0000000..6dc105b
--- /dev/null
+++ b/app/components/TradesTable.tsx
@@ -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 = {
+ 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 Bot;
+ if (by === 'outside') return Outside;
+ return —;
+}
+
+function ClosedBy({ by }: { by: string | null }) {
+ if (by === null) return Open;
+ const c = CLOSED_BY[by] ?? { label: by, cls: 'bg-slate-100 text-slate-600', title: by };
+ return {c.label};
+}
+
+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(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 && (
+
+ {trades.length} trade{trades.length === 1 ? '' : 's'}{trades.length >= limit ? ` (latest ${limit})` : ''}
+ {closed.length > 0 && (
+ <>
+ {' · net '}
+ = 0 ? 'text-green-600' : 'text-red-500'}>{signed(net)}
+ {` · fees $${money(fees)}`}
+ >
+ )}
+
+ );
+
+ const table = !trades
+ ?