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>
266 lines
16 KiB
JavaScript
266 lines
16 KiB
JavaScript
// Checks for the Vest client's pure helpers (lib/vest-helpers.ts) and the
|
||
// relayed-session store (lib/vest-session.ts).
|
||
//
|
||
// npm run check
|
||
//
|
||
// Both files import only Node built-ins, so Node strips their types and loads
|
||
// them directly; .mjs keeps this file out of Next's type check. The samples are
|
||
// shaped like the gateway's real answers (captured 2026-10-26).
|
||
import { test } from 'node:test';
|
||
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, nextVestResetMs, resetSafeStop,
|
||
} from '../lib/vest-helpers.ts';
|
||
import { putVestSession, vestSessionFor, dropVestSession, listVestSessions } from '../lib/vest-session.ts';
|
||
|
||
const iso = (s) => Date.parse(s);
|
||
|
||
test('wallet addresses and account ids', () => {
|
||
assert.equal(isVestWalletAddress('0x7ce10771819f80130e204ddb5ca1522e48a9b451'), true);
|
||
assert.equal(isVestWalletAddress('0x7CE10771819F80130E204DDB5CA1522E48A9B451'), true);
|
||
assert.equal(isVestWalletAddress('0x7ce1'), false);
|
||
assert.equal(isVestWalletAddress(''), false);
|
||
const id = vestAccountId('843f60e2-e013-5a0e-8b6c-face84bc5509');
|
||
assert.equal(id, vestAccountId('843F60E2-E013-5A0E-8B6C-FACE84BC5509'));
|
||
assert.notEqual(id, vestAccountId('102566b2-515a-51c5-9ad7-4a7eae0a6a2a'));
|
||
assert.ok(id >= 2 ** 50 && Number.isSafeInteger(id));
|
||
});
|
||
|
||
test('numbers, times and decimals as the gateway writes them', () => {
|
||
assert.equal(num('49968.511115'), 49968.511115);
|
||
assert.equal(num(''), undefined);
|
||
assert.equal(num(null), undefined);
|
||
assert.equal(toMs(1793035089), 1793035089000);
|
||
assert.equal(toMs(1793035089277), 1793035089277);
|
||
assert.equal(toMs('2026-10-26T18:41:02.886Z'), iso('2026-10-26T18:41:02.886Z'));
|
||
assert.ok(Number.isNaN(toMs(undefined)));
|
||
assert.equal(formatDecimal(29554.5, 2), '29554.5');
|
||
assert.equal(formatDecimal(50, 2), '50');
|
||
assert.equal(formatDecimal(3 * 0.0001, 4), '0.0003');
|
||
assert.equal(formatDecimal(42.65449999, 4), '42.6545');
|
||
assert.equal(formatDecimal(1e-7, 4), '0');
|
||
});
|
||
|
||
test("Vest's day turns at 20:00 New York, summer and winter", () => {
|
||
// EDT (UTC−4): 20:00 ET is 00:00 UTC.
|
||
assert.equal(vestDayOf(iso('2026-10-26T23:59:00Z')), '2026-10-26');
|
||
assert.equal(vestDayOf(iso('2026-10-27T00:00:00Z')), '2026-10-27');
|
||
assert.equal(vestDayOf(iso('2026-10-26T22:05:00Z')), '2026-10-26'); // the bots' 6:05 PM ET open
|
||
// EST (UTC−5): 20:00 ET is 01:00 UTC.
|
||
assert.equal(vestDayOf(iso('2026-12-01T00:30:00Z')), '2026-11-30');
|
||
assert.equal(vestDayOf(iso('2026-12-01T01:00:00Z')), '2026-12-01');
|
||
// Month and year ends.
|
||
assert.equal(vestDayOf(iso('2026-12-31T23:00:00Z')), '2026-12-31');
|
||
assert.equal(vestDayOf(iso('2027-01-01T01:30:00Z')), '2027-01-01');
|
||
|
||
assert.equal(lastVestResetMs(iso('2026-10-26T18:00:00Z')), iso('2026-10-26T00:00:00Z'));
|
||
assert.equal(lastVestResetMs(iso('2026-10-27T00:00:00Z')), iso('2026-10-27T00:00:00Z'));
|
||
assert.equal(lastVestResetMs(iso('2026-12-01T00:30:00Z')), iso('2026-11-30T01:00:00Z'));
|
||
assert.equal(lastVestResetMs(iso('2026-12-01T01:00:01Z')), iso('2026-12-01T01:00:00Z'));
|
||
// Across the November switch back (Nov 1 2026, 2 AM ET).
|
||
assert.equal(lastVestResetMs(iso('2026-11-01T12:00:00Z')), iso('2026-11-01T00:00:00Z'));
|
||
assert.equal(lastVestResetMs(iso('2026-11-02T12:00:00Z')), iso('2026-11-02T01:00:00Z'));
|
||
});
|
||
|
||
const evalAccount = {
|
||
payment_id: 'tailoredpay:vest-markets:production:12727557177',
|
||
id: 'e7f69717-bd18-5cb7-9466-673cb283d0b2',
|
||
plan_id: '86bb03f9-7422-513c-8d5c-c546cb8d221d',
|
||
account_type: 2, stage: 1, status: 2,
|
||
initial_capital: '50000.00000000', max_drawdown_pct: '0.060000', max_leverage: '5.0000', max_profit_split_pct: '0.8000',
|
||
target_equity: '55000.00000000', max_drawdown_limit: '47000.00000000', max_daily_loss_pct: '0.030000',
|
||
daily_reset_balance: '50000.00000000000000', daily_reset_equity: '50000.00000000000000',
|
||
daily_reset_base_limit: '1500.000000000000000', daily_loss_floor: '48500.00000000000000', daily_reset_at: 1793035043842,
|
||
origin: 'eval_purchase', plan_name: 'Vest Capital Evaluation Fee | 10% Goal | 3% Daily Loss | 80/20 | Emerald',
|
||
plan_product_type: 'eval', step: 1, steps_total: 1, root_account_id: 'e7f69717-bd18-5cb7-9466-673cb283d0b2',
|
||
stages: [
|
||
{ ordinal: 1, kind: 'eval', initial_capital: '50000.00000000', profit_target: '5000.00000000', max_drawdown_pct: '0.060000' },
|
||
{ ordinal: 2, kind: 'funded', initial_capital: '50000.00000000', profit_target: '', max_drawdown_pct: '0.060000' },
|
||
],
|
||
attempt_index: 34, activated_at: 1793035043834, created_at: 1793035043756, updated_at: 1793035043843,
|
||
};
|
||
|
||
test('capital accounts parse, and are named for their rules', () => {
|
||
const a = parseCapitalAccount(evalAccount);
|
||
assert.equal(a.id, evalAccount.id);
|
||
assert.equal(a.kind, 'eval');
|
||
assert.equal(a.status, 2);
|
||
assert.equal(a.initialCapital, 50000);
|
||
assert.equal(a.profitTarget, 5000);
|
||
assert.equal(a.maxDrawdownLimit, 47000);
|
||
assert.equal(a.dailyLossFloor, 48500);
|
||
assert.equal(a.maxDailyLossPct, 0.03);
|
||
assert.equal(a.dailyResetAt, 1793035043842);
|
||
assert.equal(vestAccountName(a), 'V50K-E-D3-T10-34');
|
||
|
||
assert.equal(vestAccountName(parseCapitalAccount({ ...evalAccount, stage: 2 })), 'V50K-F-D3-34');
|
||
assert.equal(vestAccountName(parseCapitalAccount({ ...evalAccount, max_daily_loss_pct: '0' })), 'V50K-E-D0-T10-34');
|
||
assert.equal(vestAccountName(parseCapitalAccount({
|
||
...evalAccount, initial_capital: '5000.00000000', attempt_index: 12, stage: 2, steps_total: 2,
|
||
stages: [
|
||
{ ordinal: 1, kind: 'eval', profit_target: '500.00000000' },
|
||
{ ordinal: 2, kind: 'eval', profit_target: '250.00000000' },
|
||
{ ordinal: 3, kind: 'funded', profit_target: '' },
|
||
],
|
||
})), 'V5K-E-D3-T5-12');
|
||
assert.equal(vestAccountName(parseCapitalAccount({
|
||
...evalAccount, plan_product_type: 'instant_funded', initial_capital: '25000.00000000', max_daily_loss_pct: '0', attempt_index: 7,
|
||
stages: [{ ordinal: 1, kind: 'funded', profit_target: '' }],
|
||
})), 'V25K-I-7');
|
||
assert.equal(vestAccountName(parseCapitalAccount({
|
||
...evalAccount, plan_product_type: 'instant_funded', initial_capital: '500.00000000', attempt_index: 1,
|
||
stages: [{ ordinal: 1, kind: 'funded', profit_target: '' }],
|
||
})), 'V500-I-1');
|
||
assert.equal(parseCapitalAccount({ nope: true }), null);
|
||
});
|
||
|
||
const openPosition = {
|
||
positionId: 'afe96d7e-f5ac-4b8a-aae8-f5b63b74e508', accountId: '843f60e2-e013-5a0e-8b6c-face84bc5509', accountType: 2,
|
||
symbol: 'NDX-USD-PERP', side: 'short', quantity: '42.6545', openPrice: '29529.250000000', closePrice: '290.250000000',
|
||
openDate: 1793035089277, pnl: '0', fee: '31.488885', funding: '-5.423271000000000000', collateral: '24944.027426', leverage: '50',
|
||
createdAt: 1793035089244, updatedAt: 1793037621104,
|
||
stopLosses: [{ id: 'e22311d2-fbd9-4ab8-b65f-c2921f58fefc', triggerPrice: '29554.500000000', sizeMode: 'full_position', executionType: 'market', status: 'awaiting' }],
|
||
takeProfits: [],
|
||
};
|
||
|
||
test('positions, their legs and unrealized P&L', () => {
|
||
const p = parsePosition(openPosition);
|
||
assert.equal(p.side, 'short');
|
||
assert.equal(p.quantity, 42.6545);
|
||
assert.equal(p.collateral, 24944.027426);
|
||
assert.equal(p.openedAt, 1793035089277);
|
||
assert.equal(awaitingLegs(p).length, 1);
|
||
assert.equal(awaitingLegs({ ...p, stopLosses: [{ ...p.stopLosses[0], status: 'triggered' }] }).length, 0);
|
||
// Short 42.6545 from 29529.25, marked at 29477.50: +51.75 points each.
|
||
assert.ok(Math.abs(unrealizedPnl(p, 29477.5) - 42.6545 * 51.75) < 1e-6);
|
||
assert.equal(unrealizedPnl(p, NaN), 0);
|
||
assert.equal(parsePosition({ ...openPosition, quantity: '0' }), null);
|
||
assert.equal(parseOpenOrder({ order_id: 'o1', account_id: 'a1', position_id: 'p1', symbol: 'GC-PERP', created_at: 1 }).positionId, 'p1');
|
||
assert.equal(parseOpenOrder({ symbol: 'x' }), null);
|
||
});
|
||
|
||
test('transactions book into daily P&L and fund rows by Vest day', () => {
|
||
assert.equal(txnKind(VEST_TXN.FEE), 'trade');
|
||
assert.equal(txnKind(VEST_TXN.REALIZED_PNL), 'trade');
|
||
assert.equal(txnKind(VEST_TXN.FUNDING), 'trade');
|
||
assert.equal(txnKind(VEST_TXN.WITHDRAW), 'fund');
|
||
assert.equal(txnKind(VEST_TXN.FILL_MARGIN), 'margin');
|
||
assert.equal(txnKind(VEST_TXN.ORDER_MARGIN_RELEASE), 'margin');
|
||
|
||
const w = parseTxn({ account_type: 3, action_id: 'a9', amount: '1200', created_at: iso('2026-10-26T15:00:00Z'), transaction_type: 2 });
|
||
assert.equal(w.amount, -1200);
|
||
assert.equal(w.kind, 'fund');
|
||
assert.equal(parseTxn({ amount: 'x', transaction_type: 9, created_at: 1 }), null);
|
||
|
||
const rows = [
|
||
{ action_id: 'd', amount: '50000', created_at: iso('2026-10-20T14:00:00Z'), transaction_type: 1 },
|
||
{ action_id: 'o', amount: '-24944.02', created_at: iso('2026-10-26T17:18:00Z'), transaction_type: 6 },
|
||
{ action_id: 'f1', amount: '-31.49', created_at: iso('2026-10-26T17:18:00Z'), transaction_type: 8 },
|
||
// After 20:00 ET on the 26th: the 27th's day.
|
||
{ action_id: 'r1', amount: '812.5', created_at: iso('2026-10-27T00:30:00Z'), transaction_type: 9 },
|
||
{ action_id: 'f2', amount: '-31.2', created_at: iso('2026-10-27T00:30:00Z'), transaction_type: 8 },
|
||
{ action_id: 'u1', amount: '-5.42', created_at: iso('2026-10-27T01:00:00Z'), transaction_type: 10 },
|
||
].map(parseTxn);
|
||
const { daily, funds } = bookTxns(rows);
|
||
assert.deepEqual([...funds.entries()], [['2026-10-20', 50000]]);
|
||
assert.ok(Math.abs(daily.get('2026-10-26').pnl - -31.49) < 1e-9);
|
||
assert.ok(Math.abs(daily.get('2026-10-27').pnl - (812.5 - 31.2 - 5.42)) < 1e-9);
|
||
assert.ok(Math.abs(daily.get('2026-10-27').fees - 31.2) < 1e-9);
|
||
});
|
||
|
||
test('hourly volatility leaves out the gaps', () => {
|
||
const start = iso('2026-10-19T00:00:00Z');
|
||
const kl = [];
|
||
let price = 29000;
|
||
for (let h = 0; h < 60; h++) {
|
||
price *= h % 2 === 0 ? 1.001 : 1 / 1.001;
|
||
kl.push([start + h * 3_600_000, '0', '0', '0', String(price), '1']);
|
||
}
|
||
const base = hourlyVolatility(kl);
|
||
assert.ok(base > 0.0009 && base < 0.0011, `σ ${base}`);
|
||
// A weekend gap with a 3% jump: not an hour's move, so σ doesn't change.
|
||
const gapped = [...kl, [start + 120 * 3_600_000, '0', '0', '0', String(price * 1.03), '1']];
|
||
assert.ok(Math.abs(hourlyVolatility(gapped) - base) < 1e-12);
|
||
// Any order in, the same σ out.
|
||
assert.equal(hourlyVolatility([...kl].reverse()), base);
|
||
assert.equal(hourlyVolatility(kl.slice(0, MIN_HOURLY_RETURNS)), null);
|
||
});
|
||
|
||
test('market hours: open stretches, closures and crypto', () => {
|
||
const periods = [
|
||
{ fromMs: 0, toMs: 100, state: { session: 'unspecified', closure: 'weekly', tradingStatus: 'CLOSE', price: { allow: false } } },
|
||
{ fromMs: 100, toMs: 200, state: { session: 'overnight', closure: 'unspecified', tradingStatus: 'OPEN', price: { allow: true } } },
|
||
{ fromMs: 200, toMs: 300, state: { session: 'regular', closure: 'unspecified', tradingStatus: 'OPEN', price: { allow: true } } },
|
||
{ fromMs: 300, toMs: 400, state: { session: 'unspecified', closure: 'maintenance', tradingStatus: 'OPEN', price: { allow: true } } },
|
||
{ fromMs: 400, toMs: 500, state: { session: 'overnight', closure: 'unspecified', tradingStatus: 'OPEN', price: { allow: true } } },
|
||
];
|
||
assert.deepEqual(marketOpen(periods, 50), { open: false, closesAt: null });
|
||
assert.deepEqual(marketOpen(periods, 150), { open: true, closesAt: 300 });
|
||
assert.deepEqual(marketOpen(periods, 350), { open: false, closesAt: null });
|
||
assert.deepEqual(marketOpen(periods, 450), { open: true, closesAt: 500 });
|
||
assert.deepEqual(marketOpen(periods, 600), { open: false, closesAt: null });
|
||
assert.deepEqual(marketOpen([], 600), { open: true, closesAt: null });
|
||
});
|
||
|
||
test('relayed sessions: taken, kept fresher, dropped, never listed with tokens', () => {
|
||
const now = iso('2026-10-26T18:40:00Z');
|
||
const jwt = (n) => `eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIke${n}In0.sig${n}`;
|
||
const wallet = '0x7CE10771819F80130E204DDB5CA1522E48A9B451';
|
||
assert.equal(putVestSession({ accessToken: 'nope', expiresAt: now + 900_000, primaryAddr: wallet, userId: 'u' }, now), 'accessToken is not a JWT');
|
||
assert.equal(putVestSession({ accessToken: jwt(1), expiresAt: now + 10_000, primaryAddr: wallet, userId: 'u' }, now), 'token already expired');
|
||
assert.equal(putVestSession({ accessToken: jwt(1), expiresAt: now + 900_000, primaryAddr: '0x12', userId: 'u' }, now), 'primaryAddr is not a wallet address');
|
||
assert.equal(putVestSession({ accessToken: jwt(1), expiresAt: now + 900_000, primaryAddr: wallet, userId: 'u' }, now), null);
|
||
// An older token relayed late doesn't replace the fresher one.
|
||
assert.equal(putVestSession({ accessToken: jwt(0), expiresAt: now + 300_000, primaryAddr: wallet, userId: 'u' }, now), null);
|
||
assert.equal(vestSessionFor(wallet.toLowerCase(), now).accessToken, jwt(1));
|
||
assert.equal(vestSessionFor(wallet, now + 890_000), null); // under 30s left
|
||
const listed = listVestSessions();
|
||
assert.equal(listed.length, 1);
|
||
assert.equal(listed[0].primaryAddr, wallet.toLowerCase());
|
||
assert.equal('accessToken' in listed[0], false);
|
||
dropVestSession(wallet, jwt(0)); // not the held one: kept
|
||
assert.ok(vestSessionFor(wallet, now));
|
||
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);
|
||
});
|