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 = [];