Merge branch 'vest-fix-claims' into vest-fixes

This commit is contained in:
Brandon Li
2026-10-27 14:51:04 -05:00
9 changed files with 514 additions and 86 deletions
+5 -1
View File
@@ -261,7 +261,11 @@ own traffic since Vest publishes no API):
The dashboard's **Auto-claim payouts** box turns it off (`auto_claim`). A 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 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 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 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 amount from $1 — when the account is flat; it asks first, since it moves
money to the login's main account. money to the login's main account.
+11 -5
View File
@@ -36,11 +36,16 @@ export interface AccountTypeStats {
* cycle ready to withdraw, 'drain' = a drain stage's days booked; * cycle ready to withdraw, 'drain' = a drain stage's days booked;
* cycle = the date of the fund transaction that started the cycle ('' for * cycle = the date of the fund transaction that started the cycle ('' for
* the account's first cycle) — firm + name + cycle identify one * 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 * 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 * that just ended if that cycle had met the rule. Caught-up entries are
* not counted in `completed`. Optional: older instances omit it. */ * 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: /** Live accounts by trading days completed toward the current target:
* {daysTraded: accountCount}. Feeds the master's payout calendar, so * {daysTraded: accountCount}. Feeds the master's payout calendar, so
* accounts that are dead, DLL-locked, or in the red are left out — the * accounts that are dead, DLL-locked, or in the red are left out — the
@@ -109,9 +114,10 @@ export interface FirmStats {
* instances omit it. * instances omit it.
* *
* withdrawals = how many times the balance has gone down for a payout * withdrawals = how many times the balance has gone down for a payout
* (negative fund transactions), which tells the master a completed * (negative fund transactions; on Vest, each claim, though a day's share
* cycle's money is out. Omitted while the account's fund history hasn't * one fund row), which tells the master a completed cycle's money is
* loaded, so a restart can't read as zero withdrawals. * 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 * The rest come from statusVersion builds (platform-client.ts
* accountStatus), each left out when null or at its default. status: * accountStatus), each left out when null or at its default. status:
+6
View File
@@ -148,6 +148,12 @@ export interface PlatformClient {
/** Payouts made since the firm's last daily reset — what picks the stage /** Payouts made since the firm's last daily reset — what picks the stage
* of an account type whose stages reset each day. */ * of an account type whose stages reset each day. */
sameDayClaims?(accountId: number): number; 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 /** 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). * account, where the platform takes one by API (Vest's Claim Profit).
* Resolves once the platform accepted it and the books were updated; * Resolves once the platform accepted it and the books were updated;
+30 -8
View File
@@ -20,7 +20,7 @@ import { getClients } from './clients';
import { getAccountTypeById, getKeptRuleIdsForFirm, getTradableFirms } from './firm-catalog'; import { getAccountTypeById, getKeptRuleIdsForFirm, getTradableFirms } from './firm-catalog';
import { getAccountConfig } from './account-type-resolver'; import { getAccountConfig } from './account-type-resolver';
import { getSchedulerStatus } from './auto-trade'; 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 type { AccountTypeStats, FirmStats, InstrumentStats, SymbolStats } from './master-protocol';
import { accountNextLeg, accountLegShape } from './leg-estimate'; import { accountNextLeg, accountLegShape } from './leg-estimate';
import { sizingHoldHours } from './person-size'; import { sizingHoldHours } from './person-size';
@@ -104,11 +104,22 @@ function collectFirmStats() {
// standing, while a wrong zero would clear one early. // standing, while a wrong zero would clear one early.
const loadedFundTxns = client.fundTransactions?.[acc.id]; const loadedFundTxns = client.fundTransactions?.[acc.id];
const fundTxns = loadedFundTxns ?? []; 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({ accounts.push({
name: acc.name, name: acc.name,
size: cfg?.accountSize ?? 0, size: cfg?.accountSize ?? 0,
payoutable: cfg ? loops(cfg) : null, 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 // For the master's account ledger. Data only: nothing here
// trades on it, and the totals below keep their own test. // trades on it, and the totals below keep their own test.
...statusFieldsOrUnknown(() => accountStatusFields({ ...statusFieldsOrUnknown(() => accountStatusFields({
@@ -247,7 +258,7 @@ function collectFirmStats() {
maxDay: qualifying.length > 0 ? Math.max(...qualifying.map((d) => d.pnl)) : 0, maxDay: qualifying.length > 0 ? Math.max(...qualifying.map((d) => d.pnl)) : 0,
sum, sum,
high: highWater, high: highWater,
withdrawals: fundTxns.filter((f) => f.amount < 0).length, withdrawals: withdrawalsTaken,
target: effective.profitTarget, target: effective.profitTarget,
consistency: effective.consistency, consistency: effective.consistency,
minDays: effective.minTradingDays, minDays: effective.minTradingDays,
@@ -281,12 +292,19 @@ function collectFirmStats() {
cfg, cfg,
}); });
if (ended) { 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({ (slice.completedAccounts ??= []).push({
name: acc.name, name: acc.name,
date: ended.date, date: ended.date,
stage: ended.stage, stage: place.stage,
kind: completionKind(cfg, ended.drain), kind: completionKind(cfg, ended.drain),
cycle: ended.cycle, cycle: ended.cycle,
...(place.cycleSeq > 0 ? { cycleSeq: place.cycleSeq } : {}),
}); });
} }
} }
@@ -326,14 +344,18 @@ function collectFirmStats() {
dayEntry.targetHit++; dayEntry.targetHit++;
dayEntry.completed = (dayEntry.completed ?? 0) + 1; 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({ (slice.completedAccounts ??= []).push({
name: acc.name, name: acc.name,
date: combined.tradeDate, date: combined.tradeDate,
stage: 1 + fundTxns.filter((f) => f.amount < 0).length, stage: place.stage,
kind: completionKind(cfg, effective.drain), kind: completionKind(cfg, effective.drain),
// The fund transaction that started this cycle — the cycle,
// catch-up names the same cycle by the same key. ...(place.cycleSeq > 0 ? { cycleSeq: place.cycleSeq } : {}),
cycle: client.lastFundDates?.[acc.id] ?? '',
}); });
continue; continue;
} }
+26
View File
@@ -772,6 +772,32 @@ export function cycleCompletion(opts: {
return 'none'; 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 * 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 * just ended stand complete — profit target, consistency and minimum days all
+97 -70
View File
@@ -42,7 +42,7 @@ import { currentTradeDateCT, rankForTarget, dllReserve, resetClearStop } from '.
import { sizingHoldHours } from './person-size'; import { sizingHoldHours } from './person-size';
import { import {
saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions, saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions,
saveEndedCycleStart, saveTrades, markTrade, getOrCreateSetting, TRADES_SINCE_SETTING, openTradeAccounts, saveEndedCycleStart, dropFundTransaction, saveTrades, markTrade, getOrCreateSetting, TRADES_SINCE_SETTING, openTradeAccounts,
} from './db'; } from './db';
import { getCachedFirm } from './firm-catalog'; import { getCachedFirm } from './firm-catalog';
import { firmInstruments } from './instruments'; import { firmInstruments } from './instruments';
@@ -53,9 +53,10 @@ import { vestSessionFor, dropVestSession, listVestSessions, refuseVestSession }
import { import {
vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, nextVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder, vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, nextVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder,
parseTxn, bookTxns, awaitingLegs, unrealizedPnl, hourlyVolatility, marketOpen, num, toMs, formatDecimal, resetSafeStop, parseTxn, bookTxns, awaitingLegs, unrealizedPnl, hourlyVolatility, marketOpen, num, toMs, formatDecimal, resetSafeStop,
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'; } 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'; const BASE = 'https://api-gateway.hz.vestmarkets.com';
/** The web app's origin: the gateway serves it, so requests say they are its. */ /** The web app's origin: the gateway serves it, so requests say they are its. */
@@ -113,29 +114,10 @@ const RESET_ENTRY_BLOCK_MS = RESET_GUARD_MS + 2 * 60_000;
const GUARD_ECHO_MS = 30_000; const GUARD_ECHO_MS = 30_000;
/** account_meta key of the profit claims this client made, per account. */ /** account_meta key of the profit claims this client made, per account. */
const CLAIMS_META = 'vest_claims'; 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 sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const round2 = (n: number) => Math.round(n * 100) / 100; 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 { interface SymbolSpec {
symbol: string; symbol: string;
assetId: number; assetId: number;
@@ -350,8 +332,10 @@ export class VestClient implements PlatformClient {
* none (`auth: false`, public data). Non-2xx answers become VestApiError * none (`auth: false`, public data). Non-2xx answers become VestApiError
* with the gateway's own code; transport failures a plain Error carrying * with the gateway's own code; transport failures a plain Error carrying
* only the message — never the axios error, whose config holds the token. * 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 * An account token that can't be minted is VestNotSentError: the call
* dropped, so nothing keeps knocking with it until a fresh one arrives. * 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<T>( private async request<T>(
bucket: Bucket, bucket: Bucket,
@@ -361,7 +345,8 @@ export class VestClient implements PlatformClient {
): Promise<{ data: T }> { ): Promise<{ data: T }> {
let token: string | null = null; let token: string | null = null;
if (opts.auth !== false) { 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'); if (!token) throw new VestApiError(`${path}: no Vest session`, 401, 'NO_SESSION');
} }
await this.gate(bucket); await this.gate(bucket);
@@ -762,7 +747,11 @@ export class VestClient implements PlatformClient {
const raw = JSON.parse(loadAccountMeta(accountId, CLAIMS_META) ?? '[]'); const raw = JSON.parse(loadAccountMeta(accountId, CLAIMS_META) ?? '[]');
list = Array.isArray(raw) list = Array.isArray(raw)
? raw.filter((c) => Number.isFinite(c?.at) && Number.isFinite(c?.amount) && Number.isFinite(c?.base)) ? 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 { } catch {
list = []; list = [];
@@ -784,10 +773,21 @@ export class VestClient implements PlatformClient {
saveAccountMeta(accountId, CLAIMS_META, JSON.stringify(kept)); saveAccountMeta(accountId, CLAIMS_META, JSON.stringify(kept));
} }
/** Vest's transactions show the claim: a WITHDRAW of its amount near it. */ /** The claims Vest's transactions, as last read (at `readAt`), don't
private claimShown(accountId: number, c: OwnClaim): boolean { * show yet. One of unknown outcome still not shown an hour on never went
return (this.withdrawals.get(accountId) ?? []) * in: it's dropped here (settleClaims). */
.some((w) => Math.abs(Math.abs(w.amount) - c.amount) < 0.01 && Math.abs(w.at - c.at) < CLAIM_MATCH_MS); 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 /** See PlatformClient: the payouts since the last 20:00 ET — this client's
* own claims, or the WITHDRAW transactions Vest shows, whichever counts * own claims, on Vest's day once its transactions show them, or the
* more (a claim made by hand shows only there). */ * WITHDRAW transactions Vest shows, whichever counts more (a claim made
* by hand shows only there). */
public sameDayClaims(accountId: number): number { public sameDayClaims(accountId: number): number {
const reset = lastVestResetMs(Date.now()); return payoutsSince(this.withdrawals.get(accountId) ?? [], this.claimsOf(accountId), 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); /** 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 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 * 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 * 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 }> { public async claimProfit(accountId: number, amount: number): Promise<{ claimed: number; received: number | null }> {
const uuid = this.uuidById.get(accountId); 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}`); 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; let data: { trader_amount?: unknown } | undefined;
try { try {
({ data } = await this.request<{ trader_amount?: unknown }>('trade', 'POST', '/v3/capital/withdraw', { ({ data } = await this.request<{ trader_amount?: unknown }>('trade', 'POST', '/v3/capital/withdraw', {
@@ -860,11 +874,12 @@ export class VestClient implements PlatformClient {
}, },
})); }));
} catch (err) { } catch (err) {
// Only a refusal proves nothing went in. A timeout, a reset or a // Only a refusal, or no account token to send it with, proves
// 5xx may have: hold every further claim on the account until // nothing went in. A timeout, a reset or a 5xx may have: hold
// Vest's transactions say either way (claimPending). // every further claim on the account until Vest's transactions
if (!(err instanceof VestApiError && err.status >= 400 && err.status < 500 && err.status !== 408)) { // say either way (claimPending).
this.saveClaims(accountId, [...this.claimsOf(accountId), { at: Date.now(), amount: claimed, base: round2(cash.amount - claimed), unknown: true }]); 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); 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`); 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 */ }); 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 received = num(data?.trader_amount) ?? null;
const now = Date.now(); 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); this.claimSeq.set(accountId, this.syncGeneration);
// Book it now: today's fund row (with whatever else that day holds), // 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 { public claimPending(accountId: number): boolean {
const now = Date.now(); const now = Date.now();
const readAt = this.historyReadAt.get(accountId) ?? 0; const pending = this.outstandingClaims(accountId, 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 name = this.accountList.find((a) => a.id === accountId)?.name ?? accountId; 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) { for (const c of pending) {
const key = `${accountId}:${c.at}`; const key = `${accountId}:${c.at}`;
if (!c.unknown && now - c.at > 60 * 60_000 && !this.unmatchedClaimLogged.has(key)) { 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)); .sort((x, y) => x.date.localeCompare(y.date));
saveDailyPnL(account.id, account.name, fresh); saveDailyPnL(account.id, account.name, fresh);
const fundRows = [...funds.entries()] const readAt = Date.now();
.map(([date, amount]) => ({ date, amount: round2(amount) })) const read = readClaims({
.filter((f) => f.amount !== 0) txns, funds, claims: this.claimsOf(account.id), stored: loadFundTransactions(account.id), readAt,
.sort((x, y) => x.date.localeCompare(y.date)); opening: hasDeposit ? 0 : a?.initialCapital ?? 0,
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.withdrawals.set(account.id, read.withdrawals);
this.historyReadAt.set(account.id, 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.
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 // Books check: the balance must be what the transactions add up to
// (the opening capital included). A payout or credit booked under a // (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. // cycle as if nothing happened.
const cash = this.accountCashBalances[account.id]; const cash = this.accountCashBalances[account.id];
// A claim this client just made may not show in Vest's transactions // 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). // yet: readClaims counts it until its WITHDRAW row does (same amount,
const shown = txns.filter((t) => t.type === VEST_TXN.WITHDRAW); // near time).
const unbooked = this.claimsOf(account.id) const expected = read.expected;
.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;
if (account.active && cash && !this.positions[account.id] && Math.abs(cash.amount - expected) >= BOOKS_TOLERANCE) { if (account.active && cash && !this.positions[account.id] && Math.abs(cash.amount - expected) >= BOOKS_TOLERANCE) {
this.booksHeld.add(account.id); this.booksHeld.add(account.id);
this.lastFetchErrors[account.name] = `balance ${round2(cash.amount)} vs transactions ${round2(expected)} (types ${types.join(',')}) — held until they agree`; 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); 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<string | null>((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 { private fallBackToCache(account: PlatformAccount, msg: string): void {
const cached = loadDailyPnL(account.id); const cached = loadDailyPnL(account.id);
this.fundTransactions[account.id] ??= loadFundTransactions(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 * POST an order. Only a failure that proves nothing was placed becomes
* OrderNotPlacedError (a 4xx with the gateway's refusal); a transport * OrderNotPlacedError (a 4xx with the gateway's refusal, or no account
* failure or a 5xx stays a plain Error — it may have gone in. * 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<string, unknown>): Promise<{ orderId?: string; positionId?: string }> { private async submit(uuid: string, path: string, body: Record<string, unknown>): Promise<{ orderId?: string; positionId?: string }> {
try { try {
@@ -1751,9 +1779,8 @@ export class VestClient implements PlatformClient {
positionId: typeof data?.positionId === 'string' ? data.positionId : undefined, positionId: typeof data?.positionId === 'string' ? data.positionId : undefined,
}; };
} catch (err) { } catch (err) {
if (err instanceof VestApiError && err.status >= 400 && err.status < 500 && err.status !== 408) { if (err instanceof VestNotSentError) throw new OrderNotPlacedError(`Vest order not sent: ${err.message}`);
throw new OrderNotPlacedError(`Vest refused the order: ${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)}`); throw new Error(`Vest order outcome unknown (it may still have been placed): ${errorMessage(err)}`);
} }
} }
+167 -1
View File
@@ -1,6 +1,7 @@
/** /**
* Pure helpers for the Vest client (lib/vest-class.ts) — parsing the gateway's * 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 * Only Node built-ins are imported, so scripts/check-vest.mjs loads this file
* directly (node --experimental-strip-types). * 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; 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<string>): Promise<string> {
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 ────────────────────────────────────────────────────── // ── Vest's trading day ──────────────────────────────────────────────────────
const ET_PARTS = new Intl.DateTimeFormat('en-US', { const ET_PARTS = new Intl.DateTimeFormat('en-US', {
@@ -502,6 +544,130 @@ export function bookTxns(txns: VestTxn[]): { daily: Map<string, { pnl: number; f
return { daily, funds }; return { daily, funds };
} }
// ── profit claims ───────────────────────────────────────────────────────────
/** A claim shows in Vest's transactions as a WITHDRAW of the same amount
* within this long of it. */
export 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. */
export const UNKNOWN_CLAIM_EXPIRES_MS = 60 * 60_000;
/** A claim this client sent: the amount, when (at = once it was answered;
* sentAt = before it went out, absent on claims saved before it was kept),
* 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. */
export interface OwnClaim { at: number; sentAt?: number; amount: number; base: number; unknown?: true }
/** A WITHDRAW transaction: when, and its (negative) amount. */
export interface VestWithdrawal { at: number; amount: number }
/** The WITHDRAW that shows the claim — its amount, within CLAIM_MATCH_MS,
* the nearest in time — or undefined while Vest's transactions don't. */
export function claimWithdrawal(withdrawals: VestWithdrawal[], c: OwnClaim): VestWithdrawal | undefined {
let best: VestWithdrawal | undefined;
for (const w of withdrawals) {
if (Math.abs(Math.abs(w.amount) - c.amount) >= 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<string, number>; 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<string, number>; 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 ───────────────────────────────────────────────────────────── // ── market data ─────────────────────────────────────────────────────────────
/** Fewest hourly returns a volatility is measured from. */ /** Fewest hourly returns a volatility is measured from. */
+33
View File
@@ -715,6 +715,39 @@ test('an every-day firm can finish its cycle on a Saturday', () => {
assert.equal(cycleCompletion(opts), 'none'); 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) ───────────────────────────────── // ── DXTrade helpers (lib/dxtrade-helpers.ts) ─────────────────────────────────
import { import {
+139 -1
View File
@@ -12,7 +12,8 @@ import {
isVestWalletAddress, vestAccountId, num, toMs, formatDecimal, vestDayOf, lastVestResetMs, parseCapitalAccount, isVestWalletAddress, vestAccountId, num, toMs, formatDecimal, vestDayOf, lastVestResetMs, parseCapitalAccount,
vestAccountName, parsePosition, awaitingLegs, parseOpenOrder, unrealizedPnl, parseTxn, bookTxns, txnKind, VEST_TXN, vestAccountName, parsePosition, awaitingLegs, parseOpenOrder, unrealizedPnl, parseTxn, bookTxns, txnKind, VEST_TXN,
hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS, nextVestResetMs, resetSafeStop, parseHistoryPosition, jwtClaimStrings, 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'; } from '../lib/vest-helpers.ts';
import { putVestSession, vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from '../lib/vest-session.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); 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', () => { test('hourly volatility leaves out the gaps', () => {
const start = iso('2026-10-19T00:00:00Z'); const start = iso('2026-10-19T00:00:00Z');
const kl = []; const kl = [];