diff --git a/app/api/state/route.ts b/app/api/state/route.ts index 03f0f0e..ab8adb1 100644 --- a/app/api/state/route.ts +++ b/app/api/state/route.ts @@ -5,12 +5,12 @@ import { getAccountTypeById, getCachedCatalog } from '@/lib/firm-catalog'; import { ruleDiffs } from '@/lib/kept-rules'; import { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/account-type-resolver'; import { historyFresh } from '@/lib/platform-client'; -import { rankNextLeg } from '@/lib/leg-estimate'; +import { rankNextLeg, dayLossRoom } from '@/lib/leg-estimate'; import { obfuscationDayBooked } from '@/lib/auto-trade'; import type { AccountState } from '@/types'; import { combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, currentTradeDateCT, - obfuscationStopDollars, dllRoom as computeDllRoom, dllStopDollars, botStopsActive, + obfuscationStopDollars, dllStopDollars, botStopsActive, stageInputsFor, maxLossRoom, lastStand as isLastStand, LEG_COMPLETION_EPSILON, } from '@/lib/trading-logic'; @@ -61,8 +61,11 @@ export async function GET() { const isDead = autoLiqThreshold !== 0 && cash.amount <= autoLiqThreshold; const allFundTxns = client.fundTransactions?.[acc.id] ?? []; const priorProfit = client.priorProfit?.[acc.id] ?? 0; - // Stage = 1 + number of withdrawals (negative fund transactions) - const stage = 1 + allFundTxns.filter((f) => f.amount < 0).length; + // Stage = 1 + number of withdrawals (negative fund transactions), + // or of today's payouts for a type whose stages reset each day. + const stage = 1 + (cfg?.stagesSameDay + ? (client.sameDayClaims?.(acc.id) ?? 0) + : allFundTxns.filter((f) => f.amount < 0).length); // Stage-resolved config: a drain stage swaps in its own min-day // (default $5) and no profit target. const effective = cfg ? resolveEffectiveConfig( @@ -73,7 +76,8 @@ export async function GET() { cfg.withdrawalStages, priorProfit, allFundTxns, - cfg.minDayPnL + cfg.minDayPnL, + stageInputsFor(client, acc.id, cfg), ) : null; // Days traded counts only days that hit minDayPnL (when set) — that's what // the firm requires toward the min trading day rule. @@ -128,16 +132,8 @@ export async function GET() { // the day after a loss rides stopless) against the hard // daily-loss stop, before fees. A min day trades its whole // remaining target as one leg. - const dll = cfg.dailyLossLimit ?? 0; - if (dll > 0) { - const dayStart = client.dayStartBalance?.(acc.id); - dllRoom = dayStart === null ? null : computeDllRoom({ - dailyLossLimit: dll, - closedBalance: cash.amount, - dayStartBalance: dayStart, - realizedToday: Math.min(cash.realizedPnL, combined.realizedPnL), - }); - } + const dll = effective?.dailyLossLimit ?? 0; + dllRoom = dayLossRoom(client, acc.id, dll, cash, combined.realizedPnL); // The max-drawdown floor, and whether the next leg is a last // stand — no daily-loss stop — as fireLeg decides it. const floorRoom = maxLossRoom({ @@ -164,7 +160,7 @@ export async function GET() { // fireLeg would, and the one a batch would pick. if (plan && !dayBooked && plan.remainingToday > 0) { const best = rankNextLeg({ - client, accountId: acc.id, cfg, firm: f, leg: plan.remainingToday, dllRoom, + client, accountId: acc.id, cfg: { ...cfg, dailyLossLimit: dll }, firm: f, leg: plan.remainingToday, dllRoom, obfuscationDollars: obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday), cash, tradeDate: combined.tradeDate, })[0]; diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 9a1e193..b396b8c 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, + fundRowsKey, markFinishedCycle, unbookedPayoutDrop, machineSide, stageInputsFor, floorRoom, type DayPlan, type FinishedCycleMark, } from './trading-logic'; import { getSetting, setSetting, loadAccountMeta, saveAccountMeta } from './db'; @@ -69,6 +69,8 @@ interface TradeRuntime { * machine, while it places it: another lane deciding meanwhile takes the * same side (see sideForEntry). Only counts while its lease holds. */ sideReservation?: { side: 'Buy' | 'Sell'; lane: string; lease: number }; + /** Account id → when its last payout claim failed (runClaims backoff). */ + claimFailedAt?: Map; /** Accounts whose exit the broker refused — a bracket while a hard daily * loss limit was on, or a lone take-profit: the position was flattened * and the account sits out the trade date rather than bleed a round trip @@ -538,7 +540,7 @@ function legShapeFor(item: SizingItem, tradeDate: string): LegShape { const cfg = getAccountConfig(item.acc.id).accountType!; const leg = legAmountFor(item, tradeDate); const obf = obfuscationStopDollars(cfg, item.dailyPnL, item.plan, tradeDate, leg); - const dll = cfg.dailyLossLimit ?? 0; + const dll = item.dll; const hard = Number.isFinite(item.dllRoom) && dll > 0 ? dllStopDollars({ room: item.dllRoom, dailyLossLimit: dll, entryFee: FALLBACK_COMMISSION_PER_CONTRACT, commissionPct: 0 }) : Infinity; @@ -602,6 +604,8 @@ interface AccountEvaluation { /** What the firm's day may still lose before a hard daily-loss breach * (Infinity = no limit). */ dllRoom: number; + /** The hard daily loss limit at the account's current stage (0 = none). */ + dll: number; } /** @@ -612,24 +616,34 @@ function dailyLossGate( // eslint-disable-next-line @typescript-eslint/no-explicit-any client: any, accountId: number, - cfg: { dailyLossLimit?: number; maxPositionSize: number; firmId: string }, + /** The limit at the account's current stage (0 = none). */ + dll: number, + cfg: { maxPositionSize: number; firmId: string }, cash: { amount: number; realizedPnL: number }, combinedRealized: number, remainingToday: number, singleContract: boolean, ): { room: number } | { reason: string } { - const dll = cfg.dailyLossLimit ?? 0; if (!(dll > 0)) return { room: Infinity }; - const dayStart: number | null | undefined = client.dayStartBalance?.(accountId); - if (dayStart === null) return { reason: DLL_BASE_PENDING }; - const room = dllRoom({ - dailyLossLimit: dll, - closedBalance: cash.amount, - dayStartBalance: dayStart, - // The broker's day, raw — and on a combining firm the worse of it - // and the combined figure, whichever the firm turns out to use. - realizedToday: Math.min(cash.realizedPnL, combinedRealized), - }); + // A platform that states its own daily-loss floor (Vest) decides the + // room against it, transfers and all; the rest measure the day's P&L. + const floor: number | null | undefined = client.dailyLossFloor?.(accountId); + if (floor === null) return { reason: DLL_BASE_PENDING }; + let room: number; + if (typeof floor === 'number') { + room = floorRoom({ dailyLossLimit: dll, closedBalance: cash.amount, floor }); + } else { + const dayStart: number | null | undefined = client.dayStartBalance?.(accountId); + if (dayStart === null) return { reason: DLL_BASE_PENDING }; + room = dllRoom({ + dailyLossLimit: dll, + closedBalance: cash.amount, + dayStartBalance: dayStart, + // The broker's day, raw — and on a combining firm the worse of it + // and the combined figure, whichever the firm turns out to use. + realizedToday: Math.min(cash.realizedPnL, combinedRealized), + }); + } // Measured-fee firms: a typical round trip on the contracts this leg // could use. Percent-fee firms size by notional, which isn't known // here — fireLeg re-checks with the real size before any entry. @@ -721,7 +735,7 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a const dailyPnL = combined.dailyPnL; const daysTraded = combined.daysTraded; - const base = { plan: null, isExtraDay: false, drain: false, cash, dailyPnL, daysTraded, dllRoom: Infinity }; + const base = { plan: null, isExtraDay: false, drain: false, cash, dailyPnL, daysTraded, dllRoom: Infinity, dll: 0 }; if (isAccountDead(cash.amount, autoLiqThreshold)) return { ...base, eligible: false, reason: 'dead' }; if (!acc.active) return { ...base, eligible: false, reason: 'inactive (DLL)' }; if (client.positions[acc.id]) return { ...base, eligible: false, reason: 'already in trade' }; @@ -751,7 +765,7 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a const allFundTxns = client.fundTransactions?.[acc.id] ?? []; const effective = resolveEffectiveConfig( cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, priorProfit, allFundTxns, - cfg.minDayPnL + cfg.minDayPnL, stageInputsFor(client, acc.id, cfg), ); const equityProfit = cash.amount - cfg.accountSize; @@ -768,7 +782,7 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a && effective.minTradingDays > daysTraded && totalProfit >= effective.profitTarget; - const evaluated = { plan, isExtraDay, drain: effective.drain, cash, dailyPnL, daysTraded, dllRoom: Infinity }; + const evaluated = { plan, isExtraDay, drain: effective.drain, cash, dailyPnL, daysTraded, dllRoom: Infinity, dll: effective.dailyLossLimit }; // A finished cycle that a balance drop reopens, with no fill or fund row // to explain it, is a payout not booked yet: hold it until the booking @@ -820,7 +834,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, cfg, cash, combined.realizedPnL, plan.remainingToday, isExtraDay || effective.drain); + const gate = dailyLossGate(client, acc.id, effective.dailyLossLimit, cfg, cash, combined.realizedPnL, plan.remainingToday, 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 }; @@ -975,6 +989,8 @@ interface LegItem { prefix: string; /** Hard daily-loss room (Infinity = no limit). */ dllRoom: number; + /** The hard daily loss limit at the account's current stage (0 = none). */ + dll: number; } /** What sizing a leg needs before its instrument is known. */ @@ -1065,7 +1081,7 @@ async function runTradeLocked( cash: ev.cash, dailyPnL: ev.dailyPnL, daysTraded: ev.daysTraded, plan: ev.plan, isExtraDay: ev.isExtraDay, drain: ev.drain, prefix: getAccountConfig(acc.id).accountType?.prefix ?? '', - dllRoom: ev.dllRoom, + dllRoom: ev.dllRoom, dll: ev.dll, }); } } catch (err) { @@ -1374,7 +1390,7 @@ function sizeLeg(item: SizingItem, contract: LegContract, legAmount: number, tra commissionPct: pct, targetHoldHours: sizingHoldHours(item.firm, tradeDate), financingPctPerDay: item.firm.financingPctPerDay ?? 0, - dailyLossLimit: cfg.dailyLossLimit ?? 0, + dailyLossLimit: item.dll, dllRoom: item.dllRoom, maxLossRoom: floorRoom, obfuscationDollars: obfuscation, @@ -1439,7 +1455,7 @@ async function fireLeg( // the broker (here, or in sendEntry). recordLeg(acc.id, tradeDate, legAmount); - const dll = cfg.dailyLossLimit ?? 0; + const dll = item.dll; const dllOn = Number.isFinite(item.dllRoom) && dll > 0; // Everything before the entry goes out: a failure here never reached the @@ -1761,7 +1777,7 @@ async function copyTradeLocked(lease: number, lane: string): Promise firmName: firm.name, firm, client, acc, contract, pv, cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan, drain: ev.drain, prefix: getAccountConfig(acc.id).accountType?.prefix ?? '', - dllRoom: ev.dllRoom, + dllRoom: ev.dllRoom, dll: ev.dll, }); } eligible.push(...found); @@ -1849,7 +1865,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, 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.remainingToday, ev.drain); if ('reason' in gate) throw new Error(gate.reason); room = gate.room; } @@ -1877,7 +1893,7 @@ export async function fireManualLeg(accountId: number, action: 'Buy' | 'Sell' | const sizingItem: SizingItem = { firmName: firm.name, firm, client, acc, cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan, drain: ev.drain, - prefix: cfg.prefix, dllRoom: room, + prefix: cfg.prefix, dllRoom: room, dll: ev.dll, }; // The instrument: a DXtrade or Vest firm ranks its list; a futures // firm joins the held root, else trades an explicit scheduler symbol @@ -1914,6 +1930,79 @@ export async function fireManualLeg(accountId: number, action: 'Buy' | 'Sell' | } } +// ── payout claims ──────────────────────────────────────────────────────────── + +/** A failed claim is retried after this long. */ +const CLAIM_RETRY_MS = 5 * 60_000; + +/** How much an account should claim now, or null: a payoutable account whose + * platform takes payouts by API, flat and settled, that has reached its + * stage's target — all of its profit above the starting balance. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function claimDue(client: any, acc: { id: number; active: boolean }, now: number): number | null { + const cfg = getAccountConfig(acc.id).accountType; + if (!cfg || cfg.payoutable !== true || !acc.active) return null; + if (client.positions[acc.id] || client.hasWorkingOrders?.(acc.id) || runtime().accountsInFlight.has(acc.id)) return null; + if (!client.historyReady?.(acc.id)) return null; + if (now - (runtime().claimFailedAt?.get(acc.id) ?? 0) < CLAIM_RETRY_MS) return null; + const cash = client.accountCashBalances[acc.id]; + if (!cash) return null; + const effective = resolveEffectiveConfig( + cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, + client.priorProfit?.[acc.id] ?? 0, client.fundTransactions?.[acc.id] ?? [], cfg.minDayPnL, + stageInputsFor(client, acc.id, cfg), + ); + if (effective.drain || !(effective.profitTarget > 0)) return null; + const profit = cash.amount - cfg.accountSize; + return profit >= effective.profitTarget - LEG_COMPLETION_EPSILON ? profit : null; +} + +/** + * Claim the profit of every payoutable account in the lane that reached its + * stage's target, where the platform takes payouts by API (Vest's Claim + * Profit). The account then starts its next stage the same day: its leg + * counts and entry outcome for the day are cleared, and a type whose stages + * reset each day trades its next stage on the room the claim freed. Under the + * lane's mutex, so no batch reads an account mid-claim. Off with the + * auto_claim setting at 'off'. + */ +async function runClaims(lane: string, ready: FirmClient[]): Promise { + if (getSetting('auto_claim') === 'off') return; + const items = inLane(ready, lane).filter(({ client }) => typeof (client as { claimProfit?: unknown }).claimProfit === 'function'); + if (items.length === 0) return; + const now = Date.now(); + const due = items.flatMap(({ firm, client }) => client.accountList.flatMap((acc) => { + const amount = claimDue(client, acc, now); + return amount === null ? [] : [{ firm, client, acc, amount }]; + })); + if (due.length === 0) return; + const lease = acquireTradeMutex('[auto-claim]', lane); + if (lease === null) return; + try { + const tradeDate = currentTradeDateCT(); + for (const { firm, client, acc, amount } of due) { + if (!holdsTradeMutex(lease, lane)) return; + if (claimDue(client, acc, Date.now()) === null) continue; // settled since + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { claimed, received } = await (client as any).claimProfit(acc.id, amount); + runtime().legCounts.delete(acc.id); + runtime().entryOutcomes?.delete(acc.id); + runtime().claimFailedAt?.delete(acc.id); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const stage = 1 + ((client as any).sameDayClaims?.(acc.id) ?? 0); + console.log(`[auto-claim] ${firm.name} ${acc.name}: claimed $${claimed}${received !== null ? ` (you get $${received})` : ''}` + + ` — trading stage ${stage} next (${tradeDate})`); + } catch (err) { + (runtime().claimFailedAt ??= new Map()).set(acc.id, Date.now()); + console.error(`[auto-claim] ${firm.name} ${acc.name}: claim of $${Math.floor(amount * 100) / 100} failed — ${errorMessage(err)}; retrying in ${CLAIM_RETRY_MS / 60_000}m`); + } + } + } finally { + releaseTradeMutex(lease, lane); + } +} + // ── eligibility check ──────────────────────────────────────────────────────── /** Returns true if any configured account could still trade today (eligible for a next leg / extra-day, or has an open position). */ @@ -2059,6 +2148,9 @@ export function startScheduler(action: 'Buy' | 'Sell' | 'Auto', symbol: string, console.log(`[scheduler] ${laneName(lane)}: waiting for sync: ${names}`); continue; } + // Payouts first, account by account — a claim doesn't wait + // for the rest of the lane to go flat. + await runClaims(lane, ready); // Skip this lane if any of its accounts still has an open // position from its previous batch — a batch fires only // when every (ready) account in the lane is flat. diff --git a/lib/kept-rules.ts b/lib/kept-rules.ts index e704e5a..567a415 100644 --- a/lib/kept-rules.ts +++ b/lib/kept-rules.ts @@ -22,7 +22,7 @@ import type { CatalogAccountType, KeptAccountRules } from './master-protocol'; export const RULE_FIELDS = [ 'profitTarget', 'consistency', 'minDayPnL', 'minTradingDays', 'maxLoss', 'maxPositionSize', 'targetSameEquity', 'splitEnabled', 'minLegs', 'firstDayStop', 'minDayStop', - 'combineCmeHolidays', 'withdrawalStages', 'payoutable', 'trailingDrawdown', 'dailyLossLimit', + 'combineCmeHolidays', 'withdrawalStages', 'payoutable', 'trailingDrawdown', 'dailyLossLimit', 'stagesSameDay', ] as const satisfies readonly (keyof CatalogAccountType)[]; /** Always the live type's: accounts are matched to a type by them, so a kept @@ -105,6 +105,8 @@ export function normalizeAccountType( minDayStop: type.minDayStop === true, // Lenient: no hard daily limit, no notional cap. dailyLossLimit: nonNegative(type.dailyLossLimit), + // Lenient: stages count every payout unless the master says otherwise. + stagesSameDay: type.stagesSameDay === true, // The firm's calendar, stamped on every type so the cfg-based // paths (projections, completion, the dashboard) all see it. tradesEveryDay, @@ -168,6 +170,7 @@ function normalizeStage(stage: unknown): unknown { minDayPnL: typeof s.minDayPnL === 'number' ? s.minDayPnL : (drain ? 5 : 0), ...(typeof s.withdrawalPct === 'number' ? { withdrawalPct: s.withdrawalPct } : {}), ...(typeof s.payoutCap === 'number' ? { payoutCap: s.payoutCap } : {}), + ...(typeof s.dailyLossLimit === 'number' ? { dailyLossLimit: s.dailyLossLimit } : {}), }; } @@ -181,6 +184,7 @@ function comparable(field: RuleField, value: unknown): unknown { case 'firstDayStop': case 'minDayStop': case 'combineCmeHolidays': + case 'stagesSameDay': case 'payoutable': return value === true; case 'minLegs': case 'trailingDrawdown': diff --git a/lib/leg-estimate.ts b/lib/leg-estimate.ts index b233ade..3086e29 100644 --- a/lib/leg-estimate.ts +++ b/lib/leg-estimate.ts @@ -11,9 +11,33 @@ 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, + planLegSize, rankForTarget, maxLossRoom, lastStand, dllStopDollars, stageInputsFor, floorRoom, FALLBACK_COMMISSION_PER_CONTRACT, } from './trading-logic'; + +/** + * The day's hard-loss room as the engine's dailyLossGate works it out, for + * the dashboard paths: against the platform's own floor where it states one + * (Vest), else the day's P&L. null = not known yet (or no limit, dll ≤ 0). + */ +export function dayLossRoom( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + client: any, + accountId: number, + dll: number, + cash: { amount: number; realizedPnL: number }, + combinedRealized: number, +): number | null { + if (!(dll > 0)) return null; + const floor: number | null | undefined = client.dailyLossFloor?.(accountId); + if (floor === null) return null; + if (typeof floor === 'number') return floorRoom({ dailyLossLimit: dll, closedBalance: cash.amount, floor }); + const dayStart = client.dayStartBalance?.(accountId); + return dayStart === null ? null : computeDllRoom({ + dailyLossLimit: dll, closedBalance: cash.amount, dayStartBalance: dayStart, + realizedToday: Math.min(cash.realizedPnL, combinedRealized), + }); +} import { sizingHoldHours } from './person-size'; export type CandidateLeg = SymbolStats['symbols'][number] & { @@ -105,6 +129,7 @@ function accountPlan( const effective = resolveEffectiveConfig( cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, client.priorProfit?.[acc.id] ?? 0, client.fundTransactions?.[acc.id] ?? [], cfg.minDayPnL, + stageInputsFor(client, acc.id, cfg), ); const plan = computeDayPlan( acc.id, combined.tradeDate, effective.profitTarget, effective.consistency, @@ -112,15 +137,8 @@ function accountPlan( cfg.splitEnabled, effective.drain, ); if (!plan) return null; - const dll = cfg.dailyLossLimit ?? 0; - let room: number | null = null; - if (dll > 0) { - const dayStart = client.dayStartBalance?.(acc.id); - room = dayStart === null ? null : computeDllRoom({ - dailyLossLimit: dll, closedBalance: cash.amount, dayStartBalance: dayStart, - realizedToday: Math.min(cash.realizedPnL, combined.realizedPnL), - }); - } + const dll = effective.dailyLossLimit; + const room = dayLossRoom(client, acc.id, dll, cash, combined.realizedPnL); const obfuscation = obfuscationStopDollars(cfg, combined.dailyPnL, plan, combined.tradeDate, plan.remainingToday); return { cfg, cash, plan, dll, room, obfuscation }; } @@ -147,7 +165,7 @@ export function accountNextLeg( leg: p.plan.remainingToday, lastStand: p.room !== null && lastStand({ dllRoom: p.room, dailyLossLimit: p.dll, maxLossRoom: floorRoom }), candidates: rankNextLeg({ - client, accountId: acc.id, cfg: p.cfg, firm, leg: p.plan.remainingToday, dllRoom: p.room, + client, accountId: acc.id, cfg: { ...p.cfg, dailyLossLimit: p.dll }, firm, leg: p.plan.remainingToday, dllRoom: p.room, obfuscationDollars: p.obfuscation, cash: p.cash, tradeDate, }), }; diff --git a/lib/master-protocol.ts b/lib/master-protocol.ts index 88b8b35..897ccf9 100644 --- a/lib/master-protocol.ts +++ b/lib/master-protocol.ts @@ -365,7 +365,11 @@ export interface CatalogAccountType { * minTradingDays qualifying days of ~minDayPnL (default $5, randomized * $5–$15, traded as tiny MNQ legs), then wait for the next withdrawal. * withdrawalPct/payoutCap override the type's payout defaults per stage. */ - withdrawalStages: { profit: number; consistency: number; minTradingDays: number; drain?: boolean; minDayPnL?: number; withdrawalPct?: number; payoutCap?: number }[]; + withdrawalStages: { profit: number; consistency: number; minTradingDays: number; drain?: boolean; minDayPnL?: number; withdrawalPct?: number; payoutCap?: number; dailyLossLimit?: number }[]; + /** True = the stage is chosen by the payouts made since the firm's last + * daily reset, and every new day starts back at the base stage (Vest: + * claim, then trade again the same day). Absent from older masters. */ + stagesSameDay?: boolean; /** Payout-projection settings, owned by the master's payout calendar but * consumed here to compute each account's future payout chain. Optional: * an older master doesn't send them. */ diff --git a/lib/platform-client.ts b/lib/platform-client.ts index 1f3b857..9a966b4 100644 --- a/lib/platform-client.ts +++ b/lib/platform-client.ts @@ -121,6 +121,24 @@ export interface PlatformClient { * account rather than size a stop on a guess. */ dayStartBalance?(accountId: number): number | null; + /** + * 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 + * 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; + /** 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; + /** Request a payout of `amount` from the account to the login's own main + * account, where the platform takes one by API (Vest's Claim Profit). + * Resolves once the platform accepted it and the books were updated; + * rejects with the platform's reason otherwise. */ + claimProfit?(accountId: number, amount: number): Promise<{ claimed: number; received: number | null }>; /** Drop the account's history from memory (the wipe routes), so it reads * unloaded until the next fetch rebuilds it. */ forgetHistory(accountId: number, accountName: string): void; diff --git a/lib/reporter.ts b/lib/reporter.ts index ff379bf..ef52e02 100644 --- a/lib/reporter.ts +++ b/lib/reporter.ts @@ -20,7 +20,7 @@ import { getClients } from './clients'; import { getAccountTypeById, getKeptRuleIdsForFirm, getTradableFirms } from './firm-catalog'; import { getAccountConfig } from './account-type-resolver'; import { getSchedulerStatus } from './auto-trade'; -import { botStopsActive, combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig } from './trading-logic'; +import { botStopsActive, combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig, stageInputsFor } from './trading-logic'; import type { AccountTypeStats, FirmStats, InstrumentStats, SymbolStats } from './master-protocol'; import { accountNextLeg, accountLegShape } from './leg-estimate'; import { sizingHoldHours } from './person-size'; @@ -154,6 +154,7 @@ function collectFirmStats() { cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, client.priorProfit?.[acc.id] ?? 0, fundTxns, cfg.minDayPnL, + stageInputsFor(client, acc.id, cfg), ) : null; // Days traded, for the master's per-day pass counts — the same // number as the local dashboard's Days Traded column diff --git a/lib/trading-logic.ts b/lib/trading-logic.ts index 2688f07..915009e 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -16,6 +16,8 @@ export interface WithdrawalStage { * fields fall back to the account type's defaults. */ withdrawalPct?: number; payoutCap?: number; + /** This stage's hard daily loss limit in dollars; absent = the type's. */ + dailyLossLimit?: number; } export interface EffectiveConfig { @@ -24,6 +26,17 @@ export interface EffectiveConfig { minTradingDays: number; minDayPnL: number; drain: boolean; + /** The stage's hard daily loss limit (0 = none): its own, else the type's. */ + dailyLossLimit: number; +} + +/** What picks a stage beyond the fund rows: the type's daily loss limit (a + * stage may override it), and for a type whose stages reset each day, how + * many payouts the account has made since the firm's last daily reset. */ +export interface StageInputs { + dailyLossLimit?: number; + stagesSameDay?: boolean; + sameDayWithdrawals?: number; } /** @@ -47,20 +60,28 @@ export function resolveEffectiveConfig( priorProfit: number, fundTransactions: { date: string; amount: number }[], minDayPnL = 0, + inputs: StageInputs = {}, ): EffectiveConfig { + const dailyLossLimit = Math.max(0, inputs.dailyLossLimit ?? 0); if (targetSameEquity) { // Simple Mode A: effective target = profitTarget - equityProfit, computed in computeDailyTarget. // Since computeDailyTarget already compares (amount - accountSize) against profitTarget, // no adjustment needed here — just return the base values. - return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false }; + return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false, dailyLossLimit }; } if (withdrawalStages.length > 0) { - const withdrawalCount = fundTransactions.filter((f) => f.amount < 0).length; + // Same-day stages (Vest): only today's payouts count, so every new + // day starts back at stage 1 — the room a payout frees lasts only + // until the firm's next daily reset. + const withdrawalCount = inputs.stagesSameDay + ? Math.max(0, Math.floor(inputs.sameDayWithdrawals ?? 0)) + : fundTransactions.filter((f) => f.amount < 0).length; if (withdrawalCount === 0) { - return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false }; // Stage 1 = base values + return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false, dailyLossLimit }; // Stage 1 = base values } const idx = Math.min(withdrawalCount - 1, withdrawalStages.length - 1); const stage = withdrawalStages[idx]; + const stageLimit = typeof stage.dailyLossLimit === 'number' && stage.dailyLossLimit >= 0 ? stage.dailyLossLimit : dailyLossLimit; if (stage.drain === true) { return { profitTarget: 0, @@ -73,12 +94,16 @@ export function resolveEffectiveConfig( // qualifying bar sits at the grid floor — every draw counts. minDayPnL: DRAIN_DAY_MIN, drain: true, + dailyLossLimit: stageLimit, }; } // Fall back to base minTradingDays when the stage doesn't specify one (0 or missing) - return { profitTarget: stage.profit, consistency: stage.consistency, minTradingDays: stage.minTradingDays || minTradingDays, minDayPnL, drain: false }; + return { + profitTarget: stage.profit, consistency: stage.consistency, minTradingDays: stage.minTradingDays || minTradingDays, + minDayPnL, drain: false, dailyLossLimit: stageLimit, + }; } - return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false }; + return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false, dailyLossLimit }; } /** @deprecated Use resolveEffectiveConfig instead */ @@ -1325,6 +1350,32 @@ export const DLL_MIN_STOP = 50; * 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( + client: { sameDayClaims?: (accountId: number) => number }, + accountId: number, + cfg: { dailyLossLimit?: number; stagesSameDay?: boolean }, +): StageInputs { + const sameDay = cfg.stagesSameDay === true; + return { + dailyLossLimit: cfg.dailyLossLimit ?? 0, + stagesSameDay: sameDay, + sameDayWithdrawals: sameDay && typeof client.sameDayClaims === 'function' ? client.sameDayClaims(accountId) : 0, + }; +} + +/** + * 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 + * transfers): never more than the stage's limit, nor than the floor leaves. + * Infinity when there is no limit. + */ +export function floorRoom(o: { dailyLossLimit: number; closedBalance: number; floor: number }): number { + if (!(o.dailyLossLimit > 0)) return Infinity; + return round2(Math.min(o.dailyLossLimit, o.closedBalance - o.floor)); +} + export function dllRoom(o: { dailyLossLimit: number; closedBalance: number; diff --git a/lib/vest-class.ts b/lib/vest-class.ts index 7df4007..8b056a5 100644 --- a/lib/vest-class.ts +++ b/lib/vest-class.ts @@ -36,14 +36,14 @@ * - Fees are each symbol's taker fee from exchangeInfo, a percent of notional. */ +import { randomUUID } from 'crypto'; import axios from './http-client'; -import { currentTradeDateCT, rankForTarget } from './trading-logic'; +import { currentTradeDateCT, rankForTarget, dllReserve } from './trading-logic'; import { sizingHoldHours } from './person-size'; import { saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions, saveEndedCycleStart, } from './db'; -import { getAccountConfig } from './account-type-resolver'; import { getCachedFirm } from './firm-catalog'; import { firmInstruments } from './instruments'; import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth, PickContext } from './platform-client'; @@ -51,9 +51,9 @@ import { OrderNotPlacedError, isOrderNotPlaced, errorMessage } from './platform- import { encodeContractName, decodeContractName, roundToStep, scoreCandidate } from './dxtrade-helpers'; import { vestSessionFor, dropVestSession, listVestSessions } from './vest-session'; import { - vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder, - parseTxn, bookTxns, awaitingLegs, unrealizedPnl, hourlyVolatility, marketOpen, num, toMs, formatDecimal, - VEST_STATUS_ACTIVE, + vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, nextVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder, + parseTxn, bookTxns, awaitingLegs, unrealizedPnl, hourlyVolatility, marketOpen, num, toMs, formatDecimal, resetSafeStop, + VEST_STATUS_ACTIVE, VEST_TXN, } from './vest-helpers'; import type { VestCapitalAccount, VestPosition, VestOpenOrder, VestTxn } from './vest-helpers'; @@ -95,6 +95,11 @@ const NAKED_AFTER_MS = 2 * 60_000; /** Vest's own spread isn't on its REST surface (only the depth socket): a * candidate is costed as crossing this many ticks. */ 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; +/** account_meta key of the profit claims this client made, per account. */ +const CLAIMS_META = 'vest_claims'; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); const round2 = (n: number) => Math.round(n * 100) / 100; @@ -162,6 +167,17 @@ export class VestClient implements PlatformClient { private readonly onSync: () => Promise; private uuidById = new Map(); + /** The login's main (primary) account — where a claimed profit goes. */ + private primaryAccountId: string | null = null; + /** Profit claims this client made, per account: when, how much, and the + * balance right after — today's P&L counts from there (realizedSince). + * Loaded from account_meta on first use, so a restart keeps them. */ + private claims = new Map(); + /** 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; private capital = new Map(); private positionsDetail = new Map(); private openOrders = new Map(); @@ -286,7 +302,7 @@ export class VestClient implements PlatformClient { */ private async request( bucket: Bucket, - method: 'GET' | 'POST' | 'DELETE', + method: 'GET' | 'POST' | 'PUT' | 'DELETE', path: string, opts: { params?: Record; body?: unknown; account?: string; auth?: boolean; retried?: boolean } = {}, ): Promise<{ data: T }> { @@ -354,6 +370,7 @@ export class VestClient implements PlatformClient { if (this.intentionalDisconnect) return; const now = Date.now(); if (this.syncInFlight && now - this.syncInFlightSince >= SYNC_WEDGED_MS) void this.requestSync(); + void this.guardReset().catch((err) => console.error(`${this.tag} reset guard failed: ${errorMessage(err)}`)); } // ── public market data ─────────────────────────────────────────────────── @@ -475,6 +492,8 @@ export class VestClient implements PlatformClient { for (const b of bal.data?.accounts ?? []) { const amount = num(b.amount); if (typeof b.account_id === 'string' && amount !== undefined) free.set(b.account_id, amount); + // The login's own wallet account: where Claim Profit sends a payout. + if (typeof b.account_id === 'string' && num(b.account_type) === 1) this.primaryAccountId = b.account_id; } const positions = (pos.data?.positions ?? []).map(parsePosition).filter((p): p is VestPosition => p !== null); const orders = (ord.data?.orders ?? []).map(parseOpenOrder).filter((o): o is VestOpenOrder => o !== null); @@ -516,10 +535,9 @@ export class VestClient implements PlatformClient { if (amount === undefined) { complete = false; continue; } const balance = amount + mine.reduce((s, p) => s + p.collateral, 0); const upnl = mine.reduce((s, p) => s + unrealizedPnl(p, this.marks.get(p.symbol)?.mark ?? NaN), 0); - const resetBalance = this.resetBalanceOf(a, balance, now); this.accountCashBalances[id] = { amount: round2(balance), - realizedPnL: resetBalance !== undefined ? round2(balance - resetBalance) : 0, + realizedPnL: this.realizedSince(id, a, balance, now), equity: round2(balance + upnl), availableFunds: round2(amount), }; @@ -609,21 +627,170 @@ export class VestClient implements PlatformClient { } /** - * See PlatformClient: what the engine measures the day's loss from. Vest - * states the floor itself (reset balance × (1 − rate), moved by transfers), - * so this is that floor plus the account type's own daily limit — the - * engine's room, limit + min(0, balance − dayStart), is then - * min(limit, balance − floor): never more than Vest leaves. null until - * the account has been read. + * 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 + * Vest gives no daily limit. */ - public dayStartBalance(accountId: number): number | null { + public dailyLossFloor(accountId: number): number | null | undefined { const a = this.capital.get(accountId); const cash = this.accountCashBalances[accountId]; if (!a || !cash) return null; - const floor = this.dailyFloorOf(a, cash.amount, Date.now()); - const limit = getAccountConfig(accountId).accountType?.dailyLossLimit ?? 0; - if (floor === undefined) return this.resetBalanceOf(a, cash.amount, Date.now()) ?? null; - return round2(floor + limit); + if (!(a.maxDailyLossPct > 0)) return undefined; + return this.dailyFloorOf(a, cash.amount, Date.now()) ?? null; + } + + private claimsOf(accountId: number): { at: number; amount: number; base: number }[] { + let list = this.claims.get(accountId); + if (!list) { + try { + const raw = JSON.parse(loadAccountMeta(accountId, CLAIMS_META) ?? '[]'); + list = Array.isArray(raw) ? raw.filter((c) => Number.isFinite(c?.at) && Number.isFinite(c?.amount) && Number.isFinite(c?.base)) : []; + } catch { + list = []; + } + this.claims.set(accountId, list); + } + return list; + } + + /** + * Today's realized P&L as the day plan wants it: since the latest claim + * this client made since the last reset — the plan's new cycle starts + * there — else since the reset. A claim made by hand on Vest, whose + * after-balance this client never saw, is added back to the reset balance + * instead (Vest's reset balance doesn't take the payout out). + */ + private realizedSince(accountId: number, a: VestCapitalAccount, balance: number, now: number): number { + const reset = lastVestResetMs(now); + const mine = this.claimsOf(accountId).filter((c) => c.at >= reset); + if (mine.length > 0) return round2(balance - mine[mine.length - 1].base); + const resetBalance = this.resetBalanceOf(a, balance, now); + if (resetBalance === undefined) return 0; + const byHand = (this.withdrawals.get(accountId) ?? []).filter((w) => w.at >= reset).reduce((s, w) => s + Math.abs(w.amount), 0); + return round2(balance - resetBalance + byHand); + } + + /** See PlatformClient: the payouts since the last 20:00 ET — this client's + * own claims, or the WITHDRAW transactions Vest shows, whichever counts + * more (a claim made by hand shows only there). */ + public sameDayClaims(accountId: number): number { + const reset = lastVestResetMs(Date.now()); + const mine = this.claimsOf(accountId).filter((c) => c.at >= reset).length; + const seen = (this.withdrawals.get(accountId) ?? []).filter((w) => w.at >= reset).length; + return Math.max(mine, seen); + } + + /** + * See PlatformClient. Vest's Claim Profit: POST /v3/capital/withdraw from + * the funded account to the login's main account, which pays the profit + * split within 24 hours. The account must be flat with no orders, and the + * claim at least $1 of the profit above its starting balance. + * + * The payout is booked here at once — today's fund row, the cycle cut, + * the P&L base — so the account can trade its next stage without waiting + * for the hourly history read, which then rebooks it from Vest's own + * transactions (the same day's row, the same amount). + */ + public async claimProfit(accountId: number, amount: number): Promise<{ claimed: number; received: number | null }> { + const uuid = this.uuidById.get(accountId); + const account = this.accountList.find((a) => a.id === accountId); + const cash = this.accountCashBalances[accountId]; + if (!uuid || !account || !cash) throw new Error('account not read yet'); + if (!this.primaryAccountId) throw new Error('the main account is unknown — no sync has listed it'); + if (this.positions[accountId] || (this.openOrders.get(accountId) ?? []).length > 0) throw new Error('positions or orders open'); + const claimed = Math.floor(amount * 100) / 100; + if (!(claimed >= 1)) throw new Error(`$${claimed} is below Vest's $1 minimum claim`); + + const { data } = await this.request<{ trader_amount?: unknown }>('trade', 'POST', '/v3/capital/withdraw', { + account: uuid, + body: { + account_id: uuid, + amount: formatDecimal(claimed, 6), + target_account_id: this.primaryAccountId, + idempotency_key: randomUUID(), + }, + }); + const received = num(data?.trader_amount) ?? null; + const now = Date.now(); + const list = [...this.claimsOf(accountId), { at: now, amount: claimed, base: round2(cash.amount - claimed) }] + .filter((c) => now - c.at < 7 * 86_400_000); + this.claims.set(accountId, list); + saveAccountMeta(accountId, CLAIMS_META, JSON.stringify(list)); + + // Book it now: today's fund row (with whatever else that day holds), + // the cycle cut, and the balance until the next sync reads Vest's. + const day = vestDayOf(now); + const sameDay = (this.fundTransactions[accountId] ?? []).find((f) => f.date === day)?.amount ?? 0; + this.applyFundRows(account, [{ date: day, amount: round2(sameDay - claimed) }]); + this.accountCashBalances[accountId] = { ...cash, amount: round2(cash.amount - claimed), realizedPnL: 0 }; + console.log(`${this.tag} ${account.name}: claimed $${claimed}${received !== null ? ` — $${received} to the main account` : ''}`); + + void this.fetchDaysTraded([accountId]).catch((err) => console.warn(`${this.tag} history after the claim failed: ${errorMessage(err)}`)); + void this.requestSync(); + return { claimed, received }; + } + + /** + * 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. + */ + private async guardReset(): Promise { + if (!this.syncComplete || this.intentionalDisconnect) 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' }); + 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 }, + }); + } else { + await this.submit(uuid, '/v3/positions/stop-loss', { positionId: pos.positionId, executionType: 'market', triggerPrice }); + } + 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)}`); + } + } + } + if (failed === 0) this.resetGuardedFor = next; } private settleSuspects(): void { @@ -766,28 +933,25 @@ export class VestClient implements PlatformClient { .sort((x, y) => x.date.localeCompare(y.date)); saveDailyPnL(account.id, account.name, fresh); - const storedFundDate = loadAccountMeta(account.id, 'last_fund_date'); const fundRows = [...funds.entries()] .map(([date, amount]) => ({ date, amount: round2(amount) })) .filter((f) => f.amount !== 0) .sort((x, y) => x.date.localeCompare(y.date)); - if (fundRows.length > 0) saveFundTransactions(account.id, account.name, fundRows); - this.fundTransactions[account.id] = loadFundTransactions(account.id); - const lastFundDate = fundRows.length > 0 ? fundRows[fundRows.length - 1].date : storedFundDate; - if (lastFundDate && storedFundDate && lastFundDate > storedFundDate) { - const prevAmt = this.fundTransactions[account.id]?.find((f) => f.date === storedFundDate)?.amount ?? null; - saveEndedCycleStart(account.id, { fundDate: storedFundDate, includesDay: !(prevAmt !== null && prevAmt < 0) }); - } - if (lastFundDate) saveAccountMeta(account.id, 'last_fund_date', lastFundDate); - this.lastFundDates[account.id] = lastFundDate ?? null; - this.applyCycleSlice(account.id); + this.applyFundRows(account, fundRows); + this.withdrawals.set(account.id, txns.filter((t) => t.type === VEST_TXN.WITHDRAW).map((t) => ({ at: t.time, amount: t.amount }))); // Books check: the balance must be what the transactions add up to // (the opening capital included). A payout or credit booked under a // type this client doesn't count would otherwise trade a finished // cycle as if nothing happened. const cash = this.accountCashBalances[account.id]; - const expected = txns.reduce((s, t) => s + (t.kind === 'margin' ? 0 : t.amount), 0) + (hasDeposit ? 0 : a?.initialCapital ?? 0); + // A claim this client just made may not show in Vest's transactions + // yet: count it until its WITHDRAW row does (same amount, near time). + const shown = txns.filter((t) => t.type === VEST_TXN.WITHDRAW); + const unbooked = this.claimsOf(account.id) + .filter((c) => !shown.some((w) => Math.abs(Math.abs(w.amount) - c.amount) < 0.01 && Math.abs(w.time - c.at) < 30 * 60_000)) + .reduce((s, c) => s + c.amount, 0); + const expected = txns.reduce((s, t) => s + (t.kind === 'margin' ? 0 : t.amount), 0) + (hasDeposit ? 0 : a?.initialCapital ?? 0) - unbooked; if (account.active && cash && !this.positions[account.id] && Math.abs(cash.amount - expected) >= BOOKS_TOLERANCE) { this.booksHeld.add(account.id); this.lastFetchErrors[account.name] = `balance ${round2(cash.amount)} vs transactions ${round2(expected)} (types ${types.join(',')}) — held until they agree`; @@ -799,6 +963,23 @@ export class VestClient implements PlatformClient { this.historyLoadedIds.add(account.id); } + /** Store fund rows (one per Vest day), note where a cycle ended when the + * latest fund day moves forward, and recut the active cycle. */ + private applyFundRows(account: PlatformAccount, fundRows: { date: string; amount: number }[]): void { + const storedFundDate = loadAccountMeta(account.id, 'last_fund_date'); + if (fundRows.length > 0) saveFundTransactions(account.id, account.name, fundRows); + this.fundTransactions[account.id] = loadFundTransactions(account.id); + const latest = fundRows.length > 0 ? fundRows.map((f) => f.date).reduce((x, y) => (y > x ? y : x)) : null; + const lastFundDate = latest && (!storedFundDate || latest > storedFundDate) ? latest : storedFundDate ?? latest; + if (lastFundDate && storedFundDate && lastFundDate > storedFundDate) { + const prevAmt = this.fundTransactions[account.id]?.find((f) => f.date === storedFundDate)?.amount ?? null; + saveEndedCycleStart(account.id, { fundDate: storedFundDate, includesDay: !(prevAmt !== null && prevAmt < 0) }); + } + if (lastFundDate) saveAccountMeta(account.id, 'last_fund_date', lastFundDate); + this.lastFundDates[account.id] = lastFundDate ?? null; + this.applyCycleSlice(account.id); + } + private fallBackToCache(account: PlatformAccount, msg: string): void { const cached = loadDailyPnL(account.id); this.fundTransactions[account.id] ??= loadFundTransactions(account.id); diff --git a/lib/vest-helpers.ts b/lib/vest-helpers.ts index 53af44c..eba27c7 100644 --- a/lib/vest-helpers.ts +++ b/lib/vest-helpers.ts @@ -92,6 +92,58 @@ export function lastVestResetMs(now: number): number { return NaN; } +/** The next 20:00 ET after `now`, epoch ms (a day can be 23 or 25 hours). */ +export function nextVestResetMs(now: number): number { + for (let h = 1; h <= 26; h++) { + const t = lastVestResetMs(now + h * 3_600_000); + if (t > now) return t; + } + return NaN; +} + +/** + * What to do with an open position before the daily reset so the reset can't + * breach it. At 20:00 ET Vest captures the realized balance (collateral in, + * unrealized P&L out) and sets the day's floor to balance × (1 − rate); the + * max-drawdown floor stays. A position whose loss allowance is wider than + * that next-day room would breach the moment the reset lands, so its stop + * comes in to where equity stays above the coming floor plus `reserve`, with + * the exit fee paid: + * '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. + */ +export function resetSafeStop(o: { + side: 'long' | 'short'; + quantity: number; + openPrice: number; + mark: number; + /** Realized balance, collateral included — what the reset captures. */ + balance: number; + dailyLossPct: number; + maxDrawdownFloor: number | undefined; + reserve: number; + /** Taker fee as a fraction of notional. */ + exitFeeRate: number; + 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 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' }; + const long = o.side === 'long'; + const raw = long ? o.openPrice - allowed / o.quantity : o.openPrice + allowed / o.quantity; + const price = o.tick > 0 + ? (long ? Math.ceil(raw / o.tick - 1e-9) * o.tick : Math.floor(raw / o.tick + 1e-9) * o.tick) + : raw; + const rounded = Number(price.toFixed(10)); + if (o.currentStop !== null && (long ? o.currentStop >= rounded : o.currentStop <= rounded)) return { action: 'none' }; + if (Number.isFinite(o.mark) && (long ? o.mark <= rounded : o.mark >= rounded)) return { action: 'close' }; + return { action: 'stop', price: rounded }; +} + // ── accounts ──────────────────────────────────────────────────────────────── export interface VestCapitalAccount { diff --git a/scripts/check-kept-rules.mjs b/scripts/check-kept-rules.mjs index 17ff929..1e0f524 100644 --- a/scripts/check-kept-rules.mjs +++ b/scripts/check-kept-rules.mjs @@ -19,7 +19,7 @@ const LIVE = { targetSameEquity: false, splitEnabled: true, minLegs: 2, firstDayStop: true, minDayStop: false, combineCmeHolidays: false, withdrawalStages: [{ profit: 2000, consistency: 0.4, minTradingDays: 5, drain: false, minDayPnL: 0 }], payoutable: true, trailingDrawdown: 2000, expectedPayout: 1000, withdrawalPct: 0.5, payoutCap: 3000, profitSplit: 0.9, - dailyLossLimit: 1000, tradesEveryDay: false, updatedAt: '2026-10-18T14:05:00.000Z', + dailyLossLimit: 1000, stagesSameDay: false, tradesEveryDay: false, updatedAt: '2026-10-18T14:05:00.000Z', }; /** `obj` without `keys`. */ @@ -39,7 +39,7 @@ test('field classes: the spec\'s lists, identical to the master\'s, and disjoint assert.deepEqual([...RULE_FIELDS], [ 'profitTarget', 'consistency', 'minDayPnL', 'minTradingDays', 'maxLoss', 'maxPositionSize', 'targetSameEquity', 'splitEnabled', 'minLegs', 'firstDayStop', 'minDayStop', 'combineCmeHolidays', 'withdrawalStages', 'payoutable', - 'trailingDrawdown', 'dailyLossLimit', + 'trailingDrawdown', 'dailyLossLimit', 'stagesSameDay', ]); assert.deepEqual([...IDENTITY_FIELDS], ['prefix', 'accountSize']); assert.deepEqual([...PROJECTION_FIELDS], ['expectedPayout', 'withdrawalPct', 'payoutCap', 'profitSplit']); @@ -82,6 +82,7 @@ function legacyNormalize(t, j, tradesEveryDay) { firstDayStop: type.firstDayStop === true, minDayStop: type.minDayStop === true, dailyLossLimit: nonNegative(type.dailyLossLimit), + stagesSameDay: type.stagesSameDay === true, tradesEveryDay, }; } @@ -174,7 +175,7 @@ test('overlayKept: the kept rules on top; identity, label, projections and bookk const oldRules = { profitTarget: 3000, consistency: 0.5, minDayPnL: 200, minTradingDays: 5, maxLoss: 2500, maxPositionSize: 3, targetSameEquity: true, splitEnabled: false, minLegs: 0, firstDayStop: false, minDayStop: true, combineCmeHolidays: true, - withdrawalStages: [], payoutable: false, trailingDrawdown: 2500, dailyLossLimit: 0, + withdrawalStages: [], payoutable: false, trailingDrawdown: 2500, dailyLossLimit: 0, stagesSameDay: false, }; // A master that sent other identity/projection values on the kept set: // they must not leak through. diff --git a/scripts/check-trading-logic.mjs b/scripts/check-trading-logic.mjs index 2ee3332..b0c97f9 100644 --- a/scripts/check-trading-logic.mjs +++ b/scripts/check-trading-logic.mjs @@ -14,6 +14,7 @@ import { projectPerfectPath, cycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, notionalCap, expectedSwapToWin, MARGIN_HEADROOM, dllReserve, DLL_SLIPPAGE_FRACTION, maxLossRoom, lastStand, breachDistance, LAST_STAND_FRACTION, planLegSize, rankForTarget, currentTradeDateCT, foldReason, withinFirmCaps, personSizeFactor, personHoldHours, PERSON_SIZE_SPREAD, machineSide, + resolveEffectiveConfig, stageInputsFor, floorRoom, computeDayPlan, } from '../lib/trading-logic.ts'; // Deterministic PRNG so a failure reproduces. @@ -967,3 +968,53 @@ test('machineSide: one side per machine, across every lane', () => { assert.equal(machineSide([1, -1], null), 'mixed'); assert.equal(machineSide([-5], 'Buy'), 'mixed'); }); + +test('same-day stages: today\'s payouts pick the stage, and a stage may carry its own daily limit', () => { + // The Vest 50k Funded type: $1,500 then claim, then $3,000 on the room the claim freed. + const stages = [{ profit: 3000, consistency: 0, minTradingDays: 0, dailyLossLimit: 3000 }]; + const fundRows = [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-25', amount: -1500 }]; + const resolve = (inputs) => resolveEffectiveConfig(1500, 0, 0, false, stages, 0, fundRows, 0, inputs); + + // Base stage, base limit: yesterday's payout doesn't count for a same-day type. + const fresh = resolve({ dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 0 }); + assert.equal(fresh.profitTarget, 1500); + assert.equal(fresh.dailyLossLimit, 1229); + // After a claim today: stage 2, its own limit. + const after = resolve({ dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 1 }); + assert.equal(after.profitTarget, 3000); + assert.equal(after.dailyLossLimit, 3000); + // A second claim today: the last stage repeats. + assert.equal(resolve({ dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 2 }).profitTarget, 3000); + // Without same-day stages the lifetime count picks it, as it always has. + assert.equal(resolve({ dailyLossLimit: 1229 }).profitTarget, 3000); + // A stage without its own limit keeps the type's; no inputs = no limit. + const plain = resolveEffectiveConfig(1500, 0, 0, false, [{ profit: 2000, consistency: 0, minTradingDays: 0 }], 0, fundRows, 0, { dailyLossLimit: 1229 }); + assert.equal(plain.dailyLossLimit, 1229); + assert.equal(resolveEffectiveConfig(1500, 0, 0, false, [], 0, [], 0).dailyLossLimit, 0); + + // stageInputsFor asks the client only for a same-day type. + const client = { sameDayClaims: () => 1 }; + assert.deepEqual(stageInputsFor(client, 7, { dailyLossLimit: 1229, stagesSameDay: true }), { dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 1 }); + assert.deepEqual(stageInputsFor(client, 7, { dailyLossLimit: 1229 }), { dailyLossLimit: 1229, stagesSameDay: false, sameDayWithdrawals: 0 }); + assert.deepEqual(stageInputsFor({}, 7, { stagesSameDay: true }), { dailyLossLimit: 0, stagesSameDay: true, sameDayWithdrawals: 0 }); +}); + +test('floorRoom: the platform\'s own floor, never more than the stage\'s limit', () => { + // Fresh day: $50k, Vest floor $48,500, the type's $1,229. + assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 50000, floor: 48500 }), 1229); + // Won $1,500 and claimed it: balance $50k, Vest's floor lowered to $47k. + // Stage 2's $3,000 limit gets all of it. + assert.equal(floorRoom({ dailyLossLimit: 3000, closedBalance: 50000, floor: 47000 }), 3000); + // If Vest hadn't lowered the floor, only what it really leaves. + assert.equal(floorRoom({ dailyLossLimit: 3000, closedBalance: 50000, floor: 48500 }), 1500); + // Below the floor already: negative room (no trade). + assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 48400, floor: 48500 }), -100); + assert.equal(floorRoom({ dailyLossLimit: 0, closedBalance: 50000, floor: 48500 }), Infinity); +}); + +test('the day after a claim: a fresh cycle that aims at the next stage\'s target', () => { + // Claimed everything above $50k; P&L since the claim is 0, the cycle has no days yet. + const plan = computeDayPlan(7, '2026-10-27', 3000, 0, [], 0, 0, 0, 0, false, false); + assert.ok(plan.dayTarget >= 3000 && plan.dayTarget <= 3025, `dayTarget ${plan.dayTarget}`); + assert.equal(plan.remainingToday, plan.dayTarget); +}); diff --git a/scripts/check-vest.mjs b/scripts/check-vest.mjs index f89f54b..ef6da84 100644 --- a/scripts/check-vest.mjs +++ b/scripts/check-vest.mjs @@ -11,7 +11,7 @@ import assert from 'node:assert/strict'; import { isVestWalletAddress, vestAccountId, num, toMs, formatDecimal, vestDayOf, lastVestResetMs, parseCapitalAccount, vestAccountName, parsePosition, awaitingLegs, parseOpenOrder, unrealizedPnl, parseTxn, bookTxns, txnKind, VEST_TXN, - hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS, + hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS, nextVestResetMs, resetSafeStop, } from '../lib/vest-helpers.ts'; import { putVestSession, vestSessionFor, dropVestSession, listVestSessions } from '../lib/vest-session.ts'; @@ -225,3 +225,41 @@ test('relayed sessions: taken, kept fresher, dropped, never listed with tokens', dropVestSession(wallet, jwt(1)); assert.equal(vestSessionFor(wallet, now), null); }); + +test('the next 8 PM ET reset, across both time changes', () => { + assert.equal(nextVestResetMs(iso('2026-10-26T18:00:00Z')), iso('2026-10-27T00:00:00Z')); + assert.equal(nextVestResetMs(iso('2026-10-27T00:00:00Z')), iso('2026-10-28T00:00:00Z')); + assert.equal(nextVestResetMs(iso('2026-10-31T23:59:00Z')), iso('2026-11-01T00:00:00Z')); + // Nov 1 2026, the clocks go back: that evening's reset is 01:00 UTC. + assert.equal(nextVestResetMs(iso('2026-11-01T12:00:00Z')), iso('2026-11-02T01:00:00Z')); + assert.equal(nextVestResetMs(iso('2026-12-01T00:30:00Z')), iso('2026-12-01T01:00:00Z')); +}); + +test('resetSafeStop: fit an open position into the room the reset will leave', () => { + // A stage-2 leg after a claim: balance $50,000, 3% → the reset floor is + // $48,500 (above the $47k drawdown floor). $150 reserve, a $3 fee: $1,347 to lose. + const base = { + side: 'long', quantity: 4, openPrice: 29500, mark: 29480, balance: 50000, dailyLossPct: 0.03, + maxDrawdownFloor: 47000, reserve: 150, exitFeeRate: 0.000025, tick: 0.25, + }; + // No stop at all (a last stand): one comes in at open − 1347/4, rounded up to the tick. + const fee = 0.000025 * 4 * 29480; + const allowed = 50000 - 48500 - 150 - fee; + const want = Math.ceil((29500 - allowed / 4) / 0.25) * 0.25; + assert.deepEqual(resetSafeStop({ ...base, currentStop: null }), { action: 'stop', price: want }); + // A wider stop is pulled in; one already tighter is left alone. + assert.deepEqual(resetSafeStop({ ...base, currentStop: 29000 }), { action: 'stop', price: want }); + assert.deepEqual(resetSafeStop({ ...base, currentStop: 29300 }), { action: 'none' }); + // The mark already past where the stop must go: close. + assert.deepEqual(resetSafeStop({ ...base, mark: 29100, currentStop: null }), { action: 'close' }); + // Short: mirrored, rounded down. + const short = resetSafeStop({ ...base, side: 'short', mark: 29510, currentStop: null }); + assert.equal(short.action, 'stop'); + assert.equal(short.price, Math.floor((29500 + (50000 - 48500 - 150 - 0.000025 * 4 * 29510) / 4) / 0.25) * 0.25); + // No room past the reset at all: close. + assert.deepEqual(resetSafeStop({ ...base, balance: 48600, maxDrawdownFloor: 48600 }), { action: 'close' }); + // The drawdown floor binds when it's above the daily one. + const dd = resetSafeStop({ ...base, maxDrawdownFloor: 49000, currentStop: null }); + assert.equal(dd.action, 'stop'); + assert.ok(dd.price > want); +});