Vest: claim the profit at the target, then trade again the same day

On Vest, a claim lowers the daily-loss floor by the amount claimed. Winning
$1,500 and claiming it leaves $3,000 of room the same day, the full
drawdown. The bot now uses that.

- Payout claims: a scheduler step (runClaims, under the lane mutex, before
  the flat gate) claims every payoutable account that reached its stage's
  target, via a platform that takes payouts by API. The claim is all
  profit above the starting balance. It then clears the account's legs and
  entry outcome for the day so it can trade its next stage. Failures retry
  after 5 minutes. The auto_claim setting 'off' disables it.
- VestClient.claimProfit: POST /v3/capital/withdraw to the login's main
  account (account_type 1 on /v3/accounts). The payout is booked at once:
  today's fund row and the cycle cut, with today's P&L counted from the
  post-claim balance. The history read later rebooks it from Vest's
  WITHDRAW row, and the books check counts a claim until that row shows.
- Same-day stages: an account type can pick its stage by the payouts since
  the firm's last daily reset (stagesSameDay, sameDayClaims). A stage can
  carry its own daily loss limit.
- That limit replaces the type's everywhere room, stops and last stands are
  worked out: the batch, the manual leg, copy trade, the dashboard and the
  report.
- Room from the platform's own floor: dailyLossFloor (Vest) makes the room
  min(stage limit, balance − Vest's floor). A same-day claim that lowers
  the floor therefore frees room, and the room is never more than Vest
  allows. VestClient.dayStartBalance is gone.
- Before the 8 PM ET reset: from 7:55 PM ET, each open Vest position on an
  account with a daily limit is fitted to the room the reset will leave
  (resetSafeStop). Its stop comes in via PUT/POST stop-loss, or the
  position closes when there is no room.
- Checks for same-day stages, the stage limit, floorRoom, the post-claim
  plan, the next reset across both time changes, and resetSafeStop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-10-26 15:56:00 -05:00
co-authored by Claude Opus 5.5
parent 335af486d4
commit 32081f3dcf
13 changed files with 601 additions and 94 deletions
+212 -31
View File
@@ -36,14 +36,14 @@
* - Fees are each symbol's taker fee from exchangeInfo, a percent of notional.
*/
import { randomUUID } from 'crypto';
import axios from './http-client';
import { currentTradeDateCT, rankForTarget } from './trading-logic';
import { currentTradeDateCT, rankForTarget, dllReserve } from './trading-logic';
import { sizingHoldHours } from './person-size';
import {
saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions,
saveEndedCycleStart,
} from './db';
import { getAccountConfig } from './account-type-resolver';
import { getCachedFirm } from './firm-catalog';
import { firmInstruments } from './instruments';
import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth, PickContext } from './platform-client';
@@ -51,9 +51,9 @@ import { OrderNotPlacedError, isOrderNotPlaced, errorMessage } from './platform-
import { encodeContractName, decodeContractName, roundToStep, scoreCandidate } from './dxtrade-helpers';
import { vestSessionFor, dropVestSession, listVestSessions } from './vest-session';
import {
vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder,
parseTxn, bookTxns, awaitingLegs, unrealizedPnl, hourlyVolatility, marketOpen, num, toMs, formatDecimal,
VEST_STATUS_ACTIVE,
vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, nextVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder,
parseTxn, bookTxns, awaitingLegs, unrealizedPnl, hourlyVolatility, marketOpen, num, toMs, formatDecimal, resetSafeStop,
VEST_STATUS_ACTIVE, VEST_TXN,
} from './vest-helpers';
import type { VestCapitalAccount, VestPosition, VestOpenOrder, VestTxn } from './vest-helpers';
@@ -95,6 +95,11 @@ const NAKED_AFTER_MS = 2 * 60_000;
/** Vest's own spread isn't on its REST surface (only the depth socket): a
* candidate is costed as crossing this many ticks. */
const ASSUMED_SPREAD_TICKS = 1;
/** How long before the daily reset open positions are fitted to the room
* the reset will leave (guardReset). */
const RESET_GUARD_MS = 5 * 60_000;
/** account_meta key of the profit claims this client made, per account. */
const CLAIMS_META = 'vest_claims';
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const round2 = (n: number) => Math.round(n * 100) / 100;
@@ -162,6 +167,17 @@ export class VestClient implements PlatformClient {
private readonly onSync: () => Promise<void>;
private uuidById = new Map<number, string>();
/** The login's main (primary) account — where a claimed profit goes. */
private primaryAccountId: string | null = null;
/** 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 }[]>();
/** 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 }[]>();
/** The reset guardReset last finished for (epoch ms). */
private resetGuardedFor = 0;
private capital = new Map<number, VestCapitalAccount>();
private positionsDetail = new Map<number, VestPosition[]>();
private openOrders = new Map<number, VestOpenOrder[]>();
@@ -286,7 +302,7 @@ export class VestClient implements PlatformClient {
*/
private async request<T>(
bucket: Bucket,
method: 'GET' | 'POST' | 'DELETE',
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
opts: { params?: Record<string, string | number | boolean>; body?: unknown; account?: string; auth?: boolean; retried?: boolean } = {},
): Promise<{ data: T }> {
@@ -354,6 +370,7 @@ export class VestClient implements PlatformClient {
if (this.intentionalDisconnect) return;
const now = Date.now();
if (this.syncInFlight && now - this.syncInFlightSince >= SYNC_WEDGED_MS) void this.requestSync();
void this.guardReset().catch((err) => console.error(`${this.tag} reset guard failed: ${errorMessage(err)}`));
}
// ── public market data ───────────────────────────────────────────────────
@@ -475,6 +492,8 @@ export class VestClient implements PlatformClient {
for (const b of bal.data?.accounts ?? []) {
const amount = num(b.amount);
if (typeof b.account_id === 'string' && amount !== undefined) free.set(b.account_id, amount);
// The login's own wallet account: where Claim Profit sends a payout.
if (typeof b.account_id === 'string' && num(b.account_type) === 1) this.primaryAccountId = b.account_id;
}
const positions = (pos.data?.positions ?? []).map(parsePosition).filter((p): p is VestPosition => p !== null);
const orders = (ord.data?.orders ?? []).map(parseOpenOrder).filter((o): o is VestOpenOrder => o !== null);
@@ -516,10 +535,9 @@ export class VestClient implements PlatformClient {
if (amount === undefined) { complete = false; continue; }
const balance = amount + mine.reduce((s, p) => s + p.collateral, 0);
const upnl = mine.reduce((s, p) => s + unrealizedPnl(p, this.marks.get(p.symbol)?.mark ?? NaN), 0);
const resetBalance = this.resetBalanceOf(a, balance, now);
this.accountCashBalances[id] = {
amount: round2(balance),
realizedPnL: resetBalance !== undefined ? round2(balance - resetBalance) : 0,
realizedPnL: this.realizedSince(id, a, balance, now),
equity: round2(balance + upnl),
availableFunds: round2(amount),
};
@@ -609,21 +627,170 @@ export class VestClient implements PlatformClient {
}
/**
* See PlatformClient: what the engine measures the day's loss from. Vest
* states the floor itself (reset balance × (1 − rate), moved by transfers),
* so this is that floor plus the account type's own daily limit — the
* engine's room, limit + min(0, balance − dayStart), is then
* min(limit, balance − floor): never more than Vest leaves. null until
* the account has been read.
* See PlatformClient: Vest states the day's floor itself — reset balance
* × (1 − rate), lowered by a payout the same day — so the engine's room
* is min(the stage's limit, balance − floor), never more than Vest
* leaves. null until the account has been read; undefined for an account
* Vest gives no daily limit.
*/
public dayStartBalance(accountId: number): number | null {
public dailyLossFloor(accountId: number): number | null | undefined {
const a = this.capital.get(accountId);
const cash = this.accountCashBalances[accountId];
if (!a || !cash) return null;
const floor = this.dailyFloorOf(a, cash.amount, Date.now());
const limit = getAccountConfig(accountId).accountType?.dailyLossLimit ?? 0;
if (floor === undefined) return this.resetBalanceOf(a, cash.amount, Date.now()) ?? null;
return round2(floor + limit);
if (!(a.maxDailyLossPct > 0)) return undefined;
return this.dailyFloorOf(a, cash.amount, Date.now()) ?? null;
}
private claimsOf(accountId: number): { at: number; amount: number; base: number }[] {
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)) : [];
} catch {
list = [];
}
this.claims.set(accountId, list);
}
return list;
}
/**
* 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
* there — else since the reset. A claim made by hand on Vest, whose
* after-balance this client never saw, is added back to the reset balance
* instead (Vest's reset balance doesn't take the payout out).
*/
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);
if (mine.length > 0) return round2(balance - mine[mine.length - 1].base);
const resetBalance = this.resetBalanceOf(a, balance, now);
if (resetBalance === undefined) return 0;
const byHand = (this.withdrawals.get(accountId) ?? []).filter((w) => w.at >= reset).reduce((s, w) => s + Math.abs(w.amount), 0);
return round2(balance - resetBalance + byHand);
}
/** 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). */
public sameDayClaims(accountId: number): number {
const reset = lastVestResetMs(Date.now());
const mine = this.claimsOf(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. Vest's Claim Profit: POST /v3/capital/withdraw from
* the funded account to the login's main account, which pays the profit
* split within 24 hours. The account must be flat with no orders, and the
* claim at least $1 of the profit above its starting balance.
*
* 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).
*/
public async claimProfit(accountId: number, amount: number): Promise<{ claimed: number; received: number | null }> {
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');
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');
const claimed = Math.floor(amount * 100) / 100;
if (!(claimed >= 1)) throw new Error(`$${claimed} is below Vest's $1 minimum claim`);
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(),
},
});
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));
// 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.
const day = vestDayOf(now);
const sameDay = (this.fundTransactions[accountId] ?? []).find((f) => f.date === day)?.amount ?? 0;
this.applyFundRows(account, [{ date: day, amount: round2(sameDay - claimed) }]);
this.accountCashBalances[accountId] = { ...cash, amount: round2(cash.amount - claimed), realizedPnL: 0 };
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();
return { claimed, received };
}
/**
* 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
* leg carrying more room than that — a same-day stage on the room a
* payout freed, a last stand — would otherwise breach the moment the
* floor moves up to 97% of the realized balance. Its stop comes in, or
* the position closes when there is no room or the mark is already past.
* Runs in the last RESET_GUARD_MS; a pass that fails retries next tick.
*/
private async guardReset(): Promise<void> {
if (!this.syncComplete || this.intentionalDisconnect) return;
const now = Date.now();
const next = nextVestResetMs(now);
if (!Number.isFinite(next) || next - now > RESET_GUARD_MS || this.resetGuardedFor === next) return;
if (!vestSessionFor(this.wallet, now, USER_TOKEN_MIN_MS)) return;
const specs = await this.symbolSpecs();
let failed = 0;
for (const acc of this.accountList) {
const a = this.capital.get(acc.id);
const cash = this.accountCashBalances[acc.id];
const uuid = this.uuidById.get(acc.id);
if (!a || !cash || !uuid || !(a.maxDailyLossPct > 0)) continue;
for (const pos of this.positionsDetail.get(acc.id) ?? []) {
const spec = specs.get(pos.symbol);
if (!spec) continue;
await this.refreshMarks([pos.symbol]).catch(() => { /* the last mark stands */ });
const mark = this.marks.get(pos.symbol)?.mark ?? NaN;
const stopLeg = pos.stopLosses.find((l) => l.status === 'awaiting') ?? null;
const notional = pos.quantity * (Number.isFinite(mark) ? mark : pos.openPrice);
const decision = resetSafeStop({
side: pos.side, quantity: pos.quantity, openPrice: pos.openPrice, mark,
balance: cash.amount, dailyLossPct: a.maxDailyLossPct, maxDrawdownFloor: a.maxDrawdownLimit,
reserve: dllReserve(cash.amount * a.maxDailyLossPct, notional),
exitFeeRate: spec.takerFee, currentStop: stopLeg?.triggerPrice ?? null, tick: spec.tick,
});
if (decision.action === 'none') continue;
try {
if (decision.action === 'close') {
await this.submit(uuid, '/v3/positions/close', { positionId: pos.positionId, symbol: pos.symbol, orderType: 'market' });
console.warn(`${this.tag} ${acc.name}: closed ${pos.symbol} before the 8 PM ET reset — no room for it past the reset`);
} else {
const triggerPrice = formatDecimal(decision.price, spec.priceDecimals);
if (stopLeg) {
await this.request('trade', 'PUT', '/v3/positions/stop-loss', {
account: uuid,
body: { positionId: pos.positionId, executionType: 'market', triggerPrice, stopLossId: stopLeg.id },
});
} else {
await this.submit(uuid, '/v3/positions/stop-loss', { positionId: pos.positionId, executionType: 'market', triggerPrice });
}
console.warn(`${this.tag} ${acc.name}: stop on ${pos.symbol} moved to ${triggerPrice} for the 8 PM ET reset (was ${stopLeg?.triggerPrice ?? 'none'})`);
}
} catch (err) {
failed++;
console.error(`${this.tag} ${acc.name}: couldn't fit ${pos.symbol} to the reset: ${errorMessage(err)}`);
}
}
}
if (failed === 0) this.resetGuardedFor = next;
}
private settleSuspects(): void {
@@ -766,28 +933,25 @@ export class VestClient implements PlatformClient {
.sort((x, y) => x.date.localeCompare(y.date));
saveDailyPnL(account.id, account.name, fresh);
const storedFundDate = loadAccountMeta(account.id, 'last_fund_date');
const fundRows = [...funds.entries()]
.map(([date, amount]) => ({ date, amount: round2(amount) }))
.filter((f) => f.amount !== 0)
.sort((x, y) => x.date.localeCompare(y.date));
if (fundRows.length > 0) saveFundTransactions(account.id, account.name, fundRows);
this.fundTransactions[account.id] = loadFundTransactions(account.id);
const lastFundDate = fundRows.length > 0 ? fundRows[fundRows.length - 1].date : storedFundDate;
if (lastFundDate && storedFundDate && lastFundDate > storedFundDate) {
const prevAmt = this.fundTransactions[account.id]?.find((f) => f.date === storedFundDate)?.amount ?? null;
saveEndedCycleStart(account.id, { fundDate: storedFundDate, includesDay: !(prevAmt !== null && prevAmt < 0) });
}
if (lastFundDate) saveAccountMeta(account.id, 'last_fund_date', lastFundDate);
this.lastFundDates[account.id] = lastFundDate ?? null;
this.applyCycleSlice(account.id);
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 })));
// Books check: the balance must be what the transactions add up to
// (the opening capital included). A payout or credit booked under a
// type this client doesn't count would otherwise trade a finished
// cycle as if nothing happened.
const cash = this.accountCashBalances[account.id];
const expected = txns.reduce((s, t) => s + (t.kind === 'margin' ? 0 : t.amount), 0) + (hasDeposit ? 0 : a?.initialCapital ?? 0);
// 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;
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`;
@@ -799,6 +963,23 @@ export class VestClient implements PlatformClient {
this.historyLoadedIds.add(account.id);
}
/** Store fund rows (one per Vest day), note where a cycle ended when the
* latest fund day moves forward, and recut the active cycle. */
private applyFundRows(account: PlatformAccount, fundRows: { date: string; amount: number }[]): void {
const storedFundDate = loadAccountMeta(account.id, 'last_fund_date');
if (fundRows.length > 0) saveFundTransactions(account.id, account.name, fundRows);
this.fundTransactions[account.id] = loadFundTransactions(account.id);
const latest = fundRows.length > 0 ? fundRows.map((f) => f.date).reduce((x, y) => (y > x ? y : x)) : null;
const lastFundDate = latest && (!storedFundDate || latest > storedFundDate) ? latest : storedFundDate ?? latest;
if (lastFundDate && storedFundDate && lastFundDate > storedFundDate) {
const prevAmt = this.fundTransactions[account.id]?.find((f) => f.date === storedFundDate)?.amount ?? null;
saveEndedCycleStart(account.id, { fundDate: storedFundDate, includesDay: !(prevAmt !== null && prevAmt < 0) });
}
if (lastFundDate) saveAccountMeta(account.id, 'last_fund_date', lastFundDate);
this.lastFundDates[account.id] = lastFundDate ?? null;
this.applyCycleSlice(account.id);
}
private fallBackToCache(account: PlatformAccount, msg: string): void {
const cached = loadDailyPnL(account.id);
this.fundTransactions[account.id] ??= loadFundTransactions(account.id);