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:
co-authored by
Claude Opus 5.5
parent
335af486d4
commit
32081f3dcf
+12
-16
@@ -5,12 +5,12 @@ import { getAccountTypeById, getCachedCatalog } from '@/lib/firm-catalog';
|
|||||||
import { ruleDiffs } from '@/lib/kept-rules';
|
import { ruleDiffs } from '@/lib/kept-rules';
|
||||||
import { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/account-type-resolver';
|
import { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/account-type-resolver';
|
||||||
import { historyFresh } from '@/lib/platform-client';
|
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 { obfuscationDayBooked } from '@/lib/auto-trade';
|
||||||
import type { AccountState } from '@/types';
|
import type { AccountState } from '@/types';
|
||||||
import {
|
import {
|
||||||
combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, currentTradeDateCT,
|
combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, currentTradeDateCT,
|
||||||
obfuscationStopDollars, dllRoom as computeDllRoom, dllStopDollars, botStopsActive,
|
obfuscationStopDollars, dllStopDollars, botStopsActive, stageInputsFor,
|
||||||
maxLossRoom, lastStand as isLastStand,
|
maxLossRoom, lastStand as isLastStand,
|
||||||
LEG_COMPLETION_EPSILON,
|
LEG_COMPLETION_EPSILON,
|
||||||
} from '@/lib/trading-logic';
|
} from '@/lib/trading-logic';
|
||||||
@@ -61,8 +61,11 @@ export async function GET() {
|
|||||||
const isDead = autoLiqThreshold !== 0 && cash.amount <= autoLiqThreshold;
|
const isDead = autoLiqThreshold !== 0 && cash.amount <= autoLiqThreshold;
|
||||||
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
|
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
|
||||||
const priorProfit = client.priorProfit?.[acc.id] ?? 0;
|
const priorProfit = client.priorProfit?.[acc.id] ?? 0;
|
||||||
// Stage = 1 + number of withdrawals (negative fund transactions)
|
// Stage = 1 + number of withdrawals (negative fund transactions),
|
||||||
const stage = 1 + allFundTxns.filter((f) => f.amount < 0).length;
|
// 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
|
// Stage-resolved config: a drain stage swaps in its own min-day
|
||||||
// (default $5) and no profit target.
|
// (default $5) and no profit target.
|
||||||
const effective = cfg ? resolveEffectiveConfig(
|
const effective = cfg ? resolveEffectiveConfig(
|
||||||
@@ -73,7 +76,8 @@ export async function GET() {
|
|||||||
cfg.withdrawalStages,
|
cfg.withdrawalStages,
|
||||||
priorProfit,
|
priorProfit,
|
||||||
allFundTxns,
|
allFundTxns,
|
||||||
cfg.minDayPnL
|
cfg.minDayPnL,
|
||||||
|
stageInputsFor(client, acc.id, cfg),
|
||||||
) : null;
|
) : null;
|
||||||
// Days traded counts only days that hit minDayPnL (when set) — that's what
|
// Days traded counts only days that hit minDayPnL (when set) — that's what
|
||||||
// the firm requires toward the min trading day rule.
|
// 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
|
// the day after a loss rides stopless) against the hard
|
||||||
// daily-loss stop, before fees. A min day trades its whole
|
// daily-loss stop, before fees. A min day trades its whole
|
||||||
// remaining target as one leg.
|
// remaining target as one leg.
|
||||||
const dll = cfg.dailyLossLimit ?? 0;
|
const dll = effective?.dailyLossLimit ?? 0;
|
||||||
if (dll > 0) {
|
dllRoom = dayLossRoom(client, acc.id, dll, cash, combined.realizedPnL);
|
||||||
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),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// The max-drawdown floor, and whether the next leg is a last
|
// The max-drawdown floor, and whether the next leg is a last
|
||||||
// stand — no daily-loss stop — as fireLeg decides it.
|
// stand — no daily-loss stop — as fireLeg decides it.
|
||||||
const floorRoom = maxLossRoom({
|
const floorRoom = maxLossRoom({
|
||||||
@@ -164,7 +160,7 @@ export async function GET() {
|
|||||||
// fireLeg would, and the one a batch would pick.
|
// fireLeg would, and the one a batch would pick.
|
||||||
if (plan && !dayBooked && plan.remainingToday > 0) {
|
if (plan && !dayBooked && plan.remainingToday > 0) {
|
||||||
const best = rankNextLeg({
|
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),
|
obfuscationDollars: obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday),
|
||||||
cash, tradeDate: combined.tradeDate,
|
cash, tradeDate: combined.tradeDate,
|
||||||
})[0];
|
})[0];
|
||||||
|
|||||||
+116
-24
@@ -18,7 +18,7 @@ import {
|
|||||||
BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY,
|
BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY,
|
||||||
FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP,
|
FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP,
|
||||||
CME_EVENING_OPEN_MIN, foldReason, withinFirmCaps,
|
CME_EVENING_OPEN_MIN, foldReason, withinFirmCaps,
|
||||||
fundRowsKey, markFinishedCycle, unbookedPayoutDrop, machineSide,
|
fundRowsKey, markFinishedCycle, unbookedPayoutDrop, machineSide, stageInputsFor, floorRoom,
|
||||||
type DayPlan, type FinishedCycleMark,
|
type DayPlan, type FinishedCycleMark,
|
||||||
} from './trading-logic';
|
} from './trading-logic';
|
||||||
import { getSetting, setSetting, loadAccountMeta, saveAccountMeta } from './db';
|
import { getSetting, setSetting, loadAccountMeta, saveAccountMeta } from './db';
|
||||||
@@ -69,6 +69,8 @@ interface TradeRuntime {
|
|||||||
* machine, while it places it: another lane deciding meanwhile takes the
|
* machine, while it places it: another lane deciding meanwhile takes the
|
||||||
* same side (see sideForEntry). Only counts while its lease holds. */
|
* same side (see sideForEntry). Only counts while its lease holds. */
|
||||||
sideReservation?: { side: 'Buy' | 'Sell'; lane: string; lease: number };
|
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
|
/** 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
|
* 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
|
* 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 cfg = getAccountConfig(item.acc.id).accountType!;
|
||||||
const leg = legAmountFor(item, tradeDate);
|
const leg = legAmountFor(item, tradeDate);
|
||||||
const obf = obfuscationStopDollars(cfg, item.dailyPnL, item.plan, tradeDate, leg);
|
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
|
const hard = Number.isFinite(item.dllRoom) && dll > 0
|
||||||
? dllStopDollars({ room: item.dllRoom, dailyLossLimit: dll, entryFee: FALLBACK_COMMISSION_PER_CONTRACT, commissionPct: 0 })
|
? dllStopDollars({ room: item.dllRoom, dailyLossLimit: dll, entryFee: FALLBACK_COMMISSION_PER_CONTRACT, commissionPct: 0 })
|
||||||
: Infinity;
|
: Infinity;
|
||||||
@@ -602,6 +604,8 @@ interface AccountEvaluation {
|
|||||||
/** What the firm's day may still lose before a hard daily-loss breach
|
/** What the firm's day may still lose before a hard daily-loss breach
|
||||||
* (Infinity = no limit). */
|
* (Infinity = no limit). */
|
||||||
dllRoom: number;
|
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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
client: any,
|
client: any,
|
||||||
accountId: number,
|
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 },
|
cash: { amount: number; realizedPnL: number },
|
||||||
combinedRealized: number,
|
combinedRealized: number,
|
||||||
remainingToday: number,
|
remainingToday: number,
|
||||||
singleContract: boolean,
|
singleContract: boolean,
|
||||||
): { room: number } | { reason: string } {
|
): { room: number } | { reason: string } {
|
||||||
const dll = cfg.dailyLossLimit ?? 0;
|
|
||||||
if (!(dll > 0)) return { room: Infinity };
|
if (!(dll > 0)) return { room: Infinity };
|
||||||
const dayStart: number | null | undefined = client.dayStartBalance?.(accountId);
|
// A platform that states its own daily-loss floor (Vest) decides the
|
||||||
if (dayStart === null) return { reason: DLL_BASE_PENDING };
|
// room against it, transfers and all; the rest measure the day's P&L.
|
||||||
const room = dllRoom({
|
const floor: number | null | undefined = client.dailyLossFloor?.(accountId);
|
||||||
dailyLossLimit: dll,
|
if (floor === null) return { reason: DLL_BASE_PENDING };
|
||||||
closedBalance: cash.amount,
|
let room: number;
|
||||||
dayStartBalance: dayStart,
|
if (typeof floor === 'number') {
|
||||||
// The broker's day, raw — and on a combining firm the worse of it
|
room = floorRoom({ dailyLossLimit: dll, closedBalance: cash.amount, floor });
|
||||||
// and the combined figure, whichever the firm turns out to use.
|
} else {
|
||||||
realizedToday: Math.min(cash.realizedPnL, combinedRealized),
|
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
|
// Measured-fee firms: a typical round trip on the contracts this leg
|
||||||
// could use. Percent-fee firms size by notional, which isn't known
|
// could use. Percent-fee firms size by notional, which isn't known
|
||||||
// here — fireLeg re-checks with the real size before any entry.
|
// 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 dailyPnL = combined.dailyPnL;
|
||||||
const daysTraded = combined.daysTraded;
|
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 (isAccountDead(cash.amount, autoLiqThreshold)) return { ...base, eligible: false, reason: 'dead' };
|
||||||
if (!acc.active) return { ...base, eligible: false, reason: 'inactive (DLL)' };
|
if (!acc.active) return { ...base, eligible: false, reason: 'inactive (DLL)' };
|
||||||
if (client.positions[acc.id]) return { ...base, eligible: false, reason: 'already in trade' };
|
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 allFundTxns = client.fundTransactions?.[acc.id] ?? [];
|
||||||
const effective = resolveEffectiveConfig(
|
const effective = resolveEffectiveConfig(
|
||||||
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, priorProfit, allFundTxns,
|
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;
|
const equityProfit = cash.amount - cfg.accountSize;
|
||||||
@@ -768,7 +782,7 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a
|
|||||||
&& effective.minTradingDays > daysTraded
|
&& effective.minTradingDays > daysTraded
|
||||||
&& totalProfit >= effective.profitTarget;
|
&& 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
|
// 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
|
// 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
|
// (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 —
|
// 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.
|
// 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 ('reason' in gate) return { ...evaluated, eligible: false, reason: gate.reason };
|
||||||
if (Number.isFinite(gate.room)) return { ...evaluated, eligible: true, reason: '', dllRoom: gate.room };
|
if (Number.isFinite(gate.room)) return { ...evaluated, eligible: true, reason: '', dllRoom: gate.room };
|
||||||
|
|
||||||
@@ -975,6 +989,8 @@ interface LegItem {
|
|||||||
prefix: string;
|
prefix: string;
|
||||||
/** Hard daily-loss room (Infinity = no limit). */
|
/** Hard daily-loss room (Infinity = no limit). */
|
||||||
dllRoom: number;
|
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. */
|
/** 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,
|
cash: ev.cash, dailyPnL: ev.dailyPnL, daysTraded: ev.daysTraded,
|
||||||
plan: ev.plan, isExtraDay: ev.isExtraDay, drain: ev.drain,
|
plan: ev.plan, isExtraDay: ev.isExtraDay, drain: ev.drain,
|
||||||
prefix: getAccountConfig(acc.id).accountType?.prefix ?? '',
|
prefix: getAccountConfig(acc.id).accountType?.prefix ?? '',
|
||||||
dllRoom: ev.dllRoom,
|
dllRoom: ev.dllRoom, dll: ev.dll,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1374,7 +1390,7 @@ function sizeLeg(item: SizingItem, contract: LegContract, legAmount: number, tra
|
|||||||
commissionPct: pct,
|
commissionPct: pct,
|
||||||
targetHoldHours: sizingHoldHours(item.firm, tradeDate),
|
targetHoldHours: sizingHoldHours(item.firm, tradeDate),
|
||||||
financingPctPerDay: item.firm.financingPctPerDay ?? 0,
|
financingPctPerDay: item.firm.financingPctPerDay ?? 0,
|
||||||
dailyLossLimit: cfg.dailyLossLimit ?? 0,
|
dailyLossLimit: item.dll,
|
||||||
dllRoom: item.dllRoom,
|
dllRoom: item.dllRoom,
|
||||||
maxLossRoom: floorRoom,
|
maxLossRoom: floorRoom,
|
||||||
obfuscationDollars: obfuscation,
|
obfuscationDollars: obfuscation,
|
||||||
@@ -1439,7 +1455,7 @@ async function fireLeg(
|
|||||||
// the broker (here, or in sendEntry).
|
// the broker (here, or in sendEntry).
|
||||||
recordLeg(acc.id, tradeDate, legAmount);
|
recordLeg(acc.id, tradeDate, legAmount);
|
||||||
|
|
||||||
const dll = cfg.dailyLossLimit ?? 0;
|
const dll = item.dll;
|
||||||
const dllOn = Number.isFinite(item.dllRoom) && dll > 0;
|
const dllOn = Number.isFinite(item.dllRoom) && dll > 0;
|
||||||
|
|
||||||
// Everything before the entry goes out: a failure here never reached the
|
// 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,
|
firmName: firm.name, firm, client, acc, contract, pv, cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan,
|
||||||
drain: ev.drain,
|
drain: ev.drain,
|
||||||
prefix: getAccountConfig(acc.id).accountType?.prefix ?? '',
|
prefix: getAccountConfig(acc.id).accountType?.prefix ?? '',
|
||||||
dllRoom: ev.dllRoom,
|
dllRoom: ev.dllRoom, dll: ev.dll,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
eligible.push(...found);
|
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.
|
// The once-a-day return comes before the daily-loss check: run it here.
|
||||||
let room = ev.dllRoom;
|
let room = ev.dllRoom;
|
||||||
if (!ev.eligible) {
|
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);
|
if ('reason' in gate) throw new Error(gate.reason);
|
||||||
room = gate.room;
|
room = gate.room;
|
||||||
}
|
}
|
||||||
@@ -1877,7 +1893,7 @@ export async function fireManualLeg(accountId: number, action: 'Buy' | 'Sell' |
|
|||||||
const sizingItem: SizingItem = {
|
const sizingItem: SizingItem = {
|
||||||
firmName: firm.name, firm, client, acc,
|
firmName: firm.name, firm, client, acc,
|
||||||
cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan, drain: ev.drain,
|
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
|
// The instrument: a DXtrade or Vest firm ranks its list; a futures
|
||||||
// firm joins the held root, else trades an explicit scheduler symbol
|
// 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 ────────────────────────────────────────────────────────
|
// ── eligibility check ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Returns true if any configured account could still trade today (eligible for a next leg / extra-day, or has an open position). */
|
/** 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}`);
|
console.log(`[scheduler] ${laneName(lane)}: waiting for sync: ${names}`);
|
||||||
continue;
|
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
|
// Skip this lane if any of its accounts still has an open
|
||||||
// position from its previous batch — a batch fires only
|
// position from its previous batch — a batch fires only
|
||||||
// when every (ready) account in the lane is flat.
|
// when every (ready) account in the lane is flat.
|
||||||
|
|||||||
+5
-1
@@ -22,7 +22,7 @@ import type { CatalogAccountType, KeptAccountRules } from './master-protocol';
|
|||||||
export const RULE_FIELDS = [
|
export const RULE_FIELDS = [
|
||||||
'profitTarget', 'consistency', 'minDayPnL', 'minTradingDays', 'maxLoss',
|
'profitTarget', 'consistency', 'minDayPnL', 'minTradingDays', 'maxLoss',
|
||||||
'maxPositionSize', 'targetSameEquity', 'splitEnabled', 'minLegs', 'firstDayStop', 'minDayStop',
|
'maxPositionSize', 'targetSameEquity', 'splitEnabled', 'minLegs', 'firstDayStop', 'minDayStop',
|
||||||
'combineCmeHolidays', 'withdrawalStages', 'payoutable', 'trailingDrawdown', 'dailyLossLimit',
|
'combineCmeHolidays', 'withdrawalStages', 'payoutable', 'trailingDrawdown', 'dailyLossLimit', 'stagesSameDay',
|
||||||
] as const satisfies readonly (keyof CatalogAccountType)[];
|
] as const satisfies readonly (keyof CatalogAccountType)[];
|
||||||
|
|
||||||
/** Always the live type's: accounts are matched to a type by them, so a kept
|
/** 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,
|
minDayStop: type.minDayStop === true,
|
||||||
// Lenient: no hard daily limit, no notional cap.
|
// Lenient: no hard daily limit, no notional cap.
|
||||||
dailyLossLimit: nonNegative(type.dailyLossLimit),
|
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
|
// The firm's calendar, stamped on every type so the cfg-based
|
||||||
// paths (projections, completion, the dashboard) all see it.
|
// paths (projections, completion, the dashboard) all see it.
|
||||||
tradesEveryDay,
|
tradesEveryDay,
|
||||||
@@ -168,6 +170,7 @@ function normalizeStage(stage: unknown): unknown {
|
|||||||
minDayPnL: typeof s.minDayPnL === 'number' ? s.minDayPnL : (drain ? 5 : 0),
|
minDayPnL: typeof s.minDayPnL === 'number' ? s.minDayPnL : (drain ? 5 : 0),
|
||||||
...(typeof s.withdrawalPct === 'number' ? { withdrawalPct: s.withdrawalPct } : {}),
|
...(typeof s.withdrawalPct === 'number' ? { withdrawalPct: s.withdrawalPct } : {}),
|
||||||
...(typeof s.payoutCap === 'number' ? { payoutCap: s.payoutCap } : {}),
|
...(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 'firstDayStop':
|
||||||
case 'minDayStop':
|
case 'minDayStop':
|
||||||
case 'combineCmeHolidays':
|
case 'combineCmeHolidays':
|
||||||
|
case 'stagesSameDay':
|
||||||
case 'payoutable': return value === true;
|
case 'payoutable': return value === true;
|
||||||
case 'minLegs':
|
case 'minLegs':
|
||||||
case 'trailingDrawdown':
|
case 'trailingDrawdown':
|
||||||
|
|||||||
+29
-11
@@ -11,9 +11,33 @@ import type { PlatformContract } from './platform-client';
|
|||||||
import type { SymbolStats } from './master-protocol';
|
import type { SymbolStats } from './master-protocol';
|
||||||
import {
|
import {
|
||||||
combineCmeSessions, computeDayPlan, resolveEffectiveConfig, obfuscationStopDollars, dllRoom as computeDllRoom,
|
combineCmeSessions, computeDayPlan, resolveEffectiveConfig, obfuscationStopDollars, dllRoom as computeDllRoom,
|
||||||
planLegSize, rankForTarget, maxLossRoom, lastStand, dllStopDollars,
|
planLegSize, rankForTarget, maxLossRoom, lastStand, dllStopDollars, stageInputsFor, floorRoom,
|
||||||
FALLBACK_COMMISSION_PER_CONTRACT,
|
FALLBACK_COMMISSION_PER_CONTRACT,
|
||||||
} from './trading-logic';
|
} 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';
|
import { sizingHoldHours } from './person-size';
|
||||||
|
|
||||||
export type CandidateLeg = SymbolStats['symbols'][number] & {
|
export type CandidateLeg = SymbolStats['symbols'][number] & {
|
||||||
@@ -105,6 +129,7 @@ function accountPlan(
|
|||||||
const effective = resolveEffectiveConfig(
|
const effective = resolveEffectiveConfig(
|
||||||
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages,
|
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages,
|
||||||
client.priorProfit?.[acc.id] ?? 0, client.fundTransactions?.[acc.id] ?? [], cfg.minDayPnL,
|
client.priorProfit?.[acc.id] ?? 0, client.fundTransactions?.[acc.id] ?? [], cfg.minDayPnL,
|
||||||
|
stageInputsFor(client, acc.id, cfg),
|
||||||
);
|
);
|
||||||
const plan = computeDayPlan(
|
const plan = computeDayPlan(
|
||||||
acc.id, combined.tradeDate, effective.profitTarget, effective.consistency,
|
acc.id, combined.tradeDate, effective.profitTarget, effective.consistency,
|
||||||
@@ -112,15 +137,8 @@ function accountPlan(
|
|||||||
cfg.splitEnabled, effective.drain,
|
cfg.splitEnabled, effective.drain,
|
||||||
);
|
);
|
||||||
if (!plan) return null;
|
if (!plan) return null;
|
||||||
const dll = cfg.dailyLossLimit ?? 0;
|
const dll = effective.dailyLossLimit;
|
||||||
let room: number | null = null;
|
const room = dayLossRoom(client, acc.id, dll, cash, combined.realizedPnL);
|
||||||
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 obfuscation = obfuscationStopDollars(cfg, combined.dailyPnL, plan, combined.tradeDate, plan.remainingToday);
|
const obfuscation = obfuscationStopDollars(cfg, combined.dailyPnL, plan, combined.tradeDate, plan.remainingToday);
|
||||||
return { cfg, cash, plan, dll, room, obfuscation };
|
return { cfg, cash, plan, dll, room, obfuscation };
|
||||||
}
|
}
|
||||||
@@ -147,7 +165,7 @@ export function accountNextLeg(
|
|||||||
leg: p.plan.remainingToday,
|
leg: p.plan.remainingToday,
|
||||||
lastStand: p.room !== null && lastStand({ dllRoom: p.room, dailyLossLimit: p.dll, maxLossRoom: floorRoom }),
|
lastStand: p.room !== null && lastStand({ dllRoom: p.room, dailyLossLimit: p.dll, maxLossRoom: floorRoom }),
|
||||||
candidates: rankNextLeg({
|
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,
|
obfuscationDollars: p.obfuscation, cash: p.cash, tradeDate,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -365,7 +365,11 @@ export interface CatalogAccountType {
|
|||||||
* minTradingDays qualifying days of ~minDayPnL (default $5, randomized
|
* minTradingDays qualifying days of ~minDayPnL (default $5, randomized
|
||||||
* $5–$15, traded as tiny MNQ legs), then wait for the next withdrawal.
|
* $5–$15, traded as tiny MNQ legs), then wait for the next withdrawal.
|
||||||
* withdrawalPct/payoutCap override the type's payout defaults per stage. */
|
* 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
|
/** Payout-projection settings, owned by the master's payout calendar but
|
||||||
* consumed here to compute each account's future payout chain. Optional:
|
* consumed here to compute each account's future payout chain. Optional:
|
||||||
* an older master doesn't send them. */
|
* an older master doesn't send them. */
|
||||||
|
|||||||
@@ -121,6 +121,24 @@ export interface PlatformClient {
|
|||||||
* account rather than size a stop on a guess.
|
* account rather than size a stop on a guess.
|
||||||
*/
|
*/
|
||||||
dayStartBalance?(accountId: number): number | null;
|
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
|
/** Drop the account's history from memory (the wipe routes), so it reads
|
||||||
* unloaded until the next fetch rebuilds it. */
|
* unloaded until the next fetch rebuilds it. */
|
||||||
forgetHistory(accountId: number, accountName: string): void;
|
forgetHistory(accountId: number, accountName: string): void;
|
||||||
|
|||||||
+2
-1
@@ -20,7 +20,7 @@ import { getClients } from './clients';
|
|||||||
import { getAccountTypeById, getKeptRuleIdsForFirm, getTradableFirms } from './firm-catalog';
|
import { getAccountTypeById, getKeptRuleIdsForFirm, getTradableFirms } from './firm-catalog';
|
||||||
import { getAccountConfig } from './account-type-resolver';
|
import { getAccountConfig } from './account-type-resolver';
|
||||||
import { getSchedulerStatus } from './auto-trade';
|
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 type { AccountTypeStats, FirmStats, InstrumentStats, SymbolStats } from './master-protocol';
|
||||||
import { accountNextLeg, accountLegShape } from './leg-estimate';
|
import { accountNextLeg, accountLegShape } from './leg-estimate';
|
||||||
import { sizingHoldHours } from './person-size';
|
import { sizingHoldHours } from './person-size';
|
||||||
@@ -154,6 +154,7 @@ function collectFirmStats() {
|
|||||||
cfg.profitTarget, cfg.consistency, cfg.minTradingDays,
|
cfg.profitTarget, cfg.consistency, cfg.minTradingDays,
|
||||||
cfg.targetSameEquity, cfg.withdrawalStages,
|
cfg.targetSameEquity, cfg.withdrawalStages,
|
||||||
client.priorProfit?.[acc.id] ?? 0, fundTxns, cfg.minDayPnL,
|
client.priorProfit?.[acc.id] ?? 0, fundTxns, cfg.minDayPnL,
|
||||||
|
stageInputsFor(client, acc.id, cfg),
|
||||||
) : null;
|
) : null;
|
||||||
// Days traded, for the master's per-day pass counts — the same
|
// Days traded, for the master's per-day pass counts — the same
|
||||||
// number as the local dashboard's Days Traded column
|
// number as the local dashboard's Days Traded column
|
||||||
|
|||||||
+56
-5
@@ -16,6 +16,8 @@ export interface WithdrawalStage {
|
|||||||
* fields fall back to the account type's defaults. */
|
* fields fall back to the account type's defaults. */
|
||||||
withdrawalPct?: number;
|
withdrawalPct?: number;
|
||||||
payoutCap?: number;
|
payoutCap?: number;
|
||||||
|
/** This stage's hard daily loss limit in dollars; absent = the type's. */
|
||||||
|
dailyLossLimit?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EffectiveConfig {
|
export interface EffectiveConfig {
|
||||||
@@ -24,6 +26,17 @@ export interface EffectiveConfig {
|
|||||||
minTradingDays: number;
|
minTradingDays: number;
|
||||||
minDayPnL: number;
|
minDayPnL: number;
|
||||||
drain: boolean;
|
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,
|
priorProfit: number,
|
||||||
fundTransactions: { date: string; amount: number }[],
|
fundTransactions: { date: string; amount: number }[],
|
||||||
minDayPnL = 0,
|
minDayPnL = 0,
|
||||||
|
inputs: StageInputs = {},
|
||||||
): EffectiveConfig {
|
): EffectiveConfig {
|
||||||
|
const dailyLossLimit = Math.max(0, inputs.dailyLossLimit ?? 0);
|
||||||
if (targetSameEquity) {
|
if (targetSameEquity) {
|
||||||
// Simple Mode A: effective target = profitTarget - equityProfit, computed in computeDailyTarget.
|
// Simple Mode A: effective target = profitTarget - equityProfit, computed in computeDailyTarget.
|
||||||
// Since computeDailyTarget already compares (amount - accountSize) against profitTarget,
|
// Since computeDailyTarget already compares (amount - accountSize) against profitTarget,
|
||||||
// no adjustment needed here — just return the base values.
|
// 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) {
|
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) {
|
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 idx = Math.min(withdrawalCount - 1, withdrawalStages.length - 1);
|
||||||
const stage = withdrawalStages[idx];
|
const stage = withdrawalStages[idx];
|
||||||
|
const stageLimit = typeof stage.dailyLossLimit === 'number' && stage.dailyLossLimit >= 0 ? stage.dailyLossLimit : dailyLossLimit;
|
||||||
if (stage.drain === true) {
|
if (stage.drain === true) {
|
||||||
return {
|
return {
|
||||||
profitTarget: 0,
|
profitTarget: 0,
|
||||||
@@ -73,12 +94,16 @@ export function resolveEffectiveConfig(
|
|||||||
// qualifying bar sits at the grid floor — every draw counts.
|
// qualifying bar sits at the grid floor — every draw counts.
|
||||||
minDayPnL: DRAIN_DAY_MIN,
|
minDayPnL: DRAIN_DAY_MIN,
|
||||||
drain: true,
|
drain: true,
|
||||||
|
dailyLossLimit: stageLimit,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Fall back to base minTradingDays when the stage doesn't specify one (0 or missing)
|
// 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 */
|
/** @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
|
* the next reset has its whole move counted against the NEW day — room
|
||||||
* borrowed from today's profit would be a breach tomorrow.
|
* 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: {
|
export function dllRoom(o: {
|
||||||
dailyLossLimit: number;
|
dailyLossLimit: number;
|
||||||
closedBalance: number;
|
closedBalance: number;
|
||||||
|
|||||||
+212
-31
@@ -36,14 +36,14 @@
|
|||||||
* - Fees are each symbol's taker fee from exchangeInfo, a percent of notional.
|
* - Fees are each symbol's taker fee from exchangeInfo, a percent of notional.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
import axios from './http-client';
|
import axios from './http-client';
|
||||||
import { currentTradeDateCT, rankForTarget } from './trading-logic';
|
import { currentTradeDateCT, rankForTarget, dllReserve } from './trading-logic';
|
||||||
import { sizingHoldHours } from './person-size';
|
import { sizingHoldHours } from './person-size';
|
||||||
import {
|
import {
|
||||||
saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions,
|
saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions,
|
||||||
saveEndedCycleStart,
|
saveEndedCycleStart,
|
||||||
} from './db';
|
} from './db';
|
||||||
import { getAccountConfig } from './account-type-resolver';
|
|
||||||
import { getCachedFirm } from './firm-catalog';
|
import { getCachedFirm } from './firm-catalog';
|
||||||
import { firmInstruments } from './instruments';
|
import { firmInstruments } from './instruments';
|
||||||
import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth, PickContext } from './platform-client';
|
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 { encodeContractName, decodeContractName, roundToStep, scoreCandidate } from './dxtrade-helpers';
|
||||||
import { vestSessionFor, dropVestSession, listVestSessions } from './vest-session';
|
import { vestSessionFor, dropVestSession, listVestSessions } from './vest-session';
|
||||||
import {
|
import {
|
||||||
vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder,
|
vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, nextVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder,
|
||||||
parseTxn, bookTxns, awaitingLegs, unrealizedPnl, hourlyVolatility, marketOpen, num, toMs, formatDecimal,
|
parseTxn, bookTxns, awaitingLegs, unrealizedPnl, hourlyVolatility, marketOpen, num, toMs, formatDecimal, resetSafeStop,
|
||||||
VEST_STATUS_ACTIVE,
|
VEST_STATUS_ACTIVE, VEST_TXN,
|
||||||
} from './vest-helpers';
|
} from './vest-helpers';
|
||||||
import type { VestCapitalAccount, VestPosition, VestOpenOrder, VestTxn } 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
|
/** Vest's own spread isn't on its REST surface (only the depth socket): a
|
||||||
* candidate is costed as crossing this many ticks. */
|
* candidate is costed as crossing this many ticks. */
|
||||||
const ASSUMED_SPREAD_TICKS = 1;
|
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 sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||||
@@ -162,6 +167,17 @@ export class VestClient implements PlatformClient {
|
|||||||
private readonly onSync: () => Promise<void>;
|
private readonly onSync: () => Promise<void>;
|
||||||
|
|
||||||
private uuidById = new Map<number, string>();
|
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 capital = new Map<number, VestCapitalAccount>();
|
||||||
private positionsDetail = new Map<number, VestPosition[]>();
|
private positionsDetail = new Map<number, VestPosition[]>();
|
||||||
private openOrders = new Map<number, VestOpenOrder[]>();
|
private openOrders = new Map<number, VestOpenOrder[]>();
|
||||||
@@ -286,7 +302,7 @@ export class VestClient implements PlatformClient {
|
|||||||
*/
|
*/
|
||||||
private async request<T>(
|
private async request<T>(
|
||||||
bucket: Bucket,
|
bucket: Bucket,
|
||||||
method: 'GET' | 'POST' | 'DELETE',
|
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
||||||
path: string,
|
path: string,
|
||||||
opts: { params?: Record<string, string | number | boolean>; body?: unknown; account?: string; auth?: boolean; retried?: boolean } = {},
|
opts: { params?: Record<string, string | number | boolean>; body?: unknown; account?: string; auth?: boolean; retried?: boolean } = {},
|
||||||
): Promise<{ data: T }> {
|
): Promise<{ data: T }> {
|
||||||
@@ -354,6 +370,7 @@ export class VestClient implements PlatformClient {
|
|||||||
if (this.intentionalDisconnect) return;
|
if (this.intentionalDisconnect) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (this.syncInFlight && now - this.syncInFlightSince >= SYNC_WEDGED_MS) void this.requestSync();
|
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 ───────────────────────────────────────────────────
|
// ── public market data ───────────────────────────────────────────────────
|
||||||
@@ -475,6 +492,8 @@ export class VestClient implements PlatformClient {
|
|||||||
for (const b of bal.data?.accounts ?? []) {
|
for (const b of bal.data?.accounts ?? []) {
|
||||||
const amount = num(b.amount);
|
const amount = num(b.amount);
|
||||||
if (typeof b.account_id === 'string' && amount !== undefined) free.set(b.account_id, 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 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);
|
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; }
|
if (amount === undefined) { complete = false; continue; }
|
||||||
const balance = amount + mine.reduce((s, p) => s + p.collateral, 0);
|
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 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] = {
|
this.accountCashBalances[id] = {
|
||||||
amount: round2(balance),
|
amount: round2(balance),
|
||||||
realizedPnL: resetBalance !== undefined ? round2(balance - resetBalance) : 0,
|
realizedPnL: this.realizedSince(id, a, balance, now),
|
||||||
equity: round2(balance + upnl),
|
equity: round2(balance + upnl),
|
||||||
availableFunds: round2(amount),
|
availableFunds: round2(amount),
|
||||||
};
|
};
|
||||||
@@ -609,21 +627,170 @@ export class VestClient implements PlatformClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* See PlatformClient: what the engine measures the day's loss from. Vest
|
* See PlatformClient: Vest states the day's floor itself — reset balance
|
||||||
* states the floor itself (reset balance × (1 − rate), moved by transfers),
|
* × (1 − rate), lowered by a payout the same day — so the engine's room
|
||||||
* so this is that floor plus the account type's own daily limit — the
|
* is min(the stage's limit, balance − floor), never more than Vest
|
||||||
* engine's room, limit + min(0, balance − dayStart), is then
|
* leaves. null until the account has been read; undefined for an account
|
||||||
* min(limit, balance − floor): never more than Vest leaves. null until
|
* Vest gives no daily limit.
|
||||||
* the account has been read.
|
|
||||||
*/
|
*/
|
||||||
public dayStartBalance(accountId: number): number | null {
|
public dailyLossFloor(accountId: number): number | null | undefined {
|
||||||
const a = this.capital.get(accountId);
|
const a = this.capital.get(accountId);
|
||||||
const cash = this.accountCashBalances[accountId];
|
const cash = this.accountCashBalances[accountId];
|
||||||
if (!a || !cash) return null;
|
if (!a || !cash) return null;
|
||||||
const floor = this.dailyFloorOf(a, cash.amount, Date.now());
|
if (!(a.maxDailyLossPct > 0)) return undefined;
|
||||||
const limit = getAccountConfig(accountId).accountType?.dailyLossLimit ?? 0;
|
return this.dailyFloorOf(a, cash.amount, Date.now()) ?? null;
|
||||||
if (floor === undefined) return this.resetBalanceOf(a, cash.amount, Date.now()) ?? null;
|
}
|
||||||
return round2(floor + limit);
|
|
||||||
|
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 {
|
private settleSuspects(): void {
|
||||||
@@ -766,28 +933,25 @@ export class VestClient implements PlatformClient {
|
|||||||
.sort((x, y) => x.date.localeCompare(y.date));
|
.sort((x, y) => x.date.localeCompare(y.date));
|
||||||
saveDailyPnL(account.id, account.name, fresh);
|
saveDailyPnL(account.id, account.name, fresh);
|
||||||
|
|
||||||
const storedFundDate = loadAccountMeta(account.id, 'last_fund_date');
|
|
||||||
const fundRows = [...funds.entries()]
|
const fundRows = [...funds.entries()]
|
||||||
.map(([date, amount]) => ({ date, amount: round2(amount) }))
|
.map(([date, amount]) => ({ date, amount: round2(amount) }))
|
||||||
.filter((f) => f.amount !== 0)
|
.filter((f) => f.amount !== 0)
|
||||||
.sort((x, y) => x.date.localeCompare(y.date));
|
.sort((x, y) => x.date.localeCompare(y.date));
|
||||||
if (fundRows.length > 0) saveFundTransactions(account.id, account.name, fundRows);
|
this.applyFundRows(account, fundRows);
|
||||||
this.fundTransactions[account.id] = loadFundTransactions(account.id);
|
this.withdrawals.set(account.id, txns.filter((t) => t.type === VEST_TXN.WITHDRAW).map((t) => ({ at: t.time, amount: t.amount })));
|
||||||
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);
|
|
||||||
|
|
||||||
// Books check: the balance must be what the transactions add up to
|
// Books check: the balance must be what the transactions add up to
|
||||||
// (the opening capital included). A payout or credit booked under a
|
// (the opening capital included). A payout or credit booked under a
|
||||||
// type this client doesn't count would otherwise trade a finished
|
// type this client doesn't count would otherwise trade a finished
|
||||||
// cycle as if nothing happened.
|
// cycle as if nothing happened.
|
||||||
const cash = this.accountCashBalances[account.id];
|
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) {
|
if (account.active && cash && !this.positions[account.id] && Math.abs(cash.amount - expected) >= BOOKS_TOLERANCE) {
|
||||||
this.booksHeld.add(account.id);
|
this.booksHeld.add(account.id);
|
||||||
this.lastFetchErrors[account.name] = `balance ${round2(cash.amount)} vs transactions ${round2(expected)} (types ${types.join(',')}) — held until they agree`;
|
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);
|
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 {
|
private fallBackToCache(account: PlatformAccount, msg: string): void {
|
||||||
const cached = loadDailyPnL(account.id);
|
const cached = loadDailyPnL(account.id);
|
||||||
this.fundTransactions[account.id] ??= loadFundTransactions(account.id);
|
this.fundTransactions[account.id] ??= loadFundTransactions(account.id);
|
||||||
|
|||||||
@@ -92,6 +92,58 @@ export function lastVestResetMs(now: number): number {
|
|||||||
return NaN;
|
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 ────────────────────────────────────────────────────────────────
|
// ── accounts ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface VestCapitalAccount {
|
export interface VestCapitalAccount {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const LIVE = {
|
|||||||
targetSameEquity: false, splitEnabled: true, minLegs: 2, firstDayStop: true, minDayStop: false, combineCmeHolidays: false,
|
targetSameEquity: false, splitEnabled: true, minLegs: 2, firstDayStop: true, minDayStop: false, combineCmeHolidays: false,
|
||||||
withdrawalStages: [{ profit: 2000, consistency: 0.4, minTradingDays: 5, drain: false, minDayPnL: 0 }],
|
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,
|
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`. */
|
/** `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], [
|
assert.deepEqual([...RULE_FIELDS], [
|
||||||
'profitTarget', 'consistency', 'minDayPnL', 'minTradingDays', 'maxLoss', 'maxPositionSize', 'targetSameEquity',
|
'profitTarget', 'consistency', 'minDayPnL', 'minTradingDays', 'maxLoss', 'maxPositionSize', 'targetSameEquity',
|
||||||
'splitEnabled', 'minLegs', 'firstDayStop', 'minDayStop', 'combineCmeHolidays', 'withdrawalStages', 'payoutable',
|
'splitEnabled', 'minLegs', 'firstDayStop', 'minDayStop', 'combineCmeHolidays', 'withdrawalStages', 'payoutable',
|
||||||
'trailingDrawdown', 'dailyLossLimit',
|
'trailingDrawdown', 'dailyLossLimit', 'stagesSameDay',
|
||||||
]);
|
]);
|
||||||
assert.deepEqual([...IDENTITY_FIELDS], ['prefix', 'accountSize']);
|
assert.deepEqual([...IDENTITY_FIELDS], ['prefix', 'accountSize']);
|
||||||
assert.deepEqual([...PROJECTION_FIELDS], ['expectedPayout', 'withdrawalPct', 'payoutCap', 'profitSplit']);
|
assert.deepEqual([...PROJECTION_FIELDS], ['expectedPayout', 'withdrawalPct', 'payoutCap', 'profitSplit']);
|
||||||
@@ -82,6 +82,7 @@ function legacyNormalize(t, j, tradesEveryDay) {
|
|||||||
firstDayStop: type.firstDayStop === true,
|
firstDayStop: type.firstDayStop === true,
|
||||||
minDayStop: type.minDayStop === true,
|
minDayStop: type.minDayStop === true,
|
||||||
dailyLossLimit: nonNegative(type.dailyLossLimit),
|
dailyLossLimit: nonNegative(type.dailyLossLimit),
|
||||||
|
stagesSameDay: type.stagesSameDay === true,
|
||||||
tradesEveryDay,
|
tradesEveryDay,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -174,7 +175,7 @@ test('overlayKept: the kept rules on top; identity, label, projections and bookk
|
|||||||
const oldRules = {
|
const oldRules = {
|
||||||
profitTarget: 3000, consistency: 0.5, minDayPnL: 200, minTradingDays: 5, maxLoss: 2500, maxPositionSize: 3,
|
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,
|
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:
|
// A master that sent other identity/projection values on the kept set:
|
||||||
// they must not leak through.
|
// they must not leak through.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
projectPerfectPath, cycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, notionalCap, expectedSwapToWin, MARGIN_HEADROOM,
|
projectPerfectPath, cycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, notionalCap, expectedSwapToWin, MARGIN_HEADROOM,
|
||||||
dllReserve, DLL_SLIPPAGE_FRACTION, maxLossRoom, lastStand, breachDistance, LAST_STAND_FRACTION, planLegSize, rankForTarget,
|
dllReserve, DLL_SLIPPAGE_FRACTION, maxLossRoom, lastStand, breachDistance, LAST_STAND_FRACTION, planLegSize, rankForTarget,
|
||||||
currentTradeDateCT, foldReason, withinFirmCaps, personSizeFactor, personHoldHours, PERSON_SIZE_SPREAD, machineSide,
|
currentTradeDateCT, foldReason, withinFirmCaps, personSizeFactor, personHoldHours, PERSON_SIZE_SPREAD, machineSide,
|
||||||
|
resolveEffectiveConfig, stageInputsFor, floorRoom, computeDayPlan,
|
||||||
} from '../lib/trading-logic.ts';
|
} from '../lib/trading-logic.ts';
|
||||||
|
|
||||||
// Deterministic PRNG so a failure reproduces.
|
// 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([1, -1], null), 'mixed');
|
||||||
assert.equal(machineSide([-5], 'Buy'), '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
@@ -11,7 +11,7 @@ import assert from 'node:assert/strict';
|
|||||||
import {
|
import {
|
||||||
isVestWalletAddress, vestAccountId, num, toMs, formatDecimal, vestDayOf, lastVestResetMs, parseCapitalAccount,
|
isVestWalletAddress, vestAccountId, num, toMs, formatDecimal, vestDayOf, lastVestResetMs, parseCapitalAccount,
|
||||||
vestAccountName, parsePosition, awaitingLegs, parseOpenOrder, unrealizedPnl, parseTxn, bookTxns, txnKind, VEST_TXN,
|
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';
|
} from '../lib/vest-helpers.ts';
|
||||||
import { putVestSession, vestSessionFor, dropVestSession, listVestSessions } from '../lib/vest-session.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));
|
dropVestSession(wallet, jwt(1));
|
||||||
assert.equal(vestSessionFor(wallet, now), null);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user