Compare commits

..
21 Commits
Author SHA1 Message Date
Brandon LiandClaude Opus 5.5 bf7e147507 Vest: a claim within $25 of target names its cycle, the newest goes first, and a batch on a turned day stops
The auto-claim takes a cycle as done within LEG_COMPLETION_EPSILON of its
target, so the cycle it names is judged at that tolerance too. Each saved
cycle keeps its claim's time, and the report lists a day's claims newest
first, so the master's per-slice cap can't hold the latest back. A batch
or copy whose client's day turned while it chose (Vest, at the first sync
after 8 PM ET) is abandoned rather than sized and counted on the old day.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:27:07 -05:00
Brandon LiandClaude Opus 5.5 dc210ffcb7 Vest: no leg opens past 8 PM ET until a sync has read the new day
The engine's Vest day now turns with the client's numbers, at the first
sync after the reset. A leg opened between 8 PM ET and that sync would be
planned and counted on the day that ended, so both the pick and the open
wait for it. The claim log's "stage N next" counts payouts the way the
engine picks its stage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:10:16 -05:00
Brandon LiandClaude Opus 5.5 e02838409b Vest: a claim names the completed cycle it ends, a day's second and one after the reset too
Two of the cycles a claim ends never got a completion entry. A Vest
day's second cycle is one: the auto-claim at the next tick beats the 30 s
report, and the payout books into that day's fund row, so the latest fund
date never moves for the catch-up. A cycle claimed right after the 20:00
ET reset is the other: the reset puts it back on stage 1, and all of its
P&L sits in the previous claim's day row, so endedCycleCompletion reads
none of it.

Before a claim goes out, claimedCycle (trading-logic) checks whether the
cycle stands complete: cycleCompletion 'today' or 'before' on the
client's numbers. It keys the cycle the way the final day would
(completionPlace on the payout list), so the master dedupes the two.
afterClaim keeps the result in account_meta. The reporter names each kept
cycle for the catch-up's week. It lists them last in the slice, newest
first, under the master's per-slice cap, and adds no counter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:08:17 -05:00
Brandon LiandClaude Opus 5.5 4a8e027056 Vest: the payout projection runs on Vest's day, and stages count each payout
projectPerfectPath takes the raw trade date (default: the Central one):
the reporter passes its tradeDateFor day and the account page the one
/api/state now gives each account. From 5 PM CT to 20:00 ET it took
tomorrow as today while every input was Vest's, so the day's claim moved
a day late, the next day's stage-1 payout dropped, and an open Vest-day
row counted twice.

Without same-day stages, the stage comes from the client's payout list
(payoutDays, one per payout) when it has read one, not the negative fund
rows: Vest books a day's two claims as one row, so the engine, claimDue,
the stage label and the projection stayed a stage behind the report.
StageInputs.payouts carries the count; a client with no list counts rows
as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:37:44 -05:00
Brandon LiandClaude Opus 5.5 032ba1ca9c Vest: a stale sync keeps the bot's fill, a cut-off open is still the bot's, and the day turns with the numbers
A sync whose positions read went out before the bot's own open or close
finished no longer writes that account's positions back (ownOrderSeq, as
claimSeq guards balances): the record a flatten goes by stays, so a read
lagging the fill still gives FLATTEN FAILED, not "nothing to flatten".

An open is noted in account_meta before its POST and dropped once its
outcome is known. After a restart the first sync takes the note up as an
open of unknown outcome, so the position it made is marked the bot's and
guardClose sees it. Such an open is matched by the position id its answer
named, else only to a position with no exit legs opened between the send
and a few seconds past the error (was two minutes): an outside
copy-trader's open half a minute on isn't taken for the bot's, and
several matches adopt none, loudly.

VestClient.tradeDate follows the last sync's numbers: past 8 PM ET the
day turns once a sync has worked out the fresh day's realized P&L, so
the engine, reporter and dashboard never pair the new day with the ended
day's numbers. A stalled sync holds the day back only while isClientReady
still trades on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:31:28 -05:00
Brandon Li ab588adbac Merge branch 'vest-fix-display' into vest-fixes
# Conflicts:
#	package.json
#	scripts/check-trading-logic.mjs
2026-10-27 14:51:59 -05:00
Brandon Li 9ecb584dbc Merge branch 'vest-fix-intent' into vest-fixes 2026-10-27 14:51:36 -05:00
Brandon Li 3a16af38f6 Merge branch 'vest-fix-timing' into vest-fixes
# Conflicts:
#	lib/reporter.ts
#	lib/vest-class.ts
#	scripts/check-vest.mjs
2026-10-27 14:51:33 -05:00
Brandon Li 1f1f0cbd8f Merge branch 'vest-fix-claims' into vest-fixes 2026-10-27 14:51:04 -05:00
Brandon LiandClaude Opus 5.5 b5939a06a0 Vest: only a long closure holds entries back; the maintenance hour is ridden
The close gate held every Vest entry within four holds of any close,
which with a 2 h hold meant no weekday entry from about 9 AM to 5 PM ET
for the one-hour maintenance break. That hour reopens before the reset,
as CME's daily break does for futures legs, so only a weekend or holiday
closure gates an entry now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:51:00 -05:00
Brandon LiandClaude Opus 5.5 ef15200e5e Vest: report on Vest's day, and close a leg whose open came back unknown
The reporter and the dashboard take each firm's own trade date
(tradeDateFor), as the engine does: between 5 PM CT and Vest's 8 PM ET
reset the Central date counted the open Vest day as a prior day and as
today's P&L both, and could report a pass the master keeps for good. The
completion check's settle window now follows the client's own roll.

An open whose outcome came back unknown is marked the bot's once a read
shows the position it made, so the market-close guard closes it before a
long closure; an unmarked leg is no longer logged as a hand trade.

A symbol the close gate holds back stays on the candidate board for the
dashboard and the report, and fireLeg holds each account's leg to the
close at its own size, not just the batch lead's. check-vest-client.mjs
drives the guard and the board with every gateway call answered locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:41:20 -05:00
Brandon LiandClaude Opus 5.5 35512cd504 Vest: a claim counts on the fresh day only once Vest dates it there, and a day's second cycle gets its own key
A claim's stage time is now taken before the request goes out (sentAt):
Vest dates it no earlier, so a claim answered just after 20:00 ET no longer
puts the fresh Vest day on stage 2 before the history read shows its
WITHDRAW. One Vest dates after the turn reads as the day before until then,
the cautious way round. Claims saved without it count from their answer.

Completions count one per payout on both paths (completionPlace): the
catch-up's stage no longer counts a Vest day's two claims as one, and a
cycle a day's second claim started carries cycleSeq, so it can be told from
that day's first. The master ignores it until it keys on it; old masters and
old instances are unchanged.

bookHistory's claim bookkeeping (WITHDRAWs, lapse, fund rows, the balance
the books check expects) is now the pure readClaims, tested on the case
that held an account for good: a claim that never went in, read an hour on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:39:13 -05:00
Brandon LiandClaude Opus 5.5 f325b5e969 Account page: clear its three lint errors
The equity curve's running total is summed before the map that reads
it, the first load runs from a timer like the polls, and the two
recharts callbacks keep their any with the repo's usual disable line.
No change in what the page shows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:34:30 -05:00
Brandon LiandClaude Opus 5.5 266100b65c Vest: projected days trade the next stage after each payout, as the bot does
runClaims claims a same-day type's target and trades its next stage the
same day on the room the claim freed, but the projection paid out once a
day, so Vest's payout calendar showed $1,500 a day instead of $1,500 then
$3,000. Each payout now counts toward its own date, and the same date is
projected again on the next stage, once through the stages a day (the
last one repeats). A cycle that stood complete before today counts
toward today, when it is claimed. Other types project as before.

The account page sums a date's cycles, cuts after the second payout day,
and labels a same-day type's past days from that day's own payout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:34:30 -05:00
Brandon LiandClaude Opus 5.5 e33e890a83 Vest: payout projections and the completion catch-up restart same-day stages each day
projectPerfectPath and endedCycleCompletion counted every withdrawal ever
made, so a Vest funded account that had claimed on any earlier day was
projected at stage 2's $3,000 forever (labelled stage 3, 4, ...), and a
day's stage-1 cycle that the auto-claim ended before the reporter saw it
was judged against $3,000 and never reported. The projection now takes
the stage inputs: a type whose stages reset each day trades today on
today's payouts and every projected day from the base stage. The catch-up
judges a same-day type's ended cycle on its day's base stage; the stage it
reports stays the payout's number among the account's withdrawals, which
the master clears the entry by. Other types project exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:15:07 -05:00
Brandon LiandClaude Opus 5.5 4add379c68 Dashboard: a stage's own daily loss limit shows its stop, its personal DLL and its min days
Since a withdrawal stage can carry its own daily loss limit, the engine
places that stop, but the dashboard and report still asked the type's base
limit whether the bot stops at all: with a base of 0 and no obfuscation
options the row showed no "stop $X", and a stopped-out red day read "Flat"
(and counted as a loss, not a personal DLL, in the report). botStopsActive
now takes the stage-resolved config. The state also carries the stage's
daily loss limit and min trading days, which the account page and the Days
column show instead of the type's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:15:02 -05:00
Brandon LiandClaude Opus 5.5 952e4412c4 Catalog: a build that learns a platform fetches the firms an older one skipped
A firm on a platform the build didn't know was skipped, yet the cache was
stamped current, so after updating to a build that knows the platform the
sync sent ?since= and the master answered "unchanged": the firm (Vest, on
machines that synced before 96fdd9c) stayed missing until an unrelated
catalog edit. The parser version goes to 3, so those caches fetch in full
once, and a payload now names the platforms it skipped (skippedPlatforms):
a cache whose skipped platform this build knows is no longer current. Kept
rule ids still report from such a cache. scripts/check-catalog.mjs syncs
against a stand-in master on a local port.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:14:43 -05:00
Brandon LiandClaude Opus 5.5 9fe7fc0ea3 Vest: a claim that never reached Vest stops holding the account, and each claim counts once
A claim of unknown outcome now lapses where history is read (bookHistory),
not only in claimPending: its callers wait on historyReady, and the books
check held the account while it counted that claim, so one that never went
in held the account for good. The lapse is the same (no WITHDRAW within 30
min of it, a read an hour on) and is saved; an accepted claim still holds.

An account token that can't be minted means the request never went out:
an order, stop or take-profit reads as not placed, and a claim records no
unknown claim. Only the request's own failure may be "unknown".

A claim Vest dates on the other side of 20:00 ET than it was booked here
no longer leaves a second fund row: the history read takes the local day
back (and the latest fund date with it), and sameDayClaims counts the
claim on Vest's day. A claim Vest doesn't show yet stays on its day.

The report counts payouts one per claim (Vest's WITHDRAWs and the claims
they don't show yet, never fewer than the negative fund rows) for the
account list, payout states and the completion stage, where two claims on
one Vest day were one row. Same-day cycles still share a cycle key; that
needs the master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:14:38 -05:00
Brandon LiandClaude Opus 5.5 378530595e DXtrade: an entry never closes a hand trade, and a flatten never opens one
DXtrade dropped the intent the engine now passes, so on the live
Tradeify247 accounts an entry could still close a hand position as the
"entry" and the leg's flatten then OPEN a position with no exits. The
open/close decision moves out of vest-helpers into platform-client's
orderEffect, which both clients now follow to pick OPEN or CLOSE.

A flatten's flat read is "nothing to flatten" only when the client's own
record agrees. One empty read just after a fill the client saw may be the
gateway lagging, so it stays a plain refusal and keeps the FLATTEN FAILED
alarm. A check now fails if an engine order drops its intent or a client
stops going by orderEffect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:13:07 -05:00
Brandon LiandClaude Opus 5.5 ec3e6ea388 Vest: no leg opens into a market close, and accounts plan on Vest's day
Market close: a Vest entry now needs its symbol's market to stay open
for RESET_CLEAR_HOLDS holds of the leg (clearsClose), the clearance the
8 PM ET reset already gets: at the target hold, and at the leg's sized
hold when a leverage cap stretches it. That covers the weekly close and
the daily maintenance hour as Vest's market-hours periods list them.
Before a closure that runs past an 8 PM ET reset (the weekend, a
holiday: longClosure), the bot's own legs still open in the last 5
minutes are closed at market, loudly; a position the bot didn't open is
named in the log and left alone.

Vest's day: Vest accounts are planned, and their legs and entries
counted, on Vest's day (20:00 ET) instead of the Central date
(tradeDateFor, PlatformClient.tradeDate). Between 5 PM CT and the reset
the Central date counted the open Vest day as a prior day and as today's
realized P&L both ($3,000 to go, $800 made: $2,050 left, not $1,900) and
started the leg count over. Other platforms keep the Central date.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:12:27 -05:00
Brandon LiandClaude Opus 5.5 e90f21ed91 Vest: an entry never closes a hand trade, and a flatten never opens one
sendOrder takes an optional intent. On Vest an 'open' is refused when the
account already holds the symbol, where it used to close that position as
the "entry"; the leg's exits then found nothing and its flatten opened a
fresh position with no TP or stop. A 'close' only closes or reduces, and on
a flat account is refused with NothingToCloseError, which flattenAndFail
reports as nothing to flatten instead of FLATTEN FAILED. Entries, the
extra-day round trip and flattens pass their intent; the manual order route
and the other platforms keep today's behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:03:14 -05:00
20 changed files with 2557 additions and 246 deletions
+5 -1
View File
@@ -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
View File
@@ -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;
const equityData = [
{ label: '', equity: 0, pnl: 0, origin: true, pos: 0, neg: 0, isWithdrawal: false },
...sortedEvents.map((ev) => {
for (const ev of sortedEvents) {
if (ev.fundAmt !== undefined) running += ev.fundAmt; // withdrawals reduce equity
if (ev.pnl !== undefined) running += ev.pnl;
const equity = Math.round(running * 100) / 100;
runningAfter.push(running);
}
const equityData = [
{ label: '', equity: 0, pnl: 0, origin: true, pos: 0, neg: 0, isWithdrawal: false },
...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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+33
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+107
View File
@@ -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'));
});
+358 -2
View File
@@ -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);
});
+531
View File
@@ -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
View File
@@ -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}`;
+13 -1
View File
@@ -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. */