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
+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.