From 9fe7fc0ea33be04dffc9253d2aa9ad8c92282a6e Mon Sep 17 00:00:00 2001 From: Brandon Li Date: Tue, 27 Oct 2026 14:14:38 -0500 Subject: [PATCH] Vest: a claim that never reached Vest stops holding the account, and each claim counts once A claim of unknown outcome now lapses where history is read (bookHistory), not only in claimPending: its callers wait on historyReady, and the books check held the account while it counted that claim, so one that never went in held the account for good. The lapse is the same (no WITHDRAW within 30 min of it, a read an hour on) and is saved; an accepted claim still holds. An account token that can't be minted means the request never went out: an order, stop or take-profit reads as not placed, and a claim records no unknown claim. Only the request's own failure may be "unknown". A claim Vest dates on the other side of 20:00 ET than it was booked here no longer leaves a second fund row: the history read takes the local day back (and the latest fund date with it), and sameDayClaims counts the claim on Vest's day. A claim Vest doesn't show yet stays on its day. The report counts payouts one per claim (Vest's WITHDRAWs and the claims they don't show yet, never fewer than the negative fund rows) for the account list, payout states and the completion stage, where two claims on one Vest day were one row. Same-day cycles still share a cycle key; that needs the master. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- lib/master-protocol.ts | 7 +- lib/platform-client.ts | 5 ++ lib/reporter.ts | 14 +++- lib/vest-class.ts | 142 +++++++++++++++++++++++------------------ lib/vest-helpers.ts | 134 +++++++++++++++++++++++++++++++++++++- scripts/check-vest.mjs | 100 ++++++++++++++++++++++++++++- 7 files changed, 334 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index d2537c4..2082bd5 100644 --- a/README.md +++ b/README.md @@ -261,7 +261,9 @@ own traffic since Vest publishes no API): The dashboard's **Auto-claim payouts** box turns it off (`auto_claim`). A claim holds every further claim on the account until Vest's transactions show its WITHDRAW; one whose request failed in transit holds them too, until - the transactions show it went in, or an hour of them shows it didn't. The account page's **Claim + the transactions show it went in, or an hour of them shows it didn't (one + that never went out, for want of an account token, holds nothing). Each + claim counts as its own payout in the report, even two on one Vest day. The account page's **Claim now** button claims the whole profit above the account size at once — any amount from $1 — when the account is flat; it asks first, since it moves money to the login's main account. diff --git a/lib/master-protocol.ts b/lib/master-protocol.ts index 897ccf9..e7a48f4 100644 --- a/lib/master-protocol.ts +++ b/lib/master-protocol.ts @@ -109,9 +109,10 @@ export interface FirmStats { * instances omit it. * * withdrawals = how many times the balance has gone down for a payout - * (negative fund transactions), which tells the master a completed - * cycle's money is out. Omitted while the account's fund history hasn't - * loaded, so a restart can't read as zero withdrawals. + * (negative fund transactions; on Vest, each claim, though a day's share + * one fund row), which tells the master a completed cycle's money is + * out. Omitted while the account's fund history hasn't loaded, so a + * restart can't read as zero withdrawals. * * The rest come from statusVersion builds (platform-client.ts * accountStatus), each left out when null or at its default. status: diff --git a/lib/platform-client.ts b/lib/platform-client.ts index f9427fc..62a6943 100644 --- a/lib/platform-client.ts +++ b/lib/platform-client.ts @@ -148,6 +148,11 @@ export interface PlatformClient { /** Payouts made since the firm's last daily reset — what picks the stage * of an account type whose stages reset each day. */ sameDayClaims?(accountId: number): number; + /** Payouts taken from the account, one per withdrawal, where a fund row + * can hold several (Vest books a whole day's claims as one); undefined + * until the platform's own record of them has been read. Without it, + * each negative fund row counts as one. */ + withdrawalCount?(accountId: number): number | undefined; /** Request a payout of `amount` from the account to the login's own main * account, where the platform takes one by API (Vest's Claim Profit). * Resolves once the platform accepted it and the books were updated; diff --git a/lib/reporter.ts b/lib/reporter.ts index ef52e02..fb625ab 100644 --- a/lib/reporter.ts +++ b/lib/reporter.ts @@ -104,11 +104,19 @@ function collectFirmStats() { // standing, while a wrong zero would clear one early. const loadedFundTxns = client.fundTransactions?.[acc.id]; const fundTxns = loadedFundTxns ?? []; + // One per payout: a client whose fund rows hold a whole day's + // (Vest: two claims the same day, one row) counts them itself, + // and says nothing until it has read them — falling back to the + // rows there would report fewer and then more again. + const withdrawals = client.withdrawalCount + ? client.withdrawalCount(acc.id) + : loadedFundTxns?.filter((f) => f.amount < 0).length; + const withdrawalsTaken = withdrawals ?? fundTxns.filter((f) => f.amount < 0).length; accounts.push({ name: acc.name, size: cfg?.accountSize ?? 0, payoutable: cfg ? loops(cfg) : null, - ...(loadedFundTxns ? { withdrawals: loadedFundTxns.filter((f) => f.amount < 0).length } : {}), + ...(withdrawals !== undefined ? { withdrawals } : {}), // For the master's account ledger. Data only: nothing here // trades on it, and the totals below keep their own test. ...statusFieldsOrUnknown(() => accountStatusFields({ @@ -247,7 +255,7 @@ function collectFirmStats() { maxDay: qualifying.length > 0 ? Math.max(...qualifying.map((d) => d.pnl)) : 0, sum, high: highWater, - withdrawals: fundTxns.filter((f) => f.amount < 0).length, + withdrawals: withdrawalsTaken, target: effective.profitTarget, consistency: effective.consistency, minDays: effective.minTradingDays, @@ -329,7 +337,7 @@ function collectFirmStats() { (slice.completedAccounts ??= []).push({ name: acc.name, date: combined.tradeDate, - stage: 1 + fundTxns.filter((f) => f.amount < 0).length, + stage: 1 + withdrawalsTaken, kind: completionKind(cfg, effective.drain), // The fund transaction that started this cycle — the // catch-up names the same cycle by the same key. diff --git a/lib/vest-class.ts b/lib/vest-class.ts index dfe8350..3fc5d1e 100644 --- a/lib/vest-class.ts +++ b/lib/vest-class.ts @@ -42,7 +42,7 @@ import { currentTradeDateCT, rankForTarget, dllReserve, resetClearStop } from '. import { sizingHoldHours } from './person-size'; import { saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions, - saveEndedCycleStart, saveTrades, markTrade, getOrCreateSetting, TRADES_SINCE_SETTING, openTradeAccounts, + saveEndedCycleStart, dropFundTransaction, saveTrades, markTrade, getOrCreateSetting, TRADES_SINCE_SETTING, openTradeAccounts, } from './db'; import { getCachedFirm } from './firm-catalog'; import { firmInstruments } from './instruments'; @@ -54,8 +54,9 @@ import { vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, nextVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder, parseTxn, bookTxns, awaitingLegs, unrealizedPnl, hourlyVolatility, marketOpen, num, toMs, formatDecimal, resetSafeStop, parseHistoryPosition, jwtClaimStrings, accountScopedToken, VEST_STATUS_ACTIVE, VEST_TXN, VEST_STOP_SLIPPAGE_FRACTION, + VestApiError, VestNotSentError, nothingWentIn, tokenForRequest, settleClaims, claimFundRows, payoutCount, payoutsSince, } from './vest-helpers'; -import type { VestCapitalAccount, VestPosition, VestOpenOrder, VestTxn, VestTrade } from './vest-helpers'; +import type { VestCapitalAccount, VestPosition, VestOpenOrder, VestTxn, VestTrade, OwnClaim } from './vest-helpers'; const BASE = 'https://api-gateway.hz.vestmarkets.com'; /** The web app's origin: the gateway serves it, so requests say they are its. */ @@ -113,29 +114,10 @@ const RESET_ENTRY_BLOCK_MS = RESET_GUARD_MS + 2 * 60_000; const GUARD_ECHO_MS = 30_000; /** account_meta key of the profit claims this client made, per account. */ const CLAIMS_META = 'vest_claims'; -/** A claim shows in Vest's transactions as a WITHDRAW of the same amount - * within this long of it. */ -const CLAIM_MATCH_MS = 30 * 60_000; -/** A claim of unknown outcome (the request failed in transit) that no - * WITHDRAW matches in a history read this long after it never happened. */ -const UNKNOWN_CLAIM_EXPIRES_MS = 60 * 60_000; - -/** A claim this client sent: the amount, when, and the balance it left. - * unknown = the request failed in transit, so it may or may not have gone - * in — it holds further claims, but nothing is booked on it. */ -interface OwnClaim { at: number; amount: number; base: number; unknown?: true } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const round2 = (n: number) => Math.round(n * 100) / 100; -/** The gateway answered with an error status. */ -class VestApiError extends Error { - constructor(message: string, readonly status: number, readonly code: string | undefined) { - super(message); - this.name = 'VestApiError'; - } -} - interface SymbolSpec { symbol: string; assetId: number; @@ -350,8 +332,10 @@ export class VestClient implements PlatformClient { * none (`auth: false`, public data). Non-2xx answers become VestApiError * with the gateway's own code; transport failures a plain Error carrying * only the message — never the axios error, whose config holds the token. - * A refused account token is re-minted once; a refused user token is - * dropped, so nothing keeps knocking with it until a fresh one arrives. + * An account token that can't be minted is VestNotSentError: the call + * itself never went out. A refused account token is re-minted once; a + * refused user token is dropped, so nothing keeps knocking with it until + * a fresh one arrives. */ private async request( bucket: Bucket, @@ -361,7 +345,8 @@ export class VestClient implements PlatformClient { ): Promise<{ data: T }> { let token: string | null = null; if (opts.auth !== false) { - token = opts.account ? await this.accountToken(opts.account) : this.userToken(); + const account = opts.account; + token = account ? await tokenForRequest(path, () => this.accountToken(account)) : this.userToken(); if (!token) throw new VestApiError(`${path}: no Vest session`, 401, 'NO_SESSION'); } await this.gate(bucket); @@ -784,10 +769,18 @@ export class VestClient implements PlatformClient { saveAccountMeta(accountId, CLAIMS_META, JSON.stringify(kept)); } - /** Vest's transactions show the claim: a WITHDRAW of its amount near it. */ - private claimShown(accountId: number, c: OwnClaim): boolean { - return (this.withdrawals.get(accountId) ?? []) - .some((w) => Math.abs(Math.abs(w.amount) - c.amount) < 0.01 && Math.abs(w.at - c.at) < CLAIM_MATCH_MS); + /** The claims Vest's transactions, as last read (at `readAt`), don't + * show yet. One of unknown outcome still not shown an hour on never went + * in: it's dropped here (settleClaims). */ + private outstandingClaims(accountId: number, readAt: number): OwnClaim[] { + const list = this.claimsOf(accountId); + const { pending, lapsed } = settleClaims(list, this.withdrawals.get(accountId) ?? [], readAt); + if (lapsed.length > 0) { + const name = this.accountList.find((a) => a.id === accountId)?.name ?? accountId; + this.saveClaims(accountId, list.filter((c) => !lapsed.includes(c))); + for (const c of lapsed) console.warn(`${this.tag} ${name}: the claim of $${c.amount} with an unknown outcome never showed on Vest — taken as not made`); + } + return pending; } /** @@ -808,13 +801,22 @@ export class VestClient implements PlatformClient { } /** See PlatformClient: the payouts since the last 20:00 ET — this client's - * own claims, or the WITHDRAW transactions Vest shows, whichever counts - * more (a claim made by hand shows only there). */ + * own claims, on Vest's day once its transactions show them, or the + * WITHDRAW transactions Vest shows, whichever counts more (a claim made + * by hand shows only there). */ public sameDayClaims(accountId: number): number { - const reset = lastVestResetMs(Date.now()); - const mine = this.madeClaims(accountId).filter((c) => c.at >= reset).length; - const seen = (this.withdrawals.get(accountId) ?? []).filter((w) => w.at >= reset).length; - return Math.max(mine, seen); + return payoutsSince(this.withdrawals.get(accountId) ?? [], this.claimsOf(accountId), lastVestResetMs(Date.now())); + } + + /** See PlatformClient: one per payout — Vest's WITHDRAW transactions, and + * this client's claims they don't show yet (payoutCount) — where a fund + * row holds a whole Vest day's. Undefined until the account's history + * is read. */ + public withdrawalCount(accountId: number): number | undefined { + const withdrawals = this.withdrawals.get(accountId); + const fundRows = this.fundTransactions[accountId]; + if (!withdrawals || !fundRows || !this.historyLoadedIds.has(accountId)) return undefined; + return payoutCount(withdrawals, this.claimsOf(accountId), fundRows); } /** @@ -826,7 +828,8 @@ export class VestClient implements PlatformClient { * The payout is booked here at once — today's fund row, the cycle cut, * the P&L base — so the account can trade its next stage without waiting * for the hourly history read, which then rebooks it from Vest's own - * transactions (the same day's row, the same amount). + * transactions (the same day's row, the same amount — or the day Vest + * dates it, when a claim at 20:00 ET lands on the other side). */ public async claimProfit(accountId: number, amount: number): Promise<{ claimed: number; received: number | null }> { const uuid = this.uuidById.get(accountId); @@ -860,10 +863,11 @@ export class VestClient implements PlatformClient { }, })); } catch (err) { - // Only a refusal proves nothing went in. A timeout, a reset or a - // 5xx may have: hold every further claim on the account until - // Vest's transactions say either way (claimPending). - if (!(err instanceof VestApiError && err.status >= 400 && err.status < 500 && err.status !== 408)) { + // Only a refusal, or no account token to send it with, proves + // nothing went in. A timeout, a reset or a 5xx may have: hold + // every further claim on the account until Vest's transactions + // say either way (claimPending). + if (!nothingWentIn(err)) { this.saveClaims(accountId, [...this.claimsOf(accountId), { at: Date.now(), amount: claimed, base: round2(cash.amount - claimed), unknown: true }]); this.claimSeq.set(accountId, this.syncGeneration); console.error(`${this.tag} ${account.name}: claim of $${claimed} — outcome unknown (${errorMessage(err)}); no further claim on it until Vest's transactions settle it`); @@ -900,15 +904,8 @@ export class VestClient implements PlatformClient { */ public claimPending(accountId: number): boolean { const now = Date.now(); - const readAt = this.historyReadAt.get(accountId) ?? 0; - const list = this.claimsOf(accountId); - const lapsed = list.filter((c) => c.unknown && !this.claimShown(accountId, c) && readAt - c.at > UNKNOWN_CLAIM_EXPIRES_MS); + const pending = this.outstandingClaims(accountId, this.historyReadAt.get(accountId) ?? 0); const name = this.accountList.find((a) => a.id === accountId)?.name ?? accountId; - if (lapsed.length > 0) { - this.saveClaims(accountId, list.filter((c) => !lapsed.includes(c))); - for (const c of lapsed) console.warn(`${this.tag} ${name}: the claim of $${c.amount} with an unknown outcome never showed on Vest — taken as not made`); - } - const pending = this.claimsOf(accountId).filter((c) => !this.claimShown(accountId, c)); for (const c of pending) { const key = `${accountId}:${c.at}`; if (!c.unknown && now - c.at > 60 * 60_000 && !this.unmatchedClaimLogged.has(key)) { @@ -1331,13 +1328,23 @@ export class VestClient implements PlatformClient { .sort((x, y) => x.date.localeCompare(y.date)); saveDailyPnL(account.id, account.name, fresh); - const fundRows = [...funds.entries()] - .map(([date, amount]) => ({ date, amount: round2(amount) })) - .filter((f) => f.amount !== 0) - .sort((x, y) => x.date.localeCompare(y.date)); + const withdrawals = txns.filter((t) => t.type === VEST_TXN.WITHDRAW).map((t) => ({ at: t.time, amount: t.amount })); + this.withdrawals.set(account.id, withdrawals); + const readAt = Date.now(); + this.historyReadAt.set(account.id, readAt); + // Settled here, not only in claimPending: its callers wait on + // historyReady, which a claim that never went in would hold below + // for good. + const outstanding = this.outstandingClaims(account.id, readAt); + + // A claim Vest dates on the other side of 20:00 ET than it was booked + // here leaves that day's row to this client alone: taken back, so the + // payout counts once, on Vest's day. + const { rows: fundRows, dropped } = claimFundRows({ + funds, claims: this.claimsOf(account.id), withdrawals, stored: loadFundTransactions(account.id), + }); + if (dropped.length > 0) this.dropClaimDays(account, dropped); this.applyFundRows(account, fundRows); - this.withdrawals.set(account.id, txns.filter((t) => t.type === VEST_TXN.WITHDRAW).map((t) => ({ at: t.time, amount: t.amount }))); - this.historyReadAt.set(account.id, Date.now()); // Books check: the balance must be what the transactions add up to // (the opening capital included). A payout or credit booked under a @@ -1346,10 +1353,7 @@ export class VestClient implements PlatformClient { const cash = this.accountCashBalances[account.id]; // A claim this client just made may not show in Vest's transactions // yet: count it until its WITHDRAW row does (same amount, near time). - const shown = txns.filter((t) => t.type === VEST_TXN.WITHDRAW); - const unbooked = this.claimsOf(account.id) - .filter((c) => !shown.some((w) => Math.abs(Math.abs(w.amount) - c.amount) < 0.01 && Math.abs(w.time - c.at) < 30 * 60_000)) - .reduce((s, c) => s + c.amount, 0); + const unbooked = outstanding.reduce((s, c) => s + c.amount, 0); const expected = txns.reduce((s, t) => s + (t.kind === 'margin' ? 0 : t.amount), 0) + (hasDeposit ? 0 : a?.initialCapital ?? 0) - unbooked; if (account.active && cash && !this.positions[account.id] && Math.abs(cash.amount - expected) >= BOOKS_TOLERANCE) { this.booksHeld.add(account.id); @@ -1379,6 +1383,18 @@ export class VestClient implements PlatformClient { this.applyCycleSlice(account.id); } + /** Take back fund rows only a claim of this client's put there (see + * claimFundRows), and the latest fund date with them when it was one — + * back to the latest row left. The ended-cycle start that date saved + * stays: the next fund day saves the same one. */ + private dropClaimDays(account: PlatformAccount, days: string[]): void { + const stored = loadAccountMeta(account.id, 'last_fund_date'); + const left = loadFundTransactions(account.id).map((f) => f.date).filter((d) => !days.includes(d)); + const last = stored !== null && !days.includes(stored) ? stored : left.reduce((x, y) => (x === null || y > x ? y : x), null); + for (const day of days) dropFundTransaction(account.id, day, { last_fund_date: last }); + console.warn(`${this.tag} ${account.name}: the claim booked on ${days.join(', ')} shows on another Vest day — that row taken back`); + } + private fallBackToCache(account: PlatformAccount, msg: string): void { const cached = loadDailyPnL(account.id); this.fundTransactions[account.id] ??= loadFundTransactions(account.id); @@ -1739,9 +1755,10 @@ export class VestClient implements PlatformClient { } /** - * POST an order. Only an answer that proves nothing was placed becomes - * OrderNotPlacedError (a 4xx with the gateway's refusal); a transport - * failure or a 5xx stays a plain Error — it may have gone in. + * POST an order. Only a failure that proves nothing was placed becomes + * OrderNotPlacedError (a 4xx with the gateway's refusal, or no account + * token to send it with); a transport failure or a 5xx stays a plain + * Error — it may have gone in. */ private async submit(uuid: string, path: string, body: Record): Promise<{ orderId?: string; positionId?: string }> { try { @@ -1751,9 +1768,8 @@ export class VestClient implements PlatformClient { positionId: typeof data?.positionId === 'string' ? data.positionId : undefined, }; } catch (err) { - if (err instanceof VestApiError && err.status >= 400 && err.status < 500 && err.status !== 408) { - throw new OrderNotPlacedError(`Vest refused the order: ${err.message}`); - } + if (err instanceof VestNotSentError) throw new OrderNotPlacedError(`Vest order not sent: ${err.message}`); + if (nothingWentIn(err)) throw new OrderNotPlacedError(`Vest refused the order: ${errorMessage(err)}`); throw new Error(`Vest order outcome unknown (it may still have been placed): ${errorMessage(err)}`); } } diff --git a/lib/vest-helpers.ts b/lib/vest-helpers.ts index 80e0e4b..b928c9f 100644 --- a/lib/vest-helpers.ts +++ b/lib/vest-helpers.ts @@ -1,6 +1,7 @@ /** * Pure helpers for the Vest client (lib/vest-class.ts) — parsing the gateway's - * answers, naming accounts, Vest's trading day, market hours, volatility. + * answers and errors, naming accounts, Vest's trading day, profit-claim + * bookkeeping, market hours, volatility. * * Only Node built-ins are imported, so scripts/check-vest.mjs loads this file * directly (node --experimental-strip-types). @@ -52,6 +53,47 @@ export function formatDecimal(value: number, decimals: number): string { return d > 0 ? fixed.replace(/\.?0+$/, '') || '0' : fixed; } +// ── gateway errors ────────────────────────────────────────────────────────── + +/** The gateway answered with an error status. */ +export class VestApiError extends Error { + readonly status: number; + readonly code: string | undefined; + constructor(message: string, status: number, code: string | undefined) { + super(message); + this.name = 'VestApiError'; + this.status = status; + this.code = code; + } +} + +/** The request never went out: the account token it needed couldn't be had. */ +export class VestNotSentError extends Error { + constructor(message: string) { + super(message); + this.name = 'VestNotSentError'; + } +} + +/** Whether a failed request proves nothing it asked for happened: it never + * went out, or the gateway refused it (a 4xx, but not a timeout). A + * transport failure or a 5xx may have gone in. */ +export function nothingWentIn(err: unknown): boolean { + return err instanceof VestNotSentError + || (err instanceof VestApiError && err.status >= 400 && err.status < 500 && err.status !== 408); +} + +/** The account token a request needs, from `mint`. Any failure to mint one — + * a timeout, a 5xx, an answer without a token — is the request not sent, + * never an outcome that could have gone in. */ +export async function tokenForRequest(path: string, mint: () => Promise): Promise { + try { + return await mint(); + } catch (err) { + throw new VestNotSentError(`${path} not sent — no account token: ${err instanceof Error ? err.message : String(err)}`); + } +} + // ── Vest's trading day ────────────────────────────────────────────────────── const ET_PARTS = new Intl.DateTimeFormat('en-US', { @@ -502,6 +544,96 @@ export function bookTxns(txns: VestTxn[]): { daily: Map= 0.01 || Math.abs(w.at - c.at) >= CLAIM_MATCH_MS) continue; + if (!best || Math.abs(w.at - c.at) < Math.abs(best.at - c.at)) best = w; + } + return best; +} + +/** The claims Vest's transactions, as read at `readAt`, don't show: those + * still pending, and those that lapse — of unknown outcome and still not + * shown an hour on, so never made. One Vest accepted never lapses. */ +export function settleClaims(claims: OwnClaim[], withdrawals: VestWithdrawal[], readAt: number): { pending: OwnClaim[]; lapsed: OwnClaim[] } { + const pending: OwnClaim[] = []; + const lapsed: OwnClaim[] = []; + for (const c of claims) { + if (claimWithdrawal(withdrawals, c)) continue; + if (c.unknown && readAt - c.at > UNKNOWN_CLAIM_EXPIRES_MS) lapsed.push(c); + else pending.push(c); + } + return { pending, lapsed }; +} + +/** + * The account's fund rows, one per Vest day: Vest's own (bookTxns' funds), + * plus each claim this client made that the transactions don't show yet, on + * the day it was booked. And the stored days to take back: a day a claim + * was booked on that Vest's transactions leave empty, its WITHDRAW being + * dated another day (the request spanned 20:00 ET, or the clocks differ) — + * kept, the payout would count twice. + */ +export function claimFundRows(o: { + funds: Map; claims: OwnClaim[]; withdrawals: VestWithdrawal[]; stored: { date: string }[]; +}): { rows: { date: string; amount: number }[]; dropped: string[] } { + const funds = new Map(o.funds); + const made = o.claims.filter((c) => !c.unknown); + for (const c of made) { + if (claimWithdrawal(o.withdrawals, c)) continue; + const day = vestDayOf(c.at); + funds.set(day, (funds.get(day) ?? 0) - c.amount); + } + const rows = [...funds.entries()] + .map(([date, amount]) => ({ date, amount: Math.round(amount * 100) / 100 })) + .filter((f) => f.amount !== 0) + .sort((x, y) => x.date.localeCompare(y.date)); + const claimDays = new Set(made.map((c) => vestDayOf(c.at))); + const dropped = o.stored.map((f) => f.date).filter((d) => claimDays.has(d) && !rows.some((r) => r.date === d)); + return { rows, dropped }; +} + +/** Payouts taken from the account, one per claim: every WITHDRAW Vest's + * transactions show (claims made by hand among them), and each claim of + * this client's they don't show yet — a fund row can't say, it holds a + * whole Vest day's. Never fewer than the negative fund rows, the count + * before: a day whose fund transactions net negative with no WITHDRAW + * still counts. */ +export function payoutCount(withdrawals: VestWithdrawal[], claims: OwnClaim[], fundRows: { amount: number }[]): number { + const each = withdrawals.length + claims.filter((c) => !c.unknown && !claimWithdrawal(withdrawals, c)).length; + return Math.max(each, fundRows.filter((f) => f.amount < 0).length); +} + +/** Payouts since `since` (the last 20:00 ET): this client's claims — each at + * its WITHDRAW's time once Vest shows it, Vest's day being the one it + * counts on — or the WITHDRAWs Vest shows, whichever counts more (a claim + * made by hand shows only there). */ +export function payoutsSince(withdrawals: VestWithdrawal[], claims: OwnClaim[], since: number): number { + const mine = claims.filter((c) => !c.unknown && (claimWithdrawal(withdrawals, c)?.at ?? c.at) >= since).length; + const seen = withdrawals.filter((w) => w.at >= since).length; + return Math.max(mine, seen); +} + // ── market data ───────────────────────────────────────────────────────────── /** Fewest hourly returns a volatility is measured from. */ diff --git a/scripts/check-vest.mjs b/scripts/check-vest.mjs index 897556c..ed3c98d 100644 --- a/scripts/check-vest.mjs +++ b/scripts/check-vest.mjs @@ -12,7 +12,8 @@ import { isVestWalletAddress, vestAccountId, num, toMs, formatDecimal, vestDayOf, lastVestResetMs, parseCapitalAccount, vestAccountName, parsePosition, awaitingLegs, parseOpenOrder, unrealizedPnl, parseTxn, bookTxns, txnKind, VEST_TXN, hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS, nextVestResetMs, resetSafeStop, parseHistoryPosition, jwtClaimStrings, - accountScopedToken, + accountScopedToken, VestApiError, VestNotSentError, nothingWentIn, tokenForRequest, claimWithdrawal, settleClaims, + claimFundRows, payoutCount, payoutsSince, } from '../lib/vest-helpers.ts'; import { putVestSession, vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from '../lib/vest-session.ts'; @@ -171,6 +172,103 @@ test('transactions book into daily P&L and fund rows by Vest day', () => { assert.ok(Math.abs(daily.get('2026-10-27').fees - 31.2) < 1e-9); }); +const withdrawalsOf = (txns) => txns.filter((t) => t.type === VEST_TXN.WITHDRAW).map((t) => ({ at: t.time, amount: t.amount })); +const deposit = { action_id: 'd', amount: '50000', created_at: iso('2026-10-20T14:00:00Z'), transaction_type: 1 }; + +test('a claim of unknown outcome that never shows lapses an hour on; one Vest accepted never does', () => { + const at = iso('2026-10-27T15:00:00Z'); + const lost = { at, amount: 2250, base: 50000, unknown: true }; + // Read just after the request failed: it may yet show, so it holds. + assert.deepEqual(settleClaims([lost], [], at + 60_000), { pending: [lost], lapsed: [] }); + // Still not shown by a read an hour on: it never went in. + assert.deepEqual(settleClaims([lost], [], at + 61 * 60_000), { pending: [], lapsed: [lost] }); + // It went in after all: a WITHDRAW of its amount near it settles it. + assert.deepEqual(settleClaims([lost], [{ at: at + 3_000, amount: -2250 }], at + 61 * 60_000), { pending: [], lapsed: [] }); + assert.equal(claimWithdrawal([{ at: at + 3_000, amount: -2249 }], lost), undefined, 'another amount'); + assert.equal(claimWithdrawal([{ at: at + 31 * 60_000, amount: -2250 }], lost), undefined, 'too far off'); + assert.deepEqual(claimWithdrawal([{ at: at + 20 * 60_000, amount: -2250 }, { at: at - 2_000, amount: -2250 }], lost), { at: at - 2_000, amount: -2250 }); + // A claim Vest accepted holds for as long as it doesn't show. + const accepted = { at, amount: 2250, base: 50000 }; + assert.deepEqual(settleClaims([accepted], [], at + 48 * 3_600_000), { pending: [accepted], lapsed: [] }); +}); + +test('an account token that can\'t be minted means the order or claim was never sent', async () => { + const path = '/v3/capital/withdraw'; + assert.equal(await tokenForRequest(path, async () => 'tok'), 'tok'); + const failures = [ + new Error('/v3/auth/account-token timed out after 20s'), + new VestApiError('/v3/auth/account-token: HTTP 502 (HTTP 502)', 502, undefined), + new Error('/v3/auth/account-token answered without a token'), + ]; + for (const failure of failures) { + // As the request's own answer each could have gone in... + assert.equal(nothingWentIn(failure), false); + // ...but from the mint, the request never went out. + await assert.rejects(tokenForRequest(path, async () => { throw failure; }), (err) => + err instanceof VestNotSentError && nothingWentIn(err) && err.message.startsWith(`${path} not sent`)); + } + // The request's own answer: only a refusal proves nothing went in. + assert.equal(nothingWentIn(new VestApiError('x (HTTP 400, code 1001)', 400, '1001')), true); + assert.equal(nothingWentIn(new VestApiError('x: no Vest session', 401, 'NO_SESSION')), true); + assert.equal(nothingWentIn(new VestApiError('x (HTTP 408)', 408, undefined)), false); + assert.equal(nothingWentIn(new VestApiError('x (HTTP 503)', 503, undefined)), false); + assert.equal(nothingWentIn(new Error('x: socket hang up')), false); +}); + +test('a claim Vest dates on the other side of 20:00 ET keeps no fund row of its own', () => { + // Sent at 19:59:59.9 ET on the 26th and answered after 20:00: Vest dates + // its WITHDRAW on the 26th's day, the client booked it on the 27th's. + const claim = { at: iso('2026-10-27T00:00:00.300Z'), amount: 2250, base: 50000 }; + const txns = [deposit, { action_id: 'w1', amount: '2250', created_at: iso('2026-10-26T23:59:59.900Z'), transaction_type: 2 }].map(parseTxn); + const stored = [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-27', amount: -2250 }]; + // Before Vest shows it, it stands where it was booked. + assert.deepEqual(claimFundRows({ funds: bookTxns(txns.slice(0, 1)).funds, claims: [claim], withdrawals: [], stored }), { + rows: [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-27', amount: -2250 }], dropped: [], + }); + // Once it shows, it's on Vest's day, and the 27th's row goes. + assert.deepEqual(claimFundRows({ funds: bookTxns(txns).funds, claims: [claim], withdrawals: withdrawalsOf(txns), stored }), { + rows: [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-26', amount: -2250 }], dropped: ['2026-10-27'], + }); + // The 27th starts with no payout on it — once Vest says so. + const reset = lastVestResetMs(iso('2026-10-27T15:00:00Z')); + assert.equal(payoutsSince(withdrawalsOf(txns), [claim], reset), 0); + assert.equal(payoutsSince([], [claim], reset), 1); + + // Dated the same day: its row stays. A day Vest has other fund rows on + // is Vest's, with a claim it doesn't show yet added. + const sameDay = { at: iso('2026-10-26T18:00:00Z'), amount: 1000, base: 49000 }; + const shown = [deposit, { action_id: 'w2', amount: '1000', created_at: sameDay.at + 2_000, transaction_type: 2 }].map(parseTxn); + assert.deepEqual(claimFundRows({ funds: bookTxns(shown).funds, claims: [sameDay], withdrawals: withdrawalsOf(shown), stored: [{ date: '2026-10-26' }] }).dropped, []); + const credit = parseTxn({ action_id: 'c1', amount: '40', created_at: sameDay.at - 3_600_000, transaction_type: 22 }); + assert.deepEqual(claimFundRows({ funds: bookTxns([credit]).funds, claims: [sameDay], withdrawals: [], stored: [] }).rows, [{ date: '2026-10-26', amount: -960 }]); + // One of unknown outcome was never booked, so it's no day's. + assert.deepEqual(claimFundRows({ funds: new Map(), claims: [{ ...claim, unknown: true }], withdrawals: [], stored }), { rows: [], dropped: [] }); +}); + +test('two claims on one Vest day share a fund row but count as two payouts', () => { + const first = { at: iso('2026-10-27T14:00:00Z'), amount: 2250, base: 50000 }; + const second = { at: iso('2026-10-27T18:30:00Z'), amount: 1500, base: 50000 }; + const txns = [ + deposit, + { action_id: 'w1', amount: '2250', created_at: first.at + 2_000, transaction_type: 2 }, + { action_id: 'w2', amount: '1500', created_at: second.at + 2_000, transaction_type: 2 }, + ].map(parseTxn); + const withdrawals = withdrawalsOf(txns); + const { rows } = claimFundRows({ funds: bookTxns(txns).funds, claims: [first, second], withdrawals, stored: [] }); + assert.deepEqual(rows.filter((f) => f.amount < 0), [{ date: '2026-10-27', amount: -3750 }]); + assert.equal(payoutCount(withdrawals, [first, second], rows), 2); + // The second not shown yet: still two. + assert.equal(payoutCount(withdrawals.slice(0, 1), [first, second], rows), 2); + // Made by hand, they show only as WITHDRAWs. + assert.equal(payoutCount(withdrawals, [], rows), 2); + // Of unknown outcome, one counts only once it shows. + assert.equal(payoutCount([], [{ ...first, unknown: true }], []), 0); + assert.equal(payoutCount(withdrawals.slice(0, 1), [{ ...first, unknown: true }], rows), 1); + // A day that nets negative with no WITHDRAW still counts, as it did. + assert.equal(payoutCount([], [], [{ date: '2026-10-21', amount: -75 }, { date: '2026-10-22', amount: 20 }]), 1); + assert.equal(payoutsSince(withdrawals, [first, second], lastVestResetMs(second.at)), 2); +}); + test('hourly volatility leaves out the gaps', () => { const start = iso('2026-10-19T00:00:00Z'); const kl = [];