Vest: claim the profit at the target, then trade again the same day

On Vest, a claim lowers the daily-loss floor by the amount claimed. Winning
$1,500 and claiming it leaves $3,000 of room the same day, the full
drawdown. The bot now uses that.

- Payout claims: a scheduler step (runClaims, under the lane mutex, before
  the flat gate) claims every payoutable account that reached its stage's
  target, via a platform that takes payouts by API. The claim is all
  profit above the starting balance. It then clears the account's legs and
  entry outcome for the day so it can trade its next stage. Failures retry
  after 5 minutes. The auto_claim setting 'off' disables it.
- VestClient.claimProfit: POST /v3/capital/withdraw to the login's main
  account (account_type 1 on /v3/accounts). The payout is booked at once:
  today's fund row and the cycle cut, with today's P&L counted from the
  post-claim balance. The history read later rebooks it from Vest's
  WITHDRAW row, and the books check counts a claim until that row shows.
- Same-day stages: an account type can pick its stage by the payouts since
  the firm's last daily reset (stagesSameDay, sameDayClaims). A stage can
  carry its own daily loss limit.
- That limit replaces the type's everywhere room, stops and last stands are
  worked out: the batch, the manual leg, copy trade, the dashboard and the
  report.
- Room from the platform's own floor: dailyLossFloor (Vest) makes the room
  min(stage limit, balance − Vest's floor). A same-day claim that lowers
  the floor therefore frees room, and the room is never more than Vest
  allows. VestClient.dayStartBalance is gone.
- Before the 8 PM ET reset: from 7:55 PM ET, each open Vest position on an
  account with a daily limit is fitted to the room the reset will leave
  (resetSafeStop). Its stop comes in via PUT/POST stop-loss, or the
  position closes when there is no room.
- Checks for same-day stages, the stage limit, floorRoom, the post-claim
  plan, the next reset across both time changes, and resetSafeStop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Li
2026-10-26 15:56:00 -05:00
co-authored by Claude Opus 5.5
parent 335af486d4
commit 32081f3dcf
13 changed files with 601 additions and 94 deletions
+12 -16
View File
@@ -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];
+116 -24
View File
@@ -18,7 +18,7 @@ import {
BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY,
FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP,
CME_EVENING_OPEN_MIN, foldReason, withinFirmCaps,
fundRowsKey, markFinishedCycle, unbookedPayoutDrop, machineSide,
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<number, number>;
/** 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<unknown[]>
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<void> {
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.
+5 -1
View File
@@ -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':
+29 -11
View File
@@ -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,
}),
};
+5 -1
View File
@@ -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. */
+18
View File
@@ -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;
+2 -1
View File
@@ -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
+56 -5
View File
@@ -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;
+212 -31
View File
@@ -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<void>;
private uuidById = new Map<number, string>();
/** 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<number, { at: number; amount: number; base: number }[]>();
/** Each account's WITHDRAW transactions at the last history read, so a
* claim made by hand on Vest counts toward today's stage too. */
private withdrawals = new Map<number, { at: number; amount: number }[]>();
/** The reset guardReset last finished for (epoch ms). */
private resetGuardedFor = 0;
private capital = new Map<number, VestCapitalAccount>();
private positionsDetail = new Map<number, VestPosition[]>();
private openOrders = new Map<number, VestOpenOrder[]>();
@@ -286,7 +302,7 @@ export class VestClient implements PlatformClient {
*/
private async request<T>(
bucket: Bucket,
method: 'GET' | 'POST' | 'DELETE',
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
opts: { params?: Record<string, string | number | boolean>; 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<void> {
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);
+52
View File
@@ -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 {
+4 -3
View File
@@ -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.
+51
View File
@@ -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);
});
+39 -1
View File
@@ -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);
});