diff --git a/app/api/state/route.ts b/app/api/state/route.ts index ab8adb1..ef7b3fa 100644 --- a/app/api/state/route.ts +++ b/app/api/state/route.ts @@ -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 diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 9ac5779..8eb6e65 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -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; } diff --git a/lib/leg-estimate.ts b/lib/leg-estimate.ts index 4074025..a865769 100644 --- a/lib/leg-estimate.ts +++ b/lib/leg-estimate.ts @@ -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 }; } diff --git a/lib/platform-client.ts b/lib/platform-client.ts index 04bad73..dc02f84 100644 --- a/lib/platform-client.ts +++ b/lib/platform-client.ts @@ -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; diff --git a/lib/trading-logic.ts b/lib/trading-logic.ts index 61fb1e1..4f78791 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -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 diff --git a/lib/vest-class.ts b/lib/vest-class.ts index fb320f5..818a253 100644 --- a/lib/vest-class.ts +++ b/lib/vest-class.ts @@ -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(); - /** 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(), closes: new Map(), + noSessionLogged: false, unfittable: new Set(), + }; + private resetGuardInFlight = false; private capital = new Map(); private positionsDetail = new Map(); private openOrders = new Map(); @@ -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 { - 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; diff --git a/lib/vest-helpers.ts b/lib/vest-helpers.ts index 51d76d8..860763d 100644 --- a/lib/vest-helpers.ts +++ b/lib/vest-helpers.ts @@ -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 diff --git a/scripts/check-trading-logic.mjs b/scripts/check-trading-logic.mjs index e7b8bf0..ab8288f 100644 --- a/scripts/check-trading-logic.mjs +++ b/scripts/check-trading-logic.mjs @@ -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); diff --git a/scripts/check-vest.mjs b/scripts/check-vest.mjs index 7bebb80..897556c 100644 --- a/scripts/check-vest.mjs +++ b/scripts/check-vest.mjs @@ -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', () => {