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
+12 -10
View File
@@ -11,7 +11,7 @@ import type { AccountState } from '@/types';
import {
combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, currentTradeDateCT,
obfuscationStopDollars, dllStopDollars, botStopsActive, stageInputsFor,
maxLossRoom, lastStand as isLastStand,
maxLossRoom, lastStand as isLastStand, shortestNextLeg,
LEG_COMPLETION_EPSILON,
} from '@/lib/trading-logic';
@@ -133,7 +133,7 @@ export async function GET() {
// daily-loss stop, before fees. A min day trades its whole
// remaining target as one leg.
const dll = effective?.dailyLossLimit ?? 0;
dllRoom = dayLossRoom(client, acc.id, dll, cash, combined.realizedPnL);
dllRoom = dayLossRoom(client, acc.id, dll, cash, combined.realizedPnL, plan ? shortestNextLeg(plan) : 0);
// The max-drawdown floor, and whether the next leg is a last
// stand — no daily-loss stop — as fireLeg decides it.
const floorRoom = maxLossRoom({
@@ -147,14 +147,6 @@ export async function GET() {
// "stop $912" for a leg that never fires.
const dayBooked = obfuscationDayBooked(cfg, acc.id, tradeDate, combined.realizedPnL) !== null;
lastStandNext = !dayBooked && dllRoom !== null && isLastStand({ dllRoom, dailyLossLimit: dll, maxLossRoom: floorRoom });
if (plan && !dayBooked && botStopsActive(cfg)) {
const obf = obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday);
const hard = dllRoom !== null && !lastStandNext
? dllStopDollars({ room: dllRoom, dailyLossLimit: dll, entryFee: 0, commissionPct: 0 })
: Infinity;
const stop = Math.min(Number.isFinite(obf) && obf > 0 ? obf : Infinity, hard);
if (Number.isFinite(stop) && stop > 0) nextStop = Math.round(stop * 100) / 100;
}
// DXtrade: the next leg sized on every surveyed instrument as
// fireLeg would, and the one a batch would pick.
@@ -179,6 +171,16 @@ export async function GET() {
};
}
}
if (plan && !dayBooked && botStopsActive(cfg)) {
const obf = obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday);
// The reserve grows with the position (dllReserve): at
// the size the next leg would trade, where it's known.
const hard = dllRoom !== null && !lastStandNext
? dllStopDollars({ room: dllRoom, dailyLossLimit: dll, entryFee: 0, commissionPct: 0, notional: holdEstimate?.notional })
: Infinity;
const stop = Math.min(Number.isFinite(obf) && obf > 0 ? obf : Infinity, hard);
if (Number.isFinite(stop) && stop > 0) nextStop = Math.round(stop * 100) / 100;
}
// Effective profit target = max(stage target, consistency realTarget).
// If a big day forces the consistency rule, the account must reach maxDay/consistency
+6 -5
View File
@@ -18,7 +18,7 @@ import {
BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY,
FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP,
CME_EVENING_OPEN_MIN, foldReason, withinFirmCaps,
fundRowsKey, markFinishedCycle, unbookedPayoutDrop, machineSide, stageInputsFor, floorRoom,
fundRowsKey, markFinishedCycle, unbookedPayoutDrop, machineSide, stageInputsFor, floorRoom, shortestNextLeg,
type DayPlan, type FinishedCycleMark,
} from './trading-logic';
import { getSetting, setSetting, loadAccountMeta, saveAccountMeta } from './db';
@@ -621,7 +621,7 @@ function dailyLossGate(
cfg: { maxPositionSize: number; firmId: string },
cash: { amount: number; realizedPnL: number },
combinedRealized: number,
remainingToday: number,
plan: { remainingToday: number; splitAllowed: boolean },
singleContract: boolean,
): { room: number } | { reason: string } {
if (!(dll > 0)) return { room: Infinity };
@@ -634,6 +634,7 @@ function dailyLossGate(
room = floorRoom({
dailyLossLimit: dll, closedBalance: cash.amount, floor,
realizedToday: Math.min(cash.realizedPnL, combinedRealized),
gain: client.dayGainForRoom?.(accountId, { leg: shortestNextLeg(plan), limit: dll }) ?? 0,
});
} else {
const dayStart: number | null | undefined = client.dayStartBalance?.(accountId);
@@ -651,7 +652,7 @@ function dailyLossGate(
// could use. Percent-fee firms size by notional, which isn't known
// here — fireLeg re-checks with the real size before any entry.
const contractsBound = singleContract ? 1
: Math.min(Math.max(1, Math.ceil(remainingToday / 1000)), cfg.maxPositionSize > 0 ? cfg.maxPositionSize : Infinity);
: Math.min(Math.max(1, Math.ceil(plan.remainingToday / 1000)), cfg.maxPositionSize > 0 ? cfg.maxPositionSize : Infinity);
const percentFees = (getCachedFirm(cfg.firmId)?.commissionPct ?? 0) > 0;
const feeEstimate = percentFees ? 0 : 2 * FALLBACK_COMMISSION_PER_CONTRACT * contractsBound;
if (!dllViable(room, dll, feeEstimate)) {
@@ -837,7 +838,7 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a
// (day done, challenge complete, leg cap) win. A daily-loss stop-out
// leaves about the reserve plus fees, which is below what a trade needs —
// so a stop-out benches the account for the rest of the day by itself.
const gate = dailyLossGate(client, acc.id, effective.dailyLossLimit, cfg, cash, combined.realizedPnL, plan.remainingToday, isExtraDay || effective.drain);
const gate = dailyLossGate(client, acc.id, effective.dailyLossLimit, cfg, cash, combined.realizedPnL, plan, isExtraDay || effective.drain);
if ('reason' in gate) return { ...evaluated, eligible: false, reason: gate.reason };
if (Number.isFinite(gate.room)) return { ...evaluated, eligible: true, reason: '', dllRoom: gate.room };
@@ -1868,7 +1869,7 @@ export async function fireManualLeg(accountId: number, action: 'Buy' | 'Sell' |
// The once-a-day return comes before the daily-loss check: run it here.
let room = ev.dllRoom;
if (!ev.eligible) {
const gate = dailyLossGate(client, acc.id, ev.dll, cfg, ev.cash, ev.cash.realizedPnL, ev.plan.remainingToday, ev.drain);
const gate = dailyLossGate(client, acc.id, ev.dll, cfg, ev.cash, ev.cash.realizedPnL, ev.plan, ev.drain);
if ('reason' in gate) throw new Error(gate.reason);
room = gate.room;
}
+5 -2
View File
@@ -11,7 +11,7 @@ import type { PlatformContract } from './platform-client';
import type { SymbolStats } from './master-protocol';
import {
combineCmeSessions, computeDayPlan, resolveEffectiveConfig, obfuscationStopDollars, dllRoom as computeDllRoom,
planLegSize, rankForTarget, maxLossRoom, lastStand, dllStopDollars, stageInputsFor, floorRoom,
planLegSize, rankForTarget, maxLossRoom, lastStand, dllStopDollars, stageInputsFor, floorRoom, shortestNextLeg,
FALLBACK_COMMISSION_PER_CONTRACT,
} from './trading-logic';
@@ -27,6 +27,8 @@ export function dayLossRoom(
dll: number,
cash: { amount: number; realizedPnL: number },
combinedRealized: number,
/** The shortest take-profit the next leg may have (shortestNextLeg). */
nextLeg: number,
): number | null {
if (!(dll > 0)) return null;
const floor: number | null | undefined = client.dailyLossFloor?.(accountId);
@@ -35,6 +37,7 @@ export function dayLossRoom(
return floorRoom({
dailyLossLimit: dll, closedBalance: cash.amount, floor,
realizedToday: Math.min(cash.realizedPnL, combinedRealized),
gain: client.dayGainForRoom?.(accountId, { leg: nextLeg, limit: dll }) ?? 0,
});
}
const dayStart = client.dayStartBalance?.(accountId);
@@ -143,7 +146,7 @@ function accountPlan(
);
if (!plan) return null;
const dll = effective.dailyLossLimit;
const room = dayLossRoom(client, acc.id, dll, cash, combined.realizedPnL);
const room = dayLossRoom(client, acc.id, dll, cash, combined.realizedPnL, shortestNextLeg(plan));
const obfuscation = obfuscationStopDollars(cfg, combined.dailyPnL, plan, combined.tradeDate, plan.remainingToday);
return { cfg, cash, plan, dll, room, obfuscation };
}
+10 -1
View File
@@ -125,12 +125,21 @@ export interface PlatformClient {
* The equity the account may not fall below today, where the platform
* states its daily-loss floor itself and moves it with the day's
* transfers (Vest: reset balance × (1 − rate), lowered by a payout). The
* engine's room is then min(the stage's limit, balance − floor), so a
* engine's room is then what's left of the stage's limit (widened by
* dayGainForRoom), never more than balance − floor (floorRoom), so a
* same-day payout that lowers the floor frees room to trade again. null =
* the platform has one but it isn't known yet (hold); undefined = none
* here (the engine falls back to dayStartBalance / realized P&L).
*/
dailyLossFloor?(accountId: number): number | null | undefined;
/**
* With dailyLossFloor: how much of the day's gain a leg starting now, its
* take-profit `leg` away and its room `limit` before any gain, may add to
* its daily-loss room (floorRoom's `gain`) — the gain still in the account
* since the day's base, no more than keeps the leg all but sure to close
* before the platform's daily reset (resetClearStop).
*/
dayGainForRoom?(accountId: number, o: { leg: number; limit: number }): number;
/** Payouts made since the firm's last daily reset — what picks the stage
* of an account type whose stages reset each day. */
sameDayClaims?(accountId: number): number;
+57 -13
View File
@@ -1339,17 +1339,6 @@ export function dllReserve(dailyLossLimit: number, notional = 0): number {
* anything tighter just spends the day's room on fees. */
export const DLL_MIN_STOP = 50;
/**
* How much the firm's day may still lose before a hard daily-loss breach.
* Infinity when the account type has no limit.
*
* The day's P&L is the closed balance against the platform's snapshot at the
* firm's daily reset when the client knows it (DXTrade: 22:00 UTC), else the
* broker's realized P&L for the session. Only a red day counts: a green day
* does NOT widen the room past the limit, because a position still open at
* the next reset has its whole move counted against the NEW day — room
* borrowed from today's profit would be a breach tomorrow.
*/
/** The stage inputs for one account: the type's daily loss limit, and for a
* type whose stages reset each day, the client's count of today's payouts. */
export function stageInputsFor(
@@ -1365,6 +1354,37 @@ export function stageInputsFor(
};
}
/** A leg is given this many of its target holds to be done before a daily
* reset counts its open loss against the next day: under a fair walk, a leg
* whose take-profit and stop are within 2:1 is still open after four holds
* about 1–1.5% of the time, and the reset guard fits those. */
export const RESET_CLEAR_HOLDS = 4;
/** The shortest take-profit the plan's next leg may have — what
* resetClearStop is held to: a split day can carve a leg as small as
* MIN_LEG (pickLegAmount), else the leg is all that remains. */
export function shortestNextLeg(plan: { remainingToday: number; splitAllowed: boolean }): number {
return plan.splitAllowed ? Math.min(plan.remainingToday, MIN_LEG) : plan.remainingToday;
}
/**
* The widest stop a leg whose take-profit is `leg` away may carry and still
* be all but surely closed by a daily reset `resetInMs` away; 0 when the
* reset is under RESET_CLEAR_HOLDS holds off. The walk's exit time has a
* tail that decays as e^(−π²·t/(2g·hold)), g = (A+B)²/(A·B): a lopsided leg
* (a $5,000 stop on a $500 take-profit) lingers far longer than its hold. So
* the leg may be as lopsided as k = holds-to-reset ÷ RESET_CLEAR_HOLDS times
* the 2:1 shape (g = 4.5) allows — g ≤ 4.5k — which keeps its chance of
* still being open at the reset what a 2:1 leg's is at four holds.
*/
export function resetClearStop(o: { leg: number; resetInMs: number; holdHours: number }): number {
if (!(o.leg > 0) || !(o.holdHours > 0) || !Number.isFinite(o.resetInMs)) return 0;
const k = o.resetInMs / (RESET_CLEAR_HOLDS * o.holdHours * 3_600_000);
if (k < 1) return 0;
const c = 4.5 * k - 2;
return (o.leg * (c + Math.sqrt(c * c - 4))) / 2;
}
/**
* dllRoom where the platform states its daily-loss floor itself (Vest: the
* equity the account may not fall below today, already moved by the day's
@@ -1373,6 +1393,14 @@ export function stageInputsFor(
* limit set below the firm's (Vest's 3% is $1,500; the bot's day stops at
* $1,229) would let a day that already lost $1,111 trade the $389 Vest still
* allows. Infinity when there is no limit.
*
* `gain` widens it: on a split day the next leg may give back what the
* earlier legs made, plus the limit — a $1,400 day up $2,604 gets $4,004 —
* as Vest's own floor does. The client says how much (dayGainForRoom): the
* gain still in the account, no more than keeps the leg all but sure to be
* closed by the reset (resetClearStop) — a leg still open there has all of
* its loss counted against the next day. Never more than the day's realized
* gain either way.
*/
export function floorRoom(o: {
dailyLossLimit: number;
@@ -1381,11 +1409,25 @@ export function floorRoom(o: {
/** The day's realized P&L since its base (Vest: since this client's
* latest claim today, else since the reset). */
realizedToday: number;
/** What of the day's gain the room may grow by; 0 (default) = none. */
gain?: number;
}): number {
if (!(o.dailyLossLimit > 0)) return Infinity;
return round2(Math.min(o.dailyLossLimit + Math.min(0, o.realizedToday), o.closedBalance - o.floor));
const widen = Math.min(Math.max(0, o.realizedToday), Math.max(0, o.gain ?? 0));
return round2(Math.min(o.dailyLossLimit + Math.min(0, o.realizedToday) + widen, o.closedBalance - o.floor));
}
/**
* How much the firm's day may still lose before a hard daily-loss breach.
* Infinity when the account type has no limit.
*
* The day's P&L is the closed balance against the platform's snapshot at the
* firm's daily reset when the client knows it (DXTrade: 22:00 UTC), else the
* broker's realized P&L for the session. Only a red day counts: a green day
* does NOT widen the room past the limit, because a position still open at
* the next reset has its whole move counted against the NEW day — room
* borrowed from today's profit would be a breach tomorrow.
*/
export function dllRoom(o: {
dailyLossLimit: number;
closedBalance: number;
@@ -1447,7 +1489,9 @@ export const LAST_STAND_FRACTION = 0.15;
*/
export function lastStand(o: { dllRoom: number; dailyLossLimit: number; maxLossRoom: number | null }): boolean {
if (o.maxLossRoom === null || !(o.dailyLossLimit > 0) || !Number.isFinite(o.dllRoom) || !(o.dllRoom > 0)) return false;
return o.maxLossRoom - o.dllRoom <= LAST_STAND_FRACTION * o.dllRoom;
// A full day's loss is the limit, however much a green day widened the
// room (floorRoom): a stop-out leaving $300 over the floor keeps a day.
return o.maxLossRoom - o.dllRoom <= LAST_STAND_FRACTION * Math.min(o.dllRoom, o.dailyLossLimit);
}
/** On a last stand: how far price can move against the leg before the firm's
+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;
+13 -4
View File
@@ -112,15 +112,20 @@ export function nextVestResetMs(now: number): number {
* 'none' — its stop is already at least that tight;
* 'stop' — move (or place) the stop at `price`, on the tick grid, rounded
* toward the position's safe side;
* 'close' — no room at all, or the mark is already past that price.
* 'close' — the mark is already past that price (with no room left by
* the account's other positions, it sits on the profit side),
* or no room and no mark to tell.
*/
export function resetSafeStop(o: {
side: 'long' | 'short';
quantity: number;
openPrice: number;
mark: number;
/** Realized balance, collateral included — what the reset captures. */
/** Realized balance, collateral included — what the reset captures —
* less what the account's other positions may lose at their stops. */
balance: number;
/** The realized balance the floor is set from; default `balance`. */
floorBalance?: number;
dailyLossPct: number;
maxDrawdownFloor: number | undefined;
reserve: number;
@@ -129,10 +134,14 @@ export function resetSafeStop(o: {
currentStop: number | null;
tick: number;
}): { action: 'none' } | { action: 'stop'; price: number } | { action: 'close' } {
const floor = Math.max(o.balance * (1 - o.dailyLossPct), o.maxDrawdownFloor ?? -Infinity);
const floor = Math.max((o.floorBalance ?? o.balance) * (1 - o.dailyLossPct), o.maxDrawdownFloor ?? -Infinity);
const exitFee = o.exitFeeRate * o.quantity * (Number.isFinite(o.mark) && o.mark > 0 ? o.mark : o.openPrice);
const allowed = o.balance - floor - o.reserve - exitFee;
if (!(allowed > 0) || !(o.quantity > 0)) return { action: 'close' };
if (!(o.quantity > 0)) return { action: 'close' };
// No room to lose (the account's other positions took it): the stop must
// lock in at least the shortfall — on the profit side of the entry,
// which a position already that far up can still carry.
if (!(allowed > 0) && !Number.isFinite(o.mark)) return { action: 'close' };
const long = o.side === 'long';
const raw = long ? o.openPrice - allowed / o.quantity : o.openPrice + allowed / o.quantity;
const price = o.tick > 0
+70 -3
View File
@@ -14,7 +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,
resolveEffectiveConfig, stageInputsFor, floorRoom, resetClearStop, shortestNextLeg, computeDayPlan,
} from '../lib/trading-logic.ts';
// Deterministic PRNG so a failure reproduces.
@@ -441,6 +441,13 @@ test('last stand: no daily-loss stop once hitting the daily limit would leave 15
assert.equal(lastStand({ dllRoom: 1000, dailyLossLimit: 3000, maxLossRoom: 1200 }), false);
assert.equal(lastStand({ dllRoom: 1000, dailyLossLimit: 3000, maxLossRoom: 1150 }), true);
assert.equal(lastStand({ dllRoom: Infinity, dailyLossLimit: 0, maxLossRoom: 500 }), false, 'no daily limit, no daily stop to drop');
// A green day's widened room is measured against a full day's limit: a
// $1,400 eval starting $1,700 over its floor, up $1,000 — leg 2's $2,400
// stop-out leaves $300, over 15% of $1,400, so it keeps its stop.
assert.equal(lastStand({ dllRoom: 2400, dailyLossLimit: 1400, maxLossRoom: 2700 }), false);
assert.equal(lastStand({ dllRoom: 2400, dailyLossLimit: 1400, maxLossRoom: 2610 }), true);
// The room past the floor is a last stand however it was widened.
assert.equal(lastStand({ dllRoom: 2400, dailyLossLimit: 1400, maxLossRoom: 2300 }), true);
// The far side of the walk is the nearer floor, net of the entry fee.
assert.equal(breachDistance({ dllRoom: 3000, maxLossRoom: 2900, entryFee: 120 }), 2780);
assert.equal(breachDistance({ dllRoom: 2757.39, maxLossRoom: null, entryFee: 0 }), 2757.39);
@@ -476,6 +483,54 @@ test('planLegSize: the stop, a last stand, the margin cap and the cost, as fireL
assert.equal(planLegSize({ ...base, maxLossRoom: null, contract: {} }).sizing, null);
});
test('resetClearStop: the widest stop a leg may carry and still close before the reset', () => {
const h = 3_600_000;
const near = (a, b) => Math.abs(a - b) < 1e-6;
// Exactly four holds off: a 2:1 leg, no more.
assert.ok(near(resetClearStop({ leg: 2395.76, resetInMs: 6 * h, holdHours: 1.5 }), 2 * 2395.76));
// Under four holds (the 5:30 PM CT open batch, 1.5 h before Vest's
// reset): no widening at all.
assert.equal(resetClearStop({ leg: 2395.76, resetInMs: 1.5 * h, holdHours: 1.5 }), 0);
assert.equal(resetClearStop({ leg: 2395.76, resetInMs: 6 * h - 1, holdHours: 1.5 }), 0);
// Dingo's second leg at ~4 AM ET, 16 h off: far past the $4,004 the day allows.
assert.ok(resetClearStop({ leg: 2395.76, resetInMs: 16 * h, holdHours: 1.5 }) > 20000);
// A $500 final leg 10 h off may carry about $2,656, not the $5,900 a
// $4,500 gain would give: g = (A+B)²/(AB) stays at 4.5 × 10/6.
const lopsided = resetClearStop({ leg: 500, resetInMs: 10 * h, holdHours: 1.5 });
assert.ok(Math.abs(lopsided - 2656) < 1, `${lopsided}`);
assert.ok(near((500 + lopsided) ** 2 / (500 * lopsided), 4.5 * 10 / 6));
assert.equal(resetClearStop({ leg: 0, resetInMs: 16 * h, holdHours: 1.5 }), 0);
assert.equal(resetClearStop({ leg: 500, resetInMs: 16 * h, holdHours: 0 }), 0);
assert.equal(resetClearStop({ leg: 500, resetInMs: NaN, holdHours: 1.5 }), 0);
// The cap is held to the shortest leg the day may carve: a split day's
// $2,395.76 can come out a $500 leg.
assert.equal(shortestNextLeg({ remainingToday: 2395.76, splitAllowed: true }), 500);
assert.equal(shortestNextLeg({ remainingToday: 320, splitAllowed: true }), 320);
assert.equal(shortestNextLeg({ remainingToday: 2395.76, splitAllowed: false }), 2395.76);
// Dingo at ~4 AM ET still gets the whole $2,604.24: a $500 leg 16 h off
// may carry ~$4,950.
assert.ok(resetClearStop({ leg: 500, resetInMs: 16 * h, holdHours: 1.5 }) - 1400 > 2604.24);
});
test('planLegSize: a split day\'s next leg, with the day\'s gain on its stop, sizes up for the same hold', () => {
// Dingo's 2026-10-27 evals: up $2,604.24 on a $1,400 day, $2,395.76 to
// pass, NDX at ~$29.5k on Vest (1.31%/day), sized for 1.5 h.
const ndx = { stepNotional: 2.95, volatilityPerHour: 0.0131 / Math.sqrt(24), spreadFraction: 0.0000085, marginRate: 0.02 };
const leg = {
leg: 2395.76, contract: ndx, commissionPct: 0.0025, targetHoldHours: 1.5, financingPctPerDay: 0,
dailyLossLimit: 1400, obfuscationDollars: Infinity, availableFunds: 52604.24, maxLossRoom: 5604.24,
};
const capped = planLegSize({ ...leg, dllRoom: 1400 });
const room = floorRoom({ dailyLossLimit: 1400, closedBalance: 52604.24, floor: 48500, realizedToday: 2604.24, gain: 2604.24 });
const widened = planLegSize({ ...leg, dllRoom: room });
assert.equal(widened.lastStand, false);
for (const p of [capped, widened]) assert.ok(Math.abs(p.sizing.predictedHoldHours - 1.5) < 0.05, `hold ${p.sizing.predictedHoldHours}`);
assert.ok(widened.sizing.notional > 1.5 * capped.sizing.notional, `${widened.sizing.notional} vs ${capped.sizing.notional}`);
// The stop gives back the day's gain plus the limit, less the reserve.
const n = widened.sizing.notional;
assert.ok(widened.walkAt(n) > 2604.24 && widened.walkAt(n) < 4004.24, `stop ${widened.walkAt(n)}`);
});
test('personSizeFactor: one draw per person and trade date, within the spread', () => {
const people = Array.from({ length: 300 }, (_, i) => `0b6f${i.toString(16).padStart(4, '0')}-5c1e-4a7e-9d2b-${i}`);
const draws = people.map((p) => personSizeFactor(p, '2026-10-20'));
@@ -999,14 +1054,26 @@ test('same-day stages: today\'s payouts pick the stage, and a stage may carry it
assert.deepEqual(stageInputsFor({}, 7, { stagesSameDay: true }), { dailyLossLimit: 0, stagesSameDay: true, sameDayWithdrawals: 0 });
});
test('floorRoom: the platform\'s own floor, never more than what\'s left of the stage\'s limit', () => {
test('floorRoom: what\'s left of the stage\'s limit, widened by the day\'s gain the client allows, never past the platform\'s floor', () => {
// Fresh day: $50k, Vest floor $48,500, the type's $1,229.
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 50000, floor: 48500, realizedToday: 0 }), 1229);
// Lost $1,111.65 today: Vest still allows $388.35, but the bot's own
// $1,229 day has only $117.35 left.
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 48888.35, floor: 48500, realizedToday: -1111.65 }), 117.35);
// A green day doesn't widen the limit.
// A green day alone doesn't widen the limit...
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 50600, floor: 48500, realizedToday: 600 }), 1229);
// ...the gain the client allows does: the next leg may give it back plus
// the limit, as on Dingo's 2026-10-27 evals ($1,400 a day, up $2,604.24)...
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 50600, floor: 48500, realizedToday: 600, gain: 600 }), 1829);
assert.equal(floorRoom({ dailyLossLimit: 1400, closedBalance: 52604.24, floor: 48500, realizedToday: 2604.24, gain: 2604.24 }), 4004.24);
// ...never past Vest's own floor...
assert.equal(floorRoom({ dailyLossLimit: 1400, closedBalance: 52604.24, floor: 49000, realizedToday: 2604.24, gain: 2604.24 }), 3604.24);
// ...and never by more than the day realized: $2,250 claimed by hand
// leaves realized at $2,250 (added back) but none of it in the account.
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 50000, floor: 46250, realizedToday: 2250, gain: 0 }), 1229);
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 50600, floor: 48500, realizedToday: 600, gain: 5000 }), 1829);
// A red day still counts in full, whatever gain is offered.
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 49500, floor: 48500, realizedToday: -500, gain: 300 }), 729);
// Won $1,500 and claimed it: balance $50k, Vest's floor lowered to $47k,
// the day's P&L counted from the claim. Stage 2's $3,000 gets all of it.
assert.equal(floorRoom({ dailyLossLimit: 3000, closedBalance: 50000, floor: 47000, realizedToday: 0 }), 3000);
+18
View File
@@ -263,6 +263,24 @@ test('resetSafeStop: fit an open position into the room the reset will leave', (
const dd = resetSafeStop({ ...base, maxDrawdownFloor: 49000, currentStop: null });
assert.equal(dd.action, 'stop');
assert.ok(dd.price > want);
// A second position on the account gets what the first leaves: $500
// already committed at the first one's stop, from the same $48,500 floor.
const second = resetSafeStop({ ...base, balance: 49500, floorBalance: 50000, currentStop: null });
assert.equal(second.action, 'stop');
assert.equal(second.price, Math.ceil((29500 - (allowed - 500) / 4) / 0.25) * 0.25);
assert.ok(second.price > want);
// ...and nothing when the first takes it all.
assert.deepEqual(resetSafeStop({ ...base, balance: 48600, floorBalance: 50000, currentStop: null }), { action: 'close' });
// With no room left, a position already up keeps going on a stop that
// locks in the shortfall: one already locking in more is left alone...
const up = { ...base, mark: 29560, balance: 48600, floorBalance: 50000 };
assert.deepEqual(resetSafeStop({ ...up, currentStop: 29520 }), { action: 'none' });
// ...one with none gets it, on the profit side, still under the mark...
const shortfall = 48600 - 48500 - 150 - 0.000025 * 4 * 29560;
assert.deepEqual(resetSafeStop({ ...up, currentStop: null }), { action: 'stop', price: Math.ceil((29500 - shortfall / 4) / 0.25) * 0.25 });
assert.ok(resetSafeStop({ ...up, currentStop: null }).price > 29500);
// ...and with no mark to tell, it closes.
assert.deepEqual(resetSafeStop({ ...up, mark: NaN, currentStop: null }), { action: 'close' });
});
test('parseHistoryPosition: a closed position from GET /v3/positions', () => {