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
@@ -248,6 +248,14 @@ own traffic since Vest publishes no API):
|
|||||||
**Commission %** to the same (`0.0025`) for the pre-trade checks.
|
**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,
|
- Exits: the take-profit and stop are legs of the position on Vest's side,
|
||||||
and Vest cancels the other when one triggers.
|
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).
|
`npm run check` includes the Vest helpers (scripts/check-vest.mjs).
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import type { FirmConfig, FirmState, AccountState, AccountConfig } from '@/types
|
|||||||
import { stagePillClasses } from '@/lib/stage-colors';
|
import { stagePillClasses } from '@/lib/stage-colors';
|
||||||
import { projectPerfectPath } from '@/lib/trading-logic';
|
import { projectPerfectPath } from '@/lib/trading-logic';
|
||||||
import { RulesDate } from '@/app/components/RulesDate';
|
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
|
/** 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. */
|
* back to the auto-resolver. Mirrors the control on the dashboard. */
|
||||||
@@ -838,6 +839,8 @@ export default function AccountPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<TradesTable query={`accountId=${accountId}`} title="Trades" />
|
||||||
|
|
||||||
{/* Cash History Table */}
|
{/* Cash History Table */}
|
||||||
{(displayPnL.length > 0 || fundTransactions.length > 0) && (
|
{(displayPnL.length > 0 || fundTransactions.length > 0) && (
|
||||||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-6 mt-5">
|
<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 type { FirmConfig, FirmState, AccountState } from '@/types';
|
||||||
import { stagePillClasses } from '@/lib/stage-colors';
|
import { stagePillClasses } from '@/lib/stage-colors';
|
||||||
import { RulesDate } from '@/app/components/RulesDate';
|
import { RulesDate } from '@/app/components/RulesDate';
|
||||||
|
import { TradesTable } from '@/app/components/TradesTable';
|
||||||
|
|
||||||
type SortKey = 'name' | 'balance' | 'dayPnL' | 'daysTraded' | 'target' | 'status';
|
type SortKey = 'name' | 'balance' | 'dayPnL' | 'daysTraded' | 'target' | 'status';
|
||||||
type SortDir = 'asc' | 'desc';
|
type SortDir = 'asc' | 'desc';
|
||||||
@@ -533,6 +534,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
|||||||
onStateChanged: () => void;
|
onStateChanged: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
|
const [showTrades, setShowTrades] = useState(false);
|
||||||
const [confirmCreds, setConfirmCreds] = useState(false);
|
const [confirmCreds, setConfirmCreds] = useState(false);
|
||||||
const [removingCreds, setRemovingCreds] = useState(false);
|
const [removingCreds, setRemovingCreds] = useState(false);
|
||||||
const hasCredentials = firm.hasCredentials;
|
const hasCredentials = firm.hasCredentials;
|
||||||
@@ -650,6 +652,15 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
|||||||
)}
|
)}
|
||||||
<td className="px-4 py-3.5">
|
<td className="px-4 py-3.5">
|
||||||
<div className="flex items-center justify-end gap-1">
|
<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 && (
|
{hasCredentials && !confirmCreds && (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setConfirmCreds(true); }}
|
onClick={(e) => { e.stopPropagation(); setConfirmCreds(true); }}
|
||||||
@@ -672,6 +683,14 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</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 && (
|
{open && hasCredentials && (
|
||||||
state.accounts.length === 0 ? (
|
state.accounts.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -577,6 +577,124 @@ export function wipeAccountData(accountId: number): void {
|
|||||||
txn();
|
txn();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Trades ───────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Every position a platform's history shows — the bot's and anyone else's
|
||||||
|
// (Vest: GET /v3/positions). The platform can't tell an API order from a
|
||||||
|
// click, so opened_by / closed_by record what this instance did itself:
|
||||||
|
// 'bot' when its own order opened or closed the position, NULL otherwise.
|
||||||
|
// A wipe leaves these alone: they're refetched, but the marks aren't.
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS trades (
|
||||||
|
position_id TEXT PRIMARY KEY,
|
||||||
|
account_id INTEGER NOT NULL,
|
||||||
|
account_name TEXT NOT NULL,
|
||||||
|
symbol TEXT NOT NULL,
|
||||||
|
side TEXT NOT NULL,
|
||||||
|
quantity REAL NOT NULL,
|
||||||
|
leverage REAL,
|
||||||
|
open_price REAL,
|
||||||
|
close_price REAL,
|
||||||
|
pnl REAL,
|
||||||
|
fee REAL,
|
||||||
|
funding REAL,
|
||||||
|
opened_at INTEGER NOT NULL,
|
||||||
|
closed_at INTEGER,
|
||||||
|
close_reason TEXT,
|
||||||
|
opened_by TEXT,
|
||||||
|
closed_by TEXT,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS trades_account_opened ON trades (account_id, opened_at);
|
||||||
|
`);
|
||||||
|
|
||||||
|
/** settings key: when this instance began marking its own trades (epoch
|
||||||
|
* ms). A position opened before it and never marked is of unknown origin;
|
||||||
|
* one opened after it was opened outside the bot. */
|
||||||
|
export const TRADES_SINCE_SETTING = 'trades_tracked_since';
|
||||||
|
|
||||||
|
export interface TradeRow {
|
||||||
|
positionId: string;
|
||||||
|
accountId: number;
|
||||||
|
accountName: string;
|
||||||
|
symbol: string;
|
||||||
|
side: 'long' | 'short';
|
||||||
|
quantity: number;
|
||||||
|
leverage: number | null;
|
||||||
|
openPrice: number | null;
|
||||||
|
closePrice: number | null;
|
||||||
|
pnl: number | null;
|
||||||
|
fee: number | null;
|
||||||
|
funding: number | null;
|
||||||
|
openedAt: number;
|
||||||
|
closedAt: number | null;
|
||||||
|
closeReason: string | null;
|
||||||
|
openedBy: string | null;
|
||||||
|
closedBy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The platform's view of positions: inserts new ones, refreshes known ones.
|
||||||
|
* A size it no longer reports (0) keeps the one already stored, a field it
|
||||||
|
* leaves out keeps its value, and the bot's marks are never touched. */
|
||||||
|
export function saveTrades(rows: Omit<TradeRow, 'openedBy' | 'closedBy'>[]): void {
|
||||||
|
const up = db.prepare(`
|
||||||
|
INSERT INTO trades (position_id, account_id, account_name, symbol, side, quantity, leverage, open_price, close_price,
|
||||||
|
pnl, fee, funding, opened_at, closed_at, close_reason, updated_at)
|
||||||
|
VALUES (@positionId, @accountId, @accountName, @symbol, @side, @quantity, @leverage, @openPrice, @closePrice,
|
||||||
|
@pnl, @fee, @funding, @openedAt, @closedAt, @closeReason, @updatedAt)
|
||||||
|
ON CONFLICT(position_id) DO UPDATE SET
|
||||||
|
account_name = excluded.account_name,
|
||||||
|
symbol = excluded.symbol,
|
||||||
|
side = excluded.side,
|
||||||
|
quantity = CASE WHEN excluded.quantity > 0 THEN excluded.quantity ELSE trades.quantity END,
|
||||||
|
leverage = COALESCE(excluded.leverage, trades.leverage),
|
||||||
|
open_price = COALESCE(excluded.open_price, trades.open_price),
|
||||||
|
close_price = COALESCE(excluded.close_price, trades.close_price),
|
||||||
|
pnl = COALESCE(excluded.pnl, trades.pnl),
|
||||||
|
fee = COALESCE(excluded.fee, trades.fee),
|
||||||
|
funding = COALESCE(excluded.funding, trades.funding),
|
||||||
|
opened_at = excluded.opened_at,
|
||||||
|
closed_at = COALESCE(excluded.closed_at, trades.closed_at),
|
||||||
|
close_reason = COALESCE(excluded.close_reason, trades.close_reason),
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`);
|
||||||
|
const now = Date.now();
|
||||||
|
const txn = db.transaction(() => {
|
||||||
|
for (const r of rows) up.run({ ...r, updatedAt: now });
|
||||||
|
});
|
||||||
|
txn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** This instance's own order opened (or closed) the position — stored at
|
||||||
|
* once, before the platform's history shows it. */
|
||||||
|
export function markTrade(
|
||||||
|
by: 'opened' | 'closed',
|
||||||
|
t: { positionId: string; accountId: number; accountName: string; symbol: string; side: 'long' | 'short'; quantity: number; openPrice: number | null; openedAt: number },
|
||||||
|
): void {
|
||||||
|
const col = by === 'opened' ? 'opened_by' : 'closed_by';
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO trades (position_id, account_id, account_name, symbol, side, quantity, open_price, opened_at, ${col}, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'bot', ?)
|
||||||
|
ON CONFLICT(position_id) DO UPDATE SET ${col} = 'bot', updated_at = excluded.updated_at
|
||||||
|
`).run(t.positionId, t.accountId, t.accountName, t.symbol, t.side, t.quantity, t.openPrice, t.openedAt, Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Newest first (by close, else open); every account when accountIds is
|
||||||
|
* undefined. */
|
||||||
|
export function loadTrades(o: { accountIds?: number[]; limit: number }): TradeRow[] {
|
||||||
|
if (o.accountIds && o.accountIds.length === 0) return [];
|
||||||
|
const where = o.accountIds ? `WHERE account_id IN (${o.accountIds.map(() => '?').join(', ')})` : '';
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT position_id AS positionId, account_id AS accountId, account_name AS accountName, symbol, side, quantity, leverage,
|
||||||
|
open_price AS openPrice, close_price AS closePrice, pnl, fee, funding, opened_at AS openedAt, closed_at AS closedAt,
|
||||||
|
close_reason AS closeReason, opened_by AS openedBy, closed_by AS closedBy
|
||||||
|
FROM trades ${where}
|
||||||
|
ORDER BY COALESCE(closed_at, opened_at) DESC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(...(o.accountIds ?? []), o.limit) as TradeRow[];
|
||||||
|
}
|
||||||
|
|
||||||
// ── AutoBuyer captures ───────────────────────────────────────────────────────
|
// ── AutoBuyer captures ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
db.exec(`
|
db.exec(`
|
||||||
|
|||||||
+111
-3
@@ -42,7 +42,7 @@ import { currentTradeDateCT, rankForTarget, dllReserve } from './trading-logic';
|
|||||||
import { sizingHoldHours } from './person-size';
|
import { sizingHoldHours } from './person-size';
|
||||||
import {
|
import {
|
||||||
saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions,
|
saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions,
|
||||||
saveEndedCycleStart,
|
saveEndedCycleStart, saveTrades, markTrade, getOrCreateSetting, TRADES_SINCE_SETTING,
|
||||||
} from './db';
|
} from './db';
|
||||||
import { getCachedFirm } from './firm-catalog';
|
import { getCachedFirm } from './firm-catalog';
|
||||||
import { firmInstruments } from './instruments';
|
import { firmInstruments } from './instruments';
|
||||||
@@ -53,9 +53,9 @@ import { vestSessionFor, dropVestSession, listVestSessions } from './vest-sessio
|
|||||||
import {
|
import {
|
||||||
vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, nextVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder,
|
vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, nextVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder,
|
||||||
parseTxn, bookTxns, awaitingLegs, unrealizedPnl, hourlyVolatility, marketOpen, num, toMs, formatDecimal, resetSafeStop,
|
parseTxn, bookTxns, awaitingLegs, unrealizedPnl, hourlyVolatility, marketOpen, num, toMs, formatDecimal, resetSafeStop,
|
||||||
VEST_STATUS_ACTIVE, VEST_TXN,
|
parseHistoryPosition, VEST_STATUS_ACTIVE, VEST_TXN,
|
||||||
} from './vest-helpers';
|
} from './vest-helpers';
|
||||||
import type { VestCapitalAccount, VestPosition, VestOpenOrder, VestTxn } from './vest-helpers';
|
import type { VestCapitalAccount, VestPosition, VestOpenOrder, VestTxn, VestTrade } from './vest-helpers';
|
||||||
|
|
||||||
const BASE = 'https://api-gateway.hz.vestmarkets.com';
|
const BASE = 'https://api-gateway.hz.vestmarkets.com';
|
||||||
/** The web app's origin: the gateway serves it, so requests say they are its. */
|
/** The web app's origin: the gateway serves it, so requests say they are its. */
|
||||||
@@ -87,6 +87,9 @@ const CANDLE_HOURS = 7 * 24;
|
|||||||
/** Transactions per page, and the most pages one account's history reads. */
|
/** Transactions per page, and the most pages one account's history reads. */
|
||||||
const TXN_PAGE = 100;
|
const TXN_PAGE = 100;
|
||||||
const TXN_MAX_PAGES = 200;
|
const TXN_MAX_PAGES = 200;
|
||||||
|
/** Position history (GET /v3/positions) pages, for the trades table. */
|
||||||
|
const TRADES_PAGE = 50;
|
||||||
|
const TRADES_MAX_PAGES = 20;
|
||||||
/** Balance the books can't explain by this much holds the account. */
|
/** Balance the books can't explain by this much holds the account. */
|
||||||
const BOOKS_TOLERANCE = 200;
|
const BOOKS_TOLERANCE = 200;
|
||||||
const SUSPECT_SETTLE_MS = 90_000;
|
const SUSPECT_SETTLE_MS = 90_000;
|
||||||
@@ -208,6 +211,13 @@ export class VestClient implements PlatformClient {
|
|||||||
private historyLoadedIds = new Set<number>();
|
private historyLoadedIds = new Set<number>();
|
||||||
private booksHeld = new Set<number>();
|
private booksHeld = new Set<number>();
|
||||||
private fetchingIds = new Set<number>();
|
private fetchingIds = new Set<number>();
|
||||||
|
/** Accounts whose position history to read again soon: a position
|
||||||
|
* closed, or a flat balance moved (a round trip between two syncs). */
|
||||||
|
private tradesDue = new Set<number>();
|
||||||
|
private tradesFetchInFlight = false;
|
||||||
|
/** Open positions already in the trades table, by id → size stored. */
|
||||||
|
private recordedOpen = new Map<string, number>();
|
||||||
|
private flatAmounts = new Map<number, number>();
|
||||||
|
|
||||||
private gateNext: Record<Bucket, number> = { read: 0, trade: 0, history: 0 };
|
private gateNext: Record<Bucket, number> = { read: 0, trade: 0, history: 0 };
|
||||||
private suspectAccounts = new Map<number, number>();
|
private suspectAccounts = new Map<number, number>();
|
||||||
@@ -232,6 +242,7 @@ export class VestClient implements PlatformClient {
|
|||||||
this.cfg = cfg;
|
this.cfg = cfg;
|
||||||
this.wallet = cfg.wallet.trim().toLowerCase();
|
this.wallet = cfg.wallet.trim().toLowerCase();
|
||||||
this.onSync = onSync;
|
this.onSync = onSync;
|
||||||
|
getOrCreateSetting(TRADES_SINCE_SETTING, () => String(Date.now()));
|
||||||
this.syncInterval = setInterval(() => void this.requestSync(), SYNC_INTERVAL_MS);
|
this.syncInterval = setInterval(() => void this.requestSync(), SYNC_INTERVAL_MS);
|
||||||
this.watchdogInterval = setInterval(() => this.watchdog(), WATCHDOG_INTERVAL_MS);
|
this.watchdogInterval = setInterval(() => this.watchdog(), WATCHDOG_INTERVAL_MS);
|
||||||
void this.requestSync();
|
void this.requestSync();
|
||||||
@@ -534,6 +545,14 @@ export class VestClient implements PlatformClient {
|
|||||||
const amount = free.get(a.id);
|
const amount = free.get(a.id);
|
||||||
if (amount === undefined) { complete = false; continue; }
|
if (amount === undefined) { complete = false; continue; }
|
||||||
const balance = amount + mine.reduce((s, p) => s + p.collateral, 0);
|
const balance = amount + mine.reduce((s, p) => s + p.collateral, 0);
|
||||||
|
this.recordOpenTrades(acc, mine);
|
||||||
|
if (mine.length === 0) {
|
||||||
|
const was = this.flatAmounts.get(id);
|
||||||
|
if (was !== undefined && Math.abs(was - amount) >= 0.01) this.tradesDue.add(id);
|
||||||
|
this.flatAmounts.set(id, amount);
|
||||||
|
} else {
|
||||||
|
this.flatAmounts.delete(id);
|
||||||
|
}
|
||||||
const upnl = mine.reduce((s, p) => s + unrealizedPnl(p, this.marks.get(p.symbol)?.mark ?? NaN), 0);
|
const upnl = mine.reduce((s, p) => s + unrealizedPnl(p, this.marks.get(p.symbol)?.mark ?? NaN), 0);
|
||||||
this.accountCashBalances[id] = {
|
this.accountCashBalances[id] = {
|
||||||
amount: round2(balance),
|
amount: round2(balance),
|
||||||
@@ -553,6 +572,7 @@ export class VestClient implements PlatformClient {
|
|||||||
|
|
||||||
console.log(`${this.tag} sync — ${list.length} account(s), ${Object.keys(this.positions).length} open position(s)`);
|
console.log(`${this.tag} sync — ${list.length} account(s), ${Object.keys(this.positions).length} open position(s)`);
|
||||||
this.maybeFetchDaysTraded();
|
this.maybeFetchDaysTraded();
|
||||||
|
this.maybeRefreshTrades();
|
||||||
if (this.historyLoaded && (this.lastSurveyTradeDate !== currentTradeDateCT() || Date.now() - this.lastSurveyAt >= SURVEY_INTERVAL_MS)) {
|
if (this.historyLoaded && (this.lastSurveyTradeDate !== currentTradeDateCT() || Date.now() - this.lastSurveyAt >= SURVEY_INTERVAL_MS)) {
|
||||||
this.lastSurveyTradeDate = currentTradeDateCT();
|
this.lastSurveyTradeDate = currentTradeDateCT();
|
||||||
this.lastSurveyAt = Date.now();
|
this.lastSurveyAt = Date.now();
|
||||||
@@ -591,6 +611,8 @@ export class VestClient implements PlatformClient {
|
|||||||
if (seen) {
|
if (seen) {
|
||||||
console.log(`${this.tag} ${acc.name}: position closed after ${((Date.now() - seen.at) / 3_600_000).toFixed(2)}h`);
|
console.log(`${this.tag} ${acc.name}: position closed after ${((Date.now() - seen.at) / 3_600_000).toFixed(2)}h`);
|
||||||
this.seenOpenAt.delete(acc.id);
|
this.seenOpenAt.delete(acc.id);
|
||||||
|
this.recordedOpen.delete(seen.positionId);
|
||||||
|
this.tradesDue.add(acc.id);
|
||||||
}
|
}
|
||||||
delete this.positions[acc.id];
|
delete this.positions[acc.id];
|
||||||
return;
|
return;
|
||||||
@@ -771,6 +793,7 @@ export class VestClient implements PlatformClient {
|
|||||||
try {
|
try {
|
||||||
if (decision.action === 'close') {
|
if (decision.action === 'close') {
|
||||||
await this.submit(uuid, '/v3/positions/close', { positionId: pos.positionId, symbol: pos.symbol, orderType: 'market' });
|
await this.submit(uuid, '/v3/positions/close', { positionId: pos.positionId, symbol: pos.symbol, orderType: 'market' });
|
||||||
|
this.markOwn('closed', acc.id, pos);
|
||||||
console.warn(`${this.tag} ${acc.name}: closed ${pos.symbol} before the 8 PM ET reset — no room for it past the reset`);
|
console.warn(`${this.tag} ${acc.name}: closed ${pos.symbol} before the 8 PM ET reset — no room for it past the reset`);
|
||||||
} else {
|
} else {
|
||||||
const triggerPrice = formatDecimal(decision.price, spec.priceDecimals);
|
const triggerPrice = formatDecimal(decision.price, spec.priceDecimals);
|
||||||
@@ -882,6 +905,11 @@ export class VestClient implements PlatformClient {
|
|||||||
failed++;
|
failed++;
|
||||||
this.fallBackToCache(account, errorMessage(err));
|
this.fallBackToCache(account, errorMessage(err));
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
await this.fetchTrades(account);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`${this.tag} ${account.name}: position history read failed: ${errorMessage(err)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
for (const id of ids) this.fetchingIds.delete(id);
|
for (const id of ids) this.fetchingIds.delete(id);
|
||||||
@@ -907,6 +935,84 @@ export class VestClient implements PlatformClient {
|
|||||||
return [...all.values()].sort((a, b) => a.time - b.time);
|
return [...all.values()].sort((a, b) => a.time - b.time);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The account's position history — GET /v3/positions, the app's History
|
||||||
|
* tab — into the trades table: every position, whoever opened it, with
|
||||||
|
* how it closed. */
|
||||||
|
private async fetchTrades(account: PlatformAccount): Promise<void> {
|
||||||
|
const uuid = this.uuidById.get(account.id);
|
||||||
|
if (!uuid) return;
|
||||||
|
const trades: VestTrade[] = [];
|
||||||
|
for (let page = 0; page < TRADES_MAX_PAGES; page++) {
|
||||||
|
const { data } = await this.request<{ positions?: unknown[] | null; total?: unknown }>('history', 'GET', '/v3/positions', {
|
||||||
|
account: uuid, params: { account_id: uuid, limit: TRADES_PAGE, offset: page * TRADES_PAGE },
|
||||||
|
});
|
||||||
|
const rows = data?.positions ?? [];
|
||||||
|
for (const r of rows) {
|
||||||
|
const t = parseHistoryPosition(r);
|
||||||
|
if (t) trades.push(t);
|
||||||
|
}
|
||||||
|
const total = num(data?.total);
|
||||||
|
if (rows.length < TRADES_PAGE || (total !== undefined && (page + 1) * TRADES_PAGE >= total)) break;
|
||||||
|
}
|
||||||
|
if (trades.length > 0) saveTrades(trades.map((t) => ({ ...t, accountId: account.id, accountName: account.name })));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the history of the accounts tradesDue names, one pass at a time.
|
||||||
|
* A failed read waits for the hourly history pass. */
|
||||||
|
private maybeRefreshTrades(): void {
|
||||||
|
if (this.tradesFetchInFlight || this.tradesDue.size === 0 || this.intentionalDisconnect) return;
|
||||||
|
const ids = [...this.tradesDue];
|
||||||
|
this.tradesDue.clear();
|
||||||
|
this.tradesFetchInFlight = true;
|
||||||
|
void (async () => {
|
||||||
|
for (const id of ids) {
|
||||||
|
const account = this.accountList.find((a) => a.id === id);
|
||||||
|
if (!account || this.intentionalDisconnect) continue;
|
||||||
|
try {
|
||||||
|
await this.fetchTrades(account);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`${this.tag} ${account.name}: position history read failed: ${errorMessage(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})().finally(() => {
|
||||||
|
this.tradesFetchInFlight = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open positions into the trades table as they appear (or change size),
|
||||||
|
* so a trade shows before it closes. */
|
||||||
|
private recordOpenTrades(acc: PlatformAccount, list: VestPosition[]): void {
|
||||||
|
const fresh = list.filter((p) => this.recordedOpen.get(p.positionId) !== p.quantity);
|
||||||
|
if (fresh.length === 0) return;
|
||||||
|
try {
|
||||||
|
saveTrades(fresh.map((p) => ({
|
||||||
|
positionId: p.positionId, accountId: acc.id, accountName: acc.name, symbol: p.symbol, side: p.side, quantity: p.quantity,
|
||||||
|
leverage: p.leverage > 0 ? p.leverage : null, openPrice: Number.isFinite(p.openPrice) ? p.openPrice : null, closePrice: null,
|
||||||
|
pnl: null, fee: null, funding: null, openedAt: Number.isFinite(p.openedAt) ? p.openedAt : Date.now(), closedAt: null, closeReason: null,
|
||||||
|
})));
|
||||||
|
for (const p of fresh) this.recordedOpen.set(p.positionId, p.quantity);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`${this.tag} ${acc.name}: couldn't record the open position: ${errorMessage(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** This client's own order opened or closed the position: say so in the
|
||||||
|
* trades table (Vest shows an API close as "self-requested", same as a
|
||||||
|
* click). */
|
||||||
|
private markOwn(by: 'opened' | 'closed', accountId: number, pos: VestPosition): void {
|
||||||
|
if (!pos.positionId) return;
|
||||||
|
try {
|
||||||
|
markTrade(by, {
|
||||||
|
positionId: pos.positionId, accountId, accountName: this.accountList.find((a) => a.id === accountId)?.name ?? String(accountId),
|
||||||
|
symbol: pos.symbol, side: pos.side, quantity: pos.quantity, openPrice: Number.isFinite(pos.openPrice) ? pos.openPrice : null,
|
||||||
|
openedAt: Number.isFinite(pos.openedAt) ? pos.openedAt : Date.now(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`${this.tag} couldn't mark ${pos.positionId} as the bot's: ${errorMessage(err)}`);
|
||||||
|
}
|
||||||
|
if (by === 'closed') this.tradesDue.add(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
private bookHistory(account: PlatformAccount, txns: VestTxn[]): void {
|
private bookHistory(account: PlatformAccount, txns: VestTxn[]): void {
|
||||||
const a = this.capital.get(account.id);
|
const a = this.capital.get(account.id);
|
||||||
const { daily, funds } = bookTxns(txns);
|
const { daily, funds } = bookTxns(txns);
|
||||||
@@ -1306,9 +1412,11 @@ export class VestClient implements PlatformClient {
|
|||||||
this.positions[accountId] = {
|
this.positions[accountId] = {
|
||||||
contractId: symbol, netPos: side === 'long' ? pos.quantity : -pos.quantity, netPrice: pos.openPrice, timestamp: new Date(),
|
contractId: symbol, netPos: side === 'long' ? pos.quantity : -pos.quantity, netPrice: pos.openPrice, timestamp: new Date(),
|
||||||
};
|
};
|
||||||
|
this.markOwn('opened', accountId, pos);
|
||||||
} else if (unitsOrdered >= existing.quantity - step / 2) {
|
} else if (unitsOrdered >= existing.quantity - step / 2) {
|
||||||
this.positionsDetail.set(accountId, []);
|
this.positionsDetail.set(accountId, []);
|
||||||
delete this.positions[accountId];
|
delete this.positions[accountId];
|
||||||
|
this.markOwn('closed', accountId, existing);
|
||||||
}
|
}
|
||||||
const result = {
|
const result = {
|
||||||
orderId: orderNumber, vestOrderId: placed.orderId, price: fill.price, qty: contracts, units: unitsOrdered, accountId, contractId: symbol,
|
orderId: orderNumber, vestOrderId: placed.orderId, price: fill.price, qty: contracts, units: unitsOrdered, accountId, contractId: symbol,
|
||||||
|
|||||||
@@ -283,6 +283,57 @@ export function parsePosition(raw: unknown): VestPosition | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One position from GET /v3/positions — Vest's position history, the app's
|
||||||
|
* History tab: closed positions with how they ended (an open one has no
|
||||||
|
* close yet). pnl is Vest's realized P&L before fees and funding.
|
||||||
|
*/
|
||||||
|
export interface VestTrade {
|
||||||
|
positionId: string;
|
||||||
|
symbol: string;
|
||||||
|
side: 'long' | 'short';
|
||||||
|
/** 0 when Vest doesn't say (a store keeps the size it already knows). */
|
||||||
|
quantity: number;
|
||||||
|
leverage: number | null;
|
||||||
|
openPrice: number | null;
|
||||||
|
closePrice: number | null;
|
||||||
|
pnl: number | null;
|
||||||
|
fee: number | null;
|
||||||
|
funding: number | null;
|
||||||
|
openedAt: number;
|
||||||
|
closedAt: number | null;
|
||||||
|
/** user_requested, stop_loss, take_profit, liquidation, auto_deleverage,
|
||||||
|
* temporary_leverage_limit, corporate_action. */
|
||||||
|
closeReason: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseHistoryPosition(raw: unknown): VestTrade | null {
|
||||||
|
const p = raw as Record<string, unknown>;
|
||||||
|
const id = p?.positionId ?? p?.position_id;
|
||||||
|
if (!p || typeof id !== 'string' || typeof p.symbol !== 'string') return null;
|
||||||
|
const openedAt = toMs(p.openDate ?? p.open_date ?? p.createdAt ?? p.created_at);
|
||||||
|
if (!Number.isFinite(openedAt)) return null;
|
||||||
|
const closedAt = toMs(p.closeDate ?? p.close_date);
|
||||||
|
const openPrice = num(p.openPrice ?? p.open_price);
|
||||||
|
const closePrice = num(p.closePrice ?? p.close_price);
|
||||||
|
const reason = p.closeReason ?? p.close_reason;
|
||||||
|
return {
|
||||||
|
positionId: id,
|
||||||
|
symbol: p.symbol,
|
||||||
|
side: String(p.side).toLowerCase() === 'short' ? 'short' : 'long',
|
||||||
|
quantity: Math.abs(num(p.quantity) ?? 0),
|
||||||
|
leverage: num(p.leverage) ?? null,
|
||||||
|
openPrice: openPrice !== undefined && openPrice > 0 ? openPrice : null,
|
||||||
|
closePrice: closePrice !== undefined && closePrice > 0 ? closePrice : null,
|
||||||
|
pnl: num(p.pnl) ?? null,
|
||||||
|
fee: num(p.fee) ?? null,
|
||||||
|
funding: num(p.funding) ?? null,
|
||||||
|
openedAt,
|
||||||
|
closedAt: Number.isFinite(closedAt) && closedAt > 0 ? closedAt : null,
|
||||||
|
closeReason: typeof reason === 'string' && reason !== '' ? reason : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Exit legs still waiting to trigger. */
|
/** Exit legs still waiting to trigger. */
|
||||||
export function awaitingLegs(p: VestPosition): VestLeg[] {
|
export function awaitingLegs(p: VestPosition): VestLeg[] {
|
||||||
return [...p.stopLosses, ...p.takeProfits].filter((l) => l.status === 'awaiting');
|
return [...p.stopLosses, ...p.takeProfits].filter((l) => l.status === 'awaiting');
|
||||||
|
|||||||
+34
-1
@@ -11,7 +11,7 @@ import assert from 'node:assert/strict';
|
|||||||
import {
|
import {
|
||||||
isVestWalletAddress, vestAccountId, num, toMs, formatDecimal, vestDayOf, lastVestResetMs, parseCapitalAccount,
|
isVestWalletAddress, vestAccountId, num, toMs, formatDecimal, vestDayOf, lastVestResetMs, parseCapitalAccount,
|
||||||
vestAccountName, parsePosition, awaitingLegs, parseOpenOrder, unrealizedPnl, parseTxn, bookTxns, txnKind, VEST_TXN,
|
vestAccountName, parsePosition, awaitingLegs, parseOpenOrder, unrealizedPnl, parseTxn, bookTxns, txnKind, VEST_TXN,
|
||||||
hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS, nextVestResetMs, resetSafeStop,
|
hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS, nextVestResetMs, resetSafeStop, parseHistoryPosition,
|
||||||
} from '../lib/vest-helpers.ts';
|
} from '../lib/vest-helpers.ts';
|
||||||
import { putVestSession, vestSessionFor, dropVestSession, listVestSessions } from '../lib/vest-session.ts';
|
import { putVestSession, vestSessionFor, dropVestSession, listVestSessions } from '../lib/vest-session.ts';
|
||||||
|
|
||||||
@@ -263,3 +263,36 @@ test('resetSafeStop: fit an open position into the room the reset will leave', (
|
|||||||
assert.equal(dd.action, 'stop');
|
assert.equal(dd.action, 'stop');
|
||||||
assert.ok(dd.price > want);
|
assert.ok(dd.price > want);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseHistoryPosition: a closed position from GET /v3/positions', () => {
|
||||||
|
// Shaped like the app's History tab reads it (camelCase, decimal strings).
|
||||||
|
const t = parseHistoryPosition({
|
||||||
|
positionId: 'bdda9024-63a9-4abc-a5a8-f83df8ba030a', accountId: '843f60e2-e013-5a0e-8b6c-face84bc5509', symbol: 'NDX-USD-PERP',
|
||||||
|
side: 'short', quantity: '4.2712', leverage: '50', openPrice: '29529.75', closePrice: '29556.50',
|
||||||
|
pnl: '-114.25', fee: '6.31', funding: '-0.12', collateral: '2522.54', takeProfits: [], stopLosses: [],
|
||||||
|
openDate: 1793065590123, closeDate: 1793066346456, createdAt: 1793065590123, updatedAt: 1793066346456, closeReason: 'user_requested',
|
||||||
|
});
|
||||||
|
assert.deepEqual(t, {
|
||||||
|
positionId: 'bdda9024-63a9-4abc-a5a8-f83df8ba030a', symbol: 'NDX-USD-PERP', side: 'short', quantity: 4.2712, leverage: 50,
|
||||||
|
openPrice: 29529.75, closePrice: 29556.5, pnl: -114.25, fee: 6.31, funding: -0.12,
|
||||||
|
openedAt: 1793065590123, closedAt: 1793066346456, closeReason: 'user_requested',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseHistoryPosition: open, snake_case, seconds, and junk', () => {
|
||||||
|
const open = parseHistoryPosition({
|
||||||
|
position_id: 'p1', symbol: 'GC-PERP', side: 'LONG', quantity: '0', open_price: '4208.9', close_price: '',
|
||||||
|
pnl: '', open_date: 1793065590, close_date: null, close_reason: '',
|
||||||
|
});
|
||||||
|
assert.equal(open.side, 'long');
|
||||||
|
assert.equal(open.quantity, 0, 'a size Vest leaves out reads 0 — the store keeps the one it knows');
|
||||||
|
assert.equal(open.openPrice, 4208.9);
|
||||||
|
assert.equal(open.closePrice, null);
|
||||||
|
assert.equal(open.pnl, null);
|
||||||
|
assert.equal(open.openedAt, 1793065590000);
|
||||||
|
assert.equal(open.closedAt, null);
|
||||||
|
assert.equal(open.closeReason, null);
|
||||||
|
assert.equal(parseHistoryPosition({ symbol: 'GC-PERP', openDate: 1 }), null, 'no id');
|
||||||
|
assert.equal(parseHistoryPosition({ positionId: 'p2', symbol: 'GC-PERP' }), null, 'no open time');
|
||||||
|
assert.equal(parseHistoryPosition(null), null);
|
||||||
|
});
|
||||||
|
|||||||
@@ -176,3 +176,30 @@ export interface FirmState {
|
|||||||
connected: boolean;
|
connected: boolean;
|
||||||
accounts: AccountState[];
|
accounts: AccountState[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One recorded position (GET /api/trades) — Vest's position history plus
|
||||||
|
* what this instance did itself. */
|
||||||
|
export interface TradeView {
|
||||||
|
positionId: string;
|
||||||
|
accountId: number;
|
||||||
|
accountName: string;
|
||||||
|
symbol: string;
|
||||||
|
side: 'long' | 'short';
|
||||||
|
quantity: number;
|
||||||
|
openPrice: number | null;
|
||||||
|
closePrice: number | null;
|
||||||
|
/** Realized P&L before fees and funding. */
|
||||||
|
pnl: number | null;
|
||||||
|
fee: number | null;
|
||||||
|
funding: number | null;
|
||||||
|
/** pnl − fee + funding; null while open (or unknown). */
|
||||||
|
net: number | null;
|
||||||
|
openedAt: number;
|
||||||
|
closedAt: number | null;
|
||||||
|
/** null: opened before this instance began marking its own trades. */
|
||||||
|
openedBy: 'bot' | 'outside' | null;
|
||||||
|
/** null while open; else 'bot', 'outside' (closed by hand or another
|
||||||
|
* tool), or the platform's own reason (stop_loss, take_profit,
|
||||||
|
* liquidation, …; user_requested from before marking began). */
|
||||||
|
closedBy: string | null;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user