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 + ?
{failed ? 'Couldn’t load trades' : 'Loading trades…'}
+ : trades.length === 0 + ?
No trades recorded yet — they appear after the next history read (within the hour, or a minute after a position closes)
+ : ( +
+ + + + + + {showAccount && } + + + + + + + + + + + + + + {trades.map((t) => ( + + + + {showAccount && } + + + + + + + + + + + + ))} + +
OpenedClosedAccountMarketSideSizeOpenCloseP&LFeesNetOpened byClosed by
{when(t.openedAt)}{t.closedAt !== null ? when(t.closedAt) : '—'}{formatAccount(t.accountName)}{t.symbol}{t.side === 'long' ? 'Long' : 'Short'}{t.quantity > 0 ? t.quantity.toLocaleString(undefined, { maximumFractionDigits: 4 }) : '—'}{price(t.openPrice)}{price(t.closePrice)}= 0 ? 'text-green-600' : 'text-red-500'}`}> + {t.pnl === null ? '—' : signed(t.pnl)} + {t.fee === null ? '—' : `$${money(Math.abs(t.fee))}`}= 0 ? 'text-green-600' : 'text-red-500'}`}> + {t.net === null ? '—' : signed(t.net)} +
+
+ ); + + if (title) { + return ( +
+
+

{title}

+ {summary} +
+ {table} +
+ ); + } + return ( +
+
+ Trades + {summary} +
+ {table} +
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index b586e4f..6ecee09 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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 )}
+ {firm.platform === 'vest' && hasCredentials && ( + + )} {hasCredentials && !confirmCreds && (