diff --git a/README.md b/README.md index d2537c4..395ff46 100644 --- a/README.md +++ b/README.md @@ -261,7 +261,11 @@ 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, and + the cycle a day's second claim starts is sent with its own `cycleSeq` (the + master needs to key on it to keep that cycle apart). 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..3676dcd 100644 --- a/lib/master-protocol.ts +++ b/lib/master-protocol.ts @@ -36,11 +36,16 @@ export interface AccountTypeStats { * cycle ready to withdraw, 'drain' = a drain stage's days booked; * cycle = the date of the fund transaction that started the cycle ('' for * the account's first cycle) — firm + name + cycle identify one - * completed cycle. The list also carries caught-up entries: for up to 7 + * completed cycle, but where one day started several (Vest: claim, + * trade again, claim again — a fund row holds the day's payouts): + * cycleSeq = which of that day's cycles, 1 for the second and so on, + * sent only when above 0 (trading-logic completionPlace). A master that + * keys on it tells them apart; one that doesn't folds them into the + * day's first. The list also carries caught-up entries: for up to 7 * days after a fund transaction starts a new cycle, it includes the cycle * that just ended if that cycle had met the rule. Caught-up entries are * not counted in `completed`. Optional: older instances omit it. */ - completedAccounts?: { name: string; date: string; stage: number; kind: 'pass' | 'payout' | 'drain'; cycle: string }[]; + completedAccounts?: { name: string; date: string; stage: number; kind: 'pass' | 'payout' | 'drain'; cycle: string; cycleSeq?: number }[]; /** Live accounts by trading days completed toward the current target: * {daysTraded: accountCount}. Feeds the master's payout calendar, so * accounts that are dead, DLL-locked, or in the red are left out — the @@ -109,9 +114,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..ffa9356 100644 --- a/lib/platform-client.ts +++ b/lib/platform-client.ts @@ -148,6 +148,12 @@ 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; + /** Each payout taken from the account, by the day it counts on — one + * entry per payout, 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 is one, on + * its date. */ + payoutDays?(accountId: number): string[] | 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..e07787d 100644 --- a/lib/reporter.ts +++ b/lib/reporter.ts @@ -20,7 +20,7 @@ import { getClients } from './clients'; import { getAccountTypeById, getKeptRuleIdsForFirm, getTradableFirms } from './firm-catalog'; import { getAccountConfig } from './account-type-resolver'; import { getSchedulerStatus } from './auto-trade'; -import { botStopsActive, combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig, stageInputsFor } from './trading-logic'; +import { botStopsActive, combineCmeSessions, computeDayPlan, currentTradeDateCT, completionPlace, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig, stageInputsFor } from './trading-logic'; import type { AccountTypeStats, FirmStats, InstrumentStats, SymbolStats } from './master-protocol'; import { accountNextLeg, accountLegShape } from './leg-estimate'; import { sizingHoldHours } from './person-size'; @@ -104,11 +104,22 @@ function collectFirmStats() { // standing, while a wrong zero would clear one early. const loadedFundTxns = client.fundTransactions?.[acc.id]; const fundTxns = loadedFundTxns ?? []; + // One per payout, on its day: a client whose fund rows hold a + // whole day's (Vest: two claims the same day, one row) lists them + // itself, and says nothing until it has read them — falling back + // to the rows there would report fewer and then more again. + const rowPayoutDays = (rows: { date: string; amount: number }[]) => rows.filter((f) => f.amount < 0).map((f) => f.date); + const payoutDays = client.payoutDays + ? client.payoutDays(acc.id) + : loadedFundTxns && rowPayoutDays(loadedFundTxns); + const payoutDaysTaken = payoutDays ?? rowPayoutDays(fundTxns); + const withdrawals = payoutDays?.length; + const withdrawalsTaken = payoutDaysTaken.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 +258,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, @@ -281,12 +292,19 @@ function collectFirmStats() { cfg, }); if (ended) { + // Counted one per payout, as the final day counts this + // cycle, so the two name it alike. + const place = completionPlace({ + payoutDays: payoutDaysTaken, fundTransactions: fundTxns, cycle: ended.cycle, + end: client.lastFundDates?.[acc.id] ?? undefined, + }); (slice.completedAccounts ??= []).push({ name: acc.name, date: ended.date, - stage: ended.stage, + stage: place.stage, kind: completionKind(cfg, ended.drain), cycle: ended.cycle, + ...(place.cycleSeq > 0 ? { cycleSeq: place.cycleSeq } : {}), }); } } @@ -326,14 +344,18 @@ function collectFirmStats() { dayEntry.targetHit++; dayEntry.completed = (dayEntry.completed ?? 0) + 1; } + // The fund transaction that started this cycle, and its + // place among that day's payouts — the catch-up names the + // same cycle by the same key. + const cycle = client.lastFundDates?.[acc.id] ?? ''; + const place = completionPlace({ payoutDays: payoutDaysTaken, fundTransactions: fundTxns, cycle }); (slice.completedAccounts ??= []).push({ name: acc.name, date: combined.tradeDate, - stage: 1 + fundTxns.filter((f) => f.amount < 0).length, + stage: place.stage, kind: completionKind(cfg, effective.drain), - // The fund transaction that started this cycle — the - // catch-up names the same cycle by the same key. - cycle: client.lastFundDates?.[acc.id] ?? '', + cycle, + ...(place.cycleSeq > 0 ? { cycleSeq: place.cycleSeq } : {}), }); continue; } diff --git a/lib/trading-logic.ts b/lib/trading-logic.ts index 9c40fac..d74903f 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -772,6 +772,32 @@ export function cycleCompletion(opts: { return 'none'; } +/** + * A completed cycle's place among the account's payouts, counted one per + * payout: `payoutDays` holds each payout's day once per payout + * (PlatformClient.payoutDays — a Vest day's two claims share a fund row — + * else the negative fund rows' dates). stage = 1 + the payouts before the + * cycle's own: those before `end`, the fund date that ended it (the + * catch-up), else every one (today's final day). cycleSeq = the payouts on + * the cycle's start day (`cycle`, '' = the account's first) that came + * before it, beyond the one whose fund row names that day — 0 but for a + * cycle that a second payout the same day started, which `cycle` alone + * can't tell from that day's first. + */ +export function completionPlace(o: { + payoutDays: string[]; + fundTransactions: { date: string; amount: number }[]; + cycle: string; + end?: string; +}): { stage: number; cycleSeq: number } { + const stage = 1 + o.payoutDays.filter((d) => o.end === undefined || d < o.end).length; + if (o.cycle === '') return { stage, cycleSeq: 0 }; + // A deposit's day (Vest's activation) names the cycle it started; each + // payout that day starts another. + const named = o.fundTransactions.some((f) => f.date === o.cycle && f.amount < 0) ? 1 : 0; + return { stage, cycleSeq: Math.max(0, o.payoutDays.filter((d) => d === o.cycle).length - named) }; +} + /** * Catch-up for a missed final day: did the cycle the latest fund transaction * just ended stand complete — profit target, consistency and minimum days all diff --git a/lib/vest-class.ts b/lib/vest-class.ts index dfe8350..520bbc0 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'; @@ -53,9 +53,10 @@ import { vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } 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, + parseHistoryPosition, jwtClaimStrings, accountScopedToken, VEST_STATUS_ACTIVE, VEST_STOP_SLIPPAGE_FRACTION, + VestApiError, VestNotSentError, nothingWentIn, tokenForRequest, settleClaims, readClaims, payoutDaysOf, 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); @@ -762,7 +747,11 @@ export class VestClient implements PlatformClient { const raw = JSON.parse(loadAccountMeta(accountId, CLAIMS_META) ?? '[]'); list = Array.isArray(raw) ? raw.filter((c) => Number.isFinite(c?.at) && Number.isFinite(c?.amount) && Number.isFinite(c?.base)) - .map((c): OwnClaim => ({ at: c.at, amount: c.amount, base: c.base, ...(c.unknown === true ? { unknown: true as const } : {}) })) + .map((c): OwnClaim => ({ + at: c.at, amount: c.amount, base: c.base, + ...(Number.isFinite(c.sentAt) ? { sentAt: c.sentAt } : {}), + ...(c.unknown === true ? { unknown: true as const } : {}), + })) : []; } catch { list = []; @@ -784,10 +773,21 @@ 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 { pending, lapsed } = settleClaims(this.claimsOf(accountId), this.withdrawals.get(accountId) ?? [], readAt); + this.forgetLapsed(accountId, lapsed); + return pending; + } + + /** Drop claims of unknown outcome that never went in (settleClaims). */ + private forgetLapsed(accountId: number, lapsed: OwnClaim[]): void { + if (lapsed.length === 0) return; + const name = this.accountList.find((a) => a.id === accountId)?.name ?? accountId; + this.saveClaims(accountId, this.claimsOf(accountId).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`); } /** @@ -808,13 +808,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, on its Vest day — Vest's WITHDRAW + * transactions, and this client's claims they don't show yet + * (payoutDaysOf) — where a fund row holds a whole Vest day's. Undefined + * until the account's history is read. */ + public payoutDays(accountId: number): string[] | undefined { + const withdrawals = this.withdrawals.get(accountId); + const fundRows = this.fundTransactions[accountId]; + if (!withdrawals || !fundRows || !this.historyLoadedIds.has(accountId)) return undefined; + return payoutDaysOf(withdrawals, this.claimsOf(accountId), fundRows); } /** @@ -826,7 +835,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); @@ -848,6 +858,10 @@ export class VestClient implements PlatformClient { throw new Error(`$${claimed} is more than the $${round2(cash.amount - meta.initialCapital)} above Vest's starting capital of $${meta.initialCapital}`); } + // Taken before it goes out, which Vest dates it no earlier than: a + // claim answered after 20:00 ET isn't the fresh day's payout until + // Vest says so (payoutsSince). + const sentAt = Date.now(); let data: { trader_amount?: unknown } | undefined; try { ({ data } = await this.request<{ trader_amount?: unknown }>('trade', 'POST', '/v3/capital/withdraw', { @@ -860,11 +874,12 @@ 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)) { - this.saveClaims(accountId, [...this.claimsOf(accountId), { at: Date.now(), amount: claimed, base: round2(cash.amount - claimed), unknown: true }]); + // 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(), sentAt, 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`); void this.fetchDaysTraded([accountId]).catch(() => { /* the hourly read retries */ }); @@ -873,7 +888,7 @@ export class VestClient implements PlatformClient { } const received = num(data?.trader_amount) ?? null; const now = Date.now(); - this.saveClaims(accountId, [...this.claimsOf(accountId), { at: now, amount: claimed, base: round2(cash.amount - claimed) }]); + this.saveClaims(accountId, [...this.claimsOf(accountId), { at: now, sentAt, amount: claimed, base: round2(cash.amount - claimed) }]); this.claimSeq.set(accountId, this.syncGeneration); // Book it now: today's fund row (with whatever else that day holds), @@ -900,15 +915,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 +1339,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)); - 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()); + const readAt = Date.now(); + const read = readClaims({ + txns, funds, claims: this.claimsOf(account.id), stored: loadFundTransactions(account.id), readAt, + opening: hasDeposit ? 0 : a?.initialCapital ?? 0, + }); + this.withdrawals.set(account.id, read.withdrawals); + 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. + this.forgetLapsed(account.id, read.lapsed); + + // 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. + if (read.dropped.length > 0) this.dropClaimDays(account, read.dropped); + this.applyFundRows(account, read.rows); // Books check: the balance must be what the transactions add up to // (the opening capital included). A payout or credit booked under a @@ -1345,12 +1363,9 @@ export class VestClient implements PlatformClient { // cycle as if nothing happened. 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 expected = txns.reduce((s, t) => s + (t.kind === 'margin' ? 0 : t.amount), 0) + (hasDeposit ? 0 : a?.initialCapital ?? 0) - unbooked; + // yet: readClaims counts it until its WITHDRAW row does (same amount, + // near time). + const expected = read.expected; if (account.active && cash && !this.positions[account.id] && Math.abs(cash.amount - expected) >= BOOKS_TOLERANCE) { this.booksHeld.add(account.id); this.lastFetchErrors[account.name] = `balance ${round2(cash.amount)} vs transactions ${round2(expected)} (types ${types.join(',')}) — held until they agree`; @@ -1379,6 +1394,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 +1766,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 +1779,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..7788d84 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,130 @@ 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 }; +} + +/** + * What a history read makes of this client's claims (VestClient.bookHistory): + * Vest's WITHDRAWs; the claims that lapse and those still outstanding + * (settleClaims); the fund rows and the stored days to take back + * (claimFundRows); and the balance the transactions add up to — `opening` + * (the capital of an account Vest funded without a deposit row) included, + * the claims they don't show yet taken out — that the books check holds the + * account to. A lapsed claim is out of it: counted, one that never went in + * would hold the account for good. + */ +export function readClaims(o: { + txns: VestTxn[]; funds: Map; claims: OwnClaim[]; stored: { date: string }[]; readAt: number; opening: number; +}): { + withdrawals: VestWithdrawal[]; lapsed: OwnClaim[]; outstanding: OwnClaim[]; + rows: { date: string; amount: number }[]; dropped: string[]; expected: number; +} { + const withdrawals = o.txns.filter((t) => t.type === VEST_TXN.WITHDRAW).map((t) => ({ at: t.time, amount: t.amount })); + const { pending, lapsed } = settleClaims(o.claims, withdrawals, o.readAt); + const { rows, dropped } = claimFundRows({ funds: o.funds, claims: o.claims.filter((c) => !lapsed.includes(c)), withdrawals, stored: o.stored }); + const unbooked = pending.reduce((s, c) => s + c.amount, 0); + const expected = o.txns.reduce((s, t) => s + (t.kind === 'margin' ? 0 : t.amount), 0) + o.opening - unbooked; + return { withdrawals, lapsed, outstanding: pending, rows, dropped, expected }; +} + +/** Each payout taken from the account, by its Vest day, one entry per + * payout: every WITHDRAW Vest's transactions show (claims made by hand + * among them), and each claim of this client's they don't show yet, on the + * day it was booked — a fund row can't say how many, it holds a whole Vest + * day's. A day whose fund rows net negative with neither still counts once, + * as it did before. Sorted. */ +export function payoutDaysOf(withdrawals: VestWithdrawal[], claims: OwnClaim[], fundRows: { date: string; amount: number }[]): string[] { + const days = [ + ...withdrawals.map((w) => vestDayOf(w.at)), + ...claims.filter((c) => !c.unknown && !claimWithdrawal(withdrawals, c)).map((c) => vestDayOf(c.at)), + ]; + for (const f of fundRows) if (f.amount < 0 && !days.includes(f.date)) days.push(f.date); + return days.sort(); +} + +/** 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, and until then at when it was sent — or the WITHDRAWs Vest + * shows, whichever counts more (a claim made by hand shows only there). + * Sent, not answered: Vest dates it no earlier (clocks in sync), so a + * claim answered just after 20:00 ET can't put the fresh day on a payout + * stage before Vest says it's that day's; one Vest dates after the turn + * reads as the day before until then, the cautious way round. */ +export function payoutsSince(withdrawals: VestWithdrawal[], claims: OwnClaim[], since: number): number { + const mine = claims.filter((c) => !c.unknown && (claimWithdrawal(withdrawals, c)?.at ?? c.sentAt ?? 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-trading-logic.mjs b/scripts/check-trading-logic.mjs index 5d828bd..f8a72b2 100644 --- a/scripts/check-trading-logic.mjs +++ b/scripts/check-trading-logic.mjs @@ -715,6 +715,39 @@ test('an every-day firm can finish its cycle on a Saturday', () => { assert.equal(cycleCompletion(opts), 'none'); }); +import { completionPlace } from '../lib/trading-logic.ts'; + +test('completionPlace: each payout counts, and a day\'s second cycle has a key of its own', () => { + // Vest: activated on the 20th; claims c1 and c2 on the 27th share one + // fund row; c3 on the 28th. + const activated = { date: '2026-10-20', amount: 50000 }; + // Cycle A, the first, completes on the 27th ahead of any claim. + assert.deepEqual(completionPlace({ payoutDays: [], fundTransactions: [activated], cycle: '2026-10-20' }), { stage: 1, cycleSeq: 0 }); + // c1 starts cycle B, which completes the same day. + const b = completionPlace({ payoutDays: ['2026-10-27'], fundTransactions: [activated, { date: '2026-10-27', amount: -2250 }], cycle: '2026-10-27' }); + assert.deepEqual(b, { stage: 2, cycleSeq: 0 }); + // c2 starts cycle C on the same fund row, which completes on the 28th: + // payout 3, and not B's key — the master would fold C into B. + const both = [activated, { date: '2026-10-27', amount: -3750 }]; + const c = completionPlace({ payoutDays: ['2026-10-27', '2026-10-27'], fundTransactions: both, cycle: '2026-10-27' }); + assert.deepEqual(c, { stage: 3, cycleSeq: 1 }); + // C caught up after c3 ended it: the same stage and key as its final day. + assert.deepEqual(completionPlace({ + payoutDays: ['2026-10-27', '2026-10-27', '2026-10-28'], fundTransactions: [...both, { date: '2026-10-28', amount: -1500 }], + cycle: '2026-10-27', end: '2026-10-28', + }), c); + // One fund row per payout (every other platform): 1 + the rows before, no + // cycleSeq — as the stage always read. + const rows = [{ date: '2026-09-01', amount: -500 }, { date: '2026-09-15', amount: -800 }]; + const days = rows.map((f) => f.date); + assert.deepEqual(completionPlace({ payoutDays: days, fundTransactions: rows, cycle: '2026-09-15' }), { stage: 3, cycleSeq: 0 }); + assert.deepEqual(completionPlace({ payoutDays: days, fundTransactions: rows, cycle: '2026-09-01', end: '2026-09-15' }), { stage: 2, cycleSeq: 0 }); + // The account's first cycle; and a claim on the day it was activated, + // whose deposit row names the cycle it started. + assert.deepEqual(completionPlace({ payoutDays: [], fundTransactions: [], cycle: '' }), { stage: 1, cycleSeq: 0 }); + assert.deepEqual(completionPlace({ payoutDays: ['2026-10-20'], fundTransactions: [{ date: '2026-10-20', amount: 47750 }], cycle: '2026-10-20' }), { stage: 2, cycleSeq: 1 }); +}); + // ── DXTrade helpers (lib/dxtrade-helpers.ts) ───────────────────────────────── import { diff --git a/scripts/check-vest.mjs b/scripts/check-vest.mjs index 897556c..ec6d062 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, readClaims, payoutDaysOf, payoutsSince, } from '../lib/vest-helpers.ts'; import { putVestSession, vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from '../lib/vest-session.ts'; @@ -171,6 +172,143 @@ 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('the books stop counting a claim that never went in once it lapses, so it holds the account no longer', () => { + const at = iso('2026-10-27T15:00:00Z'); + const lost = { at, sentAt: at - 20_000, amount: 2250, base: 50150, unknown: true }; + const txns = [deposit, { action_id: 'r1', amount: '2400', created_at: at - 3_600_000, transaction_type: 9 }].map(parseTxn); + const read = (claims, readAt) => readClaims({ txns, funds: bookTxns(txns).funds, claims, stored: [], readAt, opening: 0 }); + // It never went in: the balance is what the transactions add up to. + const cash = 52400; + // Half an hour on it may yet show, so the books still take it out (and hold). + const early = read([lost], at + 30 * 60_000); + assert.deepEqual([early.lapsed, early.outstanding], [[], [lost]]); + assert.equal(cash - early.expected, 2250); + // A read an hour on lapses it, and the books agree again. + const late = read([lost], at + 61 * 60_000); + assert.deepEqual([late.lapsed, late.outstanding], [[lost], []]); + assert.equal(late.expected, cash); + assert.deepEqual(late.rows, [{ date: '2026-10-20', amount: 50000 }]); + // One Vest accepted that doesn't show yet: out of the balance and on its + // day's row, however long it takes. + const accepted = { at, sentAt: at - 20_000, amount: 2250, base: 50150 }; + const waiting = read([accepted], at + 48 * 3_600_000); + assert.deepEqual([waiting.lapsed, waiting.outstanding], [[], [accepted]]); + assert.equal(waiting.expected, cash - 2250); + assert.deepEqual(waiting.rows, [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-27', amount: -2250 }]); + // Once it shows, the WITHDRAW itself is in the sum. + const shown = [...txns, parseTxn({ action_id: 'w1', amount: '2250', created_at: at + 2_000, transaction_type: 2 })]; + const settled = readClaims({ txns: shown, funds: bookTxns(shown).funds, claims: [accepted], stored: [], readAt: at + 3_600_000, opening: 0 }); + assert.deepEqual(settled.withdrawals, [{ at: at + 2_000, amount: -2250 }]); + assert.deepEqual(settled.outstanding, []); + assert.equal(settled.expected, cash - 2250); + // An account Vest funded without a deposit row: its capital counts in. + assert.equal(readClaims({ txns: txns.slice(1), funds: new Map(), claims: [], stored: [], readAt: at, opening: 50000 }).expected, cash); +}); + +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.8 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'), sentAt: iso('2026-10-26T23:59:59.800Z'), 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, before Vest shows the claim too: + // it went out on the 26th, and Vest can't date it earlier. + const reset = lastVestResetMs(iso('2026-10-27T15:00:00Z')); + assert.equal(payoutsSince(withdrawalsOf(txns), [claim], reset), 0); + assert.equal(payoutsSince([], [claim], reset), 0); + // Dated after the turn instead, it's the 27th's once Vest shows it. + const after = [deposit, { action_id: 'w3', amount: '2250', created_at: iso('2026-10-27T00:00:00.100Z'), transaction_type: 2 }].map(parseTxn); + assert.equal(payoutsSince(withdrawalsOf(after), [claim], reset), 1); + // One saved before the send time was kept counts from its answer. + assert.equal(payoutsSince([], [{ at: claim.at, amount: claim.amount, base: claim.base }], 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.deepEqual(payoutDaysOf(withdrawals, [first, second], rows), ['2026-10-27', '2026-10-27']); + // The second not shown yet: still two, on the day it was booked. + assert.deepEqual(payoutDaysOf(withdrawals.slice(0, 1), [first, second], rows), ['2026-10-27', '2026-10-27']); + // Made by hand, they show only as WITHDRAWs. + assert.equal(payoutDaysOf(withdrawals, [], rows).length, 2); + // Of unknown outcome, one counts only once it shows. + assert.deepEqual(payoutDaysOf([], [{ ...first, unknown: true }], []), []); + assert.equal(payoutDaysOf(withdrawals.slice(0, 1), [{ ...first, unknown: true }], rows).length, 1); + // A day that nets negative with no WITHDRAW still counts, as it did. + assert.deepEqual(payoutDaysOf([], [], [{ date: '2026-10-21', amount: -75 }, { date: '2026-10-22', amount: 20 }]), ['2026-10-21']); + assert.deepEqual(payoutDaysOf(withdrawals, [], [...rows, { date: '2026-10-21', amount: -75 }]), ['2026-10-21', '2026-10-27', '2026-10-27']); + 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 = [];