Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf7e147507 | ||
|
|
dc210ffcb7 | ||
|
|
e02838409b | ||
|
|
4a8e027056 | ||
|
|
032ba1ca9c | ||
|
|
ab588adbac | ||
|
|
9ecb584dbc | ||
|
|
3a16af38f6 | ||
|
|
1f1f0cbd8f | ||
|
|
b5939a06a0 | ||
|
|
ef15200e5e | ||
|
|
35512cd504 | ||
|
|
f325b5e969 | ||
|
|
266100b65c | ||
|
|
e33e890a83 | ||
|
|
4add379c68 | ||
|
|
952e4412c4 | ||
|
|
9fe7fc0ea3 | ||
|
|
378530595e | ||
|
|
ec3e6ea388 | ||
|
|
e90f21ed91 |
@@ -261,7 +261,11 @@ own traffic since Vest publishes no API):
|
||||
The dashboard's **Auto-claim payouts** box turns it off (`auto_claim`). A
|
||||
claim holds every further claim on the account until Vest's transactions
|
||||
show its WITHDRAW; one whose request failed in transit holds them too, until
|
||||
the transactions show it went in, or an hour of them shows it didn't. The account page's **Claim
|
||||
the transactions show it went in, or an hour of them shows it didn't (one
|
||||
that never went out, for want of an account token, holds nothing). Each
|
||||
claim counts as its own payout in the report, even two on one Vest day, and
|
||||
the cycle a day's second claim starts is sent with its own `cycleSeq` (the
|
||||
master needs to key on it to keep that cycle apart). The account page's **Claim
|
||||
now** button claims the whole profit above the account size at once — any
|
||||
amount from $1 — when the account is flat; it asks first, since it moves
|
||||
money to the login's main account.
|
||||
|
||||
+54
-29
@@ -282,6 +282,7 @@ function ObjRow({ passed, label, value }: { passed: boolean; label: ReactNode; v
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function EquityTooltip({ active, payload, label }: any) {
|
||||
if (!active || !payload?.length) return null;
|
||||
// Always read from the raw data point so fill-only series don't interfere
|
||||
@@ -487,9 +488,11 @@ export default function AccountPage() {
|
||||
}, [accountId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// The first load from a timer too, like the polls: not setState
|
||||
// from the effect's own body.
|
||||
const first = setTimeout(() => { void load(); }, 0);
|
||||
const interval = setInterval(() => { void load(); }, 5000);
|
||||
return () => clearInterval(interval);
|
||||
return () => { clearTimeout(first); clearInterval(interval); };
|
||||
}, [load]);
|
||||
|
||||
if (!account) {
|
||||
@@ -513,9 +516,12 @@ export default function AccountPage() {
|
||||
|
||||
// The firm's hard daily loss limit, and the least a day must make to count
|
||||
// as a trading day — two different numbers; neither is set on every type.
|
||||
const dailyLossLimit = (cfg?.dailyLossLimit ?? 0) > 0 ? cfg!.dailyLossLimit! : null;
|
||||
// The limit and the min days are the current stage's, which may set its own.
|
||||
const stageLossLimit = account.effectiveDailyLossLimit ?? cfg?.dailyLossLimit ?? 0;
|
||||
const dailyLossLimit = stageLossLimit > 0 ? stageLossLimit : null;
|
||||
const minDayPnL = (cfg?.minDayPnL ?? 0) > 0 ? cfg!.minDayPnL : null;
|
||||
const daysPassed = cfg != null && account.daysTraded >= cfg.minTradingDays;
|
||||
const minTradingDays = account.effectiveMinTradingDays ?? cfg?.minTradingDays ?? 0;
|
||||
const daysPassed = cfg != null && account.daysTraded >= minTradingDays;
|
||||
// Dead when balance hits the liquidation floor — Tradovate reports its
|
||||
// (positive) floor; TopstepX floors are reconstructed MLLs, negative or
|
||||
// +$0.01 once locked at breakeven. 0 = no floor known.
|
||||
@@ -552,13 +558,19 @@ export default function AccountPage() {
|
||||
}
|
||||
const sortedEvents = [...allDates].sort().map((d) => eventsByDate.get(d)!);
|
||||
|
||||
// Running equity after each event, summed here rather than inside the
|
||||
// map below: a render callback mustn't reassign an outer variable.
|
||||
const runningAfter: number[] = [];
|
||||
let running = 0;
|
||||
for (const ev of sortedEvents) {
|
||||
if (ev.fundAmt !== undefined) running += ev.fundAmt; // withdrawals reduce equity
|
||||
if (ev.pnl !== undefined) running += ev.pnl;
|
||||
runningAfter.push(running);
|
||||
}
|
||||
const equityData = [
|
||||
{ label: '', equity: 0, pnl: 0, origin: true, pos: 0, neg: 0, isWithdrawal: false },
|
||||
...sortedEvents.map((ev) => {
|
||||
if (ev.fundAmt !== undefined) running += ev.fundAmt; // withdrawals reduce equity
|
||||
if (ev.pnl !== undefined) running += ev.pnl;
|
||||
const equity = Math.round(running * 100) / 100;
|
||||
...sortedEvents.map((ev, i) => {
|
||||
const equity = Math.round(runningAfter[i] * 100) / 100;
|
||||
return {
|
||||
label: fmtDate(ev.date),
|
||||
equity,
|
||||
@@ -597,7 +609,7 @@ export default function AccountPage() {
|
||||
// books exactly its plan, cycles complete, and the configured withdrawal
|
||||
// fires the day a cycle finishes. Same planner the trade path runs, so the
|
||||
// numbers (bumps and drain amounts included) are what would actually be
|
||||
// aimed for. Shown up to TWO payouts ahead.
|
||||
// aimed for. Shown up to TWO payout days ahead.
|
||||
const rawProjection = (cfg && !isDead) ? projectPerfectPath({
|
||||
accountId: account.id,
|
||||
cfg,
|
||||
@@ -605,7 +617,15 @@ export default function AccountPage() {
|
||||
fundTransactions,
|
||||
balance: account.amount,
|
||||
realizedPnL: account.realizedPnL,
|
||||
maxWithdrawals: 2,
|
||||
// The day the firm's numbers follow (Vest's, until 20:00 ET).
|
||||
rawTradeDate: account.tradeDate,
|
||||
// The state stage counts the payouts that pick it: today's for a type
|
||||
// whose stages reset each day, else every one, each on its own.
|
||||
stageInputs: cfg.stagesSameDay === true
|
||||
? { stagesSameDay: true, sameDayWithdrawals: account.stage - 1 }
|
||||
: { payouts: account.stage - 1 },
|
||||
// Such a type pays out once per stage on each of those days.
|
||||
maxWithdrawals: cfg.stagesSameDay === true ? 2 * (1 + cfg.withdrawalStages.length) : 2,
|
||||
payout: {
|
||||
withdrawalPct: cfg.withdrawalPct,
|
||||
payoutCap: cfg.payoutCap,
|
||||
@@ -621,23 +641,21 @@ export default function AccountPage() {
|
||||
: undefined,
|
||||
},
|
||||
}) : [];
|
||||
// Cut the projection at the second payout — days beyond it are noise here.
|
||||
const secondPayoutIdx = (() => {
|
||||
let seen = 0;
|
||||
for (let i = 0; i < rawProjection.length; i++) {
|
||||
const p = rawProjection[i];
|
||||
if (p.withdrawal !== undefined || p.payoutReady) {
|
||||
seen++;
|
||||
if (seen >= 2 || p.payoutReady) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
})();
|
||||
const projection = secondPayoutIdx >= 0 ? rawProjection.slice(0, secondPayoutIdx + 1) : rawProjection;
|
||||
// Cut the projection after the second payout day — days beyond it are
|
||||
// noise here. The path's dates never go back, and a payoutReady entry is
|
||||
// always its last.
|
||||
const payoutDays = [...new Set(rawProjection.filter((p) => p.withdrawal !== undefined || p.payoutReady).map((p) => p.date))];
|
||||
const projection = payoutDays.length >= 2 ? rawProjection.filter((p) => p.date <= payoutDays[1]) : rawProjection;
|
||||
// A payout day is the cycle's FINAL trading day — it can carry both the
|
||||
// day's booked P&L and the withdrawal.
|
||||
const projPnlMap = new Map(projection.filter((p) => p.pnl !== 0).map((p) => [p.date, p.pnl]));
|
||||
const projFundMap = new Map(projection.filter((p) => p.withdrawal !== undefined).map((p) => [p.date, p.withdrawal!]));
|
||||
// day's booked P&L and the withdrawal. A same-day type's day can hold
|
||||
// several cycles: their P&L and withdrawals add up, and the day shows
|
||||
// the last stage it traded.
|
||||
const projPnlMap = new Map<string, number>();
|
||||
const projFundMap = new Map<string, number>();
|
||||
for (const p of projection) {
|
||||
if (p.pnl !== 0) projPnlMap.set(p.date, Math.round(((projPnlMap.get(p.date) ?? 0) + p.pnl) * 100) / 100);
|
||||
if (p.withdrawal !== undefined) projFundMap.set(p.date, Math.round(((projFundMap.get(p.date) ?? 0) + p.withdrawal) * 100) / 100);
|
||||
}
|
||||
const projStageMap = new Map(projection.map((p) => [p.date, p.stage]));
|
||||
/** Cumulative probability of the perfect path surviving through each projected day. */
|
||||
const projProbMap = new Map(projection.map((p) => [p.date, p.prob]));
|
||||
@@ -652,14 +670,20 @@ export default function AccountPage() {
|
||||
];
|
||||
const calendarMonths = [...new Set(allCalendarDates.map((d) => d.slice(0, 7)))].sort();
|
||||
|
||||
// Build stage map: stage increments after each withdrawal (negative fund txn)
|
||||
// Build stage map: stage increments after each withdrawal (negative fund txn).
|
||||
// A type whose stages reset each day starts every day on stage 1 and
|
||||
// trades its next stage after a payout that day, as the projected days
|
||||
// show — one fund row a day holds all of that day's payouts, so a day
|
||||
// with one reads stage 2.
|
||||
const sortedWithdrawalDates = fundTransactions
|
||||
.filter((f) => f.amount < 0)
|
||||
.map((f) => f.date)
|
||||
.sort();
|
||||
const stageMap = new Map<string, number>();
|
||||
for (const d of displayPnL) {
|
||||
const stage = sortedWithdrawalDates.filter((wd) => wd < d.date).length + 1;
|
||||
const stage = cfg?.stagesSameDay === true
|
||||
? sortedWithdrawalDates.filter((wd) => wd === d.date).length + 1
|
||||
: sortedWithdrawalDates.filter((wd) => wd < d.date).length + 1;
|
||||
stageMap.set(d.date, stage);
|
||||
}
|
||||
|
||||
@@ -739,7 +763,7 @@ export default function AccountPage() {
|
||||
<ObjRow
|
||||
passed={daysPassed}
|
||||
label="Trading Days"
|
||||
value={cfg ? `${account.daysTraded} of ${cfg.minTradingDays}` : '—'}
|
||||
value={cfg ? `${account.daysTraded} of ${minTradingDays}` : '—'}
|
||||
/>
|
||||
{dailyTarget != null && (
|
||||
<div className="flex items-center justify-between py-3 border-b border-slate-100 last:border-0">
|
||||
@@ -892,6 +916,7 @@ export default function AccountPage() {
|
||||
stroke={`url(#${strokeGradId})`}
|
||||
strokeWidth={2.5}
|
||||
fill="none"
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
dot={(props: any) => {
|
||||
if (props.payload?.origin) return <g key={props.key} />;
|
||||
return (
|
||||
|
||||
+19
-11
@@ -9,8 +9,8 @@ import { rankNextLeg, dayLossRoom } from '@/lib/leg-estimate';
|
||||
import { obfuscationDayBooked } from '@/lib/auto-trade';
|
||||
import type { AccountState } from '@/types';
|
||||
import {
|
||||
combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, currentTradeDateCT,
|
||||
obfuscationStopDollars, dllStopDollars, botStopsActive, stageInputsFor,
|
||||
combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, tradeDateFor,
|
||||
obfuscationStopDollars, dllStopDollars, botStopsActive, stageInputsFor, stagePayouts,
|
||||
maxLossRoom, lastStand as isLastStand, shortestNextLeg,
|
||||
LEG_COMPLETION_EPSILON,
|
||||
} from '@/lib/trading-logic';
|
||||
@@ -19,7 +19,6 @@ export async function GET() {
|
||||
try {
|
||||
const firms = getCachedCatalog();
|
||||
const clients = getClients();
|
||||
const tradeDate = currentTradeDateCT();
|
||||
|
||||
const state = firms.map((f) => {
|
||||
const client = clients.get(f.id);
|
||||
@@ -30,6 +29,9 @@ export async function GET() {
|
||||
if (!client || client.accountList.length === 0) {
|
||||
return { firmId: f.id, firm: f.name, connected, accounts: [] };
|
||||
}
|
||||
// The day the firm's accounts trade on (tradeDateFor), as the
|
||||
// engine plans them — Vest's turns at 20:00 ET, not 5 PM CT.
|
||||
const tradeDate = tradeDateFor(client);
|
||||
// Accounts whose rules date was set by hand — one read per firm
|
||||
// rather than one per account.
|
||||
const rulesDateByHand = new Set(listAssignments(f.id)
|
||||
@@ -61,11 +63,11 @@ export async function GET() {
|
||||
const isDead = autoLiqThreshold !== 0 && cash.amount <= autoLiqThreshold;
|
||||
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
|
||||
const priorProfit = client.priorProfit?.[acc.id] ?? 0;
|
||||
// Stage = 1 + number of withdrawals (negative fund transactions),
|
||||
// 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 = 1 + number of payouts (negative fund transactions, or
|
||||
// the client's own list where a row holds several), or of
|
||||
// today's for a type whose stages reset each day.
|
||||
const stageInputs = stageInputsFor(client, acc.id, cfg ?? {});
|
||||
const stage = 1 + stagePayouts(allFundTxns, stageInputs);
|
||||
// Stage-resolved config: a drain stage swaps in its own min-day
|
||||
// (default $5) and no profit target.
|
||||
const effective = cfg ? resolveEffectiveConfig(
|
||||
@@ -77,7 +79,7 @@ export async function GET() {
|
||||
priorProfit,
|
||||
allFundTxns,
|
||||
cfg.minDayPnL,
|
||||
stageInputsFor(client, acc.id, cfg),
|
||||
stageInputs,
|
||||
) : null;
|
||||
// Days traded counts only days that hit minDayPnL (when set) — that's what
|
||||
// the firm requires toward the min trading day rule.
|
||||
@@ -171,7 +173,7 @@ export async function GET() {
|
||||
};
|
||||
}
|
||||
}
|
||||
if (plan && !dayBooked && botStopsActive(cfg)) {
|
||||
if (plan && !dayBooked && botStopsActive(cfg, effective)) {
|
||||
const obf = obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday);
|
||||
// The reserve grows with the position (dllReserve): at
|
||||
// the size the next leg would trade, where it's known.
|
||||
@@ -251,6 +253,7 @@ export async function GET() {
|
||||
completed = cycleCompletion({
|
||||
accountId: acc.id,
|
||||
rawTradeDate: tradeDate,
|
||||
client,
|
||||
cycleDailyPnL: client.dailyPnL[acc.id],
|
||||
rawRealizedPnL: cash.realizedPnL,
|
||||
combineCmeHolidays: cfg.combineCmeHolidays,
|
||||
@@ -269,11 +272,14 @@ export async function GET() {
|
||||
// The combined day's P&L, not just the current session's —
|
||||
// on the Tuesday after a holiday this includes Sun/Mon.
|
||||
realizedPnL: combined.realizedPnL,
|
||||
// The firm's raw trade date, which the account page
|
||||
// projects the payouts from.
|
||||
tradeDate,
|
||||
daysTraded,
|
||||
// An account the bot puts stops on whose day booked red via
|
||||
// its own stop — the personal DLL. Firm DLL lockouts show
|
||||
// via !active instead.
|
||||
personalDll: !!cfg && botStopsActive(cfg)
|
||||
personalDll: !!cfg && !!effective && botStopsActive(cfg, effective)
|
||||
&& acc.active && !client.positions[acc.id]
|
||||
&& combined.realizedPnL <= -25,
|
||||
positionDirection: client.positions[acc.id]
|
||||
@@ -293,6 +299,8 @@ export async function GET() {
|
||||
lastStand: lastStandNext,
|
||||
remainingToday,
|
||||
effectiveProfitTarget,
|
||||
effectiveDailyLossLimit: effective?.dailyLossLimit ?? null,
|
||||
effectiveMinTradingDays: effective?.minTradingDays ?? null,
|
||||
dailyPnL,
|
||||
fullDailyPnL: combineCmeSessions(
|
||||
cfg?.combineCmeHolidays === true,
|
||||
|
||||
+1
-1
@@ -453,7 +453,7 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned, onStateChang
|
||||
<AccountTypeCell account={account} firm={firm} onAssigned={onAssigned} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-600 tabular-nums">
|
||||
{account.daysTraded} / {cfg?.minTradingDays ?? '—'}
|
||||
{account.daysTraded} / {account.effectiveMinTradingDays ?? cfg?.minTradingDays ?? '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-600 tabular-nums">
|
||||
<div className="flex flex-col">
|
||||
|
||||
+83
-23
@@ -11,24 +11,25 @@ import { getClients } from './clients';
|
||||
import { getCachedFirm, getTradableFirms } from './firm-catalog';
|
||||
import { ensureRulesEvidence, getAccountConfig } from './account-type-resolver';
|
||||
import {
|
||||
combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT,
|
||||
combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, tradeDateFor,
|
||||
obfuscationStopDollars, takeProfitPlan, onTick, percentFee, dllRoom, dllStopDollars, dllViable, stopTicks,
|
||||
expectedSwapToWin, planLegSize, maxLossRoom, breachDistance,
|
||||
expectedSwapToWin, planLegSize, maxLossRoom, breachDistance, legCloseNote,
|
||||
isNoTradeTime, isPastSessionClose,
|
||||
BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY,
|
||||
FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP,
|
||||
CME_EVENING_OPEN_MIN, foldReason, withinFirmCaps,
|
||||
fundRowsKey, markFinishedCycle, unbookedPayoutDrop, machineSide, stageInputsFor, floorRoom, shortestNextLeg,
|
||||
type DayPlan, type FinishedCycleMark,
|
||||
fundRowsKey, markFinishedCycle, unbookedPayoutDrop, machineSide, stageInputsFor, stagePayouts, floorRoom, shortestNextLeg,
|
||||
claimedCycle, withinEndedCycleWindow,
|
||||
type DayPlan, type FinishedCycleMark, type ClaimedCycle,
|
||||
} from './trading-logic';
|
||||
import { getSetting, setSetting, loadAccountMeta, saveAccountMeta } from './db';
|
||||
import { getSetting, setSetting, loadAccountMeta, saveAccountMeta, loadClaimedCycles, saveClaimedCycles } from './db';
|
||||
import type { LaneTick, TickReason } from './master-protocol';
|
||||
import { isNotionalPlatform } from './master-protocol';
|
||||
import { POINT_VALUES, housekeepingRoot, HOUSEKEEPING_ROOT, MAX_HOLD_HOURS, futuresContracts } from './instrument-specs';
|
||||
import { firmInstruments, drawFuturesInstrument, spreadBlock, type LegShape } from './instruments';
|
||||
import { recordEntry } from './trade-mix';
|
||||
import { sizingHoldHours } from './person-size';
|
||||
import { errorMessage, isClientReady, isOrderNotPlaced, type PlatformClient, type PlatformContract, type PickContext } from './platform-client';
|
||||
import { errorMessage, isClientReady, isNothingToClose, isOrderNotPlaced, type PlatformClient, type PlatformContract, type PickContext } from './platform-client';
|
||||
|
||||
// ── process-wide state ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -260,7 +261,10 @@ function unrecordLeg(accountId: number, tradeDate: string, legAmount: number): v
|
||||
* (OrderNotPlacedError). Anything ambiguous, like an ack or fill timeout,
|
||||
* keeps the leg: the order may well have filled, and a leg counted that never
|
||||
* happened only costs a leg, while one forgotten that did can trade the
|
||||
* account twice.
|
||||
* account twice. Open-only: where the platform checks (Vest, DXtrade), an
|
||||
* account that already holds the symbol — a hand trade the synced positions
|
||||
* don't show yet — is refused rather than having that position closed as the
|
||||
* "entry".
|
||||
*/
|
||||
async function sendEntry(
|
||||
tag: string,
|
||||
@@ -277,7 +281,7 @@ async function sendEntry(
|
||||
const at = Date.now();
|
||||
const realizedBefore = client.accountCashBalances[acc.id]?.realizedPnL ?? 0;
|
||||
try {
|
||||
const fill = await client.sendOrder(acc.id, contractName, contracts, action, 'Market');
|
||||
const fill = await client.sendOrder(acc.id, contractName, contracts, action, 'Market', undefined, 'open');
|
||||
outcomes.set(acc.id, { tradeDate, state: 'filled', at, realizedBefore, legAmount });
|
||||
return fill;
|
||||
} catch (err) {
|
||||
@@ -1043,7 +1047,10 @@ async function runTradeLocked(
|
||||
// operator's pick; 'Auto' is drawn per batch from the batch firm's list.
|
||||
const explicit = futuresLane && symbol !== 'Auto' ? symbol : null;
|
||||
|
||||
const tradeDate = currentTradeDateCT();
|
||||
// The lane's own day (tradeDateFor): a notional lane is one firm — a
|
||||
// Vest lane's day turns at 20:00 ET, its rows and realized P&L with it —
|
||||
// and the futures lane's clients all keep the Central trade date.
|
||||
const tradeDate = tradeDateFor(open[0]?.client);
|
||||
const maxConcurrent = maxConcurrentAccounts();
|
||||
|
||||
// ── Phase 1: the eligible accounts across the lane's firms ──
|
||||
@@ -1217,6 +1224,13 @@ async function runTradeLocked(
|
||||
console.warn('[auto-trade] superseded while collecting accounts — abandoning this batch');
|
||||
return { outcome: 'skipped-in-flight', results: [], detail: 'superseded by another batch while choosing' };
|
||||
}
|
||||
// A Vest lane's day turns with its client's numbers (tradeDateFor). One
|
||||
// that turned while choosing would size and count these legs on the day
|
||||
// that ended — the next tick plans on the new one.
|
||||
if (batch.some((item) => tradeDateFor(item.client) !== tradeDate)) {
|
||||
console.warn(`[auto-trade] ${laneName(lane)}: the trade date turned while choosing — abandoning this batch`);
|
||||
return { outcome: 'skipped-in-flight', results: [], detail: 'the trade date turned while choosing' };
|
||||
}
|
||||
|
||||
console.log(`[auto-trade] ${laneName(lane)}: ${allEligible.length} eligible account(s) across ${groups.size} prefix group(s) — firing batch of ${batch.length} ${batchPrefix || '(no prefix)'} on ${resolvedSymbol}`);
|
||||
runtime().lastFireAt = Date.now();
|
||||
@@ -1237,10 +1251,10 @@ async function runTradeLocked(
|
||||
const mnqContract = await client.findFrontMonthContract(microRoot);
|
||||
if (!mnqContract) throw new Error(`${microRoot} contract not found for extra-day trade`);
|
||||
|
||||
const fill = await client.sendOrder(acc.id, mnqContract.name, 1, resolvedAction, 'Market');
|
||||
const fill = await client.sendOrder(acc.id, mnqContract.name, 1, resolvedAction, 'Market', undefined, 'open');
|
||||
recordEntry(item.firmName, microRoot, resolvedAction, 1, mnqContract.id);
|
||||
const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy';
|
||||
const exitFill = await client.sendOrder(acc.id, mnqContract.name, 1, exitAction, 'Market');
|
||||
const exitFill = await client.sendOrder(acc.id, mnqContract.name, 1, exitAction, 'Market', undefined, 'close');
|
||||
|
||||
console.log(`[auto-trade] ${acc.name} (${item.firmName}) extra-day: ${resolvedAction} 1x${mnqContract.name} @ ${fill.price} | exited @ ${exitFill.price} (market)`);
|
||||
|
||||
@@ -1300,7 +1314,11 @@ const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
* Close a just-filled position at market because it must not stay open — its
|
||||
* daily-loss stop has no room, or the broker refused its exit bracket — and
|
||||
* report the leg as failed. A flatten that fails too is the worst case: a
|
||||
* position with no stop, so it is shouted about.
|
||||
* position with no stop, so it is shouted about. Close-only: on an account
|
||||
* that turned out flat it must not open a fresh position with no exits, and
|
||||
* where the platform checks (Vest, DXtrade) and its own record agrees, that
|
||||
* flat account has nothing riding — the leg still fails, but without the
|
||||
* alarm. A flat read the record contradicts still gets the alarm.
|
||||
*/
|
||||
async function flattenAndFail(
|
||||
tag: string,
|
||||
@@ -1314,9 +1332,13 @@ async function flattenAndFail(
|
||||
why: string,
|
||||
): Promise<never> {
|
||||
try {
|
||||
const exit = await client.sendOrder(acc.id, contractName, contracts, exitAction, 'Market');
|
||||
const exit = await client.sendOrder(acc.id, contractName, contracts, exitAction, 'Market', undefined, 'close');
|
||||
console.warn(`${tag} ${acc.name}: flattened @ ${exit.price} — ${why}`);
|
||||
} catch (err) {
|
||||
if (isNothingToClose(err)) {
|
||||
console.warn(`${tag} ${acc.name}: nothing to flatten — ${errorMessage(err)} (${why})`);
|
||||
throw new Error(`entry filled @ ${entryPrice}; ${why}; already flat — nothing to flatten`);
|
||||
}
|
||||
console.error(`${tag} ${acc.name}: FLATTEN FAILED (${errorMessage(err)}) — the position may be open WITHOUT A STOP; close it at the broker (${why})`);
|
||||
throw new Error(`entry filled @ ${entryPrice}; ${why}; flatten failed — ${errorMessage(err)}`);
|
||||
}
|
||||
@@ -1488,6 +1510,10 @@ async function fireLeg(
|
||||
if (!plan.sizing) {
|
||||
throw new Error(`no position size for ${legContract.name} — its recent volatility is unknown`);
|
||||
}
|
||||
// The pick held the batch's lead leg to its market's close; this
|
||||
// account's leg can size longer (another stage, a leverage cap).
|
||||
const closeNote = legCloseNote(legContract.closesAt, plan.sizing.predictedHoldHours, Date.now());
|
||||
if (closeNote) throw new Error(`${legContract.name}: ${closeNote} — not entering`);
|
||||
contracts = Math.max(1, Math.round(plan.sizing.notional / stepNotional));
|
||||
if (plan.cap > 0) contracts = Math.min(contracts, Math.max(1, Math.floor(plan.cap / stepNotional)));
|
||||
if ((legContract.maxContracts ?? 0) > 0) contracts = Math.min(contracts, legContract.maxContracts!);
|
||||
@@ -1742,7 +1768,8 @@ async function copyTradeLocked(lease: number, lane: string): Promise<unknown[]>
|
||||
return [];
|
||||
}
|
||||
|
||||
const tradeDate = currentTradeDateCT();
|
||||
// The lane's own day, as in runTrade.
|
||||
const tradeDate = tradeDateFor(ready[0]?.client);
|
||||
|
||||
// Collect eligible accounts (same logic as Phase 1 of runTrade)
|
||||
const eligible: LegItem[] = [];
|
||||
@@ -1806,6 +1833,10 @@ async function copyTradeLocked(lease: number, lane: string): Promise<unknown[]>
|
||||
console.warn('[copy-trade] superseded while collecting accounts — abandoning this copy');
|
||||
return [];
|
||||
}
|
||||
if (batch.some((item) => tradeDateFor(item.client) !== tradeDate)) {
|
||||
console.warn('[copy-trade] the trade date turned while choosing — abandoning this copy');
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log(`[copy-trade] copying ${resolvedAction} ${resolvedSymbol} to ${batch.length} account(s)`);
|
||||
runtime().lastFireAt = Date.now();
|
||||
@@ -1860,7 +1891,7 @@ export async function fireManualLeg(accountId: number, action: 'Buy' | 'Sell' |
|
||||
const lease = acquireTradeMutex('[manual-leg]', lane);
|
||||
if (lease === null) throw new Error('another batch is in flight in this lane');
|
||||
try {
|
||||
const tradeDate = currentTradeDateCT();
|
||||
const tradeDate = tradeDateFor(client);
|
||||
const ev = evaluateAccountForTrade(client, acc, tradeDate);
|
||||
if (!ev || !ev.plan) throw new Error(ev?.reason || 'account has no account type');
|
||||
if (!ev.eligible && !ev.reason.startsWith('already traded today')) throw new Error(ev.reason);
|
||||
@@ -1986,17 +2017,17 @@ async function runClaims(lane: string, ready: FirmClient[]): Promise<void> {
|
||||
const lease = acquireTradeMutex('[auto-claim]', lane);
|
||||
if (lease === null) return;
|
||||
try {
|
||||
const tradeDate = currentTradeDateCT();
|
||||
for (const { firm, client, acc } of due) {
|
||||
if (!holdsTradeMutex(lease, lane)) return;
|
||||
// Settled since, or a different amount now: claim what is due now.
|
||||
const amount = claimDue(client, acc, Date.now());
|
||||
if (amount === null) continue;
|
||||
try {
|
||||
const ends = claimEnds(client, acc);
|
||||
// In flight while it claims: a batch that takes over a wedged
|
||||
// lane, or a Claim now, leaves the account alone.
|
||||
const { claimed, received } = await withAccountInFlight(acc.id, () => client.claimProfit!(acc.id, amount));
|
||||
afterClaim('[auto-claim]', firm, client, acc, claimed, received, tradeDate);
|
||||
afterClaim('[auto-claim]', firm, client, acc, claimed, received, tradeDateFor(client), ends);
|
||||
} 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`);
|
||||
@@ -2044,16 +2075,45 @@ async function claimTick(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** The completed cycle a claim about to go out ends (claimedCycle), taken
|
||||
* before the claim books its payout. Not on rows a failed read left behind
|
||||
* — the master keeps it — which is historyFresh's own test for the
|
||||
* account; not its client-wide one, no read in flight, as each claim
|
||||
* starts a read and the next claim of the tick would never name its
|
||||
* cycle. The claim already stands on the account's read (historyReady).
|
||||
* Never throws: a payout isn't held back for its record. */
|
||||
function claimEnds(client: PlatformClient, acc: { id: number; name: string }): ClaimedCycle | null {
|
||||
try {
|
||||
const cfg = getAccountConfig(acc.id).accountType;
|
||||
// Judged as claimDue claims: within LEG_COMPLETION_EPSILON of the target.
|
||||
return cfg && !client.lastFetchErrors?.[acc.name] ? claimedCycle(client, acc.id, cfg, new Date(), LEG_COMPLETION_EPSILON) : null;
|
||||
} catch (err) {
|
||||
console.error(`[claim] ${acc.name}: the cycle the claim ends couldn't be read — ${errorMessage(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A claim went through: the account starts its next stage — the day's leg
|
||||
* count and entry outcome are cleared, as if it hadn't traded today. */
|
||||
* count and entry outcome are cleared, as if it hadn't traded today — and
|
||||
* the completed cycle it ended (claimEnds) is kept for the reporter, which
|
||||
* may never have seen it complete. */
|
||||
function afterClaim(
|
||||
tag: string, firm: TradableFirm, client: PlatformClient, acc: { id: number; name: string },
|
||||
claimed: number, received: number | null, tradeDate: string,
|
||||
claimed: number, received: number | null, tradeDate: string, ends: ClaimedCycle | null,
|
||||
): number {
|
||||
runtime().legCounts.delete(acc.id);
|
||||
runtime().entryOutcomes?.delete(acc.id);
|
||||
runtime().claimFailedAt?.delete(acc.id);
|
||||
const stage = 1 + (client.sameDayClaims?.(acc.id) ?? 0);
|
||||
if (ends) {
|
||||
try {
|
||||
saveClaimedCycles(acc.id, [...loadClaimedCycles(acc.id).filter((c) => withinEndedCycleWindow(c.date, ends.date)), ends]);
|
||||
} catch (err) {
|
||||
console.error(`${tag} ${firm.name} ${acc.name}: the completed cycle the claim ended wasn't saved — ${errorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
// The stage the engine trades next, counted as it counts it (stagePayouts).
|
||||
const cfg = getAccountConfig(acc.id).accountType;
|
||||
const stage = 1 + stagePayouts(client.fundTransactions?.[acc.id] ?? [], cfg ? stageInputsFor(client, acc.id, cfg) : {});
|
||||
console.log(`${tag} ${firm.name} ${acc.name}: claimed $${claimed}${received !== null ? ` (you get $${received})` : ''}`
|
||||
+ ` — trading stage ${stage} next (${tradeDate})`);
|
||||
return stage;
|
||||
@@ -2129,8 +2189,9 @@ export async function claimAccountNow(accountId: number, amount: number): Promis
|
||||
if ('reason' in c) throw new ClaimRefusedError(c.reason);
|
||||
if (!(amount >= 1)) throw new ClaimRefusedError(`$${amount} is below the $1 minimum claim`);
|
||||
if (amount > c.amount + 0.005) throw new ClaimRefusedError(`only $${c.amount.toFixed(2)} is claimable now`);
|
||||
const ends = claimEnds(c.client, c.acc);
|
||||
const { claimed, received } = await withAccountInFlight(c.acc.id, () => c.client.claimProfit!(c.acc.id, amount));
|
||||
const stage = afterClaim('[claim-now]', c.firm, c.client, c.acc, claimed, received, currentTradeDateCT());
|
||||
const stage = afterClaim('[claim-now]', c.firm, c.client, c.acc, claimed, received, tradeDateFor(c.client), ends);
|
||||
return { claimed, received, stage };
|
||||
} finally {
|
||||
releaseTradeMutex(lease, lane);
|
||||
@@ -2142,7 +2203,6 @@ export async function claimAccountNow(accountId: number, amount: number): Promis
|
||||
/** Returns true if any configured account could still trade today (eligible for a next leg / extra-day, or has an open position). */
|
||||
function hasRemainingConfiguredAccounts(): boolean {
|
||||
const { ready, unready } = firmClients();
|
||||
const tradeDate = currentTradeDateCT();
|
||||
|
||||
// A client that isn't ready can't say whether its accounts are done — its
|
||||
// snapshot is missing or stale — so while one might hold any of ours, the
|
||||
@@ -2175,7 +2235,7 @@ function hasRemainingConfiguredAccounts(): boolean {
|
||||
// Order being placed right now = still in play (evaluation calls it ineligible)
|
||||
if (runtime().accountsInFlight.has(acc.id)) return true;
|
||||
|
||||
const ev = evaluateAccountForTrade(client, acc, tradeDate);
|
||||
const ev = evaluateAccountForTrade(client, acc, tradeDateFor(client));
|
||||
if (ev?.eligible) return true;
|
||||
// Waiting on its history (or its day-start balance), not done: it may
|
||||
// still owe today's trade — during the session and before it opens
|
||||
|
||||
@@ -2,6 +2,7 @@ import Database from 'better-sqlite3';
|
||||
import { mkdirSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { sortableIso, stampRulesDate } from './kept-rules';
|
||||
import type { ClaimedCycle } from './trading-logic';
|
||||
|
||||
// AUTOFIRMER_DB lets several instances share one checkout, each with its own
|
||||
// database. It is read once, at module load — schema creation and seeding are
|
||||
@@ -556,6 +557,29 @@ export function loadEndedCycleStart(accountId: number): { fundDate: string; incl
|
||||
}
|
||||
}
|
||||
|
||||
/** The completed cycles this instance's own claims ended (trading-logic
|
||||
* claimedCycle), oldest first — saved by the claim (auto-trade afterClaim),
|
||||
* since it can end one before any report sees it complete; the reporter
|
||||
* names each for the catch-up's week. Gone with a wipe, as the claims
|
||||
* themselves are. */
|
||||
export function saveClaimedCycles(accountId: number, list: ClaimedCycle[]): void {
|
||||
saveAccountMeta(accountId, 'claimed_cycles', JSON.stringify(list));
|
||||
}
|
||||
|
||||
export function loadClaimedCycles(accountId: number): ClaimedCycle[] {
|
||||
const raw = loadAccountMeta(accountId, 'claimed_cycles');
|
||||
if (raw === null) return [];
|
||||
try {
|
||||
const list = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(list)) return [];
|
||||
return list.filter((c): c is ClaimedCycle => typeof c?.date === 'string' && typeof c.cycle === 'string'
|
||||
&& Number.isInteger(c.stage) && typeof c.drain === 'boolean' && Number.isInteger(c.cycleSeq))
|
||||
.map(({ date, stage, drain, cycle, cycleSeq, at }) => ({ date, stage, drain, cycle, cycleSeq, ...(Number.isFinite(at) ? { at } : {}) }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes every cached row for an account — daily P&L, fund transactions and
|
||||
* meta — so the next report fetch rebuilds it from scratch. Rows older than
|
||||
* the report's 28-day window are gone for good; that is the point (stale
|
||||
@@ -684,6 +708,15 @@ export function markTrade(
|
||||
`).run(t.positionId, t.accountId, t.accountName, t.symbol, t.side, t.quantity, t.openPrice, t.openedAt, Date.now());
|
||||
}
|
||||
|
||||
/** Which of these positions this instance's own order opened (markTrade). */
|
||||
export function botOpenedPositions(positionIds: string[]): Set<string> {
|
||||
if (positionIds.length === 0) return new Set();
|
||||
const rows = db.prepare(`SELECT position_id AS positionId FROM trades
|
||||
WHERE opened_by = 'bot' AND position_id IN (${positionIds.map(() => '?').join(', ')})`)
|
||||
.all(...positionIds) as { positionId: string }[];
|
||||
return new Set(rows.map((r) => r.positionId));
|
||||
}
|
||||
|
||||
/** Vest accounts with a trade the table still shows open (Vest's position
|
||||
* ids are bare numbers; the fill-built trades below carry a prefix). */
|
||||
export function openTradeAccounts(): { accountId: number; accountName: string }[] {
|
||||
|
||||
+16
-9
@@ -42,8 +42,8 @@ import { getAccountConfig } from './account-type-resolver';
|
||||
import { getCachedFirm } from './firm-catalog';
|
||||
import { firmInstruments } from './instruments';
|
||||
import type { SymbolMapCandidate } from './master-protocol';
|
||||
import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth, PickContext } from './platform-client';
|
||||
import { OrderNotPlacedError, isOrderNotPlaced, errorMessage } from './platform-client';
|
||||
import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth, PickContext, OrderIntent } from './platform-client';
|
||||
import { OrderNotPlacedError, isOrderNotPlaced, errorMessage, orderEffect } from './platform-client';
|
||||
import {
|
||||
dxAccountId, accountDisplayName, accountCodeOf, encodeContractName, decodeContractName, roundToStep,
|
||||
lastUtcReset, ctTradeDayStart, firmDayOf, timeOf, parseDurationMs, ciNum, isFundType, signedFundValue, newOrderCode,
|
||||
@@ -1557,10 +1557,10 @@ export class DXTradeClient implements PlatformClient {
|
||||
|
||||
/**
|
||||
* Place an order and wait for its fill: an OPEN when the account is flat,
|
||||
* a CLOSE of the position when it holds the opposite side. Adding to a
|
||||
* position is refused, and so is an OPEN against another account's
|
||||
* opposite position in the same symbol (no hedging). Resolves with the
|
||||
* fill `price`.
|
||||
* a CLOSE of the position when it holds the opposite side — only the one
|
||||
* `intent` names, when given (orderEffect). Adding to a position is
|
||||
* refused, and so is an OPEN against another account's opposite position
|
||||
* in the same symbol (no hedging). Resolves with the fill `price`.
|
||||
*/
|
||||
public async sendOrder(
|
||||
accountId: number,
|
||||
@@ -1569,9 +1569,10 @@ export class DXTradeClient implements PlatformClient {
|
||||
action: 'Buy' | 'Sell',
|
||||
orderType: 'Market' | 'Limit',
|
||||
price?: number,
|
||||
intent?: OrderIntent,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
return await this.sendOrderAndWait(accountId, contractSymbol, quantity, action, orderType, price);
|
||||
return await this.sendOrderAndWait(accountId, contractSymbol, quantity, action, orderType, price, intent);
|
||||
} catch (err) {
|
||||
if (!isOrderNotPlaced(err)) this.suspectAccounts.set(accountId, Date.now());
|
||||
throw err;
|
||||
@@ -1585,6 +1586,7 @@ export class DXTradeClient implements PlatformClient {
|
||||
action: 'Buy' | 'Sell',
|
||||
orderType: 'Market' | 'Limit',
|
||||
price?: number,
|
||||
intent?: OrderIntent,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const { account, symbol, step } = this.orderContext(accountId, contractSymbol);
|
||||
const side: 'BUY' | 'SELL' = action === 'Buy' ? 'BUY' : 'SELL';
|
||||
@@ -1594,8 +1596,13 @@ export class DXTradeClient implements PlatformClient {
|
||||
} catch (err) {
|
||||
throw new OrderNotPlacedError(`DXtrade position check failed — order not sent: ${errorMessage(err)}`);
|
||||
}
|
||||
const existing = positions.find((p) => p.symbol === symbol);
|
||||
if (existing && existing.side === side) throw new OrderNotPlacedError(`adding to an open ${symbol} position is not supported`);
|
||||
const held = positions.find((p) => p.symbol === symbol);
|
||||
const sideOf = (s: 'BUY' | 'SELL'): 'long' | 'short' => (s === 'BUY' ? 'long' : 'short');
|
||||
const mine = this.positionsDetail.get(accountId)?.find((p) => p.symbol === symbol);
|
||||
// orderEffect picks OPEN or CLOSE; `existing` is the position a CLOSE closes.
|
||||
const existing = orderEffect({
|
||||
symbol, held: held ? sideOf(held.side) : null, recorded: mine ? sideOf(mine.side) : null, side: sideOf(side), intent,
|
||||
}) === 'close' ? held : undefined;
|
||||
|
||||
const code = newOrderCode(existing ? 'x' : 'e');
|
||||
let body: Record<string, unknown>;
|
||||
|
||||
+18
-4
@@ -49,8 +49,11 @@ const COLD_RETRY_MAX_MS = 60_000;
|
||||
* first sync on a newer parser fetches the whole catalog.
|
||||
* 2: kept rule sets (keptAccountRules) and the firm's maxConcurrentAccounts.
|
||||
* Stamped only on a payload from a master that sends keptAccountRules: one
|
||||
* from an older master can't vouch for kept sets it never sent. */
|
||||
export const CATALOG_PARSER_VERSION = 2;
|
||||
* from an older master can't vouch for kept sets it never sent.
|
||||
* 3: Vest firms, which a build from before the platform skipped. A platform
|
||||
* learned later needs no bump: the cache names the platforms it skipped
|
||||
* (skippedPlatforms), and a build that knows one fetches in full. */
|
||||
export const CATALOG_PARSER_VERSION = 3;
|
||||
|
||||
/** A Date header further off than this is somebody else's clock, not the master's. */
|
||||
const MAX_MASTER_OFFSET_MS = 365 * 24 * 60 * 60 * 1000;
|
||||
@@ -103,6 +106,10 @@ function cache(): CatalogCache {
|
||||
const parser = (stored as { parser?: unknown }).parser;
|
||||
if (typeof parser === 'number') payload.parser = parser;
|
||||
else delete payload.parser;
|
||||
// Likewise the platforms it skipped: their firms aren't in the blob.
|
||||
const skipped = (stored as { skippedPlatforms?: unknown }).skippedPlatforms;
|
||||
if (Array.isArray(skipped)) payload.skippedPlatforms = skipped.filter((p): p is string => typeof p === 'string');
|
||||
else delete payload.skippedPlatforms;
|
||||
} catch {
|
||||
console.error('[catalog] cached payload is unreadable — ignoring it');
|
||||
}
|
||||
@@ -283,6 +290,8 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
|
||||
if (!Array.isArray(body.firms)) throw new Error('missing firms');
|
||||
|
||||
const firms: CatalogFirm[] = [];
|
||||
// Stamped on the payload, so a build that learns one refetches its firms.
|
||||
const skippedPlatforms = new Set<string>();
|
||||
body.firms.forEach((f: unknown, i: number) => {
|
||||
if (typeof f !== 'object' || f === null) throw new Error(`firm ${i} is not an object`);
|
||||
const firm = f as Record<string, unknown>;
|
||||
@@ -297,6 +306,7 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
|
||||
const platform = firm.platform === undefined ? 'tradovate' : firm.platform;
|
||||
if (!isFirmPlatform(platform)) {
|
||||
warnUnknownPlatform(firm.id, firm.name, platform);
|
||||
skippedPlatforms.add(String(platform));
|
||||
return;
|
||||
}
|
||||
// Percent commission and every-day trading are settings of the
|
||||
@@ -349,6 +359,7 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
|
||||
return {
|
||||
version: body.version, updatedAt: body.updatedAt, firms, ...(trading ? { trading } : {}),
|
||||
...(keptAware ? { parser: CATALOG_PARSER_VERSION } : {}),
|
||||
...(skippedPlatforms.size > 0 ? { skippedPlatforms: [...skippedPlatforms] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -424,10 +435,13 @@ export async function syncCatalog(): Promise<{ ok: boolean; changed: boolean; ve
|
||||
// `since` only against the master the cache came from: two masters'
|
||||
// version numbers can collide, and an 'unchanged' from the wrong one
|
||||
// would keep the other's catalog. Nor for a cache an older parser wrote:
|
||||
// it lacks what that parser dropped, and 'unchanged' would keep it so.
|
||||
// it lacks what that parser dropped, and 'unchanged' would keep it so —
|
||||
// fields, or firms on a platform it didn't know and this build does.
|
||||
const hasCache = getCachedCatalog().length > 0;
|
||||
const source = url.origin;
|
||||
const cacheCurrent = (cache().payload?.parser ?? 0) >= CATALOG_PARSER_VERSION;
|
||||
const cached = cache().payload;
|
||||
const cacheCurrent = (cached?.parser ?? 0) >= CATALOG_PARSER_VERSION
|
||||
&& !(cached?.skippedPlatforms ?? []).some((p) => isFirmPlatform(p));
|
||||
if (hasCache && cacheCurrent && getSetting('catalog_source_url') === source) url.searchParams.set('since', String(version));
|
||||
|
||||
try {
|
||||
|
||||
+19
-7
@@ -36,11 +36,19 @@ export interface AccountTypeStats {
|
||||
* cycle ready to withdraw, 'drain' = a drain stage's days booked;
|
||||
* cycle = the date of the fund transaction that started the cycle ('' for
|
||||
* the account's first cycle) — firm + name + cycle identify one
|
||||
* completed cycle. The list also carries caught-up entries: for up to 7
|
||||
* completed cycle, but where one day started several (Vest: claim,
|
||||
* trade again, claim again — a fund row holds the day's payouts):
|
||||
* cycleSeq = which of that day's cycles, 1 for the second and so on,
|
||||
* sent only when above 0 (trading-logic completionPlace). A master that
|
||||
* keys on it tells them apart; one that doesn't folds them into the
|
||||
* day's first. The list also carries caught-up entries: for up to 7
|
||||
* days after a fund transaction starts a new cycle, it includes the cycle
|
||||
* that just ended if that cycle had met the rule. Caught-up entries are
|
||||
* not counted in `completed`. Optional: older instances omit it. */
|
||||
completedAccounts?: { name: string; date: string; stage: number; kind: 'pass' | 'payout' | 'drain'; cycle: string }[];
|
||||
* that just ended if that cycle had met the rule — and, for 7 days after
|
||||
* it, each cycle the instance's own payout claim ended complete, dated
|
||||
* the claim's day and listed last, newest first (a claim can end one
|
||||
* before any report sees it). Caught-up and claimed entries are not
|
||||
* counted in `completed`. Optional: older instances omit it. */
|
||||
completedAccounts?: { name: string; date: string; stage: number; kind: 'pass' | 'payout' | 'drain'; cycle: string; cycleSeq?: number }[];
|
||||
/** Live accounts by trading days completed toward the current target:
|
||||
* {daysTraded: accountCount}. Feeds the master's payout calendar, so
|
||||
* accounts that are dead, DLL-locked, or in the red are left out — the
|
||||
@@ -109,9 +117,10 @@ export interface FirmStats {
|
||||
* instances omit it.
|
||||
*
|
||||
* withdrawals = how many times the balance has gone down for a payout
|
||||
* (negative fund transactions), which tells the master a completed
|
||||
* cycle's money is out. Omitted while the account's fund history hasn't
|
||||
* loaded, so a restart can't read as zero withdrawals.
|
||||
* (negative fund transactions; on Vest, each claim, though a day's share
|
||||
* one fund row), which tells the master a completed cycle's money is
|
||||
* out. Omitted while the account's fund history hasn't loaded, so a
|
||||
* restart can't read as zero withdrawals.
|
||||
*
|
||||
* The rest come from statusVersion builds (platform-client.ts
|
||||
* accountStatus), each left out when null or at its default. status:
|
||||
@@ -508,6 +517,9 @@ export interface FirmCatalogPayload {
|
||||
* payload. An older parser dropped fields it didn't know, so a cache
|
||||
* without it, or below the current one, isn't trusted to hold them. */
|
||||
parser?: number;
|
||||
/** Instance-local, like parser: the platforms of the firms that parser
|
||||
* skipped as unknown to it. A build that knows one fetches in full. */
|
||||
skippedPlatforms?: string[];
|
||||
}
|
||||
|
||||
export interface FirmCatalogUnchanged {
|
||||
|
||||
+84
-1
@@ -48,8 +48,16 @@ export interface PlatformContract {
|
||||
/** Margin posted per dollar of notional (0.2 = 5:1 leverage), where the
|
||||
* broker says — caps a position at available funds ÷ this. */
|
||||
marginRate?: number;
|
||||
/** When the instrument's market next closes for long (epoch ms; Vest: a
|
||||
* weekend or holiday, not the maintenance hour), where the platform
|
||||
* says: no leg starts that wouldn't be done by then (clearsClose), each
|
||||
* account's at its own size (fireLeg). */
|
||||
closesAt?: number;
|
||||
}
|
||||
|
||||
/** What an order is for, where the caller knows (see sendOrder). */
|
||||
export type OrderIntent = 'open' | 'close';
|
||||
|
||||
export interface PlatformClient {
|
||||
accountList: PlatformAccount[];
|
||||
/** realizedPnL is today's. pendingRoll (Tradovate): the broker still shows
|
||||
@@ -145,9 +153,20 @@ export interface PlatformClient {
|
||||
* before the platform's daily reset (resetClearStop).
|
||||
*/
|
||||
dayGainForRoom?(accountId: number, o: { leg: number; limit: number }): number;
|
||||
/** The trade date at `now` (epoch ms) that this platform's daily rows and
|
||||
* realized P&L follow, where its day turns at another hour than the
|
||||
* 5 PM CT roll (Vest: 20:00 ET). The engine plans the accounts and
|
||||
* counts their legs by it (tradeDateFor); absent = currentTradeDateCT. */
|
||||
tradeDate?(now: number): string;
|
||||
/** 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;
|
||||
/** Each payout taken from the account, by the day it counts on — one
|
||||
* entry per payout, where a fund row can hold several (Vest books a
|
||||
* whole day's claims as one); undefined until the platform's own record
|
||||
* of them has been read. Without it, each negative fund row is one, on
|
||||
* its date. */
|
||||
payoutDays?(accountId: number): string[] | undefined;
|
||||
/** 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;
|
||||
@@ -165,13 +184,20 @@ export interface PlatformClient {
|
||||
findFrontMonthContract(productName: string, pick?: PickContext): Promise<PlatformContract | null>;
|
||||
fetchInstrumentFees(symbols: string[]): Promise<{ [symbol: string]: number }>;
|
||||
|
||||
/** `intent`, where the caller knows what the order is for: an entry
|
||||
* ('open') must never close a position it didn't know was there, a
|
||||
* flatten ('close') must never open one on an account that turned out
|
||||
* flat. A platform that checks it (orderEffect) refuses the order
|
||||
* otherwise (NothingToCloseError for a close with nothing to close);
|
||||
* left out, or on a platform that doesn't check, the position decides. */
|
||||
sendOrder(
|
||||
accountId: number,
|
||||
contractSymbol: string,
|
||||
quantity: number,
|
||||
action: 'Buy' | 'Sell',
|
||||
orderType: 'Market' | 'Limit',
|
||||
price?: number
|
||||
price?: number,
|
||||
intent?: OrderIntent,
|
||||
): Promise<Record<string, unknown>>;
|
||||
|
||||
placeOrderNoWait(
|
||||
@@ -452,6 +478,63 @@ export function isOrderNotPlaced(err: unknown): boolean {
|
||||
return !!err && typeof err === 'object' && (err as { orderNotPlaced?: unknown }).orderNotPlaced === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A close-only order (sendOrder's 'close') refused because a fresh read
|
||||
* showed no position in the symbol to close, and the client's own record
|
||||
* agrees: the account is flat there, so a flatten that gets this has nothing
|
||||
* left to do — nothing rides without its stop.
|
||||
*/
|
||||
export class NothingToCloseError extends OrderNotPlacedError {
|
||||
readonly nothingToClose = true;
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'NothingToCloseError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Duck-typed, like isOrderNotPlaced. */
|
||||
export function isNothingToClose(err: unknown): boolean {
|
||||
return !!err && typeof err === 'object' && (err as { nothingToClose?: unknown }).nothingToClose === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* What an order on `side` does on a position-based platform (Vest, DXtrade),
|
||||
* given a fresh read of the account's position in the symbol (`held`: its
|
||||
* side, null when flat) and the client's own last record of it (`recorded`):
|
||||
* open, or close (or reduce) — or it throws why nothing is sent. With no
|
||||
* intent a flat account opens and the opposite side closes. An entry ('open')
|
||||
* never closes: a hand trade the engine didn't know about would be closed in
|
||||
* its place, and the leg's flatten would then open a position with no exits.
|
||||
* A flatten ('close') never opens, and a flat read is NothingToCloseError only
|
||||
* when the record agrees — one empty read just after a fill the client saw
|
||||
* may be the gateway lagging, and a leg riding without its stop must still be
|
||||
* shouted about. Adding to a position is never supported.
|
||||
*/
|
||||
export function orderEffect(o: {
|
||||
symbol: string;
|
||||
held: 'long' | 'short' | null;
|
||||
recorded: 'long' | 'short' | null;
|
||||
side: 'long' | 'short';
|
||||
intent?: OrderIntent;
|
||||
}): 'open' | 'close' {
|
||||
const { symbol, held, recorded, side, intent } = o;
|
||||
if (held === side) {
|
||||
throw new OrderNotPlacedError(intent === 'close'
|
||||
? `the account holds a ${held} ${symbol} position, not one to close — nothing sent`
|
||||
: `adding to an open ${symbol} position is not supported`);
|
||||
}
|
||||
if (held && intent === 'open') {
|
||||
throw new OrderNotPlacedError(`a ${held} ${symbol} position is already open — an entry never closes it, order not sent`);
|
||||
}
|
||||
if (!held && intent === 'close') {
|
||||
if (recorded && recorded !== side) {
|
||||
throw new OrderNotPlacedError(`a fresh read shows no ${symbol} position, but this client last saw a ${recorded} one open — nothing sent`);
|
||||
}
|
||||
throw new NothingToCloseError(`no ${symbol} position to close — nothing sent`);
|
||||
}
|
||||
return held ? 'close' : 'open';
|
||||
}
|
||||
|
||||
/** An error's message without the rest of it. AxiosErrors carry the request
|
||||
* config — Tradovate's login body (the password) and Bearer tokens — so they
|
||||
* must never be logged whole. */
|
||||
|
||||
+91
-14
@@ -11,7 +11,7 @@
|
||||
* POST keeps those working unchanged.
|
||||
*/
|
||||
|
||||
import { getSetting, loadEndedCycleStart } from './db';
|
||||
import { getSetting, loadClaimedCycles, loadEndedCycleStart } from './db';
|
||||
import { pushReport } from './master-link';
|
||||
import { getBuildVersion } from './version';
|
||||
import { tradeMixFor } from './trade-mix';
|
||||
@@ -20,7 +20,10 @@ import { getClients } from './clients';
|
||||
import { getAccountTypeById, getKeptRuleIdsForFirm, getTradableFirms } from './firm-catalog';
|
||||
import { getAccountConfig } from './account-type-resolver';
|
||||
import { getSchedulerStatus } from './auto-trade';
|
||||
import { botStopsActive, combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig, stageInputsFor } from './trading-logic';
|
||||
import {
|
||||
botStopsActive, combineCmeSessions, computeDayPlan, completionPlace, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON,
|
||||
projectPerfectPath, resolveEffectiveConfig, stageInputsFor, tradeDateFor, withinEndedCycleWindow,
|
||||
} from './trading-logic';
|
||||
import type { AccountTypeStats, FirmStats, InstrumentStats, SymbolStats } from './master-protocol';
|
||||
import { accountNextLeg, accountLegShape } from './leg-estimate';
|
||||
import { sizingHoldHours } from './person-size';
|
||||
@@ -33,9 +36,6 @@ function collectFirmStats() {
|
||||
// the master's table with rows for firms it does not run.
|
||||
const firms = getTradableFirms();
|
||||
const clients = getClients();
|
||||
// One raw trade date for the whole report: the combined views below roll
|
||||
// it forward, the completion check needs it as is.
|
||||
const rawToday = currentTradeDateCT();
|
||||
// projectPerfectPath's `loops` test: a type whose completed cycle keeps
|
||||
// going pays out (a funded account), one that stops just passes.
|
||||
const loops = (cfg: { payoutable?: boolean; withdrawalStages: unknown[] }): boolean =>
|
||||
@@ -49,6 +49,11 @@ function collectFirmStats() {
|
||||
const keptRuleIds = getKeptRuleIdsForFirm(f.id);
|
||||
// accounts: [] only once synced (see emptyFirmStats).
|
||||
if (!client || client.accountList.length === 0) return emptyFirmStats(f, client, keptRuleIds);
|
||||
// One raw trade date per firm, the one its accounts trade on
|
||||
// (tradeDateFor — Vest's turns at 20:00 ET, not 5 PM CT): the
|
||||
// combined views below roll it forward, the completion check needs
|
||||
// it as is.
|
||||
const rawToday = tradeDateFor(client);
|
||||
|
||||
let totalAccounts = 0;
|
||||
let accountsTraded = 0;
|
||||
@@ -90,6 +95,11 @@ function collectFirmStats() {
|
||||
}
|
||||
return entry;
|
||||
};
|
||||
// The completed cycles this instance's own claims ended, by slice
|
||||
// (listed after the accounts — see below).
|
||||
const claimedBySlice = new Map<AccountTypeStats, NonNullable<AccountTypeStats['completedAccounts']>>();
|
||||
/** When each claimed entry's claim was made, to list the newest first. */
|
||||
const claimedAt = new Map<object, number>();
|
||||
|
||||
for (const acc of client.accountList) {
|
||||
const reportedCash = client.accountCashBalances[acc.id];
|
||||
@@ -104,11 +114,22 @@ function collectFirmStats() {
|
||||
// standing, while a wrong zero would clear one early.
|
||||
const loadedFundTxns = client.fundTransactions?.[acc.id];
|
||||
const fundTxns = loadedFundTxns ?? [];
|
||||
// One per payout, on its day: a client whose fund rows hold a
|
||||
// whole day's (Vest: two claims the same day, one row) lists them
|
||||
// itself, and says nothing until it has read them — falling back
|
||||
// to the rows there would report fewer and then more again.
|
||||
const rowPayoutDays = (rows: { date: string; amount: number }[]) => rows.filter((f) => f.amount < 0).map((f) => f.date);
|
||||
const payoutDays = client.payoutDays
|
||||
? client.payoutDays(acc.id)
|
||||
: loadedFundTxns && rowPayoutDays(loadedFundTxns);
|
||||
const payoutDaysTaken = payoutDays ?? rowPayoutDays(fundTxns);
|
||||
const withdrawals = payoutDays?.length;
|
||||
const withdrawalsTaken = payoutDaysTaken.length;
|
||||
accounts.push({
|
||||
name: acc.name,
|
||||
size: cfg?.accountSize ?? 0,
|
||||
payoutable: cfg ? loops(cfg) : null,
|
||||
...(loadedFundTxns ? { withdrawals: loadedFundTxns.filter((f) => f.amount < 0).length } : {}),
|
||||
...(withdrawals !== undefined ? { withdrawals } : {}),
|
||||
// For the master's account ledger. Data only: nothing here
|
||||
// trades on it, and the totals below keep their own test.
|
||||
...statusFieldsOrUnknown(() => accountStatusFields({
|
||||
@@ -208,6 +229,8 @@ function collectFirmStats() {
|
||||
fundTransactions: fundTxns,
|
||||
balance: cash.amount,
|
||||
realizedPnL: cash.realizedPnL,
|
||||
rawTradeDate: rawToday,
|
||||
stageInputs: stageInputsFor(client, acc.id, cfg),
|
||||
payout: {
|
||||
withdrawalPct: cfg.withdrawalPct,
|
||||
payoutCap: cfg.payoutCap,
|
||||
@@ -247,7 +270,7 @@ function collectFirmStats() {
|
||||
maxDay: qualifying.length > 0 ? Math.max(...qualifying.map((d) => d.pnl)) : 0,
|
||||
sum,
|
||||
high: highWater,
|
||||
withdrawals: fundTxns.filter((f) => f.amount < 0).length,
|
||||
withdrawals: withdrawalsTaken,
|
||||
target: effective.profitTarget,
|
||||
consistency: effective.consistency,
|
||||
minDays: effective.minTradingDays,
|
||||
@@ -271,6 +294,7 @@ function collectFirmStats() {
|
||||
const ended = endedCycleCompletion({
|
||||
accountId: acc.id,
|
||||
rawTradeDate: rawToday,
|
||||
client,
|
||||
fullDailyPnL: client.fullDailyPnL[acc.id] ?? [],
|
||||
cycleDailyPnL: client.dailyPnL[acc.id],
|
||||
fundTransactions: fundTxns,
|
||||
@@ -281,16 +305,47 @@ function collectFirmStats() {
|
||||
cfg,
|
||||
});
|
||||
if (ended) {
|
||||
// Counted one per payout, as the final day counts this
|
||||
// cycle, so the two name it alike.
|
||||
const place = completionPlace({
|
||||
payoutDays: payoutDaysTaken, fundTransactions: fundTxns, cycle: ended.cycle,
|
||||
end: client.lastFundDates?.[acc.id] ?? undefined,
|
||||
});
|
||||
(slice.completedAccounts ??= []).push({
|
||||
name: acc.name,
|
||||
date: ended.date,
|
||||
stage: ended.stage,
|
||||
stage: place.stage,
|
||||
kind: completionKind(cfg, ended.drain),
|
||||
cycle: ended.cycle,
|
||||
...(place.cycleSeq > 0 ? { cycleSeq: place.cycleSeq } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// The completed cycles this instance's own claims ended
|
||||
// (afterClaim), named for the catch-up's week: a claim can end
|
||||
// one before any report saw it complete, where the catch-up
|
||||
// can't find it again (a Vest day's second claim, or one right
|
||||
// after the reset — trading-logic claimedCycle). Named only, as
|
||||
// the catch-up's: no counter moves.
|
||||
if (cfg) {
|
||||
for (const c of loadClaimedCycles(acc.id).reverse()) {
|
||||
if (!withinEndedCycleWindow(c.date, rawToday)) continue;
|
||||
let list = claimedBySlice.get(slice);
|
||||
if (!list) claimedBySlice.set(slice, list = []);
|
||||
const entry = {
|
||||
name: acc.name,
|
||||
date: c.date,
|
||||
stage: c.stage,
|
||||
kind: completionKind(cfg, c.drain),
|
||||
cycle: c.cycle,
|
||||
...(c.cycleSeq > 0 ? { cycleSeq: c.cycleSeq } : {}),
|
||||
};
|
||||
claimedAt.set(entry, c.at ?? 0);
|
||||
list.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
if (client.positions[acc.id]) {
|
||||
inTrade++;
|
||||
accountsTraded++;
|
||||
@@ -307,6 +362,7 @@ function collectFirmStats() {
|
||||
const completion = cycleCompletion({
|
||||
accountId: acc.id,
|
||||
rawTradeDate: rawToday,
|
||||
client,
|
||||
cycleDailyPnL: client.dailyPnL[acc.id],
|
||||
rawRealizedPnL: cash.realizedPnL,
|
||||
combineCmeHolidays: cfg.combineCmeHolidays,
|
||||
@@ -326,14 +382,18 @@ function collectFirmStats() {
|
||||
dayEntry.targetHit++;
|
||||
dayEntry.completed = (dayEntry.completed ?? 0) + 1;
|
||||
}
|
||||
// The fund transaction that started this cycle, and its
|
||||
// place among that day's payouts — the catch-up names the
|
||||
// same cycle by the same key.
|
||||
const cycle = client.lastFundDates?.[acc.id] ?? '';
|
||||
const place = completionPlace({ payoutDays: payoutDaysTaken, fundTransactions: fundTxns, cycle });
|
||||
(slice.completedAccounts ??= []).push({
|
||||
name: acc.name,
|
||||
date: combined.tradeDate,
|
||||
stage: 1 + fundTxns.filter((f) => f.amount < 0).length,
|
||||
stage: place.stage,
|
||||
kind: completionKind(cfg, effective.drain),
|
||||
// The fund transaction that started this cycle — the
|
||||
// catch-up names the same cycle by the same key.
|
||||
cycle: client.lastFundDates?.[acc.id] ?? '',
|
||||
cycle,
|
||||
...(place.cycleSeq > 0 ? { cycleSeq: place.cycleSeq } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -373,11 +433,11 @@ function collectFirmStats() {
|
||||
// A grind-day fill (dayTarget 0) can flag targetHit on a
|
||||
// negative day — a loss is still a loss, so red wins.
|
||||
// A red day on an account the bot puts stops on (the
|
||||
// obfuscation stops, or a hard daily loss limit) is its own
|
||||
// obfuscation stops, or its stage's hard daily loss limit) is its own
|
||||
// personal DLL, booked on purpose — not a genuine loss. (A
|
||||
// firm DLL lockout took the !acc.active branch above and
|
||||
// stays a loss.)
|
||||
if (botStopsActive(cfg) && combined.realizedPnL <= -25) {
|
||||
if (botStopsActive(cfg, effective) && combined.realizedPnL <= -25) {
|
||||
personalDllCount++;
|
||||
slice.personalDll = (slice.personalDll ?? 0) + 1;
|
||||
} else if (combined.realizedPnL < 0) {
|
||||
@@ -395,6 +455,23 @@ function collectFirmStats() {
|
||||
}
|
||||
}
|
||||
|
||||
// Claimed cycles go last in each slice's list, newest first: the
|
||||
// master reads only so many entries a slice (twice its accounts), so
|
||||
// what it has long had drops off before a final day, a catch-up or a
|
||||
// fresh claim. One those already name goes once.
|
||||
for (const [slice, claimed] of claimedBySlice) {
|
||||
const list = slice.completedAccounts ?? [];
|
||||
const key = (e: (typeof list)[number]) => `${e.name}\n${e.cycle}\n${e.cycleSeq ?? 0}`;
|
||||
const named = new Set(list.map(key));
|
||||
// Newest first across accounts: a day's ties go by the claim's time.
|
||||
for (const e of claimed.sort((a, b) => b.date.localeCompare(a.date) || (claimedAt.get(b) ?? 0) - (claimedAt.get(a) ?? 0))) {
|
||||
if (named.has(key(e))) continue;
|
||||
named.add(key(e));
|
||||
list.push(e);
|
||||
}
|
||||
if (list.length > 0) slice.completedAccounts = list;
|
||||
}
|
||||
|
||||
// Biggest shortfall first — the account types with the most left to do
|
||||
// are the ones worth reading, and Unassigned sinks to the bottom only
|
||||
// when it is genuinely small.
|
||||
|
||||
+243
-39
@@ -37,6 +37,19 @@ export interface StageInputs {
|
||||
dailyLossLimit?: number;
|
||||
stagesSameDay?: boolean;
|
||||
sameDayWithdrawals?: number;
|
||||
/** Every payout the account has made, one per payout, where a fund row
|
||||
* can hold several (PlatformClient.payoutDays — Vest books a day's
|
||||
* claims as one row). Absent = one per negative fund row. */
|
||||
payouts?: number;
|
||||
}
|
||||
|
||||
/** The payouts that pick an account's stage: today's for a type whose
|
||||
* stages reset each day, else every one — the client's own count when it
|
||||
* gives one (StageInputs.payouts), else the negative fund rows. */
|
||||
export function stagePayouts(fundTransactions: { date: string; amount: number }[], inputs: StageInputs = {}): number {
|
||||
if (inputs.stagesSameDay) return Math.max(0, Math.floor(inputs.sameDayWithdrawals ?? 0));
|
||||
if (typeof inputs.payouts === 'number' && Number.isFinite(inputs.payouts)) return Math.max(0, Math.floor(inputs.payouts));
|
||||
return fundTransactions.filter((f) => f.amount < 0).length;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,9 +86,7 @@ export function resolveEffectiveConfig(
|
||||
// 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;
|
||||
const withdrawalCount = stagePayouts(fundTransactions, inputs);
|
||||
if (withdrawalCount === 0) {
|
||||
return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false, dailyLossLimit }; // Stage 1 = base values
|
||||
}
|
||||
@@ -165,6 +176,18 @@ export function currentTradeDateCT(now: Date = new Date()): string {
|
||||
return new Date(date).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* The trade date a client's accounts are planned on and count their legs by:
|
||||
* the platform's own day where its daily rows and realized P&L follow one
|
||||
* that turns at another hour (Vest: 20:00 ET, two hours after the 5 PM CT
|
||||
* roll), else the Central trade date. Between the two rolls the Central date
|
||||
* would count the platform's still-open day both as a prior day and as
|
||||
* today's realized P&L, and start the day's leg count over mid-day.
|
||||
*/
|
||||
export function tradeDateFor(client: { tradeDate?: (now: number) => string } | null | undefined, now: Date = new Date()): string {
|
||||
return typeof client?.tradeDate === 'function' ? client.tradeDate(now.getTime()) : currentTradeDateCT(now);
|
||||
}
|
||||
|
||||
/** Drain-day target grid: $4–$15 in $0.50 steps (23 values, MNQ's
|
||||
* per-contract tick), always traded on MNQ. Drain days qualify at ≥
|
||||
* DRAIN_DAY_MIN, so every draw counts. Seeded per (account, trade date) like
|
||||
@@ -442,6 +465,11 @@ export function projectPerfectPath(opts: {
|
||||
fundTransactions: { date: string; amount: number }[];
|
||||
balance: number;
|
||||
realizedPnL: number;
|
||||
/** What picks the stage beyond the fund rows (stageInputsFor). A type
|
||||
* whose stages reset each day trades today on today's payouts and
|
||||
* every projected day from the base stage. Absent = every withdrawal
|
||||
* counts. */
|
||||
stageInputs?: StageInputs;
|
||||
/** Trading days to look ahead. */
|
||||
horizonDays?: number;
|
||||
maxWithdrawals?: number;
|
||||
@@ -463,6 +491,9 @@ export function projectPerfectPath(opts: {
|
||||
};
|
||||
/** Defaults to the current time. */
|
||||
now?: Date;
|
||||
/** The raw trade date the inputs follow (tradeDateFor — Vest's turns at
|
||||
* 20:00 ET); defaults to the Central one at `now`. */
|
||||
rawTradeDate?: string;
|
||||
}): ProjectedDay[] {
|
||||
const { accountId, cfg } = opts;
|
||||
const horizon = opts.horizonDays ?? 40;
|
||||
@@ -473,7 +504,7 @@ export function projectPerfectPath(opts: {
|
||||
const dd = Math.max(0, opts.payout?.drawdown ?? 0);
|
||||
const everyDay = cfg.tradesEveryDay === true;
|
||||
|
||||
const rawToday = currentTradeDateCT(opts.now);
|
||||
const rawToday = opts.rawTradeDate ?? currentTradeDateCT(opts.now);
|
||||
// The firm's own day view: a combining firm folds holiday-session P&L
|
||||
// into its clearing date — a Labor-Day trade and the Tuesday after it are
|
||||
// ONE trading day, exactly as the firm counts min days. Identity view
|
||||
@@ -528,17 +559,38 @@ export function projectPerfectPath(opts: {
|
||||
return room / (room + dayTarget);
|
||||
};
|
||||
|
||||
const effectiveNow = () => resolveEffectiveConfig(
|
||||
// A type whose stages reset each day (Vest) counts only the payouts made
|
||||
// since its day began: today's real ones plus the path's own on that
|
||||
// date, so every projected day starts on the base stage.
|
||||
const sameDay = opts.stageInputs?.stagesSameDay === true;
|
||||
/** The path's own payouts, by the date they are claimed on. */
|
||||
const paidOn = new Map<string, number>();
|
||||
/** The account's real payouts, one per payout (stagePayouts). */
|
||||
const paidReal = stagePayouts(opts.fundTransactions, { payouts: opts.stageInputs?.payouts });
|
||||
/** Payouts before the stage traded on `day`: that day's own for a
|
||||
* same-day type, else every withdrawal so far. */
|
||||
const paidBefore = (day: string): number => (sameDay
|
||||
? (day === today ? Math.max(0, Math.floor(opts.stageInputs?.sameDayWithdrawals ?? 0)) : 0) + (paidOn.get(day) ?? 0)
|
||||
: paidReal + withdrawals);
|
||||
/** A same-day type trades its next stage on the date a payout lands
|
||||
* (runClaims claims, then trades again on the room the claim freed) —
|
||||
* once through its stages a day: the last one repeats, and the path
|
||||
* stops there. */
|
||||
const tradesAgain = (day: string): boolean => sameDay
|
||||
&& (paidOn.get(day) ?? 0) > 0
|
||||
&& paidBefore(day) <= cfg.withdrawalStages.length;
|
||||
const effectiveOn = (day: string) => resolveEffectiveConfig(
|
||||
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity,
|
||||
cfg.withdrawalStages, 0, funds, cfg.minDayPnL,
|
||||
sameDay ? { stagesSameDay: true, sameDayWithdrawals: paidBefore(day) } : { payouts: paidBefore(day) },
|
||||
);
|
||||
/** The payout parameters of the stage currently being traded: per-stage
|
||||
/** The payout parameters of the stage being completed: per-stage
|
||||
* overrides when set, else the type defaults. */
|
||||
const stagePayout = (): { pct: number; cap: number } => {
|
||||
const count = funds.filter((f) => f.amount < 0).length;
|
||||
const stagePayout = (stage: number): { pct: number; cap: number } => {
|
||||
const count = stage - 1;
|
||||
if (count === 0 || cfg.withdrawalStages.length === 0) return { pct: basePct, cap: baseCap };
|
||||
const stage = cfg.withdrawalStages[Math.min(count - 1, cfg.withdrawalStages.length - 1)];
|
||||
return { pct: stage.withdrawalPct ?? basePct, cap: stage.payoutCap ?? baseCap };
|
||||
const s = cfg.withdrawalStages[Math.min(count - 1, cfg.withdrawalStages.length - 1)];
|
||||
return { pct: s.withdrawalPct ?? basePct, cap: s.payoutCap ?? baseCap };
|
||||
};
|
||||
const qualifyingDays = (minDay: number, excludeDate: string) => (
|
||||
minDay > 0
|
||||
@@ -550,7 +602,7 @@ export function projectPerfectPath(opts: {
|
||||
// Skipped when "today" is a weekend date (Saturday, or Friday evening
|
||||
// after the roll): there is no session to finish.
|
||||
if (todayHasSession) {
|
||||
const eff = effectiveNow();
|
||||
const eff = effectiveOn(today);
|
||||
const plan = computeDayPlan(
|
||||
accountId, today, eff.profitTarget, eff.consistency, sim, eff.minDayPnL,
|
||||
eff.minTradingDays, balance - cfg.accountSize, opts.realizedPnL, cfg.splitEnabled, eff.drain,
|
||||
@@ -579,7 +631,7 @@ export function projectPerfectPath(opts: {
|
||||
// over and over. Only a non-payoutable eval stops after one completion.
|
||||
const loops = cfg.withdrawalStages.length > 0 || cfg.payoutable === true;
|
||||
const finishCycle = (onDate: string, dayPnl: number, stage: number, drain: boolean): boolean => {
|
||||
const { pct, cap } = stagePayout();
|
||||
const { pct, cap } = stagePayout(stage);
|
||||
const rawGross = round2(Math.max(0, balance - cfg.accountSize) * pct);
|
||||
const gross = cap > 0 ? Math.min(rawGross, round2(cap)) : rawGross;
|
||||
const net = round2(gross * profitSplit);
|
||||
@@ -588,6 +640,9 @@ export function projectPerfectPath(opts: {
|
||||
balance = round2(balance - gross);
|
||||
sim = []; // withdrawal excludes its day — fresh cycle
|
||||
withdrawals++;
|
||||
// A cycle that stood complete before today is claimed today.
|
||||
const claimDay = onDate < today ? today : onDate;
|
||||
paidOn.set(claimDay, (paidOn.get(claimDay) ?? 0) + 1);
|
||||
high = Math.max(high, dd); // a payout locks the trail at breakeven
|
||||
out.push({ date: onDate, pnl: dayPnl, stage, drain, withdrawal: -gross, prob, gross, net });
|
||||
return false;
|
||||
@@ -601,14 +656,14 @@ export function projectPerfectPath(opts: {
|
||||
// requestable after that session's close. Never "today" — a projection
|
||||
// computed over the weekend must not slide a Friday finish to Sunday.
|
||||
{
|
||||
const eff = effectiveNow();
|
||||
const eff = effectiveOn(today);
|
||||
const probe = nextDay(today);
|
||||
const plan = computeDayPlan(
|
||||
accountId, probe, eff.profitTarget, eff.consistency, sim, eff.minDayPnL,
|
||||
eff.minTradingDays, balance - cfg.accountSize, 0, cfg.splitEnabled, eff.drain,
|
||||
);
|
||||
if (plan && plan.dayTarget <= 0 && eff.minTradingDays <= qualifyingDays(eff.minDayPnL, probe)) {
|
||||
const stage = 1 + funds.filter((f) => f.amount < 0).length;
|
||||
const stage = 1 + paidBefore(today);
|
||||
const traded = sim.filter((d) => d.pnl !== 0);
|
||||
const doneOn = traded.length > 0
|
||||
? traded.reduce((a, b) => (a.date > b.date ? a : b)).date
|
||||
@@ -617,11 +672,15 @@ export function projectPerfectPath(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
// A same-day type's next stage trades on the date its payout landed,
|
||||
// today's included; anything else moves on a day.
|
||||
let date = today;
|
||||
for (let step = 0; step < horizon; step++) {
|
||||
date = nextDay(date);
|
||||
const eff = effectiveNow();
|
||||
const stage = 1 + funds.filter((f) => f.amount < 0).length;
|
||||
let again = todayHasSession && tradesAgain(today);
|
||||
for (let step = 0; again || step < horizon;) {
|
||||
if (again) again = false;
|
||||
else { date = nextDay(date); step++; }
|
||||
const eff = effectiveOn(date);
|
||||
const stage = 1 + paidBefore(date);
|
||||
const plan = computeDayPlan(
|
||||
accountId, date, eff.profitTarget, eff.consistency, sim, eff.minDayPnL,
|
||||
eff.minTradingDays, balance - cfg.accountSize, 0, cfg.splitEnabled, eff.drain,
|
||||
@@ -634,6 +693,7 @@ export function projectPerfectPath(opts: {
|
||||
// The cycle completed mid-projection with nothing left to trade —
|
||||
// the pending payout lands on this day.
|
||||
if (finishCycle(date, 0, stage, eff.drain)) break;
|
||||
again = tradesAgain(date);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -647,7 +707,7 @@ export function projectPerfectPath(opts: {
|
||||
|
||||
// Did this day complete the cycle? Then it IS the payout day.
|
||||
const nd = nextDay(date);
|
||||
const eff2 = effectiveNow();
|
||||
const eff2 = effectiveOn(date);
|
||||
const plan2 = computeDayPlan(
|
||||
accountId, nd, eff2.profitTarget, eff2.consistency, sim, eff2.minDayPnL,
|
||||
eff2.minTradingDays, balance - cfg.accountSize, 0, cfg.splitEnabled, eff2.drain,
|
||||
@@ -657,6 +717,7 @@ export function projectPerfectPath(opts: {
|
||||
&& eff2.minTradingDays <= qualifyingDays(eff2.minDayPnL, nd);
|
||||
if (doneAfter) {
|
||||
if (finishCycle(date, pnl, stage, eff.drain)) break;
|
||||
again = tradesAgain(date);
|
||||
} else {
|
||||
out.push({ date, pnl, stage, drain: eff.drain, prob });
|
||||
}
|
||||
@@ -672,14 +733,22 @@ export type CycleCompletion = 'none' | 'today' | 'before';
|
||||
|
||||
/** How long a new trade date waits before it can read 'today': the platforms
|
||||
* reset realizedPnL on their own clock (a Tradovate cashBalance event, the
|
||||
* TopstepX 60s sync), not at 5 PM sharp, and until then the last session's
|
||||
* number — already booked under its own date — would count as a second day. */
|
||||
* TopstepX 60s sync, Vest's next sync after 20:00 ET), not at the roll
|
||||
* sharp, and until then the last session's number — already booked under
|
||||
* its own date — would count as a second day. */
|
||||
const ROLL_SETTLE_MS = 5 * 60 * 1000;
|
||||
|
||||
/** How long after a fund transaction starts a new cycle the cycle it ended is
|
||||
* still checked for a missed completion. */
|
||||
const ENDED_CYCLE_WINDOW_DAYS = 7;
|
||||
|
||||
/** Is `date` (a fund date, or a claimed cycle's — claimedCycle) within the
|
||||
* week its ended cycle is still named? False for an unparseable date. */
|
||||
export function withinEndedCycleWindow(date: string, rawTradeDate: string): boolean {
|
||||
const ageDays = (Date.parse(`${rawTradeDate}T00:00:00Z`) - Date.parse(`${date}T00:00:00Z`)) / (24 * 60 * 60 * 1000);
|
||||
return ageDays <= ENDED_CYCLE_WINDOW_DAYS; // NaN: false
|
||||
}
|
||||
|
||||
/** Nothing left to trade AND the days booked: with no min-day amount the
|
||||
* plan coasts at 0 while days are still owed, so count them too. */
|
||||
function cycleOwesNothing(
|
||||
@@ -704,13 +773,14 @@ function cycleOwesNothing(
|
||||
* weekend (it is the last session's stale number, already booked under its
|
||||
* own date), a Monday with a weekend-dated row — the live session filed under
|
||||
* a weekend date, which would count it twice — and the first minutes after
|
||||
* the 5 PM roll. An every-day firm's weekends are real sessions, so only the
|
||||
* roll window applies to it. */
|
||||
* the roll: the client's own (tradeDateFor), else 5 PM CT. An every-day
|
||||
* firm's weekends are real sessions, so only the roll window applies to it. */
|
||||
function sessionDoubtful(
|
||||
rawTradeDate: string,
|
||||
cycleDailyPnL: { date: string; pnl: number }[],
|
||||
now: Date,
|
||||
everyDay = false,
|
||||
client?: { tradeDate?: (now: number) => string },
|
||||
): boolean {
|
||||
const day = new Date(`${rawTradeDate}T00:00:00Z`);
|
||||
const dow = day.getUTCDay();
|
||||
@@ -721,7 +791,7 @@ function sessionDoubtful(
|
||||
if (cycleDailyPnL.some((d) => d.date === saturday || d.date === sunday)) return true;
|
||||
}
|
||||
// Settle window: a few minutes ago it was still an earlier trade date.
|
||||
return currentTradeDateCT(new Date(now.getTime() - ROLL_SETTLE_MS)) < rawTradeDate;
|
||||
return tradeDateFor(client, new Date(now.getTime() - ROLL_SETTLE_MS)) < rawTradeDate;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -734,14 +804,18 @@ function sessionDoubtful(
|
||||
* 'none' instead: a weekend (realizedPnL is the last session's stale number,
|
||||
* already booked under its own date), a Monday with a weekend-dated row — the
|
||||
* live session filed under a weekend date, which would count it twice — and
|
||||
* the first minutes after the 5 PM roll. 'before' skips those guards: it only
|
||||
* the first minutes after the roll. 'before' skips those guards: it only
|
||||
* keeps a locked, untraded pass out of the reporter's losses.
|
||||
*/
|
||||
export function cycleCompletion(opts: {
|
||||
accountId: number;
|
||||
/** currentTradeDateCT() — NOT the combined date, which rolls a weekend
|
||||
* forward to Monday. */
|
||||
/** tradeDateFor(client) — NOT the combined date, which rolls a weekend
|
||||
* forward to Monday. On a platform whose day turns later than 5 PM CT
|
||||
* (Vest), the Central date would count its open day twice. */
|
||||
rawTradeDate: string;
|
||||
/** The account's client: the settle window follows its day's roll
|
||||
* (tradeDateFor). */
|
||||
client?: { tradeDate?: (now: number) => string };
|
||||
/** Current-cycle daily P&L as the client holds it (client.dailyPnL). */
|
||||
cycleDailyPnL: { date: string; pnl: number }[];
|
||||
rawRealizedPnL: number;
|
||||
@@ -764,7 +838,7 @@ export function cycleCompletion(opts: {
|
||||
const prior = view.dailyPnL.filter((d) => d.date !== view.tradeDate);
|
||||
|
||||
if (cycleOwesNothing(accountId, eff, prior, view.tradeDate, equityProfit, view.realizedPnL, splitEnabled)) return 'before';
|
||||
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay)) return 'none';
|
||||
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay, opts.client)) return 'none';
|
||||
|
||||
const withToday = [...prior, { date: view.tradeDate, pnl: view.realizedPnL }];
|
||||
const probe = nextTradingDate(view.tradeDate, everyDay);
|
||||
@@ -772,6 +846,32 @@ export function cycleCompletion(opts: {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* A completed cycle's place among the account's payouts, counted one per
|
||||
* payout: `payoutDays` holds each payout's day once per payout
|
||||
* (PlatformClient.payoutDays — a Vest day's two claims share a fund row —
|
||||
* else the negative fund rows' dates). stage = 1 + the payouts before the
|
||||
* cycle's own: those before `end`, the fund date that ended it (the
|
||||
* catch-up), else every one (today's final day). cycleSeq = the payouts on
|
||||
* the cycle's start day (`cycle`, '' = the account's first) that came
|
||||
* before it, beyond the one whose fund row names that day — 0 but for a
|
||||
* cycle that a second payout the same day started, which `cycle` alone
|
||||
* can't tell from that day's first.
|
||||
*/
|
||||
export function completionPlace(o: {
|
||||
payoutDays: string[];
|
||||
fundTransactions: { date: string; amount: number }[];
|
||||
cycle: string;
|
||||
end?: string;
|
||||
}): { stage: number; cycleSeq: number } {
|
||||
const stage = 1 + o.payoutDays.filter((d) => o.end === undefined || d < o.end).length;
|
||||
if (o.cycle === '') return { stage, cycleSeq: 0 };
|
||||
// A deposit's day (Vest's activation) names the cycle it started; each
|
||||
// payout that day starts another.
|
||||
const named = o.fundTransactions.some((f) => f.date === o.cycle && f.amount < 0) ? 1 : 0;
|
||||
return { stage, cycleSeq: Math.max(0, o.payoutDays.filter((d) => d === o.cycle).length - named) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Catch-up for a missed final day: did the cycle the latest fund transaction
|
||||
* just ended stand complete — profit target, consistency and minimum days all
|
||||
@@ -799,8 +899,10 @@ export function cycleCompletion(opts: {
|
||||
*/
|
||||
export function endedCycleCompletion(opts: {
|
||||
accountId: number;
|
||||
/** currentTradeDateCT(). */
|
||||
/** tradeDateFor(client). */
|
||||
rawTradeDate: string;
|
||||
/** As in cycleCompletion. */
|
||||
client?: { tradeDate?: (now: number) => string };
|
||||
/** Every cycle's daily P&L, raw (client.fullDailyPnL). */
|
||||
fullDailyPnL: { date: string; pnl: number }[];
|
||||
/** Current-cycle daily P&L, raw (client.dailyPnL). */
|
||||
@@ -819,6 +921,8 @@ export function endedCycleCompletion(opts: {
|
||||
targetSameEquity: boolean; withdrawalStages: WithdrawalStage[];
|
||||
combineCmeHolidays: boolean; splitEnabled: boolean;
|
||||
tradesEveryDay?: boolean;
|
||||
/** Stages reset each day (StageInputs). */
|
||||
stagesSameDay?: boolean;
|
||||
};
|
||||
/** Defaults to the current time. */
|
||||
now?: Date;
|
||||
@@ -827,9 +931,8 @@ export function endedCycleCompletion(opts: {
|
||||
const everyDay = cfg.tradesEveryDay === true;
|
||||
const combine = effectiveCombine(cfg);
|
||||
if (lastFundDate === null || start === null) return null;
|
||||
const ageDays = (Date.parse(`${rawTradeDate}T00:00:00Z`) - Date.parse(`${lastFundDate}T00:00:00Z`)) / (24 * 60 * 60 * 1000);
|
||||
if (!(ageDays <= ENDED_CYCLE_WINDOW_DAYS)) return null; // NaN included: an unparseable date
|
||||
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay)) return null;
|
||||
if (!withinEndedCycleWindow(lastFundDate, rawTradeDate)) return null;
|
||||
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay, opts.client)) return null;
|
||||
|
||||
const latest = (a: string, b: string) => (b > a ? b : a);
|
||||
const earlierFunds = fundTransactions.filter((f) => f.date < lastFundDate);
|
||||
@@ -852,10 +955,14 @@ export function endedCycleCompletion(opts: {
|
||||
const fundAmt = fundTransactions.filter((f) => f.date === lastFundDate).reduce((s, f) => s + f.amount, 0);
|
||||
const equityBefore = round2(opts.equityProfit - curTotal - fundAmt);
|
||||
|
||||
// The stage the ended cycle traded on: only the withdrawals before it.
|
||||
// The stage the ended cycle traded on: only the withdrawals before it —
|
||||
// for a type whose stages reset each day, only that day's. One fund row
|
||||
// a day shows none before it, and the rows here hold that whole day,
|
||||
// which began on the base stage: it is judged there.
|
||||
const eff = resolveEffectiveConfig(
|
||||
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity,
|
||||
cfg.withdrawalStages, 0, earlierFunds, cfg.minDayPnL,
|
||||
cfg.stagesSameDay === true ? { stagesSameDay: true, sameDayWithdrawals: 0 } : {},
|
||||
);
|
||||
const rows = combineCmeSessions(combine, endedRaw, lastFundDate, 0).dailyPnL;
|
||||
// A combining firm's folded day can net to 0 — nothing traded, as the firm counts it.
|
||||
@@ -866,12 +973,83 @@ export function endedCycleCompletion(opts: {
|
||||
|
||||
return {
|
||||
date: traded.map((d) => d.date).reduce(latest),
|
||||
// The payout's number among the account's withdrawals, a same-day
|
||||
// type's too: the master clears the entry once that many have booked.
|
||||
stage: 1 + earlierFunds.filter((f) => f.amount < 0).length,
|
||||
drain: eff.drain,
|
||||
cycle: prevFundDate ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
/** A completed cycle a payout claim ended (claimedCycle). */
|
||||
export interface ClaimedCycle { date: string; stage: number; drain: boolean; cycle: string; cycleSeq: number; at?: number }
|
||||
|
||||
/**
|
||||
* The cycle a payout claim about to go out ends (PlatformClient.claimProfit),
|
||||
* when it stands complete — cycleCompletion's 'today' or 'before' on the
|
||||
* client's numbers just before the claim — named and numbered as the final
|
||||
* day names it (completionPlace), so the master dedupes the two. date = the
|
||||
* claim's trade date. Null for a claim short of the rule (a Claim now below
|
||||
* the target) or on numbers not read yet.
|
||||
*
|
||||
* Taken before the claim books its payout, and kept once it goes through
|
||||
* (auto-trade afterClaim). The claim starts a new cycle at once, and two of
|
||||
* the cycles it can end no report would name otherwise. A Vest day's second
|
||||
* cycle: the auto-claim at the next tick beats the 30 s report, and its
|
||||
* payout books into that day's fund row, so the latest fund date doesn't
|
||||
* move for the catch-up. And one claimed right after the 20:00 ET reset,
|
||||
* which puts it back on the first stage, whose target the day's leftover
|
||||
* profit meets: all of its P&L sits in the day row of the cycle before it,
|
||||
* so endedCycleCompletion reads none of it.
|
||||
*/
|
||||
export function claimedCycle(
|
||||
client: {
|
||||
accountCashBalances: { [accountId: number]: { amount: number; realizedPnL: number } | undefined };
|
||||
dailyPnL: { [accountId: number]: { date: string; pnl: number }[] | undefined };
|
||||
fundTransactions?: { [accountId: number]: { date: string; amount: number }[] | undefined };
|
||||
lastFundDates?: { [accountId: number]: string | null | undefined };
|
||||
priorProfit?: { [accountId: number]: number | undefined };
|
||||
tradeDate?: (now: number) => string;
|
||||
sameDayClaims?: (accountId: number) => number;
|
||||
payoutDays?: (accountId: number) => string[] | undefined;
|
||||
},
|
||||
accountId: number,
|
||||
cfg: {
|
||||
profitTarget: number; consistency: number; minTradingDays: number; minDayPnL: number; accountSize: number;
|
||||
targetSameEquity: boolean; withdrawalStages: WithdrawalStage[]; splitEnabled: boolean;
|
||||
combineCmeHolidays?: boolean; tradesEveryDay?: boolean; dailyLossLimit?: number; stagesSameDay?: boolean;
|
||||
},
|
||||
now: Date = new Date(),
|
||||
tolerance = 0,
|
||||
): ClaimedCycle | null {
|
||||
const cash = client.accountCashBalances[accountId];
|
||||
const cycleDailyPnL = client.dailyPnL[accountId];
|
||||
const fundTransactions = client.fundTransactions?.[accountId];
|
||||
if (!cash || !cycleDailyPnL || !fundTransactions) return null;
|
||||
const effective = resolveEffectiveConfig(
|
||||
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages,
|
||||
client.priorProfit?.[accountId] ?? 0, fundTransactions, cfg.minDayPnL, stageInputsFor(client, accountId, cfg),
|
||||
);
|
||||
// `tolerance`: how far short of the target the claim still ends the
|
||||
// cycle — the engine's day is done within LEG_COMPLETION_EPSILON of it.
|
||||
const judged = tolerance > 0 && effective.profitTarget > tolerance
|
||||
? { ...effective, profitTarget: effective.profitTarget - tolerance } : effective;
|
||||
const rawTradeDate = tradeDateFor(client, now);
|
||||
const completion = cycleCompletion({
|
||||
accountId, rawTradeDate, client, cycleDailyPnL, rawRealizedPnL: cash.realizedPnL,
|
||||
combineCmeHolidays: cfg.combineCmeHolidays === true, tradesEveryDay: cfg.tradesEveryDay === true,
|
||||
effective: judged, equityProfit: cash.amount - cfg.accountSize, splitEnabled: cfg.splitEnabled, now,
|
||||
});
|
||||
if (completion === 'none') return null;
|
||||
const cycle = client.lastFundDates?.[accountId] ?? '';
|
||||
const payoutDays = client.payoutDays?.(accountId) ?? fundTransactions.filter((f) => f.amount < 0).map((f) => f.date);
|
||||
const place = completionPlace({ payoutDays, fundTransactions, cycle });
|
||||
return {
|
||||
date: combineCmeSessions(effectiveCombine(cfg), [], rawTradeDate, 0).tradeDate,
|
||||
stage: place.stage, drain: effective.drain, cycle, cycleSeq: place.cycleSeq, at: now.getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the next trading day's profit target for an account.
|
||||
*
|
||||
@@ -1342,17 +1520,21 @@ export function dllReserve(dailyLossLimit: number, notional = 0, slippageFractio
|
||||
export const DLL_MIN_STOP = 50;
|
||||
|
||||
/** 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. */
|
||||
* type whose stages reset each day, the client's count of today's payouts —
|
||||
* for any other, of every payout, where the client lists them one by one
|
||||
* (payoutDays) and has read them. */
|
||||
export function stageInputsFor(
|
||||
client: { sameDayClaims?: (accountId: number) => number },
|
||||
client: { sameDayClaims?: (accountId: number) => number; payoutDays?: (accountId: number) => string[] | undefined },
|
||||
accountId: number,
|
||||
cfg: { dailyLossLimit?: number; stagesSameDay?: boolean },
|
||||
): StageInputs {
|
||||
const sameDay = cfg.stagesSameDay === true;
|
||||
const payouts = !sameDay && typeof client.payoutDays === 'function' ? client.payoutDays(accountId)?.length : undefined;
|
||||
return {
|
||||
dailyLossLimit: cfg.dailyLossLimit ?? 0,
|
||||
stagesSameDay: sameDay,
|
||||
sameDayWithdrawals: sameDay && typeof client.sameDayClaims === 'function' ? client.sameDayClaims(accountId) : 0,
|
||||
...(payouts !== undefined ? { payouts } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1387,6 +1569,23 @@ export function resetClearStop(o: { leg: number; resetInMs: number; holdHours: n
|
||||
return (o.leg * (c + Math.sqrt(c * c - 4))) / 2;
|
||||
}
|
||||
|
||||
/** Whether a leg of `holdHours` starting now is all but sure to be done
|
||||
* before its market closes `closeInMs` from now: the RESET_CLEAR_HOLDS
|
||||
* holds a daily reset is given (resetClearStop). A leg still open at the
|
||||
* close can't be exited until the market reopens, and meets the reopen's
|
||||
* gap with its stop. */
|
||||
export function clearsClose(o: { closeInMs: number; holdHours: number }): boolean {
|
||||
return o.closeInMs >= RESET_CLEAR_HOLDS * o.holdHours * 3_600_000;
|
||||
}
|
||||
|
||||
/** Why a leg of `holdHours` starting at `now` doesn't open: its market
|
||||
* closes at `closesAt` (epoch ms) before it clears (clearsClose). null when
|
||||
* it clears, or no close is known (a futures contract). */
|
||||
export function legCloseNote(closesAt: number | null | undefined, holdHours: number, now: number): string | null {
|
||||
if (closesAt === null || closesAt === undefined || clearsClose({ closeInMs: closesAt - now, holdHours })) return null;
|
||||
return `closes in ${((closesAt - now) / 3_600_000).toFixed(1)}h, under ${RESET_CLEAR_HOLDS} holds of ${holdHours.toFixed(1)}h`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -1536,10 +1735,15 @@ export function stopTicks(o: {
|
||||
}
|
||||
|
||||
/** Whether the bot places its own stops for this account type — the
|
||||
* obfuscation options or a hard daily loss limit. A red day on such an
|
||||
* account is the bot's own stop (the personal DLL), not a firm lockout. */
|
||||
export function botStopsActive(cfg: { firstDayStop: boolean; minDayStop: boolean; dailyLossLimit?: number }): boolean {
|
||||
return cfg.firstDayStop || cfg.minDayStop || (cfg.dailyLossLimit ?? 0) > 0;
|
||||
* obfuscation options or a hard daily loss limit: the current stage's
|
||||
* (resolveEffectiveConfig) when given, which a stage may set on a type with
|
||||
* none. A red day on such an account is the bot's own stop (the personal
|
||||
* DLL), not a firm lockout. */
|
||||
export function botStopsActive(
|
||||
cfg: { firstDayStop: boolean; minDayStop: boolean; dailyLossLimit?: number },
|
||||
effective?: { dailyLossLimit: number },
|
||||
): boolean {
|
||||
return cfg.firstDayStop || cfg.minDayStop || (effective ? effective.dailyLossLimit : cfg.dailyLossLimit ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+416
-91
@@ -38,24 +38,25 @@
|
||||
|
||||
import { randomUUID } from 'crypto';
|
||||
import axios from './http-client';
|
||||
import { currentTradeDateCT, rankForTarget, dllReserve, resetClearStop } from './trading-logic';
|
||||
import { currentTradeDateCT, rankForTarget, dllReserve, resetClearStop, legCloseNote } from './trading-logic';
|
||||
import { sizingHoldHours } from './person-size';
|
||||
import {
|
||||
saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions,
|
||||
saveEndedCycleStart, saveTrades, markTrade, getOrCreateSetting, TRADES_SINCE_SETTING, openTradeAccounts,
|
||||
saveEndedCycleStart, dropFundTransaction, saveTrades, markTrade, getOrCreateSetting, TRADES_SINCE_SETTING, openTradeAccounts, botOpenedPositions,
|
||||
} from './db';
|
||||
import { getCachedFirm } from './firm-catalog';
|
||||
import { firmInstruments } from './instruments';
|
||||
import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth, PickContext } from './platform-client';
|
||||
import { OrderNotPlacedError, isOrderNotPlaced, errorMessage } from './platform-client';
|
||||
import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth, PickContext, OrderIntent } from './platform-client';
|
||||
import { OrderNotPlacedError, isOrderNotPlaced, errorMessage, orderEffect } from './platform-client';
|
||||
import { encodeContractName, decodeContractName, roundToStep, scoreCandidate } from './dxtrade-helpers';
|
||||
import { vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from './vest-session';
|
||||
import {
|
||||
vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, nextVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder,
|
||||
parseTxn, bookTxns, awaitingLegs, unrealizedPnl, hourlyVolatility, marketOpen, num, toMs, formatDecimal, resetSafeStop,
|
||||
parseHistoryPosition, jwtClaimStrings, accountScopedToken, VEST_STATUS_ACTIVE, VEST_TXN, VEST_STOP_SLIPPAGE_FRACTION,
|
||||
parseTxn, bookTxns, awaitingLegs, unknownOpenFill, unrealizedPnl, hourlyVolatility, marketOpen, longClosure, num, toMs, formatDecimal, resetSafeStop,
|
||||
parseHistoryPosition, jwtClaimStrings, accountScopedToken, VEST_STATUS_ACTIVE, VEST_STOP_SLIPPAGE_FRACTION,
|
||||
VestApiError, VestNotSentError, nothingWentIn, tokenForRequest, settleClaims, readClaims, payoutDaysOf, payoutsSince,
|
||||
} from './vest-helpers';
|
||||
import type { VestCapitalAccount, VestPosition, VestOpenOrder, VestTxn, VestTrade } from './vest-helpers';
|
||||
import type { VestCapitalAccount, VestPosition, VestOpenOrder, VestTxn, VestTrade, OwnClaim } from './vest-helpers';
|
||||
|
||||
const BASE = 'https://api-gateway.hz.vestmarkets.com';
|
||||
/** The web app's origin: the gateway serves it, so requests say they are its. */
|
||||
@@ -103,7 +104,8 @@ const NAKED_AFTER_MS = 2 * 60_000;
|
||||
* candidate is costed as crossing this many ticks. */
|
||||
const ASSUMED_SPREAD_TICKS = 1;
|
||||
/** How long before the daily reset open positions are fitted to the room
|
||||
* the reset will leave (guardReset). */
|
||||
* the reset will leave (guardReset) — and before a market closure that runs
|
||||
* past one, the bot's own legs are closed (guardClose). */
|
||||
const RESET_GUARD_MS = 5 * 60_000;
|
||||
/** No new position opens this close to the reset: the guard's window, and a
|
||||
* couple of minutes before it for an entry in flight to place its exits. */
|
||||
@@ -111,31 +113,22 @@ const RESET_ENTRY_BLOCK_MS = RESET_GUARD_MS + 2 * 60_000;
|
||||
/** How long a stop or close the guard just sent counts before a read of
|
||||
* open positions shows it. */
|
||||
const GUARD_ECHO_MS = 30_000;
|
||||
/** An open of unknown outcome is the position opened between its send and
|
||||
* its error, widened this much for the gateway's clock against this
|
||||
* machine's (unknownOpenFill). */
|
||||
const UNKNOWN_OPEN_SLACK_MS = 5_000;
|
||||
/** The longest an open's POST can be out — an account token minted first,
|
||||
* a retry (request()) — for one a restart cut off with no error to time. */
|
||||
const OPEN_IN_FLIGHT_MS = 2 * 60_000;
|
||||
/** account_meta key of the profit claims this client made, per account. */
|
||||
const CLAIMS_META = 'vest_claims';
|
||||
/** A claim shows in Vest's transactions as a WITHDRAW of the same amount
|
||||
* within this long of it. */
|
||||
const CLAIM_MATCH_MS = 30 * 60_000;
|
||||
/** A claim of unknown outcome (the request failed in transit) that no
|
||||
* WITHDRAW matches in a history read this long after it never happened. */
|
||||
const UNKNOWN_CLAIM_EXPIRES_MS = 60 * 60_000;
|
||||
|
||||
/** A claim this client sent: the amount, when, and the balance it left.
|
||||
* unknown = the request failed in transit, so it may or may not have gone
|
||||
* in — it holds further claims, but nothing is booked on it. */
|
||||
interface OwnClaim { at: number; amount: number; base: number; unknown?: true }
|
||||
/** account_meta key of the open in flight, or of unknown outcome, per
|
||||
* account (savePendingOpen). */
|
||||
const PENDING_OPEN_META = 'vest_pending_open';
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||||
|
||||
/** The gateway answered with an error status. */
|
||||
class VestApiError extends Error {
|
||||
constructor(message: string, readonly status: number, readonly code: string | undefined) {
|
||||
super(message);
|
||||
this.name = 'VestApiError';
|
||||
}
|
||||
}
|
||||
|
||||
interface SymbolSpec {
|
||||
symbol: string;
|
||||
assetId: number;
|
||||
@@ -215,6 +208,30 @@ export class VestClient implements PlatformClient {
|
||||
noSessionLogged: false, unfittable: new Set<string>(),
|
||||
};
|
||||
private resetGuardInFlight = false;
|
||||
/** guardClose's closes just sent (position → when), the hand trades it
|
||||
* has named (position → the close they were named for), and when it
|
||||
* last said it couldn't read a symbol's hours or had no session. */
|
||||
private closeGuard = {
|
||||
closes: new Map<string, number>(), named: new Map<string, number>(), hoursWarnedAt: new Map<string, number>(), noSessionFor: 0,
|
||||
};
|
||||
private closeGuardInFlight = false;
|
||||
/** Opens whose outcome came back unknown, by account: the position one
|
||||
* made is the bot's once a read shows it (adoptUnknownOpen) — no exits
|
||||
* went on it, so it's the leg guardClose most needs to see. Kept in
|
||||
* account_meta too, from before the send (savePendingOpen), so a
|
||||
* restart takes them up again (restorePendingOpen). */
|
||||
private unknownOpens = new Map<number, {
|
||||
accountId: string; symbol: string; side: 'long' | 'short'; sentAt: number; from: number; until: number; positionId?: string;
|
||||
}>();
|
||||
/** Accounts whose open from before a restart has been looked for. */
|
||||
private pendingOpenRestored = new Set<number>();
|
||||
/** The sync generation current when each account's own open or close
|
||||
* last finished: a sync of that generation or older may have read
|
||||
* positions before the fill. */
|
||||
private ownOrderSeq = new Map<number, number>();
|
||||
/** When the last sync worked out the accounts' numbers (realizedSince's
|
||||
* `now`): the Vest day they hold (tradeDate). */
|
||||
private numbersAt = 0;
|
||||
private capital = new Map<number, VestCapitalAccount>();
|
||||
private positionsDetail = new Map<number, VestPosition[]>();
|
||||
private openOrders = new Map<number, VestOpenOrder[]>();
|
||||
@@ -350,8 +367,10 @@ export class VestClient implements PlatformClient {
|
||||
* none (`auth: false`, public data). Non-2xx answers become VestApiError
|
||||
* with the gateway's own code; transport failures a plain Error carrying
|
||||
* only the message — never the axios error, whose config holds the token.
|
||||
* A refused account token is re-minted once; a refused user token is
|
||||
* dropped, so nothing keeps knocking with it until a fresh one arrives.
|
||||
* An account token that can't be minted is VestNotSentError: the call
|
||||
* itself never went out. A refused account token is re-minted once; a
|
||||
* refused user token is dropped, so nothing keeps knocking with it until
|
||||
* a fresh one arrives.
|
||||
*/
|
||||
private async request<T>(
|
||||
bucket: Bucket,
|
||||
@@ -361,7 +380,8 @@ export class VestClient implements PlatformClient {
|
||||
): Promise<{ data: T }> {
|
||||
let token: string | null = null;
|
||||
if (opts.auth !== false) {
|
||||
token = opts.account ? await this.accountToken(opts.account) : this.userToken();
|
||||
const account = opts.account;
|
||||
token = account ? await tokenForRequest(path, () => this.accountToken(account)) : this.userToken();
|
||||
if (!token) throw new VestApiError(`${path}: no Vest session`, 401, 'NO_SESSION');
|
||||
}
|
||||
await this.gate(bucket);
|
||||
@@ -424,6 +444,7 @@ export class VestClient implements PlatformClient {
|
||||
const now = Date.now();
|
||||
if (this.syncInFlight && now - this.syncInFlightSince >= SYNC_WEDGED_MS) void this.requestSync();
|
||||
void this.guardReset().catch((err) => console.error(`${this.tag} reset guard failed: ${errorMessage(err)}`));
|
||||
void this.guardClose().catch((err) => console.error(`${this.tag} market-close guard failed: ${errorMessage(err)}`));
|
||||
}
|
||||
|
||||
// ── public market data ───────────────────────────────────────────────────
|
||||
@@ -583,6 +604,7 @@ export class VestClient implements PlatformClient {
|
||||
}
|
||||
this.uuidById.set(id, a.id);
|
||||
this.capital.set(id, a);
|
||||
this.restorePendingOpen(id, vestAccountName(a));
|
||||
if (a.status !== VEST_STATUS_ACTIVE && !this.unknownStatusLogged.has(`${a.id}:${a.status}`)) {
|
||||
this.unknownStatusLogged.add(`${a.id}:${a.status}`);
|
||||
console.warn(`${this.tag} ${vestAccountName(a)} has status ${a.status} — not trading it until that status is known`);
|
||||
@@ -591,9 +613,17 @@ export class VestClient implements PlatformClient {
|
||||
list.push(acc);
|
||||
|
||||
const mine = positions.filter((p) => p.accountId === a.id);
|
||||
this.positionsDetail.set(id, mine);
|
||||
// An open or close of this client's that finished while this
|
||||
// sync was out: what it read may predate the fill, and writing
|
||||
// it back would lose the position the order saw (or bring back
|
||||
// the one it closed) — the record a flatten goes by
|
||||
// (orderEffect). The order's own stands until a sync that
|
||||
// started after it.
|
||||
const ownSince = (this.ownOrderSeq.get(id) ?? -1) >= gen;
|
||||
if (!ownSince) this.positionsDetail.set(id, mine);
|
||||
this.openOrders.set(id, orders.filter((o) => o.accountId === a.id));
|
||||
this.applyPosition(acc, mine);
|
||||
if (!ownSince) this.applyPosition(acc, mine);
|
||||
this.adoptUnknownOpen(id, mine, startedAt);
|
||||
|
||||
const amount = free.get(a.id);
|
||||
if (amount === undefined) { complete = false; continue; }
|
||||
@@ -622,6 +652,7 @@ export class VestClient implements PlatformClient {
|
||||
this.lastFetchRaw[`floors:${acc.name}`] = `dd ${a.maxDrawdownLimit ?? '?'}, daily ${this.dailyFloorOf(a, balance, now) ?? 'none'}`
|
||||
+ `${a.targetEquity ? `, target ${a.targetEquity}` : ''}`;
|
||||
}
|
||||
this.numbersAt = now;
|
||||
const shrank = this.accountList.some((a) => !list.some((b) => b.id === a.id));
|
||||
this.accountList = list;
|
||||
if (shrank) void this.closeOutDropped();
|
||||
@@ -733,6 +764,26 @@ export class VestClient implements PlatformClient {
|
||||
return this.dailyFloorOf(a, cash.amount, Date.now()) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* See PlatformClient: Vest's own day (vestDayOf, 20:00 ET to 20:00 ET),
|
||||
* which its daily rows, realized P&L (realizedSince) and floors follow —
|
||||
* as of the last sync's numbers. Past 20:00 ET they still hold the day
|
||||
* that ended until a sync works them out again, and the fresh day would
|
||||
* count it as a prior day and as today's P&L both. A sync that stalls
|
||||
* holds the day back no longer than the scheduler trades on its numbers
|
||||
* (isClientReady). Before any sync, the day now.
|
||||
*/
|
||||
public tradeDate(now: number): string {
|
||||
return vestDayOf(this.numbersAt > 0 ? Math.min(now, this.numbersAt) : now);
|
||||
}
|
||||
|
||||
/** Past 8 PM ET with the numbers still the ended day's: a leg opened now
|
||||
* would be planned and counted on that day, so none opens until a sync
|
||||
* has read the new one. */
|
||||
private newDayUnread(now: number): boolean {
|
||||
return this.tradeDate(now) !== vestDayOf(now);
|
||||
}
|
||||
|
||||
/**
|
||||
* See PlatformClient: the gain since the day's base that is still in the
|
||||
* account — since this client's own claim today, else since the reset,
|
||||
@@ -743,7 +794,7 @@ export class VestClient implements PlatformClient {
|
||||
*/
|
||||
public dayGainForRoom(accountId: number, o: { leg: number; limit: number }): number {
|
||||
const now = Date.now();
|
||||
const hold = sizingHoldHours(getCachedFirm(this.cfg.firmId));
|
||||
const hold = sizingHoldHours(getCachedFirm(this.cfg.firmId), this.tradeDate(now));
|
||||
const allowed = resetClearStop({ leg: o.leg, resetInMs: nextVestResetMs(now) - now, holdHours: hold }) - o.limit;
|
||||
if (!(allowed > 0)) return 0;
|
||||
const a = this.capital.get(accountId);
|
||||
@@ -762,7 +813,11 @@ export class VestClient implements PlatformClient {
|
||||
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))
|
||||
.map((c): OwnClaim => ({ at: c.at, amount: c.amount, base: c.base, ...(c.unknown === true ? { unknown: true as const } : {}) }))
|
||||
.map((c): OwnClaim => ({
|
||||
at: c.at, amount: c.amount, base: c.base,
|
||||
...(Number.isFinite(c.sentAt) ? { sentAt: c.sentAt } : {}),
|
||||
...(c.unknown === true ? { unknown: true as const } : {}),
|
||||
}))
|
||||
: [];
|
||||
} catch {
|
||||
list = [];
|
||||
@@ -784,10 +839,21 @@ export class VestClient implements PlatformClient {
|
||||
saveAccountMeta(accountId, CLAIMS_META, JSON.stringify(kept));
|
||||
}
|
||||
|
||||
/** Vest's transactions show the claim: a WITHDRAW of its amount near it. */
|
||||
private claimShown(accountId: number, c: OwnClaim): boolean {
|
||||
return (this.withdrawals.get(accountId) ?? [])
|
||||
.some((w) => Math.abs(Math.abs(w.amount) - c.amount) < 0.01 && Math.abs(w.at - c.at) < CLAIM_MATCH_MS);
|
||||
/** The claims Vest's transactions, as last read (at `readAt`), don't
|
||||
* show yet. One of unknown outcome still not shown an hour on never went
|
||||
* in: it's dropped here (settleClaims). */
|
||||
private outstandingClaims(accountId: number, readAt: number): OwnClaim[] {
|
||||
const { pending, lapsed } = settleClaims(this.claimsOf(accountId), this.withdrawals.get(accountId) ?? [], readAt);
|
||||
this.forgetLapsed(accountId, lapsed);
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** Drop claims of unknown outcome that never went in (settleClaims). */
|
||||
private forgetLapsed(accountId: number, lapsed: OwnClaim[]): void {
|
||||
if (lapsed.length === 0) return;
|
||||
const name = this.accountList.find((a) => a.id === accountId)?.name ?? accountId;
|
||||
this.saveClaims(accountId, this.claimsOf(accountId).filter((c) => !lapsed.includes(c)));
|
||||
for (const c of lapsed) console.warn(`${this.tag} ${name}: the claim of $${c.amount} with an unknown outcome never showed on Vest — taken as not made`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -808,13 +874,22 @@ export class VestClient implements PlatformClient {
|
||||
}
|
||||
|
||||
/** 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). */
|
||||
* own claims, on Vest's day once its transactions show them, 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.madeClaims(accountId).filter((c) => c.at >= reset).length;
|
||||
const seen = (this.withdrawals.get(accountId) ?? []).filter((w) => w.at >= reset).length;
|
||||
return Math.max(mine, seen);
|
||||
return payoutsSince(this.withdrawals.get(accountId) ?? [], this.claimsOf(accountId), lastVestResetMs(Date.now()));
|
||||
}
|
||||
|
||||
/** See PlatformClient: one per payout, on its Vest day — Vest's WITHDRAW
|
||||
* transactions, and this client's claims they don't show yet
|
||||
* (payoutDaysOf) — where a fund row holds a whole Vest day's. Undefined
|
||||
* until the account's history is read. */
|
||||
public payoutDays(accountId: number): string[] | undefined {
|
||||
const withdrawals = this.withdrawals.get(accountId);
|
||||
const fundRows = this.fundTransactions[accountId];
|
||||
if (!withdrawals || !fundRows || !this.historyLoadedIds.has(accountId)) return undefined;
|
||||
return payoutDaysOf(withdrawals, this.claimsOf(accountId), fundRows);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -826,7 +901,8 @@ export class VestClient implements PlatformClient {
|
||||
* 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).
|
||||
* transactions (the same day's row, the same amount — or the day Vest
|
||||
* dates it, when a claim at 20:00 ET lands on the other side).
|
||||
*/
|
||||
public async claimProfit(accountId: number, amount: number): Promise<{ claimed: number; received: number | null }> {
|
||||
const uuid = this.uuidById.get(accountId);
|
||||
@@ -848,6 +924,10 @@ export class VestClient implements PlatformClient {
|
||||
throw new Error(`$${claimed} is more than the $${round2(cash.amount - meta.initialCapital)} above Vest's starting capital of $${meta.initialCapital}`);
|
||||
}
|
||||
|
||||
// Taken before it goes out, which Vest dates it no earlier than: a
|
||||
// claim answered after 20:00 ET isn't the fresh day's payout until
|
||||
// Vest says so (payoutsSince).
|
||||
const sentAt = Date.now();
|
||||
let data: { trader_amount?: unknown } | undefined;
|
||||
try {
|
||||
({ data } = await this.request<{ trader_amount?: unknown }>('trade', 'POST', '/v3/capital/withdraw', {
|
||||
@@ -860,11 +940,12 @@ export class VestClient implements PlatformClient {
|
||||
},
|
||||
}));
|
||||
} catch (err) {
|
||||
// Only a refusal proves nothing went in. A timeout, a reset or a
|
||||
// 5xx may have: hold every further claim on the account until
|
||||
// Vest's transactions say either way (claimPending).
|
||||
if (!(err instanceof VestApiError && err.status >= 400 && err.status < 500 && err.status !== 408)) {
|
||||
this.saveClaims(accountId, [...this.claimsOf(accountId), { at: Date.now(), amount: claimed, base: round2(cash.amount - claimed), unknown: true }]);
|
||||
// Only a refusal, or no account token to send it with, proves
|
||||
// nothing went in. A timeout, a reset or a 5xx may have: hold
|
||||
// every further claim on the account until Vest's transactions
|
||||
// say either way (claimPending).
|
||||
if (!nothingWentIn(err)) {
|
||||
this.saveClaims(accountId, [...this.claimsOf(accountId), { at: Date.now(), sentAt, amount: claimed, base: round2(cash.amount - claimed), unknown: true }]);
|
||||
this.claimSeq.set(accountId, this.syncGeneration);
|
||||
console.error(`${this.tag} ${account.name}: claim of $${claimed} — outcome unknown (${errorMessage(err)}); no further claim on it until Vest's transactions settle it`);
|
||||
void this.fetchDaysTraded([accountId]).catch(() => { /* the hourly read retries */ });
|
||||
@@ -873,7 +954,7 @@ export class VestClient implements PlatformClient {
|
||||
}
|
||||
const received = num(data?.trader_amount) ?? null;
|
||||
const now = Date.now();
|
||||
this.saveClaims(accountId, [...this.claimsOf(accountId), { at: now, amount: claimed, base: round2(cash.amount - claimed) }]);
|
||||
this.saveClaims(accountId, [...this.claimsOf(accountId), { at: now, sentAt, amount: claimed, base: round2(cash.amount - claimed) }]);
|
||||
this.claimSeq.set(accountId, this.syncGeneration);
|
||||
|
||||
// Book it now: today's fund row (with whatever else that day holds),
|
||||
@@ -900,15 +981,8 @@ export class VestClient implements PlatformClient {
|
||||
*/
|
||||
public claimPending(accountId: number): boolean {
|
||||
const now = Date.now();
|
||||
const readAt = this.historyReadAt.get(accountId) ?? 0;
|
||||
const list = this.claimsOf(accountId);
|
||||
const lapsed = list.filter((c) => c.unknown && !this.claimShown(accountId, c) && readAt - c.at > UNKNOWN_CLAIM_EXPIRES_MS);
|
||||
const pending = this.outstandingClaims(accountId, this.historyReadAt.get(accountId) ?? 0);
|
||||
const name = this.accountList.find((a) => a.id === accountId)?.name ?? accountId;
|
||||
if (lapsed.length > 0) {
|
||||
this.saveClaims(accountId, list.filter((c) => !lapsed.includes(c)));
|
||||
for (const c of lapsed) console.warn(`${this.tag} ${name}: the claim of $${c.amount} with an unknown outcome never showed on Vest — taken as not made`);
|
||||
}
|
||||
const pending = this.claimsOf(accountId).filter((c) => !this.claimShown(accountId, c));
|
||||
for (const c of pending) {
|
||||
const key = `${accountId}:${c.at}`;
|
||||
if (!c.unknown && now - c.at > 60 * 60_000 && !this.unmatchedClaimLogged.has(key)) {
|
||||
@@ -1058,6 +1132,184 @@ export class VestClient implements PlatformClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Before a long market closure — one that keeps a symbol's market shut
|
||||
* across an 8 PM ET reset: the weekend, a holiday (longClosure) — close
|
||||
* the bot's own legs in it still open in the last RESET_GUARD_MS, while
|
||||
* the market takes orders. Inside the closure nothing exits, the reset
|
||||
* guard's stops and closes are refused, and the reopen's gap jumps a
|
||||
* stop. No leg starts under RESET_CLEAR_HOLDS holds of a close
|
||||
* (pickCandidate), so this only meets one that outlived them.
|
||||
*
|
||||
* Only positions the bot's own order opened (markOwn, or an open of
|
||||
* unknown outcome that turned out filled: adoptUnknownOpen): one without
|
||||
* that mark — a hand trade, or one from before marking began — is left
|
||||
* as it is and named once in the log. The daily maintenance hour reopens
|
||||
* before the reset, and is ridden.
|
||||
*/
|
||||
private async guardClose(): Promise<void> {
|
||||
if (!this.syncComplete || this.intentionalDisconnect || this.closeGuardInFlight) return;
|
||||
const held = [...new Set([...this.positionsDetail.values()].flat().map((p) => p.symbol))];
|
||||
if (held.length === 0) return;
|
||||
this.closeGuardInFlight = true;
|
||||
try {
|
||||
const guard = this.closeGuard;
|
||||
const now = Date.now();
|
||||
for (const [id, at] of guard.closes) if (now - at >= GUARD_ECHO_MS) guard.closes.delete(id);
|
||||
for (const [id, closesAt] of guard.named) if (closesAt < now) guard.named.delete(id);
|
||||
// This runs all day while anything is open: hours it can't read
|
||||
// are said once in a while, not every tick.
|
||||
const unreadable = (what: string, err: unknown) => {
|
||||
if (now - (guard.hoursWarnedAt.get(what) ?? 0) < MARKET_HOURS_TTL_MS) return;
|
||||
guard.hoursWarnedAt.set(what, now);
|
||||
console.warn(`${this.tag} ${what}: market hours unreadable — can't tell whether its market is about to close (${errorMessage(err)})`);
|
||||
};
|
||||
let specs: Map<string, SymbolSpec>;
|
||||
try {
|
||||
specs = await this.symbolSpecs();
|
||||
} catch (err) {
|
||||
unreadable(held.join(', '), err);
|
||||
return;
|
||||
}
|
||||
// Each held symbol whose long closure is this near, and when.
|
||||
const closing = new Map<string, number>();
|
||||
for (const symbol of held) {
|
||||
const spec = specs.get(symbol);
|
||||
if (!spec) continue;
|
||||
let periods: unknown;
|
||||
try {
|
||||
periods = await this.marketHours(spec.assetId);
|
||||
} catch (err) {
|
||||
unreadable(symbol, err);
|
||||
continue;
|
||||
}
|
||||
const hours = marketOpen(periods, now);
|
||||
if (hours.open && hours.closesAt !== null && hours.closesAt - now <= RESET_GUARD_MS && longClosure(periods, hours.closesAt)) {
|
||||
closing.set(symbol, hours.closesAt);
|
||||
}
|
||||
}
|
||||
if (closing.size === 0) return;
|
||||
if (!vestSessionFor(this.wallet, now, USER_TOKEN_MIN_MS)) {
|
||||
const first = Math.min(...closing.values());
|
||||
if (guard.noSessionFor !== first) {
|
||||
guard.noSessionFor = first;
|
||||
console.error(`${this.tag} the market closes in ${Math.ceil((first - now) / 60_000)} min until past an 8 PM ET reset, and the bot's open legs can't be closed — no Vest session (open the Vest tab)`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const readAt = Date.now();
|
||||
const { data } = await this.request<{ positions?: unknown[] }>('trade', 'GET', '/v3/positions/opened');
|
||||
const all = (data?.positions ?? []).map(parsePosition).filter((p): p is VestPosition => p !== null);
|
||||
for (const id of [...this.unknownOpens.keys()]) this.adoptUnknownOpen(id, all, readAt);
|
||||
const open = all.filter((p) => closing.has(p.symbol));
|
||||
const mine = botOpenedPositions(open.map((p) => p.positionId));
|
||||
for (const acc of this.accountList) {
|
||||
const uuid = this.uuidById.get(acc.id);
|
||||
if (!uuid) continue;
|
||||
for (const pos of open.filter((p) => p.accountId === uuid)) {
|
||||
const closesAt = closing.get(pos.symbol)!;
|
||||
const inMin = Math.max(0, Math.ceil((closesAt - Date.now()) / 60_000));
|
||||
if (!mine.has(pos.positionId)) {
|
||||
if (guard.named.get(pos.positionId) !== closesAt) {
|
||||
guard.named.set(pos.positionId, closesAt);
|
||||
console.error(`${this.tag} ${acc.name}: ${pos.side} ${pos.symbol} isn't marked as the bot's (a hand trade, or a bot leg from before marking or a restart mid-entry) and stays open — its market closes in ${inMin} min until past an 8 PM ET reset; close it on Vest if it shouldn't carry the closure and the reopen's gap`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// A close just sent counts until a read of open positions shows it.
|
||||
if (guard.closes.has(pos.positionId)) continue;
|
||||
try {
|
||||
await this.submit(uuid, '/v3/positions/close', { positionId: pos.positionId, symbol: pos.symbol, orderType: 'market' });
|
||||
guard.closes.set(pos.positionId, Date.now());
|
||||
this.markOwn('closed', acc.id, pos);
|
||||
console.error(`${this.tag} ${acc.name}: CLOSED the bot's ${pos.side} ${pos.quantity} ${pos.symbol} at market — its market closes in ${inMin} min until past an 8 PM ET reset, and nothing could exit it there`);
|
||||
} catch (err) {
|
||||
console.error(`${this.tag} ${acc.name}: couldn't close the bot's ${pos.symbol} before its market closes in ${inMin} min: ${errorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.closeGuardInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** An open of unknown outcome that a read (started at `readAt`) shows
|
||||
* filled after all: the position is the bot's (markOwn). One the read
|
||||
* doesn't show by then never filled — a market open is IOC. Several
|
||||
* that could be it: none is marked, and it's said loudly. */
|
||||
private adoptUnknownOpen(accountId: number, positions: VestPosition[], readAt: number): void {
|
||||
const sent = this.unknownOpens.get(accountId);
|
||||
if (!sent) return;
|
||||
const found = unknownOpenFill(sent, positions);
|
||||
const name = this.accountList.find((a) => a.id === accountId)?.name ?? String(accountId);
|
||||
if (found.length === 1) {
|
||||
const pos = found[0];
|
||||
this.forgetUnknownOpen(accountId, sent.sentAt);
|
||||
this.markOwn('opened', accountId, pos);
|
||||
console.warn(`${this.tag} ${name}: the open of unknown outcome filled after all — ${pos.side} ${pos.quantity} ${pos.symbol} (${pos.positionId}) marked as the bot's`);
|
||||
} else if (found.length > 1) {
|
||||
this.forgetUnknownOpen(accountId, sent.sentAt);
|
||||
console.error(`${this.tag} ${name}: the ${sent.side} ${sent.symbol} open of unknown outcome could be any of ${found.length} positions opened with it`
|
||||
+ ` (${found.map((p) => p.positionId).join(', ')}) — none marked as the bot's, so none is closed before a market closure; check them on Vest`);
|
||||
} else if (readAt > sent.until) {
|
||||
this.forgetUnknownOpen(accountId, sent.sentAt);
|
||||
}
|
||||
}
|
||||
|
||||
private forgetUnknownOpen(accountId: number, sentAt: number): void {
|
||||
this.unknownOpens.delete(accountId);
|
||||
this.dropPendingOpen(accountId, sentAt);
|
||||
}
|
||||
|
||||
/** Note an open before it goes out, and what is learned of it (its
|
||||
* position id, an unknown outcome's window): a restart before its
|
||||
* outcome is known still finds the position it made. */
|
||||
private savePendingOpen(accountId: number, open: { accountId: string; symbol: string; side: 'long' | 'short'; sentAt: number; positionId?: string; until?: number }): void {
|
||||
// This run's own record from here: nothing from before to take up.
|
||||
this.pendingOpenRestored.add(accountId);
|
||||
try {
|
||||
saveAccountMeta(accountId, PENDING_OPEN_META, JSON.stringify(open));
|
||||
} catch (err) {
|
||||
console.warn(`${this.tag} couldn't note the open in flight: ${errorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** The open sent at `sentAt` has an outcome: its note goes — not a later
|
||||
* open's. */
|
||||
private dropPendingOpen(accountId: number, sentAt: number): void {
|
||||
try {
|
||||
const open = JSON.parse(loadAccountMeta(accountId, PENDING_OPEN_META) ?? 'null');
|
||||
if (open?.sentAt === sentAt) saveAccountMeta(accountId, PENDING_OPEN_META, 'null');
|
||||
} catch (err) {
|
||||
console.warn(`${this.tag} couldn't drop the note of an open: ${errorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** An open still out when the app last stopped (a restart mid-entry):
|
||||
* taken up as one of unknown outcome — its window the send to the most
|
||||
* its POST could be out, when no error timed it — once per account per
|
||||
* run. */
|
||||
private restorePendingOpen(accountId: number, name: string): void {
|
||||
if (this.pendingOpenRestored.has(accountId)) return;
|
||||
this.pendingOpenRestored.add(accountId);
|
||||
let open: Record<string, unknown> | null;
|
||||
try {
|
||||
open = JSON.parse(loadAccountMeta(accountId, PENDING_OPEN_META) ?? 'null');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!open || typeof open.accountId !== 'string' || typeof open.symbol !== 'string'
|
||||
|| (open.side !== 'long' && open.side !== 'short') || typeof open.sentAt !== 'number' || !Number.isFinite(open.sentAt)) return;
|
||||
const sentAt = open.sentAt;
|
||||
this.unknownOpens.set(accountId, {
|
||||
accountId: open.accountId, symbol: open.symbol, side: open.side, sentAt,
|
||||
from: sentAt - UNKNOWN_OPEN_SLACK_MS,
|
||||
until: typeof open.until === 'number' ? open.until : sentAt + OPEN_IN_FLIGHT_MS + UNKNOWN_OPEN_SLACK_MS,
|
||||
...(typeof open.positionId === 'string' ? { positionId: open.positionId } : {}),
|
||||
});
|
||||
console.warn(`${this.tag} ${name}: a ${open.side} ${open.symbol} open was out when the app last stopped — a position it made is marked the bot's once a read shows it`);
|
||||
}
|
||||
|
||||
private settleSuspects(): void {
|
||||
const now = Date.now();
|
||||
for (const [id, since] of this.suspectAccounts) {
|
||||
@@ -1331,13 +1583,23 @@ export class VestClient implements PlatformClient {
|
||||
.sort((x, y) => x.date.localeCompare(y.date));
|
||||
saveDailyPnL(account.id, account.name, fresh);
|
||||
|
||||
const fundRows = [...funds.entries()]
|
||||
.map(([date, amount]) => ({ date, amount: round2(amount) }))
|
||||
.filter((f) => f.amount !== 0)
|
||||
.sort((x, y) => x.date.localeCompare(y.date));
|
||||
this.applyFundRows(account, fundRows);
|
||||
this.withdrawals.set(account.id, txns.filter((t) => t.type === VEST_TXN.WITHDRAW).map((t) => ({ at: t.time, amount: t.amount })));
|
||||
this.historyReadAt.set(account.id, Date.now());
|
||||
const readAt = Date.now();
|
||||
const read = readClaims({
|
||||
txns, funds, claims: this.claimsOf(account.id), stored: loadFundTransactions(account.id), readAt,
|
||||
opening: hasDeposit ? 0 : a?.initialCapital ?? 0,
|
||||
});
|
||||
this.withdrawals.set(account.id, read.withdrawals);
|
||||
this.historyReadAt.set(account.id, readAt);
|
||||
// Settled here, not only in claimPending: its callers wait on
|
||||
// historyReady, which a claim that never went in would hold below
|
||||
// for good.
|
||||
this.forgetLapsed(account.id, read.lapsed);
|
||||
|
||||
// A claim Vest dates on the other side of 20:00 ET than it was booked
|
||||
// here leaves that day's row to this client alone: taken back, so the
|
||||
// payout counts once, on Vest's day.
|
||||
if (read.dropped.length > 0) this.dropClaimDays(account, read.dropped);
|
||||
this.applyFundRows(account, read.rows);
|
||||
|
||||
// Books check: the balance must be what the transactions add up to
|
||||
// (the opening capital included). A payout or credit booked under a
|
||||
@@ -1345,12 +1607,9 @@ export class VestClient implements PlatformClient {
|
||||
// cycle as if nothing happened.
|
||||
const cash = this.accountCashBalances[account.id];
|
||||
// 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;
|
||||
// yet: readClaims counts it until its WITHDRAW row does (same amount,
|
||||
// near time).
|
||||
const expected = read.expected;
|
||||
if (account.active && cash && !this.positions[account.id] && Math.abs(cash.amount - expected) >= BOOKS_TOLERANCE) {
|
||||
this.booksHeld.add(account.id);
|
||||
this.lastFetchErrors[account.name] = `balance ${round2(cash.amount)} vs transactions ${round2(expected)} (types ${types.join(',')}) — held until they agree`;
|
||||
@@ -1379,6 +1638,18 @@ export class VestClient implements PlatformClient {
|
||||
this.applyCycleSlice(account.id);
|
||||
}
|
||||
|
||||
/** Take back fund rows only a claim of this client's put there (see
|
||||
* claimFundRows), and the latest fund date with them when it was one —
|
||||
* back to the latest row left. The ended-cycle start that date saved
|
||||
* stays: the next fund day saves the same one. */
|
||||
private dropClaimDays(account: PlatformAccount, days: string[]): void {
|
||||
const stored = loadAccountMeta(account.id, 'last_fund_date');
|
||||
const left = loadFundTransactions(account.id).map((f) => f.date).filter((d) => !days.includes(d));
|
||||
const last = stored !== null && !days.includes(stored) ? stored : left.reduce<string | null>((x, y) => (x === null || y > x ? y : x), null);
|
||||
for (const day of days) dropFundTransaction(account.id, day, { last_fund_date: last });
|
||||
console.warn(`${this.tag} ${account.name}: the claim booked on ${days.join(', ')} shows on another Vest day — that row taken back`);
|
||||
}
|
||||
|
||||
private fallBackToCache(account: PlatformAccount, msg: string): void {
|
||||
const cached = loadDailyPnL(account.id);
|
||||
this.fundTransactions[account.id] ??= loadFundTransactions(account.id);
|
||||
@@ -1478,14 +1749,23 @@ export class VestClient implements PlatformClient {
|
||||
|
||||
private async pickCandidate(root: string, symbols: string[], sticky: boolean, ctx?: PickContext): Promise<PlatformContract | null> {
|
||||
const firm = getCachedFirm(this.cfg.firmId);
|
||||
const targetHold = sizingHoldHours(firm);
|
||||
const targetHold = sizingHoldHours(firm, this.tradeDate(Date.now()));
|
||||
const specs = await this.symbolSpecs();
|
||||
const known = symbols.filter((s) => specs.has(s));
|
||||
if (known.length > 0) await this.refreshMarks(known);
|
||||
const vols = await this.volatility(known);
|
||||
const now = Date.now();
|
||||
// A leg a long closure (a weekend, a holiday) catches open can't be
|
||||
// exited until the market reopens, and meets the reopen's gap with
|
||||
// its stop: none starts under RESET_CLEAR_HOLDS holds of one — the
|
||||
// daily reset's clearance. The daily maintenance hour reopens before
|
||||
// the reset and is ridden, as futures legs ride CME's daily break.
|
||||
// Such a symbol stays on the board (the dashboard's candidates) but
|
||||
// isn't picked (legCloseNote); fireLeg holds each account's own leg
|
||||
// to it too.
|
||||
const hold = ctx?.targetHoldHours ?? targetHold;
|
||||
|
||||
const board: { symbol: string; score: number | null; note: string; contract?: PlatformContract }[] = [];
|
||||
const board: { symbol: string; score: number | null; note: string; contract?: PlatformContract; closing?: boolean }[] = [];
|
||||
// No new position this close to the reset: nothing picked, and the
|
||||
// last survey's candidates stay up for the dashboard.
|
||||
const toReset = nextVestResetMs(now) - now;
|
||||
@@ -1493,14 +1773,21 @@ export class VestClient implements PlatformClient {
|
||||
console.log(`${this.tag} ${root} → nothing tradable — Vest's 8 PM ET reset in ${Math.ceil(toReset / 60_000)} min`);
|
||||
return null;
|
||||
}
|
||||
if (root !== 'survey' && this.newDayUnread(now)) {
|
||||
console.log(`${this.tag} ${root} → nothing tradable — Vest's day has turned and no sync has read it yet`);
|
||||
return null;
|
||||
}
|
||||
for (const symbol of symbols) {
|
||||
const spec = specs.get(symbol);
|
||||
if (!spec) { board.push({ symbol, score: null, note: 'unknown symbol' }); continue; }
|
||||
const mark = this.marks.get(symbol);
|
||||
const vol = vols.get(symbol);
|
||||
let hours: { open: boolean; closesAt: number | null };
|
||||
let gateAt: number | null;
|
||||
try {
|
||||
hours = marketOpen(await this.marketHours(spec.assetId), now);
|
||||
const periods = await this.marketHours(spec.assetId);
|
||||
hours = marketOpen(periods, now);
|
||||
gateAt = hours.closesAt !== null && longClosure(periods, hours.closesAt) ? hours.closesAt : null;
|
||||
} catch (err) {
|
||||
board.push({ symbol, score: null, note: `market hours unknown (${errorMessage(err)})` });
|
||||
continue;
|
||||
@@ -1524,12 +1811,14 @@ export class VestClient implements PlatformClient {
|
||||
volatilityPerHour: vol?.vol ?? null,
|
||||
});
|
||||
if (score === null) { board.push({ symbol, score: null, note: 'no volatility' }); continue; }
|
||||
const closing = legCloseNote(gateAt, hold, now);
|
||||
board.push({
|
||||
symbol,
|
||||
score,
|
||||
note: `fee ${(pct * 100).toFixed(2)}bp${vol?.vol ? `, σ ${(vol.vol * Math.sqrt(24) * 100).toFixed(2)}%/day` : ''}`
|
||||
+ (spec.marginRate ? `, ${+(1 / spec.marginRate).toFixed(1)}:1` : '')
|
||||
+ (hours.closesAt ? `, open ${((hours.closesAt - now) / 3_600_000).toFixed(1)}h more` : ''),
|
||||
+ (closing ? `, ${closing}` : hours.closesAt ? `, open ${((hours.closesAt - now) / 3_600_000).toFixed(1)}h more` : ''),
|
||||
closing: closing !== null,
|
||||
contract: {
|
||||
id: symbol,
|
||||
name: encodeContractName(symbol, step),
|
||||
@@ -1541,16 +1830,21 @@ export class VestClient implements PlatformClient {
|
||||
spreadFraction: spread,
|
||||
minContracts: 1,
|
||||
marginRate: spec.marginRate || undefined,
|
||||
closesAt: gateAt ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let ranked = board.filter((b) => b.contract).sort((a, b) => a.score! - b.score!);
|
||||
let ranked = board.filter((b) => b.contract && !b.closing).sort((a, b) => a.score! - b.score!);
|
||||
if (ctx) {
|
||||
const sized = ranked.flatMap((b) => {
|
||||
const ev = ctx.evaluate(b.contract!);
|
||||
if (!ev) { b.note += ', can\'t size'; return []; }
|
||||
b.note += `, $${Math.round(ev.notional / 1000)}k → ${ev.holdHours.toFixed(1)}h, ~$${Math.round(ev.cost)}`;
|
||||
// Held short of the target hold (a leverage cap), the leg
|
||||
// runs longer: it has to clear the close as well.
|
||||
const closing = legCloseNote(b.contract!.closesAt, ev.holdHours, now);
|
||||
if (closing) { b.note += `, ${closing}`; return []; }
|
||||
return [{ b, holdHours: ev.holdHours, cost: ev.cost }];
|
||||
});
|
||||
ranked = rankForTarget(sized, ctx.targetHoldHours).map((i) => sized[i].b);
|
||||
@@ -1605,10 +1899,11 @@ export class VestClient implements PlatformClient {
|
||||
|
||||
/**
|
||||
* Place an order and wait for its fill: an open when the account is flat,
|
||||
* a close (or reduce) of the position when it holds the opposite side.
|
||||
* Adding to a position is refused, and so is an open against another
|
||||
* account's opposite position in the same symbol (no hedging). Resolves
|
||||
* with the fill `price`.
|
||||
* a close (or reduce) of the position when it holds the opposite side —
|
||||
* only the one `intent` names, when given (orderEffect). Adding to a
|
||||
* position is refused, and so is an open against another account's
|
||||
* opposite position in the same symbol (no hedging). Resolves with the
|
||||
* fill `price`.
|
||||
*/
|
||||
public async sendOrder(
|
||||
accountId: number,
|
||||
@@ -1617,9 +1912,10 @@ export class VestClient implements PlatformClient {
|
||||
action: 'Buy' | 'Sell',
|
||||
orderType: 'Market' | 'Limit',
|
||||
price?: number,
|
||||
intent?: OrderIntent,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
return await this.sendOrderAndWait(accountId, contractSymbol, quantity, action, orderType, price);
|
||||
return await this.sendOrderAndWait(accountId, contractSymbol, quantity, action, orderType, price, intent);
|
||||
} catch (err) {
|
||||
if (!isOrderNotPlaced(err)) this.suspectAccounts.set(accountId, Date.now());
|
||||
throw err;
|
||||
@@ -1633,6 +1929,7 @@ export class VestClient implements PlatformClient {
|
||||
action: 'Buy' | 'Sell',
|
||||
orderType: 'Market' | 'Limit',
|
||||
price?: number,
|
||||
intent?: OrderIntent,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const { uuid, symbol, step, spec } = this.orderContext(accountId, contractSymbol);
|
||||
const side: 'long' | 'short' = action === 'Buy' ? 'long' : 'short';
|
||||
@@ -1642,8 +1939,10 @@ export class VestClient implements PlatformClient {
|
||||
} catch (err) {
|
||||
throw new OrderNotPlacedError(`Vest position check failed — order not sent: ${errorMessage(err)}`);
|
||||
}
|
||||
const existing = positions.find((p) => p.symbol === symbol);
|
||||
if (existing && existing.side === side) throw new OrderNotPlacedError(`adding to an open ${symbol} position is not supported`);
|
||||
const held = positions.find((p) => p.symbol === symbol);
|
||||
const recorded = this.positionsDetail.get(accountId)?.find((p) => p.symbol === symbol)?.side ?? null;
|
||||
// orderEffect picks open or close; `existing` is the position a close closes.
|
||||
const existing = orderEffect({ symbol, held: held?.side ?? null, recorded, side, intent }) === 'close' ? held : undefined;
|
||||
|
||||
const units = roundToStep(contracts * step, step);
|
||||
const qty = (n: number) => formatDecimal(n, spec.sizeDecimals);
|
||||
@@ -1675,6 +1974,9 @@ export class VestClient implements PlatformClient {
|
||||
if (toReset <= RESET_ENTRY_BLOCK_MS) {
|
||||
throw new OrderNotPlacedError(`Vest's 8 PM ET reset is ${Math.ceil(toReset / 60_000)} min away — no new position until it passes`);
|
||||
}
|
||||
if (this.newDayUnread(Date.now())) {
|
||||
throw new OrderNotPlacedError(`Vest's day has turned and no sync has read it yet — no new position until one has`);
|
||||
}
|
||||
const opposite = side === 'long' ? 'short' : 'long';
|
||||
const hedged = [...this.positionsDetail.values()].flat().some((p) => p.symbol === symbol && p.side === opposite)
|
||||
|| this.openingSides.get(symbol)?.side === opposite;
|
||||
@@ -1703,9 +2005,14 @@ export class VestClient implements PlatformClient {
|
||||
this.pendingFills++;
|
||||
const orderNumber = this.nextOrderNumber++;
|
||||
const sentAt = Date.now();
|
||||
// An open is noted before it goes out: a restart before its outcome
|
||||
// is known still finds the position it made (restorePendingOpen).
|
||||
if (opening) this.savePendingOpen(accountId, { accountId: uuid, symbol, side, sentAt });
|
||||
let placed: { orderId?: string; positionId?: string } | undefined;
|
||||
try {
|
||||
const placed = await this.submit(uuid, path, body);
|
||||
placed = await this.submit(uuid, path, body);
|
||||
console.log(`${this.tag} order ${placed.orderId ?? '?'}: ${side === 'long' ? 'BUY' : 'SELL'} ${unitsOrdered} ${symbol} ${orderType}${existing ? ' CLOSE' : ' OPEN'}`);
|
||||
if (opening && placed.positionId) this.savePendingOpen(accountId, { accountId: uuid, symbol, side, sentAt, positionId: placed.positionId });
|
||||
const fill = await this.waitForFill(uuid, placed, symbol, side, existing ?? null, unitsOrdered, sentAt);
|
||||
if (opening) {
|
||||
const pos = fill.position ?? {
|
||||
@@ -1716,10 +2023,13 @@ export class VestClient implements PlatformClient {
|
||||
this.positions[accountId] = {
|
||||
contractId: symbol, netPos: side === 'long' ? pos.quantity : -pos.quantity, netPrice: pos.openPrice, timestamp: new Date(),
|
||||
};
|
||||
this.ownOrderSeq.set(accountId, this.syncGeneration);
|
||||
this.markOwn('opened', accountId, pos);
|
||||
this.dropPendingOpen(accountId, sentAt);
|
||||
} else if (unitsOrdered >= existing.quantity - step / 2) {
|
||||
this.positionsDetail.set(accountId, []);
|
||||
delete this.positions[accountId];
|
||||
this.ownOrderSeq.set(accountId, this.syncGeneration);
|
||||
this.markOwn('closed', accountId, existing);
|
||||
}
|
||||
const result = {
|
||||
@@ -1728,6 +2038,21 @@ export class VestClient implements PlatformClient {
|
||||
for (const w of this.fillWaiters.filter((x) => x.orderId === orderNumber)) w.callback(result);
|
||||
this.fillWaiters = this.fillWaiters.filter((x) => x.orderId !== orderNumber);
|
||||
return result;
|
||||
} catch (err) {
|
||||
// An open that may have gone in: no exits will go on it, so the
|
||||
// position it made, once a read shows it, is marked the bot's —
|
||||
// the one its answer named, else one opened by now (IOC).
|
||||
if (opening && !isOrderNotPlaced(err)) {
|
||||
const open = {
|
||||
accountId: uuid, symbol, side, sentAt, until: Date.now() + UNKNOWN_OPEN_SLACK_MS,
|
||||
...(placed?.positionId ? { positionId: placed.positionId } : {}),
|
||||
};
|
||||
this.unknownOpens.set(accountId, { ...open, from: sentAt - UNKNOWN_OPEN_SLACK_MS });
|
||||
this.savePendingOpen(accountId, open);
|
||||
} else if (opening) {
|
||||
this.dropPendingOpen(accountId, sentAt);
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
this.pendingFills--;
|
||||
if (opening) {
|
||||
@@ -1739,9 +2064,10 @@ export class VestClient implements PlatformClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* POST an order. Only an answer that proves nothing was placed becomes
|
||||
* OrderNotPlacedError (a 4xx with the gateway's refusal); a transport
|
||||
* failure or a 5xx stays a plain Error — it may have gone in.
|
||||
* POST an order. Only a failure that proves nothing was placed becomes
|
||||
* OrderNotPlacedError (a 4xx with the gateway's refusal, or no account
|
||||
* token to send it with); a transport failure or a 5xx stays a plain
|
||||
* Error — it may have gone in.
|
||||
*/
|
||||
private async submit(uuid: string, path: string, body: Record<string, unknown>): Promise<{ orderId?: string; positionId?: string }> {
|
||||
try {
|
||||
@@ -1751,9 +2077,8 @@ export class VestClient implements PlatformClient {
|
||||
positionId: typeof data?.positionId === 'string' ? data.positionId : undefined,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof VestApiError && err.status >= 400 && err.status < 500 && err.status !== 408) {
|
||||
throw new OrderNotPlacedError(`Vest refused the order: ${err.message}`);
|
||||
}
|
||||
if (err instanceof VestNotSentError) throw new OrderNotPlacedError(`Vest order not sent: ${err.message}`);
|
||||
if (nothingWentIn(err)) throw new OrderNotPlacedError(`Vest refused the order: ${errorMessage(err)}`);
|
||||
throw new Error(`Vest order outcome unknown (it may still have been placed): ${errorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
+217
-8
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Pure helpers for the Vest client (lib/vest-class.ts) — parsing the gateway's
|
||||
* answers, naming accounts, Vest's trading day, market hours, volatility.
|
||||
* answers and errors, naming accounts, Vest's trading day, profit-claim
|
||||
* bookkeeping, market hours, volatility.
|
||||
*
|
||||
* Only Node built-ins are imported, so scripts/check-vest.mjs loads this file
|
||||
* directly (node --experimental-strip-types).
|
||||
@@ -52,6 +53,47 @@ export function formatDecimal(value: number, decimals: number): string {
|
||||
return d > 0 ? fixed.replace(/\.?0+$/, '') || '0' : fixed;
|
||||
}
|
||||
|
||||
// ── gateway errors ──────────────────────────────────────────────────────────
|
||||
|
||||
/** The gateway answered with an error status. */
|
||||
export class VestApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string | undefined;
|
||||
constructor(message: string, status: number, code: string | undefined) {
|
||||
super(message);
|
||||
this.name = 'VestApiError';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/** The request never went out: the account token it needed couldn't be had. */
|
||||
export class VestNotSentError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'VestNotSentError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a failed request proves nothing it asked for happened: it never
|
||||
* went out, or the gateway refused it (a 4xx, but not a timeout). A
|
||||
* transport failure or a 5xx may have gone in. */
|
||||
export function nothingWentIn(err: unknown): boolean {
|
||||
return err instanceof VestNotSentError
|
||||
|| (err instanceof VestApiError && err.status >= 400 && err.status < 500 && err.status !== 408);
|
||||
}
|
||||
|
||||
/** The account token a request needs, from `mint`. Any failure to mint one —
|
||||
* a timeout, a 5xx, an answer without a token — is the request not sent,
|
||||
* never an outcome that could have gone in. */
|
||||
export async function tokenForRequest(path: string, mint: () => Promise<string>): Promise<string> {
|
||||
try {
|
||||
return await mint();
|
||||
} catch (err) {
|
||||
throw new VestNotSentError(`${path} not sent — no account token: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Vest's trading day ──────────────────────────────────────────────────────
|
||||
|
||||
const ET_PARTS = new Intl.DateTimeFormat('en-US', {
|
||||
@@ -391,6 +433,27 @@ export function awaitingLegs(p: VestPosition): VestLeg[] {
|
||||
return [...p.stopLosses, ...p.takeProfits].filter((l) => l.status === 'awaiting');
|
||||
}
|
||||
|
||||
/**
|
||||
* The positions an open of unknown outcome (a timeout, a reset, a 5xx after
|
||||
* the send) may have made, as a read shows them — the bot's only when there
|
||||
* is exactly one. The one the open's answer named, when it named one. Else
|
||||
* on its account, in its symbol and side, with no exit legs (none went on
|
||||
* it), opened between `from` and `until` — the send and the error, widened
|
||||
* for the gateway's clock: a market open is IOC, so a later one (an outside
|
||||
* copy-trader's, which opens the same symbol on every account at once) isn't
|
||||
* it. More than one of those can't be told apart.
|
||||
*/
|
||||
export function unknownOpenFill(
|
||||
sent: { accountId: string; symbol: string; side: 'long' | 'short'; from: number; until: number; positionId?: string },
|
||||
positions: VestPosition[],
|
||||
): VestPosition[] {
|
||||
const mine = positions.filter((p) => p.accountId === sent.accountId);
|
||||
if (sent.positionId) return mine.filter((p) => p.positionId === sent.positionId);
|
||||
return mine.filter((p) => p.symbol === sent.symbol && p.side === sent.side
|
||||
&& p.stopLosses.length === 0 && p.takeProfits.length === 0
|
||||
&& p.openedAt >= sent.from && p.openedAt <= sent.until);
|
||||
}
|
||||
|
||||
export interface VestOpenOrder {
|
||||
orderId: string;
|
||||
accountId: string;
|
||||
@@ -502,6 +565,130 @@ export function bookTxns(txns: VestTxn[]): { daily: Map<string, { pnl: number; f
|
||||
return { daily, funds };
|
||||
}
|
||||
|
||||
// ── profit claims ───────────────────────────────────────────────────────────
|
||||
|
||||
/** A claim shows in Vest's transactions as a WITHDRAW of the same amount
|
||||
* within this long of it. */
|
||||
export const CLAIM_MATCH_MS = 30 * 60_000;
|
||||
/** A claim of unknown outcome (the request failed in transit) that no
|
||||
* WITHDRAW matches in a history read this long after it never happened. */
|
||||
export const UNKNOWN_CLAIM_EXPIRES_MS = 60 * 60_000;
|
||||
|
||||
/** A claim this client sent: the amount, when (at = once it was answered;
|
||||
* sentAt = before it went out, absent on claims saved before it was kept),
|
||||
* and the balance it left. unknown = the request failed in transit, so it
|
||||
* may or may not have gone in — it holds further claims, but nothing is
|
||||
* booked on it. */
|
||||
export interface OwnClaim { at: number; sentAt?: number; amount: number; base: number; unknown?: true }
|
||||
|
||||
/** A WITHDRAW transaction: when, and its (negative) amount. */
|
||||
export interface VestWithdrawal { at: number; amount: number }
|
||||
|
||||
/** The WITHDRAW that shows the claim — its amount, within CLAIM_MATCH_MS,
|
||||
* the nearest in time — or undefined while Vest's transactions don't. */
|
||||
export function claimWithdrawal(withdrawals: VestWithdrawal[], c: OwnClaim): VestWithdrawal | undefined {
|
||||
let best: VestWithdrawal | undefined;
|
||||
for (const w of withdrawals) {
|
||||
if (Math.abs(Math.abs(w.amount) - c.amount) >= 0.01 || Math.abs(w.at - c.at) >= CLAIM_MATCH_MS) continue;
|
||||
if (!best || Math.abs(w.at - c.at) < Math.abs(best.at - c.at)) best = w;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** The claims Vest's transactions, as read at `readAt`, don't show: those
|
||||
* still pending, and those that lapse — of unknown outcome and still not
|
||||
* shown an hour on, so never made. One Vest accepted never lapses. */
|
||||
export function settleClaims(claims: OwnClaim[], withdrawals: VestWithdrawal[], readAt: number): { pending: OwnClaim[]; lapsed: OwnClaim[] } {
|
||||
const pending: OwnClaim[] = [];
|
||||
const lapsed: OwnClaim[] = [];
|
||||
for (const c of claims) {
|
||||
if (claimWithdrawal(withdrawals, c)) continue;
|
||||
if (c.unknown && readAt - c.at > UNKNOWN_CLAIM_EXPIRES_MS) lapsed.push(c);
|
||||
else pending.push(c);
|
||||
}
|
||||
return { pending, lapsed };
|
||||
}
|
||||
|
||||
/**
|
||||
* The account's fund rows, one per Vest day: Vest's own (bookTxns' funds),
|
||||
* plus each claim this client made that the transactions don't show yet, on
|
||||
* the day it was booked. And the stored days to take back: a day a claim
|
||||
* was booked on that Vest's transactions leave empty, its WITHDRAW being
|
||||
* dated another day (the request spanned 20:00 ET, or the clocks differ) —
|
||||
* kept, the payout would count twice.
|
||||
*/
|
||||
export function claimFundRows(o: {
|
||||
funds: Map<string, number>; claims: OwnClaim[]; withdrawals: VestWithdrawal[]; stored: { date: string }[];
|
||||
}): { rows: { date: string; amount: number }[]; dropped: string[] } {
|
||||
const funds = new Map(o.funds);
|
||||
const made = o.claims.filter((c) => !c.unknown);
|
||||
for (const c of made) {
|
||||
if (claimWithdrawal(o.withdrawals, c)) continue;
|
||||
const day = vestDayOf(c.at);
|
||||
funds.set(day, (funds.get(day) ?? 0) - c.amount);
|
||||
}
|
||||
const rows = [...funds.entries()]
|
||||
.map(([date, amount]) => ({ date, amount: Math.round(amount * 100) / 100 }))
|
||||
.filter((f) => f.amount !== 0)
|
||||
.sort((x, y) => x.date.localeCompare(y.date));
|
||||
const claimDays = new Set(made.map((c) => vestDayOf(c.at)));
|
||||
const dropped = o.stored.map((f) => f.date).filter((d) => claimDays.has(d) && !rows.some((r) => r.date === d));
|
||||
return { rows, dropped };
|
||||
}
|
||||
|
||||
/**
|
||||
* What a history read makes of this client's claims (VestClient.bookHistory):
|
||||
* Vest's WITHDRAWs; the claims that lapse and those still outstanding
|
||||
* (settleClaims); the fund rows and the stored days to take back
|
||||
* (claimFundRows); and the balance the transactions add up to — `opening`
|
||||
* (the capital of an account Vest funded without a deposit row) included,
|
||||
* the claims they don't show yet taken out — that the books check holds the
|
||||
* account to. A lapsed claim is out of it: counted, one that never went in
|
||||
* would hold the account for good.
|
||||
*/
|
||||
export function readClaims(o: {
|
||||
txns: VestTxn[]; funds: Map<string, number>; claims: OwnClaim[]; stored: { date: string }[]; readAt: number; opening: number;
|
||||
}): {
|
||||
withdrawals: VestWithdrawal[]; lapsed: OwnClaim[]; outstanding: OwnClaim[];
|
||||
rows: { date: string; amount: number }[]; dropped: string[]; expected: number;
|
||||
} {
|
||||
const withdrawals = o.txns.filter((t) => t.type === VEST_TXN.WITHDRAW).map((t) => ({ at: t.time, amount: t.amount }));
|
||||
const { pending, lapsed } = settleClaims(o.claims, withdrawals, o.readAt);
|
||||
const { rows, dropped } = claimFundRows({ funds: o.funds, claims: o.claims.filter((c) => !lapsed.includes(c)), withdrawals, stored: o.stored });
|
||||
const unbooked = pending.reduce((s, c) => s + c.amount, 0);
|
||||
const expected = o.txns.reduce((s, t) => s + (t.kind === 'margin' ? 0 : t.amount), 0) + o.opening - unbooked;
|
||||
return { withdrawals, lapsed, outstanding: pending, rows, dropped, expected };
|
||||
}
|
||||
|
||||
/** Each payout taken from the account, by its Vest day, one entry per
|
||||
* payout: every WITHDRAW Vest's transactions show (claims made by hand
|
||||
* among them), and each claim of this client's they don't show yet, on the
|
||||
* day it was booked — a fund row can't say how many, it holds a whole Vest
|
||||
* day's. A day whose fund rows net negative with neither still counts once,
|
||||
* as it did before. Sorted. */
|
||||
export function payoutDaysOf(withdrawals: VestWithdrawal[], claims: OwnClaim[], fundRows: { date: string; amount: number }[]): string[] {
|
||||
const days = [
|
||||
...withdrawals.map((w) => vestDayOf(w.at)),
|
||||
...claims.filter((c) => !c.unknown && !claimWithdrawal(withdrawals, c)).map((c) => vestDayOf(c.at)),
|
||||
];
|
||||
for (const f of fundRows) if (f.amount < 0 && !days.includes(f.date)) days.push(f.date);
|
||||
return days.sort();
|
||||
}
|
||||
|
||||
/** Payouts since `since` (the last 20:00 ET): this client's claims — each at
|
||||
* its WITHDRAW's time once Vest shows it, Vest's day being the one it
|
||||
* counts on, and until then at when it was sent — or the WITHDRAWs Vest
|
||||
* shows, whichever counts more (a claim made by hand shows only there).
|
||||
* Sent, not answered: Vest dates it no earlier (clocks in sync), so a
|
||||
* claim answered just after 20:00 ET can't put the fresh day on a payout
|
||||
* stage before Vest says it's that day's; one Vest dates after the turn
|
||||
* reads as the day before until then, the cautious way round. */
|
||||
export function payoutsSince(withdrawals: VestWithdrawal[], claims: OwnClaim[], since: number): number {
|
||||
const mine = claims.filter((c) => !c.unknown && (claimWithdrawal(withdrawals, c)?.at ?? c.sentAt ?? c.at) >= since).length;
|
||||
const seen = withdrawals.filter((w) => w.at >= since).length;
|
||||
return Math.max(mine, seen);
|
||||
}
|
||||
|
||||
// ── market data ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Fewest hourly returns a volatility is measured from. */
|
||||
@@ -540,7 +727,19 @@ export function hourlyVolatility(klines: unknown[]): number | null {
|
||||
*/
|
||||
export function marketOpen(periods: unknown, now: number): { open: boolean; closesAt: number | null } {
|
||||
if (!Array.isArray(periods) || periods.length === 0) return { open: true, closesAt: null };
|
||||
const list = periods.flatMap((p) => {
|
||||
const list = hoursPeriods(periods);
|
||||
const i = list.findIndex((p) => p.from <= now && now < p.to);
|
||||
if (i < 0 || !list[i].open) return { open: false, closesAt: null };
|
||||
// Consecutive open periods (overnight → pre-market → regular …) are one stretch.
|
||||
let end = list[i].to;
|
||||
for (let j = i + 1; j < list.length && list[j].open && list[j].from <= end + 1; j++) end = list[j].to;
|
||||
return { open: true, closesAt: end };
|
||||
}
|
||||
|
||||
/** The market-hours periods, read and in order; open = trading, with no
|
||||
* closure (see marketOpen). */
|
||||
function hoursPeriods(periods: unknown[]): { from: number; to: number; open: boolean }[] {
|
||||
return periods.flatMap((p) => {
|
||||
const r = p as {
|
||||
fromMs?: unknown; toMs?: unknown;
|
||||
state?: { tradingStatus?: unknown; closure?: unknown; price?: { allow?: unknown } };
|
||||
@@ -553,10 +752,20 @@ export function marketOpen(periods: unknown, now: number): { open: boolean; clos
|
||||
&& (closure === undefined || closure === 'unspecified');
|
||||
return [{ from, to, open }];
|
||||
}).sort((a, b) => a.from - b.from);
|
||||
const i = list.findIndex((p) => p.from <= now && now < p.to);
|
||||
if (i < 0 || !list[i].open) return { open: false, closesAt: null };
|
||||
// Consecutive open periods (overnight → pre-market → regular …) are one stretch.
|
||||
let end = list[i].to;
|
||||
for (let j = i + 1; j < list.length && list[j].open && list[j].from <= end + 1; j++) end = list[j].to;
|
||||
return { open: true, closesAt: end };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the closure that starts at `closesAt` (marketOpen's) keeps the
|
||||
* market shut across one of Vest's 8 PM ET resets — a weekend, a holiday —
|
||||
* or for as far as the periods go. A position held into it can't be exited
|
||||
* or fitted to that reset until the market reopens, and meets the reopen's
|
||||
* gap with its stop; the daily maintenance hour reopens before the reset.
|
||||
* A close the periods don't show (they just end there) isn't one.
|
||||
*/
|
||||
export function longClosure(periods: unknown, closesAt: number): boolean {
|
||||
if (!Array.isArray(periods)) return false;
|
||||
const list = hoursPeriods(periods);
|
||||
if (!list.some((p) => p.to > closesAt)) return false;
|
||||
const reopensAt = list.find((p) => p.open && p.from >= closesAt)?.from;
|
||||
return reopensAt === undefined || nextVestResetMs(closesAt) < reopensAt;
|
||||
}
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs scripts/check-instruments.mjs scripts/check-account-status.mjs scripts/check-kept-rules.mjs scripts/check-request-user-agent.mjs scripts/check-vest.mjs scripts/check-trades.mjs"
|
||||
"check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs scripts/check-instruments.mjs scripts/check-account-status.mjs scripts/check-kept-rules.mjs scripts/check-request-user-agent.mjs scripts/check-vest.mjs scripts/check-vest-client.mjs scripts/check-trades.mjs scripts/check-catalog.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@microsoft/signalr": "^10.0.11",
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// Checks for the firm catalog sync in lib/firm-catalog.ts, against a stand-in
|
||||
// master on a local port and a throwaway database — never the live DB or the
|
||||
// real master.
|
||||
//
|
||||
// npm run check
|
||||
import { after, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { createServer } from 'node:http';
|
||||
import { once } from 'node:events';
|
||||
|
||||
// Resolve the extensionless local imports used by Next, without a build.
|
||||
function resolveTypes(specifier, context, nextResolve) {
|
||||
try {
|
||||
return nextResolve(specifier, context);
|
||||
} catch (err) {
|
||||
if (err.code !== 'ERR_MODULE_NOT_FOUND' || !specifier.startsWith('.')) throw err;
|
||||
return nextResolve(`${specifier}.ts`, context);
|
||||
}
|
||||
}
|
||||
registerHooks({ resolve: resolveTypes });
|
||||
|
||||
const directory = mkdtempSync(join(tmpdir(), 'autofirmer-catalog-'));
|
||||
process.env.AUTOFIRMER_DB = join(directory, 'test.sqlite');
|
||||
after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
|
||||
const { getSetting, setSetting } = await import('../lib/db.ts');
|
||||
const { syncCatalog, getCachedCatalog, getKeptRuleIdsForFirm, CATALOG_PARSER_VERSION } = await import('../lib/firm-catalog.ts');
|
||||
|
||||
const firm = (id, name, platform) => ({
|
||||
id, name, platform, keptAccountRules: [], updatedAt: '2026-10-26T19:00:00.000Z',
|
||||
accountTypes: [{
|
||||
id: `${id}-50k`, firmId: id, label: '50k', prefix: `${id.toUpperCase()}-`, updatedAt: '2026-10-26T19:00:00.000Z',
|
||||
accountSize: 50000, profitTarget: 3000, consistency: 0, minDayPnL: 0, minTradingDays: 0, maxLoss: 3000,
|
||||
maxPositionSize: 0, withdrawalStages: [],
|
||||
}],
|
||||
});
|
||||
const apex = firm('apex', 'Apex', 'tradovate');
|
||||
const vest = firm('vest', 'VestMarkets', 'vest');
|
||||
|
||||
// The master's catalog, answering ?since= as app/api/firms/catalog does, and
|
||||
// the `since` each sync sent (null = a full fetch).
|
||||
let served = { version: 41, updatedAt: '2026-10-26T19:20:00.000Z', firms: [apex, vest] };
|
||||
const sinceSent = [];
|
||||
const server = createServer((req, res) => {
|
||||
const since = new URL(req.url, 'http://master').searchParams.get('since');
|
||||
sinceSent.push(since);
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(since !== null && Number(since) === served.version
|
||||
? { version: served.version, updatedAt: served.updatedAt, unchanged: true }
|
||||
: served));
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
after(() => server.close());
|
||||
const master = `http://127.0.0.1:${server.address().port}`;
|
||||
|
||||
/** This machine's cache as some build stored it, read afresh as after a restart. */
|
||||
function storedCache(payload) {
|
||||
setSetting('master_dashboard_url', master);
|
||||
setSetting('catalog_source_url', master);
|
||||
setSetting('catalog_version', String(payload.version));
|
||||
setSetting('catalog_payload', JSON.stringify(payload));
|
||||
globalThis.__firmCatalog = undefined;
|
||||
sinceSent.length = 0;
|
||||
}
|
||||
|
||||
const names = () => getCachedCatalog().map((f) => f.name);
|
||||
|
||||
test('a cache a pre-Vest build stored is fetched in full by a build that knows Vest', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
// Parser 2 skipped the Vest firm and said nothing of it; the master's
|
||||
// catalog hasn't changed since, so ?since= would answer "unchanged".
|
||||
storedCache({ version: 41, updatedAt: served.updatedAt, firms: [apex], parser: 2 });
|
||||
assert.equal((await syncCatalog()).ok, true);
|
||||
assert.deepEqual(sinceSent, [null]);
|
||||
assert.deepEqual(names(), ['Apex', 'VestMarkets']);
|
||||
// Current from then on: the next sync asks ?since= again.
|
||||
await syncCatalog();
|
||||
assert.deepEqual(sinceSent, [null, '41']);
|
||||
});
|
||||
|
||||
test('a skipped platform is named in the cache, and a build that knows it fetches in full', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
// The master adds a firm on a platform this build doesn't know.
|
||||
served = { version: 42, updatedAt: '2026-10-27T15:00:00.000Z', firms: [apex, vest, firm('px', 'PX Firm', 'projectx')] };
|
||||
storedCache({ version: 41, updatedAt: '2026-10-26T19:20:00.000Z', firms: [apex, vest], parser: CATALOG_PARSER_VERSION });
|
||||
await syncCatalog();
|
||||
assert.deepEqual(names(), ['Apex', 'VestMarkets']);
|
||||
assert.deepEqual(JSON.parse(getSetting('catalog_payload')).skippedPlatforms, ['projectx']);
|
||||
// While this build still doesn't know it, syncs stay incremental, and the
|
||||
// cache still vouches for the kept rule sets of the firms it holds.
|
||||
globalThis.__firmCatalog = undefined;
|
||||
sinceSent.length = 0;
|
||||
await syncCatalog();
|
||||
assert.deepEqual(sinceSent, ['42']);
|
||||
assert.deepEqual(getKeptRuleIdsForFirm('apex'), []);
|
||||
// A build that knows the platform it skipped (Vest here) fetches in full.
|
||||
storedCache({ version: 42, updatedAt: served.updatedAt, firms: [apex], parser: CATALOG_PARSER_VERSION, skippedPlatforms: ['vest'] });
|
||||
await syncCatalog();
|
||||
assert.deepEqual(sinceSent, [null]);
|
||||
assert.ok(names().includes('VestMarkets'));
|
||||
});
|
||||
@@ -11,10 +11,11 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
percentFee, takeProfitPlan, onTick, obfuscationStopDollars, dllRoom, dllStopDollars, dllViable, stopTicks,
|
||||
botStopsActive, sizeNotional, isNoTradeTime, isPastSessionClose, nextTradingDate, effectiveCombine, DEFAULT_TARGET_HOLD_HOURS,
|
||||
projectPerfectPath, cycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, notionalCap, expectedSwapToWin, MARGIN_HEADROOM,
|
||||
projectPerfectPath, cycleCompletion, endedCycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, notionalCap, expectedSwapToWin, MARGIN_HEADROOM,
|
||||
dllReserve, DLL_SLIPPAGE_FRACTION, maxLossRoom, lastStand, breachDistance, LAST_STAND_FRACTION, planLegSize, rankForTarget,
|
||||
currentTradeDateCT, foldReason, withinFirmCaps, personSizeFactor, personHoldHours, PERSON_SIZE_SPREAD, machineSide,
|
||||
resolveEffectiveConfig, stageInputsFor, floorRoom, resetClearStop, shortestNextLeg, computeDayPlan,
|
||||
resolveEffectiveConfig, stageInputsFor, stagePayouts, floorRoom, resetClearStop, shortestNextLeg, computeDayPlan, clearsClose, RESET_CLEAR_HOLDS,
|
||||
legCloseNote,
|
||||
} from '../lib/trading-logic.ts';
|
||||
|
||||
// Deterministic PRNG so a failure reproduces.
|
||||
@@ -305,6 +306,22 @@ test('dllViable and botStopsActive', () => {
|
||||
assert.equal(botStopsActive({ firstDayStop: true, minDayStop: false, dailyLossLimit: 0 }), true);
|
||||
});
|
||||
|
||||
test('botStopsActive: the current stage\'s daily loss limit, which a stage may set on a type with none', () => {
|
||||
const cfg = { firstDayStop: false, minDayStop: false, dailyLossLimit: 0 };
|
||||
const stages = [{ profit: 3000, consistency: 0, minTradingDays: 0, dailyLossLimit: 3000 }];
|
||||
const paidOnce = [{ date: '2026-10-25', amount: -1500 }];
|
||||
// Stage 2 places its $3,000 stop: a red day there is the personal DLL.
|
||||
const stage2 = resolveEffectiveConfig(1500, 0, 0, false, stages, 0, paidOnce, 0, { dailyLossLimit: 0 });
|
||||
assert.equal(botStopsActive(cfg, stage2), true);
|
||||
// Stage 1 of the same type places none.
|
||||
assert.equal(botStopsActive(cfg, resolveEffectiveConfig(1500, 0, 0, false, stages, 0, [], 0, { dailyLossLimit: 0 })), false);
|
||||
// A stage that sets 0 lifts the type's limit.
|
||||
const lifted = resolveEffectiveConfig(1500, 0, 0, false, [{ ...stages[0], dailyLossLimit: 0 }], 0, paidOnce, 0, { dailyLossLimit: 1229 });
|
||||
assert.equal(botStopsActive({ ...cfg, dailyLossLimit: 1229 }, lifted), false);
|
||||
// The obfuscation stops count whatever the stage.
|
||||
assert.equal(botStopsActive({ ...cfg, minDayStop: true }, lifted), true);
|
||||
});
|
||||
|
||||
// ── sizing ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const btcSigma = 0.03 / Math.sqrt(24); // 3%/day
|
||||
@@ -527,6 +544,39 @@ test('resetClearStop: the widest stop a leg may carry and still close before the
|
||||
assert.ok(resetClearStop({ leg: 500, resetInMs: 16 * h, holdHours: 1.5 }) - 1400 > 2604.24);
|
||||
});
|
||||
|
||||
test('clearsClose: no leg starts under four holds of its market\'s close', () => {
|
||||
const h = 3_600_000;
|
||||
assert.equal(RESET_CLEAR_HOLDS, 4);
|
||||
// The weekly close 7.5 h off, legs sized for 2 h: no entry.
|
||||
assert.equal(clearsClose({ closeInMs: 7.5 * h, holdHours: 2 }), false);
|
||||
// Exactly four holds off, or more: the clearance the daily reset gets.
|
||||
assert.equal(clearsClose({ closeInMs: 8 * h, holdHours: 2 }), true);
|
||||
assert.equal(clearsClose({ closeInMs: 8 * h - 1, holdHours: 2 }), false);
|
||||
assert.equal(clearsClose({ closeInMs: 20 * h, holdHours: 2 }), true);
|
||||
// A leg its leverage cap holds to 3.5 h needs 14 h, where a 2 h one is clear.
|
||||
assert.equal(clearsClose({ closeInMs: 10 * h, holdHours: 3.5 }), false);
|
||||
// It agrees with resetClearStop, which gives no stop at all under four holds.
|
||||
for (const off of [5.9, 6, 6.1, 12]) {
|
||||
assert.equal(clearsClose({ closeInMs: off * h, holdHours: 1.5 }), resetClearStop({ leg: 500, resetInMs: off * h, holdHours: 1.5 }) > 0);
|
||||
}
|
||||
// An unknown hold never clears.
|
||||
assert.equal(clearsClose({ closeInMs: 20 * h, holdHours: NaN }), false);
|
||||
});
|
||||
|
||||
test('legCloseNote: each account\'s leg is held to its market\'s close at its own size', () => {
|
||||
const h = 3_600_000;
|
||||
const now = Date.parse('2026-10-30T12:30:00Z');
|
||||
const close = now + 9 * h;
|
||||
// The batch's lead leg, 2 h: clear. Another account's, sized to 3.5 h: not.
|
||||
assert.equal(legCloseNote(close, 2, now), null);
|
||||
assert.equal(legCloseNote(close, 3.5, now), 'closes in 9.0h, under 4 holds of 3.5h');
|
||||
// A contract that names no close (futures, crypto) never holds a leg back.
|
||||
assert.equal(legCloseNote(undefined, 3.5, now), null);
|
||||
assert.equal(legCloseNote(null, 3.5, now), null);
|
||||
// An unknown hold doesn't open into a known close.
|
||||
assert.notEqual(legCloseNote(close, NaN, now), null);
|
||||
});
|
||||
|
||||
test('planLegSize: a split day\'s next leg, with the day\'s gain on its stop, sizes up for the same hold', () => {
|
||||
// Dingo's 2026-10-27 evals: up $2,604.24 on a $1,400 day, $2,395.76 to
|
||||
// pass, NDX at ~$29.5k on Vest (1.31%/day), sized for 1.5 h.
|
||||
@@ -715,6 +765,39 @@ test('an every-day firm can finish its cycle on a Saturday', () => {
|
||||
assert.equal(cycleCompletion(opts), 'none');
|
||||
});
|
||||
|
||||
import { completionPlace } from '../lib/trading-logic.ts';
|
||||
|
||||
test('completionPlace: each payout counts, and a day\'s second cycle has a key of its own', () => {
|
||||
// Vest: activated on the 20th; claims c1 and c2 on the 27th share one
|
||||
// fund row; c3 on the 28th.
|
||||
const activated = { date: '2026-10-20', amount: 50000 };
|
||||
// Cycle A, the first, completes on the 27th ahead of any claim.
|
||||
assert.deepEqual(completionPlace({ payoutDays: [], fundTransactions: [activated], cycle: '2026-10-20' }), { stage: 1, cycleSeq: 0 });
|
||||
// c1 starts cycle B, which completes the same day.
|
||||
const b = completionPlace({ payoutDays: ['2026-10-27'], fundTransactions: [activated, { date: '2026-10-27', amount: -2250 }], cycle: '2026-10-27' });
|
||||
assert.deepEqual(b, { stage: 2, cycleSeq: 0 });
|
||||
// c2 starts cycle C on the same fund row, which completes on the 28th:
|
||||
// payout 3, and not B's key — the master would fold C into B.
|
||||
const both = [activated, { date: '2026-10-27', amount: -3750 }];
|
||||
const c = completionPlace({ payoutDays: ['2026-10-27', '2026-10-27'], fundTransactions: both, cycle: '2026-10-27' });
|
||||
assert.deepEqual(c, { stage: 3, cycleSeq: 1 });
|
||||
// C caught up after c3 ended it: the same stage and key as its final day.
|
||||
assert.deepEqual(completionPlace({
|
||||
payoutDays: ['2026-10-27', '2026-10-27', '2026-10-28'], fundTransactions: [...both, { date: '2026-10-28', amount: -1500 }],
|
||||
cycle: '2026-10-27', end: '2026-10-28',
|
||||
}), c);
|
||||
// One fund row per payout (every other platform): 1 + the rows before, no
|
||||
// cycleSeq — as the stage always read.
|
||||
const rows = [{ date: '2026-09-01', amount: -500 }, { date: '2026-09-15', amount: -800 }];
|
||||
const days = rows.map((f) => f.date);
|
||||
assert.deepEqual(completionPlace({ payoutDays: days, fundTransactions: rows, cycle: '2026-09-15' }), { stage: 3, cycleSeq: 0 });
|
||||
assert.deepEqual(completionPlace({ payoutDays: days, fundTransactions: rows, cycle: '2026-09-01', end: '2026-09-15' }), { stage: 2, cycleSeq: 0 });
|
||||
// The account's first cycle; and a claim on the day it was activated,
|
||||
// whose deposit row names the cycle it started.
|
||||
assert.deepEqual(completionPlace({ payoutDays: [], fundTransactions: [], cycle: '' }), { stage: 1, cycleSeq: 0 });
|
||||
assert.deepEqual(completionPlace({ payoutDays: ['2026-10-20'], fundTransactions: [{ date: '2026-10-20', amount: 47750 }], cycle: '2026-10-20' }), { stage: 2, cycleSeq: 1 });
|
||||
});
|
||||
|
||||
// ── DXTrade helpers (lib/dxtrade-helpers.ts) ─────────────────────────────────
|
||||
|
||||
import {
|
||||
@@ -1131,3 +1214,276 @@ test('the day after a claim: a fresh cycle that aims at the next stage\'s target
|
||||
assert.ok(plan.dayTarget >= 3000 && plan.dayTarget <= 3025, `dayTarget ${plan.dayTarget}`);
|
||||
assert.equal(plan.remainingToday, plan.dayTarget);
|
||||
});
|
||||
|
||||
// ── what an order is for (lib/platform-client.ts orderEffect) ────────────────
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { orderEffect, OrderNotPlacedError, NothingToCloseError, isOrderNotPlaced, isNothingToClose } from '../lib/platform-client.ts';
|
||||
|
||||
test('an entry never closes a position, a flatten never opens one (Vest, DXtrade)', () => {
|
||||
/** The route, or the refusal thrown. `recorded`: the client's own record
|
||||
* of the symbol, by default what the fresh read shows. */
|
||||
const effect = (held, side, intent, recorded = held) => {
|
||||
try {
|
||||
return orderEffect({ symbol: 'NDX-USD-PERP', held, recorded, side, intent });
|
||||
} catch (err) {
|
||||
return err;
|
||||
}
|
||||
};
|
||||
// No intent: the position decides, as before (the manual order route).
|
||||
assert.equal(effect(null, 'long'), 'open');
|
||||
assert.equal(effect('short', 'long'), 'close');
|
||||
assert.match(effect('long', 'long').message, /adding to an open NDX-USD-PERP position/);
|
||||
// An entry on an account that already holds the symbol — a hand short the
|
||||
// engine's synced positions didn't show yet — is refused (the leg is
|
||||
// un-counted), not turned into a close of that short.
|
||||
assert.equal(effect(null, 'long', 'open'), 'open');
|
||||
for (const recorded of ['short', null]) {
|
||||
const entry = effect('short', 'long', 'open', recorded);
|
||||
assert.equal(isOrderNotPlaced(entry), true);
|
||||
assert.equal(isNothingToClose(entry), false);
|
||||
assert.match(entry.message, /short NDX-USD-PERP position is already open/);
|
||||
}
|
||||
assert.match(effect('long', 'long', 'open').message, /adding to an open/);
|
||||
// A flatten closes the leg it was sent for; on a flat account it opens
|
||||
// nothing, and says there is nothing to close when the record agrees.
|
||||
assert.equal(effect('long', 'short', 'close'), 'close');
|
||||
const flat = effect(null, 'short', 'close');
|
||||
assert.ok(flat instanceof NothingToCloseError);
|
||||
assert.equal(isOrderNotPlaced(flat), true);
|
||||
assert.equal(isNothingToClose(flat), true);
|
||||
assert.match(flat.message, /no NDX-USD-PERP position to close/);
|
||||
// A flat read just after a fill the client saw (the leg still on record)
|
||||
// may be the gateway lagging: nothing sent, but not "flat" — the flatten
|
||||
// keeps its alarm.
|
||||
const lagging = effect(null, 'short', 'close', 'long');
|
||||
assert.ok(lagging instanceof OrderNotPlacedError);
|
||||
assert.equal(isNothingToClose(lagging), false);
|
||||
assert.match(lagging.message, /fresh read shows no NDX-USD-PERP position, but this client last saw a long one open/);
|
||||
// Holding the flatten's own side is not the leg: refused, and not "flat".
|
||||
const wrong = effect('short', 'short', 'close');
|
||||
assert.ok(wrong instanceof OrderNotPlacedError);
|
||||
assert.equal(isNothingToClose(wrong), false);
|
||||
assert.match(wrong.message, /holds a short NDX-USD-PERP position, not one to close/);
|
||||
|
||||
assert.equal(isNothingToClose(new OrderNotPlacedError('Vest refused the order: 400')), false);
|
||||
assert.equal(isNothingToClose(new Error('outcome unknown')), false);
|
||||
assert.equal(isNothingToClose(undefined), false);
|
||||
});
|
||||
|
||||
test('the engine says what each order is for, and both position-based clients go by it', () => {
|
||||
const source = (file) => readFileSync(new URL(`../lib/${file}`, import.meta.url), 'utf8');
|
||||
// An order sent without its intent lets the position decide again: an
|
||||
// entry closing a hand trade, a flatten opening a position with no exits.
|
||||
const calls = [...source('auto-trade.ts').matchAll(/client\.sendOrder\(([^;]+?)\);/g)].map((m) => m[1].trim());
|
||||
assert.ok(calls.length >= 4, `found ${calls.length} sendOrder calls`);
|
||||
for (const args of calls) assert.match(args, /, '(open|close)'$/, `sendOrder(${args}) names no intent`);
|
||||
for (const file of ['vest-class.ts', 'dxtrade-class.ts']) {
|
||||
const src = source(file);
|
||||
assert.match(src, /return await this\.sendOrderAndWait\([^)]*, intent\);/, `${file}: sendOrder drops the intent`);
|
||||
assert.match(src, /= orderEffect\(\{[^}]*\bintent\b[^}]*\}\) === 'close' \? held : undefined;/, `${file}: orderEffect doesn't pick the route`);
|
||||
}
|
||||
});
|
||||
|
||||
// The Vest 50k Funded type: $1,500 and claim it all, then $3,000 on the room
|
||||
// the claim freed — and every new day back at $1,500.
|
||||
const vestFunded = {
|
||||
profitTarget: 1500, consistency: 0, minDayPnL: 0, minTradingDays: 0, accountSize: 50000,
|
||||
targetSameEquity: false, splitEnabled: true, payoutable: true, tradesEveryDay: true, combineCmeHolidays: false,
|
||||
withdrawalStages: [{ profit: 3000, consistency: 0, minTradingDays: 0, dailyLossLimit: 3000 }],
|
||||
};
|
||||
const claimedBefore = [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-25', amount: -1500 }];
|
||||
|
||||
test('projectPerfectPath: a same-day type trades each projected day from the base stage, then its next stages that day', () => {
|
||||
const opts = {
|
||||
accountId: 7, cfg: vestFunded, cycleDailyPnL: [], fundTransactions: claimedBefore,
|
||||
balance: 50000, realizedPnL: 0, payout: { withdrawalPct: 1 }, now: cdt('2026-10-27T12:00:00'),
|
||||
};
|
||||
const firstOf = (path, n) => path.slice(0, n).map((d) => [d.date, d.stage, d.withdrawal]);
|
||||
|
||||
// An earlier day's claim doesn't count: today and every day after it
|
||||
// make $1,500 on stage 1 and claim it, then $3,000 on stage 2 and claim
|
||||
// that — once through the stages a day.
|
||||
const fresh = projectPerfectPath({ ...opts, stageInputs: { dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 0 } });
|
||||
assert.deepEqual(firstOf(fresh, 5), [
|
||||
['2026-10-27', 1, -1500], ['2026-10-27', 2, -3000],
|
||||
['2026-10-28', 1, -1500], ['2026-10-28', 2, -3000],
|
||||
['2026-10-29', 1, -1500],
|
||||
]);
|
||||
|
||||
// Claimed once already today: today's cycle is stage 2's $3,000, tomorrow $1,500 and $3,000 again.
|
||||
const claimedToday = projectPerfectPath({ ...opts, stageInputs: { dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 1 } });
|
||||
assert.deepEqual(firstOf(claimedToday, 3), [['2026-10-27', 2, -3000], ['2026-10-28', 1, -1500], ['2026-10-28', 2, -3000]]);
|
||||
|
||||
// A cycle that stood complete yesterday is claimed today, so today trades stage 2.
|
||||
const unclaimed = projectPerfectPath({
|
||||
...opts, cycleDailyPnL: [{ date: '2026-10-26', pnl: 1500 }], balance: 51500,
|
||||
stageInputs: { dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 0 },
|
||||
});
|
||||
assert.deepEqual(firstOf(unclaimed, 3), [['2026-10-26', 1, -1500], ['2026-10-27', 2, -3000], ['2026-10-28', 1, -1500]]);
|
||||
|
||||
// Two listed stages: three payouts a day, and the day after starts over.
|
||||
const twoStages = projectPerfectPath({
|
||||
...opts, cfg: { ...vestFunded, withdrawalStages: [...vestFunded.withdrawalStages, { profit: 2000, consistency: 0, minTradingDays: 0 }] },
|
||||
maxWithdrawals: 10, stageInputs: { stagesSameDay: true, sameDayWithdrawals: 0 },
|
||||
});
|
||||
assert.deepEqual(firstOf(twoStages, 4).map(([date, stage]) => [date, stage]), [
|
||||
['2026-10-27', 1], ['2026-10-27', 2], ['2026-10-27', 3], ['2026-10-28', 1],
|
||||
]);
|
||||
|
||||
// Any other type counts every withdrawal, as it always has.
|
||||
const lifetime = projectPerfectPath(opts);
|
||||
assert.deepEqual(firstOf(lifetime, 3), [['2026-10-27', 2, -3000], ['2026-10-28', 3, -3000], ['2026-10-29', 4, -3000]]);
|
||||
assert.deepEqual(projectPerfectPath({ ...opts, stageInputs: { dailyLossLimit: 1229, stagesSameDay: false, sameDayWithdrawals: 0 } }), lifetime);
|
||||
});
|
||||
|
||||
test('projectPerfectPath: today is the raw trade date it is given — Vest\'s, still the day before from 5 PM CT to 20:00 ET', () => {
|
||||
const firstOf = (path, n) => path.slice(0, n).map((d) => [d.date, d.stage, d.withdrawal]);
|
||||
// Claimed once on Vest day 10-27 and $1,000 made since.
|
||||
const claimed = {
|
||||
accountId: 7, cfg: vestFunded, cycleDailyPnL: [], fundTransactions: [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-27', amount: -1500 }],
|
||||
balance: 51000, realizedPnL: 1000, payout: { withdrawalPct: 1 }, stageInputs: { stagesSameDay: true, sameDayWithdrawals: 1 },
|
||||
};
|
||||
const before = projectPerfectPath({ ...claimed, now: cdt('2026-10-27T16:30:00') });
|
||||
assert.deepEqual(firstOf(before, 3), [['2026-10-27', 2, -3000], ['2026-10-28', 1, -1500], ['2026-10-28', 2, -3000]]);
|
||||
// 5:30 PM CT: the Central date has rolled, Vest's hasn't — the day's
|
||||
// claim stays on it, and 10-28 keeps its stage-1 payout.
|
||||
assert.deepEqual(projectPerfectPath({ ...claimed, now: cdt('2026-10-27T17:30:00'), rawTradeDate: '2026-10-27' }), before);
|
||||
// No date given: the Central one, as before.
|
||||
assert.equal(projectPerfectPath({ ...claimed, now: cdt('2026-10-27T17:30:00') })[0].date, '2026-10-28');
|
||||
|
||||
// No claim yet, Vest day 10-27's row already booked: counted once, as today's.
|
||||
const open = {
|
||||
...claimed, fundTransactions: claimedBefore, cycleDailyPnL: [{ date: '2026-10-27', pnl: 1000 }],
|
||||
stageInputs: { stagesSameDay: true, sameDayWithdrawals: 0 },
|
||||
};
|
||||
const earlier = projectPerfectPath({ ...open, now: cdt('2026-10-27T16:30:00') });
|
||||
assert.deepEqual(firstOf(earlier, 2), [['2026-10-27', 1, -1500], ['2026-10-27', 2, -3000]]);
|
||||
assert.deepEqual(projectPerfectPath({ ...open, now: cdt('2026-10-27T17:30:00'), rawTradeDate: '2026-10-27' }), earlier);
|
||||
});
|
||||
|
||||
test('stages without the same-day reset count each payout, where one fund row holds a day\'s two (Vest)', () => {
|
||||
// Two listed stages; claims of $1,500 and $3,000 on Vest day 10-27 share its row.
|
||||
const stages = [{ profit: 3000, consistency: 0, minTradingDays: 0 }, { profit: 2000, consistency: 0, minTradingDays: 0 }];
|
||||
const rows = [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-27', amount: -4500 }];
|
||||
const resolve = (inputs) => resolveEffectiveConfig(1500, 0, 0, false, stages, 0, rows, 0, inputs);
|
||||
assert.equal(resolve({ payouts: 2 }).profitTarget, 2000);
|
||||
// No payout list: one per negative fund row, as always.
|
||||
assert.equal(resolve({}).profitTarget, 3000);
|
||||
// A same-day type still counts only today's.
|
||||
assert.equal(resolve({ stagesSameDay: true, sameDayWithdrawals: 0, payouts: 2 }).profitTarget, 1500);
|
||||
|
||||
// stageInputsFor takes the count from the client's payout list, when it has read one.
|
||||
const client = { sameDayClaims: () => 2, payoutDays: () => ['2026-10-27', '2026-10-27'] };
|
||||
assert.deepEqual(stageInputsFor(client, 7, {}), { dailyLossLimit: 0, stagesSameDay: false, sameDayWithdrawals: 0, payouts: 2 });
|
||||
assert.deepEqual(stageInputsFor({ payoutDays: () => undefined }, 7, {}), { dailyLossLimit: 0, stagesSameDay: false, sameDayWithdrawals: 0 });
|
||||
assert.deepEqual(stageInputsFor(client, 7, { stagesSameDay: true }), { dailyLossLimit: 0, stagesSameDay: true, sameDayWithdrawals: 2 });
|
||||
// The state route's stage label counts the same way.
|
||||
assert.equal(stagePayouts(rows, stageInputsFor(client, 7, {})), 2);
|
||||
assert.equal(stagePayouts(rows, stageInputsFor({}, 7, {})), 1);
|
||||
|
||||
// The projection starts from the same count: stage 3's $2,000 next, then it repeats.
|
||||
const opts = {
|
||||
accountId: 7, cfg: { ...vestFunded, withdrawalStages: stages }, cycleDailyPnL: [], fundTransactions: rows,
|
||||
balance: 50000, realizedPnL: 0, payout: { withdrawalPct: 1 }, now: cdt('2026-10-27T12:00:00'),
|
||||
};
|
||||
const firstOf = (path, n) => path.slice(0, n).map((d) => [d.date, d.stage, d.withdrawal]);
|
||||
assert.deepEqual(firstOf(projectPerfectPath({ ...opts, stageInputs: stageInputsFor(client, 7, {}) }), 2), [['2026-10-27', 3, -2000], ['2026-10-28', 4, -2000]]);
|
||||
assert.deepEqual(firstOf(projectPerfectPath(opts), 2), [['2026-10-27', 2, -3000], ['2026-10-28', 3, -2000]]);
|
||||
});
|
||||
|
||||
test('endedCycleCompletion: a same-day type\'s ended cycle is judged on its day\'s base stage', () => {
|
||||
// 2026-10-26 made $1,500 and the auto-claim took it before the reporter
|
||||
// saw the final day; the account had claimed the day before too. The
|
||||
// claim ended that day's stage 1, not a $3,000 stage 2.
|
||||
const opts = {
|
||||
accountId: 7, rawTradeDate: '2026-10-26',
|
||||
fullDailyPnL: [{ date: '2026-10-25', pnl: 1500 }, { date: '2026-10-26', pnl: 1500 }],
|
||||
cycleDailyPnL: [],
|
||||
fundTransactions: [...claimedBefore, { date: '2026-10-26', amount: -1500 }],
|
||||
lastFundDate: '2026-10-26',
|
||||
endedCycleStart: { fundDate: '2026-10-25', includesDay: false },
|
||||
rawRealizedPnL: 0, equityProfit: 0,
|
||||
cfg: { ...vestFunded, stagesSameDay: true },
|
||||
now: cdt('2026-10-26T12:00:00'),
|
||||
};
|
||||
// Its stage stays the payout's number among the account's withdrawals,
|
||||
// which the master clears the entry by.
|
||||
assert.deepEqual(endedCycleCompletion(opts), { date: '2026-10-26', stage: 2, drain: false, cycle: '2026-10-25' });
|
||||
// Any other type: judged on stage 2's $3,000, as before.
|
||||
assert.equal(endedCycleCompletion({ ...opts, cfg: vestFunded }), null);
|
||||
});
|
||||
|
||||
import { claimedCycle, withinEndedCycleWindow } from '../lib/trading-logic.ts';
|
||||
|
||||
test('claimedCycle: a claim names the complete cycle it ends, where the final day and the catch-up can\'t', () => {
|
||||
const cfg = { ...vestFunded, stagesSameDay: true, dailyLossLimit: 1500 };
|
||||
const activated = { date: '2026-10-20', amount: 50000 };
|
||||
/** A Vest client's numbers for account 7, just before a claim. */
|
||||
/** The cycle a claim names, its claim time checked and left out. */
|
||||
const claimed = (...args) => {
|
||||
const c = claimedCycle(...args);
|
||||
if (c) { assert.equal(c.at, args[3].getTime()); delete c.at; }
|
||||
return c;
|
||||
};
|
||||
const client = (o) => ({
|
||||
accountCashBalances: { 7: o.cash }, dailyPnL: { 7: o.cycle ?? [] }, fundTransactions: { 7: o.rows },
|
||||
lastFundDates: { 7: o.last }, priorProfit: { 7: 0 },
|
||||
tradeDate: () => o.day, sameDayClaims: () => o.today, payoutDays: o.payouts && (() => o.payouts),
|
||||
});
|
||||
|
||||
// Vest day 10-27, 6 PM ET: claim 1 took stage 1's $1,500 at 3 PM, and
|
||||
// stage 2 has made its $3,000 since. The auto-claim at the next tick
|
||||
// beats the 30 s report, and its payout books into 10-27's row: the
|
||||
// latest fund date never moves for the catch-up.
|
||||
const claimedOnce = [activated, { date: '2026-10-27', amount: -1500 }];
|
||||
const second = { cash: { amount: 53000, realizedPnL: 3000 }, rows: claimedOnce, last: '2026-10-27', day: '2026-10-27', today: 1, payouts: ['2026-10-27'] };
|
||||
assert.deepEqual(claimed(client(second), 7, cfg, cdt('2026-10-27T17:00:00')),
|
||||
{ date: '2026-10-27', stage: 2, drain: false, cycle: '2026-10-27', cycleSeq: 0 });
|
||||
// A Claim now short of stage 2's $3,000 names nothing.
|
||||
assert.equal(claimed(client({ ...second, cash: { amount: 51000, realizedPnL: 1000 } }), 7, cfg, cdt('2026-10-27T17:00:00')), null);
|
||||
// The auto-claim takes the day as done within $25 of the target (claimDue),
|
||||
// and names the cycle at the same tolerance — not further short.
|
||||
const shortBy10 = client({ ...second, cash: { amount: 52990, realizedPnL: 2990 } });
|
||||
assert.equal(claimed(shortBy10, 7, cfg, cdt('2026-10-27T17:00:00')), null);
|
||||
assert.deepEqual(claimed(shortBy10, 7, cfg, cdt('2026-10-27T17:00:00'), 25),
|
||||
{ date: '2026-10-27', stage: 2, drain: false, cycle: '2026-10-27', cycleSeq: 0 });
|
||||
assert.equal(claimed(client({ ...second, cash: { amount: 52900, realizedPnL: 2900 } }), 7, cfg, cdt('2026-10-27T17:00:00'), 25), null);
|
||||
// The day's third cycle, after that claim, has a key of its own.
|
||||
const twice = [activated, { date: '2026-10-27', amount: -4500 }];
|
||||
assert.deepEqual(claimed(client({ ...second, rows: twice, today: 2, payouts: ['2026-10-27', '2026-10-27'] }), 7, cfg, cdt('2026-10-27T17:00:00')),
|
||||
{ date: '2026-10-27', stage: 3, drain: false, cycle: '2026-10-27', cycleSeq: 1 });
|
||||
|
||||
// Stage 2 made only $1,500 of its $3,000 by the 20:00 ET reset, which
|
||||
// puts the account back on stage 1's $1,500: claimed at the first tick,
|
||||
// 8:00:30 PM ET.
|
||||
const afterReset = { cash: { amount: 51500, realizedPnL: 0 }, rows: claimedOnce, last: '2026-10-27', day: '2026-10-28', today: 0, payouts: ['2026-10-27'] };
|
||||
assert.deepEqual(claimed(client(afterReset), 7, cfg, cdt('2026-10-27T19:00:30')),
|
||||
{ date: '2026-10-28', stage: 2, drain: false, cycle: '2026-10-27', cycleSeq: 0 });
|
||||
// The catch-up after it reads none of that cycle's P&L, all in 10-27's
|
||||
// row, which the cycle before owns.
|
||||
assert.equal(endedCycleCompletion({
|
||||
accountId: 7, rawTradeDate: '2026-10-28', fullDailyPnL: [{ date: '2026-10-27', pnl: 3000 }], cycleDailyPnL: [],
|
||||
fundTransactions: [...claimedOnce, { date: '2026-10-28', amount: -1500 }], lastFundDate: '2026-10-28',
|
||||
endedCycleStart: { fundDate: '2026-10-27', includesDay: false }, rawRealizedPnL: 0, equityProfit: 0, cfg, now: cdt('2026-10-27T19:10:00'),
|
||||
}), null);
|
||||
|
||||
// Where the catch-up does name the cycle — its P&L on a day of its own —
|
||||
// the claim names it by the same key, so the master keeps one.
|
||||
const crossDay = { cash: { amount: 51500, realizedPnL: 1500 }, rows: claimedBefore, last: '2026-10-25', day: '2026-10-26', today: 0, payouts: ['2026-10-25'] };
|
||||
assert.deepEqual(claimed(client(crossDay), 7, cfg, cdt('2026-10-26T12:00:00')),
|
||||
{ date: '2026-10-26', stage: 2, drain: false, cycle: '2026-10-25', cycleSeq: 0 });
|
||||
// Without a payout list, one payout per negative fund row.
|
||||
assert.equal(claimed(client({ ...crossDay, payouts: undefined }), 7, cfg, cdt('2026-10-26T12:00:00')).stage, 2);
|
||||
// Numbers not read yet: nothing.
|
||||
assert.equal(claimed({ ...client(crossDay), accountCashBalances: {} }, 7, cfg, cdt('2026-10-26T12:00:00')), null);
|
||||
assert.equal(claimed({ ...client(crossDay), fundTransactions: {} }, 7, cfg, cdt('2026-10-26T12:00:00')), null);
|
||||
});
|
||||
|
||||
test('withinEndedCycleWindow: a week after the date, and never for one that doesn\'t parse', () => {
|
||||
assert.equal(withinEndedCycleWindow('2026-10-21', '2026-10-28'), true);
|
||||
assert.equal(withinEndedCycleWindow('2026-10-28', '2026-10-28'), true);
|
||||
assert.equal(withinEndedCycleWindow('2026-10-20', '2026-10-28'), false);
|
||||
assert.equal(withinEndedCycleWindow('', '2026-10-28'), false);
|
||||
assert.equal(withinEndedCycleWindow('10/27', '2026-10-28'), false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
// Checks for the Vest client's own wiring (lib/vest-class.ts), past what its
|
||||
// pure helpers show: the market-close guard closing a leg whose open came back
|
||||
// unknown, and the candidate board keeping a symbol the close gate holds back;
|
||||
// a sync older than the bot's own fill, an open a restart cut off, an outside
|
||||
// trade beside an open of unknown outcome, and the client's day at 8 PM ET;
|
||||
// the completed cycles a Vest day's claims end, named at each claim.
|
||||
//
|
||||
// npm run check
|
||||
//
|
||||
// A scratch database, a fake clock, and every gateway call answered here —
|
||||
// nothing reaches Vest.
|
||||
import { after, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { registerHooks, stripTypeScriptTypes } from 'node:module';
|
||||
|
||||
// Resolve the extensionless local imports used by Next, without a build.
|
||||
function resolveTypes(specifier, context, nextResolve) {
|
||||
try {
|
||||
return nextResolve(specifier, context);
|
||||
} catch (err) {
|
||||
if (err.code !== 'ERR_MODULE_NOT_FOUND' || !specifier.startsWith('.')) throw err;
|
||||
return nextResolve(`${specifier}.ts`, context);
|
||||
}
|
||||
}
|
||||
// The client's classes use parameter properties, which plain type
|
||||
// stripping refuses: transform them instead.
|
||||
function loadTypes(url, context, nextLoad) {
|
||||
if (!url.startsWith('file:') || !url.endsWith('.ts')) return nextLoad(url, context);
|
||||
const source = stripTypeScriptTypes(readFileSync(fileURLToPath(url), 'utf8'), { mode: 'transform' });
|
||||
return { format: 'module', source, shortCircuit: true };
|
||||
}
|
||||
registerHooks({ resolve: resolveTypes, load: loadTypes });
|
||||
|
||||
const directory = mkdtempSync(join(tmpdir(), 'autofirmer-vest-client-'));
|
||||
process.env.AUTOFIRMER_DB = join(directory, 'test.sqlite');
|
||||
delete process.env.NEXT_PHASE;
|
||||
after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
|
||||
// No request leaves this process: axios refuses every one, and each client's
|
||||
// gateway is a function in this file — from construction on, since the
|
||||
// constructor starts a sync.
|
||||
const { default: axios } = await import('../lib/http-client.ts');
|
||||
axios.defaults.adapter = (config) => Promise.reject(new Error(`no network in this test: ${config.method} ${config.url}`));
|
||||
const { VestClient } = await import('../lib/vest-class.ts');
|
||||
let gateway = null;
|
||||
VestClient.prototype.request = async function (bucket, method, path, opts = {}) {
|
||||
if (!gateway) throw new Error(`no gateway in this test: ${method} ${path}`);
|
||||
return gateway(method, path, opts);
|
||||
};
|
||||
const { putVestSession } = await import('../lib/vest-session.ts');
|
||||
const {
|
||||
botOpenedPositions, loadAccountMeta, saveAccountMeta, saveFundTransactions, wipeAccountData, loadClaimedCycles, saveClaimedCycles,
|
||||
} = await import('../lib/db.ts');
|
||||
const { vestDayOf, vestAccountId, VestApiError, VEST_STATUS_ACTIVE } = await import('../lib/vest-helpers.ts');
|
||||
const { encodeContractName } = await import('../lib/dxtrade-helpers.ts');
|
||||
const { tradeDateFor, stageInputsFor, resolveEffectiveConfig, claimedCycle, endedCycleCompletion } = await import('../lib/trading-logic.ts');
|
||||
const { isClientReady, isOrderNotPlaced } = await import('../lib/platform-client.ts');
|
||||
|
||||
const iso = (s) => Date.parse(s);
|
||||
const WALLET = '0x7ce10771819f80130e204ddb5ca1522e48a9b451';
|
||||
|
||||
// The clock the client reads (Date.now), set to an instant and running on.
|
||||
const realNow = Date.now;
|
||||
after(() => { Date.now = realNow; });
|
||||
let tokens = 0;
|
||||
function clockAt(when) {
|
||||
const start = realNow();
|
||||
const t0 = iso(when);
|
||||
Date.now = () => t0 + (realNow() - start);
|
||||
tokens += 1;
|
||||
assert.equal(putVestSession({ accessToken: `eyJ.test${tokens}.sig`, expiresAt: Date.now() + 3_600_000, primaryAddr: WALLET, userId: 'u' }), null);
|
||||
}
|
||||
|
||||
// The week of NDX hours from check-vest.mjs: maintenance at 21:00 UTC
|
||||
// (17:00 ET) Thursday, the weekly close at 21:00 UTC Friday.
|
||||
const open = (from, to) => ({ fromMs: iso(from), toMs: iso(to), state: { session: 'overnight', closure: 'unspecified', tradingStatus: 'OPEN', price: { allow: true } } });
|
||||
const shut = (from, to, closure) => ({ fromMs: iso(from), toMs: iso(to), state: { session: 'unspecified', closure, tradingStatus: closure === 'weekly' ? 'CLOSE' : 'OPEN', price: { allow: closure !== 'weekly' } } });
|
||||
const week = [
|
||||
open('2026-10-28T22:00:00Z', '2026-10-29T21:00:00Z'),
|
||||
shut('2026-10-29T21:00:00Z', '2026-10-29T22:00:00Z', 'maintenance'),
|
||||
open('2026-10-29T22:00:00Z', '2026-10-30T21:00:00Z'),
|
||||
shut('2026-10-30T21:00:00Z', '2026-11-01T23:00:00Z', 'weekly'),
|
||||
open('2026-11-01T23:00:00Z', '2026-11-02T22:00:00Z'),
|
||||
];
|
||||
const spec = (symbol, assetId) => ({
|
||||
symbol, assetId, sizeDecimals: 3, priceDecimals: 2, tick: 0.01, takerFee: 0.0001, marginRate: 0.05, tradingStatus: 'TRADING', capitalTradingStatus: 'TRADING',
|
||||
});
|
||||
const specs = new Map([['NDX-USD', spec('NDX-USD', 7)], ['BTC-PERP', spec('BTC-PERP', 9)]]);
|
||||
|
||||
/** A client with no sync running, its gateway answered by `answer`, holding
|
||||
* acc-1 and acc-2 under `ids` (the ids a sync gives them: A and B). */
|
||||
function testClient(answer, ids = [1, 2]) {
|
||||
gateway = null;
|
||||
const client = new VestClient({ firmId: 'vest-test', firmName: 'Vest test', wallet: WALLET }, async () => {});
|
||||
clearInterval(client.syncInterval);
|
||||
clearInterval(client.watchdogInterval);
|
||||
gateway = answer;
|
||||
client.specs = specs;
|
||||
client.symbolSpecs = async () => specs;
|
||||
client.marketHours = async (assetId) => (assetId === 7 ? week : []);
|
||||
client.accountList = ids.map((id, i) => ({ id, name: `V50K-F-D3-${i + 1}`, active: true }));
|
||||
ids.forEach((id, i) => {
|
||||
client.uuidById.set(id, `acc-${i + 1}`);
|
||||
client.capital.set(id, { status: VEST_STATUS_ACTIVE });
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
const A = vestAccountId('acc-1');
|
||||
const B = vestAccountId('acc-2');
|
||||
const NDX = encodeContractName('NDX-USD', 0.001);
|
||||
const rawPosition = (positionId, accountId, openDate, extra = {}) => ({
|
||||
positionId, accountId, symbol: 'NDX-USD', side: 'LONG', quantity: '0.500', openPrice: '20000', collateral: '500', leverage: '20', openDate,
|
||||
stopLosses: [], takeProfits: [], ...extra,
|
||||
});
|
||||
|
||||
/** What a sync reads (acc-1 and acc-2 funded, $50k each, and the login's
|
||||
* main account; `positions` open), and the history reads after it. */
|
||||
function syncReads(method, path, positions) {
|
||||
if (method !== 'GET') return null;
|
||||
if (path === '/v3/capital/accounts/active') {
|
||||
return { data: { accounts: ['acc-1', 'acc-2'].map((id, i) => ({
|
||||
id, status: VEST_STATUS_ACTIVE, attempt_index: i + 1, initial_capital: '50000', max_daily_loss_pct: '0.03', stage: 1, stages: [{ ordinal: 1, kind: 'funded' }],
|
||||
})) } };
|
||||
}
|
||||
if (path === '/v3/accounts') {
|
||||
return { data: { accounts: [
|
||||
{ account_id: 'acc-1', amount: '50000', account_type: 2 }, { account_id: 'acc-2', amount: '50000', account_type: 2 }, { account_id: 'main', amount: '0', account_type: 1 },
|
||||
] } };
|
||||
}
|
||||
if (path === '/v3/positions/opened') return { data: { positions } };
|
||||
if (path === '/v3/positions/opened-orders') return { data: { orders: [] } };
|
||||
if (path === '/v3/ticker/latest') return { data: { tickers: [] } };
|
||||
if (path === '/v3/positions') return { data: { positions: [] } };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** testClient on the ids a sync gives, once the constructor's own sync
|
||||
* (which reaches no gateway) is over, with its history taken as read — so
|
||||
* each requestSync here is one good sync, and starts nothing else. */
|
||||
async function syncingClient(answer) {
|
||||
const client = testClient(answer, [A, B]);
|
||||
await client.syncRunning;
|
||||
client.historyLoaded = true;
|
||||
client.historyLoadedIds = new Set([A, B]);
|
||||
client.lastDaysFetchStartedAt = Date.now();
|
||||
client.lastDaysFetchSucceededAt = Infinity;
|
||||
return client;
|
||||
}
|
||||
|
||||
const unanswered = (method, path) => { throw new Error(`no gateway in this test: ${method} ${path}`); };
|
||||
|
||||
test('the market-close guard closes a bot leg whose open came back unknown, not a hand trade', async () => {
|
||||
let positions = [];
|
||||
const closes = [];
|
||||
const client = testClient((method, path, opts) => {
|
||||
if (method === 'GET' && path === '/v3/positions/opened') return { data: { positions } };
|
||||
if (method === 'POST' && path === '/v3/positions/open') throw new Error('socket hang up');
|
||||
if (method === 'POST' && path === '/v3/positions/close') { closes.push({ account: opts.account, ...opts.body }); return { data: {} }; }
|
||||
throw new Error(`no gateway in this test: ${method} ${path}`);
|
||||
});
|
||||
try {
|
||||
// Friday 8:30 AM ET: the entry's send fails in transit, but it filled.
|
||||
clockAt('2026-10-30T12:30:00Z');
|
||||
await assert.rejects(client.sendOrder(1, encodeContractName('NDX-USD', 0.001), 500, 'Buy', 'Market'), /outcome unknown/);
|
||||
const raw = (positionId, accountId, openDate) => ({
|
||||
positionId, accountId, symbol: 'NDX-USD', side: 'LONG', quantity: '0.500', openPrice: '20000', collateral: '500', leverage: '20', openDate,
|
||||
stopLosses: [], takeProfits: [],
|
||||
});
|
||||
positions = [raw('9001', 'acc-1', iso('2026-10-30T12:30:01Z')), raw('9002', 'acc-2', iso('2026-10-30T11:00:00Z'))];
|
||||
client.positionsDetail.set(1, []);
|
||||
client.positionsDetail.set(2, [{ positionId: '9002', accountId: 'acc-2', symbol: 'NDX-USD', side: 'long', quantity: 0.5 }]);
|
||||
client.syncComplete = true;
|
||||
|
||||
// 4:56 PM ET: four minutes to the weekly close.
|
||||
clockAt('2026-10-30T20:56:00Z');
|
||||
await client.guardClose();
|
||||
assert.deepEqual(closes, [{ account: 'acc-1', positionId: '9001', symbol: 'NDX-USD', orderType: 'market' }]);
|
||||
assert.deepEqual([...botOpenedPositions(['9001', '9002'])], ['9001']);
|
||||
// A close just sent isn't sent again while a read may not show it.
|
||||
await client.guardClose();
|
||||
assert.equal(closes.length, 1);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('a symbol the close gate holds back stays on the board, and isn\'t picked; the maintenance hour is ridden', async () => {
|
||||
const client = testClient((method, path) => {
|
||||
throw new Error(`no gateway in this test: ${method} ${path}`);
|
||||
});
|
||||
client.refreshMarks = async () => {};
|
||||
client.volatility = async (symbols) => new Map(symbols.map((s) => [s, { vol: 0.004, candles: 168 }]));
|
||||
const marked = () => { for (const s of ['NDX-USD', 'BTC-PERP']) client.marks.set(s, { mark: s === 'NDX-USD' ? 20000 : 70000, at: Date.now() }); };
|
||||
try {
|
||||
// Thursday 2 PM ET: NDX's maintenance hour is 3 h off, and reopens
|
||||
// before the reset — no gate.
|
||||
clockAt('2026-10-29T18:00:00Z');
|
||||
marked();
|
||||
await client.pickCandidate('Auto', ['NDX-USD', 'BTC-PERP'], false);
|
||||
const thursday = client.lastCandidates.find((c) => c.id === 'NDX-USD');
|
||||
assert.equal(thursday.closesAt, undefined);
|
||||
|
||||
// Friday 2 PM ET: the weekly close is 3 h off; crypto never closes.
|
||||
clockAt('2026-10-30T18:00:00Z');
|
||||
marked();
|
||||
const pick = await client.pickCandidate('Auto', ['NDX-USD', 'BTC-PERP'], false);
|
||||
assert.equal(pick.id, 'BTC-PERP');
|
||||
assert.equal(pick.closesAt, undefined);
|
||||
assert.deepEqual(client.lastCandidates.map((c) => c.id).sort(), ['BTC-PERP', 'NDX-USD']);
|
||||
const ndx = client.lastCandidates.find((c) => c.id === 'NDX-USD');
|
||||
assert.equal(ndx.closesAt, iso('2026-10-30T21:00:00Z'));
|
||||
// The survey keeps it up for the dashboard, though nothing is picked.
|
||||
assert.equal(await client.pickCandidate('survey', ['NDX-USD'], false), null);
|
||||
assert.deepEqual(client.lastCandidates.map((c) => c.id), ['NDX-USD']);
|
||||
// And the client's day is Vest's.
|
||||
assert.equal(client.tradeDate(iso('2026-10-27T22:30:00Z')), vestDayOf(iso('2026-10-27T22:30:00Z')));
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test("a sync whose positions read predates the bot's own fill leaves the fill's record alone", async () => {
|
||||
let positions = [];
|
||||
let holdRead = false;
|
||||
let releaseRead = null;
|
||||
clockAt('2026-10-28T14:00:00Z');
|
||||
const client = await syncingClient((method, path) => {
|
||||
if (method === 'GET' && path === '/v3/positions/opened' && holdRead) {
|
||||
// Read now, flat — answered once released.
|
||||
holdRead = false;
|
||||
const served = positions;
|
||||
return new Promise((resolve) => { releaseRead = () => resolve({ data: { positions: served } }); });
|
||||
}
|
||||
const read = syncReads(method, path, positions);
|
||||
if (read) return read;
|
||||
if (method === 'POST' && path === '/v3/positions/open') {
|
||||
positions = [rawPosition('9101', 'acc-1', Date.now() + 300)];
|
||||
return { data: { orderId: 'o-9101', positionId: '9101' } };
|
||||
}
|
||||
return unanswered(method, path);
|
||||
});
|
||||
try {
|
||||
await client.requestSync();
|
||||
// A sync reads acc-1 flat; the entry fills while it is still out.
|
||||
holdRead = true;
|
||||
const sync = client.requestSync();
|
||||
await client.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open');
|
||||
// Filled: the note kept against a restart goes.
|
||||
assert.equal(loadAccountMeta(A, 'vest_pending_open'), 'null');
|
||||
releaseRead();
|
||||
await sync;
|
||||
assert.deepEqual(client.positionsDetail.get(A).map((p) => p.positionId), ['9101']);
|
||||
assert.equal(client.positions[A].netPos, 0.5);
|
||||
// A read lagging the fill: the leg's flatten is refused as a position
|
||||
// still out there (FLATTEN FAILED), not taken as nothing to flatten.
|
||||
positions = [];
|
||||
await assert.rejects(client.sendOrder(A, NDX, 500, 'Sell', 'Market', undefined, 'close'), /last saw a long one open/);
|
||||
// A sync that went out after the fill writes what it reads.
|
||||
await client.requestSync();
|
||||
assert.deepEqual(client.positionsDetail.get(A), []);
|
||||
assert.equal(client.positions[A], undefined);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test("an open a restart cut off is marked the bot's once a read after the restart shows it", async () => {
|
||||
let positions = [];
|
||||
let posted;
|
||||
const postedAt = new Promise((resolve) => { posted = resolve; });
|
||||
clockAt('2026-10-28T15:00:00Z');
|
||||
const before = await syncingClient((method, path) => {
|
||||
const read = syncReads(method, path, positions);
|
||||
if (read) return read;
|
||||
// The app stops with the open out.
|
||||
if (method === 'POST' && path === '/v3/positions/open') { posted(Date.now()); return new Promise(() => {}); }
|
||||
return unanswered(method, path);
|
||||
});
|
||||
void before.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open').catch(() => {});
|
||||
const sentAt = await postedAt;
|
||||
before.disconnect();
|
||||
|
||||
// Vest filled it, and an outside long opened on acc-2 with it. acc-2 has
|
||||
// an older note, of a short that never showed.
|
||||
positions = [rawPosition('9201', 'acc-1', sentAt + 400), rawPosition('9202', 'acc-2', sentAt + 400)];
|
||||
saveAccountMeta(B, 'vest_pending_open', JSON.stringify({ accountId: 'acc-2', symbol: 'NDX-USD', side: 'short', sentAt: sentAt - 10 * 60_000 }));
|
||||
clockAt('2026-10-28T15:02:00Z');
|
||||
let refuse = false;
|
||||
const after = await syncingClient((method, path) => {
|
||||
const read = syncReads(method, path, positions);
|
||||
if (read) return read;
|
||||
if (method === 'POST' && path === '/v3/positions/open' && refuse) throw new VestApiError('/v3/positions/open: refused (HTTP 400)', 400, 'REFUSED');
|
||||
return unanswered(method, path);
|
||||
});
|
||||
try {
|
||||
await after.requestSync();
|
||||
assert.deepEqual([...botOpenedPositions(['9201', '9202'])], ['9201']);
|
||||
assert.equal(loadAccountMeta(A, 'vest_pending_open'), 'null');
|
||||
assert.equal(loadAccountMeta(B, 'vest_pending_open'), 'null');
|
||||
// An open Vest refuses leaves no note behind.
|
||||
positions = [];
|
||||
refuse = true;
|
||||
await assert.rejects(after.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open'), /refused/);
|
||||
assert.equal(loadAccountMeta(A, 'vest_pending_open'), 'null');
|
||||
} finally {
|
||||
after.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test("an open of unknown outcome isn't matched to an outside trade after its error, nor to one of several", async () => {
|
||||
let positions = [];
|
||||
let open = null;
|
||||
let onRead = () => {};
|
||||
clockAt('2026-10-28T16:00:00Z');
|
||||
const client = await syncingClient((method, path) => {
|
||||
if (method === 'GET' && path === '/v3/positions/opened') onRead();
|
||||
const read = syncReads(method, path, positions);
|
||||
if (read) return read;
|
||||
if (method === 'POST' && path === '/v3/positions/open') return open();
|
||||
return unanswered(method, path);
|
||||
});
|
||||
try {
|
||||
await client.requestSync();
|
||||
// The entry's send fails in transit, and it never went in. Half a
|
||||
// minute on, an outside copy-trader opens NDX long on every account.
|
||||
open = () => { throw new Error('socket hang up'); };
|
||||
await assert.rejects(client.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open'), /outcome unknown/);
|
||||
const errorAt = Date.now();
|
||||
positions = [rawPosition('9301', 'acc-1', errorAt + 30_000), rawPosition('9302', 'acc-2', errorAt + 30_000)];
|
||||
clockAt('2026-10-28T16:01:00Z');
|
||||
await client.requestSync();
|
||||
assert.deepEqual([...botOpenedPositions(['9301', '9302'])], []);
|
||||
assert.equal(loadAccountMeta(A, 'vest_pending_open'), 'null');
|
||||
|
||||
// Another on acc-2, and two positions show from that moment: either
|
||||
// could be the bot's, so neither is marked.
|
||||
positions = [];
|
||||
clockAt('2026-10-28T16:05:00Z');
|
||||
open = () => {
|
||||
positions = [rawPosition('9303', 'acc-2', Date.now() + 100), rawPosition('9304', 'acc-2', Date.now() + 150)];
|
||||
throw new Error('socket hang up');
|
||||
};
|
||||
await assert.rejects(client.sendOrder(B, NDX, 500, 'Buy', 'Market', undefined, 'open'), /outcome unknown/);
|
||||
await client.requestSync();
|
||||
assert.deepEqual([...botOpenedPositions(['9303', '9304'])], []);
|
||||
|
||||
// An open whose answer named its position, though no read showed it
|
||||
// within the wait: that position is the bot's, even with a stop a
|
||||
// hand has put on it since.
|
||||
positions = [];
|
||||
clockAt('2026-10-28T16:10:00Z');
|
||||
let answered = false;
|
||||
open = () => { answered = true; return { data: { orderId: 'o-9305', positionId: '9305' } }; };
|
||||
// The wait's first read comes back past its deadline.
|
||||
onRead = () => { if (answered) { answered = false; clockAt('2026-10-28T16:10:45Z'); } };
|
||||
await assert.rejects(client.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open'), /not seen filled/);
|
||||
const stop = { id: 'sl-9305', triggerPrice: '19900', executionType: 'market', status: 'awaiting' };
|
||||
positions = [rawPosition('9305', 'acc-1', iso('2026-10-28T16:10:00Z') + 500, { stopLosses: [stop] })];
|
||||
await client.requestSync();
|
||||
assert.deepEqual([...botOpenedPositions(['9305'])], ['9305']);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test("the client's Vest day turns with its numbers, and a sync that stalls stops the trading it holds back", async () => {
|
||||
clockAt('2026-10-27T23:58:00Z');
|
||||
const client = await syncingClient((method, path) => syncReads(method, path, []) ?? unanswered(method, path));
|
||||
// The day the engine plans the lane on, at this test's clock.
|
||||
const engineDay = () => tradeDateFor(client, new Date(Date.now()));
|
||||
try {
|
||||
// 7:58 PM ET, before any sync: the day now.
|
||||
assert.equal(engineDay(), '2026-10-27');
|
||||
await client.requestSync();
|
||||
// 8:00:20 PM ET: Vest's day has turned, but the numbers the last sync
|
||||
// worked out are still the ended day's — and so is the engine's day.
|
||||
clockAt('2026-10-28T00:00:20Z');
|
||||
assert.equal(vestDayOf(Date.now()), '2026-10-28');
|
||||
assert.equal(engineDay(), '2026-10-27');
|
||||
assert.equal(isClientReady(client), true);
|
||||
// Nothing opens on the ended day's numbers.
|
||||
const opening = await client.sendOrder(A, encodeContractName('NDX-USD', 0.001), 500, 'Buy', 'Market', undefined, 'open').catch((err) => err);
|
||||
assert.equal(isOrderNotPlaced(opening), true);
|
||||
assert.match(opening.message, /no sync has read it yet/);
|
||||
await client.requestSync();
|
||||
assert.equal(engineDay(), '2026-10-28');
|
||||
// No sync after the next reset: the day holds back only while the
|
||||
// scheduler still trades on the last sync's numbers.
|
||||
clockAt('2026-10-28T23:59:00Z');
|
||||
await client.requestSync();
|
||||
clockAt('2026-10-29T00:03:00Z');
|
||||
assert.equal(engineDay(), '2026-10-28');
|
||||
assert.equal(isClientReady(client), true);
|
||||
clockAt('2026-10-29T00:05:00Z');
|
||||
assert.equal(engineDay(), '2026-10-28');
|
||||
assert.equal(isClientReady(client), false);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('two payouts on one Vest day share its fund row, and still count two toward the stage', async () => {
|
||||
clockAt('2026-10-27T23:00:00Z');
|
||||
const client = await syncingClient(unanswered);
|
||||
try {
|
||||
// $1,500 then $3,000 taken on Vest day 10-27: Vest's two WITHDRAWs, one row.
|
||||
client.withdrawals.set(A, [{ at: iso('2026-10-27T15:00:00Z'), amount: -1500 }, { at: iso('2026-10-27T19:00:00Z'), amount: -3000 }]);
|
||||
client.fundTransactions[A] = [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-27', amount: -4500 }];
|
||||
assert.deepEqual(client.payoutDays(A), ['2026-10-27', '2026-10-27']);
|
||||
// A type whose stages don't reset each day trades stage 3's $2,000, not stage 2's again.
|
||||
const stages = [{ profit: 3000, consistency: 0, minTradingDays: 0 }, { profit: 2000, consistency: 0, minTradingDays: 0 }];
|
||||
const inputs = stageInputsFor(client, A, { stagesSameDay: false });
|
||||
assert.equal(inputs.payouts, 2);
|
||||
assert.equal(resolveEffectiveConfig(1500, 0, 0, false, stages, 0, client.fundTransactions[A], 0, inputs).profitTarget, 2000);
|
||||
// Before its history is read: one per negative fund row.
|
||||
client.historyLoadedIds.delete(A);
|
||||
assert.equal(stageInputsFor(client, A, {}).payouts, undefined);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('each claim of a Vest day names the completed cycle it ends, the second and the one after the reset too', async () => {
|
||||
// acc-1, funded $50k, Vest's own numbers: its balance, and its reset balance since 20:00 ET.
|
||||
let balance = 51500;
|
||||
let reset = { at: '2026-10-27T00:00:00Z', balance: 50000 };
|
||||
clockAt('2026-10-27T19:00:00Z'); // 3 PM ET, Vest day 10-27
|
||||
wipeAccountData(A);
|
||||
const client = await syncingClient((method, path, opts) => {
|
||||
const read = syncReads(method, path, []);
|
||||
if (read && path === '/v3/capital/accounts/active') {
|
||||
read.data.accounts[0] = { ...read.data.accounts[0], daily_reset_at: reset.at, daily_reset_balance: String(reset.balance) };
|
||||
}
|
||||
if (read && path === '/v3/accounts') read.data.accounts[0] = { ...read.data.accounts[0], amount: String(balance) };
|
||||
if (read) return read;
|
||||
if (method === 'POST' && path === '/v3/capital/withdraw' && opts.body.account_id === 'acc-1') {
|
||||
balance = Math.round((balance - Number(opts.body.amount)) * 100) / 100;
|
||||
return { data: { trader_amount: opts.body.amount } };
|
||||
}
|
||||
return unanswered(method, path);
|
||||
});
|
||||
// The history read a claim starts isn't this test's: what it books is set here.
|
||||
client.fetchDaysTraded = async () => ({ failedAccounts: 0 });
|
||||
const cfg = {
|
||||
profitTarget: 1500, consistency: 0, minDayPnL: 0, minTradingDays: 0, accountSize: 50000, targetSameEquity: false,
|
||||
splitEnabled: true, payoutable: true, tradesEveryDay: true, combineCmeHolidays: false, stagesSameDay: true, dailyLossLimit: 1500,
|
||||
withdrawalStages: [{ profit: 3000, consistency: 0, minTradingDays: 0, dailyLossLimit: 3000 }],
|
||||
};
|
||||
/** The day's trading so far, as the history read books it, and Vest's WITHDRAWs for the claims made. */
|
||||
const history = (dayPnl) => {
|
||||
client.fullDailyPnL[A] = [{ date: '2026-10-27', pnl: dayPnl }];
|
||||
client.applyCycleSlice(A);
|
||||
client.withdrawals.set(A, client.claimsOf(A).map((c) => ({ at: c.at, amount: -c.amount })));
|
||||
};
|
||||
/** The cycle a claim now would name, stamped with the claim's time. */
|
||||
const named = () => {
|
||||
const now = Date.now();
|
||||
const c = claimedCycle(client, A, cfg, new Date(now));
|
||||
if (c) { assert.equal(c.at, now); delete c.at; }
|
||||
return c;
|
||||
};
|
||||
try {
|
||||
saveFundTransactions(A, 'V50K-F-D3-1', [{ date: '2026-10-20', amount: 50000 }]);
|
||||
saveAccountMeta(A, 'last_fund_date', '2026-10-20');
|
||||
client.fundTransactions[A] = [{ date: '2026-10-20', amount: 50000 }];
|
||||
client.lastFundDates[A] = '2026-10-20';
|
||||
history(1500);
|
||||
await client.requestSync();
|
||||
|
||||
// Stage 1's $1,500, claimed: the account's first cycle.
|
||||
assert.deepEqual(named(), { date: '2026-10-27', stage: 1, drain: false, cycle: '2026-10-20', cycleSeq: 0 });
|
||||
await client.claimProfit(A, 1500);
|
||||
await client.syncRunning; // the sync the claim starts
|
||||
|
||||
// 6 PM ET: stage 2's $3,000 made, and claimed the same Vest day — its
|
||||
// payout books into 10-27's row, and the latest fund date stays.
|
||||
clockAt('2026-10-27T22:00:00Z');
|
||||
balance = 53000;
|
||||
history(4500);
|
||||
await client.requestSync();
|
||||
assert.deepEqual(named(), { date: '2026-10-27', stage: 2, drain: false, cycle: '2026-10-27', cycleSeq: 0 });
|
||||
await client.claimProfit(A, 3000);
|
||||
await client.syncRunning;
|
||||
assert.equal(client.lastFundDates[A], '2026-10-27');
|
||||
assert.deepEqual(client.fundTransactions[A].at(-1), { date: '2026-10-27', amount: -4500 });
|
||||
|
||||
// 6:30 PM ET: the day's third cycle, $1,500 of stage 2's $3,000 — not complete.
|
||||
clockAt('2026-10-27T22:30:00Z');
|
||||
balance = 51500;
|
||||
history(6000);
|
||||
await client.requestSync();
|
||||
assert.equal(named(), null);
|
||||
|
||||
// 8:00:30 PM ET: the reset puts it back on stage 1's $1,500, met —
|
||||
// claimed at the first tick.
|
||||
clockAt('2026-10-28T00:00:30Z');
|
||||
reset = { at: '2026-10-28T00:00:00Z', balance: 51500 };
|
||||
await client.requestSync();
|
||||
assert.equal(tradeDateFor(client, new Date(Date.now())), '2026-10-28');
|
||||
assert.deepEqual(named(), { date: '2026-10-28', stage: 3, drain: false, cycle: '2026-10-27', cycleSeq: 1 });
|
||||
await client.claimProfit(A, 1500);
|
||||
await client.syncRunning;
|
||||
assert.equal(client.lastFundDates[A], '2026-10-28');
|
||||
|
||||
// The catch-up names none of it: the cycle's P&L sits in 10-27's row,
|
||||
// which the cycle before owns.
|
||||
clockAt('2026-10-28T00:10:00Z');
|
||||
await client.requestSync();
|
||||
assert.equal(endedCycleCompletion({
|
||||
accountId: A, rawTradeDate: tradeDateFor(client, new Date(Date.now())), client,
|
||||
fullDailyPnL: client.fullDailyPnL[A], cycleDailyPnL: client.dailyPnL[A], fundTransactions: client.fundTransactions[A],
|
||||
lastFundDate: client.lastFundDates[A], endedCycleStart: JSON.parse(loadAccountMeta(A, 'ended_cycle_start')),
|
||||
rawRealizedPnL: client.accountCashBalances[A].realizedPnL, equityProfit: client.accountCashBalances[A].amount - 50000, cfg,
|
||||
now: new Date(Date.now()),
|
||||
}), null);
|
||||
|
||||
// Kept as the claims leave them; an entry that doesn't read is left out.
|
||||
const kept = [{ date: '2026-10-27', stage: 2, drain: false, cycle: '2026-10-27', cycleSeq: 0, at: iso('2026-10-27T22:00:00Z') }];
|
||||
saveClaimedCycles(A, [...kept, { date: '2026-10-28', stage: '3', drain: false, cycle: '2026-10-27', cycleSeq: 1 }]);
|
||||
assert.deepEqual(loadClaimedCycles(A), kept);
|
||||
saveAccountMeta(A, 'claimed_cycles', '{');
|
||||
assert.deepEqual(loadClaimedCycles(A), []);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
+248
-4
@@ -1,9 +1,10 @@
|
||||
// Checks for the Vest client's pure helpers (lib/vest-helpers.ts) and the
|
||||
// relayed-session store (lib/vest-session.ts).
|
||||
// Checks for the Vest client's pure helpers (lib/vest-helpers.ts), the
|
||||
// relayed-session store (lib/vest-session.ts), and the trade date the engine
|
||||
// plans Vest accounts on (lib/trading-logic.ts).
|
||||
//
|
||||
// npm run check
|
||||
//
|
||||
// Both files import only Node built-ins, so Node strips their types and loads
|
||||
// These files import only Node built-ins, so Node strips their types and loads
|
||||
// them directly; .mjs keeps this file out of Next's type check. The samples are
|
||||
// shaped like the gateway's real answers (captured 2026-10-26).
|
||||
import { test } from 'node:test';
|
||||
@@ -12,9 +13,11 @@ import {
|
||||
isVestWalletAddress, vestAccountId, num, toMs, formatDecimal, vestDayOf, lastVestResetMs, parseCapitalAccount,
|
||||
vestAccountName, parsePosition, awaitingLegs, parseOpenOrder, unrealizedPnl, parseTxn, bookTxns, txnKind, VEST_TXN,
|
||||
hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS, nextVestResetMs, resetSafeStop, parseHistoryPosition, jwtClaimStrings,
|
||||
accountScopedToken,
|
||||
accountScopedToken, VestApiError, VestNotSentError, nothingWentIn, tokenForRequest, claimWithdrawal, settleClaims,
|
||||
claimFundRows, readClaims, payoutDaysOf, payoutsSince, longClosure, unknownOpenFill,
|
||||
} from '../lib/vest-helpers.ts';
|
||||
import { putVestSession, vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from '../lib/vest-session.ts';
|
||||
import { computeDayPlan, currentTradeDateCT, tradeDateFor, cycleCompletion } from '../lib/trading-logic.ts';
|
||||
|
||||
const iso = (s) => Date.parse(s);
|
||||
|
||||
@@ -126,6 +129,35 @@ const openPosition = {
|
||||
takeProfits: [],
|
||||
};
|
||||
|
||||
test('an open of unknown outcome is the position on its account, symbol and side opened around it', () => {
|
||||
const pos = (positionId, accountId, side, openedAt, symbol = 'NDX-USD', stopLosses = []) => ({
|
||||
positionId, accountId, symbol, side, quantity: 0.5, openPrice: 20000, collateral: 500, leverage: 20, openedAt, stopLosses, takeProfits: [],
|
||||
});
|
||||
// Sent at 12:30:00, its error at 12:30:15: the window is a few seconds of clock either side.
|
||||
const sent = { accountId: 'acc-1', symbol: 'NDX-USD', side: 'long', from: iso('2026-10-30T12:29:55Z'), until: iso('2026-10-30T12:30:20Z') };
|
||||
const filled = pos('9001', 'acc-1', 'long', iso('2026-10-30T12:30:01Z'));
|
||||
assert.deepEqual(unknownOpenFill(sent, [pos('9002', 'acc-2', 'long', iso('2026-10-30T12:30:01Z')), filled]), [filled]);
|
||||
// Another side or symbol, a position from before it, or one opened after
|
||||
// the window (a hand trade, or an outside copy-trader, half a minute on): not it.
|
||||
assert.deepEqual(unknownOpenFill(sent, [pos('9003', 'acc-1', 'short', iso('2026-10-30T12:30:01Z'))]), []);
|
||||
assert.deepEqual(unknownOpenFill(sent, [pos('9004', 'acc-1', 'long', iso('2026-10-30T12:30:01Z'), 'SPX-USD')]), []);
|
||||
assert.deepEqual(unknownOpenFill(sent, [pos('9005', 'acc-1', 'long', iso('2026-10-30T11:00:00Z'))]), []);
|
||||
assert.deepEqual(unknownOpenFill(sent, [pos('9006', 'acc-1', 'long', iso('2026-10-30T12:30:45Z'))]), []);
|
||||
// No open time: can't be told from a hand trade.
|
||||
assert.deepEqual(unknownOpenFill(sent, [pos('9007', 'acc-1', 'long', NaN)]), []);
|
||||
// One with exit legs isn't the bot's open — none went on it.
|
||||
const leg = { id: 'sl-1', triggerPrice: 19900, executionType: 'market', status: 'awaiting' };
|
||||
assert.deepEqual(unknownOpenFill(sent, [pos('9008', 'acc-1', 'long', iso('2026-10-30T12:30:01Z'), 'NDX-USD', [leg])]), []);
|
||||
// Two inside the window: both come back, for the caller to adopt neither.
|
||||
const copy = pos('9009', 'acc-1', 'long', iso('2026-10-30T12:30:02Z'));
|
||||
assert.deepEqual(unknownOpenFill(sent, [filled, copy]), [filled, copy]);
|
||||
// The position id the open's answer named settles it, legs and window aside.
|
||||
const named = pos('9010', 'acc-1', 'long', iso('2026-10-30T12:31:00Z'), 'NDX-USD', [leg]);
|
||||
assert.deepEqual(unknownOpenFill({ ...sent, positionId: '9010' }, [filled, copy, named]), [named]);
|
||||
assert.deepEqual(unknownOpenFill({ ...sent, positionId: '9010' }, [filled, copy]), []);
|
||||
assert.deepEqual(unknownOpenFill({ ...sent, positionId: '9010' }, [{ ...named, accountId: 'acc-2' }]), []);
|
||||
});
|
||||
|
||||
test('positions, their legs and unrealized P&L', () => {
|
||||
const p = parsePosition(openPosition);
|
||||
assert.equal(p.side, 'short');
|
||||
@@ -171,6 +203,143 @@ test('transactions book into daily P&L and fund rows by Vest day', () => {
|
||||
assert.ok(Math.abs(daily.get('2026-10-27').fees - 31.2) < 1e-9);
|
||||
});
|
||||
|
||||
const withdrawalsOf = (txns) => txns.filter((t) => t.type === VEST_TXN.WITHDRAW).map((t) => ({ at: t.time, amount: t.amount }));
|
||||
const deposit = { action_id: 'd', amount: '50000', created_at: iso('2026-10-20T14:00:00Z'), transaction_type: 1 };
|
||||
|
||||
test('a claim of unknown outcome that never shows lapses an hour on; one Vest accepted never does', () => {
|
||||
const at = iso('2026-10-27T15:00:00Z');
|
||||
const lost = { at, amount: 2250, base: 50000, unknown: true };
|
||||
// Read just after the request failed: it may yet show, so it holds.
|
||||
assert.deepEqual(settleClaims([lost], [], at + 60_000), { pending: [lost], lapsed: [] });
|
||||
// Still not shown by a read an hour on: it never went in.
|
||||
assert.deepEqual(settleClaims([lost], [], at + 61 * 60_000), { pending: [], lapsed: [lost] });
|
||||
// It went in after all: a WITHDRAW of its amount near it settles it.
|
||||
assert.deepEqual(settleClaims([lost], [{ at: at + 3_000, amount: -2250 }], at + 61 * 60_000), { pending: [], lapsed: [] });
|
||||
assert.equal(claimWithdrawal([{ at: at + 3_000, amount: -2249 }], lost), undefined, 'another amount');
|
||||
assert.equal(claimWithdrawal([{ at: at + 31 * 60_000, amount: -2250 }], lost), undefined, 'too far off');
|
||||
assert.deepEqual(claimWithdrawal([{ at: at + 20 * 60_000, amount: -2250 }, { at: at - 2_000, amount: -2250 }], lost), { at: at - 2_000, amount: -2250 });
|
||||
// A claim Vest accepted holds for as long as it doesn't show.
|
||||
const accepted = { at, amount: 2250, base: 50000 };
|
||||
assert.deepEqual(settleClaims([accepted], [], at + 48 * 3_600_000), { pending: [accepted], lapsed: [] });
|
||||
});
|
||||
|
||||
test('the books stop counting a claim that never went in once it lapses, so it holds the account no longer', () => {
|
||||
const at = iso('2026-10-27T15:00:00Z');
|
||||
const lost = { at, sentAt: at - 20_000, amount: 2250, base: 50150, unknown: true };
|
||||
const txns = [deposit, { action_id: 'r1', amount: '2400', created_at: at - 3_600_000, transaction_type: 9 }].map(parseTxn);
|
||||
const read = (claims, readAt) => readClaims({ txns, funds: bookTxns(txns).funds, claims, stored: [], readAt, opening: 0 });
|
||||
// It never went in: the balance is what the transactions add up to.
|
||||
const cash = 52400;
|
||||
// Half an hour on it may yet show, so the books still take it out (and hold).
|
||||
const early = read([lost], at + 30 * 60_000);
|
||||
assert.deepEqual([early.lapsed, early.outstanding], [[], [lost]]);
|
||||
assert.equal(cash - early.expected, 2250);
|
||||
// A read an hour on lapses it, and the books agree again.
|
||||
const late = read([lost], at + 61 * 60_000);
|
||||
assert.deepEqual([late.lapsed, late.outstanding], [[lost], []]);
|
||||
assert.equal(late.expected, cash);
|
||||
assert.deepEqual(late.rows, [{ date: '2026-10-20', amount: 50000 }]);
|
||||
// One Vest accepted that doesn't show yet: out of the balance and on its
|
||||
// day's row, however long it takes.
|
||||
const accepted = { at, sentAt: at - 20_000, amount: 2250, base: 50150 };
|
||||
const waiting = read([accepted], at + 48 * 3_600_000);
|
||||
assert.deepEqual([waiting.lapsed, waiting.outstanding], [[], [accepted]]);
|
||||
assert.equal(waiting.expected, cash - 2250);
|
||||
assert.deepEqual(waiting.rows, [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-27', amount: -2250 }]);
|
||||
// Once it shows, the WITHDRAW itself is in the sum.
|
||||
const shown = [...txns, parseTxn({ action_id: 'w1', amount: '2250', created_at: at + 2_000, transaction_type: 2 })];
|
||||
const settled = readClaims({ txns: shown, funds: bookTxns(shown).funds, claims: [accepted], stored: [], readAt: at + 3_600_000, opening: 0 });
|
||||
assert.deepEqual(settled.withdrawals, [{ at: at + 2_000, amount: -2250 }]);
|
||||
assert.deepEqual(settled.outstanding, []);
|
||||
assert.equal(settled.expected, cash - 2250);
|
||||
// An account Vest funded without a deposit row: its capital counts in.
|
||||
assert.equal(readClaims({ txns: txns.slice(1), funds: new Map(), claims: [], stored: [], readAt: at, opening: 50000 }).expected, cash);
|
||||
});
|
||||
|
||||
test('an account token that can\'t be minted means the order or claim was never sent', async () => {
|
||||
const path = '/v3/capital/withdraw';
|
||||
assert.equal(await tokenForRequest(path, async () => 'tok'), 'tok');
|
||||
const failures = [
|
||||
new Error('/v3/auth/account-token timed out after 20s'),
|
||||
new VestApiError('/v3/auth/account-token: HTTP 502 (HTTP 502)', 502, undefined),
|
||||
new Error('/v3/auth/account-token answered without a token'),
|
||||
];
|
||||
for (const failure of failures) {
|
||||
// As the request's own answer each could have gone in...
|
||||
assert.equal(nothingWentIn(failure), false);
|
||||
// ...but from the mint, the request never went out.
|
||||
await assert.rejects(tokenForRequest(path, async () => { throw failure; }), (err) =>
|
||||
err instanceof VestNotSentError && nothingWentIn(err) && err.message.startsWith(`${path} not sent`));
|
||||
}
|
||||
// The request's own answer: only a refusal proves nothing went in.
|
||||
assert.equal(nothingWentIn(new VestApiError('x (HTTP 400, code 1001)', 400, '1001')), true);
|
||||
assert.equal(nothingWentIn(new VestApiError('x: no Vest session', 401, 'NO_SESSION')), true);
|
||||
assert.equal(nothingWentIn(new VestApiError('x (HTTP 408)', 408, undefined)), false);
|
||||
assert.equal(nothingWentIn(new VestApiError('x (HTTP 503)', 503, undefined)), false);
|
||||
assert.equal(nothingWentIn(new Error('x: socket hang up')), false);
|
||||
});
|
||||
|
||||
test('a claim Vest dates on the other side of 20:00 ET keeps no fund row of its own', () => {
|
||||
// Sent at 19:59:59.8 ET on the 26th and answered after 20:00: Vest dates
|
||||
// its WITHDRAW on the 26th's day, the client booked it on the 27th's.
|
||||
const claim = { at: iso('2026-10-27T00:00:00.300Z'), sentAt: iso('2026-10-26T23:59:59.800Z'), amount: 2250, base: 50000 };
|
||||
const txns = [deposit, { action_id: 'w1', amount: '2250', created_at: iso('2026-10-26T23:59:59.900Z'), transaction_type: 2 }].map(parseTxn);
|
||||
const stored = [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-27', amount: -2250 }];
|
||||
// Before Vest shows it, it stands where it was booked.
|
||||
assert.deepEqual(claimFundRows({ funds: bookTxns(txns.slice(0, 1)).funds, claims: [claim], withdrawals: [], stored }), {
|
||||
rows: [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-27', amount: -2250 }], dropped: [],
|
||||
});
|
||||
// Once it shows, it's on Vest's day, and the 27th's row goes.
|
||||
assert.deepEqual(claimFundRows({ funds: bookTxns(txns).funds, claims: [claim], withdrawals: withdrawalsOf(txns), stored }), {
|
||||
rows: [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-26', amount: -2250 }], dropped: ['2026-10-27'],
|
||||
});
|
||||
// The 27th starts with no payout on it, before Vest shows the claim too:
|
||||
// it went out on the 26th, and Vest can't date it earlier.
|
||||
const reset = lastVestResetMs(iso('2026-10-27T15:00:00Z'));
|
||||
assert.equal(payoutsSince(withdrawalsOf(txns), [claim], reset), 0);
|
||||
assert.equal(payoutsSince([], [claim], reset), 0);
|
||||
// Dated after the turn instead, it's the 27th's once Vest shows it.
|
||||
const after = [deposit, { action_id: 'w3', amount: '2250', created_at: iso('2026-10-27T00:00:00.100Z'), transaction_type: 2 }].map(parseTxn);
|
||||
assert.equal(payoutsSince(withdrawalsOf(after), [claim], reset), 1);
|
||||
// One saved before the send time was kept counts from its answer.
|
||||
assert.equal(payoutsSince([], [{ at: claim.at, amount: claim.amount, base: claim.base }], reset), 1);
|
||||
|
||||
// Dated the same day: its row stays. A day Vest has other fund rows on
|
||||
// is Vest's, with a claim it doesn't show yet added.
|
||||
const sameDay = { at: iso('2026-10-26T18:00:00Z'), amount: 1000, base: 49000 };
|
||||
const shown = [deposit, { action_id: 'w2', amount: '1000', created_at: sameDay.at + 2_000, transaction_type: 2 }].map(parseTxn);
|
||||
assert.deepEqual(claimFundRows({ funds: bookTxns(shown).funds, claims: [sameDay], withdrawals: withdrawalsOf(shown), stored: [{ date: '2026-10-26' }] }).dropped, []);
|
||||
const credit = parseTxn({ action_id: 'c1', amount: '40', created_at: sameDay.at - 3_600_000, transaction_type: 22 });
|
||||
assert.deepEqual(claimFundRows({ funds: bookTxns([credit]).funds, claims: [sameDay], withdrawals: [], stored: [] }).rows, [{ date: '2026-10-26', amount: -960 }]);
|
||||
// One of unknown outcome was never booked, so it's no day's.
|
||||
assert.deepEqual(claimFundRows({ funds: new Map(), claims: [{ ...claim, unknown: true }], withdrawals: [], stored }), { rows: [], dropped: [] });
|
||||
});
|
||||
|
||||
test('two claims on one Vest day share a fund row but count as two payouts', () => {
|
||||
const first = { at: iso('2026-10-27T14:00:00Z'), amount: 2250, base: 50000 };
|
||||
const second = { at: iso('2026-10-27T18:30:00Z'), amount: 1500, base: 50000 };
|
||||
const txns = [
|
||||
deposit,
|
||||
{ action_id: 'w1', amount: '2250', created_at: first.at + 2_000, transaction_type: 2 },
|
||||
{ action_id: 'w2', amount: '1500', created_at: second.at + 2_000, transaction_type: 2 },
|
||||
].map(parseTxn);
|
||||
const withdrawals = withdrawalsOf(txns);
|
||||
const { rows } = claimFundRows({ funds: bookTxns(txns).funds, claims: [first, second], withdrawals, stored: [] });
|
||||
assert.deepEqual(rows.filter((f) => f.amount < 0), [{ date: '2026-10-27', amount: -3750 }]);
|
||||
assert.deepEqual(payoutDaysOf(withdrawals, [first, second], rows), ['2026-10-27', '2026-10-27']);
|
||||
// The second not shown yet: still two, on the day it was booked.
|
||||
assert.deepEqual(payoutDaysOf(withdrawals.slice(0, 1), [first, second], rows), ['2026-10-27', '2026-10-27']);
|
||||
// Made by hand, they show only as WITHDRAWs.
|
||||
assert.equal(payoutDaysOf(withdrawals, [], rows).length, 2);
|
||||
// Of unknown outcome, one counts only once it shows.
|
||||
assert.deepEqual(payoutDaysOf([], [{ ...first, unknown: true }], []), []);
|
||||
assert.equal(payoutDaysOf(withdrawals.slice(0, 1), [{ ...first, unknown: true }], rows).length, 1);
|
||||
// A day that nets negative with no WITHDRAW still counts, as it did.
|
||||
assert.deepEqual(payoutDaysOf([], [], [{ date: '2026-10-21', amount: -75 }, { date: '2026-10-22', amount: 20 }]), ['2026-10-21']);
|
||||
assert.deepEqual(payoutDaysOf(withdrawals, [], [...rows, { date: '2026-10-21', amount: -75 }]), ['2026-10-21', '2026-10-27', '2026-10-27']);
|
||||
assert.equal(payoutsSince(withdrawals, [first, second], lastVestResetMs(second.at)), 2);
|
||||
});
|
||||
|
||||
test('hourly volatility leaves out the gaps', () => {
|
||||
const start = iso('2026-10-19T00:00:00Z');
|
||||
const kl = [];
|
||||
@@ -205,6 +374,81 @@ test('market hours: open stretches, closures and crypto', () => {
|
||||
assert.deepEqual(marketOpen([], 600), { open: true, closesAt: null });
|
||||
});
|
||||
|
||||
test('market hours: a closure past an 8 PM ET reset is a long one, the maintenance hour is not', () => {
|
||||
const open = (from, to) => ({ fromMs: iso(from), toMs: iso(to), state: { session: 'overnight', closure: 'unspecified', tradingStatus: 'OPEN', price: { allow: true } } });
|
||||
const shut = (from, to, closure) => ({ fromMs: iso(from), toMs: iso(to), state: { session: 'unspecified', closure, tradingStatus: closure === 'weekly' ? 'CLOSE' : 'OPEN', price: { allow: closure !== 'weekly' } } });
|
||||
const week = [
|
||||
open('2026-10-28T22:00:00Z', '2026-10-29T21:00:00Z'),
|
||||
shut('2026-10-29T21:00:00Z', '2026-10-29T22:00:00Z', 'maintenance'),
|
||||
open('2026-10-29T22:00:00Z', '2026-10-30T21:00:00Z'),
|
||||
shut('2026-10-30T21:00:00Z', '2026-11-01T23:00:00Z', 'weekly'),
|
||||
open('2026-11-01T23:00:00Z', '2026-11-02T22:00:00Z'),
|
||||
shut('2026-11-02T22:00:00Z', '2026-11-02T23:00:00Z', 'maintenance'),
|
||||
open('2026-11-02T23:00:00Z', '2026-11-03T22:00:00Z'),
|
||||
];
|
||||
// The weekend: shut through Friday's and Saturday's 8 PM ET resets.
|
||||
const friday = marketOpen(week, iso('2026-10-30T20:57:00Z'));
|
||||
assert.deepEqual(friday, { open: true, closesAt: iso('2026-10-30T21:00:00Z') });
|
||||
assert.equal(longClosure(week, friday.closesAt), true);
|
||||
// The maintenance hour reopens before the evening's reset, in summer and winter.
|
||||
const thursday = marketOpen(week, iso('2026-10-29T20:57:00Z'));
|
||||
assert.equal(thursday.closesAt, iso('2026-10-29T21:00:00Z'));
|
||||
assert.equal(longClosure(week, thursday.closesAt), false);
|
||||
assert.equal(longClosure(week, iso('2026-11-02T22:00:00Z')), false);
|
||||
// A gap in the periods is shut too (a holiday), and this one outlasts the reset.
|
||||
const early = [open('2026-11-25T14:00:00Z', '2026-11-25T18:00:00Z'), open('2026-11-26T23:00:00Z', '2026-11-27T18:00:00Z')];
|
||||
assert.equal(longClosure(early, iso('2026-11-25T18:00:00Z')), true);
|
||||
// Shut as far as the periods go: long. Periods that just end: no close known.
|
||||
assert.equal(longClosure(week.slice(0, 4), iso('2026-10-30T21:00:00Z')), true);
|
||||
assert.equal(longClosure(week.slice(0, 3), iso('2026-10-30T21:00:00Z')), false);
|
||||
assert.equal(longClosure(undefined, iso('2026-10-30T21:00:00Z')), false);
|
||||
});
|
||||
|
||||
test("a Vest account plans on Vest's day: the 5 PM CT roll doesn't count today's P&L twice", () => {
|
||||
const vest = { tradeDate: vestDayOf };
|
||||
// 3:30 and 5:30 PM CT (4:30 / 6:30 PM ET): the Central date rolls at
|
||||
// 5 PM CT, Vest's day only at 8 PM ET.
|
||||
const before = new Date('2026-10-27T20:30:00Z');
|
||||
const after = new Date('2026-10-27T22:30:00Z');
|
||||
assert.equal(currentTradeDateCT(after), '2026-10-28');
|
||||
assert.equal(tradeDateFor(vest, after), '2026-10-27');
|
||||
assert.equal(tradeDateFor(vest, new Date('2026-10-28T00:00:00Z')), '2026-10-28');
|
||||
// Every other platform keeps the Central date.
|
||||
assert.equal(tradeDateFor(undefined, after), '2026-10-28');
|
||||
assert.equal(tradeDateFor({}, after), '2026-10-28');
|
||||
// $3,000 to go, 3 min days of $150, $800 made today: Vest's row for its
|
||||
// day, and the realized P&L since its reset.
|
||||
const rows = [{ date: '2026-10-27', pnl: 800 }];
|
||||
const plan = (date) => computeDayPlan(7, date, 3000, 0, rows, 150, 3, 800, 800);
|
||||
assert.equal(plan(currentTradeDateCT(before)).remainingToday, 1900);
|
||||
assert.deepEqual(plan(tradeDateFor(vest, after)), plan(currentTradeDateCT(before)));
|
||||
// On the Central date the $800 is a prior min day and today's P&L both.
|
||||
assert.equal(plan(currentTradeDateCT(after)).remainingToday, 2050);
|
||||
});
|
||||
|
||||
test("a Vest account's completion is judged on Vest's day, and waits out Vest's own roll", () => {
|
||||
const vest = { tradeDate: vestDayOf };
|
||||
// $3,000 in 2 min days: day one made $3,100 and is flat. At 5:30 PM CT
|
||||
// its row is booked and its P&L is still today's realized P&L.
|
||||
const eff = { profitTarget: 3000, consistency: 0, minTradingDays: 2, minDayPnL: 0, drain: false, dailyLossLimit: 0 };
|
||||
const opts = {
|
||||
accountId: 7, cycleDailyPnL: [{ date: '2026-10-27', pnl: 3100 }], rawRealizedPnL: 3100, combineCmeHolidays: false,
|
||||
tradesEveryDay: true, effective: eff, equityProfit: 3100, splitEnabled: true, client: vest,
|
||||
};
|
||||
const evening = new Date('2026-10-27T22:30:00Z');
|
||||
// The Central date counts the one day twice and reports a pass.
|
||||
assert.equal(cycleCompletion({ ...opts, client: undefined, rawTradeDate: currentTradeDateCT(evening), now: evening }), 'today');
|
||||
assert.equal(cycleCompletion({ ...opts, rawTradeDate: tradeDateFor(vest, evening), now: evening }), 'none');
|
||||
// Two minutes past Vest's 8 PM ET roll the last sync's realized P&L can
|
||||
// still be the ended day's: no 'today' until the settle window passes.
|
||||
const justRolled = new Date('2026-10-28T00:02:00Z');
|
||||
assert.equal(tradeDateFor(vest, justRolled), '2026-10-28');
|
||||
assert.equal(cycleCompletion({ ...opts, rawTradeDate: '2026-10-28', now: justRolled }), 'none');
|
||||
// A real second day, once settled, finishes it.
|
||||
const settled = new Date('2026-10-28T00:10:00Z');
|
||||
assert.equal(cycleCompletion({ ...opts, rawTradeDate: '2026-10-28', rawRealizedPnL: 50, equityProfit: 3150, now: settled }), 'today');
|
||||
});
|
||||
|
||||
test('relayed sessions: taken, kept fresher, dropped, never listed with tokens', () => {
|
||||
const now = iso('2026-10-26T18:40:00Z');
|
||||
const jwt = (n) => `eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIke${n}In0.sig${n}`;
|
||||
|
||||
@@ -38,6 +38,9 @@ export interface AccountConfig {
|
||||
/** Hard daily loss limit in dollars (0/absent = none): every leg carries a
|
||||
* stop that keeps the firm's day inside it, less a reserve and fees. */
|
||||
dailyLossLimit?: number;
|
||||
/** The stage is picked by the payouts since the firm's last daily reset,
|
||||
* and every new day starts back at the base stage (Vest). */
|
||||
stagesSameDay?: boolean;
|
||||
/** The firm trades every calendar day, weekends and CME holidays included. */
|
||||
tradesEveryDay?: boolean;
|
||||
}
|
||||
@@ -81,6 +84,9 @@ export interface AccountState {
|
||||
active: boolean;
|
||||
amount: number;
|
||||
realizedPnL: number;
|
||||
/** The firm's raw trade date (tradeDateFor): Vest's turns at 20:00 ET,
|
||||
* every other firm's at 5 PM CT. */
|
||||
tradeDate: string;
|
||||
daysTraded: number;
|
||||
positionDirection: 'long' | 'short' | null;
|
||||
/** Balance floor from Tradovate's auto-liquidation profile (0 = not set) */
|
||||
@@ -96,7 +102,9 @@ export interface AccountState {
|
||||
* consistency and minimum days all met (cycleCompletion 'today'). Only
|
||||
* the final day; the firm may lock or remove the account afterwards. */
|
||||
completed: boolean;
|
||||
/** Current stage = 1 + number of withdrawals (negative fund transactions). Stage 1 = fresh account. */
|
||||
/** Current stage = 1 + number of withdrawals (negative fund transactions,
|
||||
* or the platform's own list where a row holds several; today's for a
|
||||
* type whose stages reset each day). Stage 1 = fresh account. */
|
||||
stage: number;
|
||||
/** True when the current stage is a drain stage (spam-withdraw mode). */
|
||||
stageDrain: boolean;
|
||||
@@ -136,6 +144,10 @@ export interface AccountState {
|
||||
lastStand: boolean;
|
||||
/** Actual profit target to hit — max(stage profit target, consistency realTarget = maxDay/consistency). */
|
||||
effectiveProfitTarget: number | null;
|
||||
/** The current stage's hard daily loss limit (0 = none) and minimum
|
||||
* trading days — a withdrawal stage may set its own. null = unassigned. */
|
||||
effectiveDailyLossLimit: number | null;
|
||||
effectiveMinTradingDays: number | null;
|
||||
/** Current cycle daily P&L — used for target calculations. */
|
||||
dailyPnL: { date: string; pnl: number }[];
|
||||
/** Full P&L history across all cycles — used for calendar and equity curve display. */
|
||||
|
||||
Reference in New Issue
Block a user