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
+4 -3
View File
@@ -19,7 +19,7 @@ const LIVE = {
targetSameEquity: false, splitEnabled: true, minLegs: 2, firstDayStop: true, minDayStop: false, combineCmeHolidays: false,
withdrawalStages: [{ profit: 2000, consistency: 0.4, minTradingDays: 5, drain: false, minDayPnL: 0 }],
payoutable: true, trailingDrawdown: 2000, expectedPayout: 1000, withdrawalPct: 0.5, payoutCap: 3000, profitSplit: 0.9,
dailyLossLimit: 1000, tradesEveryDay: false, updatedAt: '2026-10-18T14:05:00.000Z',
dailyLossLimit: 1000, stagesSameDay: false, tradesEveryDay: false, updatedAt: '2026-10-18T14:05:00.000Z',
};
/** `obj` without `keys`. */
@@ -39,7 +39,7 @@ test('field classes: the spec\'s lists, identical to the master\'s, and disjoint
assert.deepEqual([...RULE_FIELDS], [
'profitTarget', 'consistency', 'minDayPnL', 'minTradingDays', 'maxLoss', 'maxPositionSize', 'targetSameEquity',
'splitEnabled', 'minLegs', 'firstDayStop', 'minDayStop', 'combineCmeHolidays', 'withdrawalStages', 'payoutable',
'trailingDrawdown', 'dailyLossLimit',
'trailingDrawdown', 'dailyLossLimit', 'stagesSameDay',
]);
assert.deepEqual([...IDENTITY_FIELDS], ['prefix', 'accountSize']);
assert.deepEqual([...PROJECTION_FIELDS], ['expectedPayout', 'withdrawalPct', 'payoutCap', 'profitSplit']);
@@ -82,6 +82,7 @@ function legacyNormalize(t, j, tradesEveryDay) {
firstDayStop: type.firstDayStop === true,
minDayStop: type.minDayStop === true,
dailyLossLimit: nonNegative(type.dailyLossLimit),
stagesSameDay: type.stagesSameDay === true,
tradesEveryDay,
};
}
@@ -174,7 +175,7 @@ test('overlayKept: the kept rules on top; identity, label, projections and bookk
const oldRules = {
profitTarget: 3000, consistency: 0.5, minDayPnL: 200, minTradingDays: 5, maxLoss: 2500, maxPositionSize: 3,
targetSameEquity: true, splitEnabled: false, minLegs: 0, firstDayStop: false, minDayStop: true, combineCmeHolidays: true,
withdrawalStages: [], payoutable: false, trailingDrawdown: 2500, dailyLossLimit: 0,
withdrawalStages: [], payoutable: false, trailingDrawdown: 2500, dailyLossLimit: 0, stagesSameDay: false,
};
// A master that sent other identity/projection values on the kept set:
// they must not leak through.
+51
View File
@@ -14,6 +14,7 @@ import {
projectPerfectPath, cycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, notionalCap, expectedSwapToWin, MARGIN_HEADROOM,
dllReserve, DLL_SLIPPAGE_FRACTION, maxLossRoom, lastStand, breachDistance, LAST_STAND_FRACTION, planLegSize, rankForTarget,
currentTradeDateCT, foldReason, withinFirmCaps, personSizeFactor, personHoldHours, PERSON_SIZE_SPREAD, machineSide,
resolveEffectiveConfig, stageInputsFor, floorRoom, computeDayPlan,
} from '../lib/trading-logic.ts';
// Deterministic PRNG so a failure reproduces.
@@ -967,3 +968,53 @@ test('machineSide: one side per machine, across every lane', () => {
assert.equal(machineSide([1, -1], null), 'mixed');
assert.equal(machineSide([-5], 'Buy'), 'mixed');
});
test('same-day stages: today\'s payouts pick the stage, and a stage may carry its own daily limit', () => {
// The Vest 50k Funded type: $1,500 then claim, then $3,000 on the room the claim freed.
const stages = [{ profit: 3000, consistency: 0, minTradingDays: 0, dailyLossLimit: 3000 }];
const fundRows = [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-25', amount: -1500 }];
const resolve = (inputs) => resolveEffectiveConfig(1500, 0, 0, false, stages, 0, fundRows, 0, inputs);
// Base stage, base limit: yesterday's payout doesn't count for a same-day type.
const fresh = resolve({ dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 0 });
assert.equal(fresh.profitTarget, 1500);
assert.equal(fresh.dailyLossLimit, 1229);
// After a claim today: stage 2, its own limit.
const after = resolve({ dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 1 });
assert.equal(after.profitTarget, 3000);
assert.equal(after.dailyLossLimit, 3000);
// A second claim today: the last stage repeats.
assert.equal(resolve({ dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 2 }).profitTarget, 3000);
// Without same-day stages the lifetime count picks it, as it always has.
assert.equal(resolve({ dailyLossLimit: 1229 }).profitTarget, 3000);
// A stage without its own limit keeps the type's; no inputs = no limit.
const plain = resolveEffectiveConfig(1500, 0, 0, false, [{ profit: 2000, consistency: 0, minTradingDays: 0 }], 0, fundRows, 0, { dailyLossLimit: 1229 });
assert.equal(plain.dailyLossLimit, 1229);
assert.equal(resolveEffectiveConfig(1500, 0, 0, false, [], 0, [], 0).dailyLossLimit, 0);
// stageInputsFor asks the client only for a same-day type.
const client = { sameDayClaims: () => 1 };
assert.deepEqual(stageInputsFor(client, 7, { dailyLossLimit: 1229, stagesSameDay: true }), { dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 1 });
assert.deepEqual(stageInputsFor(client, 7, { dailyLossLimit: 1229 }), { dailyLossLimit: 1229, stagesSameDay: false, sameDayWithdrawals: 0 });
assert.deepEqual(stageInputsFor({}, 7, { stagesSameDay: true }), { dailyLossLimit: 0, stagesSameDay: true, sameDayWithdrawals: 0 });
});
test('floorRoom: the platform\'s own floor, never more than the stage\'s limit', () => {
// Fresh day: $50k, Vest floor $48,500, the type's $1,229.
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 50000, floor: 48500 }), 1229);
// Won $1,500 and claimed it: balance $50k, Vest's floor lowered to $47k.
// Stage 2's $3,000 limit gets all of it.
assert.equal(floorRoom({ dailyLossLimit: 3000, closedBalance: 50000, floor: 47000 }), 3000);
// If Vest hadn't lowered the floor, only what it really leaves.
assert.equal(floorRoom({ dailyLossLimit: 3000, closedBalance: 50000, floor: 48500 }), 1500);
// Below the floor already: negative room (no trade).
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 48400, floor: 48500 }), -100);
assert.equal(floorRoom({ dailyLossLimit: 0, closedBalance: 50000, floor: 48500 }), Infinity);
});
test('the day after a claim: a fresh cycle that aims at the next stage\'s target', () => {
// Claimed everything above $50k; P&L since the claim is 0, the cycle has no days yet.
const plan = computeDayPlan(7, '2026-10-27', 3000, 0, [], 0, 0, 0, 0, false, false);
assert.ok(plan.dayTarget >= 3000 && plan.dayTarget <= 3025, `dayTarget ${plan.dayTarget}`);
assert.equal(plan.remainingToday, plan.dayTarget);
});
+39 -1
View File
@@ -11,7 +11,7 @@ import assert from 'node:assert/strict';
import {
isVestWalletAddress, vestAccountId, num, toMs, formatDecimal, vestDayOf, lastVestResetMs, parseCapitalAccount,
vestAccountName, parsePosition, awaitingLegs, parseOpenOrder, unrealizedPnl, parseTxn, bookTxns, txnKind, VEST_TXN,
hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS,
hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS, nextVestResetMs, resetSafeStop,
} from '../lib/vest-helpers.ts';
import { putVestSession, vestSessionFor, dropVestSession, listVestSessions } from '../lib/vest-session.ts';
@@ -225,3 +225,41 @@ test('relayed sessions: taken, kept fresher, dropped, never listed with tokens',
dropVestSession(wallet, jwt(1));
assert.equal(vestSessionFor(wallet, now), null);
});
test('the next 8 PM ET reset, across both time changes', () => {
assert.equal(nextVestResetMs(iso('2026-10-26T18:00:00Z')), iso('2026-10-27T00:00:00Z'));
assert.equal(nextVestResetMs(iso('2026-10-27T00:00:00Z')), iso('2026-10-28T00:00:00Z'));
assert.equal(nextVestResetMs(iso('2026-10-31T23:59:00Z')), iso('2026-11-01T00:00:00Z'));
// Nov 1 2026, the clocks go back: that evening's reset is 01:00 UTC.
assert.equal(nextVestResetMs(iso('2026-11-01T12:00:00Z')), iso('2026-11-02T01:00:00Z'));
assert.equal(nextVestResetMs(iso('2026-12-01T00:30:00Z')), iso('2026-12-01T01:00:00Z'));
});
test('resetSafeStop: fit an open position into the room the reset will leave', () => {
// A stage-2 leg after a claim: balance $50,000, 3% → the reset floor is
// $48,500 (above the $47k drawdown floor). $150 reserve, a $3 fee: $1,347 to lose.
const base = {
side: 'long', quantity: 4, openPrice: 29500, mark: 29480, balance: 50000, dailyLossPct: 0.03,
maxDrawdownFloor: 47000, reserve: 150, exitFeeRate: 0.000025, tick: 0.25,
};
// No stop at all (a last stand): one comes in at open − 1347/4, rounded up to the tick.
const fee = 0.000025 * 4 * 29480;
const allowed = 50000 - 48500 - 150 - fee;
const want = Math.ceil((29500 - allowed / 4) / 0.25) * 0.25;
assert.deepEqual(resetSafeStop({ ...base, currentStop: null }), { action: 'stop', price: want });
// A wider stop is pulled in; one already tighter is left alone.
assert.deepEqual(resetSafeStop({ ...base, currentStop: 29000 }), { action: 'stop', price: want });
assert.deepEqual(resetSafeStop({ ...base, currentStop: 29300 }), { action: 'none' });
// The mark already past where the stop must go: close.
assert.deepEqual(resetSafeStop({ ...base, mark: 29100, currentStop: null }), { action: 'close' });
// Short: mirrored, rounded down.
const short = resetSafeStop({ ...base, side: 'short', mark: 29510, currentStop: null });
assert.equal(short.action, 'stop');
assert.equal(short.price, Math.floor((29500 + (50000 - 48500 - 150 - 0.000025 * 4 * 29510) / 4) / 0.25) * 0.25);
// No room past the reset at all: close.
assert.deepEqual(resetSafeStop({ ...base, balance: 48600, maxDrawdownFloor: 48600 }), { action: 'close' });
// The drawdown floor binds when it's above the daily one.
const dd = resetSafeStop({ ...base, maxDrawdownFloor: 49000, currentStop: null });
assert.equal(dd.action, 'stop');
assert.ok(dd.price > want);
});