Vest: a split day's next leg gives back the day's gain plus the limit

On a split day the next Vest leg's daily-loss room now adds the day's gain
still in the account to the bot's limit (floorRoom's `gain`), never past
Vest's own floor, and the leg sizes up for the same target hold (Dingo's
next eval leg: $455k -> $842k at 1.5 h, win 28% -> 56%). Other platforms
are unchanged: dllRoom still never widens on a green day.

The gain only widens a leg all but sure to close before Vest's 8 PM ET
reset (resetClearStop, held to the shortest leg the day may carve), and
none of a hand claim's profit counts. The last-stand margin is measured
against the limit, so a green day can't drop a leg's stop.

Around the reset: no new Vest position opens in the last 7 minutes, and
the guard re-fits every 15 s on fresh positions instead of one pass. An
account's positions share the next-day room (stops first, least at risk
first; locked-in profit counts), a hand-moved stop is fitted again, and a
close or profit-side stop that doesn't take is retried or closed.

The dashboard's next stop now reserves for the leg's notional, as fireLeg
does, so DXtrade labels read smaller (display only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-10-27 07:21:52 -05:00
co-authored by Claude Opus 5.5
parent 1e844178de
commit f0c1569d96
9 changed files with 364 additions and 89 deletions
+173 -51
View File
@@ -38,7 +38,7 @@
import { randomUUID } from 'crypto';
import axios from './http-client';
import { currentTradeDateCT, rankForTarget, dllReserve } from './trading-logic';
import { currentTradeDateCT, rankForTarget, dllReserve, resetClearStop } from './trading-logic';
import { sizingHoldHours } from './person-size';
import {
saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions,
@@ -105,6 +105,12 @@ 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;
/** No new position opens this close to the reset: the guard's window, and a
* couple of minutes before it for an entry in flight to place its exits. */
const RESET_ENTRY_BLOCK_MS = RESET_GUARD_MS + 2 * 60_000;
/** How long a stop or close the guard just sent counts before a read of
* open positions shows it. */
const GUARD_ECHO_MS = 30_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
@@ -200,8 +206,14 @@ export class VestClient implements PlatformClient {
/** 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;
/** The reset guardReset is fitting positions for (epoch ms), and the
* stops it placed and closes it sent, by position, with when — a read
* just after may not show them yet. */
private resetGuard = {
reset: 0, stops: new Map<string, { price: number; at: number }>(), closes: new Map<string, number>(),
noSessionLogged: false, unfittable: new Set<string>(),
};
private resetGuardInFlight = false;
private capital = new Map<number, VestCapitalAccount>();
private positionsDetail = new Map<number, VestPosition[]>();
private openOrders = new Map<number, VestOpenOrder[]>();
@@ -708,8 +720,8 @@ export class VestClient implements PlatformClient {
/**
* 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
* is what's left of the stage's limit, widened by dayGainForRoom, never
* more than balance − floor (floorRoom). null until the account has been read; undefined for an account
* Vest gives no daily limit.
*/
public dailyLossFloor(accountId: number): number | null | undefined {
@@ -720,6 +732,28 @@ export class VestClient implements PlatformClient {
return this.dailyFloorOf(a, cash.amount, Date.now()) ?? null;
}
/**
* See PlatformClient: the gain since the day's base that is still in the
* account — since this client's own claim today, else since the reset,
* with no add-back for a claim made by hand (that profit is gone) — up to
* the stop that keeps the leg all but sure to close before the 8 PM ET
* reset at the firm's target hold (resetClearStop); none before Vest has
* captured today's reset balance.
*/
public dayGainForRoom(accountId: number, o: { leg: number; limit: number }): number {
const now = Date.now();
const hold = sizingHoldHours(getCachedFirm(this.cfg.firmId));
const allowed = resetClearStop({ leg: o.leg, resetInMs: nextVestResetMs(now) - now, holdHours: hold }) - o.limit;
if (!(allowed > 0)) return 0;
const a = this.capital.get(accountId);
const cash = this.accountCashBalances[accountId];
if (!a || !cash) return 0;
const mine = this.madeClaims(accountId).filter((c) => c.at >= lastVestResetMs(now));
const base = mine.length > 0 ? mine[mine.length - 1].base
: Number.isFinite(a.dailyResetAt) && a.dailyResetAt >= lastVestResetMs(now) ? a.dailyResetBalance : undefined;
return base === undefined || !Number.isFinite(base) ? 0 : Math.max(0, Math.min(round2(cash.amount - base), round2(allowed)));
}
private claimsOf(accountId: number): OwnClaim[] {
let list = this.claims.get(accountId);
if (!list) {
@@ -888,62 +922,139 @@ export class VestClient implements PlatformClient {
* 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.
* payout freed, a last stand, a split day's leg widened by its gain, a
* hand trade — would otherwise breach the moment the floor moves up to
* 97% of the realized balance. Its stop comes in — on the profit side of
* the entry when the account's other positions took the room — or the
* position closes when the mark is already past that price. An account's
* positions share the room: those with a stop first, least at risk
* first, then those with none, oldest first.
*
* Every tick of the last RESET_GUARD_MS, on positions read afresh, so one
* opened by hand after the first pass is fitted too; no new position
* opens in that window (RESET_ENTRY_BLOCK_MS).
*/
private async guardReset(): Promise<void> {
if (!this.syncComplete || this.intentionalDisconnect) return;
if (!this.syncComplete || this.intentionalDisconnect || this.resetGuardInFlight) 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' });
this.markOwn('closed', acc.id, pos);
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 },
});
if (!Number.isFinite(next) || next - now > RESET_GUARD_MS) return;
if (this.resetGuard.reset !== next) {
this.resetGuard = { reset: next, stops: new Map(), closes: new Map(), noSessionLogged: false, unfittable: new Set() };
}
const guard = this.resetGuard;
if (!vestSessionFor(this.wallet, now, USER_TOKEN_MIN_MS)) {
const open = [...this.positionsDetail.values()].flat().length;
if (open > 0 && !guard.noSessionLogged) {
guard.noSessionLogged = true;
console.error(`${this.tag} ${open} open position(s) can't be fitted to the 8 PM ET reset — no Vest session (open the Vest tab)`);
}
return;
}
this.resetGuardInFlight = true;
try {
const specs = await this.symbolSpecs();
const { data } = await this.request<{ positions?: unknown[] }>('trade', 'GET', '/v3/positions/opened');
const open = (data?.positions ?? []).map(parsePosition).filter((p): p is VestPosition => p !== null);
const now2 = Date.now();
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;
// The account's positions share the room: each is fitted to
// what the ones before it leave — their loss at their stops,
// and a reserve for the slippage of all of them together.
// Those with a stop go first, the least at risk first, so one
// that already fits keeps it; those with none get the rest.
// The stop Vest shows wins (moved by hand after the guard, it
// is fitted again); one the guard just placed counts only
// while a read may not show it yet. A close just sent is left
// for the same while.
const stopOf = (p: VestPosition): number | null => {
const placed = guard.stops.get(p.positionId);
return p.stopLosses.find((l) => l.status === 'awaiting')?.triggerPrice
?? (placed && now2 - placed.at < GUARD_ECHO_MS ? placed.price : null);
};
// What the position loses at its stop — negative when the stop
// locks in a profit, which the others may then use.
const lossAt = (p: VestPosition, stop: number) => (p.side === 'long' ? p.openPrice - stop : stop - p.openPrice) * p.quantity;
const held = open
.filter((p) => p.accountId === uuid && now2 - (guard.closes.get(p.positionId) ?? 0) >= GUARD_ECHO_MS)
.map((p) => ({ pos: p, currentStop: stopOf(p) }))
.sort((x, y) => (x.currentStop === null ? 1 : 0) - (y.currentStop === null ? 1 : 0)
|| (x.currentStop !== null && y.currentStop !== null ? lossAt(x.pos, x.currentStop) - lossAt(y.pos, y.currentStop) : 0)
|| x.pos.openedAt - y.pos.openedAt || x.pos.positionId.localeCompare(y.pos.positionId));
let committed = 0;
let notionalSoFar = 0;
for (const { pos, currentStop } of held) {
const spec = specs.get(pos.symbol);
if (!spec) {
// Can't be fitted without its tick, but it still holds
// its loss against the room the others share.
notionalSoFar += pos.quantity * pos.openPrice;
if (currentStop !== null) committed += lossAt(pos, currentStop);
else if (!guard.unfittable.has(pos.positionId)) {
guard.unfittable.add(pos.positionId);
console.error(`${this.tag} ${acc.name}: ${pos.symbol} has no stop and no symbol spec — it can't be fitted to the 8 PM ET reset; close it or add a stop on Vest`);
}
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);
notionalSoFar += notional;
const exitFee = spec.takerFee * notional;
const decision = resetSafeStop({
side: pos.side, quantity: pos.quantity, openPrice: pos.openPrice, mark,
balance: cash.amount - committed, dailyLossPct: a.maxDailyLossPct, maxDrawdownFloor: a.maxDrawdownLimit,
floorBalance: cash.amount,
reserve: dllReserve(cash.amount * a.maxDailyLossPct, notionalSoFar),
exitFeeRate: spec.takerFee, currentStop, tick: spec.tick,
});
const stopAt = decision.action === 'stop' ? decision.price : decision.action === 'none' ? currentStop : null;
if (stopAt !== null) committed += lossAt(pos, stopAt) + exitFee;
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' });
guard.closes.set(pos.positionId, Date.now());
this.markOwn('closed', acc.id, pos);
console.warn(`${this.tag} ${acc.name}: closed ${pos.symbol} before the 8 PM ET reset — no room for it past the reset`);
} else {
await this.submit(uuid, '/v3/positions/stop-loss', { positionId: pos.positionId, executionType: 'market', triggerPrice });
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 });
}
guard.stops.set(pos.positionId, { price: decision.price, at: Date.now() });
console.warn(`${this.tag} ${acc.name}: stop on ${pos.symbol} moved to ${triggerPrice} for the 8 PM ET reset (was ${currentStop ?? 'none'})`);
}
} catch (err) {
console.error(`${this.tag} ${acc.name}: couldn't fit ${pos.symbol} to the reset: ${errorMessage(err)}`);
// A stop on the profit side that Vest won't take: the
// position can't carry its old one past the reset.
if (decision.action === 'stop' && lossAt(pos, decision.price) < 0) {
try {
await this.submit(uuid, '/v3/positions/close', { positionId: pos.positionId, symbol: pos.symbol, orderType: 'market' });
guard.closes.set(pos.positionId, Date.now());
this.markOwn('closed', acc.id, pos);
console.warn(`${this.tag} ${acc.name}: closed ${pos.symbol} before the 8 PM ET reset — its profit-side stop was refused`);
} catch (closeErr) {
console.error(`${this.tag} ${acc.name}: couldn't close ${pos.symbol} either: ${errorMessage(closeErr)}`);
}
}
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)}`);
}
}
} finally {
this.resetGuardInFlight = false;
}
if (failed === 0) this.resetGuardedFor = next;
}
private settleSuspects(): void {
@@ -1374,6 +1485,13 @@ export class VestClient implements PlatformClient {
const now = Date.now();
const board: { symbol: string; score: number | null; note: string; contract?: PlatformContract }[] = [];
// No new position this close to the reset: nothing picked, and the
// last survey's candidates stay up for the dashboard.
const toReset = nextVestResetMs(now) - now;
if (root !== 'survey' && toReset <= RESET_ENTRY_BLOCK_MS) {
console.log(`${this.tag} ${root} → nothing tradable — Vest's 8 PM ET reset in ${Math.ceil(toReset / 60_000)} min`);
return null;
}
for (const symbol of symbols) {
const spec = specs.get(symbol);
if (!spec) { board.push({ symbol, score: null, note: 'unknown symbol' }); continue; }
@@ -1552,6 +1670,10 @@ export class VestClient implements PlatformClient {
if (!meta || meta.status !== VEST_STATUS_ACTIVE) {
throw new OrderNotPlacedError(`account status is ${meta?.status ?? 'unknown'} — order not sent`);
}
const toReset = nextVestResetMs(Date.now()) - Date.now();
if (toReset <= RESET_ENTRY_BLOCK_MS) {
throw new OrderNotPlacedError(`Vest's 8 PM ET reset is ${Math.ceil(toReset / 60_000)} min away — no new position until it passes`);
}
const opposite = side === 'long' ? 'short' : 'long';
const hedged = [...this.positionsDetail.values()].flat().some((p) => p.symbol === symbol && p.side === opposite)
|| this.openingSides.get(symbol)?.side === opposite;