Auto-claim without the scheduler, and no claim twice

A payout locks in profit and opens nothing, so stopping the trading no
longer stops it: a claim watcher runs the same claims once a minute while
the scheduler is off (its tick still claims while it runs), and the
dashboard's Auto-claim payouts box turns them off.

From two independent reviews of the claim path:
- A claim holds every further claim on the account until Vest's
  transactions show its WITHDRAW. A sync already out when the claim lands
  (by sync generation, not the clock) no longer writes the pre-claim
  balance back, and a fresh sync follows the claim.
- A withdraw that fails in transit (timeout, reset, 5xx) may have gone in:
  it is recorded as of unknown outcome and holds claims the same way,
  until the transactions show it, or an hour of them shows it didn't.
- claimProfit checks Vest's own account: funded (or instant), and no more
  than the balance above its starting capital.
- A claiming account is marked in flight; runClaims sends the amount it
  re-checked under the mutex.
- The watcher starts before the rest of the init can fail; settings are
  validated before any is written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-10-26 20:45:35 -05:00
co-authored by Claude Opus 5.5
parent 016444239e
commit 6f6660fdcc
7 changed files with 210 additions and 31 deletions
+116 -20
View File
@@ -107,6 +107,17 @@ const ASSUMED_SPREAD_TICKS = 1;
const RESET_GUARD_MS = 5 * 60_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;
@@ -179,7 +190,13 @@ export class VestClient implements PlatformClient {
/** Profit claims this client made, per account: when, how much, and the
* balance right after — today's P&L counts from there (realizedSince).
* Loaded from account_meta on first use, so a restart keeps them. */
private claims = new Map<number, { at: number; amount: number; base: number }[]>();
private claims = new Map<number, OwnClaim[]>();
/** The sync generation current when each account last claimed: a sync
* of that generation or older read the balance before the claim. */
private claimSeq = new Map<number, number>();
/** When each account's transactions were last read. */
private historyReadAt = new Map<number, number>();
private unmatchedClaimLogged = new Set<string>();
/** Each account's WITHDRAW transactions at the last history read, so a
* claim made by hand on Vest counts toward today's stage too. */
private withdrawals = new Map<number, { at: number; amount: number }[]>();
@@ -577,6 +594,11 @@ export class VestClient implements PlatformClient {
this.flatAmounts.delete(id);
}
const upnl = mine.reduce((s, p) => s + unrealizedPnl(p, this.marks.get(p.symbol)?.mark ?? NaN), 0);
// A claim made while this sync was out: what it read predates
// the payout, and writing it back would show the profit again —
// a second claim of it. The claim's own booking stands until a
// sync that started after it.
if ((this.claimSeq.get(id) ?? -1) >= gen && this.accountCashBalances[id]) continue;
this.accountCashBalances[id] = {
amount: round2(balance),
realizedPnL: this.realizedSince(id, a, balance, now),
@@ -698,12 +720,15 @@ export class VestClient implements PlatformClient {
return this.dailyFloorOf(a, cash.amount, Date.now()) ?? null;
}
private claimsOf(accountId: number): { at: number; amount: number; base: number }[] {
private claimsOf(accountId: number): OwnClaim[] {
let list = this.claims.get(accountId);
if (!list) {
try {
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)) : [];
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 } : {}) }))
: [];
} catch {
list = [];
}
@@ -712,6 +737,24 @@ export class VestClient implements PlatformClient {
return list;
}
/** The claims this client knows went in (the platform said so). */
private madeClaims(accountId: number): OwnClaim[] {
return this.claimsOf(accountId).filter((c) => !c.unknown);
}
private saveClaims(accountId: number, list: OwnClaim[]): void {
const now = Date.now();
const kept = list.filter((c) => now - c.at < 7 * 86_400_000);
this.claims.set(accountId, kept);
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);
}
/**
* Today's realized P&L as the day plan wants it: since the latest claim
* this client made since the last reset — the plan's new cycle starts
@@ -721,7 +764,7 @@ export class VestClient implements PlatformClient {
*/
private realizedSince(accountId: number, a: VestCapitalAccount, balance: number, now: number): number {
const reset = lastVestResetMs(now);
const mine = this.claimsOf(accountId).filter((c) => c.at >= reset);
const mine = this.madeClaims(accountId).filter((c) => c.at >= reset);
if (mine.length > 0) return round2(balance - mine[mine.length - 1].base);
const resetBalance = this.resetBalanceOf(a, balance, now);
if (resetBalance === undefined) return 0;
@@ -734,7 +777,7 @@ export class VestClient implements PlatformClient {
* more (a claim made by hand shows only there). */
public sameDayClaims(accountId: number): number {
const reset = lastVestResetMs(Date.now());
const mine = this.claimsOf(accountId).filter((c) => c.at >= reset).length;
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);
}
@@ -754,27 +797,49 @@ export class VestClient implements PlatformClient {
const uuid = this.uuidById.get(accountId);
const account = this.accountList.find((a) => a.id === accountId);
const cash = this.accountCashBalances[accountId];
if (!uuid || !account || !cash) throw new Error('account not read yet');
const meta = this.capital.get(accountId);
if (!uuid || !account || !cash || !meta) throw new Error('account not read yet');
if (!this.primaryAccountId) throw new Error('the main account is unknown — no sync has listed it');
if (this.positions[accountId] || (this.openOrders.get(accountId) ?? []).length > 0) throw new Error('positions or orders open');
if (this.claimPending(accountId)) throw new Error("the last claim hasn't shown in Vest's transactions yet");
// Vest's own word over the account type assigned here: only a funded
// (or instant) account pays out, and only above its starting capital.
if (meta.kind !== 'funded' && !/instant/i.test(meta.productType)) {
throw new Error(`Vest shows ${account.name} as ${meta.kind || 'not funded'} — only a funded account's profit can be claimed`);
}
const claimed = Math.floor(amount * 100) / 100;
if (!(claimed >= 1)) throw new Error(`$${claimed} is below Vest's $1 minimum claim`);
if (meta.initialCapital > 0 && claimed > cash.amount - meta.initialCapital + 0.01) {
throw new Error(`$${claimed} is more than the $${round2(cash.amount - meta.initialCapital)} above Vest's starting capital of $${meta.initialCapital}`);
}
const { data } = await this.request<{ trader_amount?: unknown }>('trade', 'POST', '/v3/capital/withdraw', {
account: uuid,
body: {
account_id: uuid,
amount: formatDecimal(claimed, 6),
target_account_id: this.primaryAccountId,
idempotency_key: randomUUID(),
},
});
let data: { trader_amount?: unknown } | undefined;
try {
({ data } = await this.request<{ trader_amount?: unknown }>('trade', 'POST', '/v3/capital/withdraw', {
account: uuid,
body: {
account_id: uuid,
amount: formatDecimal(claimed, 6),
target_account_id: this.primaryAccountId,
idempotency_key: randomUUID(),
},
}));
} 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 }]);
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 */ });
}
throw err;
}
const received = num(data?.trader_amount) ?? null;
const now = Date.now();
const list = [...this.claimsOf(accountId), { at: now, amount: claimed, base: round2(cash.amount - claimed) }]
.filter((c) => now - c.at < 7 * 86_400_000);
this.claims.set(accountId, list);
saveAccountMeta(accountId, CLAIMS_META, JSON.stringify(list));
this.saveClaims(accountId, [...this.claimsOf(accountId), { at: now, 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),
// the cycle cut, and the balance until the next sync reads Vest's.
@@ -785,10 +850,40 @@ export class VestClient implements PlatformClient {
console.log(`${this.tag} ${account.name}: claimed $${claimed}${received !== null ? ` — $${received} to the main account` : ''}`);
void this.fetchDaysTraded([accountId]).catch((err) => console.warn(`${this.tag} history after the claim failed: ${errorMessage(err)}`));
void this.requestSync();
// A fresh sync — after any one already out, whose reads predate this.
void this.syncRunning.then(() => this.requestSync());
return { claimed, received };
}
/**
* See PlatformClient: a claim of this client's that Vest's transactions
* don't show yet — until they do, the balance on hand can't be trusted
* not to show the claimed profit again. One whose outcome was unknown,
* and that a history read an hour on still doesn't show, never went in:
* it's dropped. One Vest accepted holds for as long as it doesn't show
* (noted once in the log after an hour).
*/
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 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)) {
this.unmatchedClaimLogged.add(key);
console.error(`${this.tag} ${name}: the claim of $${c.amount} an hour ago still doesn't show in Vest's transactions — no further claim on this account until it does`);
}
}
return pending.length > 0;
}
/**
* Before each 20:00 ET reset, fit every open position on an account with
* a daily limit into the room the reset will leave (resetSafeStop): a
@@ -1130,6 +1225,7 @@ export class VestClient implements PlatformClient {
.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());
// Books check: the balance must be what the transactions add up to
// (the opening capital included). A payout or credit booked under a