Compare commits

...
55 Commits
Author SHA1 Message Date
Brandon LiandClaude Opus 5.5 8a5b2a64e7 Vest: the bot activates a passed evaluation's funded account, and a passed one no longer holds the sync
A passed Vest evaluation sits at GOAL_PASSED (status 3) until someone presses
"Go to funded account", which sends POST /v3/capital/activate-funded
{eval_account_id} on the evaluation's own token (read from the Vest app's
bundle, 2026-10-29); Vest then opens the funded account and marks the
evaluation CLAIMED (6). The 5 evaluations that passed on 2026-10-29 waited
for a hand activation.

runActivations now asks for it, the way runClaims claims: from the
scheduler's tick (not awaited, so a slow ask doesn't hold the lane's entry)
and from the claim watcher while the scheduler is off, for evaluations with
an account type only, 15 minutes apart after a failure. Off with the
auto_activate setting at 'off' (dashboard: "Auto-activate funded"). The
client asks for an evaluation once at a time, never again after Vest
answered, and after a timeout not until a list read a minute on says either
way; a refusal frees it at once. The rule is the Vest app's own: an
evaluation at GOAL_PASSED, not blocked, on its plan's last step.

Vest's active balance list leaves a passed evaluation out, and a missing
balance made every sync short of good (15:31-15:34Z), which holds and then
quarantines the lane. Only a live account's missing balance does now. Log
lines name Vest's statuses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:09:49 -05:00
Brandon LiandClaude Opus 5.5 0eb56b3949 Vest: one refused account token fails only its account, and the relay sends the current session's token
Every open Vest tab renews the login on its own, and each renewal moves the
session on: a token from before still reads, but Vest refuses to mint an
account's token with it (401, code 1002). The extension relayed the first
tab's token, so mints failed most of the time with two tabs open, and each
refusal dropped the whole session — every later account in a history pass
failed "no Vest session", and new accounts waited out 5-minute retries for
up to an hour (2026-10-28/29).

The client now fails only that account's call on a refused mint, keeps the
session and the cached account tokens, and logs the refusal now and then
with the login token's life left. An active account with no history is read
again every minute even while a whole pass's retry waits; a pass reads those
first and inactive accounts last, whose failures no longer set the retry.
Exits, lone take-profits and flattens wait up to 90s for the session and the
account's token instead of failing at once; entries still go now or not at
all. Closed accounts' history is read through the login first.

Extension 0.18.0: the hook records each token's session id and takes sign-in
tokens too; the relay reads every Vest tab and Vest's own current-session
record, and sends the token that session holds, else the newest, saying in
the popup when none is current.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 10:55:01 -05:00
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
Brandon LiandClaude Opus 5.5 beddbdcf6c Vest: stops reserve 0.02% of the position for slippage, not 0.1%
A platform may now state its own stop slippage share
(PlatformClient.stopSlippageFraction); the daily-loss reserve is the
larger of 10% of the limit and that share of the position. Vest's 9
measured stop-outs filled 0.9 points past the trigger on average and
2.25 at worst, against the ~30 points 0.1% held back. On a $1,400 day
with ~$495k of NDX the stop goes from ~$880 to ~$1,230, and the leg
sizes up for the same hold. Sizing, the placed stop, the dashboard's
next stop and the reset guard all use it; other platforms keep 0.1%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 11:34:27 -05:00
Brandon LiandClaude Opus 5.5 f0c1569d96 Vest: a split day's next leg gives back the day's gain plus the limit
On a split day the next Vest leg's daily-loss room now adds the day's gain
still in the account to the bot's limit (floorRoom's `gain`), never past
Vest's own floor, and the leg sizes up for the same target hold (Dingo's
next eval leg: $455k -> $842k at 1.5 h, win 28% -> 56%). Other platforms
are unchanged: dllRoom still never widens on a green day.

The gain only widens a leg all but sure to close before Vest's 8 PM ET
reset (resetClearStop, held to the shortest leg the day may carve), and
none of a hand claim's profit counts. The last-stand margin is measured
against the limit, so a green day can't drop a leg's stop.

Around the reset: no new Vest position opens in the last 7 minutes, and
the guard re-fits every 15 s on fresh positions instead of one pass. An
account's positions share the next-day room (stops first, least at risk
first; locked-in profit counts), a hand-moved stop is fitted again, and a
close or profit-side stop that doesn't take is retried or closed.

The dashboard's next stop now reserves for the leg's notional, as fireLeg
does, so DXtrade labels read smaller (display only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 07:21:52 -05:00
Brandon LiandClaude Opus 5.5 1e844178de Trades: record every firm's trades, not just Vest's, and list them on each
TopstepX and Tradovate report fills, so a trade is built from them, flat
to flat (lib/fill-trades.ts), off the window each sync already reads:
TopstepX's two days of Trade/search, Tradovate's sync snapshot. Neither
says who placed an order, so every order the bot places is noted in
own_orders, and a trade is the bot's when its opening fill's order is
one; its stop or take-profit closing it reads as such. A contract is
written only when the position before its window comes out the same
two passes running, so a fill landing between the position and history
reads can't build the wrong trades.

DXtrade reports positions: its record groups the last two days' filled
orders by position code, with each close's SETTLEMENT and COMMISSION.
The bot's own orders carry af- codes, so nothing is kept locally. Only
accounts with something going on are read, at most every 5 minutes.

The Trades button shows on every firm with a login, and the account
page lists any account's trades.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 01:27:07 -05:00
Brandon LiandClaude Opus 5.5 6f6660fdcc Auto-claim without the scheduler, and no claim twice
A payout locks in profit and opens nothing, so stopping the trading no
longer stops it: a claim watcher runs the same claims once a minute while
the scheduler is off (its tick still claims while it runs), and the
dashboard's Auto-claim payouts box turns them off.

From two independent reviews of the claim path:
- A claim holds every further claim on the account until Vest's
  transactions show its WITHDRAW. A sync already out when the claim lands
  (by sync generation, not the clock) no longer writes the pre-claim
  balance back, and a fresh sync follows the claim.
- A withdraw that fails in transit (timeout, reset, 5xx) may have gone in:
  it is recorded as of unknown outcome and holds claims the same way,
  until the transactions show it, or an hour of them shows it didn't.
- claimProfit checks Vest's own account: funded (or instant), and no more
  than the balance above its starting capital.
- A claiming account is marked in flight; runClaims sends the amount it
  re-checked under the mutex.
- The watcher starts before the rest of the init can fail; settings are
  validated before any is written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:45:35 -05:00
Brandon LiandClaude Opus 5.5 016444239e Trades: an open position shows no close price or P&L
Vest's position history gives an open position a placeholder close price
and a $0 P&L (290.25 on an NDX short at 29,538.96). Only a closed
position keeps them now — in the parser, in the stored row (a placeholder
already stored is cleared on the next read) and in the API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:02:11 -05:00
Brandon LiandClaude Opus 5.5 a03bfb5605 Vest: close out trades left open on an account Vest stopped listing
An account that leaves Vest's active list (breached, closed) was never
read again, so a position it had open stayed open in the trades table.
When the list shrinks, and on every full history pass, the client reads
such an account's position history once more per run — its id from
memory or, after a restart, from Vest's closed-account history
(GET /v3/capital/accounts/history) — falling back to the login's token
if the closed account gets none of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 20:00:43 -05:00
Brandon LiandClaude Opus 5.5 71606ffc9e Claim now: a button on the account page claims a payout account's profit
For a payout account on a firm that takes claims by API (Vest), the
account page shows what it could claim right now — its whole profit above
the account size — or why not (not flat, history loading, below its
start). Pressing it asks first, then claims to the login's main account
under the lane's mutex, with the same checks and bookkeeping as the
bot's own claim at a stage's target: the account trades its next stage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:44:10 -05:00
Brandon LiandClaude Opus 5.5 c109aff3e5 Trades: honor an explicit 'outside' mark on trades from before marking began
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:33:29 -05:00
Brandon LiandClaude Opus 5.5 a0e7c5b7c2 Vest: relay the login's token, never one account's
The Vest app renews each trading account's own token through the same
POST /v3/auth/refresh as the login's (with that account's refresh token
in the body). The extension relayed those too, and the dashboard kept
whichever token lived longest — so while someone traded in an account on
Vest, Me synced with a token that sees that account alone and dropped
the other 24 from the list.

Extension 0.17.2 skips account renewals. The dashboard also refuses a
token that sees one account and no primary (or names that account in
its claims), keeps the last account list, and falls back to the login's
token it replaced. An account gone from Vest's list no longer leaves a
stale position holding the lane.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:32:40 -05:00
Brandon LiandClaude Opus 5.5 95cc3bd23f Vest: record every trade, the bot's and outside ones, and list them
Vest's position history (GET /v3/positions) gives each position's size,
open and close price, P&L, fees, funding and why it closed. The client
reads it hourly and a minute after a position closes (or a flat balance
moves) into a new trades table, and stores open positions as they
appear. Vest can't tell an API order from a click, so the bot marks its
own opens and closes as it sends them; an unmarked trade since marking
began was someone else's.

The dashboard's Vest firm row gets a Trades button listing every trade
on its accounts with who opened it and how it closed; the account page
shows its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 19:23:24 -05:00
Brandon LiandClaude Opus 5.5 29eaf64a6b Vest: a day that lost to the bot's limit stops, even with Vest room left
The daily-loss room on Vest was min(the stage's limit, balance − Vest's
floor), which ignored the day's losses against the bot's own limit. With
the type's $1,229 under Vest's 3% ($1,500), five 50k evals that had
lost $1,111.65 today still saw $388 of room and traded again. The room
is now min(limit + the day's realized loss, balance − floor).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:55:54 -05:00
Brandon LiandClaude Opus 5.5 32081f3dcf Vest: claim the profit at the target, then trade again the same day
On Vest, a claim lowers the daily-loss floor by the amount claimed. Winning
$1,500 and claiming it leaves $3,000 of room the same day, the full
drawdown. The bot now uses that.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 15:56:00 -05:00
Brandon LiandClaude Opus 5.5 335af486d4 One side per machine: no lane opens against a position another lane holds
Each lane chose its batch's side on its own coin flip. So one person's
machine could be long NQ in the futures lane while its Vest accounts sat
short NDX, hedging that person against themselves across firms.

- Every new entry now takes the side of whatever the machine holds in any
  lane (machineSide in trading-logic). That includes another firm's
  positions and hand trades on any of the machine's accounts.
- With positions on both sides already, nothing is added: the batch reports
  positions-open with the reason, and a manual leg is refused.
- A side chosen with nothing held is reserved while its batch places it, so
  a lane deciding meanwhile follows it.
- It works on the side, not the instrument: NQ against an NDX perp is the
  same bet, and the indices and crypto are close enough that one side for
  everything is the only rule that never hedges.
- Unchanged: one direction per batch within a lane, and the DXtrade and Vest
  clients' own guards against opening opposite another of their accounts.
  The fleet still doesn't coordinate sides across people.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 15:27:18 -05:00
Brandon LiandClaude Opus 5.5 769d96883f Vest: the relayed login binds itself to an unset Vest firm
A Vest firm's credential is the wallet of the login it trades. It no longer
has to be typed or clicked in: when the extension relays a Vest tab's
session and a Vest firm here has no credential, that wallet is saved as the
credential (lib/vest-wallet.ts), the client starts, and the backup is
pushed.

- A saved wallet is never replaced. When the tab is signed in to another
  login, the firm now says which one and how to switch: sign in as the
  saved wallet, or remove the credential.
- With two logins relayed at once nothing binds itself; the one-click form
  decides.
- Extension 0.17.1 re-posts the session every minute even when it hasn't
  changed. The dashboard holds it in memory only, so a restart there was
  unauthenticated until the tab's next renewal (up to ~15 min); now it is
  back within a minute, and a firm created later binds within a minute too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:42:01 -05:00
Brandon LiandClaude Opus 5.5 96fdd9ca23 Vest platform: a client for Vest Markets, traded on a session the extension relays
Vest (next.vestmarkets.com) is a perps prop firm: index, metal and energy
perps on CME hours, plus stocks, FX and crypto. It joins as a fourth
platform, 'vest', on the DXtrade engine paths. Those are now keyed by
isNotionalPlatform() instead of === 'dxtrade': the firm's own symbols,
percent fees, notional sizing from volatility, and a scheduler lane of its
own.

- Login is relayed, not stored. Vest signs people in with Google through
  Privy and renews the session with a device key the browser won't export.
  Extension 0.17.0's vest-relay.js reads the token the Vest app renews for
  itself (~15 min). It renews nothing, so the tab is never logged out. The
  background script posts that token to POST /api/vest/session, which holds
  it in memory only. With no Vest tab open on a machine that trades Vest,
  the extension opens one.
- The firm's credential is the Vest login's wallet address. The dashboard's
  form offers the relayed wallet with one click.
- VestClient (lib/vest-class.ts), mapped from the web app's traffic and code
  since Vest publishes no API:
  - Reads the capital accounts, balances (free amount plus position
    collateral), positions and open orders with the user token.
  - Mints per-account tokens for orders and transactions.
  - Names accounts for their rules (V50K-E-D3-T10-34, V50K-F-D3-34,
    V25K-I-7) so account-type prefixes can match.
  - Takes Vest's own max-drawdown and daily-loss floors. dayStartBalance =
    floor + the type's limit, so the engine's room is never more than
    Vest's.
  - Uses each symbol's taker fee and margin rate from exchangeInfo, and
    hourly volatility over in-session hours only.
  - Places take-profit and stop as position legs, which Vest cancels as an
    OCO pair.
- Daily P&L and fund rows come from /v3/transactions by Vest's 20:00 ET
  day. A books check holds an account, naming the transaction types, when
  the balance and the transactions disagree.
- scripts/check-vest.mjs (in npm run check): Vest's day across DST, account
  names, parsing, volatility gaps, market hours, the session store.

Not yet run against Vest with a live session: the order and exit bodies
come from the app's code, and the transaction classification from its enum.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:19:31 -05:00
Brandon LiandClaude Opus 5.5 5ec5abde6e Remove broker session rest
Reverts 062ec07 and 5eb0ea7: every firm's broker session stays logged in
around the clock again, renewing its token on the fixed timer as before.
The tree is back to 6dd2528's. A session_rest row left in settings is
unused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 23:39:53 -05:00
Brandon LiandClaude Opus 5.5 062ec07f0a Session rest: a rest across the 5 PM roll reports unsynced; health excuses a wake
A firm resting across the trade-date roll kept reporting its last snapshot,
so yesterday's realized P&L and DLL lockouts counted as accounts already
traded today (Nik's Topstep read 5/5 traded at 5:31 PM from 3:29 PM data).
Its report is now the unsynced one until it logs back in; a rest within one
trade date reports as before.

/api/health also counts a firm logging back in after a rest as nothing
wrong, within its 15-minute grace, rather than reading not ok for the
seconds before its first sync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:12:52 -05:00
Brandon LiandClaude Opus 5.5 5eb0ea7760 Broker sessions rest while nothing needs them
A firm's client used to stay logged in around the clock, renewing on a fixed
timer overnight and at weekends. Now, once a firm has nothing open, nothing
pending and nothing left to trade before its next session for a random
15–45 minutes, its client disconnects and the dashboard shows it Resting.
It logs back in a random 20–50 minutes before the next session opens, at a
check-in every 3–7 Central daytime hours, or at once when trading starts, a
manual leg, copy trade, manual order or AutoBuyer run needs it. Token
renewals fall at random points (Tradovate 15–45 min before expiry, TopstepX
every 45–150 min). Settings → Trading → Rest idle broker sessions
(session_rest) turns it off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:28:47 -05:00
Brandon LiandClaude Opus 5.5 6dd2528fe2 Tradeify247: each person sizes a little apart from everyone else
Every machine sized a like account in a like state to the same position as
every other person's, so two people's positions could match to the unit.
Each person now draws a size factor in [0.85, 1.15] per trade date, seeded
by the master's uuid for them; all of one person's accounts share it.

The factor moves the hold a DXtrade leg is sized for (hold / factor², since
size goes as 1/√hold), so the instrument pick, the dashboard's next leg and
the Firms page's reach test all see the hold the size was made for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:00:33 -05:00
Brandon Li ed56e306d1 Persist one browser user agent for Axios and WebSockets 2026-10-19 06:08:23 -05:00
Brandon LiandClaude Opus 5.5 13e03c3d0f Updater heals itself: PM2 cron revived, Update now, failure reasons
PM2's cron for autofirmer-update dies inside a live daemon (six Windows
machines in October), and nothing on the machine noticed. The app now
watches scripts/.last-check and, past 10 minutes, runs
update-check.mjs --heal: a checker PM2 still lists is restarted (which
re-registers its cron), one it lost is registered again, and one whose
errored entry still holds a live pid is left alone, since PM2 would kill
that pid. The heal scrubs PM2's per-app fields from its environment first.

The master's new 'update-now' command runs --heal --now, skipping a
failed update's retry wait. A failed pull, npm install or build records
one line of reason (or the chown to run when a file isn't ours), which
rides on .last-check to the master. A package-lock.json rewritten by npm
is put back before the pull. The fetch timeout drops under the cron's
minute. scripts/.no-auto-update opts a checkout out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:41:04 -05:00
Brandon LiandClaude Opus 5.5 c0899620d4 Old rules for older accounts, a rules date per account, and a firm cap
When the master changes an account type with "New accounts only", it now
serves the type's old rules as kept rule sets (firm.keptAccountRules), each
for accounts dated inside [openedAt, closedAt). This build applies them:

- getAccountConfig, the one rules lookup trading reads, puts a kept set's
  rule fields over the live type when the account's rules date falls in
  its window. Id, label, prefix, size and the projection fields stay the
  live type's, so every report still names the live type.
- account_assignments gains rules_date and rules_date_manual. A new row is
  dated when the machine first sees the account, on the master's clock
  when this one runs behind and never before the catalog's last change.
  Auto dates come down to the broker's evidence: the end of the first fund
  day in Chicago, or a DXtrade registration instant. Evidence is applied
  before an account's first trade. Rows from before the migration get its
  time; unpinning keeps the row and its date. Backups carry both columns.
- An account on a kept set stays on its type while its prefix and cached
  size still match, so a tie-break flip can't move it off its old rules.
- Kept entries parse through the same normalizer as live types (now
  normalizeAccountType in lib/kept-rules.ts); a malformed one rejects the
  sync like a malformed type. The cached catalog records the parser that
  stored it, and a cache from an older parser is fetched in full rather
  than trusted with ?since= (this also brings in maxConcurrentAccounts).
- Reports add keptRuleIds per firm and live counts per kept set per type.
- The dashboard row and the account page show the rules date next to the
  account name, with an "old rules" chip saying what differs. Clicking
  the date edits it inline (PUT /api/accounts/:id/rules-date), previewing
  which rules the date lands on.

Also: the master's per-firm cap on accounts traded at once
(maxConcurrentAccounts), applied on top of the machine's own max
concurrent when a batch is drawn and in copy trading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:21:55 -05:00
Brandon LiandClaude Opus 5.5 98016b3eb3 Seed the instruments table in one immediate transaction
`next build` imports lib/db.ts from several workers at once. On a fresh
machine they all saw an empty instruments table and raced to insert the
same symbols, failing the build with "UNIQUE constraint failed:
instruments.symbol". The emptiness check and the inserts now share one
IMMEDIATE transaction, and the insert is OR IGNORE as a backstop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:21:28 -05:00
Brandon LiandClaude Opus 5.5 d7d1e2cdae TopstepX: book a fund event only once a later pass still sees it
A sync reads the balance first and each account's positions and trades
after it, so a fill landing in between shows in the trades but not yet in
the balance, and for that one pass the books are off by the whole fill.
reconcileFunds booked that as a fund event, and the next pass booked the
reversal. Same-day rows merge, so the two netted to the commission drift
of the day and left a tiny row:

- 2026-10-05, Express 43788281 and 23275136: +3,001.16 "deposit" (balance
  -2.16 against the -3,001.16 stop-out already in the trades), then
  -3,002.16, merged to -1.00.
- 2026-10-08, combine 99745612: +2,474.01, then -2,492.01, left -18.00.
- 2026-09-24, five 150K combines on another instance: +3,049.45, then
  -3,059.45, left -10.00 each.

A negative row is a withdrawal. It started a new cycle, dropped its day
out of the cycle (the -3,003.32 on 10-05) and counted toward the stage:
both Express accounts traded drain days from 10-06 on a $1 "payout".

- fundReconcileStep (trading-logic): an unexplained balance is booked
  only when a check at least 30s after the first sighting still sees it,
  to within $5. A transient is gone by the next pass and books nothing.
- A transient that outlasts that, and so books, is taken back whole once
  its reversal nets the day under FUND_EVENT_MIN. The day's first booking
  saves what it replaced (fund_day_before), and dropFundTransaction
  deletes the row and puts last_fund_date and ended_cycle_start back in
  one transaction. A row reconcileFunds didn't book (an import) merges as
  before.
- funds_reconciled is set only once a check settles, so a first-check
  lump that waits on a second pass is still booked as the first check.

Existing tiny rows are left as they are; cleaning them up is a separate,
one-time step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:24:29 -05:00
Brandon LiandClaude Opus 5.5 aab225a19c TopstepX: book Topstep's commission with each fill
TopstepX's gateway reports a fill's exchange and NFA fees only (NQ $1.39 a
side, GC $1.66, CL $1.51, MNQ $0.36). Topstep's own commission, $0.50 a side
on a full-size contract and $0.25 on a micro, comes off the balance with no
trade row carrying it, so our P&L ran $1.00 per full-size round turn above
Topstep's. On 2026-10-18 the combines sat $6 to $8 under their books and an
XFA $27.50 under, and one combine had its $18 booked as a "withdrawal".
Topstep's published round turns (NQ $3.78, MNQ $1.22, GC $4.32, CL $4.02)
are the reported fees plus this commission.

- lib/topstep-fees.ts: the per-product commission table, and TopstepX
  contract ids mapped to roots (ENQ, GCE and CLE as seen on real fills,
  overridden by contracts the client resolves itself; an unknown product
  pays the full-size rate and is logged once).
- Daily P&L, today's realized, and so the measured entry fee a take-profit
  is placed from, all net the commission. The 90-day refetch rewrites the
  cached days.
- npm run check: the table against the micro flags, the id mapping, and
  the published round turns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:39:52 -05:00
Brandon LiandClaude Opus 5.5 c0261f42ae Hide the next stop on a booked day; label min day P&L as such
On 2026-10-08 a Tradeify min day ($150) stopped out at −$306 on its 2×
stop of $300. The dashboard then read "stop $912": the next-stop label is
recomputed from the remaining target, which the loss grew to $456, for a
leg that never fires — the day is booked and the make-back rides tomorrow,
stopless.

- obfuscationDayBooked() holds the scheduler's booked-day check; the state
  route asks it too and shows no next stop, last stand or hold on a
  booked day.
- The account page's "Daily Loss Limit" showed minDayPnL. It now shows the
  type's real daily loss limit, with Min Day P&L on its own row; the
  objective that passed on total profit ≥ minDayPnL is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:06:35 -05:00
Brandon LiandClaude Opus 5.5 c3b58c8d8b Hold a payout that isn't booked yet, instead of re-earning it
On 2026-10-06 two Topstep Express accounts sat finished, waiting for their
payouts. The hub moved their balances the moment the payouts landed, but
reconcileFunds books a withdrawal only in the next sync. 28 seconds later
the plan read the drop as lost profit, reopened the cycle as stage 1 and
fired a full 3xNQ leg with no stop, on what should have been a drain day.
Both were liquidated against the $0 floor a payout leaves.

- TopstepX: unbookedBalanceChange holds an account while its balance is
  $200+ off its books (today's realized read fresh off the trades, so the
  5 PM roll doesn't trip it); the sync books it as before.
- Every platform: a finished cycle marks its books in account_meta; a later
  drop of $200+ with no fund row booked since holds the account until the
  payout books and moves it to its next stage. Relative to the mark, so
  history missing from before the cache cancels out; a wipe clears it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:13:48 -05:00
Brandon LiandClaude Opus 5.5 0c3a36845a P1.1: Report each account's status to the master's ledger
Every FirmStats now carries statusVersion 1, the catalog firmId and, once
synced, syncedAt (from lastSyncAt), including the no-accounts branch, which
is now built by emptyFirmStats(). Each accounts[] entry adds status, reason,
balance, floor, accountTypeId, firstFundDate, positiveFunds and brokerStatus,
each left out when null or at its default.

accountStatus() in platform-client.ts is pure: a missing balance is unknown
(cash_missing), never dead; with a balance, dead is exactly the old floor
test (legacyDead); then history_pending, locked, live. Only the reporter
uses it. The aggregates, totals and every trading decision keep their own
dead test, unchanged.

The reporter reads history readiness through a new Tradovate
peekHistoryReady(), because historyReady() there can start a hold and move
the balance baseline. DXTrade exposes accountStatus and registrationTime via
brokerAccountMeta(). A throw in the new fields reports the account as
unknown instead of losing the report.

sendReport skips the socket for a firms payload over 900,000 chars (the
master's frame cap is 1,000,000 bytes) and POSTs it, logging once per switch.

scripts/check-account-status.mjs proves the legacy invariant over the full
matrix and the size budget (worst 228 B added per account, 154 B typical
live; 2000 accounts 592,501 B).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:36:55 -05:00
Brandon LiandClaude Opus 5.5 8747dd6b08 Exit prices on the tick grid, and a refused take-profit flattens
4217.6 − 87 × 0.1 is 4208.900000000001 in floating point. TopstepX refused
that take-profit ("Invalid price. Price is outside allowed range") on two
GC shorts on 2026-10-02, and with no stop on the leg nothing else was
working: both rode far past their target with no exit. ES and NQ never hit
it because 0.25 is exact in binary; GC and CL ticks aren't.

- onTick puts the measured-fee take-profit and every stop on the grid —
  the take-profit away from the entry, the stop toward it — so an averaged
  multi-contract fill between ticks is handled the same way.
- A leg's lone take-profit (no stop, or the fallback after a refused
  bracket) that the broker refuses now flattens the position and benches
  the account for the day, like a refused bracket. An unknown outcome still
  places nothing more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:49:59 -05:00
Brandon LiandClaude Opus 5.5 d6bb5a7221 Show the person's 2FA code on the firm site the extension is on
On a firm site, the extension draws a small corner chip with this
machine's person's authenticator code for that site, a countdown, and a
Copy button. It can be minimised to a "2FA" pill for the tab's session.

- /api/autobuyer/otp?host= relays to the master's
  /api/otp?person=<instance_id>&host=, which matches the host to a firm's
  Site link and the person's assigned codes. No master link, a master
  that can't be reached, or no match all come back as no codes. An answer
  without scope: 'person' is refused: an older master ignores ?person=
  and would return every stored code.
- The chip lives in a shadow root, counts down on its own, refetches
  when a code expires, and stays hidden for a minute after the runner
  last located something, so automated clicks never land on it. Copy
  uses execCommand inside the click, since the async Clipboard API can
  stall on a permission prompt, and leaves focus in the site's code field.
- Apex (*.apextraderfunding.com) joins host_permissions, which also
  brings it into capture like the other firm sites. Version 0.16.0.
- CatalogFirm mirrors the master's new optional siteUrl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:19:32 -05:00
Brandon LiandClaude Opus 5.5 58e7414849 Futures legs respect a firm's per-instrument contract limit
A firm can cap how many contracts of an instrument its accounts may hold
(the catalog's instrumentLimits, set on the master's Firms page). A leg
trades one contract per $1,000, capped by the account type's max contracts
and that limit. The Auto draw sizes each instrument's hold with the capped
count, so a limit that slows a leg past 12 hours takes it out of the draw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:45:44 -05:00
Brandon LiandClaude Opus 5.5 b125257d4e Trade mix: name positions by a contract index that never expires
A Tradovate position opened before this build (so its entry wasn't recorded)
was named from the contract cache, whose entries expire after 30 minutes:
Majd's CL longs showed as #3273057. The resolver now also keeps a lasting
contract id → root index, which the mix reads after its recorded entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:04:42 -05:00
75 changed files with 13978 additions and 592 deletions
+2
View File
@@ -63,6 +63,8 @@ scripts/update.log
scripts/.built-commit
scripts/.update-failed
scripts/update-worker.log
# present = this checkout is never auto-updated or healed (a dev checkout)
scripts/.no-auto-update
# the previous build, set aside while the updater builds (Tailwind's source
# scan also honours this, so it never reads the old bundles)
/.next-prev/
+88 -2
View File
@@ -167,7 +167,7 @@ per-contract fees and the CME calendar.
Per account type, in the **Hard Daily Loss** column: the **daily loss limit**
(Tradeify: 3% of size — every leg then carries a stop that keeps the firm's
day inside it, less fees and a reserve of 10% of the limit or 0.1% of the
position, whichever is more) and the **static max drawdown** (Tradeify
position, whichever is more — 0.02% on Vest, whose stops fill closer) and the **static max drawdown** (Tradeify
1-Step: $6,000 on 100K). When hitting the day's loss limit would leave the
account within 15% of that loss above its max-drawdown floor, the leg is a
*last stand*: no daily-loss stop, running to its take-profit or the firm's
@@ -184,7 +184,93 @@ It is read-only unless `DX_CONFIRM=yes DX_ACCOUNT=<code>` are set too, which
adds one minimum-size live round trip.
`npm run check` runs the pure-logic checks (fees, stops, sizing, trading
hours, the DXtrade selector, the futures draw and instrument lists).
hours, the DXtrade selector, the futures draw and instrument lists), plus local
HTTP/WebSocket checks against a temporary database.
### Outbound user agent
On first server load, each instance randomly selects a desktop Chrome,
Firefox, or Edge user agent and saves it as `user_agent` in its settings.
Settings → Network shows the selected value. It stays the same across requests,
WebSocket reconnects, page reloads, and server restarts, and travels with backups.
An existing saved value is reused; an absent or blank value is generated automatically.
All server Axios calls use `lib/http-client.ts`; import Axios from
`@/lib/http-client` when adding requests. Its defaults and interceptor also apply
to `axios.create()` clients, so per-request headers cannot accidentally replace
the saved user agent. ESLint rejects direct application imports of the Axios package.
Use `brokerWebSocket` or `signalrWebSocket` from `lib/outbound-proxy.ts` for broker
sockets, and `directWebSocket` for control sockets. All share the same user agent;
the broker proxy still applies only to broker traffic.
## Vest firms (Vest Markets)
Vest (next.vestmarkets.com) is a perps prop firm: index, metal and energy perps
on CME hours, plus stocks, FX and crypto. Set it up on the master's **Firms**
page with platform **Vest**. It shares the DXtrade engine: its own symbols
(new firms start on `NDX-USD-PERP`, `SPX-USD-PERP`, `GC-PERP`, standing in for
NQ, ES and GC), notional sizing from volatility, percent fees, hard daily-loss
stops, and a scheduler lane of its own. Leave **Trades every day** off for the
index and metal perps: they close for weekends and 5–6 PM ET.
**Login is relayed, not stored.** Vest signs people in with Google (through
Privy) and renews the session with a device key the browser keeps, so this
instance cannot log in by itself. Instead:
1. In Chrome on this machine (with the AutoFirmer extension, 0.17.0+), open
next.vestmarkets.com and sign in. The person signs in once; Vest keeps the
session for 30 days.
2. The extension relays the tab's short-lived access token to this dashboard
(`POST /api/vest/session`, held in memory only) every time the Vest app
renews it. If no Vest tab is open, the extension opens one in the background.
3. The Vest firm's credential on this dashboard is the **wallet address** of
that login, and it binds itself: the first relayed session fills it in when
the firm has none. A saved wallet is never replaced. If the tab is signed in
to a different login, the firm says so; remove its credential to switch.
With two logins relayed at once, nothing binds by itself, and the credential
form offers each relayed wallet with one click.
With no live session for the wallet, the client sits unauthenticated and says
so; it never tries to log in.
What the client reads from Vest (lib/vest-class.ts, mapped from the web app's
own traffic since Vest publishes no API):
- Accounts: every eval and funded account on the login, named for their rules,
e.g. `V50K-E-D3-T10-34` (50K eval, 3% daily loss, 10% target, attempt 34),
`V50K-F-D3-34` (funded) and `V25K-I-7` (instant). Account-type prefixes on the
master match those names.
- Floors: Vest states each account's max-drawdown floor (the auto-liq threshold
here) and daily-loss floor. The engine's daily room is never more than Vest
leaves.
- Fees and leverage: each symbol's taker fee (0.25 bp on the index and metal
perps) and margin rate, from Vest's exchange info. Set the firm's
**Commission %** to the same (`0.0025`) for the pre-trade checks.
- Exits: the take-profit and stop are legs of the position on Vest's side,
and Vest cancels the other when one triggers.
- Trades: every position on the accounts, the bot's and anyone else's, from
Vest's position history (`GET /v3/positions`) — size, open and close price,
P&L, fees, funding and why it closed (stop loss, take profit, liquidation,
or an order on the account). Read hourly and a minute after a position
closes, into the `trades` table. Vest can't tell an API order from a click,
so the bot marks its own opens and closes as it sends them; anything
unmarked since marking began was someone else's. The **Trades** button on
the Vest firm's dashboard row lists them, and the account page shows its own.
- Claims: the bot claims a funded account's profit by itself at each stage's
target — whether or not the scheduler is running (a claim opens nothing).
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 (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.
`npm run check` includes the Vest helpers (scripts/check-vest.mjs).
---
+168 -39
View File
@@ -17,6 +17,8 @@ import {
import type { FirmConfig, FirmState, AccountState, AccountConfig } from '@/types';
import { stagePillClasses } from '@/lib/stage-colors';
import { projectPerfectPath } from '@/lib/trading-logic';
import { RulesDate } from '@/app/components/RulesDate';
import { TradesTable } from '@/app/components/TradesTable';
/** Reassign this account to another of its firm's account types, or hand it
* back to the auto-resolver. Mirrors the control on the dashboard. */
@@ -81,6 +83,106 @@ function fmt(value: number) {
return value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
/** Claim now: the account's whole profit above its size, to the firm's main
* account (Vest's Claim Profit) — for payout accounts on a firm that takes
* claims by API. Confirmed inline, since it moves money. */
function ClaimNowButton({ accountId, onDone }: { accountId: number; onDone: () => void }) {
type Claimable = { claimable: number; target: number | null; firm: string } | { claimable: null; reason: string; unsupported: boolean };
const [info, setInfo] = useState<Claimable | null>(null);
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
const check = useCallback(async () => {
try {
const res = await fetch(`/api/accounts/${accountId}/claim`, { cache: 'no-store' });
if (res.ok) setInfo(await res.json());
} catch {
/* keep the last answer */
}
}, [accountId]);
useEffect(() => {
void check();
const t = setInterval(() => { void check(); }, 10_000);
return () => clearInterval(t);
}, [check]);
async function claim(amount: number) {
setBusy(true);
setMessage(null);
try {
const res = await fetch(`/api/accounts/${accountId}/claim`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
setMessage({
ok: true,
text: `Claimed $${fmt(body.claimed)}${body.received != null ? ` — $${fmt(body.received)} to your main account` : ''} · stage ${body.stage} next`,
});
onDone();
} catch (err) {
setMessage({ ok: false, text: err instanceof Error ? err.message : 'Claim failed' });
} finally {
setBusy(false);
setConfirming(false);
void check();
}
}
if (!info || (info.claimable === null && info.unsupported)) return null;
if (confirming && info.claimable !== null) {
const amount = info.claimable;
return (
<div className="flex items-center gap-2">
<span className="text-xs text-slate-600">
Claim ${fmt(amount)} to your {info.firm} main account? The firm pays your split of it.
</span>
<button
onClick={() => { void claim(amount); }}
disabled={busy}
className="px-2.5 py-1 bg-emerald-600 hover:bg-emerald-700 disabled:opacity-50 text-white text-xs font-semibold rounded-md transition-colors"
>
{busy ? 'Claiming…' : `Claim $${fmt(amount)}`}
</button>
<button
onClick={() => setConfirming(false)}
disabled={busy}
className="px-2.5 py-1 bg-slate-100 hover:bg-slate-200 disabled:opacity-50 text-slate-600 text-xs font-semibold rounded-md transition-colors"
>
Cancel
</button>
</div>
);
}
return (
<div className="flex items-center gap-2">
{message && <span className={`text-xs ${message.ok ? 'text-emerald-600' : 'text-red-500'}`}>{message.text}</span>}
{info.claimable !== null ? (
<button
onClick={() => { setMessage(null); setConfirming(true); }}
className="px-2.5 py-1 bg-emerald-50 hover:bg-emerald-100 text-emerald-700 text-xs font-semibold rounded-md transition-colors"
title={`Claim the whole profit above the account size now${info.target !== null ? ` (the bot claims by itself at the stage's $${fmt(info.target)} target)` : ''}`}
>
Claim now · ${fmt(info.claimable)}
</button>
) : (
<span
className="px-2.5 py-1 bg-slate-100 text-slate-400 text-xs font-semibold rounded-md cursor-not-allowed"
title={info.reason}
>
Claim now — {info.reason}
</span>
)}
</div>
);
}
/** Wipes the account's cached P&L / fund / meta rows and refetches from
* Tradovate. Confirmed inline because rows older than the report's 28-day
* window cannot come back. */
@@ -180,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
@@ -385,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) {
@@ -409,8 +514,14 @@ export default function AccountPage() {
const fundTransactions = account.fundTransactions ?? [];
const fundMap = new Map(fundTransactions.map((f) => [f.date, f.amount]));
const hasLossLimit = cfg != null && cfg.minDayPnL !== -999;
const daysPassed = cfg != null && account.daysTraded >= cfg.minTradingDays;
// 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.
// 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 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.
@@ -427,7 +538,6 @@ export default function AccountPage() {
const profitPassed = cfg != null && totalProfit >= (account.effectiveProfitTarget ?? cfg.profitTarget);
// dailyTarget is computed server-side in the state API — single source of truth.
const dailyTarget = account.dailyTarget ?? null;
const lossPassed = !hasLossLimit || (cfg != null && totalProfit >= cfg.minDayPnL);
// Consistency target: the total profit level at which the best day no longer
// violates the consistency ratio. Only meaningful once a positive day exists.
@@ -448,13 +558,19 @@ export default function AccountPage() {
}
const sortedEvents = [...allDates].sort().map((d) => eventsByDate.get(d)!);
// Running equity after each event, summed here rather than inside the
// map below: a render callback mustn't reassign an outer variable.
const runningAfter: number[] = [];
let running = 0;
for (const ev of sortedEvents) {
if (ev.fundAmt !== undefined) running += ev.fundAmt; // withdrawals reduce equity
if (ev.pnl !== undefined) running += ev.pnl;
runningAfter.push(running);
}
const equityData = [
{ label: '', equity: 0, pnl: 0, origin: true, pos: 0, neg: 0, isWithdrawal: false },
...sortedEvents.map((ev) => {
if (ev.fundAmt !== undefined) running += ev.fundAmt; // withdrawals reduce equity
if (ev.pnl !== undefined) running += ev.pnl;
const equity = Math.round(running * 100) / 100;
...sortedEvents.map((ev, i) => {
const equity = Math.round(runningAfter[i] * 100) / 100;
return {
label: fmtDate(ev.date),
equity,
@@ -493,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,
@@ -501,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,
@@ -517,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]));
@@ -548,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);
}
@@ -576,6 +704,7 @@ export default function AccountPage() {
onAssigned={() => { void load(); }}
/>
)}
<RulesDate account={account} firm={firm} onSaved={() => { void load(); }} />
<span className="text-slate-300">/</span>
<h1 className="text-slate-900 font-bold text-sm">{account.name}</h1>
{isDead && (
@@ -583,7 +712,10 @@ export default function AccountPage() {
☠ DEAD
</span>
)}
<WipeDataButton accountId={account.id} onDone={() => { void load(); }} />
<div className="ml-auto flex items-center gap-2">
<ClaimNowButton accountId={account.id} onDone={() => { void load(); }} />
<WipeDataButton accountId={account.id} onDone={() => { void load(); }} />
</div>
</div>
{isDead && (
@@ -610,7 +742,8 @@ export default function AccountPage() {
positive={profitPct != null && profitPct >= 0}
/>
<PerfRow label="Trading Days" value={account.daysTraded} />
<PerfRow label="Daily Loss Limit" value={hasLossLimit ? `$${fmt(cfg!.minDayPnL)}` : 'None'} />
<PerfRow label="Daily Loss Limit" value={dailyLossLimit !== null ? `$${fmt(dailyLossLimit)}` : 'None'} />
<PerfRow label="Min Day P&L" value={minDayPnL !== null ? `$${fmt(minDayPnL)}` : 'None'} />
</div>
<div className="flex flex-col w-1/2 bg-white border border-slate-200 rounded-xl shadow-sm p-6">
@@ -630,15 +763,8 @@ export default function AccountPage() {
<ObjRow
passed={daysPassed}
label="Trading Days"
value={cfg ? `${account.daysTraded} of ${cfg.minTradingDays}` : '—'}
value={cfg ? `${account.daysTraded} of ${minTradingDays}` : '—'}
/>
{hasLossLimit && (
<ObjRow
passed={lossPassed}
label="Daily Loss Limit"
value={`$${fmt(cfg!.minDayPnL)}`}
/>
)}
{dailyTarget != null && (
<div className="flex items-center justify-between py-3 border-b border-slate-100 last:border-0">
<span className="text-slate-500 text-sm">Next Trading Day Amount</span>
@@ -790,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 (
@@ -840,6 +967,8 @@ export default function AccountPage() {
</div>
)}
<TradesTable query={`accountId=${accountId}`} title="Trades" />
{/* Cash History Table */}
{(displayPnL.length > 0 || fundTransactions.length > 0) && (
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-6 mt-5">
+34
View File
@@ -0,0 +1,34 @@
import { NextResponse } from 'next/server';
import { claimableNow, claimAccountNow, ClaimRefusedError } from '@/lib/auto-trade';
import { errorMessage } from '@/lib/platform-client';
/**
* Claim now (the account page's button). GET: how much the account could
* claim right now — its whole profit above the account size — or why not.
* POST { amount }: claim it, to the login's main account (Vest's Claim
* Profit). Only a person pressing the button calls this; the bot claims on
* its own at each stage's target (runClaims).
*/
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return NextResponse.json(claimableNow(Number(id)));
}
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
// JSON only: a cross-site form or no-cors fetch can't send it without a
// preflight this route never answers.
if (!(req.headers.get('content-type') ?? '').includes('application/json')) {
return NextResponse.json({ error: 'JSON body required' }, { status: 415 });
}
const { id } = await params;
const body = await req.json().catch(() => ({})) as { amount?: unknown };
const amount = Number(body.amount);
if (!Number.isFinite(amount)) return NextResponse.json({ error: 'amount required' }, { status: 400 });
try {
return NextResponse.json({ ok: true, ...await claimAccountNow(Number(id), amount) });
} catch (err) {
if (err instanceof ClaimRefusedError) return NextResponse.json({ error: err.message }, { status: 400 });
console.error(`[claim-now] account ${id}: ${errorMessage(err)}`);
return NextResponse.json({ error: `the claim failed: ${errorMessage(err)}` }, { status: 502 });
}
}
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { setRulesDate } from '@/lib/db';
import { sortableIso } from '@/lib/kept-rules';
type Params = { params: Promise<{ id: string }> };
/** A rules date may run this far ahead of the server's clock — room for a
* browser whose clock or time zone is ahead, not for dating accounts later. */
const MAX_FUTURE_MS = 24 * 60 * 60 * 1000;
function parseAccountId(raw: string): number | null {
const id = parseInt(raw, 10);
return Number.isNaN(id) ? null : id;
}
/** Set an account's rules date by hand. The date decides which of its type's
* rule sets it trades on — one dated before a firm's rule change keeps the
* old rules — and a date set here is never moved by broker evidence again.
* The trade path reads it per tick, so nothing needs re-resolving. */
export async function PUT(req: NextRequest, { params }: Params) {
const { id: idStr } = await params;
const accountId = parseAccountId(idStr);
if (accountId === null) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
const body = await req.json().catch(() => null) as { rulesDate?: unknown } | null;
const raw = body?.rulesDate;
const ms = typeof raw === 'string' ? Date.parse(raw) : NaN;
if (!Number.isFinite(ms)) {
return NextResponse.json({ error: 'rulesDate must be an ISO date' }, { status: 400 });
}
if (ms > Date.now() + MAX_FUTURE_MS) {
return NextResponse.json({ error: 'A rules date cannot be more than a day ahead' }, { status: 400 });
}
// Stored as a plain four-digit-year toISOString(), the one format every
// rules-date writer uses — lowerAutoRulesDate compares them as strings,
// so nothing before 2000 (or that doesn't print as one) gets in.
const rulesDate = sortableIso(ms);
if (!rulesDate) {
return NextResponse.json({ error: 'A rules date cannot be before 2000' }, { status: 400 });
}
if (!setRulesDate(accountId, rulesDate, true)) {
return NextResponse.json({ error: 'Unknown account' }, { status: 404 });
}
return NextResponse.json({ ok: true, rulesDate });
}
+35
View File
@@ -0,0 +1,35 @@
import { getSetting } from '@/lib/db';
import { corsJson, corsPreflight } from '../cors';
/** The extension's 2FA lookup. It knows the tab's host; this dashboard knows
* which person it is (the master-issued `instance_id`) and where the master
* is, so it asks the master for this person's codes on that site and passes
* them through.
*
* Never an error status: no link to the master, or a master that can't be
* reached, is just "no codes" with a reason — the extension shows nothing. */
export async function GET(request: Request) {
const host = new URL(request.url).searchParams.get('host')?.trim().toLowerCase();
if (!host) return corsJson({ codes: [], reason: 'host is required' });
const master = getSetting('master_dashboard_url')?.trim().replace(/\/+$/, '');
const person = getSetting('instance_id')?.trim();
if (!master || !person) return corsJson({ codes: [], reason: 'not linked to master' });
const url = `${master}/api/otp?person=${encodeURIComponent(person)}&host=${encodeURIComponent(host)}`;
try {
const res = await fetch(url, { cache: 'no-store', signal: AbortSignal.timeout(5000) });
if (!res.ok) return corsJson({ codes: [], reason: `master answered ${res.status}` });
const data = (await res.json()) as { codes?: unknown[]; serverTime?: number; scope?: string };
// A master from before per-person lookups ignores ?person= and answers
// with every stored code — never show those.
if (data.scope !== 'person') return corsJson({ codes: [], reason: 'master is too old for 2FA lookups' });
return corsJson({ codes: data.codes ?? [], serverTime: data.serverTime ?? Date.now() });
} catch (err) {
return corsJson({ codes: [], reason: `master unreachable: ${(err as Error).message}` });
}
}
export async function OPTIONS() {
return corsPreflight();
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { getClients, resetClients } from '@/lib/clients';
import { getTradableFirms } from '@/lib/firm-catalog';
import { getAccountConfig } from '@/lib/account-type-resolver';
import { combineCmeSessions, computeDayPlan, resolveEffectiveConfig, currentTradeDateCT } from '@/lib/trading-logic';
import axios from 'axios';
import axios from '@/lib/http-client';
/**
* Per-account snapshot of everything computeDayPlan sees, plus the book-balance
+6 -2
View File
@@ -16,7 +16,9 @@ type Params = { params: Promise<{ id: string }> };
* and login domain are set on the master, per firm). TopstepX firms need the
* API key; username is optional for now (the loginKey call still wants it,
* so the client sits idle with a clear error until one is added) and
* password is unused. */
* password is unused. Vest firms need the login's wallet address in the API
* key slot: Vest's session is relayed by the extension from a signed-in Vest
* tab (lib/vest-session.ts), and the address says whose to trade. */
export async function PUT(req: NextRequest, { params }: Params) {
const { id } = await params;
@@ -33,7 +35,9 @@ export async function PUT(req: NextRequest, { params }: Params) {
? 'apiKey is required for a TopstepX firm'
: firm.platform === 'dxtrade'
? 'username and password are required for a DXtrade firm'
: 'username and password are required';
: firm.platform === 'vest'
? 'a Vest firm needs the wallet address of the Vest login (0x followed by 40 hex characters)'
: 'username and password are required';
return NextResponse.json({ error }, { status: 400 });
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getClients } from '@/lib/clients';
import axios from 'axios';
import axios from '@/lib/http-client';
export async function GET(
req: NextRequest,
+6 -2
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCachedFirm } from '@/lib/firm-catalog';
import { getCachedFirm, getKeptRulesFor } from '@/lib/firm-catalog';
import { firmInstruments, instrumentsSource } from '@/lib/instruments';
import { getCredentials } from '@/lib/db';
@@ -25,7 +25,11 @@ export async function GET(
hasCredentials: !!creds,
instruments: firmInstruments(firm),
instrumentsSource: instrumentsSource(firm),
accounts: firm.accountTypes,
// Same shape as GET /api/firms: each type with its old rule sets' windows.
accounts: firm.accountTypes.map((a) => ({
...a,
kept: getKeptRulesFor(a.id).map((k) => ({ id: k.id, openedAt: k.openedAt, closedAt: k.closedAt })),
})),
catalogUpdatedAt: firm.updatedAt,
});
}
+11 -2
View File
@@ -1,8 +1,13 @@
import { NextResponse } from 'next/server';
import { firmInstruments, instrumentsSource } from '@/lib/instruments';
import { getCachedCatalog, hasPlatformCredentials } from '@/lib/firm-catalog';
import { getCachedCatalog, getKeptRulesFor, hasPlatformCredentials } from '@/lib/firm-catalog';
import { getCredentials } from '@/lib/db';
import type { FirmConfig } from '@/types';
import type { FirmConfig, KeptRulesWindow } from '@/types';
/** The windows of a live account type's old rule sets, by closedAt. */
function keptWindows(accountTypeId: string): KeptRulesWindow[] {
return getKeptRulesFor(accountTypeId).map((k) => ({ id: k.id, openedAt: k.openedAt, closedAt: k.closedAt }));
}
/**
* The firms this machine knows about — the master's catalog, annotated with
@@ -14,6 +19,9 @@ import type { FirmConfig } from '@/types';
*
* The stored password is deliberately not returned. It used to be, and nothing
* ever read it.
*
* Each account type carries the windows of the old rule sets it keeps (`kept`)
* so the dashboard's rules-date control can say which rules a date lands on.
*/
export async function GET() {
try {
@@ -48,6 +56,7 @@ export async function GET() {
withdrawalStages: a.withdrawalStages,
dailyLossLimit: a.dailyLossLimit ?? 0,
tradesEveryDay: a.tradesEveryDay === true,
kept: keptWindows(a.id),
})),
catalogUpdatedAt: firm.updatedAt,
};
+13 -1
View File
@@ -1,8 +1,9 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSetting, setSetting } from '@/lib/db';
import { scheduleBackupPush } from '@/lib/backup-sync';
import { getRequestUserAgent } from '@/lib/request-user-agent';
const VALID_KEYS = ['max_concurrent_accounts', 'tick_interval_seconds', 'master_dashboard_url', 'instance_name', 'trading_hours', 'twocaptcha_api_key', 'twocaptcha_proxy', 'master_backup_token', 'proxy_url'] as const;
const VALID_KEYS = ['max_concurrent_accounts', 'tick_interval_seconds', 'master_dashboard_url', 'instance_name', 'trading_hours', 'twocaptcha_api_key', 'twocaptcha_proxy', 'master_backup_token', 'proxy_url', 'auto_claim', 'auto_activate'] as const;
type SettingKey = typeof VALID_KEYS[number];
export async function GET() {
@@ -13,6 +14,8 @@ export async function GET() {
// Read-only on purpose (not in VALID_KEYS): the master issues this uuid,
// and editing it by hand would detach the instance from its person.
result.instance_id = getSetting('instance_id');
// Generated once per instance. Callers cannot rotate an active identity.
result.user_agent = getRequestUserAgent();
result.backup_last_push_at = getSetting('backup_last_push_at');
result.backup_last_error = getSetting('backup_last_error');
return NextResponse.json(result);
@@ -22,6 +25,15 @@ export async function PATCH(req: NextRequest) {
try {
const body = await req.json() as Partial<Record<SettingKey, string | number>>;
// Claims and activations run unless it reads exactly 'off' — say so
// exactly, and before anything is written.
for (const key of ['auto_claim', 'auto_activate'] as const) {
const v = body[key];
if (v !== undefined && v !== null && v !== 'on' && v !== 'off') {
return NextResponse.json({ error: `${key} is 'on' or 'off'` }, { status: 400 });
}
}
for (const key of VALID_KEYS) {
if (key in body) {
const raw = body[key];
+69 -33
View File
@@ -1,15 +1,17 @@
import { NextResponse } from 'next/server';
import { loadDailyPnL } from '@/lib/db';
import { listAssignments, loadDailyPnL } from '@/lib/db';
import { getClients } from '@/lib/clients';
import { getCachedCatalog } from '@/lib/firm-catalog';
import { getAccountTypeById, getCachedCatalog } from '@/lib/firm-catalog';
import { ruleDiffs } from '@/lib/kept-rules';
import { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/account-type-resolver';
import { historyFresh } from '@/lib/platform-client';
import { rankNextLeg } from '@/lib/leg-estimate';
import { rankNextLeg, dayLossRoom } from '@/lib/leg-estimate';
import { obfuscationDayBooked } from '@/lib/auto-trade';
import type { AccountState } from '@/types';
import {
combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, currentTradeDateCT,
obfuscationStopDollars, dllRoom as computeDllRoom, dllStopDollars, botStopsActive,
maxLossRoom, lastStand as isLastStand,
combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, tradeDateFor,
obfuscationStopDollars, dllStopDollars, botStopsActive, stageInputsFor, stagePayouts,
maxLossRoom, lastStand as isLastStand, shortestNextLeg,
LEG_COMPLETION_EPSILON,
} from '@/lib/trading-logic';
@@ -17,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);
@@ -28,6 +29,14 @@ 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)
.filter((row) => row.rules_date_manual === 1)
.map((row) => row.account_id));
const accounts = client.accountList.map((acc) => {
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
@@ -54,8 +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)
const stage = 1 + 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(
@@ -66,7 +78,8 @@ export async function GET() {
cfg.withdrawalStages,
priorProfit,
allFundTxns,
cfg.minDayPnL
cfg.minDayPnL,
stageInputs,
) : null;
// Days traded counts only days that hit minDayPnL (when set) — that's what
// the firm requires toward the min trading day rule.
@@ -83,6 +96,20 @@ export async function GET() {
const ambiguous = cfg
? matchAccountType(acc.name, inferredAccountSize, f.accountTypes).ambiguous
: false;
// An account on one of its type's old rule sets: cfg already
// carries those rules; say which set, and how it differs from
// the type's current rules.
const live = resolved.keptRules && resolved.accountTypeId
? getAccountTypeById(resolved.accountTypeId)
: undefined;
const keptRules: AccountState['keptRules'] = resolved.keptRules && cfg && live
? {
id: resolved.keptRules.id,
openedAt: resolved.keptRules.openedAt,
closedAt: resolved.keptRules.closedAt,
differs: ruleDiffs(cfg, live),
}
: null;
let targetHit = false;
let dailyTarget: { amount: number; path: 'first_day' | 'normal_day' | 'reduced_day' } | null = null;
let remainingToday: number | null = null;
@@ -107,39 +134,29 @@ export async function GET() {
// the day after a loss rides stopless) against the hard
// daily-loss stop, before fees. A min day trades its whole
// remaining target as one leg.
const dll = cfg.dailyLossLimit ?? 0;
if (dll > 0) {
const dayStart = client.dayStartBalance?.(acc.id);
dllRoom = dayStart === null ? null : computeDllRoom({
dailyLossLimit: dll,
closedBalance: cash.amount,
dayStartBalance: dayStart,
realizedToday: Math.min(cash.realizedPnL, combined.realizedPnL),
});
}
const dll = effective?.dailyLossLimit ?? 0;
dllRoom = dayLossRoom(client, acc.id, dll, cash, combined.realizedPnL, plan ? shortestNextLeg(plan) : 0);
// The max-drawdown floor, and whether the next leg is a last
// stand — no daily-loss stop — as fireLeg decides it.
const floorRoom = maxLossRoom({
balance: cash.amount, accountSize: cfg.accountSize, maxLoss: cfg.maxLoss ?? 0,
autoLiqThreshold: client.autoLiqThresholds?.[acc.id],
});
lastStandNext = dllRoom !== null && isLastStand({ dllRoom, dailyLossLimit: dll, maxLossRoom: floorRoom });
if (plan && (botStopsActive(cfg))) {
const obf = obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday);
const hard = dllRoom !== null && !lastStandNext
? dllStopDollars({ room: dllRoom, dailyLossLimit: dll, entryFee: 0, commissionPct: 0 })
: Infinity;
const stop = Math.min(Number.isFinite(obf) && obf > 0 ? obf : Infinity, hard);
if (Number.isFinite(stop) && stop > 0) nextStop = Math.round(stop * 100) / 100;
}
// A day an obfuscation stop booked has no next leg — the
// make-back rides tomorrow, stopless — so no next stop, last
// stand or hold to show. Its remaining target has grown by
// the loss: a $150 min day stopped at −$306 would read
// "stop $912" for a leg that never fires.
const dayBooked = obfuscationDayBooked(cfg, acc.id, tradeDate, combined.realizedPnL) !== null;
lastStandNext = !dayBooked && dllRoom !== null && isLastStand({ dllRoom, dailyLossLimit: dll, maxLossRoom: floorRoom });
// DXtrade: the next leg sized on every surveyed instrument as
// fireLeg would, and the one a batch would pick.
if (plan && plan.remainingToday > 0) {
if (plan && !dayBooked && plan.remainingToday > 0) {
const best = rankNextLeg({
client, accountId: acc.id, cfg, firm: f, leg: plan.remainingToday, dllRoom,
client, accountId: acc.id, cfg: { ...cfg, dailyLossLimit: dll }, firm: f, leg: plan.remainingToday, dllRoom,
obfuscationDollars: obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday),
cash,
cash, tradeDate: combined.tradeDate,
})[0];
if (best) {
holdEstimate = {
@@ -156,6 +173,16 @@ export async function GET() {
};
}
}
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.
const hard = dllRoom !== null && !lastStandNext
? dllStopDollars({ room: dllRoom, dailyLossLimit: dll, entryFee: 0, commissionPct: 0, notional: holdEstimate?.notional, slippageFraction: client.stopSlippageFraction })
: Infinity;
const stop = Math.min(Number.isFinite(obf) && obf > 0 ? obf : Infinity, hard);
if (Number.isFinite(stop) && stop > 0) nextStop = Math.round(stop * 100) / 100;
}
// Effective profit target = max(stage target, consistency realTarget).
// If a big day forces the consistency rule, the account must reach maxDay/consistency
@@ -226,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,
@@ -244,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]
@@ -268,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,
@@ -282,6 +315,9 @@ export async function GET() {
accountType: cfg,
inferredAccountSize,
accountTypeAmbiguous: ambiguous,
rulesDate: resolved.rulesDate,
rulesDateManual: rulesDateByHand.has(acc.id),
keptRules,
};
});
return { firmId: f.id, firm: f.name, connected, accounts };
+59
View File
@@ -0,0 +1,59 @@
import { NextResponse } from 'next/server';
import { getClients } from '@/lib/clients';
import { loadTrades, getSetting, TRADES_SINCE_SETTING } from '@/lib/db';
import type { TradeView } from '@/types';
/**
* GET /api/trades?accountId=… | ?firmId=… [&limit=…] — recorded positions,
* newest first: one account, a firm's current accounts, or all of them.
*
* Who opened and closed each one: the bot marks its own orders as it sends
* them, so after marking began (TRADES_SINCE_SETTING) anything unmarked was
* someone else's. A close Vest calls "user_requested" is an order on the
* account — the bot's if it marked it, else outside. Trades built from
* futures fills (lib/trade-recorder.ts, DXtrade's record) carry their marks
* already: 'bot', 'outside', or 'unknown' from before the bot noted its own
* orders.
*/
export async function GET(req: Request) {
const url = new URL(req.url);
const accountId = Number(url.searchParams.get('accountId'));
const firmId = url.searchParams.get('firmId');
const limit = Math.min(Math.max(Math.floor(Number(url.searchParams.get('limit'))) || 200, 1), 1000);
let accountIds: number[] | undefined;
if (Number.isFinite(accountId) && accountId > 0) accountIds = [accountId];
else if (firmId) accountIds = getClients().get(firmId)?.accountList.map((a) => a.id) ?? [];
const since = Number(getSetting(TRADES_SINCE_SETTING) ?? NaN);
const tracked = (at: number) => Number.isFinite(since) && at >= since;
const trades: TradeView[] = loadTrades({ accountIds, limit }).map((t) => {
const open = t.closedAt === null && t.closeReason === null && t.closedBy === null;
const closedBy = open ? null
: t.closedBy === 'bot' ? 'bot'
: t.closeReason === 'user_requested' ? (t.closedBy === 'outside' || tracked(t.closedAt ?? t.openedAt) ? 'outside' : 'user_requested')
: t.closeReason ?? (t.closedBy === 'outside' ? 'outside' : t.closedBy === 'unknown' ? 'user_requested' : 'unknown');
const net = open || t.pnl === null ? null
: Math.round((t.pnl - Math.abs(t.fee ?? 0) + (t.funding ?? 0)) * 100) / 100;
return {
positionId: t.positionId,
accountId: t.accountId,
accountName: t.accountName,
symbol: t.symbol,
side: t.side,
quantity: t.quantity,
openPrice: t.openPrice,
closePrice: open ? null : t.closePrice,
pnl: open ? null : t.pnl,
fee: t.fee,
funding: t.funding,
net,
openedAt: t.openedAt,
closedAt: t.closedAt,
openedBy: t.openedBy === 'bot' ? 'bot' : t.openedBy === 'unknown' ? null : t.openedBy === 'outside' || tracked(t.openedAt) ? 'outside' : null,
closedBy,
};
});
return NextResponse.json(trades);
}
+62
View File
@@ -0,0 +1,62 @@
import { NextRequest } from 'next/server';
import { putVestSession, listVestSessions } from '@/lib/vest-session';
import { autoBindVestWallet } from '@/lib/vest-wallet';
import { getTradableFirms } from '@/lib/firm-catalog';
import { getClients } from '@/lib/clients';
import { corsJson, corsPreflight } from '../../autobuyer/cors';
/** The wallets this machine's Vest firms trade (their saved credential), lower-case. */
function wantedWallets(): string[] {
const wallets = getTradableFirms()
.filter((f) => f.platform === 'vest')
.map((f) => f.apiKey.trim().toLowerCase());
return [...new Set(wallets)];
}
/**
* The extension's Vest relay (lib/vest-session.ts): POST the access token the
* signed-in Vest tab just renewed — { accessToken, expiresAt, primaryAddr,
* userId } — and it is held in memory for this machine's Vest client. A Vest
* firm here with no credential yet is bound to that login's wallet
* (lib/vest-wallet.ts). The extension posts again every minute, so a restart
* here is back on the session within a minute. GET
* lists what has been relayed (never the tokens) and which wallets this
* machine wants, so the extension knows whether to keep a Vest tab open and
* the dashboard can offer the address as the firm's credential.
*
* Same LAN-only, any-origin CORS as the AutoBuyer routes: the extension's
* service worker calls it.
*/
export async function POST(req: NextRequest) {
let body: Record<string, unknown>;
try {
body = await req.json() as Record<string, unknown>;
} catch {
return corsJson({ error: 'JSON body required' }, { status: 400 });
}
const refused = putVestSession({
accessToken: body.accessToken,
expiresAt: body.expiresAt,
primaryAddr: body.primaryAddr,
userId: body.userId,
});
if (refused) return corsJson({ ok: false, reason: refused }, { status: 400 });
const bound = autoBindVestWallet();
const wanted = wantedWallets();
const addr = String(body.primaryAddr).toLowerCase();
// A client waiting on this wallet's session syncs now, not at its next poll.
for (const client of getClients().values()) {
const vest = client as { onVestSession?: (wallet: string) => void };
vest.onVestSession?.(addr);
}
return corsJson({ ok: true, used: wanted.includes(addr), wanted, bound });
}
export async function GET() {
return corsJson({ sessions: listVestSessions(), wanted: wantedWallets() });
}
export async function OPTIONS() {
return corsPreflight();
}
+317
View File
@@ -0,0 +1,317 @@
'use client';
import { useRef, useState, type MouseEvent } from 'react';
import type { AccountState, FirmAccountType, FirmConfig, KeptRulesWindow } from '@/types';
import { evidenceDay, pickedDayRulesDate, pickKeptRules, unchangedDaySaves } from '@/lib/kept-rules';
/**
* An account's rules date, shown next to its name, and the "old rules" chip.
*
* When a firm's rules change on the master with "New accounts only", accounts
* dated before the change keep the old rules. The date defaults to when the
* account started (or when this machine first saw it); clicking it edits it
* inline — a past day is stored as its start, today as now (the master's
* "today means from now") — with a preview of which rules that lands on.
* Used on the dashboard row and the account page.
*/
/** What the master's Firms page calls each rule field (its lib/kept-rules.ts
* ruleLabel), capitalized — so an "old rules" difference reads the same on
* both. */
const RULE_LABELS: Record<string, string> = {
profitTarget: 'Profit target',
consistency: 'Consistency',
minDayPnL: 'Min day P&L',
minTradingDays: 'Min trading days',
maxLoss: 'Max drawdown',
maxPositionSize: 'Max contracts',
targetSameEquity: 'Same equity after first w/d',
splitEnabled: 'Split legs',
minLegs: 'Min legs',
firstDayStop: 'Day 1 $1k stop',
minDayStop: 'Min-day 2× stop',
combineCmeHolidays: 'CME holiday combining',
withdrawalStages: 'Withdrawal stages',
payoutable: 'Payoutable',
trailingDrawdown: 'Trailing drawdown',
dailyLossLimit: 'Hard daily loss',
};
function money(n: number): string {
return `$${n.toLocaleString('en-US', { maximumFractionDigits: 2 })}`;
}
/** One rule value the way the Firms page shows it: money as $3,000, ratios as
* 50%, switches on/off, stages by count. */
function formatRule(field: string, value: unknown): string {
if (value === undefined || value === null) return 'unset';
if (typeof value === 'boolean') return value ? 'on' : 'off';
if (Array.isArray(value)) {
return value.length === 0 ? 'none' : `${value.length} stage${value.length === 1 ? '' : 's'}`;
}
if (typeof value !== 'number') return JSON.stringify(value);
switch (field) {
case 'consistency':
return value === 0 ? 'none' : `${Math.round(value * 100)}%`;
case 'minDayPnL':
return value <= -999 ? 'none' : money(value);
case 'maxPositionSize':
return value === 0 ? 'no limit' : String(value);
case 'minLegs':
return value === 0 ? 'auto' : String(value);
case 'trailingDrawdown':
return value === 0 ? 'unset' : money(value);
case 'maxLoss':
case 'dailyLossLimit':
return value === 0 ? 'none' : money(Math.abs(value));
case 'profitTarget':
return money(value);
default:
return String(value);
}
}
/** "Consistency 50% (type now 40%)". Two edits that summarize alike — three
* stages either way — just say the field changed. */
function describeDiff(d: { field: string; kept: unknown; now: unknown }): string {
const label = RULE_LABELS[d.field] ?? d.field;
const kept = formatRule(d.field, d.kept);
const now = formatRule(d.field, d.now);
return kept === now ? `${label} changed since` : `${label} ${kept} (type now ${now})`;
}
/** "Oct 18, 2026, 2:05 PM". */
function fullWhen(iso: string): string {
return new Date(iso).toLocaleString('en-US', {
month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit',
});
}
/** "Oct 12", with the year when it isn't this year's; `time` adds "2:05 PM". */
function shortWhen(iso: string, time = false): string {
const d = new Date(iso);
const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' };
if (d.getFullYear() !== new Date().getFullYear()) opts.year = 'numeric';
if (time) {
opts.hour = 'numeric';
opts.minute = '2-digit';
}
return d.toLocaleString('en-US', opts);
}
/** Which rules dates a kept set covers, for the chip's tooltip. */
function windowText(k: KeptRulesWindow): string {
return k.openedAt
? `kept for accounts dated from ${fullWhen(k.openedAt)} to before ${fullWhen(k.closedAt)}`
: `kept for accounts dated before ${fullWhen(k.closedAt)}`;
}
/** The same, short, for the editor's preview line. */
function windowShort(k: KeptRulesWindow): string {
return k.openedAt
? `${shortWhen(k.openedAt, true)} – ${shortWhen(k.closedAt, true)}`
: `before ${shortWhen(k.closedAt, true)}`;
}
/** "Oct 12" for a calendar day (YYYY-MM-DD) itself, whatever the browser's
* zone, with the year when it isn't this year's; `year` always adds it. */
function dayLabel(day: string, year = false): string {
const d = new Date(`${day}T12:00:00.000Z`);
const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', timeZone: 'UTC' };
if (year || d.getUTCFullYear() !== new Date().getFullYear()) opts.year = 'numeric';
return d.toLocaleString('en-US', opts);
}
/** YYYY-MM-DD of a moment in the browser's time zone — a date input's value. */
function localDay(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
/** A date input's value as that day's local midnight; null when unparsable. */
function localMidnight(day: string): Date | null {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day);
if (!m) return null;
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
return Number.isNaN(d.getTime()) ? null : d;
}
/** The rules date a picked day stores (lib/kept-rules.ts pickedDayRulesDate):
* today as now, any other day as its start. null when unparsable. */
function storedFor(day: string): string | null {
return pickedDayRulesDate(day, {
today: localDay(new Date()),
nowMs: Date.now(),
localMidnightMs: localMidnight(day)?.getTime() ?? NaN,
});
}
/** The amber chip on an account trading on one of its type's old rule sets. */
export function OldRulesChip({ keptRules }: { keptRules: NonNullable<AccountState['keptRules']> }) {
const lines = keptRules.differs.length > 0
? keptRules.differs.map(describeDiff)
: ['Same as the type\'s current rules'];
return (
<span
className="inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-100 text-amber-700 shrink-0 font-sans whitespace-nowrap"
title={`Old rules — ${windowText(keptRules)}\n${lines.join('\n')}`}
>
old rules
</span>
);
}
/** The rules date and, when the account is on an old rule set, its chip.
* `firm` names the firm whose type's kept windows the editor's preview
* reads (none without it) — fetched fresh each time the editor opens, as a
* page's firm listing can predate a rule change on the master; `onSaved`
* should refresh /api/state. */
export function RulesDate({ account, firm, onSaved }: {
account: AccountState;
firm: FirmConfig | null;
onSaved: () => void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState('');
// The date and day the editor opened on: the 5s state poll can move the
// date underneath (evidence lowering it), and an untouched ✓ must still
// mean "leave it", not "store the day it showed when opened".
const [opened, setOpened] = useState<{ date: string; day: string } | null>(null);
// The type's kept windows as the server has them now; null while loading
// or when unknown.
const [kept, setKept] = useState<KeptRulesWindow[] | null>(null);
const openCount = useRef(0);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
// No assignment row yet (the resolver hasn't seen the account): nothing to
// show or set.
if (account.rulesDate === null) return null;
const rulesDate = account.rulesDate;
// Broker evidence is the end of a Chicago day: shown as that day in any
// zone, not as the next one east of Chicago.
const brokerDay = evidenceDay(rulesDate);
const currentDay = brokerDay ?? localDay(new Date(rulesDate));
// What ✓ would store, and the rules it lands on.
const chosen = editing ? storedFor(draft) : null;
const landsOn = kept && chosen ? pickKeptRules(kept, chosen) : null;
const preview = !kept || !chosen
? null
: landsOn ? `→ old rules (${windowShort(landsOn)})` : '→ current rules';
function open(e: MouseEvent) {
e.stopPropagation();
setDraft(currentDay);
setOpened({ date: rulesDate, day: currentDay });
setError('');
setKept(null);
setEditing(true);
const ticket = ++openCount.current;
const fallback = firm?.accounts.find((t) => t.id === account.accountTypeId)?.kept ?? null;
if (!firm || !account.accountTypeId) return;
void (async () => {
let windows: KeptRulesWindow[] | null = fallback;
try {
const res = await fetch(`/api/firms/${encodeURIComponent(firm.id)}`);
if (res.ok) {
const data = await res.json() as { accounts?: FirmAccountType[] };
windows = data.accounts?.find((t) => t.id === account.accountTypeId)?.kept ?? null;
}
} catch {
// Offline: the page's copy is the best there is.
}
if (ticket === openCount.current) setKept(windows);
})();
}
async function save() {
const value = storedFor(draft);
if (!value || !opened) {
setError('Pick a date');
return;
}
// An unchanged day keeps the exact date, unless storing it moves the
// account to other rules — what the preview then says ✓ does.
if (draft === opened.day && !unchangedDaySaves(kept, opened.date, value)) {
setEditing(false);
return;
}
setSaving(true);
setError('');
try {
const res = await fetch(`/api/accounts/${account.id}/rules-date`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rulesDate: value }),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? 'Failed');
setEditing(false);
onSaved();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed');
} finally {
setSaving(false);
}
}
if (editing) {
return (
<span className="inline-flex items-center gap-1 font-sans font-normal" onClick={(e) => e.stopPropagation()}>
<input
type="date"
value={draft}
max={localDay(new Date())}
autoFocus
disabled={saving}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void save();
if (e.key === 'Escape') setEditing(false);
}}
className="border border-slate-200 rounded-md px-1 py-0.5 text-[11px] bg-white text-slate-700 focus:outline-none focus:ring-1 focus:ring-blue-400"
/>
<button
type="button"
onClick={() => void save()}
disabled={saving}
className="px-1.5 py-0.5 bg-blue-100 hover:bg-blue-200 disabled:opacity-50 text-blue-700 text-[11px] font-semibold rounded-md transition-colors"
title="Save the rules date"
>
{saving ? '…' : '✓'}
</button>
<button
type="button"
onClick={() => setEditing(false)}
disabled={saving}
className="px-1.5 py-0.5 bg-slate-100 hover:bg-slate-200 disabled:opacity-50 text-slate-600 text-[11px] font-semibold rounded-md transition-colors"
title="Cancel"
>
✕
</button>
{preview && <span className="text-[11px] text-slate-400 whitespace-nowrap">{preview}</span>}
{error && <span className="text-[11px] text-red-500 whitespace-nowrap">{error}</span>}
</span>
);
}
return (
<span className="inline-flex items-center gap-1.5 font-sans font-normal">
<button
type="button"
onClick={open}
className="inline-flex items-center gap-1 text-[11px] text-slate-400 hover:text-slate-600 tabular-nums whitespace-nowrap transition-colors"
title={'Rules date — accounts dated before a firm\'s rule change keep the old rules. Click to change.\n'
+ (brokerDay
? `End of ${dayLabel(brokerDay, true)} in Chicago${account.rulesDateManual ? '' : ' — when the broker shows it opened'}`
: fullWhen(rulesDate))
+ (account.rulesDateManual ? ' (set by hand)' : '')}
>
{brokerDay ? dayLabel(brokerDay) : shortWhen(rulesDate)}
{account.rulesDateManual && (
<span className="w-1 h-1 rounded-full bg-blue-400 shrink-0" title="Set by hand" />
)}
</button>
{account.keptRules && <OldRulesChip keptRules={account.keptRules} />}
</span>
);
}
+177
View File
@@ -0,0 +1,177 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import type { TradeView } from '@/types';
/**
* Recorded positions from /api/trades — every trade on the accounts, the
* bot's and anyone else's, with who opened it and how it closed. On the
* dashboard under each firm (inline) and on the account page (as a card,
* hidden while there's nothing to show).
*/
const REFRESH_MS = 30_000;
const PILL = 'inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold whitespace-nowrap';
const CLOSED_BY: Record<string, { label: string; cls: string; title: string }> = {
bot: { label: 'Bot', cls: 'bg-violet-100 text-violet-700', title: "The bot's own close order" },
outside: { label: 'Outside', cls: 'bg-amber-100 text-amber-700', title: 'Closed by an order the bot did not send — by hand on the platform, another tool, or the firm’s own liquidation' },
stop_loss: { label: 'Stop loss', cls: 'bg-red-100 text-red-700', title: 'Its stop-loss triggered' },
take_profit: { label: 'Take profit', cls: 'bg-green-100 text-green-700', title: 'Its take-profit triggered' },
liquidation: { label: 'Liquidation', cls: 'bg-red-200 text-red-800', title: 'Liquidated by Vest' },
auto_deleverage: { label: 'Auto-deleverage', cls: 'bg-red-100 text-red-700', title: 'Closed by Vest’s auto-deleverage' },
temporary_leverage_limit: { label: 'Leverage limit', cls: 'bg-orange-100 text-orange-700', title: 'Reduced by Vest’s temporary (weekend) leverage limit' },
corporate_action: { label: 'Corporate action', cls: 'bg-slate-100 text-slate-600', title: 'Closed by a corporate action' },
user_requested: { label: 'Self-requested', cls: 'bg-slate-100 text-slate-600', title: 'An order on the account closed it — from before the bot marked its own closes, so whose is unknown' },
unknown: { label: 'Unknown', cls: 'bg-slate-100 text-slate-500', title: 'Closed while the platform’s history wasn’t being read — when and how aren’t on record' },
};
function money(v: number): string {
return v.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function signed(v: number): string {
return `${v >= 0 ? '+' : '−'}$${money(Math.abs(v))}`;
}
function price(v: number | null): string {
return v === null ? '—' : v.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 });
}
function when(ms: number): string {
return new Date(ms).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
}
function OpenedBy({ by }: { by: TradeView['openedBy'] }) {
if (by === 'bot') return <span className={`${PILL} bg-violet-100 text-violet-700`} title="Opened by the bot">Bot</span>;
if (by === 'outside') return <span className={`${PILL} bg-amber-100 text-amber-700`} title="Opened by an order the bot did not send — by hand on the platform, or another tool">Outside</span>;
return <span className="text-slate-400" title="Opened before the bot began marking its own trades">—</span>;
}
function ClosedBy({ by }: { by: string | null }) {
if (by === null) return <span className={`${PILL} bg-sky-100 text-sky-700`}>Open</span>;
const c = CLOSED_BY[by] ?? { label: by, cls: 'bg-slate-100 text-slate-600', title: by };
return <span className={`${PILL} ${c.cls}`} title={c.title}>{c.label}</span>;
}
export function TradesTable({ query, title, showAccount = false, formatAccount = (n) => n, limit = 200 }: {
/** accountId=… or firmId=… */
query: string;
/** Set: render as a titled card, and nothing at all while empty. */
title?: string;
showAccount?: boolean;
formatAccount?: (name: string) => string;
limit?: number;
}) {
const [trades, setTrades] = useState<TradeView[] | null>(null);
const [failed, setFailed] = useState(false);
const load = useCallback(async () => {
try {
const res = await fetch(`/api/trades?${query}&limit=${limit}`);
if (!res.ok) throw new Error(String(res.status));
setTrades(await res.json());
setFailed(false);
} catch {
setFailed(true);
}
}, [query, limit]);
useEffect(() => {
void load();
const t = setInterval(() => { void load(); }, REFRESH_MS);
return () => clearInterval(t);
}, [load]);
if (title && (!trades || trades.length === 0)) return null;
const closed = (trades ?? []).filter((t) => t.net !== null);
const net = closed.reduce((s, t) => s + (t.net ?? 0), 0);
const fees = closed.reduce((s, t) => s + Math.abs(t.fee ?? 0), 0);
const summary = trades && trades.length > 0 && (
<span className="text-xs text-slate-400">
{trades.length} trade{trades.length === 1 ? '' : 's'}{trades.length >= limit ? ` (latest ${limit})` : ''}
{closed.length > 0 && (
<>
{' · net '}
<span className={net >= 0 ? 'text-green-600' : 'text-red-500'}>{signed(net)}</span>
{` · fees $${money(fees)}`}
</>
)}
</span>
);
const table = !trades
? <div className="text-sm text-slate-400 italic py-2">{failed ? 'Couldn’t load trades' : 'Loading trades…'}</div>
: trades.length === 0
? <div className="text-sm text-slate-400 italic py-2">No trades recorded yet — they appear a few minutes after a fill, once the platform’s history is read</div>
: (
<div className="overflow-x-auto max-h-[28rem] overflow-y-auto">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-white">
<tr className="border-b border-slate-100 text-slate-400">
<th className="text-left font-medium py-2 pr-3">Opened</th>
<th className="text-left font-medium py-2 pr-3">Closed</th>
{showAccount && <th className="text-left font-medium py-2 pr-3">Account</th>}
<th className="text-left font-medium py-2 pr-3">Market</th>
<th className="text-left font-medium py-2 pr-3">Side</th>
<th className="text-right font-medium py-2 pr-3">Size</th>
<th className="text-right font-medium py-2 pr-3">Open</th>
<th className="text-right font-medium py-2 pr-3">Close</th>
<th className="text-right font-medium py-2 pr-3" title="Realized P&L before fees and funding">P&L</th>
<th className="text-right font-medium py-2 pr-3">Fees</th>
<th className="text-right font-medium py-2 pr-3" title="P&L − fees + funding">Net</th>
<th className="text-left font-medium py-2 pr-3">Opened by</th>
<th className="text-left font-medium py-2">Closed by</th>
</tr>
</thead>
<tbody>
{trades.map((t) => (
<tr key={t.positionId} className="border-b border-slate-50 last:border-0 hover:bg-slate-50">
<td className="py-1.5 pr-3 text-slate-500 tabular-nums whitespace-nowrap">{when(t.openedAt)}</td>
<td className="py-1.5 pr-3 text-slate-500 tabular-nums whitespace-nowrap">{t.closedAt !== null ? when(t.closedAt) : '—'}</td>
{showAccount && <td className="py-1.5 pr-3 font-mono text-slate-700 whitespace-nowrap">{formatAccount(t.accountName)}</td>}
<td className="py-1.5 pr-3 text-slate-700 whitespace-nowrap">{t.symbol}</td>
<td className={`py-1.5 pr-3 font-semibold ${t.side === 'long' ? 'text-green-600' : 'text-red-500'}`}>{t.side === 'long' ? 'Long' : 'Short'}</td>
<td className="py-1.5 pr-3 text-right tabular-nums text-slate-600">{t.quantity > 0 ? t.quantity.toLocaleString(undefined, { maximumFractionDigits: 4 }) : '—'}</td>
<td className="py-1.5 pr-3 text-right tabular-nums text-slate-600">{price(t.openPrice)}</td>
<td className="py-1.5 pr-3 text-right tabular-nums text-slate-600">{price(t.closePrice)}</td>
<td className={`py-1.5 pr-3 text-right tabular-nums ${t.pnl === null ? 'text-slate-400' : t.pnl >= 0 ? 'text-green-600' : 'text-red-500'}`}>
{t.pnl === null ? '—' : signed(t.pnl)}
</td>
<td className="py-1.5 pr-3 text-right tabular-nums text-slate-500">{t.fee === null ? '—' : `$${money(Math.abs(t.fee))}`}</td>
<td className={`py-1.5 pr-3 text-right tabular-nums font-semibold ${t.net === null ? 'text-slate-400' : t.net >= 0 ? 'text-green-600' : 'text-red-500'}`}>
{t.net === null ? '—' : signed(t.net)}
</td>
<td className="py-1.5 pr-3"><OpenedBy by={t.openedBy} /></td>
<td className="py-1.5"><ClosedBy by={t.closedBy} /></td>
</tr>
))}
</tbody>
</table>
</div>
);
if (title) {
return (
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-6 mt-5">
<div className="flex items-baseline justify-between mb-4">
<h2 className="text-slate-900 font-bold text-base">{title}</h2>
{summary}
</div>
{table}
</div>
);
}
return (
<div>
<div className="flex items-baseline justify-between mb-1">
<span className="text-xs font-semibold text-slate-600">Trades</span>
{summary}
</div>
{table}
</div>
);
}
+177 -22
View File
@@ -4,6 +4,8 @@ import { useEffect, useState } from 'react';
import Link from 'next/link';
import type { FirmConfig, FirmState, AccountState } from '@/types';
import { stagePillClasses } from '@/lib/stage-colors';
import { RulesDate } from '@/app/components/RulesDate';
import { TradesTable } from '@/app/components/TradesTable';
type SortKey = 'name' | 'balance' | 'dayPnL' | 'daysTraded' | 'target' | 'status';
type SortDir = 'asc' | 'desc';
@@ -194,14 +196,91 @@ function credentialNoun(platform: FirmConfig['platform']): string {
case 'topstepx': return 'TopstepX API key';
case 'dxtrade': return 'DXtrade login';
case 'tradovate': return 'Tradovate login';
case 'vest': return 'Vest wallet address';
}
}
/** Inline credential entry, shown to the right of a firm that has none.
* Tradovate and DXtrade want username + password; TopstepX wants the API key
* (username optional for now, though login needs it eventually — password
* unused). */
* unused); Vest the login's wallet address, whose session the extension
* relays from a signed-in Vest tab. */
function CredentialForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => void }) {
if (firm.platform === 'vest') return <VestWalletForm firm={firm} onSaved={onSaved} />;
return <LoginForm firm={firm} onSaved={onSaved} />;
}
/** A Vest firm's credential: the wallet its Vest login belongs to. It binds
* itself as soon as the extension relays a Vest tab signed in on this
* machine (lib/vest-wallet.ts); this form is for when it can't — two logins
* relayed at once, or none yet — and offers the relayed wallets with one
* click. */
function VestWalletForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => void }) {
const [wallet, setWallet] = useState('');
const [seen, setSeen] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
fetch('/api/vest/session', { cache: 'no-store' })
.then((r) => r.json())
.then((d: { sessions?: { primaryAddr: string }[] }) => setSeen([...new Set((d.sessions ?? []).map((x) => x.primaryAddr))]))
.catch(() => setSeen([]));
}, []);
async function save(address: string) {
setSaving(true);
setError('');
try {
const res = await fetch(`/api/firms/${firm.id}/credentials`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiKey: address.trim() }),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? 'Failed');
setWallet('');
onSaved();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed');
} finally {
setSaving(false);
}
}
const valid = /^0x[0-9a-fA-F]{40}$/.test(wallet.trim());
return (
<div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
{seen.map((address) => (
<button
key={address}
onClick={() => void save(address)}
disabled={saving}
title="The wallet of the Vest tab signed in on this machine"
className="px-2 py-1 bg-emerald-100 hover:bg-emerald-200 disabled:opacity-50 text-emerald-800 text-xs font-mono rounded-md"
>
Use {address.slice(0, 6)}…{address.slice(-4)}
</button>
))}
<input
className="border border-slate-200 rounded-md px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 w-72 font-mono"
placeholder={seen.length > 0 ? 'or another wallet 0x…' : 'Sign in to Vest in Chrome: it binds itself (or paste 0x…)'}
value={wallet}
onChange={(e) => setWallet(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && valid) void save(wallet); }}
/>
<button
onClick={() => void save(wallet)}
disabled={saving || !valid}
className="px-2.5 py-1 bg-blue-100 hover:bg-blue-200 disabled:opacity-50 text-blue-700 text-xs font-semibold rounded-md transition-colors"
>
{saving ? '…' : 'Save'}
</button>
{error && <span className="text-xs text-red-500">{error}</span>}
</div>
);
}
function LoginForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => void }) {
const isTopstepX = firm.platform === 'topstepx';
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
@@ -342,12 +421,14 @@ function WipeAllButton() {
);
}
function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
function AccountRow({ account, firm, hideDead, privacy, onAssigned, onStateChanged }: {
account: AccountState;
firm: FirmConfig;
hideDead: boolean;
privacy: boolean;
onAssigned: () => void;
/** Refetches /api/state — after a rules date is saved. */
onStateChanged: () => void;
}) {
const cfg = account.accountType;
const dead = isAccountDead(account);
@@ -359,8 +440,9 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
return (
<tr className={`border-b border-slate-100 hover:bg-slate-50 transition-colors${!account.active || dead ? ' opacity-50' : ''}`}>
<td className="px-4 py-3">
<div className="flex items-center gap-2 font-medium text-slate-800 pl-4 font-mono">
{maskName(account.name, privacy)}
<div className="flex items-center gap-2 pl-4">
<span className="font-medium text-slate-800 font-mono">{maskName(account.name, privacy)}</span>
<RulesDate account={account} firm={firm} onSaved={onStateChanged} />
</div>
</td>
<td className="px-4 py-3 text-slate-600 tabular-nums">${fmt(account.amount)}</td>
@@ -371,7 +453,7 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
<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">
@@ -391,7 +473,7 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
<span className="text-red-600" title="Last stand: hitting the day's loss limit would leave this account within 15% of that loss above its max-drawdown floor — nothing left to trade on — so the next leg carries no daily-loss stop and runs to its take-profit or the firm's breach"> · no stop (last stand)</span>
)}
{typeof account.dllRoom === 'number' && (
<span className="text-slate-500" title="Hard daily loss limit: what the firm's day may still lose before a breach. A reserve — 10% of the limit, or 0.1% of the position if larger — is never spent."> · DLL room ${fmt(account.dllRoom)}</span>
<span className="text-slate-500" title="Hard daily loss limit: what the firm's day may still lose before a breach. A reserve — 10% of the limit, or 0.1% of the position if larger (0.02% on Vest) — is never spent."> · DLL room ${fmt(account.dllRoom)}</span>
)}
{account.holdEstimate && (
<span
@@ -440,7 +522,7 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
);
}
function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbol, onChanged }: {
function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbol, onChanged, onStateChanged }: {
state: FirmState;
firm: FirmConfig;
hideDead: boolean;
@@ -449,8 +531,10 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
sortDir: SortDir;
tradeSymbol: string;
onChanged: () => void;
onStateChanged: () => void;
}) {
const [open, setOpen] = useState(true);
const [showTrades, setShowTrades] = useState(false);
const [confirmCreds, setConfirmCreds] = useState(false);
const [removingCreds, setRemovingCreds] = useState(false);
const hasCredentials = firm.hasCredentials;
@@ -498,7 +582,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
<td className="px-4 py-3.5">
<div className="flex items-center gap-2.5">
<span className="font-bold text-sm text-slate-800">{firm.firm}</span>
{firm.platform !== 'dxtrade' && tradeSymbol !== 'Auto' && !firm.instruments?.includes(tradeSymbol) && (
{firm.platform !== 'dxtrade' && firm.platform !== 'vest' && tradeSymbol !== 'Auto' && !firm.instruments?.includes(tradeSymbol) && (
<span className="text-xs bg-red-100 text-red-700 px-1.5 py-0.5 rounded font-semibold" title="Not on this firm's instrument list (master Firms page) — it sits this symbol out">
{tradeSymbol} not listed
</span>
@@ -513,6 +597,11 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
DXtrade
</span>
)}
{firm.platform === 'vest' && (
<span className="text-xs bg-violet-100 text-violet-700 px-1.5 py-0.5 rounded font-semibold" title="Trades through the session the extension relays from a signed-in Vest tab">
Vest
</span>
)}
{firm.platform === 'topstepx' && hasCredentials && !firm.username && (
<span
className="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-semibold"
@@ -563,6 +652,15 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
)}
<td className="px-4 py-3.5">
<div className="flex items-center justify-end gap-1">
{hasCredentials && (
<button
onClick={(e) => { e.stopPropagation(); setShowTrades((v) => !v); }}
className={`px-2 py-0.5 text-xs font-semibold rounded-md transition-colors ${showTrades ? 'bg-violet-100 text-violet-700' : 'text-slate-500 hover:bg-slate-200'}`}
title="Every trade on this firm's accounts — the bot's and anyone else's — with how each one closed"
>
Trades
</button>
)}
{hasCredentials && !confirmCreds && (
<button
onClick={(e) => { e.stopPropagation(); setConfirmCreds(true); }}
@@ -585,6 +683,14 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
</td>
</tr>
{showTrades && hasCredentials && (
<tr className="border-b border-slate-100">
<td colSpan={7} className="px-4 py-3 pl-12 bg-white">
<TradesTable query={`firmId=${encodeURIComponent(firm.id)}`} showAccount formatAccount={(n) => maskName(n, privacy)} />
</td>
</tr>
)}
{open && hasCredentials && (
state.accounts.length === 0 ? (
<tr>
@@ -601,6 +707,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
hideDead={hideDead}
privacy={privacy}
onAssigned={onChanged}
onStateChanged={onStateChanged}
/>
))
)
@@ -636,6 +743,8 @@ export default function Home() {
const [tickInterval, setTickInterval] = useState('60');
const [tradeLoading, setTradeLoading] = useState(false);
const [stopAfterAll, setStopAfterAll] = useState(false);
const [autoClaim, setAutoClaim] = useState(true);
const [autoActivate, setAutoActivate] = useState(true);
const handleSort = (col: SortKey) => {
if (sortKey === col) {
@@ -673,6 +782,18 @@ export default function Home() {
fetchConfig();
};
const fetchState = async () => {
try {
const res = await fetch('/api/state');
if (res.ok) {
const data: FirmState[] = await res.json();
setFirms(data);
}
} catch {
// server not running yet
}
};
const fetchScheduler = async () => {
try {
const res = await fetch('/api/auto-trade');
@@ -693,27 +814,16 @@ export default function Home() {
fetch('/api/settings')
.then((r) => r.json())
.then((s: { tick_interval_seconds?: string | null }) => {
.then((s: { tick_interval_seconds?: string | null; auto_claim?: string | null; auto_activate?: string | null }) => {
if (s.tick_interval_seconds != null) setTickInterval(s.tick_interval_seconds);
setAutoClaim(s.auto_claim !== 'off');
setAutoActivate(s.auto_activate !== 'off');
})
.catch(() => {});
fetchConfig();
fetchCatalog();
fetchScheduler();
const fetchState = async () => {
try {
const res = await fetch('/api/state');
if (res.ok) {
const data: FirmState[] = await res.json();
setFirms(data);
}
} catch {
// server not running yet
}
};
fetchState();
const stateInterval = setInterval(fetchState, 5000);
const schedulerInterval = setInterval(fetchScheduler, 5000);
@@ -894,6 +1004,50 @@ export default function Home() {
/>
<span className="text-xs text-slate-400 whitespace-nowrap">Stop after all eligible</span>
</label>
{config.some((f) => f.platform === 'vest') && (
<label
className="flex items-center gap-1.5 ml-2 cursor-pointer select-none"
title="Claim a funded account's profit as soon as it reaches its stage's target (Vest) — whether or not the scheduler is running"
>
<input
type="checkbox"
checked={autoClaim}
onChange={(e) => {
const on = e.target.checked;
setAutoClaim(on);
fetch('/api/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ auto_claim: on ? 'on' : 'off' }),
}).then((r) => { if (!r.ok) setAutoClaim(!on); }).catch(() => setAutoClaim(!on));
}}
className="rounded border-slate-300 text-emerald-500 focus:ring-emerald-500"
/>
<span className="text-xs text-slate-400 whitespace-nowrap">Auto-claim payouts</span>
</label>
)}
{config.some((f) => f.platform === 'vest') && (
<label
className="flex items-center gap-1.5 ml-2 cursor-pointer select-none"
title="Activate a passed evaluation's funded account as soon as it passes (Vest) — whether or not the scheduler is running"
>
<input
type="checkbox"
checked={autoActivate}
onChange={(e) => {
const on = e.target.checked;
setAutoActivate(on);
fetch('/api/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ auto_activate: on ? 'on' : 'off' }),
}).then((r) => { if (!r.ok) setAutoActivate(!on); }).catch(() => setAutoActivate(!on));
}}
className="rounded border-slate-300 text-emerald-500 focus:ring-emerald-500"
/>
<span className="text-xs text-slate-400 whitespace-nowrap">Auto-activate funded</span>
</label>
)}
<div className="ml-auto flex items-center gap-2">
{hasAnyPosition && (
<button
@@ -965,6 +1119,7 @@ export default function Home() {
sortDir={sortDir}
tradeSymbol={tradeSymbol}
onChanged={fetchConfig}
onStateChanged={fetchState}
/>
);
})
+13
View File
@@ -47,6 +47,7 @@ interface AppSettings {
trading_hours: string | null;
master_backup_token: string | null;
proxy_url: string | null;
user_agent: string | null;
backup_last_push_at: string | null;
backup_last_error: string | null;
}
@@ -77,6 +78,7 @@ export default function SettingsPage() {
const [saved, setSaved] = useState(false);
const [backupToken, setBackupToken] = useState<string>('');
const [proxyUrl, setProxyUrl] = useState<string>('');
const [userAgent, setUserAgent] = useState<string>('');
const [backupStatus, setBackupStatus] = useState<{ at: string | null; error: string | null }>({ at: null, error: null });
const [restoreList, setRestoreList] = useState<BackupMeta[] | null>(null);
const [restoreBusy, setRestoreBusy] = useState(false);
@@ -130,6 +132,7 @@ export default function SettingsPage() {
if (s.proxy_url != null) {
setProxyUrl(s.proxy_url);
}
setUserAgent(s.user_agent ?? '');
setBackupStatus({ at: s.backup_last_push_at, error: s.backup_last_error || null });
});
@@ -440,6 +443,16 @@ export default function SettingsPage() {
className="w-72 rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-sm font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="border-t border-slate-100 px-4 py-3">
<p className="text-sm font-medium text-slate-800">User agent</p>
<p className="text-xs text-slate-400 mt-0.5">
Chrome, Firefox, or Edge is randomly selected on first load and saved.
All server Axios requests and WebSocket connections reuse it, including after restarts.
</p>
<p className="mt-2 break-all rounded-lg bg-slate-50 px-3 py-2 text-xs font-mono text-slate-700">
{userAgent || 'Loading…'}
</p>
</div>
</div>
{/* ── Instruments ── */}
+23
View File
@@ -5,6 +5,29 @@ import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
{
files: ['**/*.{js,mjs,cjs,ts,tsx}'],
ignores: ['lib/http-client.ts', 'lib/outbound-proxy.ts'],
rules: {
'no-restricted-imports': ['error', {
patterns: [{
group: ['axios', 'axios/*'],
message: 'Import Axios from @/lib/http-client so requests keep the saved user agent, proxy and timeout.',
}],
}],
'no-restricted-modules': ['error', {
paths: [{
name: 'axios',
message: 'Use @/lib/http-client for the configured Axios client.',
}],
patterns: ['axios/*'],
}],
'no-restricted-syntax': ['error', {
selector: 'ImportExpression[source.value=/^axios(\\/|$)/]',
message: 'Import @/lib/http-client for the configured Axios client.',
}],
},
},
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
+64
View File
@@ -58,6 +58,70 @@ going back to `<all_urls>`: it would put every tab you visit in reach, and limit
firm needs its host in *both* lists — `host_permissions` and the `content_scripts`
matches — or captchas on that site will have no callback to fire.
## 2FA codes
On a firm site, the extension shows the machine's person's authenticator code
for that site in a small chip in the bottom-right corner, with a Copy button
(minimise it to a "2FA" pill; that sticks for the tab's session).
```
extension --GET /api/autobuyer/otp?host=<tab host>--> dashboard
dashboard --GET <master>/api/otp?person=<instance_id>&host=<tab host>--> master
```
The master holds the secrets and matches the host against each firm's **Site**
link (Firms page), then returns the codes assigned to this person and that firm
(Codes page). The chip only needs a code to be assigned — it doesn't wait for a
code field. It stays hidden for a minute after the runner last located
something, so automated clicks never land on it.
A new 2FA site needs its host in `host_permissions` (not the captcha
`content_scripts` list), a Site link on its firm on the master, and its codes
assigned on the master's Codes page.
## Vest session relay
Vest signs people in with Google (through Privy) in the browser and renews the
session with a device key the browser will not export, so the dashboard cannot
log in to Vest by itself. The extension relays the browser's session instead:
```
vest-relay.js (Vest tab, MAIN world) keeps the access token the Vest app last renewed
background.js relayVestSession lifts it every 10s, posts it when it changes (and every minute)
dashboard POST /api/vest/session holds it in memory for the firm's Vest client,
and binds an unset Vest firm to the login's wallet
```
- The hook only reads the Vest app's own `/v3/auth/refresh` and `/register`
answers. It renews nothing and sends nothing; the session stays the app's,
so the bot never logs the tab out.
- It takes the login's token only. The app renews each trading account's own
token through the same `/v3/auth/refresh` (with that account's refresh token
in the body), and one of those sees a single account. Before 0.17.2 the hook
relayed those too, and the dashboard dropped every other account; the
dashboard now refuses such a token and falls back to the login's.
- The token lives ~15 minutes and the app renews it by itself while the tab is
open. The tab is marked not auto-discardable, since a discarded tab stops
renewing.
- Several Vest tabs each renew on their own, and every renewal moves the login
on to a new session (localStorage `vest-auth-session`). A token from before
still reads, but Vest refuses to mint an account's token with it (401, code
1002), so the bot's orders and history reads fail. Since 0.18.0 the hook
records each token's session id, and the relay sends the token Vest's
current session holds, whichever tab has it, not the first tab's. A tab
hooked by an older build is matched by its token's expiry instead; one that
renewed before any hook was in it shows in the popup as "none holds the
current session" until it renews again or is reloaded.
- A sign-in's answer (`/register`) carries the login's own refresh token, and
is taken too since 0.18.0; before, the first token after a sign-in waited
for the tab's first renewal.
- When the dashboard has a Vest firm (its credential is the login's wallet)
and no Vest tab is open, the extension opens one in the background. The app
signs back in from its 30-day session. After that the person signs in again
in that tab.
- This is the one place the extension reads a tab you are not looking at. It
only reads `next.vestmarkets.com` tabs, and only that one value.
## Notes
- The extension never captures the dashboard's own pages — otherwise it would
+435
View File
@@ -10,6 +10,11 @@
*
* The locate queue is served whenever clicks are waiting. The AutoBuyer is
* ready at all times; a run happens because someone asked for one.
*
* On a firm site, the person's 2FA code for it shows in a corner chip.
*
* A signed-in Vest tab's session is relayed to the dashboard, whose Vest client
* trades with it (relayVestSession).
*/
const DEFAULTS = {
@@ -900,6 +905,7 @@ async function serveLocateRequest(cfg) {
let error = null;
try {
let { tab, opened } = await resolveLocateTab(cfg, request);
await hideOtpChip(tab.id);
// A navigate step points the tab at a specific page first. Skip it when we
// are already there — reloading would throw away page state for nothing,
@@ -1176,6 +1182,435 @@ async function tick() {
} catch (err) {
await setState({ connected: true, error: err.message });
}
try {
await maybeShowOtp(cfg);
} catch (err) {
await setState({ connected: true, error: `2FA chip: ${err.message}` });
}
try {
await relayVestSession(cfg);
} catch (err) {
await setState({ connected: true, vest: `Vest relay: ${err.message}` });
}
}
// ── Vest: relay the signed-in tab's session to the dashboard ────────────────
//
// Vest signs people in through Google in the browser and renews the session
// with a device key the browser won't hand out, so the dashboard cannot log in
// by itself. vest-relay.js, in the Vest tab, keeps the latest access token the
// Vest app renewed for itself (~15 minutes each); this lifts it and posts it to
// the dashboard whenever it changes. Nothing here renews or signs anything: the
// session stays the app's.
//
// With several Vest tabs open, each renews on its own, and each renewal moves
// the login on to a new session: the other tabs' tokens still read, but Vest
// refuses to mint an account's token with them (401, code 1002) — the bot's
// history reads and orders fail until the tab it relays renews again. So the
// token relayed is the one Vest's current session holds (pickVestSession),
// whichever tab it is in, not the first tab's.
const VEST_PATTERN = 'https://next.vestmarkets.com/*';
const VEST_URL = 'https://next.vestmarkets.com/portfolio';
const VEST_EVERY_MS = 10_000;
/** The session is posted again this often even unchanged: the dashboard
* keeps it in memory only, so a restart there needs it back. */
const VEST_REPOST_MS = 60_000;
/** A Vest tab opened for the relay isn't opened again for this long. */
const VEST_REOPEN_MS = 10 * 60_000;
let vestLastCheck = 0;
let vestLastRelayed = '';
let vestLastPostAt = 0;
let vestOpenedAt = 0;
/** Runs in the Vest tab's MAIN world: the session vest-relay.js holds, while
* it has life left, and the current session the Vest app keeps for all its
* tabs. Self-contained. Version 2's token whenever it holds one: it sees
* every login renewal version 1 does once it's in, so a version-1 token it
* lacks is an account's (the 0.17.0/0.17.1 hooks kept those too). */
function pageVestSession() {
const live = (s) => (s && typeof s.accessToken === 'string' && s.expiresAt > Date.now() + 30000 ? s : null);
const session = live(window.__afVestLogin) ?? live(window.__afVestSession);
let current = null;
try {
const raw = JSON.parse(localStorage.getItem('vest-auth-session') || 'null');
if (raw && typeof raw === 'object') {
current = {
refreshTokenId: typeof raw.refreshTokenId === 'string' && raw.refreshTokenId ? raw.refreshTokenId : null,
accessTokenExpiresAt: Number.isFinite(raw.accessTokenExpiresAt) ? raw.accessTokenExpiresAt : null,
};
}
} catch { /* no session kept */ }
return { hooked: window.__afVestHook === 2, session, current };
}
/** Whether a tab's token is the one the Vest app's current session holds:
* the same session id — the app's own check before it mints — else, for a
* token caught by a version-1 hook (no id), the same expiry, which the app
* writes into the session with the token. */
function isCurrentVestSession(session, current) {
if (!session || !current) return false;
if (session.refreshTokenId && current.refreshTokenId) return session.refreshTokenId === current.refreshTokenId;
return current.accessTokenExpiresAt !== null && session.expiresAt === current.accessTokenExpiresAt;
}
/** Out of every Vest tab's pageVestSession, the session to relay: the
* current one, else — no tab holds it (one renewed before its hook was in)
* — the latest to expire, which still reads. `current` is null when no tab
* shows the app's session record to judge by. */
function pickVestSession(pages) {
const live = pages.filter((p) => p && p.session);
if (live.length === 0) return null;
const current = live.find((p) => isCurrentVestSession(p.session, p.current));
if (current) return { session: current.session, current: true };
const newest = live.reduce((x, y) => (y.session.expiresAt > x.session.expiresAt ? y : x));
return { session: newest.session, current: pages.some((p) => p && p.current) ? false : null };
}
/** The wallets the dashboard's Vest firms trade; [] from an older dashboard. */
async function fetchVestWanted(cfg) {
try {
const res = await fetch(`${cfg.apiBase}/api/vest/session`, { cache: 'no-store' });
if (!res.ok) return [];
const body = await res.json();
return Array.isArray(body.wanted) ? body.wanted : [];
} catch {
return [];
}
}
async function relayVestSession(cfg) {
const now = Date.now();
if (now - vestLastCheck < VEST_EVERY_MS) return;
vestLastCheck = now;
const tabs = (await chrome.tabs.query({ url: VEST_PATTERN })).filter((t) => /^https:/.test(t.url || ''));
if (tabs.length === 0) {
// This machine trades Vest: open the app in the background. It signs
// itself back in from its 30-day session, and the hook catches that.
const wanted = await fetchVestWanted(cfg);
if (wanted.length === 0) return;
if (now - vestOpenedAt > VEST_REOPEN_MS) {
vestOpenedAt = now;
await chrome.tabs.create({ url: VEST_URL, active: false });
await setState({ vest: 'Opened a Vest tab to relay its session' });
} else {
await setState({ vest: 'No Vest tab open' });
}
return;
}
// Every tab: the current session may be in any of them.
const pages = [];
for (const tab of tabs) {
// A discarded tab stops renewing the session; keep Chrome from it.
if (tab.autoDiscardable !== false) chrome.tabs.update(tab.id, { autoDiscardable: false }).catch(() => {});
const [injection] = await chrome.scripting.executeScript({
target: { tabId: tab.id }, world: 'MAIN', func: pageVestSession,
}).catch(() => [null]);
const out = injection?.result;
if (out && !out.hooked) {
// A tab open from before this build: hook it now, and its app's
// next renewal is caught.
await chrome.scripting.executeScript({ target: { tabId: tab.id }, world: 'MAIN', files: ['vest-relay.js'] }).catch(() => {});
}
if (out) pages.push(out);
}
const picked = pickVestSession(pages);
if (!picked) {
await setState({ vest: 'Vest tab open — waiting for its session (sign in there if it shows Log In)' });
return;
}
const found = picked.session;
const fingerprint = `${found.expiresAt}:${found.accessToken.length}:${found.accessToken.slice(-16)}`;
if (fingerprint === vestLastRelayed && now - vestLastPostAt < VEST_REPOST_MS) return;
const res = await fetch(`${cfg.apiBase}/api/vest/session`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
accessToken: found.accessToken, expiresAt: found.expiresAt, primaryAddr: found.primaryAddr, userId: found.userId,
}),
});
const body = await res.json().catch(() => ({}));
if (res.status === 404) throw new Error('this dashboard is too old for the Vest relay');
if (!res.ok) throw new Error(`dashboard refused the session: ${body.reason || body.error || `HTTP ${res.status}`}`);
vestLastRelayed = fingerprint;
vestLastPostAt = now;
const short = `${found.primaryAddr.slice(0, 6)}…${found.primaryAddr.slice(-4)}`;
const mins = Math.max(0, Math.round((found.expiresAt - Date.now()) / 60000));
const note = Array.isArray(body.bound) && body.bound.length > 0 ? ` · bound to ${body.bound.join(', ')}`
: body.used ? '' : ' (no Vest firm here uses this wallet)';
// A tab that renewed before its hook was in holds the current session
// unseen: until it renews again, orders and history reads can fail —
// reloading it hooks it.
const tabsNote = picked.current === false
? (tabs.length === 1 ? " · its token isn't Vest's current session — reload the Vest tab"
: ` · ${tabs.length} Vest tabs and none holds the current session — reload them, or keep one open`)
: tabs.length > 1 ? ` · ${tabs.length} Vest tabs${picked.current ? ', relaying the current one' : ''}` : '';
await setState({ vest: `Relayed ${short}${note} · good ${mins}m${tabsNote}` });
}
// ── 2FA: the person's authenticator code for the firm site you are on ───────
//
// The master keeps the authenticator secrets, each assigned to a firm (whose
// site link it matches against) and a person. The dashboard knows which
// person this machine is, so the extension only sends the tab's host and gets
// back that person's live codes for the site — shown in a small chip in the
// corner with a Copy button.
/** Refetch at least this often, so a newly assigned code turns up. */
const OTP_REFRESH_MS = 30_000;
/** No chip for this long after the runner last located something: its clicks
* land on screen coordinates, and a chip there would take them. */
const OTP_QUIET_AFTER_LOCATE_MS = 60_000;
let otpCache = null; // { host, codes, fetchedAt }
let lastLocateAt = 0;
const chipTabs = new Set(); // tabs we have drawn a chip in
/** This person's codes for a host, with expiresAt moved onto this clock. An
* unreachable dashboard returns nothing and retries next tick; any answer —
* including an old dashboard's 404 — is cached until a code expires. */
async function fetchOtpCodes(cfg, host) {
const now = Date.now();
if (otpCache && otpCache.host === host && now - otpCache.fetchedAt < OTP_REFRESH_MS
&& otpCache.codes.every((c) => c.expiresAt > now)) {
return otpCache.codes;
}
let codes = [];
try {
const res = await fetch(`${cfg.apiBase}/api/autobuyer/otp?host=${encodeURIComponent(host)}`, { cache: 'no-store' });
if (res.ok) {
const data = await res.json();
// The master's clock made the codes; a countdown on ours needs the gap.
const drift = (Number(data.serverTime) || now) - now;
codes = (Array.isArray(data.codes) ? data.codes : [])
.filter((c) => c && typeof c.code === 'string')
.map((c) => ({
label: String(c.label || ''),
firmName: c.firmName ? String(c.firmName) : '',
code: c.code,
period: Number(c.period) || 30,
expiresAt: Number(c.expiresAt) - drift,
}));
}
} catch {
return otpCache && otpCache.host === host ? otpCache.codes.filter((c) => c.expiresAt > now) : [];
}
otpCache = { host, codes, fetchedAt: now };
return codes;
}
async function maybeShowOtp(cfg) {
const tab = await pickTab();
if (!tab) return;
if (Date.now() - lastLocateAt < OTP_QUIET_AFTER_LOCATE_MS) return;
let host;
try { host = new URL(tab.url).hostname; } catch { return; }
const codes = await fetchOtpCodes(cfg, host);
// An empty list is still drawn once where a chip was, which removes it.
if (!codes.length && !chipTabs.has(tab.id)) return;
await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: pageOtpChip, args: [codes] });
if (codes.length) chipTabs.add(tab.id); else chipTabs.delete(tab.id);
}
/** Take the chip down before the runner measures or clicks anything there. */
async function hideOtpChip(tabId) {
lastLocateAt = Date.now();
if (!chipTabs.has(tabId)) return;
chipTabs.delete(tabId);
try {
await chrome.scripting.executeScript({ target: { tabId }, func: pageOtpChip, args: [[]] });
} catch {
// tab gone or navigated away — nothing left to hide
}
}
chrome.tabs.onRemoved.addListener((tabId) => chipTabs.delete(tabId));
/** Draws, updates or (with no codes) removes the 2FA chip. Runs in the
* extension's isolated world and must be self-contained. The chip lives in a
* shadow root so the site's CSS can't reach it, and counts down on its own
* between the worker's updates. */
function pageOtpChip(codes) {
const ID = 'autofirmer-otp';
const COLLAPSED_KEY = 'autofirmer-otp-collapsed';
let host = document.getElementById(ID);
if (!codes || !codes.length) {
if (host) host.remove();
return;
}
// A chip drawn by an older build has no updater; start over.
if (host && typeof host.__afOtp !== 'function') {
host.remove();
host = null;
}
if (host) {
host.__afOtp(codes);
return;
}
host = document.createElement('div');
host.id = ID;
host.style.cssText = 'all: initial; position: fixed; right: 16px; bottom: 16px; z-index: 2147483647;';
const root = host.attachShadow({ mode: 'open' });
root.innerHTML = `
<style>
:host { all: initial; }
* { box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
.chip { width: 260px; background: #fff; color: #0f172a; border: 1px solid #e2e8f0; border-radius: 12px;
box-shadow: 0 8px 24px rgba(15, 23, 42, .18); overflow: hidden; }
.head { display: flex; align-items: center; justify-content: space-between; padding: 8px 10px 6px 12px;
font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: #64748b; }
.icon { border: 0; background: none; color: #94a3b8; font-size: 16px; line-height: 1; cursor: pointer; padding: 2px 4px; border-radius: 4px; }
.icon:hover { color: #334155; background: #f1f5f9; }
.row { padding: 6px 12px 10px; border-top: 1px solid #f1f5f9; }
.row:first-child { border-top: 0; }
.label { font-size: 12px; font-weight: 600; color: #334155; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.line { display: flex; align-items: center; gap: 8px; margin-top: 2px; }
.code { font-size: 24px; font-weight: 700; letter-spacing: .12em; font-variant-numeric: tabular-nums; }
.secs { font-size: 11px; font-weight: 600; color: #94a3b8; font-variant-numeric: tabular-nums; margin-left: auto; }
.soon { color: #dc2626; }
.copy { border: 1px solid #e2e8f0; background: #fff; color: #475569; font-size: 12px; font-weight: 600;
padding: 5px 10px; border-radius: 8px; cursor: pointer; }
.copy:hover { background: #f1f5f9; }
.copy.done { border-color: #86efac; color: #15803d; background: #f0fdf4; }
.bar { height: 3px; background: #f1f5f9; border-radius: 2px; margin-top: 6px; overflow: hidden; }
.fill { height: 100%; background: #22c55e; }
.fill.soon { background: #ef4444; }
.pill { border: 1px solid #e2e8f0; background: #fff; color: #0f172a; font-size: 12px; font-weight: 700;
letter-spacing: .06em; padding: 7px 12px; border-radius: 999px; cursor: pointer;
box-shadow: 0 6px 18px rgba(15, 23, 42, .16); }
.pill:hover { background: #f8fafc; }
</style>
<div class="chip">
<div class="head"><span>2FA code</span><button class="icon" title="Minimise">–</button></div>
<div class="rows"></div>
</div>
<button class="pill" title="Show the 2FA code">2FA</button>`;
const chip = root.querySelector('.chip');
const pill = root.querySelector('.pill');
const rowsEl = root.querySelector('.rows');
let rows = [];
let signature = '';
const collapsed = () => { try { return sessionStorage.getItem(COLLAPSED_KEY) === '1'; } catch { return false; } };
const setCollapsed = (on) => {
try { sessionStorage.setItem(COLLAPSED_KEY, on ? '1' : '0'); } catch { /* storage blocked */ }
chip.style.display = on ? 'none' : '';
pill.style.display = on ? '' : 'none';
};
root.querySelector('.icon').addEventListener('click', () => setCollapsed(true));
pill.addEventListener('click', () => setCollapsed(false));
setCollapsed(collapsed());
function flash(button, text, done) {
button.textContent = text;
button.classList.toggle('done', done);
setTimeout(() => { button.textContent = 'Copy'; button.classList.remove('done'); }, 1500);
}
// execCommand first: it is synchronous inside the click, needs no
// clipboard permission, and never stalls on a prompt the way the async
// Clipboard API can. Focus goes back to whatever had it (the code field).
function copy(text, button) {
const had = document.activeElement;
const area = document.createElement('textarea');
area.value = text;
area.setAttribute('readonly', '');
area.style.cssText = 'position: fixed; top: 0; left: 0; opacity: 0;';
root.appendChild(area);
area.select();
let ok = false;
try { ok = document.execCommand('copy'); } catch { /* fall through */ }
area.remove();
if (had && had !== document.body && typeof had.focus === 'function') had.focus();
if (ok) return flash(button, 'Copied', true);
if (!navigator.clipboard) return flash(button, 'Copy failed', false);
navigator.clipboard.writeText(text).then(
() => flash(button, 'Copied', true),
() => flash(button, 'Copy failed', false),
);
}
function build(list) {
rowsEl.textContent = '';
rows = list.map((c) => {
const row = document.createElement('div');
row.className = 'row';
const label = document.createElement('div');
label.className = 'label';
label.textContent = [c.firmName, c.label].filter(Boolean).join(' · ');
label.title = label.textContent;
const line = document.createElement('div');
line.className = 'line';
const code = document.createElement('span');
code.className = 'code';
const secs = document.createElement('span');
secs.className = 'secs';
const button = document.createElement('button');
button.className = 'copy';
button.textContent = 'Copy';
// Keep focus where it is (the site's code field), so ⌘V follows.
button.addEventListener('mousedown', (e) => e.preventDefault());
button.addEventListener('click', () => copy(c.code, button));
line.append(code, secs, button);
const bar = document.createElement('div');
bar.className = 'bar';
const fill = document.createElement('div');
fill.className = 'fill';
bar.appendChild(fill);
row.append(label, line, bar);
rowsEl.appendChild(row);
return { c, code, secs, fill, button };
});
}
function paint() {
const now = Date.now();
for (const r of rows) {
const msLeft = r.c.expiresAt - now;
const left = Math.max(0, Math.ceil(msLeft / 1000));
const soon = left <= 5;
// Expired: blank it until the worker brings the next one (seconds).
const half = Math.ceil(r.c.code.length / 2);
r.code.textContent = left > 0 ? `${r.c.code.slice(0, half)} ${r.c.code.slice(half)}` : '… …';
r.code.classList.toggle('soon', soon && left > 0);
r.secs.textContent = left > 0 ? `${left}s` : '';
r.secs.classList.toggle('soon', soon);
r.fill.style.width = `${Math.max(0, Math.min(1, msLeft / (r.c.period * 1000))) * 100}%`;
r.fill.classList.toggle('soon', soon);
r.button.disabled = left === 0;
}
}
host.__afOtp = (list) => {
const next = list.map((c) => `${c.firmName}|${c.label}|${c.code}`).join('\n');
if (next !== signature) {
signature = next;
build(list);
} else {
rows.forEach((r, i) => { r.c = list[i]; });
}
paint();
};
const timer = setInterval(() => {
if (!host.isConnected) { clearInterval(timer); return; }
paint();
}, 250);
(document.body || document.documentElement).appendChild(host);
host.__afOtp(codes);
}
// ── Keeping ourselves current ───────────────────────────────────────────────
+10 -2
View File
@@ -1,17 +1,19 @@
{
"manifest_version": 3,
"name": "AutoFirmer Capture",
"version": "0.15.0",
"version": "0.18.0",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxdbOMRR+58a9dHlbIKtlALtS3E4eltBGqfTnUc0lzJuWj5ZA4+5YFSdUnxv5N1ogxwpJUGkUV3MnKJuZnTekCmsNwjCy41a20Xtc7jQLHrIBZkesB6cB+d0l9Vo8M6EC3ZMD8Iv6MdAalU3GZh1Hn9vm24NjJ+xUT1qNIVHEcNUIb9evYobR/XHv+JAp6bOFnaLM+3wpyYKe4efMhsz0F86u9thRNhQnraTqUWfnKNpsHPK2zivaYHrziEEWX7Y3EsMHkf2FRhkUe7IDoxeJM49DBToTo9sAJUkcgLeGCGZLo7H3mcxyn/zERYR+rD3k5xdeBtjoZHXFx2+FanLb9wIDAQAB",
"update_url": "https://ext.juicerroom.com/updates.xml",
"description": "Scrapes the HTML of the firm tab you are viewing and posts it to the AutoFirmer dashboard.",
"description": "Scrapes the HTML of the firm tab you are viewing and posts it to the AutoFirmer dashboard, shows the 2FA code for the firm site you are on, and relays a signed-in Vest tab's session to the dashboard.",
"permissions": ["scripting", "tabs", "storage", "alarms"],
"host_permissions": [
"https://*.tradeify.co/*",
"https://*.lucidtrading.com/*",
"https://*.topstep.com/*",
"https://*.apextraderfunding.com/*",
"https://*.vestmarkets.com/*",
"http://localhost/*",
"http://127.0.0.1/*"
],
@@ -28,6 +30,12 @@
"run_at": "document_start",
"all_frames": true,
"world": "MAIN"
},
{
"matches": ["https://next.vestmarkets.com/*"],
"js": ["vest-relay.js"],
"run_at": "document_start",
"world": "MAIN"
}
],
"action": {
+1
View File
@@ -29,6 +29,7 @@ async function render() {
document.getElementById('status').innerHTML =
`<span class="dot" style="background:${color}"></span><strong>${label}</strong>` +
detail +
(state.vest ? `<div class="muted">Vest: ${esc(state.vest)}</div>` : '') +
(state.error ? `<div class="err">${esc(state.error)}</div>` : '');
}
+96
View File
@@ -0,0 +1,96 @@
/* AutoFirmer — Vest session hook.
*
* Runs in the page's MAIN world on next.vestmarkets.com, at document_start
* (manifest content script) or injected later into a tab that was already open
* (background.js relayVestSession). Installs once per page.
*
* Vest's app renews its own ~15-minute access token by itself: at load, then
* whenever one nears expiry (POST /v3/auth/refresh, or /v3/auth/register at
* sign-in). Those answers carry the token, its expiry, the login's wallet and
* the session it belongs to (refreshTokenId). This hook reads them off the
* app's own XHRs and keeps the latest on `window.__afVestLogin`, where
* background.js collects it and relays it to the local dashboard, whose Vest
* client trades with it.
*
* Every tab renews on its own, and each renewal moves the login on to a new
* session (localStorage `vest-auth-session`): a token from before still reads,
* but Vest refuses to mint an account's token with it (401, code 1002). The
* session id lets background.js relay the tab whose token is the current one.
*
* It sends nothing anywhere, changes no request and never renews anything
* itself: the session stays the app's, refreshed by the app with its own
* device key, so the tab is never logged out by the bot.
*
* The app also renews each trading account's own token through the same
* /v3/auth/refresh, sending that account's refresh token in the body (the
* login's rides in a cookie) and getting it back renewed. Those tokens see
* one account only, so they are skipped: relaying one would leave the
* dashboard with a single account. A sign-in's answer carries the login's
* own refresh token, and is taken.
*
* Version 2. A page hooked by version 1 (`__afVestHook === true`, keeping
* `__afVestSession`, no session id, no sign-ins) takes this one beside it.
*/
(() => {
if (window.__afVestHook === 2) return;
window.__afVestHook = 2;
const AUTH = /^https:\/\/api-gateway\.hz\.vestmarkets\.com\/v3\/auth\/(refresh|register)(\?|$)/;
const SIGN_IN = /\/v3\/auth\/register(\?|$)/;
/** A request body that carries a refresh token: an account's renewal. */
function accountRenewal(reqBody) {
return typeof reqBody === 'string' && /"refreshToken"\s*:\s*"[^"]/.test(reqBody);
}
function take(url, status, text, accountRequest) {
if (accountRequest || status < 200 || status >= 300 || !AUTH.test(url)) return;
let body;
try { body = JSON.parse(text); } catch { return; }
if (!body || typeof body.accessToken !== 'string' || !body.accessToken) return;
// A renewal answering with a refresh token is an account's.
if (!SIGN_IN.test(url) && typeof body.refreshToken === 'string' && body.refreshToken) return;
const expiresAt = Number(body.accessExpiresAtMs);
if (!Number.isFinite(expiresAt)) return;
window.__afVestLogin = {
accessToken: body.accessToken,
expiresAt,
primaryAddr: typeof body.primaryAddr === 'string' ? body.primaryAddr : '',
userId: typeof body.userId === 'string' ? body.userId : '',
refreshTokenId: typeof body.refreshTokenId === 'string' && body.refreshTokenId ? body.refreshTokenId : null,
at: Date.now(),
};
}
const open = XMLHttpRequest.prototype.open;
const send = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) {
try { this.__afVestUrl = String(url); } catch { /* ignore */ }
return open.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function (reqBody) {
if (this.__afVestUrl && AUTH.test(this.__afVestUrl)) {
let acct = false;
try { acct = accountRenewal(reqBody); } catch { /* ignore */ }
this.addEventListener('loadend', () => {
try {
if (this.responseType === '' || this.responseType === 'text') take(this.__afVestUrl, this.status, this.responseText, acct);
else if (this.responseType === 'json') take(this.__afVestUrl, this.status, JSON.stringify(this.response), acct);
} catch { /* never break the app */ }
});
}
return send.apply(this, arguments);
};
const fetch0 = window.fetch;
window.fetch = async function (input, init) {
const res = await fetch0.apply(this, arguments);
try {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input && input.url;
const acct = accountRenewal(init && init.body);
if (url && AUTH.test(url)) res.clone().text().then((t) => take(url, res.status, t, acct)).catch(() => {});
} catch { /* never break the app */ }
return res;
};
})();
+165 -19
View File
@@ -9,31 +9,58 @@
*
* An Unassigned account does not trade: every caller already guards on a
* missing config, which is the behaviour we want.
*
* Which of its type's rule sets an account trades on is the other half (see
* lib/kept-rules.ts): each assignment row carries a rules date, and an
* account dated inside one of the type's kept rule sets trades that set's
* rules — getAccountConfig applies it, so every trading read sees it.
*/
import type { CatalogAccountType } from './master-protocol';
import type { CatalogAccountType, KeptAccountRules } from './master-protocol';
import type { PlatformClient } from './platform-client';
import { firstFundDate } from './platform-client';
import {
getAssignment,
setAssignment,
setAutoAssignment,
clearAssignment,
unpinAssignment,
lowerAutoRulesDate,
stampMissingRulesDate,
listAssignments,
loadAccountMeta,
saveAccountMeta,
} from './db';
import { getAccountTypeById, getCachedFirm } from './firm-catalog';
import { getAccountTypeById, getCachedFirm, getCatalogUpdatedAt, getKeptRulesFor, getMasterOffsetMs } from './firm-catalog';
import {
identityStillMatches, lowersRulesDate, openingEvidence, overlayKept, pickKeptRules, stampRulesDate,
type EffectiveAccountType, type KeptRulesWindow,
} from './kept-rules';
const INFERRED_SIZE_KEY = 'inferred_account_size';
/** Accounts this process has run the evidence step on with their fund
* history loaded (see ensureRulesEvidence). On `global` like the clients,
* so the trade path and the resolver share it whichever bundle loads them. */
const ge = global as typeof globalThis & { __rulesEvidenceApplied?: Set<number> };
function evidenceApplied(): Set<number> {
return (ge.__rulesEvidenceApplied ??= new Set());
}
export type AssignmentSource = 'manual' | 'auto' | 'unassigned';
export interface ResolvedAssignment {
/** Always the live account type's id — a kept rule set's never is one. */
accountTypeId: string | null;
accountType: CatalogAccountType | null;
/** The rules the account trades on: its live type, or the live type with
* a kept rule set's rules on top (then keptRules says which). */
accountType: EffectiveAccountType | null;
source: AssignmentSource;
/** True when more than one account type matched and the tie-break decided. */
ambiguous: boolean;
/** The account's rules date (ISO); null = none recorded, current rules. */
rulesDate: string | null;
/** The kept rule set the account trades on; null = its type's current rules. */
keptRules: KeptRulesWindow | null;
}
const UNASSIGNED: ResolvedAssignment = {
@@ -41,8 +68,23 @@ const UNASSIGNED: ResolvedAssignment = {
accountType: null,
source: 'unassigned',
ambiguous: false,
rulesDate: null,
keptRules: null,
};
/** One overlay per kept set and live type, so a tick's many lookups share one
* object the way they share the live type. Both come from the same catalog
* index, so a sync replaces them together. */
const overlays = new WeakMap<KeptAccountRules, { live: CatalogAccountType; effective: EffectiveAccountType }>();
function overlaid(live: CatalogAccountType, kept: KeptAccountRules): EffectiveAccountType {
const hit = overlays.get(kept);
if (hit && hit.live === live) return hit.effective;
const effective = overlayKept(live, kept);
overlays.set(kept, { live, effective });
return effective;
}
/** The earliest positive fund transaction — the account's opening balance.
* Returns null while the reports fetch hasn't produced one yet, which is the
* "wait until it is findable" case rather than an error. */
@@ -93,13 +135,20 @@ export function matchAccountType(
}
/** THE account→config lookup. Reads the assignment table; never writes.
* Replaces the longest-prefix helper that used to be copied into five files. */
* Replaces the longest-prefix helper that used to be copied into five files.
* Every trading read of an account's rules comes through here, so this is
* where an account dated inside a kept rule set gets that set's rules —
* manual pins and auto assignments alike. One row read, the rest from the
* in-memory catalog: it runs per account per tick. */
export function getAccountConfig(accountId: number): ResolvedAssignment {
const row = getAssignment(accountId);
if (!row?.account_type_id) return UNASSIGNED;
if (!row) return UNASSIGNED;
// `?? null`: a row read before the rules-date migration ran has no column.
const rulesDate = row.rules_date ?? null;
if (!row.account_type_id) return { ...UNASSIGNED, rulesDate };
const accountType = getAccountTypeById(row.account_type_id);
if (!accountType) {
const live = getAccountTypeById(row.account_type_id);
if (!live) {
// The account type was deleted on the master. A manual pin is kept so
// that a master flap which recreates it restores the user's intent, but
// it reports as unassigned until then, so the account stops trading.
@@ -108,14 +157,20 @@ export function getAccountConfig(accountId: number): ResolvedAssignment {
accountType: null,
source: 'unassigned',
ambiguous: false,
rulesDate,
keptRules: null,
};
}
const kept = pickKeptRules(getKeptRulesFor(live.id), rulesDate);
const accountType: EffectiveAccountType = kept ? overlaid(live, kept) : live;
return {
accountTypeId: accountType.id,
accountTypeId: live.id,
accountType,
source: row.manual === 1 ? 'manual' : 'auto',
ambiguous: false,
rulesDate,
keptRules: accountType.keptRules ?? null,
};
}
@@ -130,28 +185,106 @@ export function getInferredSize(accountId: number, client: PlatformClient): numb
return inferAccountSize(client.fundTransactions?.[accountId] ?? []);
}
/** Re-resolves every account on one firm's client. Skips manual overrides.
* Called after each Tradovate sync and after the catalog changes. */
/** The rules date a row first written now gets — on the master's clock when
* this one runs behind, and never before the catalog's last change. */
function freshRulesDate(): string {
return stampRulesDate(Date.now(), getMasterOffsetMs(), getCatalogUpdatedAt());
}
/** The broker's evidence of when the account opened (the report's
* firstFundDate, read as lib/kept-rules.ts openingEvidence says); undefined
* until its fund history has loaded, null when that history doesn't say. */
function brokerEvidence(client: PlatformClient, accountId: number): string | null | undefined {
const funds = client.fundTransactions?.[accountId];
if (!funds) return undefined;
return openingEvidence(firstFundDate(funds, undefined), client.brokerAccountMeta?.(accountId)?.registrationTime);
}
/**
* The evidence step for one account, for the trade path: once its fund
* history has loaded, lower an auto rules date to the broker's evidence
* before anything reads the account's rules. The resolver does the same on
* every pass, but only after a sync — and history lands between syncs, when
* an account turns tradable (historyReady needs the fund rows loaded, on
* every platform) and could trade on its first-seen date for up to a sync.
* Once per account per process; a no-op until the history has loaded and the
* account has an assignment row.
*/
export function ensureRulesEvidence(client: PlatformClient, accountId: number): void {
if (evidenceApplied().has(accountId)) return;
const evidence = brokerEvidence(client, accountId);
if (evidence === undefined) return;
const row = getAssignment(accountId);
if (!row) return;
if (evidence && lowersRulesDate(row, evidence)) lowerAutoRulesDate(accountId, evidence);
evidenceApplied().add(accountId);
}
/** Re-resolves every account on one firm's client. Skips manual overrides,
* and auto ones on a kept rule set (see below). Lowers rules dates to the
* broker's evidence on the way. Called after each sync and after the
* catalog changes. */
export function resolveFirmAssignments(
firmId: string,
client: PlatformClient,
): { assigned: number; unassigned: number; manual: number } {
const firm = getCachedFirm(firmId);
const types = firm?.accountTypes ?? [];
const stamp = freshRulesDate();
let assigned = 0;
let unassigned = 0;
let manual = 0;
for (const account of client.accountList) {
if (getAssignment(account.id)?.manual === 1) {
let row = getAssignment(account.id);
// A row an older build wrote without a rules date (between a build's
// migration and its restart, or after a rollback): dated now, as the
// migration dates rows, before the evidence below can lower it.
if (row && !row.rules_date) {
stampMissingRulesDate(account.id, stamp);
row = { ...row, rules_date: stamp };
}
// Evidence next, for every account, pinned or not: once its fund
// history has loaded, the broker shows when it opened (the report's
// firstFundDate), and an auto rules date later than that comes down
// to it. Ahead of the kept-set check below, which reads the date.
const loadedFunds = client.fundTransactions?.[account.id];
const evidence = brokerEvidence(client, account.id) ?? null;
if (row && evidence && lowersRulesDate(row, evidence)) {
lowerAutoRulesDate(account.id, evidence);
row = { ...row, rules_date: evidence };
}
if (row && loadedFunds) evidenceApplied().add(account.id);
if (row?.manual === 1) {
manual++;
continue;
}
const fundTxns = client.fundTransactions?.[account.id] ?? [];
const fundTxns = loadedFunds ?? [];
const cached = loadAccountMeta(account.id, INFERRED_SIZE_KEY);
// Sticky: an account on one of its type's kept rule sets stays bound
// to that type, so a tie-break flip to another matching type can never
// move it off its old rules. Only while it still matches the type's
// prefix and size, though: a kept set takes both from the live type,
// so after a size edit it would trade on a size it doesn't have.
// Unstuck, it re-matches as it did before kept rules existed —
// usually to Unassigned, which stops it trading.
if (row?.account_type_id) {
const live = getAccountTypeById(row.account_type_id);
if (
live && live.firmId === firmId
&& identityStillMatches(account.name, cached, live)
&& pickKeptRules(getKeptRulesFor(live.id), row.rules_date ?? null)
) {
assigned++;
continue;
}
}
let matched: { type: CatalogAccountType | null; ambiguous: boolean } = { type: null, ambiguous: false };
let matchedSize: number | null = null;
@@ -194,24 +327,37 @@ export function resolveFirmAssignments(
if (matchedSize !== null && cached === null) {
saveAccountMeta(account.id, INFERRED_SIZE_KEY, String(matchedSize));
}
setAutoAssignment(account.id, firmId, matched.type.id);
setAutoAssignment(account.id, firmId, matched.type.id, stamp);
assigned++;
} else {
setAutoAssignment(account.id, firmId, null);
setAutoAssignment(account.id, firmId, null, stamp);
unassigned++;
}
// A row this pass created is dated "first seen now"; the evidence may
// already date it earlier.
if (!row && evidence) lowerAutoRulesDate(account.id, evidence);
if (!row && loadedFunds) evidenceApplied().add(account.id);
}
return { assigned, unassigned, manual };
}
export function setManualAssignment(accountId: number, firmId: string, accountTypeId: string | null): void {
setAssignment(accountId, firmId, accountTypeId, true);
/** insertRulesDate dates the row when the pin creates it (default: now, as
* stampRulesDate gives it); re-pinning keeps the account's rules date. */
export function setManualAssignment(
accountId: number,
firmId: string,
accountTypeId: string | null,
insertRulesDate: string = freshRulesDate(),
): void {
setAssignment(accountId, firmId, accountTypeId, true, insertRulesDate);
}
/** Drops the override so the auto-resolver takes the account back over. */
/** Drops the override so the auto-resolver takes the account back over. The
* row and its rules date stay: unpinning must not make an old account look
* new. */
export function clearManualAssignment(accountId: number): boolean {
return clearAssignment(accountId);
return unpinAssignment(accountId);
}
export { listAssignments };
+617 -94
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -19,7 +19,7 @@ import {
getSetting,
setCredentials,
setInstrumentEnabled,
setAssignment,
restoreAssignment,
runInTransaction,
} from './db';
import type { BackupPayload } from './backup-sync';
@@ -116,8 +116,10 @@ export function applyBackup(payload: BackupPayload, opts: { keepMasterUrl?: bool
setInstrumentEnabled(i.symbol, i.enabled);
counts.instruments++;
}
// Rules dates travel with them: a restored machine must not date
// its accounts afresh, or old ones would leave their kept rules.
for (const a of payload.assignments ?? []) {
setAssignment(a.account_id, a.firm_id, a.account_type_id ?? null, a.manual === 1);
restoreAssignment(a);
counts.assignments++;
}
});
+4 -2
View File
@@ -26,7 +26,7 @@ import {
getInstruments,
listAllAssignments,
type FirmCredentialRow,
type AssignmentRow,
type BackupAssignmentRow,
} from './db';
export interface BackupPayload {
@@ -35,7 +35,9 @@ export interface BackupPayload {
settings: Record<string, string>;
credentials: FirmCredentialRow[];
instruments: { symbol: string; enabled: boolean }[];
assignments: AssignmentRow[];
/** Whole rows (SELECT *), rules dates included — absent from a backup
* an older build pushed. */
assignments: BackupAssignmentRow[];
}
const BACKUP_PATH = '/api/instances/backup';
+21 -5
View File
@@ -1,6 +1,7 @@
import { TradovateClient } from './tradovate-class';
import { TopstepXClient } from './topstepx-class';
import { DXTradeClient } from './dxtrade-class';
import { VestClient } from './vest-class';
import type { FirmPlatform } from './master-protocol';
import { errorMessage, isClientReady, type PlatformClient } from './platform-client';
import { getTradableFirms, startCatalogSync } from './firm-catalog';
@@ -11,8 +12,10 @@ import { resolveContracts } from './contract-resolver';
import { startReporter, sendReport } from './reporter';
import { startBackupSync } from './backup-sync';
import { applyBrokerHttpDefaults, applyBrokerProxy } from './outbound-proxy';
import axios from './http-client';
import { startMasterLink } from './master-link';
import { resumeSchedulerIfPersisted } from './auto-trade';
import { startUpdaterWatchdog } from './updater-watchdog';
import { resumeSchedulerIfPersisted, startClaimWatcher } from './auto-trade';
import { superviseQuotes, SUPERVISE_INTERVAL_MS, type QuoteClient } from './live-quotes';
// Use global to persist the client pool across HMR reloads in dev mode.
@@ -100,6 +103,11 @@ export function initClient(
case 'tradovate':
client = new TradovateClient(creds.username, creds.password, onSyncComplete);
break;
case 'vest':
// The saved "API key" is the Vest login's wallet: whose session,
// relayed by the extension, this client trades.
client = new VestClient({ firmId: id, firmName, wallet: creds.apiKey }, onSyncComplete);
break;
default: {
const unknown: never = platform;
throw new Error(`no client for platform ${String(unknown)}`);
@@ -181,13 +189,16 @@ export function getClients(): Map<string, PlatformClient> {
if (!g.__tradovateClientsInitialized) {
g.__tradovateClientsInitialized = true;
// Payouts at target are claimed whether or not the scheduler runs —
// started first, so a later init step that throws can't leave it off.
startClaimWatcher();
try {
// Before anything dials out: a timeout on every broker HTTP call,
// and the operator's proxy (if any) under the broker clients.
// Master traffic stays direct by design. (outbound-proxy applies
// Master traffic stays direct by design. (http-client applies
// both when it loads, too; these are idempotent.)
applyBrokerHttpDefaults();
applyBrokerProxy();
applyBrokerHttpDefaults(axios.defaults);
applyBrokerProxy(axios.defaults);
// Pull the catalog before anything else — without it there are no
// firms to start clients for. Subsequent changes re-reconcile and
@@ -227,6 +238,9 @@ export function getClients(): Map<string, PlatformClient> {
// Start master dashboard reporter
startReporter();
// Bring this machine's auto-updater back if its PM2 cron stops firing.
startUpdaterWatchdog();
// Push settings+credentials backups to the master, so a dead VPS
// can be replaced and this identity adopted.
startBackupSync();
@@ -371,10 +385,12 @@ const REBUILD_COOLDOWN_MS = 20 * 60_000;
* and fixing the credentials (or the firm's settings) rebuilds them through
* reconcileClients anyway. TopstepX with an API key but no username never
* tries to log in; DXTrade with no server URL can't, and after refused
* credentials it stops trying so as not to lock the account. */
* credentials it stops trying so as not to lock the account; Vest waits on
* the extension to relay a signed-in tab's session. */
function needsAttention(platform: FirmPlatform, client: PlatformClient): boolean {
if (platform === 'topstepx') return /username missing/i.test(client.lastFetchErrors?.['login'] ?? '');
if (platform === 'dxtrade') return (client as DXTradeClient).needsAttention === true;
if (platform === 'vest') return (client as VestClient).needsAttention === true;
return false;
}
+15 -3
View File
@@ -12,7 +12,7 @@
* Results are cached and refreshed periodically (default: every 30 minutes).
*/
import axios from 'axios';
import axios from './http-client';
import { errorMessage } from './platform-client';
interface ContractInfo {
@@ -37,8 +37,13 @@ const PROBE_WINDOW = 6;
// route handlers (settings page, manual trades) never see.
const _g = globalThis as typeof globalThis & {
__contractCache?: Map<string, { contract: ResolvedContract; resolvedAt: number }>;
__contractRoots?: Map<string, string>;
};
const cache = (_g.__contractCache ??= new Map());
/** Contract id → the root it was resolved for. Unlike the cache it never
* expires: a position opened hours ago still needs its instrument's name
* (lib/trade-mix.ts). Front months are few, so it stays small. */
const roots = (_g.__contractRoots ??= new Map());
const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
export function getCachedContract(symbol: string): ResolvedContract | null {
@@ -51,6 +56,12 @@ export function getCachedContract(symbol: string): ResolvedContract | null {
return entry.contract;
}
/** The root a resolved contract id belongs to, however long ago it was
* resolved; null for one this process never resolved. */
export function rootOfContract(contractId: number | string): string | null {
return roots.get(String(contractId)) ?? null;
}
export function getAllCachedContracts(): Record<string, ResolvedContract | null> {
const result: Record<string, ResolvedContract | null> = {};
for (const [symbol, entry] of cache) {
@@ -71,7 +82,7 @@ async function getContinuousExchange(product: string): Promise<string | null> {
try {
const res = await axios.get(
`https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(product + '=F')}`,
{ params: { range: '1d', interval: '1d' }, headers: { 'User-Agent': 'Mozilla/5.0' } }
{ params: { range: '1d', interval: '1d' } }
);
const meta = res.data?.chart?.result?.[0]?.meta;
if (!meta) return null;
@@ -106,7 +117,7 @@ async function findHighestVolumeContract(
try {
const res = await axios.get(
`https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(ticker)}`,
{ params: { range: '1d', interval: '1d' }, headers: { 'User-Agent': 'Mozilla/5.0' } }
{ params: { range: '1d', interval: '1d' } }
);
const result = res.data?.chart?.result?.[0];
const meta = result?.meta;
@@ -205,6 +216,7 @@ export async function resolveContracts(
};
results[sym] = contract;
cache.set(sym, { contract, resolvedAt: Date.now() });
roots.set(String(contract.id), sym);
if (rolledFrom) {
console.log(`[contract-resolver] ${sym}: ${rolledFrom} → ${match.name} ROLLED (by volume)`);
+461 -13
View File
@@ -1,6 +1,8 @@
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
@@ -78,6 +80,62 @@ try {
// Column already exists
}
// Migration: each account's rules date — which of its account type's rule
// sets it trades on (lib/kept-rules.ts): an account dated before a firm's
// rule change keeps the rules from before it. Written when this machine first
// records the account (or from a restored backup), lowered to the broker's
// evidence of when the account opened, or set by hand (rules_date_manual,
// which evidence never moves). Every writer stores a toISOString() UTC
// string, so string order is time order.
//
// The run that adds the column dates every existing row at the migration
// time, never NULL: no account changes rules merely because its machine
// updated. The resolver then lowers those auto dates to the broker's
// evidence, so an account the evidence shows opened before a rule change
// goes back to the old rules. Column and backfill commit together, so
// `next build` workers racing the migration can't leave rows undated.
/** The date an undated row gets: now, but never before the catalog's last
* change — lib/kept-rules.ts stampRulesDate's floor (every build keeps
* settings.catalog_updated_at current), so a machine whose clock runs
* behind can't backdate an account first seen after a rule change into the
* rule set that change closed. Call inside the writing transaction. */
function undatedRowsDate(): string {
let catalogUpdatedAt: string | null = null;
try {
const row = db.prepare("SELECT value FROM settings WHERE key = 'catalog_updated_at'").get() as { value?: unknown } | undefined;
if (typeof row?.value === 'string') catalogUpdatedAt = row.value;
} catch {
// No settings table yet: a fresh database, with no rows to date.
}
return stampRulesDate(Date.now(), null, catalogUpdatedAt);
}
try {
db.transaction(() => {
db.exec('ALTER TABLE account_assignments ADD COLUMN rules_date TEXT');
db.prepare('UPDATE account_assignments SET rules_date = ? WHERE rules_date IS NULL').run(undatedRowsDate());
}).immediate();
} catch {
// Column already exists
}
try {
db.exec('ALTER TABLE account_assignments ADD COLUMN rules_date_manual INTEGER NOT NULL DEFAULT 0');
} catch {
// Column already exists
}
// Rows an older build wrote after that run have no date: it keeps serving
// between a build's migration and the restart, and comes back in a rollback,
// and its INSERT has no rules_date. Dated the same way at every start, so no
// row stays undated (current rules forever, with no date to set by hand).
try {
db.transaction(() => {
db.prepare('UPDATE account_assignments SET rules_date = ? WHERE rules_date IS NULL').run(undatedRowsDate());
}).immediate();
} catch {
// No rules_date column — the migration above failed; nothing to date.
}
// ── Firm credentials ─────────────────────────────────────────────────────────
export interface FirmCredentialRow {
@@ -133,8 +191,18 @@ export interface AssignmentRow {
account_type_id: string | null;
manual: number; // 0 | 1
resolved_at: string;
/** ISO (toISOString). Which of the type's rule sets the account trades
* on; null only on a row an older build wrote after the migration —
* the current rules until evidence dates it. */
rules_date: string | null;
rules_date_manual: number; // 0 | 1 — set by hand; evidence leaves it alone
}
/** An assignment as a backup carries it: one from a build before rules
* dates has neither column. */
export type BackupAssignmentRow = Omit<AssignmentRow, 'rules_date' | 'rules_date_manual'>
& Partial<Pick<AssignmentRow, 'rules_date' | 'rules_date_manual'>>;
export function getAssignment(accountId: number): AssignmentRow | undefined {
return db.prepare('SELECT * FROM account_assignments WHERE account_id = ?').get(accountId) as AssignmentRow | undefined;
}
@@ -148,33 +216,116 @@ export function listAllAssignments(): AssignmentRow[] {
return db.prepare('SELECT * FROM account_assignments').all() as AssignmentRow[];
}
/** insertRulesDate dates a row this call creates (default now — callers pass
* lib/kept-rules.ts stampRulesDate). An existing row keeps its rules date
* and how it was set: re-resolving or re-pinning an account never makes it
* look new. Only a row with no date at all (an older build's) takes
* insertRulesDate. */
export function setAssignment(
accountId: number,
firmId: string,
accountTypeId: string | null,
manual: boolean,
insertRulesDate?: string,
): void {
const now = new Date().toISOString();
db.prepare(`
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at)
VALUES (?, ?, ?, ?, ?)
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at, rules_date)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(account_id) DO UPDATE SET
firm_id = excluded.firm_id,
account_type_id = excluded.account_type_id,
manual = excluded.manual,
resolved_at = excluded.resolved_at
`).run(accountId, firmId, accountTypeId, manual ? 1 : 0, new Date().toISOString());
resolved_at = excluded.resolved_at,
rules_date = COALESCE(account_assignments.rules_date, excluded.rules_date)
`).run(accountId, firmId, accountTypeId, manual ? 1 : 0, now, insertRulesDate ?? now);
}
/** Writes an auto-resolved assignment, leaving any manual override alone.
* The `manual = 0` guard is what makes a user's pick stick across syncs. */
export function setAutoAssignment(accountId: number, firmId: string, accountTypeId: string | null): void {
export function setAutoAssignment(
accountId: number,
firmId: string,
accountTypeId: string | null,
insertRulesDate?: string,
): void {
const existing = getAssignment(accountId);
if (existing?.manual === 1) return;
setAssignment(accountId, firmId, accountTypeId, false);
setAssignment(accountId, firmId, accountTypeId, false, insertRulesDate);
}
export function clearAssignment(accountId: number): boolean {
return db.prepare('DELETE FROM account_assignments WHERE account_id = ?').run(accountId).changes > 0;
/** Drops a manual pin but keeps the row — and with it the rules date, so
* unpinning can't make an old account look new. The auto-resolver takes the
* account back on its next pass. False when there is no row. */
export function unpinAssignment(accountId: number): boolean {
return db.prepare('UPDATE account_assignments SET manual = 0, account_type_id = NULL, resolved_at = ? WHERE account_id = ?')
.run(new Date().toISOString(), accountId).changes > 0;
}
/** Sets an account's rules date (an ISO toISOString() string); manual marks
* it set by hand, which broker evidence never moves. False when the account
* has no row. */
export function setRulesDate(accountId: number, iso: string, manual: boolean): boolean {
return db.prepare('UPDATE account_assignments SET rules_date = ?, rules_date_manual = ? WHERE account_id = ?')
.run(iso, manual ? 1 : 0, accountId).changes > 0;
}
/** Dates a row that has none (one an older build wrote) at `iso`; a dated
* row is left alone. */
export function stampMissingRulesDate(accountId: number, iso: string): void {
db.prepare('UPDATE account_assignments SET rules_date = ? WHERE account_id = ? AND rules_date IS NULL').run(iso, accountId);
}
/** Moves an auto rules date earlier to `iso` (the broker's evidence of when
* the account opened, lib/kept-rules.ts openingEvidence); never later and
* never a date set by hand. Compared as strings, which is time order only
* because every rules date is a plain four-digit-year UTC toISOString()
* string (lib/kept-rules.ts sortableIso) — keep it that way. */
export function lowerAutoRulesDate(accountId: number, iso: string): void {
db.prepare(`
UPDATE account_assignments SET rules_date = ?
WHERE account_id = ? AND rules_date_manual = 0 AND (rules_date IS NULL OR rules_date > ?)
`).run(iso, accountId, iso);
}
/**
* One backed-up assignment onto this machine, every column (both restore
* paths: lib/backup-restore.ts, and scripts/seed-settings.js by hand). The
* backup's rules date and its manual flag win when the backup has one; a
* backup from an older build has none, so an existing row keeps its own and
* a new row is dated now, at the restore.
*/
export function restoreAssignment(row: BackupAssignmentRow): void {
const now = new Date().toISOString();
const resolvedAt = typeof row.resolved_at === 'string' ? row.resolved_at : now;
// Only a date that sorts as a string counts as the backup's (anything
// else is restored like an older build's undated row).
const rulesDate = typeof row.rules_date === 'string' ? sortableIso(Date.parse(row.rules_date)) : null;
if (rulesDate) {
db.prepare(`
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at, rules_date, rules_date_manual)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(account_id) DO UPDATE SET
firm_id = excluded.firm_id,
account_type_id = excluded.account_type_id,
manual = excluded.manual,
resolved_at = excluded.resolved_at,
rules_date = excluded.rules_date,
rules_date_manual = excluded.rules_date_manual
`).run(row.account_id, row.firm_id, row.account_type_id ?? null, row.manual === 1 ? 1 : 0, resolvedAt,
rulesDate, row.rules_date_manual === 1 ? 1 : 0);
return;
}
db.prepare(`
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at, rules_date)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(account_id) DO UPDATE SET
firm_id = excluded.firm_id,
account_type_id = excluded.account_type_id,
manual = excluded.manual,
resolved_at = excluded.resolved_at,
rules_date = COALESCE(account_assignments.rules_date, excluded.rules_date)
`).run(row.account_id, row.firm_id, row.account_type_id ?? null, row.manual === 1 ? 1 : 0, resolvedAt, now);
}
// ── Settings ─────────────────────────────────────────────────────────────────
@@ -239,6 +390,18 @@ export function setSetting(key: string, value: string): void {
db.prepare(`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(key, value);
}
/** Seed a generated setting once, including across simultaneous server workers.
* Acquire the write lock before reading so they cannot choose different values. */
export function getOrCreateSetting(key: string, create: () => string): string {
return db.transaction(() => {
const saved = getSetting(key);
if (saved?.trim()) return saved;
const value = create();
setSetting(key, value);
return value;
}).immediate();
}
// ── Instruments ──────────────────────────────────────────────────────────────
const SYMBOLS = ['NQ','MNQ','ES','MES','YM','MYM','RTY','M2K','GC','MGC','SI','CL','MCL','NG','ZB','ZN','ZF','6E','6J','6B'];
@@ -247,12 +410,17 @@ const SYMBOLS = ['NQ','MNQ','ES','MES','YM','MYM','RTY','M2K','GC','MGC','SI','C
// from the Instruments page, they just start off.
const DEFAULT_ENABLED = new Set(['NQ', 'GC', 'CL']);
// Seed instruments table if empty
const instrCount = (db.prepare('SELECT COUNT(*) as count FROM instruments').get() as { count: number }).count;
if (instrCount === 0) {
const ins = db.prepare('INSERT INTO instruments (symbol, enabled) VALUES (?, ?)');
// Seed instruments table if empty. Check and insert share one IMMEDIATE
// transaction: `next build` imports this module from several workers at once,
// and on a fresh machine they all saw an empty table and raced to insert the
// same symbols (UNIQUE constraint failed: instruments.symbol). OR IGNORE is
// the backstop if the lock is ever skipped.
db.transaction(() => {
const instrCount = (db.prepare('SELECT COUNT(*) as count FROM instruments').get() as { count: number }).count;
if (instrCount > 0) return;
const ins = db.prepare('INSERT OR IGNORE INTO instruments (symbol, enabled) VALUES (?, ?)');
for (const s of SYMBOLS) ins.run(s, DEFAULT_ENABLED.has(s) ? 1 : 0);
}
}).immediate();
export interface InstrumentRow {
symbol: string;
@@ -328,6 +496,22 @@ export function loadFundTransactions(accountId: number): { date: string; amount:
return db.prepare('SELECT date, amount FROM fund_transactions WHERE account_id = ? ORDER BY date').all(accountId) as { date: string; amount: number }[];
}
/** Deletes one day's fund transaction and sets account meta keys back in the
* same transaction (null deletes the key), so no reader sees the row gone
* with the cycle it started still in place. TopstepX's reconcile uses it to
* take back a day whose bookings netted to fee noise. */
export function dropFundTransaction(accountId: number, date: string, meta: { [key: string]: string | null }): void {
const del = db.prepare('DELETE FROM account_meta WHERE account_id = ? AND key = ?');
const txn = db.transaction(() => {
db.prepare('DELETE FROM fund_transactions WHERE account_id = ? AND date = ?').run(accountId, date);
for (const [key, value] of Object.entries(meta)) {
if (value === null) del.run(accountId, key);
else saveAccountMeta(accountId, key, value);
}
});
txn();
}
// ── Account Meta ─────────────────────────────────────────────────────────────
db.exec(`
@@ -373,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
@@ -394,6 +601,247 @@ export function wipeAccountData(accountId: number): void {
txn();
}
// ── Trades ───────────────────────────────────────────────────────────────────
//
// Every position a platform's history shows — the bot's and anyone else's
// (Vest: GET /v3/positions). The platform can't tell an API order from a
// click, so opened_by / closed_by record what this instance did itself:
// 'bot' when its own order opened or closed the position, NULL otherwise —
// or 'outside', set by hand for a trade from before marking began whose
// origin the bot's own log settles.
// A wipe leaves these alone: they're refetched, but the marks aren't.
db.exec(`
CREATE TABLE IF NOT EXISTS trades (
position_id TEXT PRIMARY KEY,
account_id INTEGER NOT NULL,
account_name TEXT NOT NULL,
symbol TEXT NOT NULL,
side TEXT NOT NULL,
quantity REAL NOT NULL,
leverage REAL,
open_price REAL,
close_price REAL,
pnl REAL,
fee REAL,
funding REAL,
opened_at INTEGER NOT NULL,
closed_at INTEGER,
close_reason TEXT,
opened_by TEXT,
closed_by TEXT,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS trades_account_opened ON trades (account_id, opened_at);
`);
/** settings key: when this instance began marking its own trades (epoch
* ms). A position opened before it and never marked is of unknown origin;
* one opened after it was opened outside the bot. */
export const TRADES_SINCE_SETTING = 'trades_tracked_since';
export interface TradeRow {
positionId: string;
accountId: number;
accountName: string;
symbol: string;
side: 'long' | 'short';
quantity: number;
leverage: number | null;
openPrice: number | null;
closePrice: number | null;
pnl: number | null;
fee: number | null;
funding: number | null;
openedAt: number;
closedAt: number | null;
closeReason: string | null;
openedBy: string | null;
closedBy: string | null;
}
/** The platform's view of positions: inserts new ones, refreshes known ones.
* A size it no longer reports (0) keeps the one already stored, a field it
* leaves out keeps its value, and the bot's marks are never touched. */
export function saveTrades(rows: Omit<TradeRow, 'openedBy' | 'closedBy'>[]): void {
const up = db.prepare(`
INSERT INTO trades (position_id, account_id, account_name, symbol, side, quantity, leverage, open_price, close_price,
pnl, fee, funding, opened_at, closed_at, close_reason, updated_at)
VALUES (@positionId, @accountId, @accountName, @symbol, @side, @quantity, @leverage, @openPrice, @closePrice,
@pnl, @fee, @funding, @openedAt, @closedAt, @closeReason, @updatedAt)
ON CONFLICT(position_id) DO UPDATE SET
account_name = excluded.account_name,
symbol = excluded.symbol,
side = excluded.side,
quantity = CASE WHEN excluded.quantity > 0 THEN excluded.quantity ELSE trades.quantity END,
leverage = COALESCE(excluded.leverage, trades.leverage),
open_price = COALESCE(excluded.open_price, trades.open_price),
close_price = CASE WHEN COALESCE(excluded.closed_at, trades.closed_at) IS NULL THEN NULL
ELSE COALESCE(excluded.close_price, trades.close_price) END,
pnl = CASE WHEN COALESCE(excluded.closed_at, trades.closed_at) IS NULL THEN NULL
ELSE COALESCE(excluded.pnl, trades.pnl) END,
fee = COALESCE(excluded.fee, trades.fee),
funding = COALESCE(excluded.funding, trades.funding),
opened_at = excluded.opened_at,
closed_at = COALESCE(excluded.closed_at, trades.closed_at),
close_reason = COALESCE(excluded.close_reason, trades.close_reason),
updated_at = excluded.updated_at
`);
const now = Date.now();
const txn = db.transaction(() => {
for (const r of rows) up.run({ ...r, updatedAt: now });
});
txn();
}
/** This instance's own order opened (or closed) the position — stored at
* once, before the platform's history shows it. */
export function markTrade(
by: 'opened' | 'closed',
t: { positionId: string; accountId: number; accountName: string; symbol: string; side: 'long' | 'short'; quantity: number; openPrice: number | null; openedAt: number },
): void {
const col = by === 'opened' ? 'opened_by' : 'closed_by';
db.prepare(`
INSERT INTO trades (position_id, account_id, account_name, symbol, side, quantity, open_price, opened_at, ${col}, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'bot', ?)
ON CONFLICT(position_id) DO UPDATE SET ${col} = 'bot', updated_at = excluded.updated_at
`).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 }[] {
return db.prepare(`SELECT account_id AS accountId, MAX(account_name) AS accountName FROM trades
WHERE closed_at IS NULL AND position_id NOT LIKE '%:%' GROUP BY account_id`)
.all() as { accountId: number; accountName: string }[];
}
// ── Own orders, and trades built from fills ──────────────────────────────────
//
// TopstepX and Tradovate report fills, not positions, and neither says who
// placed an order. So every order this instance places is noted here as it
// goes in, and a trade built from fills (lib/trade-recorder.ts) is the bot's
// when its opening fill's order is one of these. Its kind tells how the bot
// closed one: its stop, its take-profit limit, or a market order. Those
// trades carry a platform prefix on their id ("tsx:…", "tdv:…") and their
// marks are worked out afresh on each pass: 'bot', 'outside', or 'unknown'
// for a fill from before this table began.
db.exec(`
CREATE TABLE IF NOT EXISTS own_orders (
account_id INTEGER NOT NULL,
order_id TEXT NOT NULL,
kind TEXT NOT NULL,
placed_at INTEGER NOT NULL,
PRIMARY KEY (account_id, order_id)
);
`);
/** settings key: when this instance began noting its own futures orders. */
const OWN_ORDERS_SINCE_SETTING = 'own_orders_since';
const ownOrdersSinceMs = Number(getOrCreateSetting(OWN_ORDERS_SINCE_SETTING, () => String(Date.now())));
export function ownOrdersSince(): number {
return ownOrdersSinceMs;
}
export type OwnOrderKind = 'market' | 'limit' | 'stop';
export function recordOwnOrder(accountId: number, orderId: string | number, kind: OwnOrderKind): void {
db.prepare('INSERT OR IGNORE INTO own_orders (account_id, order_id, kind, placed_at) VALUES (?, ?, ?, ?)')
.run(accountId, String(orderId), kind, Date.now());
}
export function loadOwnOrders(accountId: number): Map<string, OwnOrderKind> {
const rows = db.prepare('SELECT order_id AS orderId, kind FROM own_orders WHERE account_id = ?').all(accountId) as { orderId: string; kind: OwnOrderKind }[];
return new Map(rows.map((r) => [r.orderId, r.kind]));
}
export type FillTradeRow = Omit<TradeRow, 'leverage' | 'funding'>;
/** One account's stored trades off one platform — for one contract, when
* `symbol` is given. */
export function loadFillTrades(accountId: number, prefix: string, symbol?: string): FillTradeRow[] {
return db.prepare(`
SELECT position_id AS positionId, account_id AS accountId, account_name AS accountName, symbol, side, quantity,
open_price AS openPrice, close_price AS closePrice, pnl, fee, opened_at AS openedAt, closed_at AS closedAt,
close_reason AS closeReason, opened_by AS openedBy, closed_by AS closedBy
FROM trades WHERE account_id = ? AND position_id LIKE ? ${symbol === undefined ? '' : 'AND symbol = ?'}
`).all(accountId, `${prefix}:%`, ...(symbol === undefined ? [] : [symbol])) as FillTradeRow[];
}
/** Trades rebuilt from fills: every field as worked out this pass, except a
* null openedBy (a carried trade's) keeps the stored one. */
export function saveFillTrades(rows: FillTradeRow[]): void {
const up = db.prepare(`
INSERT INTO trades (position_id, account_id, account_name, symbol, side, quantity, open_price, close_price,
pnl, fee, opened_at, closed_at, close_reason, opened_by, closed_by, updated_at)
VALUES (@positionId, @accountId, @accountName, @symbol, @side, @quantity, @openPrice, @closePrice,
@pnl, @fee, @openedAt, @closedAt, @closeReason, @openedBy, @closedBy, @updatedAt)
ON CONFLICT(position_id) DO UPDATE SET
account_name = excluded.account_name,
side = excluded.side,
quantity = excluded.quantity,
open_price = COALESCE(excluded.open_price, trades.open_price),
close_price = excluded.close_price,
pnl = excluded.pnl,
fee = COALESCE(excluded.fee, trades.fee),
opened_at = excluded.opened_at,
closed_at = excluded.closed_at,
close_reason = excluded.close_reason,
opened_by = COALESCE(excluded.opened_by, trades.opened_by),
closed_by = excluded.closed_by,
updated_at = excluded.updated_at
`);
const now = Date.now();
db.transaction(() => {
for (const r of rows) up.run({ ...r, updatedAt: now });
})();
}
/** Trades a pass found never existed (built off a history that moved
* under it). */
export function deleteTrades(positionIds: string[]): void {
const del = db.prepare('DELETE FROM trades WHERE position_id = ?');
db.transaction(() => {
for (const id of positionIds) del.run(id);
})();
}
/** Open trades whose close the history no longer reaches: closed, how and
* when unknown. */
export function closeTradesUnseen(positionIds: string[]): void {
const up = db.prepare(`UPDATE trades SET close_reason = 'unknown', updated_at = ? WHERE position_id = ? AND closed_at IS NULL AND close_reason IS NULL`);
const now = Date.now();
db.transaction(() => {
for (const id of positionIds) up.run(now, id);
})();
}
/** Newest first (by close, else open); every account when accountIds is
* undefined. */
export function loadTrades(o: { accountIds?: number[]; limit: number }): TradeRow[] {
if (o.accountIds && o.accountIds.length === 0) return [];
const where = o.accountIds ? `WHERE account_id IN (${o.accountIds.map(() => '?').join(', ')})` : '';
return db.prepare(`
SELECT position_id AS positionId, account_id AS accountId, account_name AS accountName, symbol, side, quantity, leverage,
open_price AS openPrice, close_price AS closePrice, pnl, fee, funding, opened_at AS openedAt, closed_at AS closedAt,
close_reason AS closeReason, opened_by AS openedBy, closed_by AS closedBy
FROM trades ${where}
ORDER BY COALESCE(closed_at, opened_at) DESC
LIMIT ?
`).all(...(o.accountIds ?? []), o.limit) as TradeRow[];
}
// ── AutoBuyer captures ───────────────────────────────────────────────────────
db.exec(`
+157 -14
View File
@@ -31,29 +31,36 @@
* account shows as accountStatus ≠ FULL_TRADING → active=false.
*/
import axios from 'axios';
import { currentTradeDateCT, FIRM_RESET_UTC_HOUR, DEFAULT_TARGET_HOLD_HOURS, rankForTarget } from './trading-logic';
import axios from './http-client';
import { currentTradeDateCT, FIRM_RESET_UTC_HOUR, rankForTarget } from './trading-logic';
import { sizingHoldHours } from './person-size';
import {
saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions,
saveEndedCycleStart,
saveEndedCycleStart, loadFillTrades, saveFillTrades, closeTradesUnseen, type FillTradeRow,
} from './db';
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,
inTradingHours, realizedVolPerHour, scoreCandidate, candidateRejection, parseTransfers, marginRateOf,
parseHistoryOrder, dxTrades,
} from './dxtrade-helpers';
import type { DxTxn } from './dxtrade-helpers';
import type { DxTxn, DxHistoryOrder, DxKnownPosition } from './dxtrade-helpers';
import { hyperliquidCoins, hyperliquidCoinFor, hyperliquidQuote, hyperliquidHourlyCloses } from './hyperliquid-feed';
// ── timings ──────────────────────────────────────────────────────────────────
const SYNC_INTERVAL_MS = 60_000;
/** The trades record reads an active account's orders this often — sooner
* once its positions or the day's cash show something happened. */
const TRADES_RECORD_MS = 5 * 60_000;
/** How far back the trades record reads orders and cash. */
const TRADES_WINDOW_MS = 2 * 86_400_000;
const SYNC_WEDGED_MS = 3 * 60_000;
const WATCHDOG_INTERVAL_MS = 60_000;
/** Logged in longer than this with no good sync for as long → re-login. */
@@ -256,6 +263,12 @@ export class DXTradeClient implements PlatformClient {
private recentTxns = new Map<number, Map<string, DxTxn>>();
/** Accounts whose balance and day's transactions came back this pass. */
private dayFresh = new Set<number>();
/** The trades record: when each account was last read, and what its
* positions and day's cash looked like then. */
private tradesRecordedAt = new Map<number, { at: number; sig: string }>();
private tradesRecordInFlight = false;
/** The order history refused a time-from: read the latest orders instead. */
private historyTakesNoTimeFrom = false;
/** When each open position was first seen, for hold-time logging. */
private seenOpenAt = new Map<number, { positionCode: string; at: number; naggedAt: number }>();
@@ -684,6 +697,7 @@ export class DXTradeClient implements PlatformClient {
await this.reapStrayOrders();
this.checkNakedPositions();
if (superseded()) return;
if (fresh) void this.recordTrades();
console.log(`${this.tag} sync — ${list.length} account(s), ${Object.keys(this.positions).length} open position(s)`);
this.maybeFetchDaysTraded();
@@ -1018,6 +1032,12 @@ export class DXTradeClient implements PlatformClient {
&& this.fundTransactions[accountId] !== undefined;
}
/** The account's accountStatus and registration time, for the report. */
public brokerAccountMeta(accountId: number): { status: string; registrationTime: number } | undefined {
const meta = this.accountMeta.get(accountId);
return meta ? { status: meta.status, registrationTime: meta.registrationTime } : undefined;
}
public forgetHistory(accountId: number, accountName: string): void {
this.historyLoadedIds.delete(accountId);
this.booksHeld.delete(accountId);
@@ -1216,7 +1236,7 @@ export class DXTradeClient implements PlatformClient {
const ref = this.referenceAccount();
if (!ref) return null;
const firm = getCachedFirm(this.cfg.firmId);
const targetHold = firm?.targetHoldHours || DEFAULT_TARGET_HOLD_HOURS;
const targetHold = sizingHoldHours(firm);
const symbols = [...new Set(candidates.map((c) => c.symbol))];
const [insts, dets, quotes, vols] = await Promise.all([
this.instruments(symbols),
@@ -1323,6 +1343,122 @@ export class DXTradeClient implements PlatformClient {
return {};
}
// ── trades record ────────────────────────────────────────────────────────
/**
* Every position on the accounts, the bot's and anyone else's, in the
* trades table the dashboard lists — off the last two days' filled orders
* and their SETTLEMENT and COMMISSION. The bot's orders carry its af-
* codes, so who opened and closed each needs nothing kept locally. Only
* an account with something going on is read (an open position, the
* day's cash, a trade still open in the table), each at most every few
* minutes. Never fails the sync.
*/
private async recordTrades(): Promise<void> {
if (this.tradesRecordInFlight || !this.token || this.intentionalDisconnect) return;
const now = Date.now();
const due: { acc: PlatformAccount; code: string; sig: string; stored: FillTradeRow[] }[] = [];
for (const acc of this.accountList) {
const code = this.codeById.get(acc.id);
if (!code || !this.dayFresh.has(acc.id)) continue;
const positions = (this.positionsDetail.get(acc.id) ?? []).map((p) => p.positionCode).sort().join(',');
const trading = [...(this.recentTxns.get(acc.id)?.values() ?? [])].filter((t) => !isFundType(t.type)).length;
const sig = positions || trading > 0 ? `${positions}|${trading}` : '';
const stored = loadFillTrades(acc.id, 'dx');
const last = this.tradesRecordedAt.get(acc.id);
const storedOpen = stored.some((t) => t.closedAt === null && t.closeReason === null);
if (sig === '' && !storedOpen && (!last || last.sig === '')) continue;
if (last && last.sig === sig && now - last.at < TRADES_RECORD_MS) continue;
due.push({ acc, code, sig, stored });
}
if (due.length === 0) return;
this.tradesRecordInFlight = true;
try {
const from = new Date(now - TRADES_WINDOW_MS).toISOString();
const txns = (await this.perAccounts<unknown>('/accounts/transfers', due.map((d) => d.code), (d) => [d],
{ 'time-from': from, limit: 1000 }, 'history')).flatMap((body) => parseTransfers(body));
for (const { acc, code, sig, stored } of due) {
if (this.intentionalDisconnect) return;
const orders = await this.historyOrders(code, from);
if (orders === null) continue;
this.saveTrades(acc, orders, txns.filter((t) => t.account === code), stored);
this.tradesRecordedAt.set(acc.id, { at: Date.now(), sig });
}
} catch (err) {
console.warn(`${this.tag} trades record failed: ${errorMessage(err)}`);
} finally {
this.tradesRecordInFlight = false;
}
}
/** An account's filled orders since `from`; null when they couldn't be read. */
private async historyOrders(code: string, from: string): Promise<DxHistoryOrder[] | null> {
const path = `/accounts/${encodeURIComponent(code)}/orders/history`;
try {
let data: { orders?: unknown[] } | undefined;
try {
({ data } = await this.request<{ orders?: unknown[] }>('history', 'GET', path, {
params: this.historyTakesNoTimeFrom ? { limit: 500 } : { 'time-from': from, limit: 500 },
}));
} catch (err) {
if (!(err instanceof DxApiError) || err.status !== 400 || this.historyTakesNoTimeFrom) throw err;
this.historyTakesNoTimeFrom = true;
({ data } = await this.request<{ orders?: unknown[] }>('history', 'GET', path, { params: { limit: 500 } }));
}
const since = Date.parse(from);
return (data?.orders ?? []).map(parseHistoryOrder).filter((o): o is DxHistoryOrder => o !== null && o.at >= since);
} catch (err) {
console.warn(`${this.tag} order history for ${accountDisplayName(code)} failed: ${errorMessage(err)}`);
return null;
}
}
private saveTrades(acc: PlatformAccount, orders: DxHistoryOrder[], txns: DxTxn[], stored: FillTradeRow[]): void {
const prefix = `dx:${acc.id}:`;
const open = new Map<string, DxKnownPosition>();
for (const p of this.positionsDetail.get(acc.id) ?? []) {
open.set(p.positionCode, {
symbol: p.symbol, side: p.side === 'BUY' ? 'long' : 'short', quantity: p.quantity,
openPrice: Number.isFinite(p.openPrice) ? p.openPrice : null, openedAt: p.openTime,
});
}
const known = new Map<string, DxKnownPosition>(stored.map((t) => [t.positionId.slice(prefix.length), {
symbol: t.symbol, side: t.side, quantity: t.quantity, openPrice: t.openPrice, openedAt: t.openedAt,
}]));
const ours = (code: string | null) => code !== null && code.startsWith('af-');
const rows: FillTradeRow[] = dxTrades(orders, txns, open, known)
.filter((t) => Number.isFinite(t.openedAt))
.map((t) => {
const closed = t.closedAt !== null;
const exit = closed && ours(t.closeCode) ? t.closeType : null;
return {
positionId: `${prefix}${t.positionCode}`,
accountId: acc.id,
accountName: acc.name,
symbol: t.symbol,
side: t.side,
quantity: t.quantity,
openPrice: t.openPrice,
closePrice: t.closePrice,
pnl: t.pnl,
fee: t.fee,
openedAt: t.openedAt,
closedAt: t.closedAt,
closeReason: exit === 'STOP' ? 'stop_loss' : exit === 'LIMIT' ? 'take_profit' : null,
openedBy: t.openCode === null ? null : ours(t.openCode) ? 'bot' : 'outside',
closedBy: !closed || exit === 'STOP' || exit === 'LIMIT' ? null : ours(t.closeCode) ? 'bot' : 'outside',
};
});
saveFillTrades(rows);
// Open in the table, gone from the account, and nothing in the
// history closed it: closed out of sight.
const seen = new Set(rows.map((r) => r.positionId));
closeTradesUnseen(stored
.filter((t) => t.closedAt === null && t.closeReason === null && !seen.has(t.positionId) && !open.has(t.positionId.slice(prefix.length)))
.map((t) => t.positionId));
}
// ── orders ───────────────────────────────────────────────────────────────
private async freshPositions(account: string): Promise<DxPosition[]> {
@@ -1421,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,
@@ -1433,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;
@@ -1449,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';
@@ -1458,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>;
+153
View File
@@ -328,3 +328,156 @@ export function candidateRejection(o: {
if (o.currency && o.currency !== o.accountCurrency) return `priced in ${o.currency}`;
return null;
}
// ── trades ───────────────────────────────────────────────────────────────────
/** A filled order off /orders/history, as the trades record needs it. */
export interface DxHistoryOrder {
orderCode: string;
orderId: number | undefined;
/** MARKET, LIMIT, STOP. */
type: string;
instrument: string;
positionCode: string | undefined;
effect: 'OPEN' | 'CLOSE' | undefined;
side: 'BUY' | 'SELL' | undefined;
/** Filled. */
qty: number;
/** Average fill price. */
price: number | undefined;
/** The last fill, else the order's own time. */
at: number;
}
/** null for an order that never filled. */
export function parseHistoryOrder(raw: unknown): DxHistoryOrder | null {
const o = raw as Record<string, unknown>;
if (!o || typeof o !== 'object' || typeof o.orderCode !== 'string') return null;
const legs = Array.isArray(o.legs) ? (o.legs as Record<string, unknown>[]) : [];
const leg = legs[0] ?? {};
const qty = Math.abs(ciNum(leg, 'filledQuantity') ?? ciNum(o, 'filledQuantity') ?? 0);
if (!(qty > 0)) return null;
const executions = Array.isArray(o.executions) ? (o.executions as Record<string, unknown>[]) : [];
const fillTimes = executions
.filter((e) => (ciNum(e, 'lastQuantity') ?? ciNum(e, 'filledQuantity') ?? 0) > 0)
.map((e) => timeOf(e.transactionTime))
.filter(Number.isFinite);
const at = fillTimes.length > 0 ? Math.max(...fillTimes) : [timeOf(o.transactionTime), timeOf(o.issueTime)].find(Number.isFinite) ?? NaN;
if (!Number.isFinite(at)) return null;
const effect = leg.positionEffect === 'OPEN' || leg.positionEffect === 'CLOSE' ? leg.positionEffect : undefined;
const sideRaw = leg.side ?? o.side;
return {
orderCode: o.orderCode,
orderId: ciNum(o, 'orderId'),
type: String(o.type ?? ''),
instrument: String(leg.instrument ?? o.instrument ?? ''),
positionCode: typeof leg.positionCode === 'string' && leg.positionCode ? leg.positionCode : undefined,
effect,
side: sideRaw === 'BUY' || sideRaw === 'SELL' ? sideRaw : undefined,
qty,
price: ciNum(leg, 'averagePrice')
?? [...executions].reverse().map((e) => ciNum(e, 'averagePrice')).find((p) => p !== undefined && p > 0),
at,
};
}
/** One position, from the orders that opened and closed it. */
export interface DxTrade {
positionCode: string;
symbol: string;
side: 'long' | 'short';
quantity: number;
openPrice: number | null;
closePrice: number | null;
/** The SETTLEMENT its closes booked; null while open or unseen. */
pnl: number | null;
/** Its COMMISSION, opens and closes; null when none was seen. */
fee: number | null;
openedAt: number;
closedAt: number | null;
/** The opening order's code; null when it opened before the history. */
openCode: string | null;
/** The order that closed it, and its type. */
closeCode: string | null;
closeType: string | null;
}
/** What the record knows of a position already: the open one on the
* account now, or one stored from an earlier pass. */
export interface DxKnownPosition {
symbol: string;
side: 'long' | 'short';
quantity: number;
openPrice: number | null;
openedAt: number;
}
const vwap = (orders: DxHistoryOrder[]): number | null => {
const priced = orders.filter((o) => o.price !== undefined);
const qty = priced.reduce((s, o) => s + o.qty, 0);
return qty > 0 ? priced.reduce((s, o) => s + o.price! * o.qty, 0) / qty : null;
};
/**
* The account's positions, out of its filled orders (one account) and its
* cash transactions. On a position-based account an opening order's position
* code is its order id, so an OPEN leg without one takes that. A position the
* account still holds (`open`) stays open whatever closed part of it; one
* whose opening is older than the orders at hand is completed from `known`,
* and left out when nothing knows it.
*/
export function dxTrades(orders: DxHistoryOrder[], txns: DxTxn[], open: Map<string, DxKnownPosition>, known: Map<string, DxKnownPosition>): DxTrade[] {
const groups = new Map<string, DxHistoryOrder[]>();
for (const o of orders) {
const code = o.positionCode ?? (o.effect === 'OPEN' && o.orderId !== undefined ? String(o.orderId) : undefined);
if (!code) continue;
groups.set(code, [...(groups.get(code) ?? []), o]);
}
for (const code of open.keys()) if (!groups.has(code)) groups.set(code, []);
const settled = new Map<string, number>();
const commission = new Map<string, number>();
for (const t of txns) {
if (!t.orderCode) continue;
if (t.type === 'SETTLEMENT') settled.set(t.orderCode, (settled.get(t.orderCode) ?? 0) + t.value);
else if (t.type === 'COMMISSION') commission.set(t.orderCode, (commission.get(t.orderCode) ?? 0) + Math.abs(t.value));
}
const out: DxTrade[] = [];
for (const [code, list] of groups) {
const sorted = [...list].sort((a, b) => a.at - b.at);
const now = open.get(code);
const before = now ?? known.get(code);
// An OPEN leg, or one on the same side as the opening.
const firstOpen = sorted.find((o) => o.effect === 'OPEN') ?? (before ? undefined : sorted.find((o) => o.effect === undefined));
const openSide = firstOpen?.side ?? (before ? (before.side === 'long' ? 'BUY' : 'SELL') : undefined);
if (!openSide) continue;
const opens = sorted.filter((o) => o.effect === 'OPEN' || (o.effect === undefined && o.side === openSide));
const closes = sorted.filter((o) => !opens.includes(o));
if (opens.length === 0 && !before) continue;
const openPrice = opens.length > 0 ? vwap(opens) : before!.openPrice;
const closeQty = closes.reduce((s, o) => s + o.qty, 0);
const closed = !now && closeQty > 0;
const last = closed ? closes[closes.length - 1] : undefined;
const codes = sorted.map((o) => o.orderCode);
const fees = codes.filter((c) => commission.has(c));
const pnlCodes = closes.map((o) => o.orderCode).filter((c) => settled.has(c));
out.push({
positionCode: code,
symbol: opens[0]?.instrument || before?.symbol || closes[0]?.instrument || '',
side: openSide === 'BUY' ? 'long' : 'short',
quantity: Math.max(opens.reduce((s, o) => s + o.qty, 0), now?.quantity ?? 0, before?.quantity ?? 0),
openPrice: openPrice ?? now?.openPrice ?? null,
closePrice: closed ? vwap(closes) : null,
pnl: closed && pnlCodes.length > 0 ? Math.round(pnlCodes.reduce((s, c) => s + settled.get(c)!, 0) * 100) / 100 : null,
fee: fees.length > 0 ? Math.round(fees.reduce((s, c) => s + commission.get(c)!, 0) * 100) / 100 : null,
openedAt: opens.length > 0 ? opens[0].at : before!.openedAt,
closedAt: last ? last.at : null,
openCode: opens.length > 0 ? opens[0].orderCode : null,
closeCode: last?.orderCode ?? null,
closeType: last?.type ?? null,
});
}
return out;
}
+243
View File
@@ -0,0 +1,243 @@
/**
* Trades out of fills, for the futures platforms (TopstepX, Tradovate). A
* trade runs from flat back to flat: adds and partial closes stay inside it,
* and a fill that crosses zero closes one trade and opens the next. Vest and
* DXtrade report whole positions themselves.
*
* No imports, so scripts/check-fill-trades.mjs can test it directly.
*/
export interface Fill {
id: string;
orderId: string;
at: number;
side: 'buy' | 'sell';
qty: number;
price: number;
/** What the fill cost (positive); null when unknown. */
fee: number | null;
/** The P&L the platform booked on this fill, before fees (TopstepX's
* closing half-turns); null to work it out from the prices. */
pnl: number | null;
}
/** A trade already stored as open, from before the fills at hand begin —
* continued when they start part-way through a position. */
export interface CarriedTrade {
id: string;
side: 'long' | 'short';
quantity: number;
openPrice: number | null;
fee: number | null;
openedAt: number;
}
export interface FillTrade {
/** The first fill's id, or the carried trade's. */
id: string;
side: 'long' | 'short';
/** The most held at once. */
quantity: number;
/** Average of the fills that opened or added to it. */
openPrice: number | null;
/** Average of the fills that reduced it; null while open. */
closePrice: number | null;
/** Before fees; null while open, or when it can't be told. */
pnl: number | null;
fee: number | null;
openedAt: number;
closedAt: number | null;
/** The order whose fill opened it; null for a carried trade. */
openOrderId: string | null;
/** The order whose fill took it flat; null while open. */
closeOrderId: string | null;
carried: boolean;
}
const EPS = 1e-9;
const round2 = (n: number) => Math.round(n * 100) / 100;
const signed = (f: Fill) => (f.side === 'buy' ? f.qty : -f.qty);
/** Oldest first; ties by id, numerically when both are numbers. */
export function byFillTime(a: Fill, b: Fill): number {
if (a.at !== b.at) return a.at - b.at;
const an = Number(a.id);
const bn = Number(b.id);
if (Number.isFinite(an) && Number.isFinite(bn)) return an - bn;
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
}
/** The position before the first of `fills`, given the one after the last. */
export function startingNet(fills: Fill[], currentNet: number): number {
const before = currentNet - fills.reduce((s, f) => s + signed(f), 0);
return Math.abs(before) < EPS ? 0 : before;
}
interface Building {
id: string;
dir: 1 | -1;
net: number;
maxAbs: number;
openQty: number;
openNotional: number | null;
closeQty: number;
closeNotional: number;
platformPnl: number;
pnlFromPlatform: boolean;
fee: number | null;
openedAt: number;
openOrderId: string | null;
carried: boolean;
}
/**
* The trades `fills` (one account, one contract) make. `startNet` is the
* position before the first fill (startingNet): when it isn't flat, the
* fills start inside a position — `carry` continues the stored trade when it
* is on the same side, and without one those fills are passed over until the
* account is flat. `pointValue` (dollars per point per contract) works out
* the P&L of a fill the platform gave none for.
*/
export function fillsToTrades(fills: Fill[], o: { startNet: number; pointValue: number | null; carry?: CarriedTrade | null }): FillTrade[] {
const out: FillTrade[] = [];
let net = Math.abs(o.startNet) < EPS ? 0 : o.startNet;
let cur: Building | null = null;
if (net !== 0 && o.carry && (o.carry.side === 'long') === net > 0) {
const qty = Math.abs(net);
cur = {
id: o.carry.id,
dir: net > 0 ? 1 : -1,
net,
maxAbs: Math.max(qty, o.carry.quantity),
openQty: qty,
openNotional: o.carry.openPrice === null ? null : o.carry.openPrice * qty,
closeQty: 0,
closeNotional: 0,
platformPnl: 0,
pnlFromPlatform: true,
fee: o.carry.fee,
openedAt: o.carry.openedAt,
openOrderId: null,
carried: true,
};
}
const open = (f: Fill, qty: number, feeShare: number | null): Building => ({
id: f.id,
dir: f.side === 'buy' ? 1 : -1,
net: f.side === 'buy' ? qty : -qty,
maxAbs: qty,
openQty: qty,
openNotional: f.price * qty,
closeQty: 0,
closeNotional: 0,
platformPnl: 0,
pnlFromPlatform: true,
fee: feeShare,
openedAt: f.at,
openOrderId: f.orderId,
carried: false,
});
const addFee = (b: Building, fee: number | null) => {
b.fee = b.fee === null || fee === null ? null : b.fee + fee;
};
for (const f of [...fills].sort(byFillTime)) {
if (!(f.qty > 0)) continue;
const s = signed(f);
if (cur === null && net !== 0) {
// Inside a position whose start isn't on record: wait for flat.
if (Math.sign(s) === Math.sign(net)) {
net += s;
continue;
}
const closing = Math.min(Math.abs(s), Math.abs(net));
net += Math.sign(s) * closing;
if (Math.abs(net) < EPS) net = 0;
const rest = f.qty - closing;
if (net === 0 && rest > EPS) {
cur = open(f, rest, f.fee === null ? null : f.fee * (rest / f.qty));
net = cur.net;
}
continue;
}
if (cur === null) {
cur = open(f, f.qty, f.fee);
net = cur.net;
continue;
}
if (Math.sign(s) === cur.dir) {
cur.net += s;
net = cur.net;
cur.maxAbs = Math.max(cur.maxAbs, Math.abs(cur.net));
cur.openQty += f.qty;
cur.openNotional = cur.openNotional === null ? null : cur.openNotional + f.price * f.qty;
addFee(cur, f.fee);
continue;
}
const closing = Math.min(f.qty, Math.abs(cur.net));
const rest = f.qty - closing;
addFee(cur, f.fee === null ? null : f.fee * (closing / f.qty));
cur.closeQty += closing;
cur.closeNotional += f.price * closing;
if (f.pnl === null) cur.pnlFromPlatform = false;
else cur.platformPnl += f.pnl;
cur.net += Math.sign(s) * closing;
if (Math.abs(cur.net) < EPS) cur.net = 0;
net = cur.net;
if (cur.net !== 0) continue;
out.push(finish(cur, f, o.pointValue));
cur = null;
if (rest > EPS) {
cur = open(f, rest, f.fee === null ? null : f.fee * (rest / f.qty));
net = cur.net;
}
}
if (cur !== null) {
out.push({
id: cur.id,
side: cur.dir === 1 ? 'long' : 'short',
quantity: cur.maxAbs,
openPrice: cur.openNotional === null ? null : cur.openNotional / cur.openQty,
closePrice: null,
pnl: null,
fee: cur.fee === null ? null : round2(cur.fee),
openedAt: cur.openedAt,
closedAt: null,
openOrderId: cur.openOrderId,
closeOrderId: null,
carried: cur.carried,
});
}
return out;
}
function finish(b: Building, last: Fill, pointValue: number | null): FillTrade {
// Flat to flat, so as much was bought as sold: the P&L is the difference
// in what each side came to.
const computed = b.openNotional === null || pointValue === null
? null
: b.dir * (b.closeNotional - b.openNotional) * pointValue;
const pnl = b.pnlFromPlatform ? b.platformPnl : computed;
return {
id: b.id,
side: b.dir === 1 ? 'long' : 'short',
quantity: b.maxAbs,
openPrice: b.openNotional === null ? null : b.openNotional / b.openQty,
closePrice: b.closeQty > 0 ? b.closeNotional / b.closeQty : null,
pnl: pnl === null ? null : round2(pnl),
fee: b.fee === null ? null : round2(b.fee),
openedAt: b.openedAt,
closedAt: last.at,
openOrderId: b.openOrderId,
closeOrderId: last.orderId,
carried: b.carried,
};
}
/** "MNQZ6" → "MNQ"; a name with no month code comes back as it is. */
export function contractRoot(name: string): string {
const m = /^([A-Z0-9]+?)[FGHJKMNQUVXZ]\d{1,2}$/.exec(name.trim().toUpperCase());
return m ? m[1] : name.trim().toUpperCase();
}
+177 -53
View File
@@ -17,10 +17,13 @@
* the two are not linked yet.)
*/
import { isFirmPlatform } from './master-protocol';
import { sanitizeInstruments, DEFAULT_MAX_ENTRY_SPREAD } from './instrument-specs';
import { isFirmPlatform, isNotionalPlatform } from './master-protocol';
import { isVestWalletAddress } from './vest-helpers';
import { sanitizeInstruments, sanitizeInstrumentLimits, DEFAULT_MAX_ENTRY_SPREAD } from './instrument-specs';
import { normalizeAccountType } from './kept-rules';
import type {
CatalogAccountType, CatalogFirm, CatalogTradingSettings, FirmCatalogPayload, FirmPlatform, SymbolMap, SymbolMapCandidate,
CatalogAccountType, CatalogFirm, CatalogTradingSettings, FirmCatalogPayload, FirmPlatform, KeptAccountRules, SymbolMap,
SymbolMapCandidate,
} from './master-protocol';
import {
getSetting,
@@ -39,24 +42,53 @@ const FETCH_TIMEOUT_MS = 10_000;
const COLD_RETRY_MIN_MS = 10_000;
const COLD_RETRY_MAX_MS = 60_000;
/** The version of validatePayload below, stamped on the payload it caches.
* Bump it whenever the parser starts keeping a field an older one dropped:
* a cache from before then lacks the field however often it is re-read, and
* `?since=` would answer "unchanged" until the master next changes, so the
* 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.
* 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;
interface CatalogCache {
payload: FirmCatalogPayload | null;
/** Index over every firm's account types, for the id lookup. */
typesById: Map<string, CatalogAccountType>;
/** Each live account type's kept rule sets, by closedAt (oldest window
* first). Kept ids are deliberately not in typesById: nothing can be
* pinned to one. */
keptByLiveId: Map<string, KeptAccountRules[]>;
}
const g = global as typeof globalThis & {
__firmCatalog?: CatalogCache;
__firmCatalogTimer?: ReturnType<typeof setTimeout>;
__firmCatalogColdDelay?: number;
/** The master's clock minus this one, from the last sync's Date header. */
__masterOffsetMs?: number;
};
function indexPayload(payload: FirmCatalogPayload | null): CatalogCache {
const typesById = new Map<string, CatalogAccountType>();
const keptByLiveId = new Map<string, KeptAccountRules[]>();
for (const firm of payload?.firms ?? []) {
for (const type of firm.accountTypes) typesById.set(type.id, type);
for (const kept of firm.keptAccountRules ?? []) {
const list = keptByLiveId.get(kept.keptFrom);
if (list) list.push(kept);
else keptByLiveId.set(kept.keptFrom, [kept]);
}
}
return { payload, typesById };
for (const list of keptByLiveId.values()) list.sort((a, b) => Date.parse(a.closedAt) - Date.parse(b.closedAt));
return { payload, typesById, keptByLiveId };
}
function cache(): CatalogCache {
@@ -66,7 +98,18 @@ function cache(): CatalogCache {
const raw = getSetting('catalog_payload');
if (raw) {
try {
payload = validatePayload(JSON.parse(raw));
const stored = JSON.parse(raw);
payload = validatePayload(stored);
// Whatever parsed the blob when it was stored, not this parser: a
// field an older build dropped on the way in can't come back from
// re-reading the blob.
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');
}
@@ -87,6 +130,37 @@ export function getAccountTypeById(typeId: string): CatalogAccountType | undefin
return cache().typesById.get(typeId);
}
/** The kept rule sets of one live account type, oldest window first ([] when
* it keeps none). lib/account-type-resolver.ts picks an account's by its
* rules date. */
export function getKeptRulesFor(liveTypeId: string): KeptAccountRules[] {
return cache().keptByLiveId.get(liveTypeId) ?? [];
}
/** The kept rule set ids loaded for a firm, for the report — null while the
* cached catalog came from a parser older than CATALOG_PARSER_VERSION, which
* can't be trusted to hold them (the master then reads this machine as not
* applying kept rules yet). */
export function getKeptRuleIdsForFirm(firmId: string): string[] | null {
const payload = cache().payload;
if (!payload || (payload.parser ?? 0) < CATALOG_PARSER_VERSION) return null;
return (payload.firms.find((f) => f.id === firmId)?.keptAccountRules ?? []).map((k) => k.id);
}
/** The master's clock minus this machine's, as of the last good sync (null
* before one this process). Dates new accounts on the master's clock when
* this one runs behind (lib/kept-rules.ts stampRulesDate). */
export function getMasterOffsetMs(): number | null {
return g.__masterOffsetMs ?? null;
}
/** When the cached catalog last changed on the master (its payload's
* updatedAt); null with no cache. No account first seen after a change can
* be dated before it. */
export function getCatalogUpdatedAt(): string | null {
return cache().payload?.updatedAt ?? null;
}
export function getCatalogMeta(): {
version: number;
updatedAt: string | null;
@@ -104,7 +178,8 @@ export function getCatalogMeta(): {
}
/** Whether stored credentials are what the firm's platform logs in with — a
* TopstepX API key, or a username + password (Tradovate, DXTrade). */
* TopstepX API key, a username + password (Tradovate, DXTrade), or for Vest
* the login's wallet address (its session is relayed by the extension). */
export function hasPlatformCredentials(
platform: FirmPlatform,
creds: { username: string; password: string; apiKey: string } | null | undefined,
@@ -112,6 +187,7 @@ export function hasPlatformCredentials(
if (!creds) return false;
switch (platform) {
case 'topstepx': return !!creds.apiKey;
case 'vest': return isVestWalletAddress(creds.apiKey);
case 'tradovate':
case 'dxtrade': return !!creds.username && !!creds.password;
}
@@ -131,7 +207,10 @@ export function getTradableFirms(): (CatalogFirm & { username: string; password:
// ── Sync ─────────────────────────────────────────────────────────────────────
const g2 = global as typeof globalThis & { __catalogUnknownPlatformWarned?: Set<string> };
const g2 = global as typeof globalThis & {
__catalogUnknownPlatformWarned?: Set<string>;
__catalogKeptSkipWarned?: Set<string>;
};
/** Once per firm per process — the catalog is re-validated every 60s. */
function warnUnknownPlatform(id: string, name: string, platform: unknown): void {
@@ -164,12 +243,41 @@ function sanitizeSymbolMap(raw: unknown): SymbolMap {
return map;
}
function isStages(value: unknown): value is { profit: number; consistency: number; minTradingDays: number }[] {
return Array.isArray(value) && value.every((s) =>
typeof s === 'object' && s !== null
&& typeof (s as Record<string, unknown>).profit === 'number'
&& typeof (s as Record<string, unknown>).consistency === 'number'
&& typeof (s as Record<string, unknown>).minTradingDays === 'number');
function isDateString(value: unknown): value is string {
return typeof value === 'string' && Number.isFinite(Date.parse(value));
}
/**
* A firm's kept rule sets, each as strict as an account type: a malformed
* entry throws and the whole sync is rejected. One that names no live type
* of this firm is skipped instead (warned once) — no account can map to it.
*/
function parseKeptRules(
firm: { id: string; name: string },
raw: unknown[],
accountTypes: CatalogAccountType[],
tradesEveryDay: boolean,
): KeptAccountRules[] {
const liveIds = new Set(accountTypes.map((t) => t.id));
const kept: KeptAccountRules[] = [];
raw.forEach((k: unknown, j: number) => {
const where = `kept rules ${j} of ${firm.name}`;
const type = normalizeAccountType(k, { tradesEveryDay, where });
const entry = k as Record<string, unknown>;
if (typeof entry.keptFrom !== 'string') throw new Error(`${where} is missing keptFrom`);
if (entry.openedAt !== null && !isDateString(entry.openedAt)) throw new Error(`${where} has bad openedAt`);
if (!isDateString(entry.closedAt)) throw new Error(`${where} has bad closedAt`);
if (!liveIds.has(entry.keptFrom) || type.firmId !== firm.id) {
const warned = (g2.__catalogKeptSkipWarned ??= new Set());
if (!warned.has(type.id)) {
warned.add(type.id);
console.warn(`[catalog] skipping kept rules ${type.id} of "${firm.name}": it names no account type of this firm (keptFrom ${entry.keptFrom}, firmId ${type.firmId})`);
}
return;
}
kept.push({ ...type, keptFrom: entry.keptFrom, openedAt: entry.openedAt, closedAt: entry.closedAt });
});
return kept;
}
/** Rejects anything that isn't a complete catalog. A half-parsed payload would
@@ -182,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>;
@@ -196,47 +306,23 @@ 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 DXtrade settings;
// futures firms keep measured per-contract fees and the CME calendar.
const dx = platform === 'dxtrade';
// Percent commission and every-day trading are settings of the
// notional platforms (DXtrade, Vest); futures firms keep measured
// per-contract fees and the CME calendar.
const dx = isNotionalPlatform(platform);
const tradesEveryDay = dx && firm.tradesEveryDay === true;
if (!Array.isArray(firm.accountTypes)) throw new Error(`firm ${firm.name} is missing accountTypes`);
const accountTypes: CatalogAccountType[] = firm.accountTypes.map((t: unknown, j: number) => {
if (typeof t !== 'object' || t === null) throw new Error(`account type ${j} is not an object`);
const type = t as Record<string, unknown>;
for (const key of ['id', 'firmId', 'label', 'prefix', 'updatedAt']) {
if (typeof type[key] !== 'string') throw new Error(`account type ${j} is missing ${key}`);
}
for (const key of [
'accountSize', 'profitTarget', 'consistency', 'minDayPnL',
'minTradingDays', 'maxLoss', 'maxPositionSize',
]) {
if (typeof type[key] !== 'number') throw new Error(`account type ${j} is missing ${key}`);
}
if (!isStages(type.withdrawalStages)) throw new Error(`account type ${j} has bad withdrawalStages`);
// Lenient like platform: a master from before the split toggle
// doesn't send the field, and those account types all split. Same
// for CME holiday combining, where the old behavior was "off".
return {
...type,
splitEnabled: type.splitEnabled !== false,
// An every-day firm has no CME calendar to fold holidays by.
combineCmeHolidays: type.combineCmeHolidays === true && !tradesEveryDay,
// Lenient: a master from before the min-legs field means "no minimum".
minLegs: typeof type.minLegs === 'number' ? type.minLegs : 0,
// Lenient: the obfuscation stops are opt-in, so absent means off.
firstDayStop: type.firstDayStop === true,
minDayStop: type.minDayStop === true,
// Lenient: no hard daily limit, no notional cap.
dailyLossLimit: nonNegative(type.dailyLossLimit),
// The firm's calendar, stamped on every type so the cfg-based
// paths (projections, completion, the dashboard) all see it.
tradesEveryDay,
} as unknown as CatalogAccountType;
});
const accountTypes: CatalogAccountType[] = firm.accountTypes.map((t: unknown, j: number) =>
normalizeAccountType(t, { tradesEveryDay, where: `account type ${j}` }));
// Only when the master sent the list: absent = a master from before
// kept rules, [] = every account on the current rules.
const keptAccountRules = Array.isArray(firm.keptAccountRules)
? parseKeptRules({ id: firm.id, name: firm.name }, firm.keptAccountRules, accountTypes, tradesEveryDay)
: undefined;
firms.push({
id: firm.id,
@@ -245,8 +331,10 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
// The master's list when it sends one; absent from an older
// master, whose bannedSymbols / symbolMap then apply.
...(Array.isArray(firm.instruments) ? { instruments: sanitizeInstruments(platform, firm.instruments) } : {}),
...(!dx ? { instrumentLimits: sanitizeInstrumentLimits(firm.instrumentLimits) } : {}),
bannedSymbols: Array.isArray(firm.bannedSymbols) ? (firm.bannedSymbols as string[]) : [],
accountTypes,
...(keptAccountRules ? { keptAccountRules } : {}),
serverUrl: typeof firm.serverUrl === 'string' ? firm.serverUrl : '',
loginDomain: typeof firm.loginDomain === 'string' ? firm.loginDomain : '',
commissionPct: dx ? nonNegative(firm.commissionPct) : 0,
@@ -254,12 +342,25 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
symbolMap: sanitizeSymbolMap(firm.symbolMap),
targetHoldHours: nonNegative(firm.targetHoldHours),
financingPctPerDay: nonNegative(firm.financingPctPerDay),
// Lenient: absent or malformed = no firm cap.
maxConcurrentAccounts: Math.floor(nonNegative(firm.maxConcurrentAccounts)),
updatedAt: typeof firm.updatedAt === 'string' ? firm.updatedAt : new Date(0).toISOString(),
});
});
const trading = parseTradingSettings(body.trading);
return { version: body.version, updatedAt: body.updatedAt, firms, ...(trading ? { trading } : {}) };
// A kept-aware master sends keptAccountRules on every firm (even []). A
// payload without it is a master from before kept rules: cached without
// a parser version, so the cache isn't trusted to hold kept sets — every
// sync fetches in full (no `since`), and the first one after a kept-aware
// master is back brings them, though its catalog version never moved.
const keptAware = body.firms.every((f: unknown) =>
typeof f === 'object' && f !== null && Array.isArray((f as Record<string, unknown>).keptAccountRules));
return {
version: body.version, updatedAt: body.updatedAt, firms, ...(trading ? { trading } : {}),
...(keptAware ? { parser: CATALOG_PARSER_VERSION } : {}),
...(skippedPlatforms.size > 0 ? { skippedPlatforms: [...skippedPlatforms] } : {}),
};
}
/** The master's trading settings; undefined when it sent none (or nothing
@@ -292,6 +393,17 @@ function recordError(message: string): void {
setSetting('catalog_last_sync_at', new Date().toISOString());
}
/** The master's clock off a response's Date header (whole seconds, read on
* arrival — close enough to date accounts by). An unreadable header, or one
* a year off, leaves the last offset standing. */
function recordMasterClock(header: string | null): void {
const at = header ? Date.parse(header) : NaN;
if (!Number.isFinite(at)) return;
const offset = at - Date.now();
if (Math.abs(offset) > MAX_MASTER_OFFSET_MS) return;
g.__masterOffsetMs = offset;
}
/** If the master's database was rebuilt, its firms come back with fresh ids and
* every credential row orphans. Match the survivors up by name (once) rather
* than making someone retype every password. */
@@ -322,14 +434,20 @@ export async function syncCatalog(): Promise<{ ok: boolean; changed: boolean; ve
// before anyone has got round to naming it.
// `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.
// 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 —
// fields, or firms on a platform it didn't know and this build does.
const hasCache = getCachedCatalog().length > 0;
const source = url.origin;
if (hasCache && getSetting('catalog_source_url') === source) url.searchParams.set('since', String(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 {
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
if (!res.ok) throw new Error(`master returned ${res.status}`);
recordMasterClock(res.headers.get('date'));
const body = await res.json();
if (body && typeof body === 'object' && (body as Record<string, unknown>).unchanged === true) {
@@ -352,13 +470,19 @@ export async function syncCatalog(): Promise<{ ok: boolean; changed: boolean; ve
}
// Same reasoning one level down: a firm losing every account type
// unassigns all of its accounts. Keep the previous types and flag it.
// unassigns all of its accounts. Keep the previous types and flag it,
// with the kept rule sets that went with them (whose keptFrom named
// types this payload lacks, so none of its own survived parsing). An
// empty kept list on its own is never suspicious: it is every account
// on the current rules.
const previous = new Map(getCachedCatalog().map((f) => [f.id, f]));
const suspicious: string[] = [];
for (const firm of payload.firms) {
const before = previous.get(firm.id);
if (firm.accountTypes.length === 0 && before && before.accountTypes.length > 0) {
firm.accountTypes = before.accountTypes;
if (before.keptAccountRules) firm.keptAccountRules = before.keptAccountRules;
else delete firm.keptAccountRules;
suspicious.push(firm.name);
}
}
+36
View File
@@ -0,0 +1,36 @@
/** All application Axios imports belong here. New calls and axios.create()
* clients inherit the instance's browser identity, proxy and timeout. */
import axios, { type AxiosInstance } from 'axios';
import { applyBrokerHttpDefaults, applyBrokerProxy } from './outbound-proxy';
import { getRequestUserAgent } from './request-user-agent';
const g = globalThis as typeof globalThis & { __configuredHttpClients?: WeakSet<AxiosInstance> };
const configured = g.__configuredHttpClients ??= new WeakSet<AxiosInstance>();
function configure<T extends AxiosInstance>(client: T): T {
if (configured.has(client)) return client;
applyBrokerHttpDefaults(client.defaults);
// Builds import route modules but must not choose an instance's identity.
if (process.env.NEXT_PHASE !== 'phase-production-build') {
client.defaults.headers.common['User-Agent'] = getRequestUserAgent();
try {
applyBrokerProxy(client.defaults);
} catch (err) {
console.error(`[proxy] could not apply proxy_url at load: ${err instanceof Error ? err.message : String(err)}`);
}
}
client.interceptors.request.use((config) => {
// Force replacement even for differently cased headers or `false`.
// This also replaces a stale per-call UA without losing auth headers.
config.headers.set('User-Agent', getRequestUserAgent(), true);
return config;
}, undefined, { synchronous: true });
// Axios copies defaults into child clients, but not interceptors.
const create = client.create.bind(client);
client.create = (options) => configure(create(options));
configured.add(client);
return client;
}
export default configure(axios);
+2 -2
View File
@@ -12,8 +12,6 @@
* No key, no account: POST https://api.hyperliquid.xyz/info.
*/
import axios from 'axios';
const INFO_URL = 'https://api.hyperliquid.xyz/info';
const TIMEOUT_MS = 10_000;
const META_TTL_MS = 60 * 60_000;
@@ -21,6 +19,8 @@ const META_TTL_MS = 60 * 60_000;
const DEXES = ['', 'xyz'];
async function info<T>(body: Record<string, unknown>): Promise<T> {
// Keep the pure quote helpers usable without opening the instance database.
const { default: axios } = await import('./http-client');
const res = await axios.post(INFO_URL, body, {
timeout: TIMEOUT_MS,
signal: AbortSignal.timeout(TIMEOUT_MS + 5_000),
+22 -6
View File
@@ -107,10 +107,24 @@ export function dailyMoveDollars(closes: number[], pointValue: number, barsPerDa
}
/** Contracts a futures leg trades: one per $1,000, capped by the account
* type's max contracts (0 = no cap) — the rule fireLeg sizes by. */
export function futuresContracts(leg: number, maxPositionSize: number): number {
* type's max contracts and the firm's limit on the instrument (0 = no
* cap) — the rule fireLeg sizes by. */
export function futuresContracts(leg: number, maxPositionSize: number, instrumentLimit = 0): number {
const raw = Math.max(1, Math.ceil(leg / 1000));
return maxPositionSize > 0 ? Math.min(raw, maxPositionSize) : raw;
const caps = [maxPositionSize, instrumentLimit].filter((c) => c > 0);
return caps.length > 0 ? Math.min(raw, ...caps) : raw;
}
/** The most contracts a firm allows on one instrument, as its catalog entry
* says: CME roots only, whole numbers from 1 to 1000. Anything else is
* dropped — no limit beyond the account type's max contracts. */
export function sanitizeInstrumentLimits(raw: unknown): Record<string, number> {
const out: Record<string, number> = {};
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return out;
for (const [root, value] of Object.entries(raw as Record<string, unknown>)) {
if (CME_SPECS[root] && typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 1000) out[root] = value;
}
return out;
}
/**
@@ -245,7 +259,9 @@ const DXTRADE_SYMBOL = /^[A-Za-z0-9./_:-]{1,32}$/;
* specs for, or valid DXtrade symbols. Deduplicated, in order. */
export function sanitizeInstruments(platform: string, raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
const futures = platform !== 'dxtrade';
// Notional platforms (DXtrade, Vest) list their own symbols. Spelled out
// here: this file has no imports (see master-protocol isNotionalPlatform).
const futures = platform !== 'dxtrade' && platform !== 'vest';
const out: string[] = [];
for (const value of raw) {
if (typeof value !== 'string') continue;
@@ -267,10 +283,10 @@ export function resolveAllowlist(
localEnabled: readonly string[],
): { roots: string[]; source: 'master' | 'legacy' } {
if (Array.isArray(firm.instruments)) return { roots: sanitizeInstruments(firm.platform, firm.instruments), source: 'master' };
if (firm.platform === 'dxtrade') {
if (firm.platform === 'dxtrade' || firm.platform === 'vest') {
const map = firm.symbolMap ?? {};
const keys = Object.keys(map).sort((a, b) => (a === '*' ? -1 : b === '*' ? 1 : a.localeCompare(b)));
return { roots: sanitizeInstruments('dxtrade', keys.flatMap((k) => (map[k] ?? []).map((c) => c.symbol))), source: 'legacy' };
return { roots: sanitizeInstruments(firm.platform, keys.flatMap((k) => (map[k] ?? []).map((c) => c.symbol))), source: 'legacy' };
}
const banned = new Set(firm.bannedSymbols ?? []);
return { roots: sanitizeInstruments(firm.platform, localEnabled.filter((r) => !banned.has(r))), source: 'legacy' };
+17 -7
View File
@@ -19,7 +19,7 @@
* entry path asks spreadBlock before sending.
*/
import axios from 'axios';
import axios from './http-client';
import { getInstruments, getSetting } from './db';
import { getTradableFirms, getTradingSettings } from './firm-catalog';
import { entrySpread, quoteSource } from './live-quotes';
@@ -27,6 +27,7 @@ import { getCachedContract } from './contract-resolver';
import { currentTradeDateCT } from './trading-logic';
import { errorMessage } from './platform-client';
import type { InstrumentStats } from './master-protocol';
import { isNotionalPlatform } from './master-protocol';
import {
CME_SPECS, CME_ROOTS, dailyMoveDollars, futuresContracts, expectedHoldHours, sessionProgress,
drawChances, pickByChance, resolveAllowlist, spreadTooWide, MAX_HOLD_HOURS, type DrawCandidate,
@@ -36,6 +37,7 @@ export interface FirmLike {
id?: string;
platform: string;
instruments?: string[];
instrumentLimits?: Record<string, number>;
bannedSymbols?: string[];
symbolMap?: Record<string, { symbol: string }[]>;
}
@@ -55,7 +57,7 @@ export function instrumentsSource(firm: FirmLike): 'master' | 'legacy' {
}
export function isFuturesFirm(firm: { platform: string }): boolean {
return firm.platform !== 'dxtrade';
return !isNotionalPlatform(firm.platform);
}
/** Every root a futures firm on this machine lists, in CME order — what the
@@ -97,7 +99,6 @@ async function fetchMarket(root: string): Promise<void> {
try {
const res = await axios.get(`https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(spec.yahoo)}`, {
params: { interval: '60m', range: '7d' },
headers: { 'User-Agent': 'Mozilla/5.0' },
timeout: FETCH_TIMEOUT_MS,
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS + 5_000),
});
@@ -155,6 +156,8 @@ export interface LegShape {
leg: number;
stopDollars: number;
maxPositionSize: number;
/** The firm's most contracts per instrument, by root (absent = none). */
limits?: Record<string, number>;
}
function progressNow(now: Date): number | null {
@@ -188,11 +191,13 @@ export function futuresBoard(roots: readonly string[], leg: LegShape | null, now
disabled: string[];
wide: string[];
} {
const contracts = leg ? futuresContracts(leg.leg, leg.maxPositionSize) : 1;
const max = maxEntrySpread();
const candidates = roots.filter((r) => CME_SPECS[r]).map((root) => {
const dailyMove = market.get(root)?.dailyMove ?? null;
const spread = entrySpread(root, now.getTime());
// The size this leg would trade on this root: a firm's cap on the
// instrument slows it, and may push it past the hold cap.
const contracts = leg ? futuresContracts(leg.leg, leg.maxPositionSize, leg.limits?.[root] ?? 0) : 1;
return {
symbol: root,
spreadDollars: spread.dollars,
@@ -254,10 +259,10 @@ export function instrumentStatsFor(
lead: (LegShape & { account: string }) | null,
): InstrumentStats {
const listed = firmInstruments(firm);
const board = futuresBoard(listed, lead);
const limits = firm.instrumentLimits ?? {};
const board = futuresBoard(listed, lead ? { ...lead, limits } : null);
const disabled = new Set(board.disabled);
const max = maxEntrySpread();
const leg = lead ? { leg: lead.leg, stopDollars: lead.stopDollars, contracts: futuresContracts(lead.leg, lead.maxPositionSize) } : null;
return {
at: new Date().toISOString(),
account: lead?.account ?? null,
@@ -268,7 +273,12 @@ export function instrumentStatsFor(
quoteSource: quoteSource(),
instruments: CME_ROOTS.map((root) => {
const dailyMove = market.get(root)?.dailyMove ?? null;
const holdHours = leg ? expectedHoldHours({ ...leg, dailyMove }) : null;
const holdHours = lead
? expectedHoldHours({
leg: lead.leg, stopDollars: lead.stopDollars, dailyMove,
contracts: futuresContracts(lead.leg, lead.maxPositionSize, limits[root] ?? 0),
})
: null;
const spread = entrySpread(root);
return {
symbol: root,
+407
View File
@@ -0,0 +1,407 @@
/**
* Kept rules — the old rule sets a firm's account type keeps for its older
* accounts.
*
* When the owner changes an account type's rules on the master's Firms page
* and picks "New accounts only", the master freezes the old rules as a kept
* rule set with a window [openedAt, closedAt). Every account here carries a
* rules date (account_assignments.rules_date): an account whose date falls
* inside a window trades its type's live fields with that set's RULE fields
* on top; every other account trades the current rules. The account keeps
* reporting the live type's id — a kept set is never an account type.
*
* Pure (only `import type`), so scripts/check-kept-rules.mjs loads it
* directly. The field lists must stay identical to the master's
* lib/kept-rules.ts.
*/
import type { CatalogAccountType, KeptAccountRules } from './master-protocol';
/** The fields a kept rule set freezes: what the bot trades by. Changing one
* is what asks the owner "new accounts only, or all accounts?". */
export const RULE_FIELDS = [
'profitTarget', 'consistency', 'minDayPnL', 'minTradingDays', 'maxLoss',
'maxPositionSize', 'targetSameEquity', 'splitEnabled', 'minLegs', 'firstDayStop', 'minDayStop',
'combineCmeHolidays', 'withdrawalStages', 'payoutable', 'trailingDrawdown', 'dailyLossLimit', 'stagesSameDay',
] as const satisfies readonly (keyof CatalogAccountType)[];
/** Always the live type's: accounts are matched to a type by them, so a kept
* set can't hold different ones (the master refuses "new accounts only"
* when either changes). */
export const IDENTITY_FIELDS = ['prefix', 'accountSize'] as const satisfies readonly (keyof CatalogAccountType)[];
/** The payout calendar's projection inputs: always the live type's, never
* frozen. (Per-stage withdrawalPct / payoutCap ride inside withdrawalStages,
* so they are rules.) */
export const PROJECTION_FIELDS = [
'expectedPayout', 'withdrawalPct', 'payoutCap', 'profitSplit',
] as const satisfies readonly (keyof CatalogAccountType)[];
export type RuleField = (typeof RULE_FIELDS)[number];
/** Which kept rule set an account trades on. */
export interface KeptRulesWindow {
id: string;
openedAt: string | null;
closedAt: string;
}
/** The rules an account trades on: its live account type, or — when its
* rules date falls in a kept set — the live type with that set's rules on
* top, marked with `keptRules`. */
export type EffectiveAccountType = CatalogAccountType & { keptRules?: KeptRulesWindow };
// ── Catalog parsing ──────────────────────────────────────────────────────────
/** Same as lib/firm-catalog.ts's: a positive finite number, else 0. */
function nonNegative(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0;
}
function isStages(value: unknown): value is { profit: number; consistency: number; minTradingDays: number }[] {
return Array.isArray(value) && value.every((s) =>
typeof s === 'object' && s !== null
&& typeof (s as Record<string, unknown>).profit === 'number'
&& typeof (s as Record<string, unknown>).consistency === 'number'
&& typeof (s as Record<string, unknown>).minTradingDays === 'number');
}
/**
* One account type off the master's catalog, validated and normalized — the
* live types and the kept rule sets alike, so a kept set reads exactly the
* way its type did. Throws on anything malformed (`where` names it in the
* message): a half-parsed catalog would silently unassign accounts, so the
* whole sync is better rejected and the last good cache kept.
*/
export function normalizeAccountType(
raw: unknown,
ctx: { tradesEveryDay: boolean; where: string },
): CatalogAccountType {
const { tradesEveryDay, where } = ctx;
if (typeof raw !== 'object' || raw === null) throw new Error(`${where} is not an object`);
const type = raw as Record<string, unknown>;
for (const key of ['id', 'firmId', 'label', 'prefix', 'updatedAt']) {
if (typeof type[key] !== 'string') throw new Error(`${where} is missing ${key}`);
}
for (const key of [
'accountSize', 'profitTarget', 'consistency', 'minDayPnL',
'minTradingDays', 'maxLoss', 'maxPositionSize',
]) {
if (typeof type[key] !== 'number') throw new Error(`${where} is missing ${key}`);
}
if (!isStages(type.withdrawalStages)) throw new Error(`${where} has bad withdrawalStages`);
// Lenient like platform: a master from before the split toggle
// doesn't send the field, and those account types all split. Same
// for CME holiday combining, where the old behavior was "off".
return {
...type,
splitEnabled: type.splitEnabled !== false,
// An every-day firm has no CME calendar to fold holidays by.
combineCmeHolidays: type.combineCmeHolidays === true && !tradesEveryDay,
// Lenient: a master from before the min-legs field means "no minimum".
minLegs: typeof type.minLegs === 'number' ? type.minLegs : 0,
// Lenient: the obfuscation stops are opt-in, so absent means off.
firstDayStop: type.firstDayStop === true,
minDayStop: type.minDayStop === true,
// Lenient: no hard daily limit, no notional cap.
dailyLossLimit: nonNegative(type.dailyLossLimit),
// Lenient: stages count every payout unless the master says otherwise.
stagesSameDay: type.stagesSameDay === true,
// The firm's calendar, stamped on every type so the cfg-based
// paths (projections, completion, the dashboard) all see it.
tradesEveryDay,
} as unknown as CatalogAccountType;
}
// ── Which rules an account trades on ─────────────────────────────────────────
/**
* The kept set whose window holds the rules date: openedAt (null = no lower
* bound) <= date < closedAt — an account dated exactly at a change's cutoff
* is on the newer rules. null when no window holds it, when the account has
* no rules date, or when the date can't be read: the current rules.
*/
export function pickKeptRules<T extends { openedAt: string | null; closedAt: string }>(
sets: T[] | undefined,
rulesDate: string | null,
): T | null {
if (!sets || sets.length === 0 || !rulesDate) return null;
const date = Date.parse(rulesDate);
if (!Number.isFinite(date)) return null;
for (const set of sets) {
// Comparisons written so an unreadable bound (NaN) never matches.
if (!(date < Date.parse(set.closedAt))) continue;
if (set.openedAt !== null && !(Date.parse(set.openedAt) <= date)) continue;
return set;
}
return null;
}
/**
* The live type with a kept set's rules on top. Everything that isn't a rule
* stays the live type's — id, firmId, label, prefix, accountSize, updatedAt,
* tradesEveryDay and the projection fields — so the account still matches,
* groups and reports as its live type. A rule field the kept set doesn't
* carry at all keeps the live value.
*/
export function overlayKept(live: CatalogAccountType, kept: KeptAccountRules): EffectiveAccountType {
const effective: EffectiveAccountType = {
...live,
keptRules: { id: kept.id, openedAt: kept.openedAt, closedAt: kept.closedAt },
};
const target = effective as unknown as Record<string, unknown>;
for (const field of RULE_FIELDS) {
if (kept[field] !== undefined) target[field] = kept[field];
}
return effective;
}
/** A stage the way the master's parseAccountTypeInput builds it, so stored
* JSON from before drain / minDayPnL existed doesn't read as a change. */
function normalizeStage(stage: unknown): unknown {
if (typeof stage !== 'object' || stage === null) return stage;
const s = stage as Record<string, unknown>;
const drain = s.drain === true;
return {
profit: s.profit,
consistency: s.consistency,
minTradingDays: s.minTradingDays,
drain,
minDayPnL: typeof s.minDayPnL === 'number' ? s.minDayPnL : (drain ? 5 : 0),
...(typeof s.withdrawalPct === 'number' ? { withdrawalPct: s.withdrawalPct } : {}),
...(typeof s.payoutCap === 'number' ? { payoutCap: s.payoutCap } : {}),
...(typeof s.dailyLossLimit === 'number' ? { dailyLossLimit: s.dailyLossLimit } : {}),
};
}
/** A rule field's value as the bot reads it, for comparing: an absent
* optional flag or amount is its default. */
function comparable(field: RuleField, value: unknown): unknown {
switch (field) {
case 'withdrawalStages': return Array.isArray(value) ? value.map(normalizeStage) : value;
case 'splitEnabled': return value !== false;
case 'targetSameEquity':
case 'firstDayStop':
case 'minDayStop':
case 'combineCmeHolidays':
case 'stagesSameDay':
case 'payoutable': return value === true;
case 'minLegs':
case 'trailingDrawdown':
case 'dailyLossLimit': return typeof value === 'number' ? value : 0;
default: return value;
}
}
/** JSON with every object's keys sorted, so key order never reads as a change. */
function canonical(value: unknown): string {
return JSON.stringify(value, (_key, v: unknown) => (
typeof v === 'object' && v !== null && !Array.isArray(v)
? Object.fromEntries(Object.entries(v as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
: v
)) ?? 'undefined';
}
/** The rule fields where an account's effective rules differ from its live
* type's, with both raw values (the dashboard formats them). Empty when the
* account is on the current rules, or its kept set happens to match them. */
export function ruleDiffs(
effective: Pick<CatalogAccountType, RuleField>,
live: Pick<CatalogAccountType, RuleField>,
): { field: RuleField; kept: unknown; now: unknown }[] {
const diffs: { field: RuleField; kept: unknown; now: unknown }[] = [];
for (const field of RULE_FIELDS) {
if (canonical(comparable(field, effective[field])) === canonical(comparable(field, live[field]))) continue;
diffs.push({ field, kept: effective[field], now: live[field] });
}
return diffs;
}
// ── Rules dates ──────────────────────────────────────────────────────────────
/**
* The rules date for an account this machine records for the first time:
* the latest of this clock, the master's clock (this clock plus the offset
* the catalog sync measured, when known) and the catalog's own last change.
* A machine whose clock runs behind must never date a brand-new account
* inside a rule set the master closed moments ago.
*/
export function stampRulesDate(nowMs: number, masterOffsetMs: number | null, catalogUpdatedAt: string | null): string {
let ms = nowMs;
if (masterOffsetMs !== null && Number.isFinite(masterOffsetMs)) ms = Math.max(ms, nowMs + masterOffsetMs);
const catalog = catalogUpdatedAt ? Date.parse(catalogUpdatedAt) : NaN;
if (Number.isFinite(catalog)) ms = Math.max(ms, catalog);
return new Date(ms).toISOString();
}
/** 2000-01-01T00:00:00.000Z — no rules date is earlier. */
const EARLIEST_RULES_DATE_MS = Date.UTC(2000, 0, 1);
/** The plain toISOString() shape — four-digit year, milliseconds, Z. String
* order is time order only between strings of exactly this shape. */
const PLAIN_ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
/**
* An instant as a storable rules date: its toISOString(), when that is the
* plain four-digit-year shape and not before 2000 — so every stored rules
* date sorts as a string (lib/db.ts lowerAutoRulesDate compares them so).
* null for anything else (NaN, a pre-2000 or a six-digit-year instant).
*/
export function sortableIso(ms: number): string | null {
if (!Number.isFinite(ms) || ms < EARLIEST_RULES_DATE_MS || Math.abs(ms) > 8.64e15) return null;
const iso = new Date(ms).toISOString();
return PLAIN_ISO.test(iso) ? iso : null;
}
// The fund rows' dates — CME trade dates (Tradovate, TopstepX) and DXtrade's
// firm days (ending 22:00Z) — are calendar days that end no later than
// midnight in Chicago, so that is the calendar evidence is read in.
let chicagoClock: Intl.DateTimeFormat | undefined;
/** Chicago's wall clock minus UTC at an instant, in ms: −5 h under CDT,
* −6 h under CST (Intl knows the DST rules; no dependency needed). */
function chicagoOffsetMs(ms: number): number {
chicagoClock ??= new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Chicago', hourCycle: 'h23',
year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric',
});
const p: Record<string, number> = {};
for (const part of chicagoClock.formatToParts(new Date(ms))) {
if (part.type !== 'literal') p[part.type] = Number(part.value);
}
// Some engines spell midnight "24" even under h23.
const wall = Date.UTC(p.year, p.month - 1, p.day, p.hour === 24 ? 0 : p.hour, p.minute, p.second);
return wall - (ms - (((ms % 1000) + 1000) % 1000));
}
/** Whether `day` is YYYY-MM-DD naming a real calendar day (no Feb 31). */
function isCalendarDay(day: unknown): day is string {
if (typeof day !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(day)) return false;
const ms = Date.parse(`${day}T00:00:00.000Z`);
return Number.isFinite(ms) && new Date(ms).toISOString().slice(0, 10) === day;
}
/** The UTC instant of a wall-clock time on a Chicago calendar day. Exact for
* any time a DST change neither skips nor repeats (they happen at 2 AM), so
* for the day's first and last millisecond. NaN for a bad day. */
function chicagoWallMs(day: string, time: string): number {
if (!isCalendarDay(day)) return NaN;
const naive = Date.parse(`${day}T${time}Z`);
// The offset where the answer lands, which a DST change between the naive
// instant and the real one can make differ from the naive instant's.
const first = naive - chicagoOffsetMs(naive);
return naive - chicagoOffsetMs(first);
}
/** The Chicago calendar day (YYYY-MM-DD) an instant falls on; null when unreadable. */
export function chicagoDay(iso: string): string | null {
const ms = Date.parse(iso);
if (!Number.isFinite(ms) || Math.abs(ms) > 8.64e15) return null;
return new Date(ms + chicagoOffsetMs(ms)).toISOString().slice(0, 10);
}
/** The first millisecond of a Chicago calendar day, as a rules date — what a
* day picked by hand can never be earlier than, so it is never inside the
* evidence of the day before it. null for a bad or pre-2000 day. */
export function chicagoDayStart(day: string): string | null {
return sortableIso(chicagoWallMs(day, '00:00:00.000'));
}
/**
* The rules date the broker's evidence gives an account: the end of its first
* fund day (YYYY-MM-DD, platform-client.ts firstFundDate) in Chicago —
* D 23:59:59.999 America/Chicago, so 04:59:59.999Z the next day under CDT and
* 05:59:59.999Z under CST. Every source's day ends by then, so the evidence is
* never earlier than the moment it stands for, and an account opened on the
* day of a rule change — even that evening — counts as opened after it. null
* for anything else.
*/
export function evidenceRulesDate(firstFundDate: string | null | undefined): string | null {
if (!isCalendarDay(firstFundDate)) return null;
return sortableIso(chicagoWallMs(firstFundDate, '23:59:59.999'));
}
/** The Chicago day a rules date stands for when it is exactly the end of one
* — the shape evidenceRulesDate stores — so the dashboard shows the broker's
* day in any time zone rather than the next one. null for any other date. */
export function evidenceDay(rulesDate: string): string | null {
const day = chicagoDay(rulesDate);
return day !== null && evidenceRulesDate(day) === rulesDate ? day : null;
}
/**
* The rules date a day picked by hand on the dashboard stores. Today is now
* (`nowMs`) — the master's default cutoff is "now", and today's start would
* sit before a change made earlier today, leaving no way onto the current
* rules. Any other day is its start: the browser's midnight
* (`localMidnightMs`), but never before Chicago's, so a day picked in a
* browser east of Chicago is never inside the evidence of the day before
* (which ends at Chicago midnight). null when unreadable.
*/
export function pickedDayRulesDate(
day: string,
at: { today: string; nowMs: number; localMidnightMs: number },
): string | null {
if (day === at.today) return sortableIso(at.nowMs);
const chicago = chicagoDayStart(day);
if (!chicago || !Number.isFinite(at.localMidnightMs)) return null;
return sortableIso(Math.max(at.localMidnightMs, Date.parse(chicago)));
}
/**
* Whether ✓ on the very day the editor opened on stores it. An unchanged day
* keeps the exact date — storing the day's start can land before a same-day
* rule change and put a running account back on old rules — unless storing
* it moves the account to other rules, which the editor's preview shows
* (today's "now" past a same-day change, say). With the type's windows
* unknown, never. `openedDate` is the date the editor opened on, not one a
* later poll brought.
*/
export function unchangedDaySaves(
kept: { id: string; openedAt: string | null; closedAt: string }[] | null,
openedDate: string,
stored: string,
): boolean {
if (!kept) return false;
return (pickKeptRules(kept, openedDate)?.id ?? null) !== (pickKeptRules(kept, stored)?.id ?? null);
}
/**
* The broker's evidence of when an account opened, once its fund history has
* loaded: the end of its earliest positive fund row's day (evidenceRulesDate),
* or — with no such row — the DXtrade registration instant itself. Never the
* registration's calendar day: that is a Chicago date, and an evening
* registration must not read as earlier than it happened. null when neither
* says (or a value is unreadable).
*/
export function openingEvidence(firstFundDay: string | null | undefined, registeredAt: number | null | undefined): string | null {
if (typeof firstFundDay === 'string') return evidenceRulesDate(firstFundDay);
return typeof registeredAt === 'number' ? sortableIso(registeredAt) : null;
}
/**
* Whether an account bound to a live type still matches that type's identity
* — its name carries the type's prefix and the size cached when it matched
* equals the type's size (0 = 0 for a $0-start type). An account on a kept
* rule set stays bound to its type only while this holds: kept sets take the
* live type's prefix and size, so after a size edit a "sticky" account would
* trade on a size it doesn't have.
*/
export function identityStillMatches(
accountName: string,
cachedSize: string | null,
live: Pick<CatalogAccountType, 'prefix' | 'accountSize'>,
): boolean {
if (!accountName.startsWith(live.prefix)) return false;
if (cachedSize === null || cachedSize.trim() === '') return false;
const size = Number(cachedSize);
return Number.isFinite(size) && Math.abs(size - live.accountSize) < 0.01;
}
/** Whether broker evidence moves an auto rules date: only earlier, never a
* date set by hand. The same test lib/db.ts lowerAutoRulesDate applies in
* SQL (string order is time order: every rules date is a sortableIso()). */
export function lowersRulesDate(row: { rules_date: string | null; rules_date_manual: number }, evidence: string): boolean {
return row.rules_date_manual === 0 && (row.rules_date === null || row.rules_date > evidence);
}
+43 -13
View File
@@ -11,10 +11,43 @@ import type { PlatformContract } from './platform-client';
import type { SymbolStats } from './master-protocol';
import {
combineCmeSessions, computeDayPlan, resolveEffectiveConfig, obfuscationStopDollars, dllRoom as computeDllRoom,
planLegSize, rankForTarget, maxLossRoom, lastStand, dllStopDollars, DEFAULT_TARGET_HOLD_HOURS,
planLegSize, rankForTarget, maxLossRoom, lastStand, dllStopDollars, stageInputsFor, floorRoom, shortestNextLeg,
FALLBACK_COMMISSION_PER_CONTRACT,
} from './trading-logic';
/**
* The day's hard-loss room as the engine's dailyLossGate works it out, for
* the dashboard paths: against the platform's own floor where it states one
* (Vest), else the day's P&L. null = not known yet (or no limit, dll ≤ 0).
*/
export function dayLossRoom(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any,
accountId: number,
dll: number,
cash: { amount: number; realizedPnL: number },
combinedRealized: number,
/** The shortest take-profit the next leg may have (shortestNextLeg). */
nextLeg: number,
): number | null {
if (!(dll > 0)) return null;
const floor: number | null | undefined = client.dailyLossFloor?.(accountId);
if (floor === null) return null;
if (typeof floor === 'number') {
return floorRoom({
dailyLossLimit: dll, closedBalance: cash.amount, floor,
realizedToday: Math.min(cash.realizedPnL, combinedRealized),
gain: client.dayGainForRoom?.(accountId, { leg: nextLeg, limit: dll }) ?? 0,
});
}
const dayStart = client.dayStartBalance?.(accountId);
return dayStart === null ? null : computeDllRoom({
dailyLossLimit: dll, closedBalance: cash.amount, dayStartBalance: dayStart,
realizedToday: Math.min(cash.realizedPnL, combinedRealized),
});
}
import { sizingHoldHours } from './person-size';
export type CandidateLeg = SymbolStats['symbols'][number] & {
swapReserve: number;
leverage: number | null;
@@ -39,10 +72,12 @@ export function rankNextLeg(o: {
dllRoom: number | null;
obfuscationDollars: number;
cash: { amount: number; availableFunds?: number };
/** The trade date the hold is drawn for (sizingHoldHours); default today. */
tradeDate?: string;
}): CandidateLeg[] {
const candidates: PlatformContract[] = o.client.lastCandidates ?? [];
if (!(o.leg > 0) || candidates.length === 0) return [];
const target = o.firm.targetHoldHours || DEFAULT_TARGET_HOLD_HOURS;
const target = sizingHoldHours(o.firm, o.tradeDate);
const floorRoom = maxLossRoom({
balance: o.cash.amount, accountSize: o.cfg.accountSize, maxLoss: o.cfg.maxLoss ?? 0,
autoLiqThreshold: o.client.autoLiqThresholds?.[o.accountId],
@@ -65,6 +100,7 @@ export function rankNextLeg(o: {
maxLossRoom: floorRoom,
obfuscationDollars: o.obfuscationDollars,
availableFunds: funds,
slippageFraction: o.client.stopSlippageFraction,
});
const p = plan(true);
if (!p.sizing || p.cost === null) return [];
@@ -102,6 +138,7 @@ function accountPlan(
const effective = resolveEffectiveConfig(
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages,
client.priorProfit?.[acc.id] ?? 0, client.fundTransactions?.[acc.id] ?? [], cfg.minDayPnL,
stageInputsFor(client, acc.id, cfg),
);
const plan = computeDayPlan(
acc.id, combined.tradeDate, effective.profitTarget, effective.consistency,
@@ -109,15 +146,8 @@ function accountPlan(
cfg.splitEnabled, effective.drain,
);
if (!plan) return null;
const dll = cfg.dailyLossLimit ?? 0;
let room: number | null = null;
if (dll > 0) {
const dayStart = client.dayStartBalance?.(acc.id);
room = dayStart === null ? null : computeDllRoom({
dailyLossLimit: dll, closedBalance: cash.amount, dayStartBalance: dayStart,
realizedToday: Math.min(cash.realizedPnL, combined.realizedPnL),
});
}
const dll = effective.dailyLossLimit;
const room = dayLossRoom(client, acc.id, dll, cash, combined.realizedPnL, shortestNextLeg(plan));
const obfuscation = obfuscationStopDollars(cfg, combined.dailyPnL, plan, combined.tradeDate, plan.remainingToday);
return { cfg, cash, plan, dll, room, obfuscation };
}
@@ -144,8 +174,8 @@ export function accountNextLeg(
leg: p.plan.remainingToday,
lastStand: p.room !== null && lastStand({ dllRoom: p.room, dailyLossLimit: p.dll, maxLossRoom: floorRoom }),
candidates: rankNextLeg({
client, accountId: acc.id, cfg: p.cfg, firm, leg: p.plan.remainingToday, dllRoom: p.room,
obfuscationDollars: p.obfuscation, cash: p.cash,
client, accountId: acc.id, cfg: { ...p.cfg, dailyLossLimit: p.dll }, firm, leg: p.plan.remainingToday, dllRoom: p.room,
obfuscationDollars: p.obfuscation, cash: p.cash, tradeDate,
}),
};
}
+2 -3
View File
@@ -106,14 +106,13 @@ class TopstepxFeed implements Feed {
constructor(readonly firmId: string, readonly firmName: string, token: () => string | null) {
// Like the user hub: one websocket, through the operator's proxy.
const proxiedWs = signalrWebSocket();
const configuredWs = signalrWebSocket();
this.hub = new signalR.HubConnectionBuilder()
.withUrl(MARKET_HUB, {
skipNegotiation: true,
transport: signalR.HttpTransportType.WebSockets,
accessTokenFactory: () => token() ?? '',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...(proxiedWs ? { WebSocket: proxiedWs as any } : {}),
...{ WebSocket: configuredWs },
})
.withAutomaticReconnect()
.configureLogging(signalR.LogLevel.Warning)
+7 -1
View File
@@ -24,6 +24,8 @@ import { queueRun } from './queue-run';
import { pushBackup } from './backup-sync';
import { getBuildVersion } from './version';
import { updaterStatus } from './updater-status';
import { runUpdateNow } from './updater-watchdog';
import { directWebSocket } from './outbound-proxy';
import type {
Command,
InstanceCapabilities,
@@ -87,6 +89,7 @@ export function collectCapabilities(): InstanceCapabilities {
}))
),
runnerOnline,
updateNow: true,
};
}
@@ -131,6 +134,9 @@ async function execute(command: Command): Promise<unknown> {
case 'backup-now':
return pushBackup(true);
case 'update-now':
return runUpdateNow();
default: {
// A newer master may know commands this build does not. Say so
// rather than failing silently.
@@ -234,7 +240,7 @@ function connect() {
let socket: WebSocket;
try {
socket = new WebSocket(url);
socket = directWebSocket(url);
} catch (err: any) {
scheduleReconnect(err?.message ?? 'could not open socket');
return;
+116 -11
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
@@ -81,6 +89,10 @@ export interface AccountTypeStats {
stage: number; drain?: boolean;
}[];
}[];
/** Live accounts of this slice on each kept rule set — the slice itself
* stays keyed by the live account type, whose id is the only one ever
* reported as an accountTypeId. Optional. */
keptRules?: { id: string; accounts: number }[];
}
export interface FirmStats {
@@ -105,10 +117,48 @@ 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. */
accounts?: { name: string; size: number; payoutable: boolean | null; withdrawals?: number }[];
* (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:
* unknown when the balance is missing (reason cash_missing — never dead),
* dead on the floor test, history_pending, locked when the firm stopped
* it trading (reason says how), else live. balance and floor (0 = none)
* in dollars; accountTypeId = the resolved catalog type (absent =
* unassigned); firstFundDate = the earliest positive fund row, YYYY-MM-DD
* (DXtrade: the registration date when there is none); positiveFunds
* counts those rows, absent until the fund history loads; brokerStatus =
* the broker's own flags (Tradovate closed/archived/restricted, DXtrade
* accountStatus, TopstepX canTrade:false). */
accounts?: {
name: string; size: number; payoutable: boolean | null; withdrawals?: number;
status?: 'live' | 'locked' | 'dead' | 'history_pending' | 'unknown';
reason?: string;
balance?: number;
floor?: number;
accountTypeId?: string;
firstFundDate?: string;
positiveFunds?: number;
brokerStatus?: string;
}[];
/** 1 on every firm from a build that sends the account status fields,
* including one with no accounts. Absent = an older build: its accounts
* are unknown, never dead. An empty accounts[] from a statusVersion build
* is a synced firm with no accounts. Optional. */
statusVersion?: 1;
/** The catalog firm's uuid (statusVersion builds), so the master needn't
* resolve the name. Optional. */
firmId?: string;
/** Kept rule set ids this machine has loaded for the firm. Present (even
* []) = this build applies kept rules and its cached catalog was parsed
* by a parser that understands them. Absent = old build or stale cache. */
keptRuleIds?: string[];
/** ISO time the firm's last good sync finished (statusVersion builds), sent
* once synced. The statuses count only while it is recent. Optional. */
syncedAt?: string;
/** Per account type, summing to the firm's own totals. Optional so a master
* on an older build simply ignores it. */
accountTypes?: AccountTypeStats[];
@@ -277,6 +327,8 @@ export interface InstanceCapabilities {
symbols: string[];
automations: AutomationSummary[];
runnerOnline: boolean;
/** Answers the 'update-now' command; the master offers it only then. */
updateNow: boolean;
}
// ── Firm catalog ─────────────────────────────────────────────────────────────
@@ -322,7 +374,11 @@ export interface CatalogAccountType {
* minTradingDays qualifying days of ~minDayPnL (default $5, randomized
* $5–$15, traded as tiny MNQ legs), then wait for the next withdrawal.
* withdrawalPct/payoutCap override the type's payout defaults per stage. */
withdrawalStages: { profit: number; consistency: number; minTradingDays: number; drain?: boolean; minDayPnL?: number; withdrawalPct?: number; payoutCap?: number }[];
withdrawalStages: { profit: number; consistency: number; minTradingDays: number; drain?: boolean; minDayPnL?: number; withdrawalPct?: number; payoutCap?: number; dailyLossLimit?: number }[];
/** True = the stage is chosen by the payouts made since the firm's last
* daily reset, and every new day starts back at the base stage (Vest:
* claim, then trade again the same day). Absent from older masters. */
stagesSameDay?: boolean;
/** Payout-projection settings, owned by the master's payout calendar but
* consumed here to compute each account's future payout chain. Optional:
* an older master doesn't send them. */
@@ -348,15 +404,39 @@ export interface CatalogAccountType {
updatedAt: string;
}
/** Old rules an account type keeps for its accounts whose rules date is
* inside [openedAt, closedAt) — set when the owner changes the type's rules
* for new accounts only. Built as: live type X's fields, overwritten by the
* snapshot's RULE fields, plus these. Never an account type itself: nothing
* is pinned or reported by its id as an accountTypeId (lib/kept-rules.ts). */
export interface KeptAccountRules extends CatalogAccountType {
/** This kept set's own uuid — never a live account type id. */
id: string;
/** The live account type id (X). */
keptFrom: string;
/** ISO; inclusive lower bound on the rules date; null = none. */
openedAt: string | null;
/** ISO; exclusive upper bound. */
closedAt: string;
}
/** Where a firm's accounts trade. A build only knows the platforms listed
* here; the catalog sync skips a firm on any other rather than guess. */
export const FIRM_PLATFORMS = ['tradovate', 'topstepx', 'dxtrade'] as const;
export const FIRM_PLATFORMS = ['tradovate', 'topstepx', 'dxtrade', 'vest'] as const;
export type FirmPlatform = (typeof FIRM_PLATFORMS)[number];
export function isFirmPlatform(value: unknown): value is FirmPlatform {
return typeof value === 'string' && (FIRM_PLATFORMS as readonly string[]).includes(value);
}
/** Platforms whose firms trade their own symbols by notional (DXtrade's
* CFDs, Vest's perps) rather than CME contracts: the firm's instrument list
* is platform symbols, fees are a percent of notional, and each firm is a
* scheduler lane of its own. */
export function isNotionalPlatform(platform: string | undefined): boolean {
return platform === 'dxtrade' || platform === 'vest';
}
/** One symbol a DXTrade firm may trade for a CME root. Position size comes
* from volatility and the firm's target hold, and the commission is the
* firm's (an older master's notionalPerContract or per-symbol commissionPct
@@ -381,10 +461,24 @@ export interface CatalogFirm {
* own symbols for DXtrade (see lib/instruments.ts). Absent from a master
* from before the lists — then bannedSymbols and symbolMap apply. */
instruments?: string[];
/** Futures firms: the most contracts the firm allows on an instrument,
* by root, for every account type (absent = no limit beyond the type's
* max contracts). */
instrumentLimits?: Record<string, number>;
/** The firm's website ('' = none; absent from older masters). The master
* matches the extension's tab against it to find a person's 2FA code. */
siteUrl?: string;
/** The most of the firm's accounts this machine trades at once, on top of
* its own max_concurrent_accounts (0 = no firm cap; absent from older
* masters). */
maxConcurrentAccounts?: number;
/** Older masters' way to exclude roots; newer ones derive it from
* `instruments`. */
bannedSymbols: string[];
accountTypes: CatalogAccountType[];
/** Old rule sets the firm's account types keep for older accounts.
* Optional; old builds drop it. */
keptAccountRules?: KeptAccountRules[];
/** DXTrade REST base, e.g. https://dx.tradeify247.co/dxsca-web ('' otherwise). */
serverUrl?: string;
/** DXTrade login domain ('' = 'default'). */
@@ -418,6 +512,14 @@ export interface FirmCatalogPayload {
firms: CatalogFirm[];
/** Absent from a master from before its Settings page. */
trading?: CatalogTradingSettings;
/** Instance-local, never sent by the master: the catalog parser version
* (lib/firm-catalog.ts CATALOG_PARSER_VERSION) that produced a cached
* 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 {
@@ -432,7 +534,10 @@ export type Command =
| { kind: 'autobuyer-run'; automationId: string; inputs?: Record<string, number> }
| { kind: 'report-now' }
/** Push a fresh settings/credentials backup right now. */
| { kind: 'backup-now' };
| { kind: 'backup-now' }
/** Heal this machine's auto-updater if it stopped, then check for an
* update at once, skipping a failed update's retry wait. */
| { kind: 'update-now' };
export type MasterMessage =
/** personId is the person's stable uuid; the instance stores it and
+36 -40
View File
@@ -21,20 +21,20 @@
* process default is the scoped choice. It covers axios only; websockets and
* the SignalR hub keep their own liveness handling in the clients.
*
* Both defaults are applied when this module loads. Every broker client
* imports it, and that matters: each server bundle (instrumentation.ts, the
* route handlers) can carry its own copy of axios, and a copy that never had
* the defaults applied would dial the brokers direct, with no timeout.
* lib/http-client applies these defaults to every Axios client it exports,
* including clients created with axios.create(), in every server bundle.
*/
import axios from 'axios';
import { ProxyAgent, type Dispatcher } from 'undici';
import type { AxiosDefaults } from 'axios';
import { ProxyAgent, WebSocket as UndiciWebSocket, type Dispatcher } from 'undici';
import NodeWebSocket from 'ws';
import { socksDispatcher } from 'fetch-socks';
import { HttpProxyAgent } from 'http-proxy-agent';
import { HttpsProxyAgent } from 'https-proxy-agent';
import { SocksProxyAgent } from 'socks-proxy-agent';
import type { Agent } from 'node:http';
import { getSetting } from './db';
import { getRequestUserAgent } from './request-user-agent';
const SCHEMES = ['http:', 'https:', 'socks5:', 'socks5h:'] as const;
@@ -120,18 +120,18 @@ export function getProxyUrl(): string | null {
/** Puts the proxy under every axios call. All axios usage in this app is
* broker/quote traffic (TopstepX, Tradovate REST, contract resolver), so the
* process-wide default is the scoped choice, not a global one. Idempotent;
* runs when this module loads and again first in the getClients() bootstrap.
* runs when the shared HTTP client loads and in the getClients() bootstrap.
*
* "Already applied" is judged on the axios defaults themselves, not a
* process-wide flag: the proxy state is shared through globalThis, but each
* bundle's copy of axios has its own defaults, and a global flag set by the
* first copy would leave every other one dialling direct. */
export function applyBrokerProxy(): void {
export function applyBrokerProxy(defaults: AxiosDefaults): void {
const s = state();
if (!s.url || axios.defaults.httpsAgent === s.httpsAgent) return;
axios.defaults.proxy = false; // agents own the routing; ignore env HTTP(S)_PROXY handling
axios.defaults.httpAgent = s.httpAgent!;
axios.defaults.httpsAgent = s.httpsAgent!;
if (!s.url || defaults.httpsAgent === s.httpsAgent) return;
defaults.proxy = false; // agents own the routing; ignore env HTTP(S)_PROXY handling
defaults.httpAgent = s.httpAgent!;
defaults.httpsAgent = s.httpsAgent!;
}
/** Idle limit for one broker HTTP request. axios applies it as a socket
@@ -147,53 +147,49 @@ export function applyBrokerProxy(): void {
export const BROKER_HTTP_TIMEOUT_MS = 60_000;
/** Gives axios a timeout unless one is already set (axios's own default, 0,
* means wait forever). Idempotent; runs when this module loads and at the
* means wait forever). Idempotent; runs when the shared HTTP client loads and at the
* top of the getClients() bootstrap. Timeouts surface as ECONNABORTED, which
* the Tradovate report fetch already retries as transient. */
export function applyBrokerHttpDefaults(): void {
if (!axios.defaults.timeout) axios.defaults.timeout = BROKER_HTTP_TIMEOUT_MS;
export function applyBrokerHttpDefaults(defaults: AxiosDefaults): void {
if (!defaults.timeout) defaults.timeout = BROKER_HTTP_TIMEOUT_MS;
}
// At load, so no broker request from this bundle can go out before them. The
// proxy is skipped during `next build`: the build imports route modules to
// collect page data, and it dials nothing that would need one. Nothing may
// throw out of here: a module that fails to load takes every route that
// imports it (the whole trading side) down with it.
applyBrokerHttpDefaults();
try {
if (process.env.NEXT_PHASE !== 'phase-production-build') applyBrokerProxy();
} catch (err) {
console.error(`[proxy] could not apply proxy_url at load: ${err instanceof Error ? err.message : String(err)}`);
/** Direct control-channel socket: same browser identity, no broker proxy. */
export function directWebSocket(wsUrl: string): WebSocket {
return new UndiciWebSocket(wsUrl, {
headers: { 'User-Agent': getRequestUserAgent() },
}) as unknown as WebSocket;
}
/** A websocket to a BROKER endpoint: the built-in (undici) WebSocket, dialled
* through the proxy when one is set. The master link keeps using plain
* `new WebSocket(...)` — do not use this for it. */
/** Broker socket with the same browser identity and optional broker proxy.
* Use the installed Undici: Node's built-in WebSocket may ignore headers. */
export function brokerWebSocket(wsUrl: string): WebSocket {
const { dispatcher } = state();
if (!dispatcher) return new WebSocket(wsUrl);
// The runtime accepts { dispatcher } fine; only the DOM lib type doesn't.
return new (WebSocket as unknown as new (u: string, o: object) => WebSocket)(wsUrl, { dispatcher });
return new UndiciWebSocket(wsUrl, {
...(dispatcher ? { dispatcher } : {}),
headers: { 'User-Agent': getRequestUserAgent() },
}) as unknown as WebSocket;
}
/** A `ws`-package constructor with the proxy agent baked in, for SignalR's
* `withUrl({ WebSocket })` option. undefined = no proxy, let SignalR use its
* own require('ws'). */
/** SignalR always uses this constructor, even without a proxy, because its
* own transport supplies a library User-Agent that we must replace. */
export function signalrWebSocket(): unknown {
const s = state();
if (!s.httpsAgent) return undefined;
const agent = s.httpsAgent;
// eslint-disable-next-line @typescript-eslint/no-require-imports
const WS = require('ws') as new (address: string, protocols?: unknown, options?: object) => unknown;
return class ProxiedWebSocket extends (WS as new (...args: never[]) => object) {
return class ConfiguredWebSocket extends NodeWebSocket {
constructor(address: string, protocols?: unknown, options?: Record<string, unknown>) {
// ws also allows (address, options) — normalize before merging.
if (protocols && typeof protocols === 'object' && !Array.isArray(protocols)) {
options = protocols as Record<string, unknown>;
protocols = undefined;
}
super(...[address, protocols, { ...(options ?? {}), agent }] as never[]);
const headers = { ...(options?.headers as Record<string, string> | undefined) };
for (const name of Object.keys(headers)) {
if (name.toLowerCase() === 'user-agent') delete headers[name];
}
headers['User-Agent'] = getRequestUserAgent();
const agent = address.startsWith('ws:') ? s.httpAgent : s.httpsAgent;
super(address, protocols, { ...(options ?? {}), ...(agent ? { agent } : {}), headers });
}
};
}
+24
View File
@@ -0,0 +1,24 @@
/**
* The hold this machine sizes DXtrade and Vest legs for. Every person's
* machine would otherwise size a like account in a like state to the same
* position as everyone else's; each person's own draw (personHoldHours) puts
* their sizes within ±PERSON_SIZE_SPREAD of the plan instead, the same for
* all of their accounts and new each trade date.
*/
import { getSetting } from './db';
import { currentTradeDateCT, personHoldHours, DEFAULT_TARGET_HOLD_HOURS } from './trading-logic';
/** Who this machine trades for: the master's uuid for the person, else the
* instance name it reports under. */
function personKey(): string {
return getSetting('instance_id') || getSetting('instance_name') || '';
}
/** The hold a DXtrade or Vest firm's legs are sized for on this machine today. */
export function sizingHoldHours(
firm: { targetHoldHours?: number } | null | undefined,
tradeDate: string = currentTradeDateCT(),
): number {
return personHoldHours(firm?.targetHoldHours || DEFAULT_TARGET_HOLD_HOURS, personKey(), tradeDate);
}
+336 -2
View File
@@ -10,7 +10,7 @@
* safe.
*/
import type { FirmPlatform } from './master-protocol';
import type { FirmPlatform, FirmStats } from './master-protocol';
export interface PlatformAccount {
id: number;
@@ -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
@@ -74,6 +82,11 @@ export interface PlatformClient {
fundTransactions: { [accountId: number]: { date: string; amount: number }[] };
priorProfit: { [accountId: number]: number };
autoLiqThresholds: { [accountId: number]: number };
/** Share of a position's notional a stop may fill past its price — the
* part of the daily-loss reserve that grows with size (dllReserve).
* Only where this platform's stops are measured to fill closer than
* DLL_SLIPPAGE_FRACTION, the default. */
stopSlippageFraction?: number;
fetchDaysComplete: boolean;
syncComplete: boolean;
@@ -100,6 +113,18 @@ export interface PlatformClient {
* read as a fresh stage-1 cycle, so such an account must not trade.
*/
historyReady(accountId: number): boolean;
/** historyReady() as it stands, for a reader that must not change it: no
* new hold, no baseline moved. Only where historyReady() has side effects
* (Tradovate's balance-drift check); elsewhere historyReady() is the read. */
peekHistoryReady?(accountId: number): boolean;
/** Dollars of balance the account's books don't explain yet, or null when
* they do. Only where balances arrive live but fund events are booked
* later (TopstepX: the hub, then the next sync); the engine holds the
* account until they're booked, or a payout plans as lost profit. */
unbookedBalanceChange?(accountId: number): number | null;
/** What the broker says about the account beyond active (DXTrade: its
* accountStatus and registration time, epoch ms or NaN). */
brokerAccountMeta?(accountId: number): { status: string; registrationTime: number } | undefined;
/**
* The account's closed balance at the firm's last daily reset — what a
* hard daily loss limit is measured from. Only platforms that know the
@@ -109,6 +134,55 @@ export interface PlatformClient {
* account rather than size a stop on a guess.
*/
dayStartBalance?(accountId: number): number | null;
/**
* The equity the account may not fall below today, where the platform
* states its daily-loss floor itself and moves it with the day's
* transfers (Vest: reset balance × (1 − rate), lowered by a payout). The
* engine's room is then what's left of the stage's limit (widened by
* dayGainForRoom), never more than balance − floor (floorRoom), so a
* same-day payout that lowers the floor frees room to trade again. null =
* the platform has one but it isn't known yet (hold); undefined = none
* here (the engine falls back to dayStartBalance / realized P&L).
*/
dailyLossFloor?(accountId: number): number | null | undefined;
/**
* With dailyLossFloor: how much of the day's gain a leg starting now, its
* take-profit `leg` away and its room `limit` before any gain, may add to
* its daily-loss room (floorRoom's `gain`) — the gain still in the account
* since the day's base, no more than keeps the leg all but sure to close
* 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;
* rejects with the platform's reason otherwise. */
claimProfit?(accountId: number, amount: number): Promise<{ claimed: number; received: number | null }>;
/** A claim this client made that no good sync has read past yet: the
* balance on hand may not show it, so no claim may be judged on it. */
claimPending?(accountId: number): boolean;
/** Passed evaluations whose funded account the platform activates by
* API (Vest's "Go to funded account"), by account id — less any this
* client already asked for that no sync has read back yet. */
activatable?(): number[];
/** Activate the funded account of a passed evaluation. Resolves once the
* platform opened it, with its name when the answer gives one (the next
* sync lists it); rejects with the platform's reason otherwise. */
activateFunded?(accountId: number): Promise<{ funded: string | null }>;
/** Drop the account's history from memory (the wipe routes), so it reads
* unloaded until the next fetch rebuilds it. */
forgetHistory(accountId: number, accountName: string): void;
@@ -118,13 +192,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(
@@ -189,6 +270,202 @@ export function isClientReady(client: PlatformClient, now = Date.now()): boolean
return client.syncComplete && client.lastSyncAt > 0 && now - client.lastSyncAt < SYNC_STALE_MS;
}
// ── Account status, as the master's ledger reads it ──────────────────────────
//
// Pure, so scripts/check-account-status.mjs can load this file. The reporter
// is the only caller: the scheduler, the dashboards and the trading decisions
// keep their own copies of the dead test for now.
/** FirmStats.statusVersion: this build sends the status fields. The master
* reads a firm without it as an older build — unknown, never dead. */
export const REPORT_STATUS_VERSION = 1 as const;
export type ReportedAccount = NonNullable<FirmStats['accounts']>[number];
export type AccountReportStatus = NonNullable<ReportedAccount['status']>;
export interface AccountStatusInput {
/** The cash balance; null or undefined while the platform has none. */
cash: number | null | undefined;
/** Auto-liquidation floor; 0 = none known. */
floor: number;
active: boolean;
historyReady: boolean;
/** Why the platform has the account inactive, where it can say. */
inactiveReason?: string;
/** Why a dead account is dead, where the platform can say. */
deadReason?: string;
}
/** The dead test the reporter has always applied: a floor (0 = none, negative
* = a reconstructed TopstepX MLL) the cash is at or below. It read a missing
* balance as 0, so pass `cash ?? 0` for the old answer. */
export function legacyDead(cashOrZero: number, floor: number): boolean {
return floor !== 0 && cashOrZero <= floor;
}
/**
* The account's status, first match wins:
* 1. no balance → unknown (cash_missing) — never dead, never 0;
* 2. the legacy dead test → dead;
* 3. history not loaded → history_pending;
* 4. inactive → locked: the firm stopped it trading (DLL, liquidate-only,
* canTrade=false, CLOSE_ONLY — a pass lock looks the same);
* 5. live.
* So with a balance, 'dead' is exactly legacyDead(cash, floor).
*/
export function accountStatus(input: AccountStatusInput): { status: AccountReportStatus; reason?: string } {
const { cash, floor } = input;
if (cash === null || cash === undefined) return { status: 'unknown', reason: 'cash_missing' };
if (legacyDead(cash, floor)) return input.deadReason ? { status: 'dead', reason: input.deadReason } : { status: 'dead' };
if (!input.historyReady) return { status: 'history_pending' };
if (!input.active) return { status: 'locked', reason: input.inactiveReason || 'inactive' };
return { status: 'live' };
}
export interface BrokerFacts {
brokerStatus?: string;
inactiveReason?: string;
deadReason?: string;
registeredAt?: number;
}
/**
* The broker's own words for an account, per platform. Tradovate's list
* entries carry closed/archived/restricted, and an inactive one there is
* liquidate-only (a DLL lockout and a pass look alike). TopstepX's active is
* canTrade. DXTrade's accountStatus decides both — CLOSE_ONLY inactive, the
* terminal ones dead (dxtrade-class.ts DEAD_STATUSES) — and FULL_TRADING is
* the normal case, so it isn't repeated. Each field is left out when there is
* nothing to say.
*/
export function brokerFacts(
platform: FirmPlatform,
account: { active: boolean; closed?: unknown; archived?: unknown; restricted?: unknown },
meta?: { status: string; registrationTime: number },
): BrokerFacts {
if (platform === 'tradovate') {
const flags = (['closed', 'archived', 'restricted'] as const).filter((k) => account[k] === true);
return { inactiveReason: 'liquidate_only', ...(flags.length > 0 ? { brokerStatus: flags.join(',') } : {}) };
}
if (platform === 'topstepx') {
return { inactiveReason: 'cannot_trade', ...(account.active ? {} : { brokerStatus: 'canTrade:false' }) };
}
if (platform === 'vest') {
// Vest's capital account status is a number; 2 is the live one
// (vest-helpers VEST_STATUS_ACTIVE). A breached account leaves the
// active list, so no status here reads as terminal.
const status = meta?.status;
return {
inactiveReason: status ? `status:${status}` : 'inactive',
...(status && status !== '2' ? { brokerStatus: `status:${status}` } : {}),
...(meta && validTime(meta.registrationTime) ? { registeredAt: meta.registrationTime } : {}),
};
}
const status = meta?.status || undefined;
return {
inactiveReason: status === 'CLOSE_ONLY' ? 'close_only' : status ? `status:${status}` : 'inactive',
...(status ? { deadReason: `terminal:${status}` } : {}),
...(status && status !== 'FULL_TRADING' ? { brokerStatus: status } : {}),
...(meta && validTime(meta.registrationTime) ? { registeredAt: meta.registrationTime } : {}),
};
}
export interface AccountReportInput extends AccountStatusInput, BrokerFacts {
/** The account's resolved catalog type; null or undefined = unassigned. */
accountTypeId: string | null | undefined;
/** The account's fund rows; undefined until its fund history has loaded. */
fundTransactions: { date: string; amount: number }[] | undefined;
}
const CT_DATE = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Chicago', year: 'numeric', month: '2-digit', day: '2-digit' });
const cents = (n: number) => Math.round(n * 100) / 100;
/** Epoch ms a Date can hold — formatting anything else throws. */
const validTime = (ms: number | undefined): ms is number => ms !== undefined && Number.isFinite(ms) && Math.abs(ms) <= 8.64e15;
/**
* When the account opened, as far as the broker shows: its earliest positive
* fund row's date (YYYY-MM-DD), or for a DXTrade account with none its
* registration date, in Chicago. Undefined until the fund history has loaded
* (`fundTransactions` undefined) and when neither says. The report's
* firstFundDate. The resolver's evidence for an account's rules date reads
* the fund rows through here (registeredAt undefined) but takes the
* registration instant itself, not its day (lib/kept-rules.ts openingEvidence).
*/
export function firstFundDate(
fundTransactions: { date: string; amount: number }[] | undefined,
registeredAt: number | undefined,
): string | undefined {
if (!fundTransactions) return undefined;
const positive = fundTransactions.filter((f) => f.amount > 0);
if (positive.length > 0) return positive.reduce((min, f) => (f.date < min ? f.date : min), positive[0].date).slice(0, 10);
return validTime(registeredAt) ? CT_DATE.format(new Date(registeredAt)) : undefined;
}
/**
* The status fields one accounts[] entry adds. Every one is left out when it
* is null or at its default — 2000 accounts must stay well under the master
* socket's 1 MB frame. firstFundDate: see the function of that name;
* positiveFunds counts the positive fund rows, and both wait for the fund
* history.
*/
export function accountStatusFields(input: AccountReportInput): Omit<ReportedAccount, 'name' | 'size' | 'payoutable' | 'withdrawals'> {
const { status, reason } = accountStatus(input);
const positive = input.fundTransactions?.filter((f) => f.amount > 0);
const firstFund = firstFundDate(input.fundTransactions, input.registeredAt);
return {
status,
...(reason ? { reason } : {}),
...(typeof input.cash === 'number' && Number.isFinite(input.cash) ? { balance: cents(input.cash) } : {}),
...(Number.isFinite(input.floor) && input.floor !== 0 ? { floor: cents(input.floor) } : {}),
...(input.accountTypeId ? { accountTypeId: input.accountTypeId } : {}),
...(firstFund ? { firstFundDate: firstFund } : {}),
...(positive ? { positiveFunds: positive.length } : {}),
...(input.brokerStatus ? { brokerStatus: input.brokerStatus } : {}),
};
}
/** The firm-level keys every FirmStats from this build adds. syncedAt only
* once the client has synced: the master trusts the statuses while it is
* recent (SYNC_STALE_MS). */
export function firmStatusFields(firmId: string, syncComplete: boolean, lastSyncAt: number): Pick<FirmStats, 'statusVersion' | 'firmId' | 'syncedAt'> {
return {
statusVersion: REPORT_STATUS_VERSION,
firmId,
...(syncComplete && validTime(lastSyncAt) && lastSyncAt > 0 ? { syncedAt: new Date(lastSyncAt).toISOString() } : {}),
};
}
/** FirmStats.keptRuleIds: the firm's kept rule set ids when this machine's
* catalog can be trusted to hold them (lib/firm-catalog.ts
* getKeptRuleIdsForFirm), else left out — the master then counts this
* machine as trading every account on the newest rules. */
export function keptRuleIdsField(keptRuleIds: string[] | null): Pick<FirmStats, 'keptRuleIds'> {
return keptRuleIds ? { keptRuleIds } : {};
}
/**
* FirmStats for a firm with no accounts to walk — no client yet, or an empty
* broker list. accounts is [] only once synced: before that, a firm whose
* accounts haven't arrived would read as every saved account erased. A synced
* firm with none also says when it synced, so the master sees a fresh, empty
* list rather than an unknown one.
*/
export function emptyFirmStats(
firm: { id: string; name: string },
client: { syncComplete: boolean; lastSyncAt: number } | undefined,
keptRuleIds: string[] | null = null,
): FirmStats {
const synced = client?.syncComplete === true;
return {
firm: firm.name, totalAccounts: 0, accountsTraded: 0, inTrade: 0,
targetHit: 0, partial: 0, loss: 0, personalDll: 0, completed: 0,
...(synced ? { accounts: [] } : {}),
accountTypes: [],
...firmStatusFields(firm.id, synced, client?.lastSyncAt ?? 0),
...keptRuleIdsField(keptRuleIds),
};
}
/**
* Thrown when a client KNOWS an order never reached the broker: no live
* connection, the broker refused it, or it failed before anything was sent.
@@ -209,6 +486,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. */
+163 -34
View File
@@ -11,20 +11,24 @@
* 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';
import { updaterStatus } from './updater-status';
import { getClients } from './clients';
import { getTradableFirms } from './firm-catalog';
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, DEFAULT_TARGET_HOLD_HOURS } 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';
import { instrumentStatsFor, isFuturesFirm } from './instruments';
import { historyFresh } from './platform-client';
import { accountStatusFields, brokerFacts, emptyFirmStats, firmStatusFields, historyFresh, keptRuleIdsField } from './platform-client';
function collectFirmStats() {
// Only firms this machine has credentials for. A firm it cannot log into
@@ -32,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 =>
@@ -44,16 +45,15 @@ function collectFirmStats() {
return firms.map((f) => {
const client = clients.get(f.id);
if (!client || client.accountList.length === 0) {
return {
firm: f.name, totalAccounts: 0, accountsTraded: 0, inTrade: 0,
targetHit: 0, partial: 0, loss: 0, personalDll: 0, completed: 0,
// Empty only once synced — before that, a firm whose accounts
// haven't arrived would read as every saved account erased.
...(client?.syncComplete ? { accounts: [] } : {}),
accountTypes: [],
};
}
// Absent (null) while this machine's catalog can't vouch for them.
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;
@@ -71,16 +71,19 @@ function collectFirmStats() {
// Per account type, so the master can say what the accounts still to
// trade actually are. Keyed by account type id; unassigned accounts
// share the null bucket — they inflate "left to trade" indefinitely,
// which is precisely the thing worth being able to see.
// which is precisely the thing worth being able to see. Always the
// live type's id, label and size: accounts on a kept rule set count
// in their live type's slice (and in its keptRules).
const byType = new Map<string | null, AccountTypeStats>();
const bucket = (cfg: { id: string; label: string; accountSize: number } | null): AccountTypeStats => {
const key = cfg?.id ?? null;
let entry = byType.get(key);
if (!entry) {
const live = key ? getAccountTypeById(key) ?? cfg : null;
entry = {
accountTypeId: key,
label: cfg?.label ?? 'Unassigned',
accountSize: cfg?.accountSize ?? 0,
label: live?.label ?? 'Unassigned',
accountSize: live?.accountSize ?? 0,
totalAccounts: 0,
accountsTraded: 0,
inTrade: 0,
@@ -92,11 +95,18 @@ 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 cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
const reportedCash = client.accountCashBalances[acc.id];
const cash = reportedCash ?? { amount: 0, realizedPnL: 0 };
const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0;
const cfg = getAccountConfig(acc.id).accountType;
const resolved = getAccountConfig(acc.id);
const cfg = resolved.accountType;
// undefined until the account's fund history has loaded. The master
// clears a completed cycle once the withdrawal it was waiting for
// shows up here, so an account that hasn't loaded reports no count
@@ -104,11 +114,33 @@ 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({
cash: reportedCash?.amount,
floor: autoLiqThreshold,
active: acc.active,
historyReady: client.peekHistoryReady ? client.peekHistoryReady(acc.id) : client.historyReady(acc.id),
accountTypeId: cfg?.id,
fundTransactions: loadedFundTxns,
...brokerFacts(f.platform, acc, client.brokerAccountMeta?.(acc.id)),
})),
});
// 0 = no floor; negative floors are reconstructed TopstepX MLLs.
const isDead = autoLiqThreshold !== 0 && cash.amount <= autoLiqThreshold;
@@ -118,6 +150,12 @@ function collectFirmStats() {
totalAccounts++;
slice.totalAccounts++;
if (resolved.keptRules) {
const keptId = resolved.keptRules.id;
const kept = (slice.keptRules ??= []).find((k) => k.id === keptId);
if (kept) kept.accounts++;
else slice.keptRules.push({ id: keptId, accounts: 1 });
}
// Same combined-day view as the trade path: for a combining firm,
// "today" spans the holiday stretch. Computed for every live
@@ -137,6 +175,7 @@ function collectFirmStats() {
cfg.profitTarget, cfg.consistency, cfg.minTradingDays,
cfg.targetSameEquity, cfg.withdrawalStages,
client.priorProfit?.[acc.id] ?? 0, fundTxns, cfg.minDayPnL,
stageInputsFor(client, acc.id, cfg),
) : null;
// Days traded, for the master's per-day pass counts — the same
// number as the local dashboard's Days Traded column
@@ -190,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,
@@ -229,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,
@@ -253,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,
@@ -263,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++;
@@ -289,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,
@@ -308,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;
}
@@ -355,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) {
@@ -377,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.
@@ -394,10 +489,26 @@ function collectFirmStats() {
...symbolStatsFor(f, client, rawToday),
...instrumentStatsBlock(f, client, rawToday),
mix: tradeMixFor(f, client),
...firmStatusFields(f.id, client.syncComplete, client.lastSyncAt),
...keptRuleIdsField(keptRuleIds),
};
});
}
/** An account's status fields, or unknown if working them out throws — the
* ledger's data must never cost the rest of the report. Logged once. */
function statusFieldsOrUnknown(fields: () => ReturnType<typeof accountStatusFields>): ReturnType<typeof accountStatusFields> {
try {
return fields();
} catch (err) {
if (!_g.__accountStatusFailed) {
_g.__accountStatusFailed = true;
console.error('[reporter] account status failed — reporting it as unknown', err);
}
return { status: 'unknown', reason: 'status_error' };
}
}
/** The live accounts of a firm's largest prefix group first — the group a
* batch fires — each with an account type. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -462,7 +573,7 @@ function symbolStatsFor(
symbolStats: {
account: acc.name,
leg: next.leg,
targetHoldHours: f.targetHoldHours || DEFAULT_TARGET_HOLD_HOURS,
targetHoldHours: Math.round(sizingHoldHours(f, tradeDate) * 10) / 10,
lastStand: next.lastStand,
at: new Date().toISOString(),
symbols: next.candidates.map((c) => ({
@@ -478,7 +589,18 @@ function symbolStatsFor(
}
// HMR-safe global to avoid duplicate intervals
const _g = globalThis as typeof globalThis & { __reporterInterval?: ReturnType<typeof setInterval> };
const _g = globalThis as typeof globalThis & {
__reporterInterval?: ReturnType<typeof setInterval>;
/** The last report was too big for the socket (logged on each change). */
__reportOverSocketLimit?: boolean;
/** accountStatusFields has thrown once (logged the first time only). */
__accountStatusFailed?: boolean;
};
/** The master's socket closes on a frame over 1,000,000 bytes (maxPayload in
* its server.mjs), and the link would resend it every tick — a report this
* big goes by POST instead. */
const SOCKET_REPORT_MAX_CHARS = 900_000;
/**
* Collect and send one report. Exported so the control link can fire an
@@ -495,7 +617,14 @@ export async function sendReport(): Promise<void> {
const firms = collectFirmStats();
const scheduler = getSchedulerStatus();
if (pushReport(firms, scheduler)) return;
const overSocketLimit = JSON.stringify(firms).length > SOCKET_REPORT_MAX_CHARS;
if (overSocketLimit !== (_g.__reportOverSocketLimit ?? false)) {
_g.__reportOverSocketLimit = overSocketLimit;
console.warn(overSocketLimit
? `[reporter] report over ${SOCKET_REPORT_MAX_CHARS} chars — sending it by POST, not the socket`
: '[reporter] report fits the socket again');
}
if (!overSocketLimit && pushReport(firms, scheduler)) return;
await fetch(new URL('/api/report', url), {
method: 'POST',
+16
View File
@@ -0,0 +1,16 @@
import { randomInt } from 'node:crypto';
import { getOrCreateSetting } from './db';
// Desktop Windows browser profiles. Choose a browser once per instance, not
// once per request, so HTTP calls and socket reconnects keep the same identity.
const USER_AGENTS = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 Edg/144.0.0.0',
] as const;
const g = globalThis as typeof globalThis & { __requestUserAgent?: string };
export function getRequestUserAgent(): string {
return g.__requestUserAgent ??= getOrCreateSetting('user_agent', () => USER_AGENTS[randomInt(USER_AGENTS.length)]);
}
+74
View File
@@ -0,0 +1,74 @@
/**
* Topstep's own commission on TopstepX — the part of a fill's cost the
* gateway never reports.
*
* Trade/search and the user hub carry `fees` on every half-turn, but only the
* exchange and NFA fees (NQ $1.39 a side, GC $1.66, CL $1.51, MNQ $0.36).
* Topstep's commission — $0.50 a side on a full-size contract, $0.25 on a
* micro — comes off the balance with no trade carrying it. Topstep's
* "TopstepX Commissions and Fees" page totals both: NQ and ES $3.78 a round
* turn, MNQ and MES $1.22, GC $4.32, CL $4.02. Left out, the books ran $1.00
* a full-size round turn above the balance (2026-10-18: an XFA $27.50 off
* after 26 NQ/GC round turns and 3 MNQ, each combine $1.00 per round turn).
*
* No imports, so scripts/check-instruments.mjs can test it directly.
*/
/** Per contract and side, by CME root. */
export const TOPSTEP_COMMISSION_PER_SIDE: Readonly<Record<string, number>> = {
NQ: 0.5, MNQ: 0.25,
ES: 0.5, MES: 0.25,
YM: 0.5, MYM: 0.25,
RTY: 0.5, M2K: 0.25,
GC: 0.5, MGC: 0.25,
SI: 0.5,
CL: 0.5, MCL: 0.25,
NG: 0.5,
ZB: 0.5, ZN: 0.5, ZF: 0.5,
'6E': 0.5, '6J': 0.5, '6B': 0.5,
};
/** A product missing from the table pays the full-size rate, so the books
* err toward the lower balance. */
export const TOPSTEP_COMMISSION_FALLBACK = 0.5;
/** TopstepX's product code in a contract id → the CME root, where the two
* differ. ENQ, GCE and CLE are seen on real fills; the rest follow the same
* naming, and contracts the client resolves itself override them. */
const PRODUCT_ROOTS: Readonly<Record<string, string>> = {
ENQ: 'NQ', EP: 'ES', GCE: 'GC', SIE: 'SI', CLE: 'CL', MCLE: 'MCL', NGE: 'NG',
USA: 'ZB', TYA: 'ZN', FVA: 'ZF', EU6: '6E', JY6: '6J', BP6: '6B',
};
/** "CON.F.US.ENQ.Z26" → "ENQ"; null for anything else. */
export function topstepxProductCode(contractId: unknown): string | null {
const parts = String(contractId ?? '').split('.');
return parts.length === 5 && parts[0] === 'CON' && parts[3] ? parts[3] : null;
}
/** The CME root a TopstepX contract id trades, or null when it's none this
* table knows. `learned` maps product codes to roots off the contracts the
* client resolved itself; the table covers the rest of the history. */
export function topstepxRoot(contractId: unknown, learned?: ReadonlyMap<string, string>): string | null {
const code = topstepxProductCode(contractId);
if (!code) return null;
const known = (root: string | undefined) => (root !== undefined && root in TOPSTEP_COMMISSION_PER_SIDE ? root : null);
return known(learned?.get(code)) ?? known(PRODUCT_ROOTS[code]) ?? known(code);
}
/** Topstep's commission on one fill of `size` contracts (one side). */
export function topstepCommission(root: string | null, size: number): number {
const perSide = (root !== null ? TOPSTEP_COMMISSION_PER_SIDE[root] : undefined) ?? TOPSTEP_COMMISSION_FALLBACK;
return Math.round(perSide * Math.abs(size || 0) * 100) / 100;
}
/** How a contract reads on the dashboard: "CON.F.US.ENQ.Z26" → "NQZ6" (the
* root where it's known, else the product code); anything else as it is. */
export function topstepxSymbol(contractId: unknown, learned?: ReadonlyMap<string, string>): string {
const parts = String(contractId ?? '').split('.');
const code = topstepxProductCode(contractId);
if (!code) return String(contractId ?? '');
const expiry = /^([FGHJKMNQUVXZ])(\d{1,2})$/.exec(parts[4] ?? '');
const root = topstepxRoot(contractId, learned) ?? code;
return expiry ? `${root}${expiry[1]}${expiry[2].slice(-1)}` : root;
}
+263 -64
View File
@@ -11,7 +11,9 @@
* - There is no cash-history / fund-transaction report. Daily P&L comes from
* POST /api/Trade/search (each half-turn carries fees; closing halves carry
* profitAndLoss), grouped into Central-time trade dates with the same 5 PM
* roll Tradovate uses. Deposits/withdrawals are invisible to the API, so
* roll Tradovate uses. Those fees are exchange and NFA only — Topstep's
* commission is added from lib/topstep-fees.ts. Deposits/withdrawals are
* invisible to the API, so
* fund transactions only ever come from whatever the local DB already holds.
* - There is no OCO order. placeOcoExit places the limit and stop separately
* and cancels the sibling the moment one fills (order events, plus a safety
@@ -20,13 +22,16 @@
* all zeros; a blown account shows up as canTrade=false → active=false.
*/
import axios from 'axios';
import axios from './http-client';
import * as signalR from '@microsoft/signalr';
import { currentTradeDateCT } from './trading-logic';
import { currentTradeDateCT, FUND_EVENT_MIN, fundReconcileStep, type FundSighting } from './trading-logic';
import { getCachedContract } from './contract-resolver';
import { signalrWebSocket } from './outbound-proxy';
import { saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions, saveEndedCycleStart } from './db';
import { saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions, saveEndedCycleStart, dropFundTransaction, recordOwnOrder } from './db';
import { getAccountConfig } from './account-type-resolver';
import { topstepxProductCode, topstepxRoot, topstepxSymbol, topstepCommission, TOPSTEP_COMMISSION_FALLBACK } from './topstep-fees';
import { recordFillTrades } from './trade-recorder';
import type { Fill } from './fill-trades';
import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth } from './platform-client';
import { OrderNotPlacedError, isOrderNotPlaced, errorMessage } from './platform-client';
@@ -70,6 +75,10 @@ const TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1000;
* minutes after its caller gave up on it. Longer than the idle timeout, so
* it only bites where that one can't. */
const REST_DEADLINE_MS = 90_000;
/** account_meta: what the day's first fund booking replaced (its date, and
* last_fund_date and ended_cycle_start as they stood), so a reversal that
* nets the day to fee noise can put them back. */
const FUND_DAY_BEFORE_META = 'fund_day_before';
/** Rejects if `promise` hasn't settled within `ms`. The underlying operation
* keeps running (callers discard whatever it belonged to); Promise.race
@@ -242,6 +251,12 @@ export class TopstepXClient implements PlatformClient {
* fetched without it (after a wipe took the fund rows) would plan the
* account as stage 1 until the next sync. */
private reconcilePendingIds = new Set<number>();
/** Accounts unbookedBalanceChange has warned about, until their books
* settle — one line per episode, not one per tick. */
private unbookedWarned = new Set<number>();
/** Unexplained balances reconcileFunds has seen but not booked yet: one
* is booked only once a later check still sees it (fundReconcileStep). */
private fundSightings = new Map<number, FundSighting>();
/** See PlatformClient: when the last good pass started reading. */
public lastSyncStartedAt = 0;
/** Accounts whose last order ended with its outcome unknown: each sync
@@ -259,10 +274,14 @@ export class TopstepXClient implements PlatformClient {
* recomputed from these on every REST sync and every live trade event. */
private todayTrades: { [accountId: number]: Map<number, TsxTrade> } = {};
/** Reported fees per trade date, per account — weights for spreading the
* off-book fee drift when reconstructing the MLL floor. */
/** Fees per trade date, per account (reported plus Topstep's commission) —
* weights for spreading the off-book fee drift when reconstructing the
* MLL floor. */
private dailyFees: { [accountId: number]: Map<string, number> } = {};
/** Product codes already warned about having no Topstep commission. */
private unknownCommissionCodes = new Set<string>();
/** Front-month contract per product symbol, plus name→contract for order
* placement (sendOrder receives the contract NAME, e.g. "NQZ5"). */
private contractByProduct = new Map<string, { contract: TsxContract; resolvedAt: number }>();
@@ -659,14 +678,13 @@ export class TopstepXClient implements PlatformClient {
// everything the hub does, just slower).
// With skipNegotiation the hub is a single websocket to a broker
// endpoint, so it follows the operator's proxy like the REST calls do.
const proxiedWs = signalrWebSocket();
const configuredWs = signalrWebSocket();
const hub = new signalR.HubConnectionBuilder()
.withUrl(USER_HUB, {
skipNegotiation: true,
transport: signalR.HttpTransportType.WebSockets,
accessTokenFactory: () => this.token ?? '',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...(proxiedWs ? { WebSocket: proxiedWs as any } : {}),
...{ WebSocket: configuredWs },
})
.withAutomaticReconnect()
.configureLogging(signalR.LogLevel.Warning)
@@ -789,21 +807,92 @@ export class TopstepXClient implements PlatformClient {
}
private recomputeRealized(accountId: number): void {
const existing = this.accountCashBalances[accountId];
this.accountCashBalances[accountId] = {
amount: existing?.amount ?? 0,
realizedPnL: this.realizedToday(accountId),
};
}
/** The current trade date's realized P&L, net of fees, off the trades
* held — read fresh, so it's 0 the moment the date rolls even before a
* sync recomputes cash.realizedPnL. */
private realizedToday(accountId: number): number {
const today = currentTradeDateCT();
const map = this.todayTrades[accountId];
let realized = 0;
if (map) {
const learned = this.learnedProductRoots();
for (const t of map.values()) {
if (t.voided) continue;
if (currentTradeDateCT(new Date(t.creationTimestamp)) !== today) continue;
realized += (t.profitAndLoss ?? 0) - (t.fees ?? 0);
realized += (t.profitAndLoss ?? 0) - this.tradeFees(t, learned);
}
}
const existing = this.accountCashBalances[accountId];
this.accountCashBalances[accountId] = {
amount: existing?.amount ?? 0,
realizedPnL: Math.round(realized * 100) / 100,
};
return Math.round(realized * 100) / 100;
}
/** What a fill cost: the exchange and NFA fees TopstepX reports plus
* Topstep's commission, which it doesn't (lib/topstep-fees.ts). */
private tradeFees(t: TsxTrade, learned: ReadonlyMap<string, string>): number {
const root = topstepxRoot(t.contractId, learned);
if (root === null) {
const code = topstepxProductCode(t.contractId) ?? String(t.contractId);
if (!this.unknownCommissionCodes.has(code)) {
this.unknownCommissionCodes.add(code);
console.warn(`[topstepx] no Topstep commission known for ${code} — charging $${TOPSTEP_COMMISSION_FALLBACK} a side`);
}
}
return (t.fees ?? 0) + topstepCommission(root, t.size);
}
/** The trades the last two days' fills make, in the trades table the
* dashboard lists (lib/trade-recorder.ts). Never fails the sync. */
private recordTrades(acc: PlatformAccount, trades: TsxTrade[], open: { contractId: string; type: number; size: number }[]): void {
try {
const learned = this.learnedProductRoots();
const byContract = new Map<string, Fill[]>();
for (const p of open) byContract.set(p.contractId, []);
for (const t of trades) {
if (t.voided || !(t.size > 0)) continue;
const at = Date.parse(t.creationTimestamp);
if (!Number.isFinite(at)) continue;
const fills = byContract.get(t.contractId) ?? [];
fills.push({
id: String(t.id),
orderId: String(t.orderId),
at,
side: t.side === SIDE_BUY ? 'buy' : 'sell',
qty: t.size,
price: t.price,
fee: this.tradeFees(t, learned),
pnl: t.profitAndLoss,
});
byContract.set(t.contractId, fills);
}
recordFillTrades('tsx', acc, [...byContract].map(([contractId, fills]) => {
const p = open.find((x) => x.contractId === contractId);
return {
symbol: topstepxSymbol(contractId, learned),
pointValue: null,
fills,
currentNet: !p ? 0 : p.type === 1 ? p.size : -p.size,
};
}));
} catch (err) {
console.error(`[topstepx] trade record failed for ${acc.name}: ${errorMessage(err)}`);
}
}
/** Product code → root, off the contracts resolved this run
* ("CON.F.US.GCE.Z26" resolved for GC → GCE is GC). */
private learnedProductRoots(): Map<string, string> {
const learned = new Map<string, string>();
for (const [root, { contract }] of this.contractByProduct) {
const code = topstepxProductCode(contract.id);
if (code) learned.set(code, root);
}
return learned;
}
// ── sync ─────────────────────────────────────────────────────────────────
@@ -851,12 +940,15 @@ export class TopstepXClient implements PlatformClient {
for (const acc of this.accountList) {
const balance = (accRes.accounts ?? []).find((a) => a.id === acc.id)?.balance ?? 0;
let accountFresh = true;
/** This pass's open positions, for the trades record. */
let openNow: { contractId: string; type: number; size: number }[] | null = null;
try {
const posRes = await this.post<{ positions: { accountId: number; contractId: string; type: number; size: number; averagePrice: number; creationTimestamp: string }[] }>(
'/api/Position/searchOpen', { accountId: acc.id });
if (superseded()) return;
const open = (posRes.positions ?? []).filter((p) => p.size > 0);
openNow = open;
if (open.length > 0) {
this.applyPosition(open[0]);
} else {
@@ -892,6 +984,7 @@ export class TopstepXClient implements PlatformClient {
map.set(t.id, t);
}
this.todayTrades[acc.id] = map;
if (openNow !== null) this.recordTrades(acc, tradeRes.trades ?? [], openNow);
} catch (err) {
accountFresh = false;
console.error(`[topstepx] trade sync failed for ${acc.name}: ${errorMessage(err)}`);
@@ -992,65 +1085,153 @@ export class TopstepXClient implements PlatformClient {
* and starts a new cycle; the very first check also books any historical
* net withdrawal in one lump (fine — the last stage repeats).
*
* $200 keeps fee noise and rounding out; the smallest real withdrawal in
* the drain flow is well above it. Skipped while a position is open (fills
* in flight) or while the trade history is known-stale.
* FUND_EVENT_MIN keeps fee noise and rounding out. An amount is booked
* only once a later check still sees it: for one pass, a fill landing
* between the balance read and the trade read looks just like a fund
* event (fundReconcileStep). Skipped while a position is open (fills in
* flight) or while the trade history is known-stale; until it books,
* unbookedBalanceChange holds the account.
*/
/** Books a withdrawal/deposit the trades can't explain. True once it has
* actually checked the books against the balance (false when it had to
* skip: a position open, no history or balance yet, no account type). */
* skip: a position open, no history or balance yet, no account type; or
* when what it saw waits on a later check to be booked). */
private reconcileFunds(account: PlatformAccount): boolean {
if (this.positions[account.id]) return false;
if (this.lastFetchErrors[account.name]) return false;
const history = this.fullDailyPnL[account.id];
if (!history) return false; // first daily-P&L fetch hasn't landed yet
const cash = this.accountCashBalances[account.id];
if (!cash) return false;
// The account's starting balance ($0 for Express, the funded size
// otherwise) is part of the balance the books must explain — without
// it a funded account books a phantom deposit for its whole size. Until
// the account type is resolved that number is unknown, so hold off
// rather than guess $0 and misfire on a funded account.
const acctType = getAccountConfig(account.id).accountType;
if (!acctType) return false;
const startBal = acctType.accountSize;
const delta = this.positions[account.id] || this.lastFetchErrors[account.name]
? null
: this.unbookedBalance(account.id);
if (delta === null) {
// A sighting is confirmed by the next check, not one after a skip.
this.fundSightings.delete(account.id);
return false;
}
const cash = this.accountCashBalances[account.id]!;
const startBal = getAccountConfig(account.id).accountType!.accountSize;
const today = currentTradeDateCT();
const tradePnL = history
.filter((d) => d.date !== today)
.reduce((s, d) => s + d.pnl, 0) + cash.realizedPnL;
const txns = this.fundTransactions[account.id] ?? [];
const funds = txns.reduce((s, f) => s + f.amount, 0);
const delta = Math.round((cash.amount - startBal - tradePnL - funds) * 100) / 100;
const existingToday = txns.find((f) => f.date === today)?.amount ?? null;
const before = this.fundDayBefore(account.id, today);
const step = fundReconcileStep({
delta,
sighting: this.fundSightings.get(account.id) ?? null,
now: Date.now(),
existingToday,
undoable: before !== null,
});
const books = Math.round((cash.amount - startBal - delta) * 100) / 100;
if (step.kind === 'unconfirmed') {
this.fundSightings.set(account.id, step.sighting);
if (step.fresh) console.log(`[topstepx] ${account.name}: balance ${cash.amount} vs books ${books} — ${delta} unexplained; booked if a later pass still sees it`);
return false;
}
this.fundSightings.delete(account.id);
// The first check on this instance (or since a wipe) only has the
// fetch window to explain the balance with: a lump it books is unseen
// P&L and past payouts, not the end of a cycle this instance watched.
const firstCheck = loadAccountMeta(account.id, 'funds_reconciled') === null;
if (firstCheck) saveAccountMeta(account.id, 'funds_reconciled', today);
if (Math.abs(delta) < 200) return true;
if (step.kind === 'settled') return true;
console.log(`[topstepx] ${account.name}: balance ${cash.amount} vs books ${Math.round((tradePnL + funds) * 100) / 100} — recording ${delta < 0 ? 'withdrawal' : 'deposit'} of ${delta}`);
// A new cycle starts today: note where the one it ends began, by
// applyCycleSlice's rule for that date's own row, for the reporter's
// catch-up. A second event today doesn't start another cycle.
const prevFundDate = this.lastFundDates[account.id] ?? null;
if (!firstCheck && prevFundDate !== today) {
const prevAmt = prevFundDate ? (txns.find((f) => f.date === prevFundDate)?.amount ?? null) : null;
saveEndedCycleStart(account.id, { fundDate: prevFundDate ?? '', includesDay: !(prevAmt !== null && prevAmt < 0) });
if (step.kind === 'drop') {
// A transient booked today and then reversed: the cycle it started
// never happened, so the markers go back to what it replaced.
console.log(`[topstepx] ${account.name}: balance ${cash.amount} vs books ${books} — today's fund bookings net to ${step.net}, a transient and its reversal; taking today's row back`);
dropFundTransaction(account.id, today, {
last_fund_date: before!.lastFundDate,
ended_cycle_start: before!.endedCycleStart,
[FUND_DAY_BEFORE_META]: null,
});
this.lastFundDates[account.id] = before!.lastFundDate;
} else {
console.log(`[topstepx] ${account.name}: balance ${cash.amount} vs books ${books} — recording ${delta < 0 ? 'withdrawal' : 'deposit'} of ${delta}`);
// The day's first booking: keep what it replaces.
if (existingToday === null) {
saveAccountMeta(account.id, FUND_DAY_BEFORE_META, JSON.stringify({
date: today,
lastFundDate: loadAccountMeta(account.id, 'last_fund_date'),
endedCycleStart: loadAccountMeta(account.id, 'ended_cycle_start'),
}));
}
// A new cycle starts today: note where the one it ends began, by
// applyCycleSlice's rule for that date's own row, for the reporter's
// catch-up. A second event today doesn't start another cycle.
const prevFundDate = this.lastFundDates[account.id] ?? null;
if (!firstCheck && prevFundDate !== today) {
const prevAmt = prevFundDate ? (txns.find((f) => f.date === prevFundDate)?.amount ?? null) : null;
saveEndedCycleStart(account.id, { fundDate: prevFundDate ?? '', includesDay: !(prevAmt !== null && prevAmt < 0) });
}
// Same-day events merge into one row (the table keys on
// account+date): step.amount is today's delta on top of whatever
// is already booked for today.
saveFundTransactions(account.id, account.name, [{ date: today, amount: step.amount }]);
saveAccountMeta(account.id, 'last_fund_date', today);
this.lastFundDates[account.id] = today;
}
// Same-day events merge into one row (the table keys on account+date),
// so add today's delta onto whatever is already booked for today.
const existingToday = txns.find((f) => f.date === today)?.amount ?? 0;
saveFundTransactions(account.id, account.name, [
{ date: today, amount: Math.round((existingToday + delta) * 100) / 100 },
]);
saveAccountMeta(account.id, 'last_fund_date', today);
this.fundTransactions[account.id] = loadFundTransactions(account.id);
this.lastFundDates[account.id] = today;
this.applyCycleSlice(account.id);
return true;
}
/** What today's first fund booking replaced — null when today has none
* that reconcileFunds made (none yet, or a row it didn't book). */
private fundDayBefore(accountId: number, today: string): { lastFundDate: string | null; endedCycleStart: string | null } | null {
const raw = loadAccountMeta(accountId, FUND_DAY_BEFORE_META);
if (raw === null) return null;
try {
const saved = JSON.parse(raw) as { date?: unknown; lastFundDate?: unknown; endedCycleStart?: unknown };
const strOrNull = (v: unknown) => v === null || typeof v === 'string';
if (saved.date !== today || !strOrNull(saved.lastFundDate) || !strOrNull(saved.endedCycleStart)) return null;
return { lastFundDate: saved.lastFundDate as string | null, endedCycleStart: saved.endedCycleStart as string | null };
} catch {
return null;
}
}
/**
* Balance the books don't explain: balance less the starting balance
* ($0 for Express, the funded size otherwise), all trade P&L (today's off
* the trades held) and all recorded fund transactions. null until the
* history, the balance and the account type are all known — without the
* type the starting balance is unknown, and guessing $0 would book a
* funded account's whole size as a phantom deposit.
*/
private unbookedBalance(accountId: number): number | null {
const history = this.fullDailyPnL[accountId];
const cash = this.accountCashBalances[accountId];
const acctType = getAccountConfig(accountId).accountType;
if (!history || !cash || !acctType) return null;
const today = currentTradeDateCT();
const tradePnL = history
.filter((d) => d.date !== today)
.reduce((s, d) => s + d.pnl, 0) + this.realizedToday(accountId);
const funds = (this.fundTransactions[accountId] ?? []).reduce((s, f) => s + f.amount, 0);
return Math.round((cash.amount - acctType.accountSize - tradePnL - funds) * 100) / 100;
}
/**
* See PlatformClient. The hub moves the balance the moment a payout
* lands, but reconcileFunds books it only a sync or two later — and not
* while a position is open. Planned on in between, the payout reads as
* lost profit: on 2026-10-06 two Express accounts that had finished
* their cycle re-planned as stage 1, fired a full 3×NQ leg 28 seconds
* after their payouts instead of a drain day, and were liquidated
* against the $0 floor a payout leaves. Not checked with a position open
* (its fills are still landing, and the account can't trade anyway).
*/
public unbookedBalanceChange(accountId: number): number | null {
const delta = this.positions[accountId] ? null : this.unbookedBalance(accountId);
if (delta === null || Math.abs(delta) < FUND_EVENT_MIN) {
this.unbookedWarned.delete(accountId);
return null;
}
if (!this.unbookedWarned.has(accountId)) {
this.unbookedWarned.add(accountId);
const name = this.accountList.find((a) => a.id === accountId)?.name ?? accountId;
console.warn(`[topstepx] ${name}: balance is $${delta} off its books — held out of trading until a sync books it`);
}
return delta;
}
/**
* Reverse-engineer the account's MLL floor — TopstepX never reports it.
*
@@ -1061,9 +1242,10 @@ export class TopstepXClient implements PlatformClient {
* floor = min(0, eodBalanceHigh − trailingDrawdown)
*
* The EOD balance curve is rebuilt from daily P&L + recorded fund events.
* Balance the books don't explain (sub-threshold off-book fees) is spread
* across days proportionally to each day's reported fees — validated
* against a platform-reported MLL to within $0.11.
* Balance the books don't explain (sub-threshold off-book fees — mostly
* Topstep's commission, until the books carried it) is spread across days
* proportionally to each day's fees — validated against a
* platform-reported MLL to within $0.11.
*
* Stored in autoLiqThresholds (negative, or +$0.01 once locked at
* breakeven — 0 must keep meaning "no floor"), which lights up the same
@@ -1116,7 +1298,7 @@ export class TopstepXClient implements PlatformClient {
const residual = Math.round((cash.amount - startBal - cash.realizedPnL - todayFund - booked) * 100) / 100;
// Books not settled (an unabsorbed fill or an unbooked fund event) —
// keep the previous floor rather than skewing the curve with it.
if (Math.abs(residual) >= 200) return;
if (Math.abs(residual) >= FUND_EVENT_MIN) return;
const totalFees = dates.reduce((s, d) => s + (fees.get(d) ?? 0), 0);
let bal = startBal;
@@ -1251,11 +1433,13 @@ export class TopstepXClient implements PlatformClient {
this.lastFetchRaw[account.name] = trades.length > 0 ? JSON.stringify(trades[0]) : '(no trades)';
const dailyMap: { [date: string]: { pnl: number; fees: number } } = {};
const learned = this.learnedProductRoots();
for (const t of trades) {
const date = currentTradeDateCT(new Date(t.creationTimestamp));
const entry = (dailyMap[date] ??= { pnl: 0, fees: 0 });
entry.pnl += (t.profitAndLoss ?? 0) - (t.fees ?? 0);
entry.fees += t.fees ?? 0;
const fees = this.tradeFees(t, learned);
entry.pnl += (t.profitAndLoss ?? 0) - fees;
entry.fees += fees;
}
const fresh = Object.entries(dailyMap)
@@ -1268,8 +1452,8 @@ export class TopstepXClient implements PlatformClient {
const merged = mergePnL(cached, fresh);
// Reported fees per day — the weights the MLL reconstruction
// uses to place off-book fee drift. Fresh overwrites cached.
// Fees per day — the weights the MLL reconstruction uses to
// place off-book fee drift. Fresh overwrites cached.
const fees = new Map<string, number>();
for (const e of cached) fees.set(e.date, e.fees ?? 0);
for (const e of fresh) fees.set(e.date, e.fees);
@@ -1340,6 +1524,7 @@ export class TopstepXClient implements PlatformClient {
public forgetHistory(accountId: number, accountName: string): void {
this.historyLoadedIds.delete(accountId);
this.reconcilePendingIds.delete(accountId);
this.fundSightings.delete(accountId);
delete this.dailyPnL[accountId];
delete this.fullDailyPnL[accountId];
delete this.daysTraded[accountId];
@@ -1461,9 +1646,11 @@ export class TopstepXClient implements PlatformClient {
trailPrice: null,
customTag: null,
});
this.noteOwnOrder(accountId, res.orderId, type);
return res.orderId;
} catch (err) {
if (err instanceof GatewayRefusedError) {
if (err.orderId !== undefined) this.noteOwnOrder(accountId, err.orderId, type);
const ambiguous = err.orderId !== undefined
|| (err.errorCode !== undefined && AMBIGUOUS_PLACE_ERRORS.has(err.errorCode));
if (!ambiguous) throw new OrderNotPlacedError(`TopstepX refused the order: ${err.message}`);
@@ -1473,6 +1660,7 @@ export class TopstepXClient implements PlatformClient {
const status = response?.status;
// Same rule as a refusal: an answer that names an order means it exists.
const namesOrder = typeof response?.data?.orderId === 'number' && response.data.orderId > 0;
if (namesOrder) this.noteOwnOrder(accountId, response?.data?.orderId, type);
if (typeof status === 'number' && REFUSED_BEFORE_HANDLER.has(status) && !namesOrder) {
throw new OrderNotPlacedError(`TopstepX rejected the order request: ${errorMessage(err)}`);
}
@@ -1480,6 +1668,17 @@ export class TopstepXClient implements PlatformClient {
}
}
/** Notes an order as the bot's, so the trade its fill opens or closes is
* credited to the bot (lib/trade-recorder.ts). */
private noteOwnOrder(accountId: number, orderId: unknown, type: number): void {
if (typeof orderId !== 'number' || !(orderId > 0)) return;
try {
recordOwnOrder(accountId, orderId, type === TYPE_STOP ? 'stop' : type === TYPE_LIMIT ? 'limit' : 'market');
} catch (err) {
console.error(`[topstepx] could not note order ${orderId} as the bot's: ${errorMessage(err)}`);
}
}
/** See PlatformClient: an account whose last order's outcome is unknown
* counts as holding a working order until a sync's Order/searchOpen shows
* none. */
+8 -7
View File
@@ -5,16 +5,18 @@
* Open positions come off the client's position map, each contract named by
* the instrument it is: a DXtrade position carries its symbol; a futures one
* is looked up among the contracts this machine has entered (recorded at the
* fill, kept across restarts), then the resolver's cache and TopstepX's own.
* fill, kept across restarts), TopstepX's own, and every contract the
* resolver has resolved in this process (never expiring).
* Entries are counted as they fill and kept in a setting per trade date, so
* a restart doesn't lose the day.
*/
import { getSetting, setSetting } from './db';
import { currentTradeDateCT } from './trading-logic';
import { getAllCachedContracts } from './contract-resolver';
import { rootOfContract } from './contract-resolver';
import { errorMessage } from './platform-client';
import type { InstrumentCount, TradeMix } from './master-protocol';
import { isNotionalPlatform } from './master-protocol';
const TODAY_KEY = 'trade_mix_today';
const NAMES_KEY = 'trade_mix_contracts';
@@ -71,16 +73,15 @@ export function tradeMixFor(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any,
): TradeMix {
// Recorded at the fill, then TopstepX's own contracts, then every
// contract this process resolved (Tradovate's, however long ago).
const names = new Map<string, string>();
for (const [root, contract] of Object.entries(getAllCachedContracts())) {
if (contract) names.set(String(contract.id), root);
}
const own = typeof client.contractRoots === 'function' ? (client.contractRoots() as Map<string, string>) : null;
for (const [id, root] of own ?? []) names.set(id, root);
for (const [id, instrument] of Object.entries(readJson<Record<string, string>>(NAMES_KEY) ?? {})) names.set(id, instrument);
const instrumentOf = (contractId: unknown): string => (firm.platform === 'dxtrade'
const instrumentOf = (contractId: unknown): string => (isNotionalPlatform(firm.platform)
? String(contractId).split('|')[0]
: names.get(String(contractId)) ?? `#${String(contractId)}`);
: names.get(String(contractId)) ?? rootOfContract(contractId as number | string) ?? `#${String(contractId)}`);
const open = new Map<string, InstrumentCount>();
for (const acc of (client.accountList ?? []) as { id: number }[]) {
+101
View File
@@ -0,0 +1,101 @@
import { byFillTime, fillsToTrades, startingNet, type Fill, type CarriedTrade } from './fill-trades';
import {
loadOwnOrders, ownOrdersSince, loadFillTrades, saveFillTrades, deleteTrades, closeTradesUnseen,
type FillTradeRow,
} from './db';
/**
* Records the trades a futures platform's fills make (lib/fill-trades.ts) in
* the trades table the dashboard lists, with who opened and closed each: the
* bot when the fill's order is one it placed (own_orders), and how — its
* stop, its take-profit, or a market close.
*
* Each pass hands over a window of fills (TopstepX: two days; Tradovate: the
* session's) with the account's position now, and the window's trades are
* rebuilt whole. A history and a position read a moment apart can disagree —
* a fill landing between the two calls — and that pass would rebuild the
* wrong trades, so a contract is written only when the position before its
* window comes out the same two passes running.
*/
export interface ContractFills {
/** As shown: "NQZ6". */
symbol: string;
/** Dollars per point per contract, for P&L the platform didn't give. */
pointValue: number | null;
fills: Fill[];
/** Signed contracts held now. */
currentNet: number;
}
/** The position before each window, last pass: prefix:account:symbol → net. */
const lastStart = new Map<string, number>();
export function recordFillTrades(prefix: string, account: { id: number; name: string }, contracts: ContractFills[]): void {
let own: Map<string, string> | null = null;
for (const c of contracts) {
const fills = [...c.fills].sort(byFillTime);
const startNet = startingNet(fills, c.currentNet);
const key = `${prefix}:${account.id}:${c.symbol}`;
const steady = lastStart.get(key) === startNet;
lastStart.set(key, startNet);
if (!steady) continue;
const stored = loadFillTrades(account.id, prefix, c.symbol);
const isOpen = (t: FillTradeRow) => t.closedAt === null && t.closeReason === null;
const windowStart = fills.length > 0 ? fills[0].at : Infinity;
if (fills.length === 0) {
// Nothing in the window: an open trade stays open while the
// position does, and is closed out of sight once it's gone.
if (startNet === 0) closeTradesUnseen(stored.filter(isOpen).map((t) => t.positionId));
continue;
}
const carryRow = stored
.filter((t) => isOpen(t) && t.openedAt < windowStart)
.sort((a, b) => b.openedAt - a.openedAt)[0];
const carry: CarriedTrade | null = carryRow
? { id: carryRow.positionId, side: carryRow.side, quantity: carryRow.quantity, openPrice: carryRow.openPrice, fee: carryRow.fee, openedAt: carryRow.openedAt }
: null;
const trades = fillsToTrades(fills, { startNet, pointValue: c.pointValue, carry });
own ??= loadOwnOrders(account.id);
const since = ownOrdersSince();
const rows: FillTradeRow[] = trades.map((t) => {
const closeKind = t.closeOrderId === null ? undefined : own!.get(t.closeOrderId);
const closed = t.closedAt !== null;
return {
positionId: t.carried ? t.id : `${prefix}:${t.id}`,
accountId: account.id,
accountName: account.name,
symbol: c.symbol,
side: t.side,
quantity: t.quantity,
openPrice: t.openPrice,
closePrice: t.closePrice,
pnl: t.pnl,
fee: t.fee,
openedAt: t.openedAt,
closedAt: t.closedAt,
closeReason: !closed ? null : closeKind === 'stop' ? 'stop_loss' : closeKind === 'limit' ? 'take_profit' : null,
openedBy: t.carried ? null
: t.openOrderId !== null && own!.has(t.openOrderId) ? 'bot'
: t.openedAt >= since ? 'outside' : 'unknown',
// A trade opened before own orders were noted had its exits
// placed then too: whose close it was is unknown, not outside.
closedBy: !closed || closeKind === 'stop' || closeKind === 'limit' ? null
: closeKind === 'market' ? 'bot'
: t.openedAt >= since ? 'outside' : 'unknown',
};
});
saveFillTrades(rows);
const ids = new Set(rows.map((r) => r.positionId));
// Built last time off a window that has since moved under it.
deleteTrades(stored.filter((t) => t.openedAt >= windowStart && !ids.has(t.positionId)).map((t) => t.positionId));
// Open from before the window, and the window doesn't carry it on:
// it closed somewhere the history no longer reaches.
closeTradesUnseen(stored.filter((t) => isOpen(t) && t.openedAt < windowStart && !ids.has(t.positionId)).map((t) => t.positionId));
}
}
+567 -48
View File
@@ -16,6 +16,8 @@ export interface WithdrawalStage {
* fields fall back to the account type's defaults. */
withdrawalPct?: number;
payoutCap?: number;
/** This stage's hard daily loss limit in dollars; absent = the type's. */
dailyLossLimit?: number;
}
export interface EffectiveConfig {
@@ -24,6 +26,30 @@ export interface EffectiveConfig {
minTradingDays: number;
minDayPnL: number;
drain: boolean;
/** The stage's hard daily loss limit (0 = none): its own, else the type's. */
dailyLossLimit: number;
}
/** What picks a stage beyond the fund rows: the type's daily loss limit (a
* stage may override it), and for a type whose stages reset each day, how
* many payouts the account has made since the firm's last daily reset. */
export interface StageInputs {
dailyLossLimit?: number;
stagesSameDay?: boolean;
sameDayWithdrawals?: number;
/** 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;
}
/**
@@ -47,20 +73,26 @@ export function resolveEffectiveConfig(
priorProfit: number,
fundTransactions: { date: string; amount: number }[],
minDayPnL = 0,
inputs: StageInputs = {},
): EffectiveConfig {
const dailyLossLimit = Math.max(0, inputs.dailyLossLimit ?? 0);
if (targetSameEquity) {
// Simple Mode A: effective target = profitTarget - equityProfit, computed in computeDailyTarget.
// Since computeDailyTarget already compares (amount - accountSize) against profitTarget,
// no adjustment needed here — just return the base values.
return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false };
return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false, dailyLossLimit };
}
if (withdrawalStages.length > 0) {
const withdrawalCount = fundTransactions.filter((f) => f.amount < 0).length;
// Same-day stages (Vest): only today's payouts count, so every new
// day starts back at stage 1 — the room a payout frees lasts only
// until the firm's next daily reset.
const withdrawalCount = stagePayouts(fundTransactions, inputs);
if (withdrawalCount === 0) {
return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false }; // Stage 1 = base values
return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false, dailyLossLimit }; // Stage 1 = base values
}
const idx = Math.min(withdrawalCount - 1, withdrawalStages.length - 1);
const stage = withdrawalStages[idx];
const stageLimit = typeof stage.dailyLossLimit === 'number' && stage.dailyLossLimit >= 0 ? stage.dailyLossLimit : dailyLossLimit;
if (stage.drain === true) {
return {
profitTarget: 0,
@@ -73,12 +105,16 @@ export function resolveEffectiveConfig(
// qualifying bar sits at the grid floor — every draw counts.
minDayPnL: DRAIN_DAY_MIN,
drain: true,
dailyLossLimit: stageLimit,
};
}
// Fall back to base minTradingDays when the stage doesn't specify one (0 or missing)
return { profitTarget: stage.profit, consistency: stage.consistency, minTradingDays: stage.minTradingDays || minTradingDays, minDayPnL, drain: false };
return {
profitTarget: stage.profit, consistency: stage.consistency, minTradingDays: stage.minTradingDays || minTradingDays,
minDayPnL, drain: false, dailyLossLimit: stageLimit,
};
}
return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false };
return { profitTarget, consistency, minTradingDays, minDayPnL, drain: false, dailyLossLimit };
}
/** @deprecated Use resolveEffectiveConfig instead */
@@ -140,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
@@ -417,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;
@@ -438,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;
@@ -448,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
@@ -503,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
@@ -525,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,
@@ -554,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);
@@ -563,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;
@@ -576,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
@@ -592,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,
@@ -609,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;
}
@@ -622,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,
@@ -632,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 });
}
@@ -647,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(
@@ -679,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();
@@ -696,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;
}
/**
@@ -709,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;
@@ -739,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);
@@ -747,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
@@ -774,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). */
@@ -794,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;
@@ -802,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);
@@ -827,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.
@@ -841,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.
*
@@ -1064,6 +1267,117 @@ export function computeDayPlan(
};
}
// ── payouts not yet booked ───────────────────────────────────────────────────
/** A drop at least this far below a finished cycle's books holds the account. */
export const PAYOUT_HOLD_MIN = 200;
/**
* Where a finished cycle (day target 0) left the books: its fund rows, and
* day-start equity less the P&L booked by then. Until a fund row is added,
* booked P&L is all that may move day-start equity — relative, so history
* missing from before the cache (or an initial deposit row some platforms
* book and others don't) cancels out.
*/
export interface FinishedCycleMark { funds: string; offset: number }
/** Fund rows as one comparable value: a second payout on a date that already
* has one changes the amount, not the row count. */
export function fundRowsKey(txns: { date: string; amount: number }[]): string {
return txns.map((f) => `${f.date}:${f.amount}`).join('|');
}
export function markFinishedCycle(funds: string, dayStartEquity: number, bookedPnL: number): FinishedCycleMark {
return { funds, offset: round2(dayStartEquity - bookedPnL) };
}
/**
* How far day-start equity has fallen below a finished cycle's books with no
* fund row booked since — a payout the platform hasn't booked yet. null when
* there's no mark, a fund row has moved on from it, or the drop is fee noise.
* Planned on, that drop reads as lost profit: the finished cycle reopens as
* the same stage and re-earns the payout at full size, instead of moving to
* the next stage (a drain day) once the payout books.
*/
export function unbookedPayoutDrop(
mark: FinishedCycleMark | null,
funds: string,
dayStartEquity: number,
bookedPnL: number,
): number | null {
if (!mark || mark.funds !== funds) return null;
const drop = round2(mark.offset - (dayStartEquity - bookedPnL));
return drop >= PAYOUT_HOLD_MIN ? drop : null;
}
// ── fund events from the balance (TopstepX) ──────────────────────────────────
/** Balance the books don't explain past this is a fund event (TopstepX's
* reconcileFunds books it); under it, fee noise and rounding. The smallest
* real withdrawal in the drain flow is well above it. */
export const FUND_EVENT_MIN = 200;
/** A sighting is booked only once a later pass, at least this much later,
* sees the same amount... */
export const FUND_CONFIRM_MS = 30_000;
/** ...to within this (fee noise between the two passes). */
export const FUND_CONFIRM_TOLERANCE = 5;
/** An unexplained balance seen, not yet booked: the amount and when it was
* first seen at about that amount. */
export interface FundSighting { delta: number; since: number }
export type FundStep =
/** Under FUND_EVENT_MIN: nothing to book. */
| { kind: 'settled' }
/** Not yet seen twice far enough apart. `sighting` is what to keep for
* the next pass; `fresh` when it is new (or the amount moved). */
| { kind: 'unconfirmed'; sighting: FundSighting; fresh: boolean }
/** Book today's row as `amount` (today's existing row merged in). */
| { kind: 'book'; amount: number }
/** Today's bookings net under FUND_EVENT_MIN: a transient and its
* reversal. Take today's row back and leave `net` unbooked. */
| { kind: 'drop'; net: number };
/**
* One fresh pass's decision on the balance the books don't explain.
*
* The pass reads the balance first and the positions and trades after it, so
* a fill landing in between shows in the trades but not yet in the balance
* (or, the other way, in the balance before its trade row). For one pass the
* books are off by the whole fill. On 2026-10-05 two Express accounts booked a
* $3,001.16 "deposit" that way, and the next pass booked the $3,002.16
* "withdrawal" that undid it. The two merged into a −$1.00 row, the
* commission drift of the day. A negative row is a withdrawal: it started a
* new cycle, dropped the day's −$3,003.32 out of it and moved both accounts
* to their drain stage. A real fund event stays put, so an amount is booked
* only when a pass at least FUND_CONFIRM_MS after the first sighting still
* sees it.
*
* `undoable`: today's row was booked by this reconcile alone, with what it
* replaced saved (the first booking of a day saves it), so it can be taken
* back whole. A transient that outlasts the confirmation and is then
* reversed nets under FUND_EVENT_MIN, and the day is dropped, not left as a
* tiny row.
*/
export function fundReconcileStep(opts: {
delta: number;
sighting: FundSighting | null;
now: number;
/** Today's fund row, null when there is none. */
existingToday: number | null;
undoable: boolean;
}): FundStep {
const { delta, sighting, now, existingToday } = opts;
if (Math.abs(delta) < FUND_EVENT_MIN) return { kind: 'settled' };
if (sighting === null || Math.abs(sighting.delta - delta) > FUND_CONFIRM_TOLERANCE) {
return { kind: 'unconfirmed', sighting: { delta, since: now }, fresh: true };
}
if (now - sighting.since < FUND_CONFIRM_MS) return { kind: 'unconfirmed', sighting, fresh: false };
const amount = round2((existingToday ?? 0) + delta);
if (existingToday !== null && opts.undoable && Math.abs(amount) < FUND_EVENT_MIN) return { kind: 'drop', net: amount };
return { kind: 'book', amount };
}
// ── fees ─────────────────────────────────────────────────────────────────────
/** An entry's fee when the balance update hasn't shown it yet — at least one
@@ -1085,11 +1399,28 @@ export function percentFee(pct: number, price: number, pointValue: number, contr
return Math.ceil(raw * 100 - 1e-9) / 100;
}
/**
* A price on the tick grid, free of float fuzz. 4217.6 − 87 × 0.1 is
* 4208.900000000001 in floating point, and TopstepX refuses a price off the
* grid ("Invalid price. Price is outside allowed range") — a GC short rode
* with no take-profit on 2026-10-02. An averaged multi-contract fill can sit
* between ticks too (by 1/contracts of a tick or more), so an off-grid price
* goes `mode`'s way; fuzz within a millionth of a tick is never pushed a
* whole tick.
*/
export function onTick(price: number, tickSize: number, mode: 'up' | 'down'): number {
if (!(tickSize > 0)) return price;
const n = price / tickSize;
const k = mode === 'up' ? Math.ceil(n - 1e-6) : Math.floor(n + 1e-6);
return Math.round(k * tickSize * 1e10) / 1e10;
}
/**
* Where to put the take-profit so the leg nets at least `leg` after fees.
*
* Measured-fee firms (commissionPct 0): the exit fee is taken to equal the
* entry fee — the same formula the fire path always used, reproduced exactly.
* entry fee — the same formula the fire path always used, reproduced exactly,
* then put on the tick grid away from the entry (onTick).
*
* Percent-fee firms: the exit fee depends on the exit price itself, so solve
* for it. With r = pct/100 and k = pointValue × contracts, a long nets
@@ -1116,7 +1447,7 @@ export function takeProfitPlan(o: {
const grossTarget = o.leg + totalCommission;
const ticks = Math.ceil(grossTarget / k / o.tickSize);
return {
exitPrice: o.entry + dir * ticks * o.tickSize,
exitPrice: onTick(o.entry + dir * ticks * o.tickSize, o.tickSize, o.side === 'Buy' ? 'up' : 'down'),
ticks,
entryFee: o.entryCommission,
exitFee: o.entryCommission,
@@ -1174,18 +1505,119 @@ export function obfuscationStopDollars(
* that fills worse than its price (gaps, thin weekend books). */
export const DLL_RESERVE_FRACTION = 0.10;
/** …or this share of the position's notional, when that is more: a stop's
* slippage grows with the position, a flat reserve doesn't. */
* slippage grows with the position, a flat reserve doesn't. A platform whose
* stops are known to fill closer states its own (stopSlippageFraction). */
export const DLL_SLIPPAGE_FRACTION = 0.001;
/** The part of the day's room a stop leaves unspent. */
export function dllReserve(dailyLossLimit: number, notional = 0): number {
return Math.max(DLL_RESERVE_FRACTION * dailyLossLimit, DLL_SLIPPAGE_FRACTION * Math.max(0, notional));
export function dllReserve(dailyLossLimit: number, notional = 0, slippageFraction = DLL_SLIPPAGE_FRACTION): number {
const share = Number.isFinite(slippageFraction) && slippageFraction >= 0 ? slippageFraction : DLL_SLIPPAGE_FRACTION;
return Math.max(DLL_RESERVE_FRACTION * dailyLossLimit, share * Math.max(0, notional));
}
/** Smallest daily-loss stop worth trading. Under a fair walk a leg of L with a
* stop of S wins S/(S + L) of the time — about 9% for $50 against $500 — so
* anything tighter just spends the day's room on fees. */
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 —
* 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; 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 } : {}),
};
}
/** A leg is given this many of its target holds to be done before a daily
* reset counts its open loss against the next day: under a fair walk, a leg
* whose take-profit and stop are within 2:1 is still open after four holds
* about 1–1.5% of the time, and the reset guard fits those. */
export const RESET_CLEAR_HOLDS = 4;
/** The shortest take-profit the plan's next leg may have — what
* resetClearStop is held to: a split day can carve a leg as small as
* MIN_LEG (pickLegAmount), else the leg is all that remains. */
export function shortestNextLeg(plan: { remainingToday: number; splitAllowed: boolean }): number {
return plan.splitAllowed ? Math.min(plan.remainingToday, MIN_LEG) : plan.remainingToday;
}
/**
* The widest stop a leg whose take-profit is `leg` away may carry and still
* be all but surely closed by a daily reset `resetInMs` away; 0 when the
* reset is under RESET_CLEAR_HOLDS holds off. The walk's exit time has a
* tail that decays as e^(−π²·t/(2g·hold)), g = (A+B)²/(A·B): a lopsided leg
* (a $5,000 stop on a $500 take-profit) lingers far longer than its hold. So
* the leg may be as lopsided as k = holds-to-reset ÷ RESET_CLEAR_HOLDS times
* the 2:1 shape (g = 4.5) allows — g ≤ 4.5k — which keeps its chance of
* still being open at the reset what a 2:1 leg's is at four holds.
*/
export function resetClearStop(o: { leg: number; resetInMs: number; holdHours: number }): number {
if (!(o.leg > 0) || !(o.holdHours > 0) || !Number.isFinite(o.resetInMs)) return 0;
const k = o.resetInMs / (RESET_CLEAR_HOLDS * o.holdHours * 3_600_000);
if (k < 1) return 0;
const c = 4.5 * k - 2;
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
* transfers): what's left of the stage's limit after the day's losses, and
* never more than the floor leaves. The firm's floor alone isn't enough — a
* limit set below the firm's (Vest's 3% is $1,500; the bot's day stops at
* $1,229) would let a day that already lost $1,111 trade the $389 Vest still
* allows. Infinity when there is no limit.
*
* `gain` widens it: on a split day the next leg may give back what the
* earlier legs made, plus the limit — a $1,400 day up $2,604 gets $4,004 —
* as Vest's own floor does. The client says how much (dayGainForRoom): the
* gain still in the account, no more than keeps the leg all but sure to be
* closed by the reset (resetClearStop) — a leg still open there has all of
* its loss counted against the next day. Never more than the day's realized
* gain either way.
*/
export function floorRoom(o: {
dailyLossLimit: number;
closedBalance: number;
floor: number;
/** The day's realized P&L since its base (Vest: since this client's
* latest claim today, else since the reset). */
realizedToday: number;
/** What of the day's gain the room may grow by; 0 (default) = none. */
gain?: number;
}): number {
if (!(o.dailyLossLimit > 0)) return Infinity;
const widen = Math.min(Math.max(0, o.realizedToday), Math.max(0, o.gain ?? 0));
return round2(Math.min(o.dailyLossLimit + Math.min(0, o.realizedToday) + widen, o.closedBalance - o.floor));
}
/**
* How much the firm's day may still lose before a hard daily-loss breach.
* Infinity when the account type has no limit.
@@ -1223,10 +1655,10 @@ export function dllRoom(o: {
* reset has its entry fee on the old day's books, and the swap takes no more
* than that slot on the new day's.
*/
export function dllStopDollars(o: { room: number; dailyLossLimit: number; entryFee: number; commissionPct: number; notional?: number }): number {
export function dllStopDollars(o: { room: number; dailyLossLimit: number; entryFee: number; commissionPct: number; notional?: number; slippageFraction?: number }): number {
if (!Number.isFinite(o.room)) return Infinity;
const r = Math.max(0, o.commissionPct) / 100;
return (o.room - dllReserve(o.dailyLossLimit, o.notional) - 2 * o.entryFee - 0.01) / (1 + r);
return (o.room - dllReserve(o.dailyLossLimit, o.notional, o.slippageFraction) - 2 * o.entryFee - 0.01) / (1 + r);
}
/**
@@ -1258,7 +1690,9 @@ export const LAST_STAND_FRACTION = 0.15;
*/
export function lastStand(o: { dllRoom: number; dailyLossLimit: number; maxLossRoom: number | null }): boolean {
if (o.maxLossRoom === null || !(o.dailyLossLimit > 0) || !Number.isFinite(o.dllRoom) || !(o.dllRoom > 0)) return false;
return o.maxLossRoom - o.dllRoom <= LAST_STAND_FRACTION * o.dllRoom;
// A full day's loss is the limit, however much a green day widened the
// room (floorRoom): a stop-out leaving $300 over the floor keeps a day.
return o.maxLossRoom - o.dllRoom <= LAST_STAND_FRACTION * Math.min(o.dllRoom, o.dailyLossLimit);
}
/** On a last stand: how far price can move against the leg before the firm's
@@ -1301,10 +1735,34 @@ 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;
}
/**
* The side every new entry on this machine must take, so nothing it opens
* hedges the person against themselves: the side of every position the
* machine's accounts hold, in any lane (another firm's, a hand trade's), else
* the side another lane's batch is placing right now. 'mixed' = it holds both
* sides already (hand trades, or from before this rule), so no side can be
* joined without hedging one of them. null = free to choose.
*
* The side, not the instrument: NQ futures against an NDX perp is the same
* bet, and ES or a crypto index close enough to it that one side for
* everything is the only rule that never hedges.
*/
export function machineSide(netPositions: number[], reserved: 'Buy' | 'Sell' | null): 'Buy' | 'Sell' | 'mixed' | null {
const long = netPositions.some((n) => n > 0) || reserved === 'Buy';
const short = netPositions.some((n) => n < 0) || reserved === 'Sell';
if (long && short) return 'mixed';
return long ? 'Buy' : short ? 'Sell' : null;
}
// ── position size ────────────────────────────────────────────────────────────
@@ -1312,6 +1770,44 @@ export function botStopsActive(cfg: { firstDayStop: boolean; minDayStop: boolean
/** The hold a leg is sized for when the firm doesn't set one. */
export const DEFAULT_TARGET_HOLD_HOURS = 12;
/** How far one person's DXtrade sizes move off the plan, each way. */
export const PERSON_SIZE_SPREAD = 0.15;
/**
* One person's size factor for a trade date, in [1 − spread, 1 + spread].
* Seeded (FNV-1a) by the person, so all of one person's accounts size alike
* while another person's land elsewhere, and drawn afresh each trade date.
* FNV alone barely moves its top bits when only the key's last character
* changes (the next day's date), so the hash is mixed (murmur3's finalizer)
* before it is scaled.
*/
export function personSizeFactor(personKey: string, tradeDate: string): number {
const key = `size|${personKey}|${tradeDate}`;
let hash = 0x811c9dc5;
for (let i = 0; i < key.length; i++) {
hash ^= key.charCodeAt(i);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
hash ^= hash >>> 16;
hash = Math.imul(hash, 0x85ebca6b);
hash ^= hash >>> 13;
hash = Math.imul(hash, 0xc2b2ae35);
hash = (hash ^ (hash >>> 16)) >>> 0;
return 1 - PERSON_SIZE_SPREAD + (hash / 0x100000000) * 2 * PERSON_SIZE_SPREAD;
}
/**
* The hold a person's DXtrade leg is sized for: the firm's target, moved so
* the size comes out personSizeFactor times the plan. Size goes as 1/√hold
* (sizeNotional), so hold / factor² does it, and every rule that ranks or
* checks against the hold sees the same number the size was made for.
*/
export function personHoldHours(targetHoldHours: number, personKey: string, tradeDate: string): number {
const hold = targetHoldHours > 0 ? targetHoldHours : DEFAULT_TARGET_HOLD_HOURS;
const factor = personSizeFactor(personKey, tradeDate);
return hold / (factor * factor);
}
/** Share of the broker's margin headroom a new position may use: the rest
* absorbs the price moving between sizing and the fill, and the entry fee. */
export const MARGIN_HEADROOM = 0.97;
@@ -1475,6 +1971,8 @@ export function planLegSize(o: {
/** Infinity (or ≤ 0) = no obfuscation stop. */
obfuscationDollars: number;
availableFunds?: number;
/** The platform's stopSlippageFraction; default DLL_SLIPPAGE_FRACTION. */
slippageFraction?: number;
}): {
lastStand: boolean;
cap: number;
@@ -1490,7 +1988,7 @@ export function planLegSize(o: {
obf,
!dllOn ? Infinity
: stand ? breachDistance({ dllRoom: o.dllRoom, maxLossRoom: o.maxLossRoom, entryFee: r * notional })
: dllStopDollars({ room: o.dllRoom, dailyLossLimit: o.dailyLossLimit, entryFee: r * notional, commissionPct: o.commissionPct, notional }),
: dllStopDollars({ room: o.dllRoom, dailyLossLimit: o.dailyLossLimit, entryFee: r * notional, commissionPct: o.commissionPct, notional, slippageFraction: o.slippageFraction }),
);
const cap = notionalCap({ marginRate: o.contract.marginRate, availableFunds: o.availableFunds });
const step = o.contract.stepNotional ?? 0;
@@ -1622,3 +2120,24 @@ export function isPastSessionClose(now: Date, everyDay: boolean): boolean {
if (day === 'Sat' || day === 'Sun') return false;
return minutes >= 14 * 60 + 55 && minutes < 17 * 60;
}
/**
* A firm's cap on how many of its accounts trade at once, on top of the
* machine's own max concurrent accounts: keeps `items` in order, at most
* `firm.maxConcurrentAccounts` per firm less what that firm already holds
* open (`held`, by firm id). A cap of 0 (or none) means no firm cap.
*/
export function withinFirmCaps<T extends { firm: { id: string; maxConcurrentAccounts?: number } }>(
items: readonly T[],
held: ReadonlyMap<string, number> = new Map(),
): T[] {
const taken = new Map(held);
return items.filter(({ firm }) => {
const cap = firm.maxConcurrentAccounts ?? 0;
if (cap <= 0) return true;
const count = taken.get(firm.id) ?? 0;
if (count >= cap) return false;
taken.set(firm.id, count + 1);
return true;
});
}
+151 -2
View File
@@ -1,12 +1,14 @@
// Create a class with a constructor that takes a username and password and sets the credentials
import axios from 'axios';
import axios from './http-client';
import type { AccountItem, AuthLoginResponse, Contract } from './tradovate-helpers';
import { computeSec, randomUUIDV4 } from './tradovate-helpers';
import { currentTradeDateCT } from './trading-logic';
import { POINT_VALUES } from './instrument-specs';
import { getCachedContract, resolveContracts } from './contract-resolver';
import { saveDailyPnL, loadDailyPnL, saveAccountMeta, loadAccountMeta, saveFundTransactions, loadFundTransactions, saveEndedCycleStart } from './db';
import { saveDailyPnL, loadDailyPnL, saveAccountMeta, loadAccountMeta, saveFundTransactions, loadFundTransactions, saveEndedCycleStart, recordOwnOrder, type OwnOrderKind } from './db';
import { recordFillTrades, type ContractFills } from './trade-recorder';
import { contractRoot, type Fill } from './fill-trades';
import { brokerWebSocket } from './outbound-proxy';
import { FILLS_FALLBACK_NOTE, OrderNotPlacedError, SYNC_STALE_MS, errorMessage, type ClientHealth } from './platform-client';
@@ -113,6 +115,8 @@ const FINISHED_ORDER_STATUSES = new Set(['Filled', 'Canceled', 'Rejected', 'Expi
* a payout is hundreds. */
const FUND_DRIFT_TOLERANCE = 25;
const CONTRACT_FIND_TIMEOUT_MS = 15_000;
/** How often a product's fees are asked for again, for the trades record. */
const FEES_REFRESH_MS = 6 * 60 * 60 * 1000;
/** On-demand front-month resolve (several Yahoo lookups, then Tradovate). */
const CONTRACT_RESOLVE_DEADLINE_MS = 30_000;
const READY_STATE_NAMES = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'];
@@ -371,6 +375,13 @@ export class TradovateClient {
* outcome clears it. */
private cashHistoryRefusedSince = new Map<number, number>();
/** Interval handles tracked so they can be cleared on reconnect */
/** Contract id → name ("NQZ6"), for the trades record. */
private contractNames = new Map<number, string>();
private contractLookupInFlight = false;
/** Product → fees per contract per side, for the trades record. */
private feePerSide = new Map<string, number>();
private feesAskedAt = new Map<string, number>();
private feesInFlight = false;
private syncInterval: ReturnType<typeof setInterval> | null = null;
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
private tokenRenewalInterval: ReturnType<typeof setInterval> | null = null;
@@ -1049,6 +1060,8 @@ export class TradovateClient {
{} as { [accountId: number]: { contractId: number; netPos: number; netPrice: number; timestamp: Date } }
);
this.recordTrades(response);
this.lastSyncAt = Date.now();
this.lastSyncStartedAt = requestedAt || this.lastSyncAt;
// A session counts as working once it has synced (abandonSocket).
@@ -1544,6 +1557,15 @@ export class TradovateClient {
return !this.balanceDrifted(accountId);
}
/** historyReady() without its drift check, which can start a hold and
* moves the balance baseline — for the reporter, which must change
* neither. A drift not yet held reads ready until the scheduler looks. */
public peekHistoryReady(accountId: number): boolean {
if (!this.historyLoadedIds.has(accountId)) return false;
if (this.dailyPnL[accountId] === undefined || this.fundTransactions[accountId] === undefined) return false;
return !this.historyHolds.has(accountId);
}
/** Drop the account's history from memory (a wipe): it reads unloaded, and
* stays out of trading, until a Cash History rebuilds it — the wipe took
* its fund transactions, which neither the Fills report nor the cache can
@@ -2019,6 +2041,128 @@ export class TradovateClient {
}
}
/** Notes an order as the bot's, so the trade its fill opens or closes is
* credited to the bot (lib/trade-recorder.ts). */
private noteOwnOrder(accountId: number, orderId: unknown, kind: OwnOrderKind): void {
if (typeof orderId !== 'number' || !(orderId > 0)) return;
try {
recordOwnOrder(accountId, orderId, kind);
} catch (err) {
console.error(`${this.tag} could not note order ${orderId} as the bot's: ${errorMessage(err)}`);
}
}
/**
* The trades the sync snapshot's fills make (the session's), in the
* trades table the dashboard lists (lib/trade-recorder.ts). A fill names
* its order, not its account, so the snapshot's orders say whose it is.
* Never fails the sync.
*/
private recordTrades(response: { contracts?: unknown[]; orders?: unknown[]; positions?: unknown[]; fills?: unknown[] }): void {
try {
for (const c of (response.contracts ?? []) as { id?: unknown; name?: unknown }[]) {
if (typeof c?.id === 'number' && typeof c.name === 'string') this.contractNames.set(c.id, c.name);
}
const accountOfOrder = new Map<number, number>();
for (const o of (response.orders ?? []) as { id?: unknown; accountId?: unknown }[]) {
if (typeof o?.id === 'number' && typeof o.accountId === 'number') accountOfOrder.set(o.id, o.accountId);
}
const books = new Map<number, Map<number, { fills: Fill[]; net: number; netAt: number }>>();
const book = (accountId: number, contractId: number) => {
const contracts = books.get(accountId) ?? new Map<number, { fills: Fill[]; net: number; netAt: number }>();
books.set(accountId, contracts);
const b = contracts.get(contractId) ?? { fills: [], net: 0, netAt: -Infinity };
contracts.set(contractId, b);
return b;
};
for (const p of (response.positions ?? []) as { accountId?: unknown; contractId?: unknown; netPos?: unknown; timestamp?: unknown }[]) {
if (typeof p?.accountId !== 'number' || typeof p.contractId !== 'number') continue;
const b = book(p.accountId, p.contractId);
const at = Date.parse(String(p.timestamp ?? '')) || 0;
if (at >= b.netAt) {
b.net = typeof p.netPos === 'number' ? p.netPos : 0;
b.netAt = at;
}
}
for (const raw of (response.fills ?? []) as Record<string, unknown>[]) {
const f = raw ?? {};
if (f.active === false || typeof f.id !== 'number' || typeof f.orderId !== 'number' || typeof f.contractId !== 'number') continue;
const qty = Number(f.qty);
const price = Number(f.price);
const at = Date.parse(String(f.timestamp ?? ''));
const accountId = accountOfOrder.get(f.orderId);
if (accountId === undefined || !(qty > 0) || !Number.isFinite(price) || !Number.isFinite(at)) continue;
book(accountId, f.contractId).fills.push({
id: String(f.id), orderId: String(f.orderId), at, side: f.action === 'Buy' ? 'buy' : 'sell', qty, price, fee: null, pnl: null,
});
}
const unnamed = new Set<number>();
const roots = new Set<string>();
for (const acc of this.accountList) {
const contracts = books.get(acc.id);
if (!contracts) continue;
const list: ContractFills[] = [];
for (const [contractId, b] of contracts) {
if (b.fills.length === 0 && b.net === 0) continue;
const name = this.contractNames.get(contractId);
if (!name) {
unnamed.add(contractId);
continue;
}
const root = contractRoot(name);
roots.add(root);
const perSide = this.feePerSide.get(root);
list.push({
symbol: name,
pointValue: POINT_VALUES[root] ?? null,
currentNet: b.net,
fills: b.fills.map((f) => ({ ...f, fee: perSide === undefined ? null : Math.round(perSide * f.qty * 100) / 100 })),
});
}
if (list.length > 0) recordFillTrades('tdv', acc, list);
}
if (unnamed.size > 0) void this.lookUpContractNames([...unnamed]);
this.refreshFees(roots);
} catch (err) {
console.error(`${this.tag} trade record failed: ${errorMessage(err)}`);
}
}
/** Names for contracts the snapshot didn't describe — the next sync's
* record uses them. */
private async lookUpContractNames(ids: number[]): Promise<void> {
if (this.contractLookupInFlight) return;
this.contractLookupInFlight = true;
try {
await this.sendRequest('contract/items', `ids=${ids.join(',')}`, CONTRACT_FIND_TIMEOUT_MS, (data) => {
for (const c of (Array.isArray(data) ? data : []) as { id?: unknown; name?: unknown }[]) {
if (typeof c?.id === 'number' && typeof c.name === 'string') this.contractNames.set(c.id, c.name);
}
});
} catch (err) {
console.warn(`${this.tag} contract/items failed: ${errorMessage(err)}`);
} finally {
this.contractLookupInFlight = false;
}
}
/** Each product's fees per contract per side (getproductfeeparams), for
* the trades record: asked for a product once, then every few hours. */
private refreshFees(roots: Set<string>): void {
const now = Date.now();
const due = [...roots].filter((r) => now - (this.feesAskedAt.get(r) ?? 0) >= FEES_REFRESH_MS);
if (due.length === 0 || this.feesInFlight || this.products.length === 0) return;
this.feesInFlight = true;
for (const r of due) this.feesAskedAt.set(r, now);
this.fetchInstrumentFees(due)
.then((fees) => {
for (const [root, fee] of Object.entries(fees)) this.feePerSide.set(root, fee);
})
.catch((err) => console.warn(`${this.tag} fee lookup failed: ${errorMessage(err)}`))
.finally(() => { this.feesInFlight = false; });
}
/** A string or empty ack is the broker refusing the order — nothing was placed. */
private static acceptedAck(response: unknown, endpoint: string): { orderId?: number; id?: number; commandStatus?: string } {
if (typeof response === 'string') throw new OrderNotPlacedError(response || `${endpoint} refused`);
@@ -2139,6 +2283,7 @@ export class TradovateClient {
const ack = TradovateClient.acceptedAck(response, 'order/placeorder');
// Tradovate returns the command object: { id, commandStatus, orderId, ... }
const orderId = ack.orderId ?? ack.id;
this.noteOwnOrder(accountId, orderId, orderType === 'Limit' ? 'limit' : 'market');
console.log(`${this.tag} sendOrder orderId=${orderId} status=${ack.commandStatus}`);
// Wait for the fill event — it carries the real execution price
@@ -2171,6 +2316,7 @@ export class TradovateClient {
ACK_TIMEOUT_MS,
(response) => {
const ack = TradovateClient.acceptedAck(response, 'order/placeorder');
this.noteOwnOrder(accountId, ack.orderId ?? ack.id, orderType === 'Limit' ? 'limit' : 'market');
return { orderId: ack.orderId ?? ack.id };
},
);
@@ -2210,6 +2356,9 @@ export class TradovateClient {
ACK_TIMEOUT_MS,
(response) => {
const ack = TradovateClient.acceptedAck(response, 'order/placeoco');
// PlaceOCOResult: orderId is the take-profit limit, ocoId its stop.
this.noteOwnOrder(accountId, ack.orderId ?? ack.id, 'limit');
this.noteOwnOrder(accountId, (response as { ocoId?: unknown }).ocoId, 'stop');
return { orderId: ack.orderId ?? ack.id };
},
);
+103
View File
@@ -0,0 +1,103 @@
/**
* Keeps this machine's auto-updater (scripts/update-check.mjs) alive.
*
* PM2 fires the update checker every minute, and nothing on the machine
* notices when it stops. On the fleet it stops on its own: PM2's cron dies
* inside a live daemon (six Windows machines in October 2026; the mechanism
* is in update-check.mjs heal), and a `pm2 stop` or a reboot after an unsaved
* process-list change does the same. The master's dashboard notices ("updater
* not running"), but then someone has to get onto the machine. The app is a
* long-running PM2 process, which PM2 keeps alive reliably, so it checks: once
* the last check is older than the master's 10 minutes, it asks the script to
* heal, i.e. get PM2 firing it again, and run a check.
*
* It only revives an updater that has run here before (scripts/.last-check
* exists). A checkout that never had one is someone's choice, and
* scripts/.no-auto-update switches a checkout off outright (a dev checkout).
* Only the checkout's first instance watches — PM2 name `autofirmer`, which
* scripts/instances.mjs keeps for instance 1 — since the others share its
* updater.
*
* The master's "Update now" command comes through here as well.
*/
import { spawn } from 'node:child_process';
import { existsSync, readFileSync, statSync } from 'node:fs';
import path from 'node:path';
const EVERY_MS = 5 * 60_000;
/** The master's UPDATER_STALE_MS: past this its dashboard says "not running". */
const STALE_MS = 10 * 60_000;
/** A heal that didn't take (PM2 missing, say) is retried no sooner than this. */
const RETRY_MS = 30 * 60_000;
const scriptsDir = () => path.join(process.cwd(), 'scripts');
const optedOut = () => existsSync(path.join(scriptsDir(), '.no-auto-update'));
// Per process: instrumentation.ts and the route handlers each load this module.
const g = globalThis as typeof globalThis & { __updaterWatchdog?: { lastHealAt: number } };
/** When the updater last checked (epoch ms), or null if it never has here. */
function lastCheckAt(): number | null {
const file = path.join(scriptsDir(), '.last-check');
try {
// "<ISO time> <status>", rewritten on every check.
const at = Date.parse(readFileSync(file, 'utf8').split(/\s/, 1)[0]);
return Number.isFinite(at) ? at : statSync(file).mtimeMs;
} catch {
return null;
}
}
/**
* Start update-check.mjs on its own. Detached, because what it starts may
* restart this very process; the script logs to scripts/update.log itself.
*/
function spawnUpdateCheck(args: string[]): number {
const child = spawn(process.execPath, [path.join(scriptsDir(), 'update-check.mjs'), ...args], {
cwd: process.cwd(),
detached: true,
// No console window popping up on a Windows trading desktop.
windowsHide: true,
stdio: 'ignore',
});
child.on('error', (err) => console.error(`[updater-watchdog] update-check.mjs failed to start: ${err.message}`));
child.unref();
if (!child.pid) throw new Error('could not start scripts/update-check.mjs');
return child.pid;
}
function tick(): void {
const state = g.__updaterWatchdog;
if (!state || optedOut()) return;
const last = lastCheckAt();
if (last === null) return; // never ran here: not ours to start
const now = Date.now();
if (now - last <= STALE_MS || now - state.lastHealAt < RETRY_MS) return;
state.lastHealAt = now;
try {
const pid = spawnUpdateCheck(['--heal']);
console.log(`[updater-watchdog] last update check ${Math.round((now - last) / 60_000)} min ago — asked update-check.mjs to heal (pid ${pid})`);
} catch (err) {
console.error(`[updater-watchdog] ${err instanceof Error ? err.message : String(err)}`);
}
}
export function startUpdaterWatchdog(): void {
if (g.__updaterWatchdog) return;
// Under PM2 only (pm_id is PM2's): `next dev`, a sandbox copy or a
// by-hand `next start` has no updater to keep alive.
if (process.env.pm_id === undefined || process.env.name !== 'autofirmer') return;
g.__updaterWatchdog = { lastHealAt: 0 };
setInterval(tick, EVERY_MS).unref();
}
/**
* The master's "Update now": heal the updater if it stopped, then check at
* once, skipping a failed update's retry wait. Answers before the update
* runs — it may restart this process — so its outcome reaches the master
* with the next report's updater status.
*/
export function runUpdateNow(): { started: true; pid: number } {
if (optedOut()) throw new Error('updates are switched off on this machine (scripts/.no-auto-update)');
return { started: true, pid: spawnUpdateCheck(['--heal', '--now']) };
}
+2459
View File
File diff suppressed because it is too large Load Diff
+818
View File
@@ -0,0 +1,818 @@
/**
* Pure helpers for the Vest client (lib/vest-class.ts) — parsing the gateway's
* 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).
*/
import { createHash } from 'crypto';
/** A Vest login's primary wallet: 0x + 40 hex. The instance's saved Vest
* "credential" is this address — it says whose relayed session to trade. */
export function isVestWalletAddress(value: unknown): value is string {
return typeof value === 'string' && /^0x[0-9a-fA-F]{40}$/.test(value.trim());
}
/** Stable numeric id for a Vest account uuid, in the same range as DXtrade's
* (dxAccountId) but keyed apart, so every numeric account table keeps
* working. */
export function vestAccountId(uuid: string): number {
const digest = createHash('sha256').update(`vest|${uuid.toLowerCase()}`).digest();
return 2 ** 50 + digest.readUIntBE(0, 6) * 16 + (digest[6] >> 4);
}
/** A decimal string or number from the gateway → a number (undefined when
* missing or not a number). */
export function num(raw: unknown): number | undefined {
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : undefined;
if (typeof raw !== 'string' || raw.trim() === '') return undefined;
const n = Number(raw);
return Number.isFinite(n) ? n : undefined;
}
/** Epoch ms from the gateway's epoch ms, epoch seconds or ISO strings; NaN
* when unreadable. */
export function toMs(raw: unknown): number {
if (typeof raw === 'number' && Number.isFinite(raw)) return raw < 1e11 ? raw * 1000 : raw;
if (typeof raw === 'string' && raw.trim() !== '') {
const n = Number(raw);
if (Number.isFinite(n)) return n < 1e11 ? n * 1000 : n;
const t = Date.parse(raw);
return Number.isFinite(t) ? t : NaN;
}
return NaN;
}
/** `value` on a decimal grid, as the gateway wants it: a plain string with at
* most `decimals` places, trailing zeros trimmed (never exponent notation). */
export function formatDecimal(value: number, decimals: number): string {
const d = Math.max(0, Math.min(18, Math.floor(decimals)));
const fixed = value.toFixed(d);
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', {
timeZone: 'America/New_York', hourCycle: 'h23',
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit',
});
/** Vest's daily reset hour, America/New_York (its docs: 20:00 ET). */
export const VEST_RESET_HOUR_ET = 20;
/**
* The trading day a moment belongs to, YYYY-MM-DD. Vest's day runs 20:00 ET
* to 20:00 ET and is named for the date it ends on, like the CME session
* that opens the evening before — so 21:00 ET on the 26th is the 27th's day.
*/
export function vestDayOf(ms: number): string {
const parts = Object.fromEntries(ET_PARTS.formatToParts(new Date(ms)).map((p) => [p.type, p.value]));
const date = `${parts.year}-${parts.month}-${parts.day}`;
if (Number(parts.hour) < VEST_RESET_HOUR_ET) return date;
const next = new Date(`${date}T12:00:00Z`);
next.setUTCDate(next.getUTCDate() + 1);
return next.toISOString().slice(0, 10);
}
/** The last 20:00 ET at or before `now`, epoch ms. 20:00 ET is 00:00 UTC
* in summer and 01:00 UTC in winter; whichever of those hours, on this or
* an earlier UTC day, reads as 20:00 in New York is the boundary. */
export function lastVestResetMs(now: number): number {
const day = Date.UTC(new Date(now).getUTCFullYear(), new Date(now).getUTCMonth(), new Date(now).getUTCDate());
for (let back = 0; back <= 2; back++) {
for (const hour of [1, 0]) {
const t = day - back * 86_400_000 + hour * 3_600_000;
if (t > now) continue;
const parts = Object.fromEntries(ET_PARTS.formatToParts(new Date(t)).map((p) => [p.type, p.value]));
if (Number(parts.hour) === VEST_RESET_HOUR_ET) return t;
}
}
return NaN;
}
/** The next 20:00 ET after `now`, epoch ms (a day can be 23 or 25 hours). */
export function nextVestResetMs(now: number): number {
for (let h = 1; h <= 26; h++) {
const t = lastVestResetMs(now + h * 3_600_000);
if (t > now) return t;
}
return NaN;
}
/** Vest's stopSlippageFraction: 0.02% of the position, about 6 NDX points.
* Measured 2026-10-27 on 9 bot stop-outs (Me, $74k–$132k): fills 0.9 points
* past the trigger on average, 2.25 at worst — the 0.1% default held back
* about 30. The flat 10% of the limit still applies under it. */
export const VEST_STOP_SLIPPAGE_FRACTION = 0.0002;
/**
* What to do with an open position before the daily reset so the reset can't
* breach it. At 20:00 ET Vest captures the realized balance (collateral in,
* unrealized P&L out) and sets the day's floor to balance × (1 − rate); the
* max-drawdown floor stays. A position whose loss allowance is wider than
* that next-day room would breach the moment the reset lands, so its stop
* comes in to where equity stays above the coming floor plus `reserve`, with
* the exit fee paid:
* 'none' — its stop is already at least that tight;
* 'stop' — move (or place) the stop at `price`, on the tick grid, rounded
* toward the position's safe side;
* 'close' — the mark is already past that price (with no room left by
* the account's other positions, it sits on the profit side),
* or no room and no mark to tell.
*/
export function resetSafeStop(o: {
side: 'long' | 'short';
quantity: number;
openPrice: number;
mark: number;
/** Realized balance, collateral included — what the reset captures —
* less what the account's other positions may lose at their stops. */
balance: number;
/** The realized balance the floor is set from; default `balance`. */
floorBalance?: number;
dailyLossPct: number;
maxDrawdownFloor: number | undefined;
reserve: number;
/** Taker fee as a fraction of notional. */
exitFeeRate: number;
currentStop: number | null;
tick: number;
}): { action: 'none' } | { action: 'stop'; price: number } | { action: 'close' } {
const floor = Math.max((o.floorBalance ?? o.balance) * (1 - o.dailyLossPct), o.maxDrawdownFloor ?? -Infinity);
const exitFee = o.exitFeeRate * o.quantity * (Number.isFinite(o.mark) && o.mark > 0 ? o.mark : o.openPrice);
const allowed = o.balance - floor - o.reserve - exitFee;
if (!(o.quantity > 0)) return { action: 'close' };
// No room to lose (the account's other positions took it): the stop must
// lock in at least the shortfall — on the profit side of the entry,
// which a position already that far up can still carry.
if (!(allowed > 0) && !Number.isFinite(o.mark)) return { action: 'close' };
const long = o.side === 'long';
const raw = long ? o.openPrice - allowed / o.quantity : o.openPrice + allowed / o.quantity;
const price = o.tick > 0
? (long ? Math.ceil(raw / o.tick - 1e-9) * o.tick : Math.floor(raw / o.tick + 1e-9) * o.tick)
: raw;
const rounded = Number(price.toFixed(10));
if (o.currentStop !== null && (long ? o.currentStop >= rounded : o.currentStop <= rounded)) return { action: 'none' };
if (Number.isFinite(o.mark) && (long ? o.mark <= rounded : o.mark >= rounded)) return { action: 'close' };
return { action: 'stop', price: rounded };
}
// ── accounts ────────────────────────────────────────────────────────────────
export interface VestCapitalAccount {
id: string;
planId: string;
planName: string;
/** 'eval' | 'instant_funded' | … as the gateway names the product. */
productType: string;
/** 1-based stage; stages[stage-1] is the current one. */
stage: number;
/** Evaluation steps the plan has (a 1-Step's is 1, a 2-Step's 2). */
stepsTotal: number;
/** The evaluation step this account is (1-based; a funded one's is
* stepsTotal + 1, as the Vest app counts it when Vest leaves it out). */
step: number;
/** Vest's account_type: 2 an evaluation, 3 a funded account. */
accountType: number;
/** Vest blocked the account (blocked_at set): nothing it offers applies. */
blocked: boolean;
/** The current stage: 'eval' or 'funded'. */
kind: string;
status: number;
attemptIndex: number;
initialCapital: number;
/** The current stage's profit target in dollars (0 = none). */
profitTarget: number;
targetEquity: number | undefined;
maxDrawdownLimit: number | undefined;
maxDailyLossPct: number;
dailyResetBalance: number | undefined;
dailyLossFloor: number | undefined;
dailyResetAt: number;
activatedAt: number;
createdAt: number;
}
/** One entry of GET /v3/capital/accounts/active; null when it isn't one. */
export function parseCapitalAccount(raw: unknown): VestCapitalAccount | null {
const a = raw as Record<string, unknown>;
if (!a || typeof a.id !== 'string') return null;
const stages = Array.isArray(a.stages) ? (a.stages as Record<string, unknown>[]) : [];
const stage = num(a.stage) ?? 1;
const current = stages.find((s) => num(s.ordinal) === stage) ?? stages[stage - 1] ?? {};
// The Vest app's own fallbacks, for an answer that leaves these out.
const stepsTotal = num(a.steps_total) ?? (a.plan_product_type === 'eval' ? 1 : 0);
return {
id: a.id,
planId: typeof a.plan_id === 'string' ? a.plan_id : '',
planName: typeof a.plan_name === 'string' ? a.plan_name : '',
productType: typeof a.plan_product_type === 'string' ? a.plan_product_type : '',
stage,
stepsTotal,
step: num(a.step) ?? (stage === VEST_STAGE_FUNDED ? stepsTotal + 1 : 1),
accountType: num(a.account_type) ?? 0,
blocked: Boolean(a.blocked_at),
kind: typeof current.kind === 'string' ? current.kind : '',
status: num(a.status) ?? -1,
attemptIndex: num(a.attempt_index) ?? 0,
initialCapital: num(a.initial_capital) ?? 0,
profitTarget: num(current.profit_target) ?? 0,
targetEquity: num(a.target_equity),
maxDrawdownLimit: num(a.max_drawdown_limit),
maxDailyLossPct: num(a.max_daily_loss_pct) ?? 0,
dailyResetBalance: num(a.daily_reset_balance),
dailyLossFloor: num(a.daily_loss_floor),
dailyResetAt: toMs(a.daily_reset_at),
activatedAt: toMs(a.activated_at),
createdAt: toMs(a.created_at),
};
}
/** Vest's capital account statuses, as the Vest app names them (its bundle,
* 2026-10-29). Only an ACTIVE account trades. A passed evaluation sits at
* GOAL_PASSED until its funded account is activated, and is CLAIMED after. */
export const VEST_STATUS = {
CREATED: 1,
ACTIVE: 2,
GOAL_PASSED: 3,
FAILED_MAX_DRAWDOWN: 4,
FAILED_MAX_DAILY_LOSS: 5,
CLAIMED: 6,
EXPIRED: 7,
} as const;
export const VEST_STATUS_ACTIVE = VEST_STATUS.ACTIVE;
/** Vest's stage field: an evaluation's, then the funded account's. */
const VEST_STAGE_EVAL = 1;
const VEST_STAGE_FUNDED = 2;
/** Vest's account_type of an evaluation. */
const VEST_ACCOUNT_TYPE_EVAL = 2;
/** A status as the log says it: Vest's own name, with its number. */
export function vestStatusName(status: number): string {
const name = Object.entries(VEST_STATUS).find(([, v]) => v === status)?.[0];
return `${name ? name.toLowerCase().replace(/_/g, ' ') : 'unknown'} (status ${status})`;
}
/**
* A passed evaluation whose funded account Vest activates on request (POST
* /v3/capital/activate-funded) — the Vest app's own test for its "Go to
* funded account" button: an evaluation at GOAL_PASSED, not blocked, on its
* plan's last step. An earlier step of a 2-Step passes on to the next step
* by itself.
*/
export function awaitingActivation(a: VestCapitalAccount): boolean {
const evaluation = a.accountType === VEST_ACCOUNT_TYPE_EVAL || a.stage === VEST_STAGE_EVAL;
return evaluation && a.status === VEST_STATUS.GOAL_PASSED && !a.blocked && a.stepsTotal > 0 && a.step === a.stepsTotal;
}
const pct = (fraction: number) => Math.round(fraction * 1000) / 10;
/**
* What the dashboard calls the account and what account-type prefixes match
* against. Vest itself only says "Account 34", so the name carries the rules
* that tell its types apart:
* V50K-E-D3-T10-34 evaluation, $50k, 3% daily loss, 10% target, attempt 34
* V50K-F-D3-34 the funded stage of an evaluation account
* V25K-I-7 instant funded (no daily loss limit, no target)
* D0 = no daily loss limit. The target is the current stage's (a 2-Step's
* second stage reads T5).
*/
export function vestAccountName(a: VestCapitalAccount): string {
const size = a.initialCapital >= 1000 ? `${+(a.initialCapital / 1000).toFixed(1)}K` : `${Math.round(a.initialCapital)}`;
const dll = `D${pct(a.maxDailyLossPct)}`;
if (/instant/i.test(a.productType)) return `V${size}-I-${a.attemptIndex}`;
if (a.kind === 'funded') return `V${size}-F-${dll}-${a.attemptIndex}`;
const target = a.initialCapital > 0 && a.profitTarget > 0 ? `-T${pct(a.profitTarget / a.initialCapital)}` : '';
return `V${size}-E-${dll}${target}-${a.attemptIndex}`;
}
// ── positions and orders ────────────────────────────────────────────────────
export interface VestLeg {
id: string;
triggerPrice: number;
executionType: string;
status: string;
}
export interface VestPosition {
positionId: string;
accountId: string;
symbol: string;
side: 'long' | 'short';
quantity: number;
openPrice: number;
/** Margin the position holds out of the free balance. */
collateral: number;
leverage: number;
openedAt: number;
stopLosses: VestLeg[];
takeProfits: VestLeg[];
}
function parseLegs(raw: unknown): VestLeg[] {
if (!Array.isArray(raw)) return [];
return raw.flatMap((l) => {
const leg = l as Record<string, unknown>;
const trigger = num(leg?.triggerPrice);
if (typeof leg?.id !== 'string' || trigger === undefined) return [];
return [{ id: leg.id, triggerPrice: trigger, executionType: String(leg.executionType ?? ''), status: String(leg.status ?? '') }];
});
}
/** One entry of GET /v3/positions/opened; null when it isn't an open one. */
export function parsePosition(raw: unknown): VestPosition | null {
const p = raw as Record<string, unknown>;
const accountId = p?.accountId ?? p?.account_id;
if (!p || typeof p.positionId !== 'string' || typeof accountId !== 'string' || typeof p.symbol !== 'string') return null;
const quantity = Math.abs(num(p.quantity) ?? 0);
if (!(quantity > 0)) return null;
return {
positionId: p.positionId,
accountId,
symbol: p.symbol,
side: String(p.side).toLowerCase() === 'short' ? 'short' : 'long',
quantity,
openPrice: num(p.openPrice) ?? NaN,
collateral: num(p.collateral) ?? 0,
leverage: num(p.leverage) ?? 0,
openedAt: toMs(p.openDate ?? p.createdAt),
stopLosses: parseLegs(p.stopLosses),
takeProfits: parseLegs(p.takeProfits),
};
}
/**
* One position from GET /v3/positions — Vest's position history, the app's
* History tab: closed positions with how they ended (an open one has no
* close yet). pnl is Vest's realized P&L before fees and funding.
*/
export interface VestTrade {
positionId: string;
symbol: string;
side: 'long' | 'short';
/** 0 when Vest doesn't say (a store keeps the size it already knows). */
quantity: number;
leverage: number | null;
openPrice: number | null;
closePrice: number | null;
pnl: number | null;
fee: number | null;
funding: number | null;
openedAt: number;
closedAt: number | null;
/** user_requested, stop_loss, take_profit, liquidation, auto_deleverage,
* temporary_leverage_limit, corporate_action. */
closeReason: string | null;
}
export function parseHistoryPosition(raw: unknown): VestTrade | null {
const p = raw as Record<string, unknown>;
const id = p?.positionId ?? p?.position_id;
if (!p || typeof id !== 'string' || typeof p.symbol !== 'string') return null;
const openedAt = toMs(p.openDate ?? p.open_date ?? p.createdAt ?? p.created_at);
if (!Number.isFinite(openedAt)) return null;
const closedMs = toMs(p.closeDate ?? p.close_date);
const closedAt = Number.isFinite(closedMs) && closedMs > 0 ? closedMs : null;
const openPrice = num(p.openPrice ?? p.open_price);
// An open position comes with a placeholder close price and a $0 P&L
// (seen: 290.25 on an NDX short at 29,538.96): only a close has them.
const closePrice = closedAt === null ? undefined : num(p.closePrice ?? p.close_price);
const reason = p.closeReason ?? p.close_reason;
return {
positionId: id,
symbol: p.symbol,
side: String(p.side).toLowerCase() === 'short' ? 'short' : 'long',
quantity: Math.abs(num(p.quantity) ?? 0),
leverage: num(p.leverage) ?? null,
openPrice: openPrice !== undefined && openPrice > 0 ? openPrice : null,
closePrice: closePrice !== undefined && closePrice > 0 ? closePrice : null,
pnl: closedAt === null ? null : num(p.pnl) ?? null,
fee: num(p.fee) ?? null,
funding: num(p.funding) ?? null,
openedAt,
closedAt,
closeReason: typeof reason === 'string' && reason !== '' ? reason : null,
};
}
/** Every string value in a JWT's payload, lower-case ([] when it isn't
* one). Used only to compare against account ids — never logged. */
export function jwtClaimStrings(token: string): string[] {
const part = token.split('.')[1];
if (!part) return [];
let payload: unknown;
try {
payload = JSON.parse(Buffer.from(part.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
} catch {
return [];
}
const out: string[] = [];
const walk = (v: unknown, depth: number): void => {
if (typeof v === 'string') out.push(v.toLowerCase());
else if (v && typeof v === 'object' && depth < 4) for (const x of Object.values(v)) walk(x, depth + 1);
};
walk(payload, 0);
return out;
}
/**
* Whether a token the extension relayed is one Vest account's own token, not
* the login's. The Vest app renews both through POST /v3/auth/refresh — the
* login's every ~15 minutes, an account's while someone trades in it — and
* an account's token sees only that account: /v3/capital/accounts/active
* lists it alone, and /v3/accounts has no primary (wallet) account. The
* login sees every account and its primary one.
*/
export function accountScopedToken(o: { capitalIds: string[]; hasPrimary: boolean; claims: string[] }): boolean {
if (o.capitalIds.length > 1) return false;
if (!o.hasPrimary) return true;
return o.capitalIds.some((id) => o.claims.includes(id.toLowerCase()));
}
/** Exit legs still waiting to trigger. */
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;
positionId: string | undefined;
symbol: string;
createdAt: number;
}
/** One entry of GET /v3/positions/opened-orders (snake or camel case). */
export function parseOpenOrder(raw: unknown): VestOpenOrder | null {
const o = raw as Record<string, unknown>;
const orderId = o?.order_id ?? o?.orderId ?? o?.id;
const accountId = o?.account_id ?? o?.accountId;
if (typeof orderId !== 'string' || typeof accountId !== 'string') return null;
const positionId = o.position_id ?? o.positionId;
return {
orderId,
accountId,
positionId: typeof positionId === 'string' ? positionId : undefined,
symbol: String(o.symbol ?? ''),
createdAt: toMs(o.created_at ?? o.createdAt),
};
}
/** Unrealized P&L of a position at `mark`. */
export function unrealizedPnl(p: Pick<VestPosition, 'side' | 'quantity' | 'openPrice'>, mark: number): number {
if (!Number.isFinite(mark) || !Number.isFinite(p.openPrice)) return 0;
return (p.side === 'long' ? 1 : -1) * p.quantity * (mark - p.openPrice);
}
// ── transactions ────────────────────────────────────────────────────────────
/**
* The gateway's transaction types (its app's own enum, 2026-10-26). Trading
* P&L is fees, realized P&L, funding and the adjustments that come with them;
* funds are money moved in or out; the rest only moves margin between the
* free balance and positions, and leaves the account's total alone.
*/
export const VEST_TXN = {
DEPOSIT: 1, WITHDRAW: 2, OPEN_POSITION: 3, CLOSE_POSITION: 4, CORRECTION: 5, FILL_MARGIN: 6, MARGIN_RELEASE: 7,
FEE: 8, REALIZED_PNL: 9, FUNDING: 10, LEVERAGE_RESERVE: 11, LEVERAGE_RELEASE: 12, ADD_COLLATERAL: 13,
REMOVE_COLLATERAL: 14, ORDER_MARGIN_RESERVE: 15, ORDER_MARGIN_RELEASE: 16, LIQUIDATION_DEFICIT_COVERAGE: 17,
CORPORATE_ACTION_CASH_ADJUSTMENT: 18, FUNDED_PROFIT_CREDIT: 19, AFFILIATE_PAYOUT_CREDIT: 20, COMPENSATION: 22,
BALANCE_MIGRATION: 23,
} as const;
const TRADING_TXNS = new Set<number>([
VEST_TXN.CORRECTION, VEST_TXN.FEE, VEST_TXN.REALIZED_PNL, VEST_TXN.FUNDING,
VEST_TXN.LIQUIDATION_DEFICIT_COVERAGE, VEST_TXN.CORPORATE_ACTION_CASH_ADJUSTMENT,
]);
const FUND_TXNS = new Set<number>([
VEST_TXN.DEPOSIT, VEST_TXN.WITHDRAW, VEST_TXN.FUNDED_PROFIT_CREDIT, VEST_TXN.AFFILIATE_PAYOUT_CREDIT,
VEST_TXN.COMPENSATION, VEST_TXN.BALANCE_MIGRATION,
]);
export type VestTxnKind = 'trade' | 'fund' | 'margin';
export function txnKind(type: number): VestTxnKind {
if (TRADING_TXNS.has(type)) return 'trade';
if (FUND_TXNS.has(type)) return 'fund';
return 'margin';
}
export interface VestTxn {
/** action_id, else type|time|amount — what a page overlap dedupes on. */
key: string;
type: number;
kind: VestTxnKind;
/** Signed: what it did to the account's balance. */
amount: number;
time: number;
}
/** One entry of GET /v3/transactions; null when unreadable. A withdrawal is
* always negative and a deposit positive, whatever sign the gateway used. */
export function parseTxn(raw: unknown): VestTxn | null {
const t = raw as Record<string, unknown>;
const type = num(t?.transaction_type ?? t?.transactionType);
const amount = num(t?.amount);
const time = toMs(t?.created_at ?? t?.createdAt);
if (type === undefined || amount === undefined || !Number.isFinite(time)) return null;
const signed = type === VEST_TXN.WITHDRAW ? -Math.abs(amount) : type === VEST_TXN.DEPOSIT ? Math.abs(amount) : amount;
const action = t.action_id ?? t.actionId;
return {
key: typeof action === 'string' || typeof action === 'number' ? `${action}|${type}` : `${type}|${time}|${amount}`,
type,
kind: txnKind(type),
amount: signed,
time,
};
}
/** Daily trading P&L and fund rows by Vest day, from the account's
* transactions. */
export function bookTxns(txns: VestTxn[]): { daily: Map<string, { pnl: number; fees: number }>; funds: Map<string, number> } {
const daily = new Map<string, { pnl: number; fees: number }>();
const funds = new Map<string, number>();
for (const t of txns) {
const day = vestDayOf(t.time);
if (t.kind === 'fund') {
funds.set(day, (funds.get(day) ?? 0) + t.amount);
} else if (t.kind === 'trade') {
const e = daily.get(day) ?? { pnl: 0, fees: 0 };
e.pnl += t.amount;
if (t.type === VEST_TXN.FEE) e.fees += Math.abs(t.amount);
daily.set(day, e);
}
}
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. */
export const MIN_HOURLY_RETURNS = 24;
/**
* σ of log returns per √hour from hourly klines ([openTime, o, h, l, close, …],
* any order). Only returns between consecutive hours count: an index perp
* closes for weekends and the daily maintenance hour, and a return across
* such a gap is a gap, not an hour's move. null when too few are left.
*/
export function hourlyVolatility(klines: unknown[]): number | null {
const rows = klines.flatMap((k) => {
if (!Array.isArray(k)) return [];
const t = num(k[0]);
const close = num(k[4]);
return t !== undefined && close !== undefined && close > 0 ? [{ t: toMs(t), close }] : [];
}).sort((a, b) => a.t - b.t);
const returns: number[] = [];
for (let i = 1; i < rows.length; i++) {
if (Math.abs(rows[i].t - rows[i - 1].t - 3_600_000) > 60_000) continue;
returns.push(Math.log(rows[i].close / rows[i - 1].close));
}
if (returns.length < MIN_HOURLY_RETURNS) return null;
const mean = returns.reduce((s, r) => s + r, 0) / returns.length;
const variance = returns.reduce((s, r) => s + (r - mean) ** 2, 0) / (returns.length - 1);
return Math.sqrt(variance);
}
/**
* Whether the market-hours periods (GET /v4/market-hours/{assetId}) have the
* symbol open for trading at `now`, and for how long yet. A symbol with no
* periods at all (crypto: always open) reads as open with no end. A closure
* (weekly, the daily maintenance hour) counts as closed even where the
* gateway still says OPEN: no leg should start into one.
*/
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 = 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 } };
};
const from = num(r?.fromMs);
const to = num(r?.toMs);
if (from === undefined || to === undefined) return [];
const closure = r.state?.closure;
const open = r.state?.tradingStatus === 'OPEN' && r.state?.price?.allow !== false
&& (closure === undefined || closure === 'unspecified');
return [{ from, to, open }];
}).sort((a, b) => a.from - b.from);
}
/**
* 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;
}
+122
View File
@@ -0,0 +1,122 @@
/**
* Vest sessions relayed by the extension.
*
* Vest logs a person in through Google/Privy in the browser, and refreshes
* the session with a device key the browser will not export, so this
* machine cannot log in by itself. Instead the AutoFirmer extension watches
* the signed-in Vest tab: whenever the Vest app renews its own ~15-minute
* access token, the extension posts it here (POST /api/vest/session). The
* Vest client (lib/vest-class.ts) trades with the latest one for its wallet
* and mints per-account tokens from it.
*
* Kept in memory only — never in the DB, the logs, a report or a backup. A
* restart forgets them; the Vest tab's next renewal (or the extension's
* next tick) relays a fresh one within minutes.
*/
export interface VestSession {
accessToken: string;
/** Epoch ms the access token stops working. */
expiresAt: number;
/** The wallet the Vest login belongs to, lower-case. */
primaryAddr: string;
userId: string;
receivedAt: number;
}
/** What the dashboard and the extension may see of a session: never the token. */
export interface VestSessionInfo {
primaryAddr: string;
userId: string;
expiresAt: number;
receivedAt: number;
}
const g = globalThis as typeof globalThis & {
__vestSessions?: Map<string, VestSession>;
__vestPrevious?: Map<string, VestSession>;
__vestRefused?: Map<string, number>;
};
function store(): Map<string, VestSession> {
return (g.__vestSessions ??= new Map());
}
/** The session each wallet's current one replaced — what a refused token
* falls back to. */
function previous(): Map<string, VestSession> {
return (g.__vestPrevious ??= new Map());
}
/** Tokens the client found to be one account's, not the login's (by their
* tail — enough to tell them apart, never the whole token), with when. */
function refused(): Map<string, number> {
return (g.__vestRefused ??= new Map());
}
const REFUSED_KEEP = 50;
/** Shortest life a relayed token must have left to be taken. */
const MIN_REMAINING_MS = 30_000;
/** Longest a relayed token may claim to live — a Vest access token is
* ~15 minutes; anything far beyond that is not one. */
const MAX_REMAINING_MS = 6 * 60 * 60_000;
/** Take a relayed session. Returns why it was refused, or null. A token for
* a wallet only ever replaces one that expires sooner. */
export function putVestSession(input: { accessToken: unknown; expiresAt: unknown; primaryAddr: unknown; userId: unknown }, now = Date.now()): string | null {
const token = typeof input.accessToken === 'string' ? input.accessToken.trim() : '';
const expiresAt = Number(input.expiresAt);
const addr = typeof input.primaryAddr === 'string' ? input.primaryAddr.trim().toLowerCase() : '';
if (!/^[\w-]+\.[\w-]+\.[\w-]+$/.test(token) || token.length > 8192) return 'accessToken is not a JWT';
if (!/^0x[0-9a-f]{40}$/.test(addr)) return 'primaryAddr is not a wallet address';
if (!Number.isFinite(expiresAt) || expiresAt - now < MIN_REMAINING_MS) return 'token already expired';
if (expiresAt - now > MAX_REMAINING_MS) return 'expiresAt is implausibly far away';
if (refused().has(token.slice(-32))) return "that is one Vest account's token, not the login's — reload the Vest tab, or update the extension";
const prev = store().get(addr);
if (prev && prev.expiresAt > expiresAt && prev.accessToken !== token) return null; // keep the fresher one
if (prev && prev.accessToken !== token) previous().set(addr, prev);
store().set(addr, {
accessToken: token,
expiresAt,
primaryAddr: addr,
userId: typeof input.userId === 'string' ? input.userId : '',
receivedAt: now,
});
return null;
}
/** The live session for a wallet, or null when none has been relayed or it
* is about to expire. */
export function vestSessionFor(primaryAddr: string, now = Date.now(), minRemainingMs = MIN_REMAINING_MS): VestSession | null {
const s = store().get(primaryAddr.trim().toLowerCase());
return s && s.expiresAt - now >= minRemainingMs ? s : null;
}
/** The client found the wallet's token to be one account's own (see
* accountScopedToken): drop it, and take it no more if it's relayed again —
* the login's own token, when the Vest app next renews it, takes its place. */
export function refuseVestSession(primaryAddr: string, token: string, now = Date.now()): void {
const key = primaryAddr.trim().toLowerCase();
refused().set(token.slice(-32), now);
while (refused().size > REFUSED_KEEP) refused().delete(refused().keys().next().value as string);
if (store().get(key)?.accessToken !== token) return;
store().delete(key);
// Back to the token it replaced, if that one is still good.
const before = previous().get(key);
previous().delete(key);
if (before && !refused().has(before.accessToken.slice(-32)) && before.expiresAt - now >= MIN_REMAINING_MS) store().set(key, before);
}
/** Drop a wallet's session — the gateway refused it. */
export function dropVestSession(primaryAddr: string, token: string): void {
const key = primaryAddr.trim().toLowerCase();
if (store().get(key)?.accessToken === token) store().delete(key);
}
/** Every relayed session, newest first, without tokens. */
export function listVestSessions(): VestSessionInfo[] {
return [...store().values()]
.sort((a, b) => b.receivedAt - a.receivedAt)
.map(({ primaryAddr, userId, expiresAt, receivedAt }) => ({ primaryAddr, userId, expiresAt, receivedAt }));
}
+42
View File
@@ -0,0 +1,42 @@
/**
* Binds the Vest login the extension relays to this machine's Vest firm, so
* nobody has to copy a wallet address into the dashboard.
*
* A Vest firm's only credential here is the wallet of the login whose session
* it trades (lib/vest-session.ts). One person per machine, and Vest allows one
* profile per person, so the Vest tab signed in on this machine is the one to
* trade. It is bound automatically when:
* - the firm has no credential yet. A saved one is never replaced: someone
* chose it, and a different login in the tab is a mistake to flag (the
* client says so), not one to follow;
* - exactly one wallet has a live relayed session. Two logins in two tabs
* can't be told apart, so the dashboard's one-click form decides.
*/
import { getCachedCatalog } from './firm-catalog';
import { getCredentials, setCredentials } from './db';
import { listVestSessions } from './vest-session';
import { reconcileClients } from './clients';
import { scheduleBackupPush } from './backup-sync';
/** Bind the relayed wallet to every Vest firm here that has no credential.
* Returns the names of the firms it bound. */
export function autoBindVestWallet(now = Date.now()): string[] {
const live = [...new Set(listVestSessions().filter((s) => s.expiresAt > now).map((s) => s.primaryAddr))];
if (live.length !== 1) return [];
const wallet = live[0];
const bound: string[] = [];
for (const firm of getCachedCatalog()) {
if (firm.platform !== 'vest') continue;
const creds = getCredentials(firm.id);
if (creds && (creds.apiKey || creds.username || creds.password)) continue;
setCredentials(firm.id, firm.name, '', '', wallet);
bound.push(firm.name);
console.log(`[vest] ${firm.name}: bound to wallet ${wallet}, the Vest login signed in on this machine`);
}
if (bound.length > 0) {
reconcileClients();
scheduleBackupPush();
}
return bound;
}
+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"
"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 scripts/check-extension-vest.mjs"
},
"dependencies": {
"@microsoft/signalr": "^10.0.11",
+283
View File
@@ -0,0 +1,283 @@
// Checks for the account status the reporter sends the master's ledger
// (lib/platform-client.ts accountStatus and the helpers around it).
//
// npm run check
//
// .mjs on purpose: Next's tsconfig includes every .ts file, and a .ts test
// importing another .ts by extension would trip the build's type check. Node
// strips the types from lib/platform-client.ts at import time (its only
// imports are `import type`).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
accountStatus, accountStatusFields, brokerFacts, emptyFirmStats, firmStatusFields, firstFundDate, keptRuleIdsField, legacyDead,
REPORT_STATUS_VERSION, SYNC_STALE_MS,
} from '../lib/platform-client.ts';
const FLOORS = { negative: -2000, zero: 0, positive: 48_000 };
const OFFSETS = { above: 1500, at: 0, below: -1500 };
/** Every case of {cash present/missing, floor <0/0/>0, cash above/at/below the
* floor, active, history ready}. */
function* matrix() {
for (const [floorName, floor] of Object.entries(FLOORS)) {
for (const [offsetName, offset] of Object.entries(OFFSETS)) {
for (const missing of [false, true]) {
for (const active of [true, false]) {
for (const historyReady of [true, false]) {
const cash = missing ? undefined : floor + offset;
yield { label: `${floorName} floor, cash ${missing ? 'missing' : offsetName}, active ${active}, history ${historyReady}`, cash, floor, active, historyReady };
}
}
}
}
}
}
test('legacyDead is the test reporter.ts:114 used before', () => {
for (const c of matrix()) {
const cashOrZero = c.cash ?? 0;
// Copied from reporter.ts at 8747dd6: autoLiqThreshold !== 0 && cash.amount <= autoLiqThreshold
assert.equal(legacyDead(cashOrZero, c.floor), c.floor !== 0 && cashOrZero <= c.floor, c.label);
}
});
test('dead is exactly the legacy test whenever there is a balance', () => {
let cases = 0;
for (const c of matrix()) {
if (c.cash === undefined) continue;
cases++;
const { status } = accountStatus(c);
assert.equal(status === 'dead', legacyDead(c.cash, c.floor), c.label);
}
assert.equal(cases, 36);
// A balance of exactly 0 is a balance, not a missing one.
assert.deepEqual(accountStatus({ cash: 0, floor: 48_000, active: true, historyReady: true }), { status: 'dead' });
assert.deepEqual(accountStatus({ cash: 0, floor: 0, active: true, historyReady: true }), { status: 'live' });
});
test('a missing balance is unknown, never dead and never 0', () => {
for (const c of matrix()) {
if (c.cash !== undefined) continue;
assert.deepEqual(accountStatus(c), { status: 'unknown', reason: 'cash_missing' }, c.label);
}
assert.deepEqual(accountStatus({ cash: null, floor: 48_000, active: true, historyReady: true }), { status: 'unknown', reason: 'cash_missing' });
});
test('the legacy invariant: the old dead count = dead + cash-missing with a positive floor', () => {
for (const c of matrix()) {
const { status, reason } = accountStatus(c);
const oldDead = legacyDead(c.cash ?? 0, c.floor);
const newDead = status === 'dead' || (status === 'unknown' && reason === 'cash_missing' && c.floor > 0);
assert.equal(newDead, oldDead, c.label);
}
});
test('priority: dead, then history pending, then locked, then live', () => {
const base = { cash: 50_000, floor: 48_000, active: true, historyReady: true };
assert.deepEqual(accountStatus(base), { status: 'live' });
assert.deepEqual(accountStatus({ ...base, active: false }), { status: 'locked', reason: 'inactive' });
assert.deepEqual(accountStatus({ ...base, active: false, inactiveReason: 'liquidate_only' }), { status: 'locked', reason: 'liquidate_only' });
assert.deepEqual(accountStatus({ ...base, active: false, historyReady: false }), { status: 'history_pending' });
assert.deepEqual(accountStatus({ ...base, cash: 47_000, active: false, historyReady: false }), { status: 'dead' });
assert.deepEqual(accountStatus({ ...base, cash: 47_000, deadReason: 'terminal:TERMINATED' }), { status: 'dead', reason: 'terminal:TERMINATED' });
// A floor of 0 is no floor: never dead, whatever the balance.
assert.equal(accountStatus({ ...base, cash: -10, floor: 0 }).status, 'live');
});
test('broker facts per platform', () => {
assert.deepEqual(brokerFacts('tradovate', { active: true, closed: false, archived: false, restricted: false }), { inactiveReason: 'liquidate_only' });
assert.deepEqual(brokerFacts('tradovate', { active: false, closed: true, archived: true, restricted: false }),
{ inactiveReason: 'liquidate_only', brokerStatus: 'closed,archived' });
assert.deepEqual(brokerFacts('topstepx', { active: true }), { inactiveReason: 'cannot_trade' });
assert.deepEqual(brokerFacts('topstepx', { active: false }), { inactiveReason: 'cannot_trade', brokerStatus: 'canTrade:false' });
const registered = Date.parse('2026-10-03T03:00:00Z');
assert.deepEqual(brokerFacts('dxtrade', { active: true }, { status: 'FULL_TRADING', registrationTime: registered }),
{ inactiveReason: 'status:FULL_TRADING', deadReason: 'terminal:FULL_TRADING', registeredAt: registered });
assert.deepEqual(brokerFacts('dxtrade', { active: false }, { status: 'CLOSE_ONLY', registrationTime: NaN }),
{ inactiveReason: 'close_only', deadReason: 'terminal:CLOSE_ONLY', brokerStatus: 'CLOSE_ONLY' });
assert.deepEqual(brokerFacts('dxtrade', { active: false }, undefined), { inactiveReason: 'inactive' });
});
test('a DXtrade account the firm terminated reads dead with the terminal status', () => {
// dxtrade-class.ts sets the floor to the balance for DEAD_STATUSES.
const facts = brokerFacts('dxtrade', { active: false }, { status: 'TERMINATED', registrationTime: NaN });
const fields = accountStatusFields({ cash: 94_210.5, floor: 94_210.5, active: false, historyReady: true, accountTypeId: null, fundTransactions: [], ...facts });
assert.equal(fields.status, 'dead');
assert.equal(fields.reason, 'terminal:TERMINATED');
assert.equal(fields.brokerStatus, 'TERMINATED');
// A CLOSE_ONLY account is inactive (floor 0 there), not dead.
const closeOnly = accountStatusFields({ cash: 94_210.5, floor: 0, active: false, historyReady: true, accountTypeId: 't', fundTransactions: [],
...brokerFacts('dxtrade', { active: false }, { status: 'CLOSE_ONLY', registrationTime: NaN }) });
assert.deepEqual([closeOnly.status, closeOnly.reason], ['locked', 'close_only']);
});
test('per-account fields: defaults and nulls are left out', () => {
const fields = accountStatusFields({ cash: undefined, floor: 0, active: true, historyReady: false, accountTypeId: null, fundTransactions: undefined, inactiveReason: 'liquidate_only' });
assert.deepEqual(fields, { status: 'unknown', reason: 'cash_missing' });
const live = accountStatusFields({
cash: 50_123.456, floor: 47_623.451, active: true, historyReady: true, accountTypeId: 'cc9d7145-f85c-4b61-9e90-b7091b6f7dcc',
fundTransactions: [{ date: '2026-09-30', amount: -1000 }, { date: '2026-09-25', amount: 50_000 }, { date: '2026-10-02', amount: 25 }],
inactiveReason: 'liquidate_only',
});
assert.deepEqual(live, {
status: 'live', balance: 50_123.46, floor: 47_623.45, accountTypeId: 'cc9d7145-f85c-4b61-9e90-b7091b6f7dcc',
firstFundDate: '2026-09-25', positiveFunds: 2,
});
// Fund history loaded with no positive rows: a count of 0, no date.
const none = accountStatusFields({ cash: 10, floor: 0, active: true, historyReady: true, accountTypeId: 't', fundTransactions: [] });
assert.deepEqual([none.positiveFunds, 'firstFundDate' in none], [0, false]);
});
test('DXtrade: the registration date (Chicago) stands in for the first fund date', () => {
const registeredAt = Date.parse('2026-10-03T03:00:00Z'); // Oct 2, 22:00 CDT
const input = { cash: 100_000, floor: 0, active: true, historyReady: true, accountTypeId: 't', registeredAt };
assert.equal(accountStatusFields({ ...input, fundTransactions: [] }).firstFundDate, '2026-10-02');
// A real fund row wins; and nothing until the fund history has loaded.
assert.equal(accountStatusFields({ ...input, fundTransactions: [{ date: '2026-10-05', amount: 100_000 }] }).firstFundDate, '2026-10-05');
assert.equal('firstFundDate' in accountStatusFields({ ...input, fundTransactions: undefined }), false);
// A time no Date can hold is no date — and must not throw.
for (const bad of [1.7e18, -1e16, NaN]) {
assert.equal('registeredAt' in brokerFacts('dxtrade', { active: true }, { status: 'FULL_TRADING', registrationTime: bad }), false);
assert.equal('firstFundDate' in accountStatusFields({ ...input, registeredAt: bad, fundTransactions: [] }), false);
}
assert.deepEqual(firmStatusFields('f', true, 1e17), { statusVersion: 1, firmId: 'f' });
});
test('firstFundDate: the helper the report and the rules-date evidence share', () => {
const registeredAt = Date.parse('2026-10-03T03:00:00Z'); // Oct 2, 22:00 CDT
assert.equal(firstFundDate(undefined, registeredAt), undefined);
assert.equal(firstFundDate([], registeredAt), '2026-10-02');
assert.equal(firstFundDate([], undefined), undefined);
assert.equal(firstFundDate([{ date: '2026-10-05T00:00:00', amount: 100_000 }, { date: '2026-10-04', amount: -50 }], registeredAt), '2026-10-05');
assert.equal(firstFundDate([{ date: '2026-10-09', amount: 25 }, { date: '2026-10-05', amount: 100_000 }], undefined), '2026-10-05');
// Always what accountStatusFields reports.
for (const funds of [undefined, [], [{ date: '2026-09-25', amount: 50_000 }], [{ date: '2026-09-30', amount: -1 }]]) {
for (const reg of [undefined, registeredAt, NaN]) {
const fields = accountStatusFields({ cash: 1, floor: 0, active: true, historyReady: true, accountTypeId: 't', fundTransactions: funds, registeredAt: reg });
assert.equal(fields.firstFundDate, firstFundDate(funds, reg));
}
}
});
test('firm-level fields: statusVersion and firmId always, syncedAt once synced', () => {
const at = Date.parse('2026-10-06T14:00:00Z');
assert.deepEqual(firmStatusFields('f-1', true, at), { statusVersion: 1, firmId: 'f-1', syncedAt: '2026-10-06T14:00:00.000Z' });
assert.deepEqual(firmStatusFields('f-1', true, 0), { statusVersion: 1, firmId: 'f-1' });
assert.deepEqual(firmStatusFields('f-1', false, at), { statusVersion: 1, firmId: 'f-1' });
assert.equal(REPORT_STATUS_VERSION, 1);
assert.equal(SYNC_STALE_MS, 5 * 60 * 1000);
});
test('keptRuleIds: present (even empty) when the catalog vouches for them, absent otherwise', () => {
assert.deepEqual(keptRuleIdsField(null), {});
assert.deepEqual(keptRuleIdsField([]), { keptRuleIds: [] });
assert.deepEqual(keptRuleIdsField(['k1', 'k2']), { keptRuleIds: ['k1', 'k2'] });
});
test('the no-accounts branch keeps every existing key and value', () => {
const firm = { id: '71016d02-752f-4078-9e24-e74c9d75d1c8', name: 'Tradeify' };
// What reporter.ts sent before (its :46-56 branch), for each client state.
const legacy = (client) => ({
firm: firm.name, totalAccounts: 0, accountsTraded: 0, inTrade: 0,
targetHit: 0, partial: 0, loss: 0, personalDll: 0, completed: 0,
...(client?.syncComplete ? { accounts: [] } : {}),
accountTypes: [],
});
const at = Date.parse('2026-10-06T14:00:00Z');
const states = [undefined, { syncComplete: false, lastSyncAt: 0 }, { syncComplete: false, lastSyncAt: at }, { syncComplete: true, lastSyncAt: 0 }, { syncComplete: true, lastSyncAt: at }];
// No kept-rule ids (an older cache: null, or not passed), none, some.
const keptCases = [undefined, null, [], ['3f0c2b7e-0000-4000-8000-000000000001']];
for (const client of states) {
for (const kept of keptCases) {
const got = kept === undefined ? emptyFirmStats(firm, client) : emptyFirmStats(firm, client, kept);
const old = legacy(client);
const label = `${JSON.stringify(client)} kept ${JSON.stringify(kept)}`;
for (const [key, value] of Object.entries(old)) assert.deepEqual(got[key], value, `${label}: ${key}`);
const added = Object.keys(got).filter((k) => !(k in old)).sort();
const synced = client?.syncComplete === true;
assert.equal(got.statusVersion, 1);
assert.equal(got.firmId, firm.id);
assert.equal('accounts' in got, synced);
assert.equal('syncedAt' in got, synced && client.lastSyncAt > 0);
assert.deepEqual(got.keptRuleIds, Array.isArray(kept) ? kept : undefined, label);
assert.deepEqual(added, ['firmId', 'statusVersion', ...(got.syncedAt ? ['syncedAt'] : []), ...(Array.isArray(kept) ? ['keptRuleIds'] : [])].sort(), label);
// The old keys stay first and in their order: only new keys follow.
assert.deepEqual(Object.keys(got).slice(0, Object.keys(old).length), Object.keys(old));
}
}
});
// ── Size: 4 firms × 500 accounts against the master socket's 1 MB frame ──────
const TYPE_ID = 'f5ad1527-8bd7-4ca0-9e35-bcadf78e52e9';
const pad = (n, w) => String(n).padStart(w, '0');
/** The entry reporter.ts sent before, with a realistic 14-character name. */
function legacyEntry(i) {
return { name: `TDFY${pad(i, 10)}`, size: 150_000, payoutable: false, withdrawals: 2 };
}
/** Every new field populated, worst realistic case per platform. */
function fullFields(platform, i) {
const balance = 149_823.45 - (i % 97);
if (platform === 'tradovate') {
return accountStatusFields({ cash: balance, floor: 147_500.12, active: false, historyReady: true, accountTypeId: TYPE_ID,
fundTransactions: [{ date: '2026-09-25', amount: 150_000 }, { date: '2026-10-01', amount: 25 }],
...brokerFacts('tradovate', { active: false, closed: true, archived: true, restricted: true }) });
}
if (platform === 'dxtrade') {
return accountStatusFields({ cash: balance, floor: balance, active: false, historyReady: true, accountTypeId: TYPE_ID,
fundTransactions: [{ date: '2026-09-25', amount: 150_000 }],
...brokerFacts('dxtrade', { active: false }, { status: 'TERMINATED', registrationTime: Date.parse('2026-09-25T15:00:00Z') }) });
}
return accountStatusFields({ cash: balance, floor: 147_500.12, active: false, historyReady: true, accountTypeId: TYPE_ID,
fundTransactions: [{ date: '2026-09-25', amount: 150_000 }],
...brokerFacts('topstepx', { active: false }) });
}
/** A typical live account: no reason, no broker status. */
function typicalFields(i) {
return accountStatusFields({ cash: 50_123.45 + (i % 89), floor: 47_623.45, active: true, historyReady: true, accountTypeId: TYPE_ID,
fundTransactions: [{ date: '2026-09-25', amount: 50_000 }], ...brokerFacts('tradovate', { active: true, closed: false, archived: false, restricted: false }) });
}
const added = (fields, i) => JSON.stringify({ ...legacyEntry(i), ...fields }).length - JSON.stringify(legacyEntry(i)).length;
const PLATFORMS = ['tradovate', 'tradovate', 'topstepx', 'dxtrade'];
const ALL_FIELDS = ['status', 'reason', 'balance', 'floor', 'accountTypeId', 'firstFundDate', 'positiveFunds', 'brokerStatus'];
test('size: the new fields add < 230 bytes per fully populated account, < 180 per typical live one', () => {
let worst = 0;
for (const platform of new Set(PLATFORMS)) {
assert.deepEqual(Object.keys(fullFields(platform, 0)).sort(), [...ALL_FIELDS].sort(), `${platform} entry is fully populated`);
assert.equal(fullFields(platform, 0).accountTypeId.length, 36);
for (let i = 0; i < 500; i++) worst = Math.max(worst, added(fullFields(platform, i), i));
}
assert.deepEqual(Object.keys(typicalFields(0)).sort(), ['accountTypeId', 'balance', 'firstFundDate', 'floor', 'positiveFunds', 'status']);
let typical = 0;
for (let i = 0; i < 500; i++) typical = Math.max(typical, added(typicalFields(i), i));
console.log(` # new fields per account: ${worst} B fully populated (worst platform), ${typical} B typical live`);
assert.ok(worst < 230, `fully populated adds ${worst} B`);
assert.ok(typical < 180, `typical live adds ${typical} B`);
});
test('size: 2000 fully populated accounts < 600 KB, the whole report body < 900 KB', () => {
const firms = PLATFORMS.map((platform, f) => ({
firm: `Firm ${f}`, totalAccounts: 500, accountsTraded: 500, inTrade: 0,
targetHit: 0, partial: 0, loss: 500, personalDll: 0, completed: 0,
accounts: Array.from({ length: 500 }, (_, i) => ({ ...legacyEntry(f * 500 + i), ...fullFields(platform, i) })),
accountTypes: [{ accountTypeId: TYPE_ID, label: '150K Eval', accountSize: 150_000, totalAccounts: 500, accountsTraded: 500, inTrade: 0, targetHit: 0, partial: 0, loss: 500 }],
...firmStatusFields(`00000000-0000-4000-8000-00000000000${f}`, true, Date.parse('2026-10-06T14:00:00Z')),
}));
const accountsBytes = JSON.stringify(firms.flatMap((f) => f.accounts)).length;
const body = JSON.stringify({
instance: 'Someone', id: '00000000-0000-4000-8000-000000000000', firms,
scheduler: { running: true, lastTicks: [] }, version: '8747dd6', updater: null,
}).length;
console.log(` # 2000 accounts: ${accountsBytes} B; report body: ${body} B`);
assert.ok(accountsBytes < 600_000, `accounts serialize to ${accountsBytes} B`);
assert.ok(body < 900_000, `report body is ${body} B`);
});
+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'));
});
+166
View File
@@ -0,0 +1,166 @@
// Checks for the extension's Vest relay: the hook in the Vest tab
// (extension/vest-relay.js) and the choice of which tab's token the background
// relays (extension/background.js pageVestSession / pickVestSession).
//
// npm run check
//
// The hook runs in a sandbox standing in for the Vest page — its own XHR and
// fetch, answered here; the background's functions are lifted out of its
// source, since it is a service worker and not a module. Nothing reaches Vest
// or Chrome. The answers are shaped like the Vest app's own (index bundle,
// 2026-10-29): a renewal's carries refreshTokenId, a sign-in's the login's
// refreshToken too, an account's renewal a refreshToken in its request.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const read = (path) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8');
const HOOK = read('extension/vest-relay.js');
const BACKGROUND = read('extension/background.js');
const GATEWAY = 'https://api-gateway.hz.vestmarkets.com';
const WALLET = '0x7ce10771819f80130e204ddb5ca1522e48a9b451';
const answer = (extra) => JSON.stringify({ accessToken: 'eyJ.login.sig', accessExpiresAtMs: 1_800_000_000_000, primaryAddr: WALLET, userId: 'u', ...extra });
/** A Vest page with the hook run in it (after `before`, a hook already
* there): `xhr(url, body, status, text)` and `fetch(...)` play one of the
* app's requests through it. */
function page(before = {}) {
class FakeXHR {
constructor() { this.listeners = []; this.responseType = ''; }
open(method, url) { this.url = url; }
send() {}
addEventListener(type, fn) { if (type === 'loadend') this.listeners.push(fn); }
}
const answers = [];
const sandbox = {
XMLHttpRequest: FakeXHR, URL,
fetch: async () => answers.shift(),
...before,
};
sandbox.window = sandbox;
vm.createContext(sandbox);
vm.runInContext(HOOK, sandbox);
return {
sandbox,
run: () => vm.runInContext(HOOK, sandbox),
xhr(url, body, status, text) {
const x = new sandbox.XMLHttpRequest();
x.open('POST', url);
x.send(body);
x.status = status;
x.responseText = text;
for (const fn of x.listeners) fn();
},
async fetch(url, body, status, text) {
answers.push({ status, clone: () => ({ text: async () => text }) });
await sandbox.window.fetch(url, { method: 'POST', body });
await new Promise((resolve) => setImmediate(resolve));
},
};
}
test("the hook keeps the login's renewed token with its session id, and an account's never", async () => {
const p = page();
assert.equal(p.sandbox.__afVestHook, 2);
p.xhr(`${GATEWAY}/v3/auth/refresh`, JSON.stringify({ requestId: 'r1', rememberMe: true }), 200, answer({ refreshTokenId: 'rt-1' }));
assert.equal(p.sandbox.__afVestLogin.accessToken, 'eyJ.login.sig');
assert.equal(p.sandbox.__afVestLogin.refreshTokenId, 'rt-1');
assert.equal(p.sandbox.__afVestLogin.primaryAddr, WALLET);
// An account's renewal, by its request, then by its answer alone.
p.xhr(`${GATEWAY}/v3/auth/refresh`, JSON.stringify({ refreshToken: 'acct-rt', requestId: 'r2' }), 200, answer({ accessToken: 'eyJ.acct.sig', refreshTokenId: 'rt-a' }));
await p.fetch(`${GATEWAY}/v3/auth/refresh`, undefined, 200, answer({ accessToken: 'eyJ.acct2.sig', refreshToken: 'acct-rt', refreshTokenId: 'rt-b' }));
assert.equal(p.sandbox.__afVestLogin.accessToken, 'eyJ.login.sig');
// The login's next renewal, through fetch; a refused one leaves it be.
await p.fetch(`${GATEWAY}/v3/auth/refresh`, JSON.stringify({ requestId: 'r3' }), 200, answer({ accessToken: 'eyJ.login2.sig', refreshTokenId: 'rt-2' }));
p.xhr(`${GATEWAY}/v3/auth/refresh`, JSON.stringify({ requestId: 'r4' }), 401, answer({ accessToken: 'eyJ.no.sig', refreshTokenId: 'rt-x' }));
assert.equal(p.sandbox.__afVestLogin.accessToken, 'eyJ.login2.sig');
assert.equal(p.sandbox.__afVestLogin.refreshTokenId, 'rt-2');
});
test("a sign-in's token is taken, though its answer carries the login's refresh token", () => {
const p = page();
p.xhr(`${GATEWAY}/v3/auth/register`, JSON.stringify({ primaryAddr: WALLET }), 200, answer({ accessToken: 'eyJ.signin.sig', refreshToken: 'login-rt', refreshTokenId: 'rt-s' }));
assert.equal(p.sandbox.__afVestLogin.accessToken, 'eyJ.signin.sig');
assert.equal(p.sandbox.__afVestLogin.refreshTokenId, 'rt-s');
});
test('the hook installs once, and beside a version-1 hook', () => {
const p = page({ __afVestHook: true, __afVestSession: { accessToken: 'eyJ.old.sig', expiresAt: 1 } });
assert.equal(p.sandbox.__afVestHook, 2);
const send = p.sandbox.XMLHttpRequest.prototype.send;
p.run();
assert.equal(p.sandbox.XMLHttpRequest.prototype.send, send);
});
// The background's own functions, out of its source.
function lift(name) {
const m = BACKGROUND.match(new RegExp(`^function ${name}\\([\\s\\S]*?^}`, 'm'));
assert.ok(m, `${name} not found in background.js`);
return m[0];
}
const { isCurrentVestSession, pickVestSession } = new Function(`${lift('isCurrentVestSession')}\n${lift('pickVestSession')}\nreturn { isCurrentVestSession, pickVestSession };`)();
const pageVestSession = (window, stored) => new Function('window', 'localStorage', `${lift('pageVestSession')}\nreturn pageVestSession();`)(
window, { getItem: (key) => (key === 'vest-auth-session' ? stored : null) },
);
const later = (min) => Date.now() + min * 60_000;
const session = (token, expiresAt, refreshTokenId) => ({ accessToken: token, expiresAt, primaryAddr: WALLET, userId: 'u', ...(refreshTokenId !== undefined ? { refreshTokenId } : {}) });
const record = (refreshTokenId, accessTokenExpiresAt) => JSON.stringify({ refreshTokenId, accessTokenExpiresAt, refreshTokenExpiresAt: later(43200), rememberMe: true });
test("a tab reports its live token (version 2's, else version 1's) and the app's current session", () => {
const expiresAt = later(12);
let out = pageVestSession({ __afVestHook: 2, __afVestLogin: session('eyJ.v2.sig', expiresAt, 'rt-2'), __afVestSession: session('eyJ.v1.sig', expiresAt) }, record('rt-2', expiresAt));
assert.equal(out.hooked, true);
assert.equal(out.session.accessToken, 'eyJ.v2.sig');
assert.deepEqual(out.current, { refreshTokenId: 'rt-2', accessTokenExpiresAt: expiresAt });
// Hooked by version 1 only: its token, and the tab asks for version 2.
out = pageVestSession({ __afVestHook: true, __afVestSession: session('eyJ.v1.sig', later(9)) }, record('rt-3', later(14)));
assert.equal(out.hooked, false);
assert.equal(out.session.accessToken, 'eyJ.v1.sig');
// A 0.17.0 hook kept an account's renewal too: version 2's login token
// wins over it, though it expires sooner.
out = pageVestSession({ __afVestHook: 2, __afVestLogin: session('eyJ.v2.sig', later(5), 'rt-1'), __afVestSession: session('eyJ.acct.sig', later(13)) }, null);
assert.equal(out.session.accessToken, 'eyJ.v2.sig');
assert.equal(out.current, null);
// Nearly expired, or a record that doesn't read.
out = pageVestSession({ __afVestHook: 2, __afVestLogin: session('eyJ.v2.sig', Date.now() + 20_000, 'rt-1') }, '{nope');
assert.equal(out.session, null);
assert.equal(out.current, null);
});
test("the token relayed is the one the current session holds, whichever tab has it — not the first tab's", () => {
// Tab 1 renewed first; tab 2 renewed after it, so the session is tab 2's.
const tab1 = { hooked: true, session: session('eyJ.tab1.sig', later(6), 'rt-1'), current: { refreshTokenId: 'rt-2', accessTokenExpiresAt: later(13) } };
const tab2 = { hooked: true, session: session('eyJ.tab2.sig', later(13), 'rt-2'), current: { refreshTokenId: 'rt-2', accessTokenExpiresAt: later(13) } };
assert.deepEqual(pickVestSession([tab1, tab2]), { session: tab2.session, current: true });
assert.equal(isCurrentVestSession(tab1.session, tab1.current), false);
// Tab 1 renews again: now its token is the current one.
const rt3 = { refreshTokenId: 'rt-3', accessTokenExpiresAt: later(15) };
const tab1b = { ...tab1, session: session('eyJ.tab1b.sig', later(15), 'rt-3'), current: rt3 };
assert.equal(pickVestSession([{ ...tab2, current: rt3 }, tab1b]).session.accessToken, 'eyJ.tab1b.sig');
// Version-1 hooks keep no id: matched by the expiry the app keeps with it.
const v1a = { hooked: false, session: session('eyJ.v1a.sig', later(4)), current: { refreshTokenId: 'rt-9', accessTokenExpiresAt: later(11) } };
const v1b = { hooked: false, session: session('eyJ.v1b.sig', v1a.current.accessTokenExpiresAt), current: v1a.current };
assert.deepEqual(pickVestSession([v1a, v1b]), { session: v1b.session, current: true });
});
test('with no tab holding the current session the newest is relayed, and said; with no record, unjudged', () => {
const current = { refreshTokenId: 'rt-5', accessTokenExpiresAt: later(14) };
const a = { hooked: true, session: session('eyJ.a.sig', later(3), 'rt-3'), current };
const b = { hooked: true, session: session('eyJ.b.sig', later(8), 'rt-4'), current };
assert.deepEqual(pickVestSession([a, b]), { session: b.session, current: false });
assert.deepEqual(pickVestSession([{ ...a, current: null }, { ...b, current: null }]), { session: b.session, current: null });
// A tab with no token yet still shows the record.
assert.equal(pickVestSession([{ hooked: true, session: null, current }, { ...b, current: null }]).current, false);
assert.equal(pickVestSession([{ hooked: true, session: null, current }]), null);
assert.equal(pickVestSession([]), null);
});
+62 -1
View File
@@ -10,9 +10,10 @@ import assert from 'node:assert/strict';
import {
CME_SPECS, CME_ROOTS, POINT_VALUES, MICRO_OF, tickValue, spreadDollars, dailyMoveDollars, futuresContracts,
expectedHoldHours, sessionProgress, drawChances, pickByChance, housekeepingRoot, sanitizeInstruments,
resolveAllowlist, MAX_HOLD_HOURS, DRAW_FLOOR, DEFAULT_MAX_ENTRY_SPREAD, spreadTooWide,
resolveAllowlist, MAX_HOLD_HOURS, DRAW_FLOOR, DEFAULT_MAX_ENTRY_SPREAD, spreadTooWide, sanitizeInstrumentLimits,
} from '../lib/instrument-specs.ts';
import { mergeTopstepxQuote, parseTradovateFrame, bookSpread } from '../lib/quote-helpers.ts';
import { TOPSTEP_COMMISSION_PER_SIDE, topstepxProductCode, topstepxRoot, topstepCommission } from '../lib/topstep-fees.ts';
// Deterministic PRNG so a failure reproduces.
function rng(seed) {
@@ -234,3 +235,63 @@ test('the draw leaves out what is over the spread limit', () => {
// All of them wide: nothing to draw.
assert.deepEqual(drawChances(cands.map((c) => ({ ...c, wide: true })), 0.5).chances, []);
});
test('a firm\'s per-instrument limit caps the contracts, with the account type\'s max', () => {
// One per $1,000 of leg ...
assert.equal(futuresContracts(4200, 0), 5);
// ... capped by the account type's max, the firm's limit, whichever is lower.
assert.equal(futuresContracts(4200, 0, 3), 3);
assert.equal(futuresContracts(4200, 4, 2), 2);
assert.equal(futuresContracts(4200, 2, 4), 2);
assert.equal(futuresContracts(4200, 0, 0), 5);
// Never under one contract.
assert.equal(futuresContracts(300, 0, 5), 1);
// A capped leg holds longer: 5 contracts on NQ vs a cap of 2.
const free = expectedHoldHours({ leg: 5000, stopDollars: Infinity, contracts: futuresContracts(5000, 0), dailyMove: 5227 });
const capped = expectedHoldHours({ leg: 5000, stopDollars: Infinity, contracts: futuresContracts(5000, 0, 2), dailyMove: 5227 });
assert.ok(Math.abs(capped / free - 6.25) < 1e-9, `${capped} vs ${free}`);
});
test('instrument limits off the catalog: CME roots, whole numbers 1-1000', () => {
assert.deepEqual(sanitizeInstrumentLimits({ NQ: 15, CL: 5, GC: 0, ES: 2.5, ZZ: 3, SI: 2000, YM: '4' }), { NQ: 15, CL: 5 });
assert.deepEqual(sanitizeInstrumentLimits(null), {});
assert.deepEqual(sanitizeInstrumentLimits(['NQ']), {});
});
test('Topstep commission: every root, a micro at half the full-size rate', () => {
assert.deepEqual(Object.keys(TOPSTEP_COMMISSION_PER_SIDE).sort(), [...CME_ROOTS].sort());
for (const root of CME_ROOTS) assert.equal(TOPSTEP_COMMISSION_PER_SIDE[root], CME_SPECS[root].micro ? 0.25 : 0.5, root);
});
test('Topstep commission: TopstepX contract ids to roots', () => {
// Codes seen on real fills.
assert.equal(topstepxRoot('CON.F.US.ENQ.Z26'), 'NQ');
assert.equal(topstepxRoot('CON.F.US.MNQ.Z26'), 'MNQ');
assert.equal(topstepxRoot('CON.F.US.GCE.Z26'), 'GC');
assert.equal(topstepxRoot('CON.F.US.CLE.X26'), 'CL');
assert.equal(topstepxProductCode('CON.F.US.ENQ.Z26'), 'ENQ');
// Not a contract id, or a product with no entry.
assert.equal(topstepxRoot('NQZ6'), null);
assert.equal(topstepxRoot(undefined), null);
assert.equal(topstepxRoot('CON.F.US.MBT.Z26'), null);
// A contract the client resolved names its own code; a stray key doesn't.
assert.equal(topstepxRoot('CON.F.US.MBT.Z26', new Map([['MBT', 'MES']])), 'MES');
assert.equal(topstepxRoot('CON.F.US.ENQ.Z26', new Map([['ENQ', '*']])), 'NQ');
});
test('Topstep commission: reported fees plus commission make the published round turn', () => {
// TopstepX reports exchange + NFA a side; Topstep's page totals a round turn.
const roundTurn = (root, reportedPerSide, contracts) =>
Math.round((2 * reportedPerSide * contracts + 2 * topstepCommission(root, contracts)) * 100) / 100;
assert.equal(roundTurn('NQ', 1.39, 1), 3.78);
assert.equal(roundTurn('MNQ', 0.36, 1), 1.22);
assert.equal(roundTurn('GC', 1.66, 1), 4.32);
assert.equal(roundTurn('CL', 1.51, 1), 4.02);
assert.equal(roundTurn('NQ', 1.39, 3), 11.34);
// A sell fill's size is still a cost; an unknown product pays the full-size rate.
assert.equal(topstepCommission('NQ', -3), 1.5);
assert.equal(topstepCommission(null, 2), 1);
// 2026-10-18, an XFA $27.50 under its books: 26 full-size round turns and 3 MNQ.
const missing = 26 * 2 * topstepCommission('NQ', 1) + 3 * 2 * topstepCommission('MNQ', 1);
assert.equal(missing, 27.5);
});
+517
View File
@@ -0,0 +1,517 @@
// Checks for kept rules — which of an account type's rule sets an account
// trades on (lib/kept-rules.ts).
//
// npm run check
//
// lib/kept-rules.ts has only `import type`s, so Node strips its types and
// loads it directly; .mjs keeps this file out of Next's type check.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
RULE_FIELDS, IDENTITY_FIELDS, PROJECTION_FIELDS, normalizeAccountType, pickKeptRules, overlayKept, ruleDiffs,
stampRulesDate, evidenceRulesDate, lowersRulesDate, sortableIso, chicagoDay, chicagoDayStart, evidenceDay,
openingEvidence, identityStillMatches, pickedDayRulesDate, unchangedDaySaves,
} from '../lib/kept-rules.ts';
const LIVE = {
id: 'live-x', firmId: 'firm-1', label: '50K Eval', prefix: 'TDFY', accountSize: 50_000,
profitTarget: 2500, consistency: 0.4, minDayPnL: 150, minTradingDays: 3, maxLoss: 2000, maxPositionSize: 5,
targetSameEquity: false, splitEnabled: true, minLegs: 2, firstDayStop: true, minDayStop: false, combineCmeHolidays: false,
withdrawalStages: [{ profit: 2000, consistency: 0.4, minTradingDays: 5, drain: false, minDayPnL: 0 }],
payoutable: true, trailingDrawdown: 2000, expectedPayout: 1000, withdrawalPct: 0.5, payoutCap: 3000, profitSplit: 0.9,
dailyLossLimit: 1000, stagesSameDay: false, tradesEveryDay: false, updatedAt: '2026-10-18T14:05:00.000Z',
};
/** `obj` without `keys`. */
const omit = (obj, keys) => Object.fromEntries(Object.entries(obj).filter(([k]) => !keys.includes(k)));
/** A kept set as the master builds it: the live type, the old rules on top. */
function keptOf(rules, window, id = 'kept-1') {
return {
...LIVE, ...rules, id, keptFrom: LIVE.id, createdAt: '2026-01-01T00:00:00.000Z',
openedAt: window.openedAt, closedAt: window.closedAt,
};
}
// ── field classes ───────────────────────────────────────────────────────────
test('field classes: the spec\'s lists, identical to the master\'s, and disjoint', () => {
assert.deepEqual([...RULE_FIELDS], [
'profitTarget', 'consistency', 'minDayPnL', 'minTradingDays', 'maxLoss', 'maxPositionSize', 'targetSameEquity',
'splitEnabled', 'minLegs', 'firstDayStop', 'minDayStop', 'combineCmeHolidays', 'withdrawalStages', 'payoutable',
'trailingDrawdown', 'dailyLossLimit', 'stagesSameDay',
]);
assert.deepEqual([...IDENTITY_FIELDS], ['prefix', 'accountSize']);
assert.deepEqual([...PROJECTION_FIELDS], ['expectedPayout', 'withdrawalPct', 'payoutCap', 'profitSplit']);
const all = [...RULE_FIELDS, ...IDENTITY_FIELDS, ...PROJECTION_FIELDS, 'label'];
assert.equal(new Set(all).size, all.length);
// Every field of a catalog account type is in exactly one class, or is
// bookkeeping the live type always owns.
const bookkeeping = ['id', 'firmId', 'updatedAt', 'tradesEveryDay'];
assert.deepEqual(Object.keys(LIVE).filter((k) => !all.includes(k) && !bookkeeping.includes(k)), []);
});
// ── normalizeAccountType: exactly the old validatePayload callback ──────────
/** lib/firm-catalog.ts validatePayload's per-type callback before the move
* (git show HEAD:lib/firm-catalog.ts), verbatim but for the types. */
function legacyNormalize(t, j, tradesEveryDay) {
const nonNegative = (value) => (typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0);
const isStages = (value) => Array.isArray(value) && value.every((s) =>
typeof s === 'object' && s !== null
&& typeof s.profit === 'number'
&& typeof s.consistency === 'number'
&& typeof s.minTradingDays === 'number');
if (typeof t !== 'object' || t === null) throw new Error(`account type ${j} is not an object`);
const type = t;
for (const key of ['id', 'firmId', 'label', 'prefix', 'updatedAt']) {
if (typeof type[key] !== 'string') throw new Error(`account type ${j} is missing ${key}`);
}
for (const key of [
'accountSize', 'profitTarget', 'consistency', 'minDayPnL',
'minTradingDays', 'maxLoss', 'maxPositionSize',
]) {
if (typeof type[key] !== 'number') throw new Error(`account type ${j} is missing ${key}`);
}
if (!isStages(type.withdrawalStages)) throw new Error(`account type ${j} has bad withdrawalStages`);
return {
...type,
splitEnabled: type.splitEnabled !== false,
combineCmeHolidays: type.combineCmeHolidays === true && !tradesEveryDay,
minLegs: typeof type.minLegs === 'number' ? type.minLegs : 0,
firstDayStop: type.firstDayStop === true,
minDayStop: type.minDayStop === true,
dailyLossLimit: nonNegative(type.dailyLossLimit),
stagesSameDay: type.stagesSameDay === true,
tradesEveryDay,
};
}
function outcome(fn) {
try {
return { ok: JSON.stringify(fn()) };
} catch (err) {
return { error: err.message };
}
}
test('normalizeAccountType is the old per-type validation, byte for byte (values, key order, errors)', () => {
const wire = omit(LIVE, ['tradesEveryDay']);
const variants = [
wire,
// An old master: none of the lenient fields.
omit(wire, ['splitEnabled', 'minLegs', 'firstDayStop', 'minDayStop', 'combineCmeHolidays', 'dailyLossLimit']),
{ ...wire, splitEnabled: false, combineCmeHolidays: true, minLegs: '3', firstDayStop: 'yes', dailyLossLimit: -5 },
{ ...wire, dailyLossLimit: NaN, minDayStop: true, extra: { nested: [1, 2] } },
{ ...wire, withdrawalStages: [] },
// Malformed: each must throw the same message.
null, 'type', 42,
{ ...wire, id: 7 }, { ...wire, label: undefined }, { ...wire, updatedAt: null },
{ ...wire, accountSize: '50000' }, { ...wire, maxPositionSize: undefined },
{ ...wire, withdrawalStages: [{ profit: 1, consistency: 0.5 }] }, { ...wire, withdrawalStages: 'none' },
{ ...wire, withdrawalStages: [null] },
];
let errors = 0;
for (const tradesEveryDay of [false, true]) {
variants.forEach((v, j) => {
const legacy = outcome(() => legacyNormalize(v, j, tradesEveryDay));
const now = outcome(() => normalizeAccountType(v, { tradesEveryDay, where: `account type ${j}` }));
assert.deepEqual(now, legacy, `variant ${j}, every day ${tradesEveryDay}`);
if (legacy.error) errors++;
});
}
assert.equal(errors, 2 * 11);
// The kept-set wording only changes the name in the message.
assert.throws(() => normalizeAccountType({ ...wire, prefix: 1 }, { tradesEveryDay: false, where: 'kept rules 0 of Tradeify' }),
{ message: 'kept rules 0 of Tradeify is missing prefix' });
});
// ── pickKeptRules ───────────────────────────────────────────────────────────
const G1 = { id: 'g1', openedAt: null, closedAt: '2026-10-02T15:00:00.000Z' };
const G2 = { id: 'g2', openedAt: '2026-10-02T15:00:00.000Z', closedAt: '2026-10-18T14:05:00.000Z' };
const G3 = { id: 'g3', openedAt: '2026-10-18T14:05:00.000Z', closedAt: '2026-10-18T19:30:00.000Z' };
const GENERATIONS = [G1, G2, G3];
test('pickKeptRules: the window holding the date, [openedAt, closedAt)', () => {
const pick = (d, sets = GENERATIONS) => pickKeptRules(sets, d)?.id ?? null;
// No lower bound on the first generation.
assert.equal(pick('2020-01-01T00:00:00.000Z'), 'g1');
assert.equal(pick('2026-10-02T14:59:59.999Z'), 'g1');
// A date exactly at a cutoff is on the newer side: closedAt excluded,
// openedAt included.
assert.equal(pick('2026-10-02T15:00:00.000Z'), 'g2');
assert.equal(pick('2026-10-18T14:04:59.999Z'), 'g2');
assert.equal(pick('2026-10-18T14:05:00.000Z'), 'g3');
assert.equal(pick('2026-10-18T19:29:59.999Z'), 'g3');
// From the last cutoff on: the current rules.
assert.equal(pick('2026-10-18T19:30:00.000Z'), null);
assert.equal(pick('2027-01-01T00:00:00.000Z'), null);
// Compared as times, not strings: other ISO spellings of the same instant.
assert.equal(pick('2026-10-02T10:00:00-05:00'), 'g2');
assert.equal(pick('2026-10-02T09:59:59.999-05:00'), 'g1');
// Order of the list doesn't matter for disjoint windows.
assert.equal(pick('2026-10-10T00:00:00.000Z', [G3, G1, G2]), 'g2');
// Returns the set itself (the generic carries through).
assert.equal(pickKeptRules(GENERATIONS, '2026-10-10T00:00:00.000Z'), G2);
});
test('pickKeptRules: no date, no sets, or an unreadable date — the current rules', () => {
assert.equal(pickKeptRules(GENERATIONS, null), null);
assert.equal(pickKeptRules(GENERATIONS, ''), null);
assert.equal(pickKeptRules(GENERATIONS, 'not a date'), null);
assert.equal(pickKeptRules(undefined, '2026-10-10T00:00:00.000Z'), null);
assert.equal(pickKeptRules([], '2026-10-10T00:00:00.000Z'), null);
// An unreadable bound never matches (the catalog parse rejects them anyway).
assert.equal(pickKeptRules([{ id: 'bad', openedAt: null, closedAt: 'soon' }], '2020-01-01T00:00:00.000Z'), null);
assert.equal(pickKeptRules([{ id: 'bad', openedAt: 'once', closedAt: '2030-01-01T00:00:00.000Z' }], '2026-01-01T00:00:00.000Z'), null);
// An empty window (openedAt == closedAt) holds nothing.
assert.equal(pickKeptRules([{ id: 'empty', openedAt: G2.closedAt, closedAt: G2.closedAt }], G2.closedAt), null);
});
// ── overlayKept ─────────────────────────────────────────────────────────────
test('overlayKept: the kept rules on top; identity, label, projections and bookkeeping stay live', () => {
const oldRules = {
profitTarget: 3000, consistency: 0.5, minDayPnL: 200, minTradingDays: 5, maxLoss: 2500, maxPositionSize: 3,
targetSameEquity: true, splitEnabled: false, minLegs: 0, firstDayStop: false, minDayStop: true, combineCmeHolidays: true,
withdrawalStages: [], payoutable: false, trailingDrawdown: 2500, dailyLossLimit: 0, stagesSameDay: false,
};
// A master that sent other identity/projection values on the kept set:
// they must not leak through.
const kept = keptOf({ ...oldRules, label: 'OLD', prefix: 'OLD', accountSize: 1, expectedPayout: 1, withdrawalPct: 1,
payoutCap: 1, profitSplit: 1, firmId: 'other', updatedAt: '1999-01-01T00:00:00.000Z', tradesEveryDay: true }, G2);
const liveBefore = structuredClone(LIVE);
const keptBefore = structuredClone(kept);
const eff = overlayKept(LIVE, kept);
for (const field of RULE_FIELDS) assert.deepEqual(eff[field], oldRules[field], field);
for (const field of ['id', 'firmId', 'label', 'prefix', 'accountSize', 'updatedAt', 'tradesEveryDay', ...PROJECTION_FIELDS]) {
assert.deepEqual(eff[field], LIVE[field], field);
}
assert.deepEqual(eff.keptRules, { id: 'kept-1', openedAt: G2.openedAt, closedAt: G2.closedAt });
// Nothing of the kept set's own bookkeeping comes along.
for (const key of ['keptFrom', 'openedAt', 'closedAt', 'createdAt']) assert.equal(key in eff, false, key);
assert.deepEqual(Object.keys(eff).sort(), [...Object.keys(LIVE), 'keptRules'].sort());
// Inputs untouched.
assert.deepEqual(LIVE, liveBefore);
assert.deepEqual(kept, keptBefore);
});
test('overlayKept: a rule field the kept set lacks keeps the live value', () => {
const kept = keptOf({ consistency: 0.5 }, G1);
delete kept.payoutable;
kept.trailingDrawdown = undefined;
const eff = overlayKept(LIVE, kept);
assert.equal(eff.consistency, 0.5);
assert.equal(eff.payoutable, LIVE.payoutable);
assert.equal(eff.trailingDrawdown, LIVE.trailingDrawdown);
});
// ── ruleDiffs ───────────────────────────────────────────────────────────────
test('ruleDiffs: the rule fields that differ, raw values both sides', () => {
assert.deepEqual(ruleDiffs(LIVE, LIVE), []);
const eff = overlayKept(LIVE, keptOf({ consistency: 0.5, profitTarget: 3000 }, G1));
assert.deepEqual(ruleDiffs(eff, LIVE), [
{ field: 'profitTarget', kept: 3000, now: 2500 },
{ field: 'consistency', kept: 0.5, now: 0.4 },
]);
// Non-rule fields never count.
assert.deepEqual(ruleDiffs({ ...LIVE, label: 'x', prefix: 'y', profitSplit: 0.1 }, LIVE), []);
const stages = [{ profit: 3000, consistency: 0.4, minTradingDays: 5, drain: false, minDayPnL: 0 }];
assert.deepEqual(ruleDiffs({ ...LIVE, withdrawalStages: stages }, LIVE), [
{ field: 'withdrawalStages', kept: stages, now: LIVE.withdrawalStages },
]);
});
test('ruleDiffs: stored JSON from before a field existed is not a change', () => {
// A stage without drain / minDayPnL is what parseAccountTypeInput fills
// with drain false, minDayPnL 0 (5 for a drain stage); key order is free.
const bare = { ...LIVE, withdrawalStages: [{ minTradingDays: 5, consistency: 0.4, profit: 2000 }] };
assert.deepEqual(ruleDiffs(bare, LIVE), []);
const drainFilled = { ...LIVE, withdrawalStages: [{ profit: 0, consistency: 0, minTradingDays: 5, drain: true, minDayPnL: 5 }] };
const drainBare = { ...LIVE, withdrawalStages: [{ profit: 0, consistency: 0, minTradingDays: 5, drain: true }] };
assert.deepEqual(ruleDiffs(drainBare, drainFilled), []);
// A per-stage payout override is a rule.
assert.equal(ruleDiffs({ ...LIVE, withdrawalStages: [{ ...LIVE.withdrawalStages[0], payoutCap: 1500 }] }, LIVE).length, 1);
// An absent optional flag or amount is its default.
const noOptionals = omit(LIVE, ['payoutable', 'trailingDrawdown']);
assert.deepEqual(ruleDiffs(noOptionals, { ...LIVE, payoutable: false, trailingDrawdown: 0 }), []);
assert.deepEqual(ruleDiffs({ ...LIVE, splitEnabled: undefined }, LIVE), []);
assert.deepEqual(ruleDiffs({ ...LIVE, splitEnabled: undefined }, { ...LIVE, splitEnabled: false }).map((d) => d.field), ['splitEnabled']);
});
// ── rules dates ─────────────────────────────────────────────────────────────
test('stampRulesDate: the latest of this clock, the master\'s and the catalog\'s last change', () => {
const now = Date.parse('2026-10-18T14:00:00.000Z');
assert.equal(stampRulesDate(now, null, null), '2026-10-18T14:00:00.000Z');
// This clock 5 minutes behind the master's.
assert.equal(stampRulesDate(now, 5 * 60_000, null), '2026-10-18T14:05:00.000Z');
// This clock ahead: its own time stands.
assert.equal(stampRulesDate(now, -5 * 60_000, null), '2026-10-18T14:00:00.000Z');
// The catalog changed after both: no new account dates before it.
assert.equal(stampRulesDate(now, 60_000, '2026-10-18T14:07:30.000Z'), '2026-10-18T14:07:30.000Z');
assert.equal(stampRulesDate(now, null, '2026-10-01T00:00:00.000Z'), '2026-10-18T14:00:00.000Z');
// Unreadable inputs are ignored.
assert.equal(stampRulesDate(now, NaN, 'never'), '2026-10-18T14:00:00.000Z');
assert.equal(stampRulesDate(now, null, ''), '2026-10-18T14:00:00.000Z');
});
test('stampRulesDate: a machine whose clock is behind never dates a new account inside the set just closed', () => {
// The owner saves "new accounts only" at 14:05 master time; this machine's
// clock reads 14:01 when it first sees a brand-new account at 14:05.
const cutoff = '2026-10-18T14:05:00.000Z';
const kept = [{ id: 'g', openedAt: null, closedAt: cutoff }];
const local = Date.parse('2026-10-18T14:01:00.000Z');
// Unclamped, the account would land in the old rules.
assert.equal(pickKeptRules(kept, new Date(local).toISOString())?.id, 'g');
// With the master's offset, or just the catalog's updatedAt, it doesn't.
assert.equal(pickKeptRules(kept, stampRulesDate(local, 4 * 60_000, null)), null);
assert.equal(pickKeptRules(kept, stampRulesDate(local, null, cutoff)), null);
// A machine whose master offset isn't known yet still clamps to the catalog.
assert.equal(pickKeptRules(kept, stampRulesDate(local, null, '2026-10-18T14:05:00.250Z')), null);
});
test('evidenceRulesDate: the end of the first fund day in Chicago — CDT and CST', () => {
// CDT (UTC−5): 23:59:59.999 CDT is 04:59:59.999Z the next day.
assert.equal(evidenceRulesDate('2026-10-18'), '2026-10-19T04:59:59.999Z');
assert.equal(evidenceRulesDate('2026-07-04'), '2026-07-05T04:59:59.999Z');
// CST (UTC−6): 05:59:59.999Z the next day.
assert.equal(evidenceRulesDate('2026-01-15'), '2026-01-16T05:59:59.999Z');
assert.equal(evidenceRulesDate('2026-12-31'), '2027-01-01T05:59:59.999Z');
assert.equal(evidenceRulesDate('2028-02-29'), '2028-03-01T05:59:59.999Z');
for (const bad of [null, undefined, '', '2026-10-18T00:00:00Z', '18/10/2026', '2026-1-8', '2026-02-31', '2026-13-01', 20261018]) {
assert.equal(evidenceRulesDate(bad), null, String(bad));
}
});
test('evidenceRulesDate: DST boundary days end on the right side of the change', () => {
// 2026: CST→CDT at 2 AM on Sunday Mar 8, CDT→CST at 2 AM on Sunday Nov 1.
assert.equal(evidenceRulesDate('2026-03-07'), '2026-03-08T05:59:59.999Z'); // last CST day
assert.equal(evidenceRulesDate('2026-03-08'), '2026-03-09T04:59:59.999Z'); // springs forward: ends in CDT
assert.equal(evidenceRulesDate('2026-03-09'), '2026-03-10T04:59:59.999Z');
assert.equal(evidenceRulesDate('2026-10-31'), '2026-11-01T04:59:59.999Z'); // last CDT day
assert.equal(evidenceRulesDate('2026-11-01'), '2026-11-02T05:59:59.999Z'); // falls back: ends in CST
assert.equal(evidenceRulesDate('2026-11-02'), '2026-11-03T05:59:59.999Z');
// The day before each boundary and the boundary day are 23 h / 25 h long
// in Chicago, measured end to end.
const len = (a, b) => (Date.parse(evidenceRulesDate(b)) - Date.parse(evidenceRulesDate(a))) / 3_600_000;
assert.equal(len('2026-03-07', '2026-03-08'), 23);
assert.equal(len('2026-10-31', '2026-11-01'), 25);
assert.equal(len('2026-06-01', '2026-06-02'), 24);
});
test('evidenceRulesDate: every day of 2026–2027 ends 1 ms before the next one starts, in Chicago', () => {
for (let ms = Date.UTC(2026, 0, 1); ms < Date.UTC(2028, 0, 1); ms += 86_400_000) {
const day = new Date(ms).toISOString().slice(0, 10);
const next = new Date(ms + 86_400_000).toISOString().slice(0, 10);
const end = evidenceRulesDate(day);
assert.equal(Date.parse(chicagoDayStart(next)) - Date.parse(end), 1, day);
// Both read back as their own Chicago day, and the end as evidence.
assert.equal(chicagoDay(end), day);
assert.equal(chicagoDay(chicagoDayStart(next)), next);
assert.equal(evidenceDay(end), day);
// Under either offset the end is 04:59:59.999Z or 05:59:59.999Z the next UTC day.
assert.match(end, new RegExp(`^${next}T0[45]:59:59\\.999Z$`));
}
});
test('evidenceRulesDate: an account opened on the day of a change — even that evening — counts as after it', () => {
// A change at 14:05 CDT on Oct 18: an account first funded that day is
// after it; one funded the day before keeps the old rules.
const afternoon = [{ id: 'g', openedAt: null, closedAt: '2026-10-18T19:05:00.000Z' }];
assert.equal(pickKeptRules(afternoon, evidenceRulesDate('2026-10-18')), null);
assert.equal(pickKeptRules(afternoon, evidenceRulesDate('2026-10-17'))?.id, 'g');
// A change at 8 PM CDT (01:00Z the next UTC day): a UTC end of day
// (Oct 18 23:59:59.999Z = 6:59 PM CDT) would have put an account that
// opened that evening before it.
const evening = [{ id: 'g', openedAt: null, closedAt: '2026-10-19T01:00:00.000Z' }];
assert.equal(pickKeptRules(evening, evidenceRulesDate('2026-10-18')), null);
assert.equal(pickKeptRules(evening, evidenceRulesDate('2026-10-17'))?.id, 'g');
// Same under CST: a change at 11:30 PM CST Jan 15 (05:30Z Jan 16).
const winter = [{ id: 'g', openedAt: null, closedAt: '2026-01-16T05:30:00.000Z' }];
assert.equal(pickKeptRules(winter, evidenceRulesDate('2026-01-15')), null);
assert.equal(pickKeptRules(winter, evidenceRulesDate('2026-01-14'))?.id, 'g');
});
test('chicagoDayStart / chicagoDay / evidenceDay', () => {
assert.equal(chicagoDayStart('2026-10-18'), '2026-10-18T05:00:00.000Z');
assert.equal(chicagoDayStart('2026-01-15'), '2026-01-15T06:00:00.000Z');
assert.equal(chicagoDayStart('2026-03-08'), '2026-03-08T06:00:00.000Z'); // midnight is still CST
assert.equal(chicagoDayStart('2026-11-01'), '2026-11-01T05:00:00.000Z'); // midnight is still CDT
assert.equal(chicagoDayStart('2026-02-31'), null);
assert.equal(chicagoDayStart('1999-12-31'), null); // before 2000: not a rules date
assert.equal(chicagoDay('2026-10-19T04:59:59.999Z'), '2026-10-18');
assert.equal(chicagoDay('2026-10-19T05:00:00.000Z'), '2026-10-19');
assert.equal(chicagoDay('nope'), null);
// Only the exact end of a Chicago day reads as evidence.
assert.equal(evidenceDay('2026-10-19T04:59:59.999Z'), '2026-10-18');
assert.equal(evidenceDay('2026-10-19T04:59:59.998Z'), null);
assert.equal(evidenceDay('2026-10-18T23:59:59.999Z'), null); // the old UTC shape
assert.equal(evidenceDay('2026-10-18T19:05:00.000Z'), null);
});
test('sortableIso: plain four-digit-year ISO from 2000 on, else null', () => {
assert.equal(sortableIso(Date.parse('2026-10-18T14:05:00Z')), '2026-10-18T14:05:00.000Z');
assert.equal(sortableIso(Date.UTC(2000, 0, 1)), '2000-01-01T00:00:00.000Z');
assert.equal(sortableIso(Date.UTC(2000, 0, 1) - 1), null);
assert.equal(sortableIso(0), null);
assert.equal(sortableIso(Date.UTC(9999, 11, 31, 23, 59, 59, 999)), '9999-12-31T23:59:59.999Z');
assert.equal(sortableIso(Date.parse('+010000-01-01T00:00:00Z')), null); // would print as +010000-…
assert.equal(sortableIso(8.64e15 + 1), null);
assert.equal(sortableIso(NaN), null);
assert.equal(sortableIso(Infinity), null);
});
test('openingEvidence: a fund day ends in Chicago; the registration fallback is the instant itself', () => {
// A positive fund row's day: the end of it in Chicago, whatever the registration.
assert.equal(openingEvidence('2026-10-02', Date.parse('2026-09-01T00:00:00Z')), '2026-10-03T04:59:59.999Z');
// Positive rows present but unreadable: no evidence (never the registration).
assert.equal(openingEvidence('2026-02-31', Date.parse('2026-09-01T00:00:00Z')), null);
// No positive rows: the registration instant, exactly.
assert.equal(openingEvidence(undefined, Date.parse('2026-10-19T02:00:00Z')), '2026-10-19T02:00:00.000Z');
// Unreadable or implausible registrations: none.
for (const bad of [undefined, null, NaN, 0, 5, -1, Infinity, Date.parse('1999-12-31T23:00:00Z'), 9e15]) {
assert.equal(openingEvidence(undefined, bad), null, String(bad));
}
});
test('openingEvidence: a DXtrade account registered after an evening cutoff is never lowered before it', () => {
// "New accounts only" saved at 8 PM CDT Oct 18 (01:00Z Oct 19); a DX
// account registered at 9 PM CDT (02:00Z) with no DEPOSIT row.
const kept = [{ id: 'g', openedAt: null, closedAt: '2026-10-19T01:00:00.000Z' }];
const stamped = '2026-10-19T02:00:05.000Z';
const evidence = openingEvidence(undefined, Date.parse('2026-10-19T02:00:00Z'));
const lowered = lowersRulesDate({ rules_date: stamped, rules_date_manual: 0 }, evidence) ? evidence : stamped;
assert.equal(pickKeptRules(kept, lowered), null);
// And at 9 PM CDT Oct 20 against an 8 PM cutoff that day.
const kept2 = [{ id: 'g', openedAt: null, closedAt: '2026-10-21T01:00:00.000Z' }];
assert.equal(pickKeptRules(kept2, openingEvidence(undefined, Date.parse('2026-10-21T02:00:00Z'))), null);
// One registered before the cutoff, first seen after it, goes back to the old rules.
const before = openingEvidence(undefined, Date.parse('2026-10-19T00:30:00Z'));
assert.equal(lowersRulesDate({ rules_date: stamped, rules_date_manual: 0 }, before), true);
assert.equal(pickKeptRules(kept, before)?.id, 'g');
});
test('identityStillMatches: prefix and the cached size against the live type', () => {
const live = { prefix: 'TDFY', accountSize: 50_000 };
assert.equal(identityStillMatches('TDFY1234', '50000', live), true);
assert.equal(identityStillMatches('TDFY1234', '50000.004', live), true);
// A size edit on the type (50K → 100K, or → $0-start): no longer its size.
assert.equal(identityStillMatches('TDFY1234', '50000', { ...live, accountSize: 100_000 }), false);
assert.equal(identityStillMatches('TDFY1234', '50000', { ...live, accountSize: 0 }), false);
// A prefix edit.
assert.equal(identityStillMatches('TDFY1234', '50000', { ...live, prefix: 'TDFX' }), false);
// A $0-start type caches 0 when it matches.
assert.equal(identityStillMatches('XFA77', '0', { prefix: 'XFA', accountSize: 0 }), true);
assert.equal(identityStillMatches('XFA77', '150000', { prefix: 'XFA', accountSize: 0 }), false);
// Nothing cached, or junk: not provably the same account.
assert.equal(identityStillMatches('TDFY1234', null, live), false);
assert.equal(identityStillMatches('TDFY1234', 'abc', live), false);
assert.equal(identityStillMatches('TDFY1234', '', { ...live, accountSize: 0 }), false);
});
test('pickedDayRulesDate: today is now; another day its start, never before Chicago\'s', () => {
const nowMs = Date.parse('2026-10-18T19:30:00.000Z');
const at = (localMidnightMs, today = '2026-10-18') => ({ today, nowMs, localMidnightMs });
// Today: now, whatever the zone.
assert.equal(pickedDayRulesDate('2026-10-18', at(Date.parse('2026-10-18T05:00:00Z'))), '2026-10-18T19:30:00.000Z');
// Chicago browser: its own midnight.
assert.equal(pickedDayRulesDate('2026-10-15', at(Date.parse('2026-10-15T05:00:00Z'))), '2026-10-15T05:00:00.000Z');
// Pacific browser: local midnight is later than Chicago's — stands.
assert.equal(pickedDayRulesDate('2026-10-15', at(Date.parse('2026-10-15T07:00:00Z'))), '2026-10-15T07:00:00.000Z');
// London (BST), Eastern and Karachi browsers: raised to Chicago's midnight.
for (const local of ['2026-10-14T23:00:00Z', '2026-10-15T04:00:00Z', '2026-10-14T19:00:00Z']) {
assert.equal(pickedDayRulesDate('2026-10-15', at(Date.parse(local))), '2026-10-15T05:00:00.000Z', local);
}
assert.equal(pickedDayRulesDate('2026-02-31', at(0)), null);
assert.equal(pickedDayRulesDate('2026-10-15', at(NaN)), null);
});
test('pickedDayRulesDate: a picked day never lands inside the evidence of the day before', () => {
// A browser at UTC+1 picks Oct 15 for a set closing at Chicago midnight
// of Oct 15: an account first funded Oct 14 stays on the old rules, the
// hand-dated one on Oct 15 does not.
const kept = [{ id: 'g', openedAt: null, closedAt: chicagoDayStart('2026-10-15') }];
const picked = pickedDayRulesDate('2026-10-15', {
today: '2026-10-18', nowMs: Date.parse('2026-10-18T12:00:00Z'), localMidnightMs: Date.parse('2026-10-14T23:00:00Z'),
});
assert.equal(pickKeptRules(kept, evidenceRulesDate('2026-10-14'))?.id, 'g');
assert.equal(pickKeptRules(kept, picked), null);
for (let ms = Date.UTC(2026, 0, 2); ms < Date.UTC(2027, 0, 1); ms += 86_400_000) {
const day = new Date(ms).toISOString().slice(0, 10);
const prev = new Date(ms - 86_400_000).toISOString().slice(0, 10);
for (const offsetH of [-10, -8, -6, -5, -4, 0, 1, 5, 9, 14]) {
const localMidnightMs = Date.parse(`${day}T00:00:00Z`) - offsetH * 3_600_000;
const stored = pickedDayRulesDate(day, { today: '2030-01-01', nowMs: 0, localMidnightMs });
assert.ok(stored > evidenceRulesDate(prev), `${day} at UTC${offsetH}`);
}
}
});
test('unchangedDaySaves: an untouched day saves only when it moves the account to other rules', () => {
// A change at 2:05 PM CDT today (19:05Z) with the default cutoff "now".
const kept = [{ id: 'g', openedAt: null, closedAt: '2026-10-18T19:05:00.000Z' }];
const at = { today: '2026-10-18', nowMs: Date.parse('2026-10-18T19:30:00Z'), localMidnightMs: Date.parse('2026-10-18T05:00:00Z') };
// Dated this morning (old rules): ✓ on today stores now → current rules.
const now = pickedDayRulesDate('2026-10-18', at);
assert.equal(unchangedDaySaves(kept, '2026-10-18T15:00:00.000Z', now), true);
assert.equal(pickKeptRules(kept, now), null);
// Already after the change: nothing to save.
assert.equal(unchangedDaySaves(kept, '2026-10-18T19:10:00.000Z', now), false);
// A past day's start lands where the date already is: nothing to save.
const oct2 = pickedDayRulesDate('2026-10-02', at);
assert.equal(unchangedDaySaves(kept, evidenceRulesDate('2026-10-02'), oct2), false);
// Windows not known (still loading, no firm): never.
assert.equal(unchangedDaySaves(null, '2026-10-18T15:00:00.000Z', now), false);
// The editor opened on Oct 18 (current rules) and a poll since lowered the
// date to Oct 2: an untouched ✓ compares against the opened date, so it
// doesn't pin Oct 18 by hand over the evidence.
const sets = [{ id: 'g2', openedAt: null, closedAt: '2026-10-10T00:00:00.000Z' }];
const oct18 = pickedDayRulesDate('2026-10-18', { ...at, today: '2026-10-19' });
assert.equal(unchangedDaySaves(sets, '2026-10-18T15:00:00.000Z', oct18), false);
});
test('lowersRulesDate: only earlier, only auto dates — the test lowerAutoRulesDate runs in SQL', () => {
const evidence = '2026-10-01T23:59:59.999Z';
assert.equal(lowersRulesDate({ rules_date: '2026-10-18T14:00:00.000Z', rules_date_manual: 0 }, evidence), true);
assert.equal(lowersRulesDate({ rules_date: null, rules_date_manual: 0 }, evidence), true);
assert.equal(lowersRulesDate({ rules_date: evidence, rules_date_manual: 0 }, evidence), false);
assert.equal(lowersRulesDate({ rules_date: '2026-09-01T00:00:00.000Z', rules_date_manual: 0 }, evidence), false);
// Set by hand: never moved.
assert.equal(lowersRulesDate({ rules_date: '2026-10-18T14:00:00.000Z', rules_date_manual: 1 }, evidence), false);
assert.equal(lowersRulesDate({ rules_date: null, rules_date_manual: 1 }, evidence), false);
// String order is time order for the two formats that are ever stored.
assert.equal('2026-10-01T23:59:59.999Z' < '2026-10-02T00:00:00.000Z', true);
assert.equal(evidenceRulesDate('2026-10-02') > new Date(Date.parse('2026-10-02T23:59:59.998Z')).toISOString(), true);
});
// ── the whole selection, as getAccountConfig runs it ────────────────────────
test('selection: generations, boundaries and clamps end to end', () => {
const sets = [
keptOf({ profitTarget: 3000, consistency: 0.5 }, G1, 'g1'),
keptOf({ profitTarget: 2800, consistency: 0.45 }, G2, 'g2'),
];
const rulesFor = (rulesDate) => {
const kept = pickKeptRules(sets, rulesDate);
return kept ? overlayKept(LIVE, kept) : LIVE;
};
const cases = [
[null, 2500, undefined],
['2026-09-01T00:00:00.000Z', 3000, 'g1'],
[G1.closedAt, 2800, 'g2'],
[G2.closedAt, 2500, undefined],
[evidenceRulesDate('2026-10-02'), 2800, 'g2'],
[evidenceRulesDate('2026-10-01'), 3000, 'g1'],
];
for (const [date, target, keptId] of cases) {
const eff = rulesFor(date);
assert.equal(eff.profitTarget, target, String(date));
assert.equal(eff.keptRules?.id, keptId, String(date));
// Always reported as the live type.
assert.equal(eff.id, LIVE.id);
}
});
+201
View File
@@ -0,0 +1,201 @@
// Real local HTTP requests and WebSocket handshakes. Never use the live DB
// or contact a broker. The existing check suite uses Node's type stripping.
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 { execFileSync } from 'node:child_process';
import { createServer } from 'node:http';
import { connect } from 'node:net';
import { once } from 'node:events';
import { WebSocketServer } from 'ws';
import { HubConnectionBuilder, HttpTransportType, LogLevel } from '@microsoft/signalr';
// 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-user-agent-'));
process.env.AUTOFIRMER_DB = join(directory, 'test.sqlite');
delete process.env.NEXT_PHASE;
after(() => rmSync(directory, { recursive: true, force: true }));
const { getSetting, setSetting } = await import('../lib/db.ts');
// Do not let inherited environment proxy settings route local test traffic.
process.env.NO_PROXY = '*';
setSetting('proxy_url', '');
const { getRequestUserAgent } = await import('../lib/request-user-agent.ts');
const { default: axios } = await import('../lib/http-client.ts');
const { brokerWebSocket, directWebSocket, signalrWebSocket } = await import('../lib/outbound-proxy.ts');
const userAgent = getRequestUserAgent();
async function listen(server) {
server.listen(0, '127.0.0.1');
await once(server, 'listening');
return `http://127.0.0.1:${server.address().port}`;
}
async function socketHeaders(makeSocket, server, url, path = '/') {
const accepted = once(server, 'connection');
const client = makeSocket(url.replace('http:', 'ws:') + path);
const opened = new Promise((resolve, reject) => {
client.addEventListener('open', resolve, { once: true });
client.addEventListener('error', reject, { once: true });
});
const [peer, request] = await accepted;
try {
await opened;
return request.headers;
} finally {
client.close();
peer.terminate();
}
}
test('first load saves a browser identity and another process reuses it', () => {
assert.match(userAgent, /^Mozilla\/5\.0 .*(Chrome\/|Firefox\/)/);
assert.equal(getSetting('user_agent'), userAgent);
const value = execFileSync(process.execPath, [
'--experimental-strip-types', '--disable-warning=MODULE_TYPELESS_PACKAGE_JSON',
'--input-type=module', '-e',
`import { registerHooks } from 'node:module';
registerHooks({ resolve: ${resolveTypes.toString()} });
const { getRequestUserAgent } = await import(${JSON.stringify(new URL('../lib/request-user-agent.ts', import.meta.url).href)});
process.stdout.write(getRequestUserAgent());`,
], { env: process.env, encoding: 'utf8' });
assert.equal(value, userAgent);
});
test('Axios calls, custom headers, and nested create clients use the saved identity', async () => {
const server = createServer((req, res) => {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(req.headers));
});
const url = await listen(server);
try {
const child = axios.create({ headers: { 'user-agent': 'old child identity' } });
const grandchild = child.create({ headers: { 'User-Agent': false } });
for (const client of [axios, child, grandchild]) {
const result = await client.post(url, { hello: 'world' }, {
headers: { 'uSeR-aGeNt': false, Authorization: 'Bearer local-test', 'X-Test': 'kept' },
});
assert.equal(result.data['user-agent'], userAgent);
assert.equal(result.data.authorization, 'Bearer local-test');
assert.equal(result.data['x-test'], 'kept');
assert.equal(client.defaults.timeout, 60_000);
}
assert.equal((await axios(url)).data['user-agent'], userAgent);
} finally {
await new Promise((resolve) => server.close(resolve));
}
});
test('direct, broker and SignalR handshakes preserve the saved identity and auth', async () => {
const server = createServer();
const wss = new WebSocketServer({ server });
const url = await listen(server);
try {
for (const makeSocket of [directWebSocket, brokerWebSocket]) {
assert.equal((await socketHeaders(makeSocket, wss, url))['user-agent'], userAgent);
}
const WS = signalrWebSocket();
for (const overload of [false, true]) {
const headers = await socketHeaders((address) => {
const options = { headers: { 'user-agent': 'SignalR default', Cookie: 'session=local', Authorization: 'Bearer local-test' } };
return overload ? new WS(address, options) : new WS(address, undefined, options);
}, wss, url);
assert.equal(headers['user-agent'], userAgent);
assert.equal(headers.cookie, 'session=local');
assert.equal(headers.authorization, 'Bearer local-test');
}
// Exercise SignalR itself, including its generated UA/auth headers.
wss.on('connection', (socket) => {
socket.on('message', () => socket.send('{}\u001e'));
});
const hub = new HubConnectionBuilder().withUrl(url, {
skipNegotiation: true,
transport: HttpTransportType.WebSockets,
WebSocket: WS,
accessTokenFactory: () => 'signalr-test',
}).configureLogging(LogLevel.None).build();
const accepted = once(wss, 'connection');
await hub.start();
try {
const [, request] = await accepted;
assert.equal(request.headers['user-agent'], userAgent);
assert.equal(request.headers.authorization, 'Bearer signalr-test');
} finally {
await hub.stop();
}
} finally {
for (const socket of wss.clients) socket.terminate();
await new Promise((resolve) => wss.close(resolve));
await new Promise((resolve) => server.close(resolve));
}
});
test('HTTP proxy keeps the identity on broker sockets while control sockets stay direct', async () => {
const server = createServer();
const wss = new WebSocketServer({ server });
const url = await listen(server);
let tunnels = 0;
const connections = new Set();
const proxy = createServer((req, res) => {
// ws with HttpProxyAgent uses an absolute-form upgrade; handled below.
res.writeHead(500).end();
});
function tunnel(req, socket, head, isConnect) {
tunnels++;
const target = new URL(isConnect ? `http://${req.url}` : req.url);
const upstream = connect(Number(target.port), target.hostname, () => {
if (isConnect) socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
else {
upstream.write(`${req.method} ${target.pathname} HTTP/1.1\r\n`);
for (let i = 0; i < req.rawHeaders.length; i += 2) upstream.write(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\r\n`);
upstream.write('\r\n');
}
if (head.length) upstream.write(head);
socket.pipe(upstream).pipe(socket);
});
connections.add(socket);
connections.add(upstream);
socket.on('error', () => upstream.destroy());
upstream.on('error', () => socket.destroy());
}
proxy.on('connect', (req, socket, head) => tunnel(req, socket, head, true));
proxy.on('upgrade', (req, socket, head) => tunnel(req, socket, head, false));
const proxyUrl = await listen(proxy);
setSetting('proxy_url', proxyUrl);
delete globalThis.__outboundProxy;
try {
for (const makeSocket of [brokerWebSocket, (address) => new (signalrWebSocket())(address)]) {
const before = tunnels;
assert.equal((await socketHeaders(makeSocket, wss, url))['user-agent'], userAgent);
assert.equal(tunnels, before + 1);
}
const before = tunnels;
assert.equal((await socketHeaders(directWebSocket, wss, url))['user-agent'], userAgent);
assert.equal(tunnels, before);
} finally {
await globalThis.__outboundProxy.dispatcher.destroy();
globalThis.__outboundProxy.httpAgent.destroy();
globalThis.__outboundProxy.httpsAgent.destroy();
delete globalThis.__outboundProxy;
setSetting('proxy_url', '');
for (const socket of connections) socket.destroy();
for (const socket of wss.clients) socket.terminate();
await new Promise((resolve) => wss.close(resolve));
await new Promise((resolve) => server.close(resolve));
await new Promise((resolve) => proxy.close(resolve));
}
});
+190
View File
@@ -0,0 +1,190 @@
// Checks for building trades out of fills (lib/fill-trades.ts — TopstepX and
// Tradovate) and out of DXtrade's order history (lib/dxtrade-helpers.ts).
//
// npm run check
//
// The 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.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { fillsToTrades, startingNet, contractRoot, byFillTime } from '../lib/fill-trades.ts';
import { topstepxSymbol } from '../lib/topstep-fees.ts';
import { parseHistoryOrder, dxTrades } from '../lib/dxtrade-helpers.ts';
let n = 0;
const fill = (side, qty, price, extra = {}) => {
n += 1;
return { id: String(1000 + n), orderId: String(5000 + n), at: 1_000 + n * 1_000, side, qty, price, fee: 1, pnl: null, ...extra };
};
test('a round trip, with the platform’s P&L', () => {
const a = fill('buy', 2, 100);
const b = fill('sell', 2, 101, { pnl: 40 });
const [t, ...rest] = fillsToTrades([b, a], { startNet: 0, pointValue: null });
assert.equal(rest.length, 0);
assert.deepEqual(t, {
id: a.id, side: 'long', quantity: 2, openPrice: 100, closePrice: 101, pnl: 40, fee: 2,
openedAt: a.at, closedAt: b.at, openOrderId: a.orderId, closeOrderId: b.orderId, carried: false,
});
});
test('P&L from the prices when the platform gives none; adds and partial closes stay one trade', () => {
const fills = [fill('buy', 1, 100), fill('buy', 1, 102), fill('sell', 1, 103), fill('sell', 1, 104)];
const [t, ...rest] = fillsToTrades(fills, { startNet: 0, pointValue: 20 });
assert.equal(rest.length, 0);
assert.equal(t.quantity, 2);
assert.equal(t.openPrice, 101);
assert.equal(t.closePrice, 103.5);
assert.equal(t.pnl, 100);
assert.equal(t.fee, 4);
assert.equal(t.closeOrderId, fills[3].orderId);
// No point value and no platform P&L: unknown, not zero.
assert.equal(fillsToTrades(fills, { startNet: 0, pointValue: null })[0].pnl, null);
});
test('a short', () => {
const fills = [fill('sell', 3, 50.5), fill('buy', 3, 50)];
const [t] = fillsToTrades(fills, { startNet: 0, pointValue: 1000 });
assert.equal(t.side, 'short');
assert.equal(t.pnl, 1500);
});
test('a fill through zero closes one trade and opens the next', () => {
const a = fill('buy', 1, 100);
const b = fill('sell', 3, 105, { fee: 3, pnl: 100 });
const [first, second, ...rest] = fillsToTrades([a, b], { startNet: 0, pointValue: 20 });
assert.equal(rest.length, 0);
assert.equal(first.side, 'long');
assert.equal(first.pnl, 100);
assert.equal(first.fee, 2); // its own buy, and a third of the reversing fill
assert.equal(first.closeOrderId, b.orderId);
assert.equal(second.id, b.id);
assert.equal(second.side, 'short');
assert.equal(second.quantity, 2);
assert.equal(second.openPrice, 105);
assert.equal(second.fee, 2);
assert.equal(second.closedAt, null);
assert.equal(second.pnl, null);
assert.equal(second.closePrice, null);
});
test('fills that start inside a position: passed over, or carried on', () => {
const close = fill('sell', 1, 110, { pnl: 200 });
const a = fill('buy', 1, 100);
const b = fill('sell', 1, 101, { pnl: 20 });
// Nothing stored: the leading close belongs to nothing on record.
const plain = fillsToTrades([close, a, b], { startNet: 1, pointValue: 20 });
assert.equal(plain.length, 1);
assert.equal(plain[0].id, a.id);
// The stored open trade carries on and closes.
const carry = { id: 'tsx:42', side: 'long', quantity: 1, openPrice: 100, fee: 0.5, openedAt: 10 };
const [carried, next] = fillsToTrades([close, a, b], { startNet: 1, pointValue: 20, carry });
assert.equal(carried.id, 'tsx:42');
assert.equal(carried.carried, true);
assert.equal(carried.openedAt, 10);
assert.equal(carried.openOrderId, null);
assert.equal(carried.pnl, 200);
assert.equal(carried.fee, 1.5);
assert.equal(carried.closedAt, close.at);
assert.equal(next.id, a.id);
// A carry on the other side isn't this position.
assert.equal(fillsToTrades([close], { startNet: 1, pointValue: 20, carry: { ...carry, side: 'short' } }).length, 0);
});
test('the position before the fills', () => {
const buy = fill('buy', 1, 1);
const sell = fill('sell', 1, 1);
assert.equal(startingNet([buy, sell], 0), 0);
assert.equal(startingNet([buy], 1), 0);
assert.equal(startingNet([sell], 0), 1);
assert.equal(startingNet([], -2), -2);
});
test('fills sort by time, then numerically by id', () => {
const a = { id: '9', at: 5 };
const b = { id: '10', at: 5 };
const c = { id: '1', at: 6 };
assert.deepEqual([c, b, a].sort(byFillTime).map((f) => f.id), ['9', '10', '1']);
});
test('contract roots and TopstepX names', () => {
assert.equal(contractRoot('MNQZ6'), 'MNQ');
assert.equal(contractRoot('ZNZ6'), 'ZN');
assert.equal(contractRoot('NGZ6'), 'NG');
assert.equal(contractRoot('6EZ6'), '6E');
assert.equal(contractRoot('M2KH27'), 'M2K');
assert.equal(contractRoot('CL'), 'CL');
assert.equal(topstepxSymbol('CON.F.US.ENQ.Z26'), 'NQZ6');
assert.equal(topstepxSymbol('CON.F.US.MNQ.H27'), 'MNQH7');
assert.equal(topstepxSymbol('CON.F.US.XYZ.M26'), 'XYZM6');
assert.equal(topstepxSymbol('something'), 'something');
});
// DXtrade: a bot round trip on BTC/USD, as /orders/history and /transfers
// answer (position-based account: the opening order's id is the position).
const openOrder = {
account: 'default:T247-1', orderId: 555, orderCode: 'af-mh2-abc-e', type: 'MARKET', instrument: 'BTC/USD',
status: 'COMPLETED', finalStatus: true,
legs: [{ instrument: 'BTC/USD', positionEffect: 'OPEN', side: 'BUY', quantity: 0.5, filledQuantity: 0.5, averagePrice: 60000 }],
issueTime: '2026-10-26T01:00:00.000Z', transactionTime: '2026-10-26T01:00:01.000Z',
executions: [{ transactionTime: '2026-10-26T01:00:00.500Z', lastQuantity: 0.5, averagePrice: 60000 }],
};
const stopOrder = {
account: 'default:T247-1', orderId: 556, orderCode: 'af-mh3-def-sl', type: 'STOP', instrument: 'BTC/USD',
status: 'COMPLETED', finalStatus: true,
legs: [{ instrument: 'BTC/USD', positionEffect: 'CLOSE', positionCode: '555', side: 'SELL', filledQuantity: 0.5, averagePrice: 59000 }],
issueTime: '2026-10-26T01:00:02.000Z', transactionTime: '2026-10-26T02:00:00.000Z', executions: [],
};
const txn = (type, value, orderCode) => ({ key: `${orderCode}|${type}`, account: 'default:T247-1', type, value, time: 0, orderCode });
test('DXtrade order history rows', () => {
const o = parseHistoryOrder(openOrder);
assert.equal(o.orderCode, 'af-mh2-abc-e');
assert.equal(o.effect, 'OPEN');
assert.equal(o.side, 'BUY');
assert.equal(o.positionCode, undefined);
assert.equal(o.qty, 0.5);
assert.equal(o.price, 60000);
assert.equal(o.at, Date.parse('2026-10-26T01:00:00.500Z'));
const s = parseHistoryOrder(stopOrder);
assert.equal(s.positionCode, '555');
assert.equal(s.at, Date.parse('2026-10-26T02:00:00.000Z'));
// Never filled: not a trade's.
assert.equal(parseHistoryOrder({ ...openOrder, legs: [{ ...openOrder.legs[0], filledQuantity: 0 }] }), null);
assert.equal(parseHistoryOrder({}), null);
});
test('DXtrade positions from their orders and cash', () => {
const orders = [parseHistoryOrder(stopOrder), parseHistoryOrder(openOrder)];
const txns = [txn('SETTLEMENT', -500, 'af-mh3-def-sl'), txn('COMMISSION', -12, 'af-mh2-abc-e'), txn('COMMISSION', -11.8, 'af-mh3-def-sl')];
const [t, ...rest] = dxTrades(orders, txns, new Map(), new Map());
assert.equal(rest.length, 0);
assert.equal(t.positionCode, '555');
assert.equal(t.side, 'long');
assert.equal(t.quantity, 0.5);
assert.equal(t.openPrice, 60000);
assert.equal(t.closePrice, 59000);
assert.equal(t.pnl, -500);
assert.equal(t.fee, 23.8);
assert.equal(t.openCode, 'af-mh2-abc-e');
assert.equal(t.closeCode, 'af-mh3-def-sl');
assert.equal(t.closeType, 'STOP');
// Still held: open, whatever the history says.
const held = new Map([['555', { symbol: 'BTC/USD', side: 'long', quantity: 0.5, openPrice: 60000, openedAt: 1 }]]);
const [open] = dxTrades([parseHistoryOrder(openOrder)], [], held, new Map());
assert.equal(open.closedAt, null);
assert.equal(open.pnl, null);
// Opened before the history: completed from what's stored, or left out.
const [late] = dxTrades([parseHistoryOrder(stopOrder)], txns, new Map(), held);
assert.equal(late.openedAt, 1);
assert.equal(late.openCode, null);
assert.equal(late.pnl, -500);
assert.equal(dxTrades([parseHistoryOrder(stopOrder)], txns, new Map(), new Map()).length, 0);
// A position open with no orders in the window still shows.
const [quiet] = dxTrades([], [], held, new Map());
assert.equal(quiet.positionCode, '555');
assert.equal(quiet.openedAt, 1);
});
+753 -4
View File
@@ -9,11 +9,13 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
percentFee, takeProfitPlan, obfuscationStopDollars, dllRoom, dllStopDollars, dllViable, stopTicks,
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,
currentTradeDateCT, foldReason, withinFirmCaps, personSizeFactor, personHoldHours, PERSON_SIZE_SPREAD, machineSide,
resolveEffectiveConfig, stageInputsFor, stagePayouts, floorRoom, resetClearStop, shortestNextLeg, computeDayPlan, clearsClose, RESET_CLEAR_HOLDS,
legCloseNote,
} from '../lib/trading-logic.ts';
// Deterministic PRNG so a failure reproduces.
@@ -28,6 +30,38 @@ function rng(seed) {
};
}
// ── prices on the tick grid ─────────────────────────────────────────────────
test('onTick: 2026-10-02 GC short — 87 ticks below 4217.6 is 4208.9, not 4208.900000000001', () => {
assert.notEqual(4217.6 - 87 * 0.1, 4208.9); // the fuzz TopstepX refused
const tp = takeProfitPlan({ side: 'Sell', entry: 4217.6, leg: 861.4, pointValue: 100, contracts: 1, tickSize: 0.1, commissionPct: 0, entryCommission: 2.8 });
assert.equal(tp.ticks, 87);
assert.equal(tp.exitPrice, 4208.9);
assert.equal(JSON.stringify(tp.exitPrice), '4208.9');
});
test('onTick: measured-fee take-profits always land on the grid, as the shortest decimal', () => {
const r = rng(11);
for (let i = 0; i < 5000; i++) {
const side = r() < 0.5 ? 'Buy' : 'Sell';
const [pv, tick] = [[100, 0.1], [1000, 0.01], [10, 0.1], [12.5, 0.0001], [1000, 0.03125], [50, 0.25]][Math.floor(r() * 6)];
const decimals = String(tick).split('.')[1].length;
const entry = Number((Math.round((50 + r() * 20000) / tick) * tick).toFixed(decimals));
const tp = takeProfitPlan({ side, entry, leg: 5 + r() * 2500, pointValue: pv, contracts: 1 + Math.floor(r() * 4), tickSize: tick, commissionPct: 0, entryCommission: Math.round(r() * 1000) / 100 });
assert.equal(tp.exitPrice, Number(tp.exitPrice.toFixed(decimals)), `${tp.exitPrice} has more decimals than tick ${tick}`);
assert.equal(Math.round(Math.abs(tp.exitPrice - entry) / tick), tp.ticks);
}
});
test('onTick: an averaged fill between ticks goes the given way; fuzz never moves a tick', () => {
assert.equal(onTick(7749.125, 0.25, 'up'), 7749.25); // 2 ES filled at .00 and .25
assert.equal(onTick(7749.125, 0.25, 'down'), 7749);
assert.equal(onTick(4208.900000000001, 0.1, 'up'), 4208.9);
assert.equal(onTick(4208.899999999999, 0.1, 'down'), 4208.9);
assert.equal(onTick(62.07, 0.01, 'up'), 62.07);
assert.equal(onTick(123.5, 0, 'up'), 123.5); // no tick size known: untouched
});
// ── parity with the pre-refactor inline code ────────────────────────────────
/** The take-profit exactly as auto-trade.ts computed it before takeProfitPlan. */
@@ -51,7 +85,8 @@ test('takeProfitPlan reproduces the old formula when fees are measured', () => {
const fee = Math.round(r() * 1000) / 100;
const got = takeProfitPlan({ side, entry, leg, pointValue: pv, contracts, tickSize: tick, commissionPct: 0, entryCommission: fee });
const want = oldTakeProfit(side, entry, leg, pv, contracts, tick, fee);
assert.equal(got.exitPrice, want.exitPrice);
// The same price, minus the old formula's float fuzz.
assert.equal(got.exitPrice, Math.round(want.exitPrice * 1e10) / 1e10);
assert.equal(got.ticks, want.ticks);
assert.equal(got.totalCommission, want.totalCommission);
assert.equal(got.grossTarget, want.grossTarget);
@@ -271,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
@@ -385,6 +436,21 @@ test('the daily-loss reserve grows with the position past 10% of the limit', ()
assert.equal(dllStopDollars({ room: 3000, dailyLossLimit: 3000, entryFee: 0, commissionPct: 0 }), 2699.99);
});
test('a platform whose stops fill closer reserves its own share of the position', () => {
// Me's Vest eval on 2026-10-27: $1,400 of room on $495,439 of NDX. At the
// 0.1% default the reserve is $495.44; at 0.02% the flat $140 binds.
const n = 495439;
assert.ok(Math.abs(dllReserve(1400, n) - 495.439) < 1e-9);
assert.equal(dllReserve(1400, n, 0.0002), 140);
assert.ok(Math.abs(dllReserve(1400, 900000, 0.0002) - 180) < 1e-9, 'past $700k the share binds again');
// Left out, or not a share at all: the default.
for (const bad of [undefined, NaN, -0.0002, Infinity]) assert.equal(dllReserve(1400, n, bad), dllReserve(1400, n), String(bad));
assert.equal(dllReserve(1400, n, 0), 140, 'no slippage at all still keeps the flat 10%');
const stop = (slippageFraction) => dllStopDollars({ room: 1400, dailyLossLimit: 1400, entryFee: 12.45, commissionPct: 0, notional: n, slippageFraction });
assert.ok(Math.abs(stop(undefined) - (1400 - 495.439 - 24.9 - 0.01)) < 1e-9);
assert.ok(Math.abs(stop(0.0002) - (1400 - 140 - 24.9 - 0.01)) < 1e-9);
});
test('max-drawdown room: the static floor, the broker floor, the higher', () => {
assert.equal(maxLossRoom({ balance: 99757.39, accountSize: 100000, maxLoss: 6000 }), 5757.39);
assert.equal(maxLossRoom({ balance: 99757.39, accountSize: 100000, maxLoss: -6000 }), 5757.39);
@@ -407,6 +473,13 @@ test('last stand: no daily-loss stop once hitting the daily limit would leave 15
assert.equal(lastStand({ dllRoom: 1000, dailyLossLimit: 3000, maxLossRoom: 1200 }), false);
assert.equal(lastStand({ dllRoom: 1000, dailyLossLimit: 3000, maxLossRoom: 1150 }), true);
assert.equal(lastStand({ dllRoom: Infinity, dailyLossLimit: 0, maxLossRoom: 500 }), false, 'no daily limit, no daily stop to drop');
// A green day's widened room is measured against a full day's limit: a
// $1,400 eval starting $1,700 over its floor, up $1,000 — leg 2's $2,400
// stop-out leaves $300, over 15% of $1,400, so it keeps its stop.
assert.equal(lastStand({ dllRoom: 2400, dailyLossLimit: 1400, maxLossRoom: 2700 }), false);
assert.equal(lastStand({ dllRoom: 2400, dailyLossLimit: 1400, maxLossRoom: 2610 }), true);
// The room past the floor is a last stand however it was widened.
assert.equal(lastStand({ dllRoom: 2400, dailyLossLimit: 1400, maxLossRoom: 2300 }), true);
// The far side of the walk is the nearer floor, net of the entry fee.
assert.equal(breachDistance({ dllRoom: 3000, maxLossRoom: 2900, entryFee: 120 }), 2780);
assert.equal(breachDistance({ dllRoom: 2757.39, maxLossRoom: null, entryFee: 0 }), 2757.39);
@@ -442,6 +515,154 @@ test('planLegSize: the stop, a last stand, the margin cap and the cost, as fireL
assert.equal(planLegSize({ ...base, maxLossRoom: null, contract: {} }).sizing, null);
});
test('resetClearStop: the widest stop a leg may carry and still close before the reset', () => {
const h = 3_600_000;
const near = (a, b) => Math.abs(a - b) < 1e-6;
// Exactly four holds off: a 2:1 leg, no more.
assert.ok(near(resetClearStop({ leg: 2395.76, resetInMs: 6 * h, holdHours: 1.5 }), 2 * 2395.76));
// Under four holds (the 5:30 PM CT open batch, 1.5 h before Vest's
// reset): no widening at all.
assert.equal(resetClearStop({ leg: 2395.76, resetInMs: 1.5 * h, holdHours: 1.5 }), 0);
assert.equal(resetClearStop({ leg: 2395.76, resetInMs: 6 * h - 1, holdHours: 1.5 }), 0);
// Dingo's second leg at ~4 AM ET, 16 h off: far past the $4,004 the day allows.
assert.ok(resetClearStop({ leg: 2395.76, resetInMs: 16 * h, holdHours: 1.5 }) > 20000);
// A $500 final leg 10 h off may carry about $2,656, not the $5,900 a
// $4,500 gain would give: g = (A+B)²/(AB) stays at 4.5 × 10/6.
const lopsided = resetClearStop({ leg: 500, resetInMs: 10 * h, holdHours: 1.5 });
assert.ok(Math.abs(lopsided - 2656) < 1, `${lopsided}`);
assert.ok(near((500 + lopsided) ** 2 / (500 * lopsided), 4.5 * 10 / 6));
assert.equal(resetClearStop({ leg: 0, resetInMs: 16 * h, holdHours: 1.5 }), 0);
assert.equal(resetClearStop({ leg: 500, resetInMs: 16 * h, holdHours: 0 }), 0);
assert.equal(resetClearStop({ leg: 500, resetInMs: NaN, holdHours: 1.5 }), 0);
// The cap is held to the shortest leg the day may carve: a split day's
// $2,395.76 can come out a $500 leg.
assert.equal(shortestNextLeg({ remainingToday: 2395.76, splitAllowed: true }), 500);
assert.equal(shortestNextLeg({ remainingToday: 320, splitAllowed: true }), 320);
assert.equal(shortestNextLeg({ remainingToday: 2395.76, splitAllowed: false }), 2395.76);
// Dingo at ~4 AM ET still gets the whole $2,604.24: a $500 leg 16 h off
// may carry ~$4,950.
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.
const ndx = { stepNotional: 2.95, volatilityPerHour: 0.0131 / Math.sqrt(24), spreadFraction: 0.0000085, marginRate: 0.02 };
const leg = {
leg: 2395.76, contract: ndx, commissionPct: 0.0025, targetHoldHours: 1.5, financingPctPerDay: 0,
dailyLossLimit: 1400, obfuscationDollars: Infinity, availableFunds: 52604.24, maxLossRoom: 5604.24,
};
const capped = planLegSize({ ...leg, dllRoom: 1400 });
const room = floorRoom({ dailyLossLimit: 1400, closedBalance: 52604.24, floor: 48500, realizedToday: 2604.24, gain: 2604.24 });
const widened = planLegSize({ ...leg, dllRoom: room });
assert.equal(widened.lastStand, false);
for (const p of [capped, widened]) assert.ok(Math.abs(p.sizing.predictedHoldHours - 1.5) < 0.05, `hold ${p.sizing.predictedHoldHours}`);
assert.ok(widened.sizing.notional > 1.5 * capped.sizing.notional, `${widened.sizing.notional} vs ${capped.sizing.notional}`);
// The stop gives back the day's gain plus the limit, less the reserve.
const n = widened.sizing.notional;
assert.ok(widened.walkAt(n) > 2604.24 && widened.walkAt(n) < 4004.24, `stop ${widened.walkAt(n)}`);
});
test('planLegSize: a platform\'s own slippage share widens the stop and sizes the leg up for the same hold', () => {
// Me's first-day Vest eval leg on 2026-10-27: $5,000 to pass, $1,400 of
// room, NDX at ~$29.8k, sized for 2.6 h.
const ndx = { stepNotional: 2.98, volatilityPerHour: 0.0131 / Math.sqrt(24), spreadFraction: 0.0000085, marginRate: 0.02 };
const leg = {
leg: 5000, contract: ndx, commissionPct: 0.0025, targetHoldHours: 2.6, financingPctPerDay: 0,
dailyLossLimit: 1400, dllRoom: 1400, obfuscationDollars: Infinity, availableFunds: 50000, maxLossRoom: 3000,
};
const deflt = planLegSize(leg);
const vest = planLegSize({ ...leg, slippageFraction: 0.0002 });
assert.equal(planLegSize({ ...leg, slippageFraction: undefined }).sizing.notional, deflt.sizing.notional);
for (const p of [deflt, vest]) {
assert.equal(p.lastStand, false);
assert.ok(Math.abs(p.sizing.predictedHoldHours - 2.6) < 0.05, `hold ${p.sizing.predictedHoldHours}`);
}
const r = 0.0025 / 100;
const nd = deflt.sizing.notional, nv = vest.sizing.notional;
assert.ok(Math.abs(deflt.walkAt(nd) - (1400 - 0.001 * nd - 2 * r * nd - 0.01) / (1 + r)) < 1e-9);
assert.ok(Math.abs(vest.walkAt(nv) - (1400 - 140 - 2 * r * nv - 0.01) / (1 + r)) < 1e-9, 'the flat $140 binds');
assert.ok(vest.walkAt(nv) > 1200 && deflt.walkAt(nd) < 950, `stops ${vest.walkAt(nv)} vs ${deflt.walkAt(nd)}`);
assert.ok(nv > 1.1 * nd, `${nv} vs ${nd}`);
assert.ok(vest.sizing.winProbability > deflt.sizing.winProbability);
});
test('personSizeFactor: one draw per person and trade date, within the spread', () => {
const people = Array.from({ length: 300 }, (_, i) => `0b6f${i.toString(16).padStart(4, '0')}-5c1e-4a7e-9d2b-${i}`);
const draws = people.map((p) => personSizeFactor(p, '2026-10-20'));
for (const f of draws) assert.ok(f >= 1 - PERSON_SIZE_SPREAD && f <= 1 + PERSON_SIZE_SPREAD, `factor ${f}`);
// Spread over the whole range, not bunched at 1.
assert.ok(Math.min(...draws) < 0.88 && Math.max(...draws) > 1.12, `${Math.min(...draws)}–${Math.max(...draws)}`);
// Repeatable, so every sizing of the day agrees.
assert.equal(personSizeFactor(people[0], '2026-10-20'), draws[0]);
// Two people land within half a percent of each other about as often as
// uniform draws do (2 × 0.005 / 0.3 ≈ 3%), not bunched together.
const close = draws.filter((f, i) => i > 0 && Math.abs(f - draws[i - 1]) < 0.005).length;
assert.ok(close < draws.length * 0.08, `${close} near-identical neighbours`);
// A new trade date, a new draw: a month of one person's days covers the
// range too, not a slow drift (the next date differs in its last digit).
const days = Array.from({ length: 30 }, (_, i) => personSizeFactor(people[0], `2026-11-${String(i + 1).padStart(2, '0')}`));
assert.ok(Math.max(...days) - Math.min(...days) > PERSON_SIZE_SPREAD, `days span ${Math.min(...days)}–${Math.max(...days)}`);
const steps = days.slice(1).map((f, i) => Math.abs(f - days[i]));
assert.ok(steps.reduce((a, b) => a + b, 0) / steps.length > 0.05, 'day to day the draw should jump, not creep');
});
test('personHoldHours: the size comes out the person\'s factor times the plan, at the hold it was sized for', () => {
const eth = { stepNotional: 26.86, volatilityPerHour: 0.022 / Math.sqrt(24), spreadFraction: 0.00004, marginRate: 0.2 };
const base = {
leg: 12239, contract: eth, commissionPct: 0.04, targetHoldHours: 12, financingPctPerDay: 0.033,
dailyLossLimit: 3000, dllRoom: 2757.39, maxLossRoom: 5757.39, obfuscationDollars: Infinity, availableFunds: 99757.39,
};
const plan = planLegSize(base).sizing;
for (const person of ['me-uuid', 'nik-uuid', 'ahmad-uuid', 'ziad-uuid']) {
const factor = personSizeFactor(person, '2026-10-20');
const hold = personHoldHours(12, person, '2026-10-20');
const sized = planLegSize({ ...base, targetHoldHours: hold }).sizing;
// Fees and the daily-loss stop move a little with the size, so the
// ratio sits a touch nearer 1 than the factor.
const ratio = sized.notional / plan.notional;
assert.ok(Math.abs(ratio - factor) < 0.02, `${person}: ×${ratio.toFixed(4)} vs factor ${factor.toFixed(4)}`);
assert.ok((ratio - 1) * (factor - 1) > 0, `${person}: moved the wrong way`);
// The pick ranks against this hold, and the size reaches it.
assert.ok(Math.abs(sized.predictedHoldHours - hold) < 0.1, `${person}: ${sized.predictedHoldHours}h vs ${hold}h`);
}
// No firm hold: the default, moved the same way.
assert.equal(personHoldHours(0, 'me-uuid', '2026-10-20'), personHoldHours(DEFAULT_TARGET_HOLD_HOURS, 'me-uuid', '2026-10-20'));
});
test('planLegSize: the broker\'s order size limits bind the sizing, so the pick sees the real hold', () => {
const eth = { stepNotional: 26.86, volatilityPerHour: 0.022 / Math.sqrt(24), spreadFraction: 0.00004, marginRate: 0.2 };
const base = {
@@ -544,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 {
@@ -738,3 +992,498 @@ test('tick reasons fold their amounts, so like reasons count together', () => {
assert.equal(foldReason("waiting for the broker to roll the account's day"), "waiting for the broker to roll the account's day");
assert.equal(foldReason('leg cap reached (5 today)'), 'leg cap reached (5 today)');
});
test('withinFirmCaps: a firm\'s cap on accounts at once, in order, less what it holds', () => {
const topstep = { id: 'ts', maxConcurrentAccounts: 3 };
const apex = { id: 'apex', maxConcurrentAccounts: 0 };
const lucid = { id: 'lucid' };
const items = [
...[1, 2, 3, 4, 5, 6].map((n) => ({ firm: topstep, n: `ts${n}` })),
...[1, 2, 3, 4].map((n) => ({ firm: apex, n: `apex${n}` })),
{ firm: lucid, n: 'lucid1' },
];
const names = (xs) => xs.map((x) => x.n);
// 6 Topstep capped at 3 keep the first 3; uncapped firms keep everything.
assert.deepEqual(names(withinFirmCaps(items)), ['ts1', 'ts2', 'ts3', 'apex1', 'apex2', 'apex3', 'apex4', 'lucid1']);
// Two already open: one more slot.
assert.deepEqual(names(withinFirmCaps(items, new Map([['ts', 2]]))), ['ts1', 'apex1', 'apex2', 'apex3', 'apex4', 'lucid1']);
// At (or past) the cap: none.
assert.deepEqual(names(withinFirmCaps(items, new Map([['ts', 4]]))), ['apex1', 'apex2', 'apex3', 'apex4', 'lucid1']);
// Held counts don't touch an uncapped firm, and the input isn't mutated.
const held = new Map([['apex', 9]]);
assert.equal(withinFirmCaps(items, held).filter((x) => x.firm === apex).length, 4);
assert.equal(held.get('apex'), 9);
assert.equal(held.has('ts'), false);
// Interleaved order is kept.
const mixed = [{ firm: topstep, n: 'a' }, { firm: apex, n: 'b' }, { firm: topstep, n: 'c' }, { firm: topstep, n: 'd' }, { firm: topstep, n: 'e' }];
assert.deepEqual(names(withinFirmCaps(mixed)), ['a', 'b', 'c', 'd']);
assert.deepEqual(withinFirmCaps([]), []);
});
// ── fund events from the balance (TopstepX reconcileFunds) ──────────────────
import { fundReconcileStep, FUND_CONFIRM_MS } from '../lib/trading-logic.ts';
/** reconcileFunds' bookkeeping for one trade date, pass by pass: `balance`
* and `trades` (all trade P&L, today's included) as each pass read them.
* `existing` is a row already on the day that reconcileFunds didn't book. */
function reconcileDay(passes, existing = null) {
let row = existing;
let undoable = false; // the day's first booking saves what it replaces
let sighting = null;
const steps = [];
for (const { at, balance, trades } of passes) {
const delta = Math.round((balance - trades - (row ?? 0)) * 100) / 100;
const step = fundReconcileStep({ delta, sighting, now: at * 1000, existingToday: row, undoable });
steps.push(step);
if (step.kind === 'unconfirmed') { sighting = step.sighting; continue; }
sighting = null;
if (step.kind === 'book') { if (row === null) undoable = true; row = step.amount; }
if (step.kind === 'drop') { row = null; undoable = false; }
}
return { row, kinds: steps.map((s) => s.kind), steps };
}
test('fund reconcile: a balance read before a fill the trades already show books nothing', () => {
// 2026-10-05, EXPRESS-V2-CT-DLL-39029-23275136: the pass read the balance
// (−2.16), then the trades with the −3,001.16 stop-out in them. One pass
// later the balance had it (−3,004.32, $1.00 of commission off-book).
// Booked as it came, that was +3,001.16, then −3,002.16, merged to −1.00.
const express = reconcileDay([
{ at: 0, balance: -2.16, trades: -3003.32 },
{ at: 60, balance: -3004.32, trades: -3003.32 },
]);
assert.deepEqual(express.kinds, ['unconfirmed', 'settled']);
assert.equal(express.steps[0].sighting.delta, 3001.16);
assert.equal(express.row, null);
// 2026-10-08, 150KTC-SKU-V2-DLL-39029-99745612 (balance less the $150K
// start): +2,474.01 then −2,492.01, which left a −18.00 row.
const combine = reconcileDay([
{ at: 0, balance: 2413.43, trades: -60.58 },
{ at: 60, balance: -78.58, trades: -60.58 },
]);
assert.deepEqual(combine.kinds, ['unconfirmed', 'settled']);
assert.equal(combine.row, null);
});
test('fund reconcile: a real withdrawal books once a later pass still sees it', () => {
// 2026-10-14, EXPRESS-V2-CT-DLL-39029-43788281: a $4,620 payout.
const payout = reconcileDay([
{ at: 0, balance: 4601.42, trades: 9221.42 },
{ at: 10, balance: 4601.42, trades: 9221.42 }, // too soon to confirm
{ at: 60, balance: 4601.42, trades: 9221.42 },
]);
assert.deepEqual(payout.kinds, ['unconfirmed', 'unconfirmed', 'book']);
assert.equal(payout.steps[1].fresh, false);
assert.equal(payout.steps[1].sighting.since, 0); // the clock runs from the first sighting
assert.equal(payout.row, -4620);
// Fee noise between the passes still confirms; the later amount books.
assert.equal(reconcileDay([
{ at: 0, balance: 4601.42, trades: 9221.42 },
{ at: FUND_CONFIRM_MS / 1000, balance: 4598.42, trades: 9221.42 },
]).row, -4623);
// An amount that moves starts over: the second sighting is the new one.
const moved = reconcileDay([
{ at: 0, balance: 4601.42, trades: 9221.42 },
{ at: 60, balance: 4901.42, trades: 9221.42 },
{ at: 120, balance: 4901.42, trades: 9221.42 },
]);
assert.deepEqual(moved.kinds, ['unconfirmed', 'unconfirmed', 'book']);
assert.equal(moved.steps[1].fresh, true);
assert.equal(moved.row, -4320);
});
test('fund reconcile: a transient that outlasts the confirmation is taken back whole', () => {
// The balance lags for two passes, so +3,001.16 books; when it catches up,
// the reversal nets the day to −1.00 and the day is dropped, not left as
// a −1.00 "withdrawal" that splits the cycle.
const lagging = reconcileDay([
{ at: 0, balance: -2.16, trades: -3003.32 },
{ at: 60, balance: -2.16, trades: -3003.32 },
{ at: 120, balance: -3004.32, trades: -3003.32 },
{ at: 180, balance: -3004.32, trades: -3003.32 },
]);
assert.deepEqual(lagging.kinds, ['unconfirmed', 'book', 'unconfirmed', 'drop']);
assert.equal(lagging.steps[1].amount, 3001.16);
assert.equal(lagging.steps[3].net, -1);
assert.equal(lagging.row, null);
// A payout landing with the reversal is real: the day keeps its row.
const withPayout = reconcileDay([
{ at: 0, balance: -2.16, trades: -3003.32 },
{ at: 60, balance: -2.16, trades: -3003.32 },
{ at: 120, balance: -7624.32, trades: -3003.32 },
{ at: 180, balance: -7624.32, trades: -3003.32 },
]);
assert.deepEqual(withPayout.kinds, ['unconfirmed', 'book', 'unconfirmed', 'book']);
assert.equal(withPayout.row, -4621);
// Only a day reconcileFunds booked itself is taken back: a row it found
// there (an import) merges as before.
const imported = reconcileDay([
{ at: 0, balance: -1, trades: 0 },
{ at: 60, balance: -1, trades: 0 },
], -4620);
assert.deepEqual(imported.kinds, ['unconfirmed', 'book']);
assert.equal(imported.row, -1);
});
test('machineSide: one side per machine, across every lane', () => {
// Nothing held, nothing being placed: free.
assert.equal(machineSide([], null), null);
// Vest shorts held (hand trades or the bot's): every lane must short.
assert.equal(machineSide([-42.6545, -12.1], null), 'Sell');
// A long NQ in the futures lane: the Vest lane must go long.
assert.equal(machineSide([3], null), 'Buy');
// Another lane's batch is placing longs right now, nothing filled yet.
assert.equal(machineSide([], 'Buy'), 'Buy');
assert.equal(machineSide([2], 'Buy'), 'Buy');
// Both sides already: nothing may be added.
assert.equal(machineSide([1, -1], null), 'mixed');
assert.equal(machineSide([-5], 'Buy'), 'mixed');
});
test('same-day stages: today\'s payouts pick the stage, and a stage may carry its own daily limit', () => {
// The Vest 50k Funded type: $1,500 then claim, then $3,000 on the room the claim freed.
const stages = [{ profit: 3000, consistency: 0, minTradingDays: 0, dailyLossLimit: 3000 }];
const fundRows = [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-25', amount: -1500 }];
const resolve = (inputs) => resolveEffectiveConfig(1500, 0, 0, false, stages, 0, fundRows, 0, inputs);
// Base stage, base limit: yesterday's payout doesn't count for a same-day type.
const fresh = resolve({ dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 0 });
assert.equal(fresh.profitTarget, 1500);
assert.equal(fresh.dailyLossLimit, 1229);
// After a claim today: stage 2, its own limit.
const after = resolve({ dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 1 });
assert.equal(after.profitTarget, 3000);
assert.equal(after.dailyLossLimit, 3000);
// A second claim today: the last stage repeats.
assert.equal(resolve({ dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 2 }).profitTarget, 3000);
// Without same-day stages the lifetime count picks it, as it always has.
assert.equal(resolve({ dailyLossLimit: 1229 }).profitTarget, 3000);
// A stage without its own limit keeps the type's; no inputs = no limit.
const plain = resolveEffectiveConfig(1500, 0, 0, false, [{ profit: 2000, consistency: 0, minTradingDays: 0 }], 0, fundRows, 0, { dailyLossLimit: 1229 });
assert.equal(plain.dailyLossLimit, 1229);
assert.equal(resolveEffectiveConfig(1500, 0, 0, false, [], 0, [], 0).dailyLossLimit, 0);
// stageInputsFor asks the client only for a same-day type.
const client = { sameDayClaims: () => 1 };
assert.deepEqual(stageInputsFor(client, 7, { dailyLossLimit: 1229, stagesSameDay: true }), { dailyLossLimit: 1229, stagesSameDay: true, sameDayWithdrawals: 1 });
assert.deepEqual(stageInputsFor(client, 7, { dailyLossLimit: 1229 }), { dailyLossLimit: 1229, stagesSameDay: false, sameDayWithdrawals: 0 });
assert.deepEqual(stageInputsFor({}, 7, { stagesSameDay: true }), { dailyLossLimit: 0, stagesSameDay: true, sameDayWithdrawals: 0 });
});
test('floorRoom: what\'s left of the stage\'s limit, widened by the day\'s gain the client allows, never past the platform\'s floor', () => {
// Fresh day: $50k, Vest floor $48,500, the type's $1,229.
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 50000, floor: 48500, realizedToday: 0 }), 1229);
// Lost $1,111.65 today: Vest still allows $388.35, but the bot's own
// $1,229 day has only $117.35 left.
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 48888.35, floor: 48500, realizedToday: -1111.65 }), 117.35);
// A green day alone doesn't widen the limit...
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 50600, floor: 48500, realizedToday: 600 }), 1229);
// ...the gain the client allows does: the next leg may give it back plus
// the limit, as on Dingo's 2026-10-27 evals ($1,400 a day, up $2,604.24)...
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 50600, floor: 48500, realizedToday: 600, gain: 600 }), 1829);
assert.equal(floorRoom({ dailyLossLimit: 1400, closedBalance: 52604.24, floor: 48500, realizedToday: 2604.24, gain: 2604.24 }), 4004.24);
// ...never past Vest's own floor...
assert.equal(floorRoom({ dailyLossLimit: 1400, closedBalance: 52604.24, floor: 49000, realizedToday: 2604.24, gain: 2604.24 }), 3604.24);
// ...and never by more than the day realized: $2,250 claimed by hand
// leaves realized at $2,250 (added back) but none of it in the account.
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 50000, floor: 46250, realizedToday: 2250, gain: 0 }), 1229);
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 50600, floor: 48500, realizedToday: 600, gain: 5000 }), 1829);
// A red day still counts in full, whatever gain is offered.
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 49500, floor: 48500, realizedToday: -500, gain: 300 }), 729);
// Won $1,500 and claimed it: balance $50k, Vest's floor lowered to $47k,
// the day's P&L counted from the claim. Stage 2's $3,000 gets all of it.
assert.equal(floorRoom({ dailyLossLimit: 3000, closedBalance: 50000, floor: 47000, realizedToday: 0 }), 3000);
// ...and after a $500 stage-2 loss, $2,500 either way.
assert.equal(floorRoom({ dailyLossLimit: 3000, closedBalance: 49500, floor: 47000, realizedToday: -500 }), 2500);
// If Vest hadn't lowered the floor, only what it really leaves.
assert.equal(floorRoom({ dailyLossLimit: 3000, closedBalance: 50000, floor: 48500, realizedToday: 0 }), 1500);
// Below the floor already: negative room (no trade).
assert.equal(floorRoom({ dailyLossLimit: 1229, closedBalance: 48400, floor: 48500, realizedToday: -1600 }), -371);
assert.equal(floorRoom({ dailyLossLimit: 0, closedBalance: 50000, floor: 48500, realizedToday: -900 }), Infinity);
});
test('the day after a claim: a fresh cycle that aims at the next stage\'s target', () => {
// Claimed everything above $50k; P&L since the claim is 0, the cycle has no days yet.
const plan = computeDayPlan(7, '2026-10-27', 3000, 0, [], 0, 0, 0, 0, false, false);
assert.ok(plan.dayTarget >= 3000 && plan.dayTarget <= 3025, `dayTarget ${plan.dayTarget}`);
assert.equal(plan.remainingToday, plan.dayTarget);
});
// ── 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);
});
+771
View File
@@ -0,0 +1,771 @@
// 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; a
// refused account-token mint, a new account's history read again within the
// minute, and exits that wait out a lapse in the session.
//
// 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');
// The client's own transport, for the checks of what it does with an answer.
const realRequest = VestClient.prototype.request;
let gateway = null;
VestClient.prototype.request = async function (bucket, method, path, opts = {}) {
// Calls on an account's token reach the gateway here without one, so its
// mint is answered here too.
if (opts.mint) return { data: { accessToken: `eyJ.${opts.body.accountId}.sig`, accessExpiresAtMs: Date.now() + 600_000 } };
if (!gateway) throw new Error(`no gateway in this test: ${method} ${path}`);
return gateway(method, path, opts);
};
const { putVestSession, vestSessionFor, dropVestSession } = 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();
}
});
// A timeout: an ask let through twice leaves the first one waiting forever.
test("a passed evaluation doesn't hold the sync, and its funded account is asked for once", { timeout: 10_000 }, async () => {
clockAt('2026-10-29T15:31:00Z');
// Vest's status for each account (acc-2 passed), and the balance Vest's
// active list leaves out — a passed one's.
const status = { 'acc-1': 2, 'acc-2': 3 };
let missingBalance = 'acc-2';
let activation = null;
const sent = [];
const client = await syncingClient((method, path, opts) => {
if (method === 'GET' && path === '/v3/capital/accounts/active') {
return { data: { accounts: Object.keys(status).map((id, i) => ({
id, status: status[id], account_type: 2, stage: 1, step: 1, steps_total: 1, plan_product_type: 'eval',
attempt_index: i + 80, initial_capital: '50000', max_daily_loss_pct: '0.03',
stages: [{ ordinal: 1, kind: 'eval', profit_target: '5000' }, { ordinal: 2, kind: 'funded', profit_target: '' }],
})) } };
}
if (method === 'GET' && path === '/v3/accounts') {
return { data: { accounts: [
{ account_id: 'acc-1', amount: '50000', account_type: 2 }, { account_id: 'acc-2', amount: '55100', account_type: 2 }, { account_id: 'main', amount: '0', account_type: 1 },
].filter((b) => b.account_id !== missingBalance) } };
}
if (method === 'POST' && path === '/v3/capital/activate-funded') {
sent.push({ account: opts.account, body: opts.body });
return activation();
}
return syncReads(method, path, []) ?? unanswered(method, path);
});
const funded = { id: 'acc-2-funded', status: 2, account_type: 3, stage: 2, attempt_index: 81, initial_capital: '50000', max_daily_loss_pct: '0.03', stages: [{ ordinal: 1, kind: 'eval' }, { ordinal: 2, kind: 'funded' }] };
// The sync an ask starts behind it, run to its end.
const settled = async () => {
await new Promise((resolve) => setImmediate(resolve));
await client.syncRunning;
};
try {
await client.requestSync();
assert.ok(client.lastSyncAt > 0, "the passed account's missing balance didn't make the sync short of good");
assert.equal(client.accountList.find((a) => a.id === B).active, false);
assert.deepEqual(client.activatable(), [B]);
await assert.rejects(client.activateFunded(A), /only a passed evaluation/);
// While the ask is out, a second caller is refused, and a sync reads
// a list from before Vest's answer: it isn't asked again on it.
let answer;
activation = () => new Promise((resolve) => { answer = resolve; });
const asking = client.activateFunded(B);
await assert.rejects(client.activateFunded(B), /hasn't been read back/);
clockAt('2026-10-29T15:31:30Z');
await client.requestSync();
assert.deepEqual(client.activatable(), []);
answer({ data: { account: funded } });
assert.deepEqual(await asking, { funded: 'V50K-F-D3-81' });
assert.deepEqual(sent, [{ account: 'acc-2', body: { eval_account_id: 'acc-2' } }]);
// Answered: never asked again, while Vest's list is slow to move it
// or once it's claimed.
await settled();
clockAt('2026-10-29T15:40:00Z');
await client.requestSync();
assert.deepEqual(client.activatable(), []);
status['acc-2'] = 6;
await client.requestSync();
assert.deepEqual(client.activatable(), []);
assert.equal(sent.length, 1);
// acc-1 passes too. A timeout may have gone in: held until a list
// read a minute after it. A refusal went nowhere: free at once.
status['acc-1'] = 3;
missingBalance = 'acc-1';
await client.requestSync();
assert.deepEqual(client.activatable(), [A]);
activation = () => { throw new Error('/v3/capital/activate-funded timed out after 15s'); };
await assert.rejects(client.activateFunded(A), /timed out/);
await settled();
assert.deepEqual(client.activatable(), []);
clockAt('2026-10-29T15:40:30Z');
await client.requestSync();
assert.deepEqual(client.activatable(), []);
clockAt('2026-10-29T15:41:10Z');
await client.requestSync();
assert.deepEqual(client.activatable(), [A]);
activation = () => { throw new VestApiError('/v3/capital/activate-funded: funded limit reached (HTTP 400)', 400); };
await assert.rejects(client.activateFunded(A), /funded limit/);
assert.deepEqual(client.activatable(), [A]);
assert.equal(sent.length, 3);
// A live account's missing balance still holds the sync.
status['acc-1'] = 2;
const good = client.lastSyncAt;
clockAt('2026-10-29T15:45:00Z');
await client.requestSync();
assert.equal(client.lastSyncAt, good);
} 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();
}
});
test("a refused account-token mint fails only that account: the login's session stays, and the next account's reads go out", async () => {
clockAt('2026-10-29T05:41:00Z');
const client = testClient(unanswered, [A, B]);
await client.syncRunning;
// The client's own transport, on a gateway that refuses acc-1's mint as
// Vest did all night on 2026-10-28 (401, code 1002).
client.request = realRequest;
const adapter = axios.defaults.adapter;
const sent = [];
axios.defaults.adapter = async (config) => {
const path = config.url.replace(/^https:\/\/[^/]+/, '');
const body = typeof config.data === 'string' ? JSON.parse(config.data) : undefined;
sent.push(`${config.method.toUpperCase()} ${path}${body?.accountId ? ` ${body.accountId}` : ''}`);
const reply = (status, data) => ({ status, statusText: '', headers: {}, config, data });
if (path === '/v3/auth/account-token') {
return body.accountId === 'acc-1'
? reply(401, { detail: { code: 1002, msg: 'Unauthorized' } })
: reply(200, { accessToken: `eyJ.${body.accountId}.sig`, accessExpiresAtMs: Date.now() + 600_000 });
}
if (path === '/v3/transactions') return reply(200, { transactions: [] });
if (path === '/v3/positions') return reply(200, { positions: [] });
return reply(404, {});
};
try {
const { failedAccounts } = await client.fetchDaysTraded([A, B]);
assert.equal(failedAccounts, 1);
assert.notEqual(vestSessionFor(WALLET), null);
assert.equal(client.historyLoadedIds.has(A), false);
assert.equal(client.historyLoadedIds.has(B), true);
assert.match(client.lastFetchErrors['V50K-F-D3-1'], /code 1002/);
assert.ok(sent.includes('POST /v3/auth/account-token acc-2'));
assert.ok(sent.includes('GET /v3/transactions'));
} finally {
axios.defaults.adapter = adapter;
client.disconnect();
}
});
test('an active account with no history is read again within a minute, while a whole pass waits out its retry', async () => {
clockAt('2026-10-29T05:41:00Z');
const C = vestAccountId('acc-3');
const client = testClient(unanswered, [A, B, C]);
await client.syncRunning;
// acc-3 isn't active: it waits for a whole pass.
client.capital.set(C, { status: 3 });
client.accountList[2].active = false;
client.historyLoaded = true;
client.historyLoadedIds = new Set([A]);
client.lastDaysFetchStartedAt = Date.now() - 120_000;
// A whole pass failed: its retry is four minutes off.
client.nextDaysFetchRetryAt = Date.now() + 4 * 60_000;
const reads = [];
client.fetchDaysTraded = async (ids) => {
reads.push(ids);
return { failedAccounts: ids ? ids.length : 0 };
};
const settled = () => new Promise((resolve) => setImmediate(resolve));
try {
client.maybeFetchDaysTraded();
await settled();
assert.deepEqual(reads, [[B]]);
// It failed again: not before the minute is up, and the whole pass's
// retry stays where it was.
client.maybeFetchDaysTraded();
await settled();
assert.deepEqual(reads, [[B]]);
clockAt('2026-10-29T05:42:01Z');
client.maybeFetchDaysTraded();
await settled();
assert.deepEqual(reads, [[B], [B]]);
clockAt('2026-10-29T05:45:01Z');
client.maybeFetchDaysTraded();
await settled();
assert.deepEqual(reads, [[B], [B], undefined]);
} finally {
client.disconnect();
}
});
test("a pass reads the accounts with no history first and the inactive last, and an inactive one's failure isn't the pass's", async () => {
clockAt('2026-10-29T05:41:00Z');
const C = vestAccountId('acc-3');
const client = testClient(unanswered, [A, B, C]);
await client.syncRunning;
client.capital.set(C, { status: 3 });
client.accountList[2].active = false;
client.historyLoadedIds = new Set([A]);
const order = [];
client.fetchTxns = async (uuid) => {
order.push(uuid);
if (uuid === 'acc-3') throw new Error('/v3/transactions not sent — no account token: Unauthorized (HTTP 401, code 1002)');
return [];
};
client.fetchTrades = async () => {};
try {
const { failedAccounts } = await client.fetchDaysTraded([A, B, C]);
assert.deepEqual(order, ['acc-2', 'acc-1', 'acc-3']);
assert.equal(failedAccounts, 0);
assert.match(client.lastFetchErrors['V50K-F-D3-3'], /code 1002/);
} finally {
client.disconnect();
}
});
test('exits wait for the login session to be relayed again; an open goes now or not at all', async () => {
clockAt('2026-10-29T06:00:00Z');
const legs = [];
const client = testClient((method, path, opts) => {
if (method === 'GET' && path === '/v3/positions/opened') return { data: { positions: [rawPosition('9101', 'acc-1', iso('2026-10-29T05:59:00Z'))] } };
if (method === 'POST' && (path === '/v3/positions/stop-loss' || path === '/v3/positions/take-profit')) {
legs.push(`${path.slice('/v3/positions/'.length)} @ ${opts.body.triggerPrice}`);
return { data: {} };
}
throw new Error(`no gateway in this test: ${method} ${path}`);
}, [A, B]);
try {
// The session lapses (Vest refused it) just after an entry filled.
dropVestSession(WALLET, vestSessionFor(WALLET).accessToken);
assert.equal(vestSessionFor(WALLET), null);
const opening = await client.sendOrder(B, NDX, 500, 'Buy', 'Market', undefined, 'open').catch((err) => err);
assert.equal(isOrderNotPlaced(opening), true);
assert.match(opening.message, /no Vest session/);
const exits = client.placeOcoExit(A, NDX, 500, 'Sell', 20100, 19900);
await new Promise((resolve) => setTimeout(resolve, 1_200));
assert.deepEqual(legs, []);
// The extension relays it again: the exits go, the stop first.
tokens += 1;
assert.equal(putVestSession({ accessToken: `eyJ.test${tokens}.sig`, expiresAt: Date.now() + 3_600_000, primaryAddr: WALLET, userId: 'u' }), null);
await exits;
assert.deepEqual(legs, ['stop-loss @ 19900', 'take-profit @ 20100']);
} finally {
client.disconnect();
}
});
+651
View File
@@ -0,0 +1,651 @@
// 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
//
// 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';
import assert from 'node:assert/strict';
import {
isVestWalletAddress, vestAccountId, num, toMs, formatDecimal, vestDayOf, lastVestResetMs, parseCapitalAccount,
vestAccountName, parsePosition, awaitingLegs, parseOpenOrder, unrealizedPnl, parseTxn, bookTxns, txnKind, VEST_TXN,
hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS, nextVestResetMs, resetSafeStop, parseHistoryPosition, jwtClaimStrings,
accountScopedToken, VestApiError, VestNotSentError, nothingWentIn, tokenForRequest, claimWithdrawal, settleClaims,
claimFundRows, readClaims, payoutDaysOf, payoutsSince, longClosure, unknownOpenFill, awaitingActivation, vestStatusName,
VEST_STATUS,
} 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);
test('wallet addresses and account ids', () => {
assert.equal(isVestWalletAddress('0x7ce10771819f80130e204ddb5ca1522e48a9b451'), true);
assert.equal(isVestWalletAddress('0x7CE10771819F80130E204DDB5CA1522E48A9B451'), true);
assert.equal(isVestWalletAddress('0x7ce1'), false);
assert.equal(isVestWalletAddress(''), false);
const id = vestAccountId('843f60e2-e013-5a0e-8b6c-face84bc5509');
assert.equal(id, vestAccountId('843F60E2-E013-5A0E-8B6C-FACE84BC5509'));
assert.notEqual(id, vestAccountId('102566b2-515a-51c5-9ad7-4a7eae0a6a2a'));
assert.ok(id >= 2 ** 50 && Number.isSafeInteger(id));
});
test('numbers, times and decimals as the gateway writes them', () => {
assert.equal(num('49968.511115'), 49968.511115);
assert.equal(num(''), undefined);
assert.equal(num(null), undefined);
assert.equal(toMs(1793035089), 1793035089000);
assert.equal(toMs(1793035089277), 1793035089277);
assert.equal(toMs('2026-10-26T18:41:02.886Z'), iso('2026-10-26T18:41:02.886Z'));
assert.ok(Number.isNaN(toMs(undefined)));
assert.equal(formatDecimal(29554.5, 2), '29554.5');
assert.equal(formatDecimal(50, 2), '50');
assert.equal(formatDecimal(3 * 0.0001, 4), '0.0003');
assert.equal(formatDecimal(42.65449999, 4), '42.6545');
assert.equal(formatDecimal(1e-7, 4), '0');
});
test("Vest's day turns at 20:00 New York, summer and winter", () => {
// EDT (UTC−4): 20:00 ET is 00:00 UTC.
assert.equal(vestDayOf(iso('2026-10-26T23:59:00Z')), '2026-10-26');
assert.equal(vestDayOf(iso('2026-10-27T00:00:00Z')), '2026-10-27');
assert.equal(vestDayOf(iso('2026-10-26T22:05:00Z')), '2026-10-26'); // the bots' 6:05 PM ET open
// EST (UTC−5): 20:00 ET is 01:00 UTC.
assert.equal(vestDayOf(iso('2026-12-01T00:30:00Z')), '2026-11-30');
assert.equal(vestDayOf(iso('2026-12-01T01:00:00Z')), '2026-12-01');
// Month and year ends.
assert.equal(vestDayOf(iso('2026-12-31T23:00:00Z')), '2026-12-31');
assert.equal(vestDayOf(iso('2027-01-01T01:30:00Z')), '2027-01-01');
assert.equal(lastVestResetMs(iso('2026-10-26T18:00:00Z')), iso('2026-10-26T00:00:00Z'));
assert.equal(lastVestResetMs(iso('2026-10-27T00:00:00Z')), iso('2026-10-27T00:00:00Z'));
assert.equal(lastVestResetMs(iso('2026-12-01T00:30:00Z')), iso('2026-11-30T01:00:00Z'));
assert.equal(lastVestResetMs(iso('2026-12-01T01:00:01Z')), iso('2026-12-01T01:00:00Z'));
// Across the November switch back (Nov 1 2026, 2 AM ET).
assert.equal(lastVestResetMs(iso('2026-11-01T12:00:00Z')), iso('2026-11-01T00:00:00Z'));
assert.equal(lastVestResetMs(iso('2026-11-02T12:00:00Z')), iso('2026-11-02T01:00:00Z'));
});
const evalAccount = {
payment_id: 'tailoredpay:vest-markets:production:12727557177',
id: 'e7f69717-bd18-5cb7-9466-673cb283d0b2',
plan_id: '86bb03f9-7422-513c-8d5c-c546cb8d221d',
account_type: 2, stage: 1, status: 2,
initial_capital: '50000.00000000', max_drawdown_pct: '0.060000', max_leverage: '5.0000', max_profit_split_pct: '0.8000',
target_equity: '55000.00000000', max_drawdown_limit: '47000.00000000', max_daily_loss_pct: '0.030000',
daily_reset_balance: '50000.00000000000000', daily_reset_equity: '50000.00000000000000',
daily_reset_base_limit: '1500.000000000000000', daily_loss_floor: '48500.00000000000000', daily_reset_at: 1793035043842,
origin: 'eval_purchase', plan_name: 'Vest Capital Evaluation Fee | 10% Goal | 3% Daily Loss | 80/20 | Emerald',
plan_product_type: 'eval', step: 1, steps_total: 1, root_account_id: 'e7f69717-bd18-5cb7-9466-673cb283d0b2',
stages: [
{ ordinal: 1, kind: 'eval', initial_capital: '50000.00000000', profit_target: '5000.00000000', max_drawdown_pct: '0.060000' },
{ ordinal: 2, kind: 'funded', initial_capital: '50000.00000000', profit_target: '', max_drawdown_pct: '0.060000' },
],
attempt_index: 34, activated_at: 1793035043834, created_at: 1793035043756, updated_at: 1793035043843,
};
test('capital accounts parse, and are named for their rules', () => {
const a = parseCapitalAccount(evalAccount);
assert.equal(a.id, evalAccount.id);
assert.equal(a.kind, 'eval');
assert.equal(a.status, 2);
assert.equal(a.initialCapital, 50000);
assert.equal(a.profitTarget, 5000);
assert.equal(a.maxDrawdownLimit, 47000);
assert.equal(a.dailyLossFloor, 48500);
assert.equal(a.maxDailyLossPct, 0.03);
assert.equal(a.dailyResetAt, 1793035043842);
assert.equal(vestAccountName(a), 'V50K-E-D3-T10-34');
assert.equal(vestAccountName(parseCapitalAccount({ ...evalAccount, stage: 2 })), 'V50K-F-D3-34');
assert.equal(vestAccountName(parseCapitalAccount({ ...evalAccount, max_daily_loss_pct: '0' })), 'V50K-E-D0-T10-34');
assert.equal(vestAccountName(parseCapitalAccount({
...evalAccount, initial_capital: '5000.00000000', attempt_index: 12, stage: 2, steps_total: 2,
stages: [
{ ordinal: 1, kind: 'eval', profit_target: '500.00000000' },
{ ordinal: 2, kind: 'eval', profit_target: '250.00000000' },
{ ordinal: 3, kind: 'funded', profit_target: '' },
],
})), 'V5K-E-D3-T5-12');
assert.equal(vestAccountName(parseCapitalAccount({
...evalAccount, plan_product_type: 'instant_funded', initial_capital: '25000.00000000', max_daily_loss_pct: '0', attempt_index: 7,
stages: [{ ordinal: 1, kind: 'funded', profit_target: '' }],
})), 'V25K-I-7');
assert.equal(vestAccountName(parseCapitalAccount({
...evalAccount, plan_product_type: 'instant_funded', initial_capital: '500.00000000', attempt_index: 1,
stages: [{ ordinal: 1, kind: 'funded', profit_target: '' }],
})), 'V500-I-1');
assert.equal(parseCapitalAccount({ nope: true }), null);
});
test('a passed evaluation waits on its funded account, as the Vest app decides', () => {
const passed = { ...evalAccount, status: VEST_STATUS.GOAL_PASSED };
const a = parseCapitalAccount(passed);
assert.equal(a.step, 1);
assert.equal(a.stepsTotal, 1);
assert.equal(a.accountType, 2);
assert.equal(a.blocked, false);
assert.equal(awaitingActivation(a), true);
// Still trading, already claimed, failed or expired: nothing to activate.
for (const status of [1, 2, 4, 5, 6, 7]) assert.equal(awaitingActivation(parseCapitalAccount({ ...evalAccount, status })), false, `status ${status}`);
// Blocked by Vest.
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, blocked_at: 1793287000000 })), false);
// A funded account that reached its goal isn't an evaluation — on the
// last step's number too, so only the evaluation test can say so.
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, account_type: 3, stage: 2, step: 1 })), false);
// Either of Vest's marks makes it one.
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, account_type: 3 })), true);
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, stage: 2, step: 1 })), true);
// A 2-Step's first step passes on to its second by itself; the last one waits.
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, step: 1, steps_total: 2 })), false);
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, step: 2, steps_total: 2 })), true);
// Without step or steps_total, the Vest app's own fallbacks.
const { step, steps_total, ...bare } = passed;
assert.equal(awaitingActivation(parseCapitalAccount(bare)), true);
assert.equal(parseCapitalAccount({ ...bare, stage: 2 }).step, 2);
assert.equal(parseCapitalAccount({ ...bare, plan_product_type: 'instant_funded' }).stepsTotal, 0);
assert.equal(awaitingActivation(parseCapitalAccount({ ...bare, plan_product_type: 'instant_funded' })), false);
assert.equal(vestStatusName(3), 'goal passed (status 3)');
assert.equal(vestStatusName(6), 'claimed (status 6)');
assert.equal(vestStatusName(9), 'unknown (status 9)');
});
const openPosition = {
positionId: 'afe96d7e-f5ac-4b8a-aae8-f5b63b74e508', accountId: '843f60e2-e013-5a0e-8b6c-face84bc5509', accountType: 2,
symbol: 'NDX-USD-PERP', side: 'short', quantity: '42.6545', openPrice: '29529.250000000', closePrice: '290.250000000',
openDate: 1793035089277, pnl: '0', fee: '31.488885', funding: '-5.423271000000000000', collateral: '24944.027426', leverage: '50',
createdAt: 1793035089244, updatedAt: 1793037621104,
stopLosses: [{ id: 'e22311d2-fbd9-4ab8-b65f-c2921f58fefc', triggerPrice: '29554.500000000', sizeMode: 'full_position', executionType: 'market', status: 'awaiting' }],
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');
assert.equal(p.quantity, 42.6545);
assert.equal(p.collateral, 24944.027426);
assert.equal(p.openedAt, 1793035089277);
assert.equal(awaitingLegs(p).length, 1);
assert.equal(awaitingLegs({ ...p, stopLosses: [{ ...p.stopLosses[0], status: 'triggered' }] }).length, 0);
// Short 42.6545 from 29529.25, marked at 29477.50: +51.75 points each.
assert.ok(Math.abs(unrealizedPnl(p, 29477.5) - 42.6545 * 51.75) < 1e-6);
assert.equal(unrealizedPnl(p, NaN), 0);
assert.equal(parsePosition({ ...openPosition, quantity: '0' }), null);
assert.equal(parseOpenOrder({ order_id: 'o1', account_id: 'a1', position_id: 'p1', symbol: 'GC-PERP', created_at: 1 }).positionId, 'p1');
assert.equal(parseOpenOrder({ symbol: 'x' }), null);
});
test('transactions book into daily P&L and fund rows by Vest day', () => {
assert.equal(txnKind(VEST_TXN.FEE), 'trade');
assert.equal(txnKind(VEST_TXN.REALIZED_PNL), 'trade');
assert.equal(txnKind(VEST_TXN.FUNDING), 'trade');
assert.equal(txnKind(VEST_TXN.WITHDRAW), 'fund');
assert.equal(txnKind(VEST_TXN.FILL_MARGIN), 'margin');
assert.equal(txnKind(VEST_TXN.ORDER_MARGIN_RELEASE), 'margin');
const w = parseTxn({ account_type: 3, action_id: 'a9', amount: '1200', created_at: iso('2026-10-26T15:00:00Z'), transaction_type: 2 });
assert.equal(w.amount, -1200);
assert.equal(w.kind, 'fund');
assert.equal(parseTxn({ amount: 'x', transaction_type: 9, created_at: 1 }), null);
const rows = [
{ action_id: 'd', amount: '50000', created_at: iso('2026-10-20T14:00:00Z'), transaction_type: 1 },
{ action_id: 'o', amount: '-24944.02', created_at: iso('2026-10-26T17:18:00Z'), transaction_type: 6 },
{ action_id: 'f1', amount: '-31.49', created_at: iso('2026-10-26T17:18:00Z'), transaction_type: 8 },
// After 20:00 ET on the 26th: the 27th's day.
{ action_id: 'r1', amount: '812.5', created_at: iso('2026-10-27T00:30:00Z'), transaction_type: 9 },
{ action_id: 'f2', amount: '-31.2', created_at: iso('2026-10-27T00:30:00Z'), transaction_type: 8 },
{ action_id: 'u1', amount: '-5.42', created_at: iso('2026-10-27T01:00:00Z'), transaction_type: 10 },
].map(parseTxn);
const { daily, funds } = bookTxns(rows);
assert.deepEqual([...funds.entries()], [['2026-10-20', 50000]]);
assert.ok(Math.abs(daily.get('2026-10-26').pnl - -31.49) < 1e-9);
assert.ok(Math.abs(daily.get('2026-10-27').pnl - (812.5 - 31.2 - 5.42)) < 1e-9);
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 = [];
let price = 29000;
for (let h = 0; h < 60; h++) {
price *= h % 2 === 0 ? 1.001 : 1 / 1.001;
kl.push([start + h * 3_600_000, '0', '0', '0', String(price), '1']);
}
const base = hourlyVolatility(kl);
assert.ok(base > 0.0009 && base < 0.0011, `σ ${base}`);
// A weekend gap with a 3% jump: not an hour's move, so σ doesn't change.
const gapped = [...kl, [start + 120 * 3_600_000, '0', '0', '0', String(price * 1.03), '1']];
assert.ok(Math.abs(hourlyVolatility(gapped) - base) < 1e-12);
// Any order in, the same σ out.
assert.equal(hourlyVolatility([...kl].reverse()), base);
assert.equal(hourlyVolatility(kl.slice(0, MIN_HOURLY_RETURNS)), null);
});
test('market hours: open stretches, closures and crypto', () => {
const periods = [
{ fromMs: 0, toMs: 100, state: { session: 'unspecified', closure: 'weekly', tradingStatus: 'CLOSE', price: { allow: false } } },
{ fromMs: 100, toMs: 200, state: { session: 'overnight', closure: 'unspecified', tradingStatus: 'OPEN', price: { allow: true } } },
{ fromMs: 200, toMs: 300, state: { session: 'regular', closure: 'unspecified', tradingStatus: 'OPEN', price: { allow: true } } },
{ fromMs: 300, toMs: 400, state: { session: 'unspecified', closure: 'maintenance', tradingStatus: 'OPEN', price: { allow: true } } },
{ fromMs: 400, toMs: 500, state: { session: 'overnight', closure: 'unspecified', tradingStatus: 'OPEN', price: { allow: true } } },
];
assert.deepEqual(marketOpen(periods, 50), { open: false, closesAt: null });
assert.deepEqual(marketOpen(periods, 150), { open: true, closesAt: 300 });
assert.deepEqual(marketOpen(periods, 350), { open: false, closesAt: null });
assert.deepEqual(marketOpen(periods, 450), { open: true, closesAt: 500 });
assert.deepEqual(marketOpen(periods, 600), { open: false, closesAt: null });
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}`;
const wallet = '0x7CE10771819F80130E204DDB5CA1522E48A9B451';
assert.equal(putVestSession({ accessToken: 'nope', expiresAt: now + 900_000, primaryAddr: wallet, userId: 'u' }, now), 'accessToken is not a JWT');
assert.equal(putVestSession({ accessToken: jwt(1), expiresAt: now + 10_000, primaryAddr: wallet, userId: 'u' }, now), 'token already expired');
assert.equal(putVestSession({ accessToken: jwt(1), expiresAt: now + 900_000, primaryAddr: '0x12', userId: 'u' }, now), 'primaryAddr is not a wallet address');
assert.equal(putVestSession({ accessToken: jwt(1), expiresAt: now + 900_000, primaryAddr: wallet, userId: 'u' }, now), null);
// An older token relayed late doesn't replace the fresher one.
assert.equal(putVestSession({ accessToken: jwt(0), expiresAt: now + 300_000, primaryAddr: wallet, userId: 'u' }, now), null);
assert.equal(vestSessionFor(wallet.toLowerCase(), now).accessToken, jwt(1));
assert.equal(vestSessionFor(wallet, now + 890_000), null); // under 30s left
const listed = listVestSessions();
assert.equal(listed.length, 1);
assert.equal(listed[0].primaryAddr, wallet.toLowerCase());
assert.equal('accessToken' in listed[0], false);
dropVestSession(wallet, jwt(0)); // not the held one: kept
assert.ok(vestSessionFor(wallet, now));
dropVestSession(wallet, jwt(1));
assert.equal(vestSessionFor(wallet, now), null);
});
test('the next 8 PM ET reset, across both time changes', () => {
assert.equal(nextVestResetMs(iso('2026-10-26T18:00:00Z')), iso('2026-10-27T00:00:00Z'));
assert.equal(nextVestResetMs(iso('2026-10-27T00:00:00Z')), iso('2026-10-28T00:00:00Z'));
assert.equal(nextVestResetMs(iso('2026-10-31T23:59:00Z')), iso('2026-11-01T00:00:00Z'));
// Nov 1 2026, the clocks go back: that evening's reset is 01:00 UTC.
assert.equal(nextVestResetMs(iso('2026-11-01T12:00:00Z')), iso('2026-11-02T01:00:00Z'));
assert.equal(nextVestResetMs(iso('2026-12-01T00:30:00Z')), iso('2026-12-01T01:00:00Z'));
});
test('resetSafeStop: fit an open position into the room the reset will leave', () => {
// A stage-2 leg after a claim: balance $50,000, 3% → the reset floor is
// $48,500 (above the $47k drawdown floor). $150 reserve, a $3 fee: $1,347 to lose.
const base = {
side: 'long', quantity: 4, openPrice: 29500, mark: 29480, balance: 50000, dailyLossPct: 0.03,
maxDrawdownFloor: 47000, reserve: 150, exitFeeRate: 0.000025, tick: 0.25,
};
// No stop at all (a last stand): one comes in at open − 1347/4, rounded up to the tick.
const fee = 0.000025 * 4 * 29480;
const allowed = 50000 - 48500 - 150 - fee;
const want = Math.ceil((29500 - allowed / 4) / 0.25) * 0.25;
assert.deepEqual(resetSafeStop({ ...base, currentStop: null }), { action: 'stop', price: want });
// A wider stop is pulled in; one already tighter is left alone.
assert.deepEqual(resetSafeStop({ ...base, currentStop: 29000 }), { action: 'stop', price: want });
assert.deepEqual(resetSafeStop({ ...base, currentStop: 29300 }), { action: 'none' });
// The mark already past where the stop must go: close.
assert.deepEqual(resetSafeStop({ ...base, mark: 29100, currentStop: null }), { action: 'close' });
// Short: mirrored, rounded down.
const short = resetSafeStop({ ...base, side: 'short', mark: 29510, currentStop: null });
assert.equal(short.action, 'stop');
assert.equal(short.price, Math.floor((29500 + (50000 - 48500 - 150 - 0.000025 * 4 * 29510) / 4) / 0.25) * 0.25);
// No room past the reset at all: close.
assert.deepEqual(resetSafeStop({ ...base, balance: 48600, maxDrawdownFloor: 48600 }), { action: 'close' });
// The drawdown floor binds when it's above the daily one.
const dd = resetSafeStop({ ...base, maxDrawdownFloor: 49000, currentStop: null });
assert.equal(dd.action, 'stop');
assert.ok(dd.price > want);
// A second position on the account gets what the first leaves: $500
// already committed at the first one's stop, from the same $48,500 floor.
const second = resetSafeStop({ ...base, balance: 49500, floorBalance: 50000, currentStop: null });
assert.equal(second.action, 'stop');
assert.equal(second.price, Math.ceil((29500 - (allowed - 500) / 4) / 0.25) * 0.25);
assert.ok(second.price > want);
// ...and nothing when the first takes it all.
assert.deepEqual(resetSafeStop({ ...base, balance: 48600, floorBalance: 50000, currentStop: null }), { action: 'close' });
// With no room left, a position already up keeps going on a stop that
// locks in the shortfall: one already locking in more is left alone...
const up = { ...base, mark: 29560, balance: 48600, floorBalance: 50000 };
assert.deepEqual(resetSafeStop({ ...up, currentStop: 29520 }), { action: 'none' });
// ...one with none gets it, on the profit side, still under the mark...
const shortfall = 48600 - 48500 - 150 - 0.000025 * 4 * 29560;
assert.deepEqual(resetSafeStop({ ...up, currentStop: null }), { action: 'stop', price: Math.ceil((29500 - shortfall / 4) / 0.25) * 0.25 });
assert.ok(resetSafeStop({ ...up, currentStop: null }).price > 29500);
// ...and with no mark to tell, it closes.
assert.deepEqual(resetSafeStop({ ...up, mark: NaN, currentStop: null }), { action: 'close' });
});
test('parseHistoryPosition: a closed position from GET /v3/positions', () => {
// Shaped like the app's History tab reads it (camelCase, decimal strings).
const t = parseHistoryPosition({
positionId: 'bdda9024-63a9-4abc-a5a8-f83df8ba030a', accountId: '843f60e2-e013-5a0e-8b6c-face84bc5509', symbol: 'NDX-USD-PERP',
side: 'short', quantity: '4.2712', leverage: '50', openPrice: '29529.75', closePrice: '29556.50',
pnl: '-114.25', fee: '6.31', funding: '-0.12', collateral: '2522.54', takeProfits: [], stopLosses: [],
openDate: 1793065590123, closeDate: 1793066346456, createdAt: 1793065590123, updatedAt: 1793066346456, closeReason: 'user_requested',
});
assert.deepEqual(t, {
positionId: 'bdda9024-63a9-4abc-a5a8-f83df8ba030a', symbol: 'NDX-USD-PERP', side: 'short', quantity: 4.2712, leverage: 50,
openPrice: 29529.75, closePrice: 29556.5, pnl: -114.25, fee: 6.31, funding: -0.12,
openedAt: 1793065590123, closedAt: 1793066346456, closeReason: 'user_requested',
});
});
test('parseHistoryPosition: open, snake_case, seconds, and junk', () => {
const open = parseHistoryPosition({
position_id: 'p1', symbol: 'GC-PERP', side: 'LONG', quantity: '0', open_price: '4208.9', close_price: '',
pnl: '', open_date: 1793065590, close_date: null, close_reason: '',
});
assert.equal(open.side, 'long');
assert.equal(open.quantity, 0, 'a size Vest leaves out reads 0 — the store keeps the one it knows');
assert.equal(open.openPrice, 4208.9);
assert.equal(open.closePrice, null);
assert.equal(open.pnl, null);
assert.equal(open.openedAt, 1793065590000);
assert.equal(open.closedAt, null);
assert.equal(open.closeReason, null);
const held = parseHistoryPosition({
positionId: 'p3', symbol: 'NDX-USD-PERP', side: 'short', quantity: '85.7363', openPrice: '29538.96', closePrice: '290.25',
pnl: '0.0', fee: '63.314059', funding: '0.0', openDate: 1793065422000, closeDate: null,
});
assert.equal(held.closePrice, null, "an open position's placeholder close price is dropped");
assert.equal(held.pnl, null, 'and its $0 P&L');
assert.equal(held.fee, 63.314059, 'its entry fee is real');
assert.equal(parseHistoryPosition({ symbol: 'GC-PERP', openDate: 1 }), null, 'no id');
assert.equal(parseHistoryPosition({ positionId: 'p2', symbol: 'GC-PERP' }), null, 'no open time');
assert.equal(parseHistoryPosition(null), null);
});
test('an account\'s own token is told from the login\'s', () => {
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
const acct = '275e765a-2d3d-5de3-8386-71aaa847c0e8';
const accountToken = `${b64({ alg: 'ES256' })}.${b64({ sub: '3cab1794-47bc-523b-91e6-64a9ad26dcbf', acc: { id: acct.toUpperCase() } })}.sig`;
assert.ok(jwtClaimStrings(accountToken).includes(acct));
assert.deepEqual(jwtClaimStrings('not.a-jwt.at-all'), []);
assert.deepEqual(jwtClaimStrings('nodots'), []);
// The login sees every account and its primary one.
assert.equal(accountScopedToken({ capitalIds: [acct, 'b', 'c'], hasPrimary: true, claims: [] }), false);
// One account and no primary: an account's token, whatever its claims.
assert.equal(accountScopedToken({ capitalIds: [acct], hasPrimary: false, claims: [] }), true);
// One account with a primary: only when the token names that account.
assert.equal(accountScopedToken({ capitalIds: [acct], hasPrimary: true, claims: jwtClaimStrings(accountToken) }), true);
assert.equal(accountScopedToken({ capitalIds: [acct], hasPrimary: true, claims: ['3cab1794-47bc-523b-91e6-64a9ad26dcbf'] }), false);
// A login with no capital accounts yet.
assert.equal(accountScopedToken({ capitalIds: [], hasPrimary: true, claims: [] }), false);
});
test('a refused account token falls back to the login\'s and stays refused', () => {
const now = iso('2026-10-27T01:00:00Z');
const wallet = '0x00000000000000000000000000000000000000aa';
const login = 'eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJsb2dpbiJ9.loginsignature-loginsignature-loginsig';
const account = 'eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJhY2N0In0.accountsignature-accountsignature-acct';
assert.equal(putVestSession({ accessToken: login, expiresAt: now + 600_000, primaryAddr: wallet, userId: 'u' }, now), null);
// The account's token lives longer, so it replaces the login's...
assert.equal(putVestSession({ accessToken: account, expiresAt: now + 900_000, primaryAddr: wallet, userId: 'u' }, now), null);
assert.equal(vestSessionFor(wallet, now).accessToken, account);
// ...until the client finds it sees one account: back to the login's.
refuseVestSession(wallet, account, now);
assert.equal(vestSessionFor(wallet, now).accessToken, login);
// Relayed again (the old extension posts it every minute): refused.
assert.match(putVestSession({ accessToken: account, expiresAt: now + 900_000, primaryAddr: wallet, userId: 'u' }, now), /one Vest account's token/);
assert.equal(vestSessionFor(wallet, now).accessToken, login);
// The login's next renewal is taken as usual.
const renewed = 'eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJsb2dpbiJ9.renewedsignature-renewedsignature-rene';
assert.equal(putVestSession({ accessToken: renewed, expiresAt: now + 1_200_000, primaryAddr: wallet, userId: 'u' }, now), null);
assert.equal(vestSessionFor(wallet, now).accessToken, renewed);
// Refusing a token that isn't held changes nothing held.
refuseVestSession(wallet, 'x.y.zzz', now);
assert.equal(vestSessionFor(wallet, now).accessToken, renewed);
// No good token to fall back to: none at all.
const wallet2 = '0x00000000000000000000000000000000000000bb';
assert.equal(putVestSession({ accessToken: account.replace('acct', 'accu'), expiresAt: now + 900_000, primaryAddr: wallet2, userId: 'u' }, now), null);
refuseVestSession(wallet2, account.replace('acct', 'accu'), now);
assert.equal(vestSessionFor(wallet2, now), null);
});
+65 -10
View File
@@ -67,11 +67,13 @@ db.exec(`
);
CREATE TABLE IF NOT EXISTS account_assignments (
account_id INTEGER PRIMARY KEY,
firm_id TEXT NOT NULL,
account_type_id TEXT,
manual INTEGER NOT NULL DEFAULT 0,
resolved_at TEXT NOT NULL
account_id INTEGER PRIMARY KEY,
firm_id TEXT NOT NULL,
account_type_id TEXT,
manual INTEGER NOT NULL DEFAULT 0,
resolved_at TEXT NOT NULL,
rules_date TEXT,
rules_date_manual INTEGER NOT NULL DEFAULT 0
);
`);
try {
@@ -79,6 +81,36 @@ try {
} catch {
// Column already exists
}
// The rules-date migration, as lib/db.ts runs it — including its backfill:
// adding the column here means lib/db.ts's own ALTER never runs on this file,
// and rows the restore doesn't overwrite must not stay undated. Dated as
// lib/db.ts undatedRowsDate dates them: now, never before the catalog's last
// change (a clock running behind must not date a row into an old rule set).
function undatedRowsDate() {
const row = db.prepare("SELECT value FROM settings WHERE key = 'catalog_updated_at'").get();
const floor = row && typeof row.value === 'string' ? Date.parse(row.value) : NaN;
return new Date(Math.max(Date.now(), Number.isFinite(floor) ? floor : 0)).toISOString();
}
// lib/kept-rules.ts sortableIso: the only rules dates stored are plain
// four-digit-year ISO strings from 2000 on, so string order is time order.
function sortableIso(ms) {
if (!Number.isFinite(ms) || ms < Date.UTC(2000, 0, 1) || Math.abs(ms) > 8.64e15) return null;
const iso = new Date(ms).toISOString();
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(iso) ? iso : null;
}
try {
db.transaction(() => {
db.exec('ALTER TABLE account_assignments ADD COLUMN rules_date TEXT');
db.prepare('UPDATE account_assignments SET rules_date = ? WHERE rules_date IS NULL').run(undatedRowsDate());
}).immediate();
} catch {
// Column already exists
}
try {
db.exec('ALTER TABLE account_assignments ADD COLUMN rules_date_manual INTEGER NOT NULL DEFAULT 0');
} catch {
// Column already exists
}
const upsert = db.prepare(
'INSERT INTO settings (key, value) VALUES (?, ?) ' +
@@ -134,14 +166,29 @@ async function restore() {
'INSERT INTO instruments (symbol, enabled) VALUES (?, ?) ' +
'ON CONFLICT(symbol) DO UPDATE SET enabled = excluded.enabled'
);
const setAssign = db.prepare(`
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at)
VALUES (?, ?, ?, ?, ?)
// lib/db.ts restoreAssignment, by hand: the backup's rules date (and how
// it was set) wins; a backup from an older build has none, so an existing
// row keeps its own and a new one is dated at the restore.
const setAssignDated = db.prepare(`
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at, rules_date, rules_date_manual)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(account_id) DO UPDATE SET
firm_id = excluded.firm_id,
account_type_id = excluded.account_type_id,
manual = excluded.manual,
resolved_at = excluded.resolved_at
resolved_at = excluded.resolved_at,
rules_date = excluded.rules_date,
rules_date_manual = excluded.rules_date_manual
`);
const setAssignUndated = db.prepare(`
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at, rules_date)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(account_id) DO UPDATE SET
firm_id = excluded.firm_id,
account_type_id = excluded.account_type_id,
manual = excluded.manual,
resolved_at = excluded.resolved_at,
rules_date = COALESCE(account_assignments.rules_date, excluded.rules_date)
`);
db.transaction(() => {
@@ -162,7 +209,15 @@ async function restore() {
setInstr.run(i.symbol, i.enabled ? 1 : 0);
}
for (const a of payload.assignments ?? []) {
setAssign.run(a.account_id, a.firm_id, a.account_type_id ?? null, a.manual ? 1 : 0, a.resolved_at ?? new Date().toISOString());
const now = new Date().toISOString();
const resolvedAt = a.resolved_at ?? now;
const rulesDate = typeof a.rules_date === 'string' ? sortableIso(Date.parse(a.rules_date)) : null;
if (rulesDate) {
setAssignDated.run(a.account_id, a.firm_id, a.account_type_id ?? null, a.manual ? 1 : 0, resolvedAt,
rulesDate, a.rules_date_manual === 1 ? 1 : 0);
} else {
setAssignUndated.run(a.account_id, a.firm_id, a.account_type_id ?? null, a.manual ? 1 : 0, resolvedAt, now);
}
}
})();
+276 -22
View File
@@ -33,9 +33,15 @@
* the build fails, and a failure is retried on a backoff (scripts/.update-failed)
* rather than every minute.
*
* A machine whose checker stopped firing can't notice by itself, so the app
* asks for a heal (lib/updater-watchdog.ts) once the last check goes stale, and
* the master's "Update now" asks for a heal plus an immediate check.
*
* node scripts/update-check.mjs # check; start a worker if needed
* node scripts/update-check.mjs --apply # update now, here, in the foreground
* node scripts/update-check.mjs --dry-run # report only, change nothing
* node scripts/update-check.mjs --heal # re-register a checker that stopped, then check
* node scripts/update-check.mjs --now # check without waiting out a failure's backoff
*/
import { spawn, spawnSync } from 'node:child_process';
import {
@@ -65,6 +71,14 @@ const BRANCH = process.env.AUTOFIRMER_BRANCH ?? 'master';
const INSTANCES = loadInstances();
const APPLY = process.argv.includes('--apply');
const DRY_RUN = process.argv.includes('--dry-run');
const HEAL = process.argv.includes('--heal');
const NOW = process.argv.includes('--now');
const OPT_OUT_FILE = path.join(ROOT, 'scripts', '.no-auto-update');
// The PM2 app install-autostart.* registers, and its schedule.
const CHECKER = 'autofirmer-update';
const DEFAULT_CRON = '*/1 * * * *';
// The master's UPDATER_STALE_MS: past this its dashboard says "updater not running".
const STALE_CHECK_MS = 10 * 60 * 1000;
const STALE_LOCK_MS = 2 * 60 * 60 * 1000;
const LEGACY_STALE_LOCK_MS = 10 * 60 * 1000;
const LOCK_SETTLE_MS = 1000;
@@ -76,7 +90,10 @@ const REPEAT_RETRY_MS = 60 * 60 * 1000;
// it. npm and next build are left unbounded: on Windows a timeout would kill
// only the cmd.exe in front of them, and an orphaned build still writing into
// .next is worse than a slow one.
const FETCH_TIMEOUT_MS = 2 * 60 * 1000;
// The fetch also stays under the cron's minute: a check still running at the next
// fire has to be killed by PM2, and that kill is where PM2 loses the cron
// (see heal).
const FETCH_TIMEOUT_MS = 45 * 1000;
const PM2_TIMEOUT_MS = 3 * 60 * 1000;
const LOG_MAX_BYTES = 256 * 1024;
const LOG_KEEP_LINES = 500;
@@ -120,6 +137,13 @@ function recordCheck(status) {
} catch { /* never let bookkeeping break an update */ }
}
/**
* An argument for run() as cmd.exe will see it. On Windows run() goes through
* the shell, which joins the arguments with plain spaces, so a cron string or
* a path with a space in it would arrive as several.
*/
const shellArg = (arg) => (process.platform === 'win32' && /[\s"]/.test(arg) ? `"${arg.replace(/"/g, '""')}"` : arg);
function run(cmd, args, { capture = false, timeout } = {}) {
return spawnSync(cmd, args, {
cwd: ROOT,
@@ -370,7 +394,7 @@ function releaseLock() {
}
// ── failure record ──────────────────────────────────────────────────────────
// scripts/.update-failed = { target, step, at, count, restart, clicker }.
// scripts/.update-failed = { target, step, at, count, restart, clicker, detail? }.
//
// Written when an attempt STARTS and deleted when it succeeds, so a worker
// that dies without reporting (killed, rebooted) still counts as a failed
@@ -393,6 +417,54 @@ function writeFailure(f) {
function clearFailure() {
try { rmSync(FAILED_FILE, { force: true }); } catch { /* bookkeeping */ }
}
/** Size of update-worker.log right now, so a step's own output can be told apart. */
function workerLogSize() {
try { return statSync(WORKER_LOG_FILE).size; } catch { return 0; }
}
/** What update-worker.log gained since `from` ('' when a by-hand --apply
* wrote to the terminal instead). */
function workerLogSince(from) {
try {
const buf = readFileSync(WORKER_LOG_FILE);
return buf.length > from ? buf.subarray(from).toString('utf8') : '';
} catch {
return '';
}
}
/**
* Why a step failed, from its output since `from`: the file this user can't
* replace when a permission is what stopped it, else one line of the output.
*/
function explainFailure(from) {
const text = workerLogSince(from);
return (PERMISSION_DENIED.test(text) && ownershipProblem()) || failureReason(text);
}
/**
* One line on why a step failed, from its output, or null.
*
* The full npm/next output stays on the machine. Without a reason the master
* shows only "update failed at build", and a machine failing for a cause of
* its own (a Node upgrade leaving better-sqlite3 on the old ABI, a file
* Defender holds, a full disk) can't be told from a broken push. The line
* rides on .last-check, which every app build and the master already carry.
*/
function failureReason(text) {
// eslint-disable-next-line no-control-regex
const lines = text.replace(/\x1b\[[0-9;]*m/g, '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
// npm's own epilogue says only that the script failed, and where its log
// is; and this script's own log lines (they go to the console too) aren't
// the step's output.
const boilerplate = /^npm (ERR!|error)( (code|path|command|errno|syscall|signal|workspace|location|A complete log|Lifecycle)\b|\s*$)|^> |^\d{4}-\d\d-\d\dT\S+ {2}/;
const first = (re) => lines.find((l) => re.test(l) && !boilerplate.test(l));
const line = first(/NODE_MODULE_VERSION|ERR_DLOPEN_FAILED|\bE(PERM|BUSY|ACCES|NOSPC)\b|heap out of memory|Type error:/)
?? first(/\b[Ee]rror\b/)
?? lines.filter((l) => !boilerplate.test(l)).pop();
return line ? line.replace(/\s+/g, ' ').slice(0, 100) : null;
}
/** 15 min after the first failure, an hour after repeated ones: long enough
* that a deterministic failure (a broken push) doesn't rebuild every minute
* on a trading PC, short enough that a transient one (network, EPERM) heals
@@ -434,19 +506,28 @@ function headMovedAt() {
}
}
/** PM2's process list, or null when PM2 can't be asked. */
function pm2List() {
const r = run('pm2', ['jlist'], { capture: true, timeout: 30_000 });
if (r.status !== 0) return null;
// jlist prints the process list as one JSON line, possibly after "[PM2] ..." chatter.
const line = (r.stdout ?? '').split('\n').find((l) => /^\s*\[\s*[{\]]/.test(l));
try {
const procs = JSON.parse(line);
return Array.isArray(procs) ? procs : null;
} catch {
return null;
}
}
/**
* Names of this checkout's PM2 instances that started before `ms`, i.e. still
* running code from before the build on disk. Empty when PM2 can't be asked:
* this only sharpens a heuristic and must never block an update.
*/
function instancesStartedBefore(ms) {
const r = run('pm2', ['jlist'], { capture: true, timeout: 30_000 });
if (r.status !== 0) return [];
// jlist prints the process list as one JSON line, possibly after "[PM2] ..." chatter.
const line = (r.stdout ?? '').split('\n').find((l) => /^\s*\[\s*[{\]]/.test(l));
let procs;
try { procs = JSON.parse(line); } catch { return []; }
if (!Array.isArray(procs)) return [];
const procs = pm2List();
if (!procs) return [];
const names = new Set(INSTANCES.map((i) => i.name));
return procs
.filter((p) => names.has(p?.name) && p.pm2_env?.status === 'online' && Number(p.pm2_env.pm_uptime) < ms)
@@ -550,6 +631,65 @@ function workFor(plan) {
};
}
// ── stuck states ────────────────────────────────────────────────────────────
// Two states that fail every update until a person gets onto the machine.
/** What a step prints when it may not touch a file. */
const PERMISSION_DENIED = /permission denied|insufficient permission|\bEACCES\b|\bEPERM\b/i;
/**
* A file in the checkout this user doesn't own, as the fix to run, or null.
*
* One `sudo git pull` or `sudo npm run build` leaves files this user cannot
* replace, and from then on every update fails: the build can't clear .next,
* and a fetch can't write into .git (Ziad's Pi, 2026-10-02: ~1,800 of them).
* The failure itself only says EACCES somewhere in npm's output, so name the
* file and the one command that fixes it — the master's updater chip shows
* this line. Asked only once a step has failed on a permission: a stray
* root-owned file that blocks nothing must not stop an update. POSIX only.
*/
function ownershipProblem() {
if (typeof process.getuid !== 'function') return null;
const r = run('find', [ROOT, '!', '-user', String(process.getuid()), '-print', '-quit'], { capture: true, timeout: 20_000 });
const hit = (r.stdout ?? '').split('\n')[0].trim();
if (!hit) return null;
let owner = 'another user';
try {
const uid = lstatSync(hit).uid;
owner = uid === 0 ? 'root' : `uid ${uid}`;
} catch { /* gone since: the generic name will do */ }
return `${path.relative(ROOT, hit) || '.'} is owned by ${owner}: run sudo chown -R ${os.userInfo().username} ${ROOT}`;
}
/** inspect(), with a fetch refused by a .git this user can't write named as such. */
function inspectNamingOwnership(opts) {
try {
return inspect(opts);
} catch (err) {
const why = PERMISSION_DENIED.test(err.message) ? ownershipProblem() : null;
throw why ? new Error(why) : err;
}
}
/**
* npm install rewrites package-lock.json on some machines (another npm
* version, platform-specific optional dependencies), and that edit then
* refuses every fast-forward that touches the file. It is generated, so when
* it is the only tracked edit, put it back. Any other edit is a person's and
* stays, showing as "edited here" on the master.
*/
function restoreRewrittenLockfile() {
let changed;
try {
changed = git('diff', '--name-only', 'HEAD').split('\n').filter(Boolean);
} catch {
return;
}
if (changed.length !== 1 || changed[0] !== 'package-lock.json') return;
git('checkout', 'HEAD', '--', 'package-lock.json');
log('package-lock.json had been rewritten here (npm) — put it back before the pull');
}
// ── build ───────────────────────────────────────────────────────────────────
/**
@@ -630,6 +770,111 @@ function removePrevBuild() {
}
}
// ── heal ────────────────────────────────────────────────────────────────────
// The checker runs only while PM2 fires it, and PM2 loses it easily: a
// `pm2 stop`, a `pm2 delete all`, a reboot after a list change nobody saved.
/** How long ago the last check ran, in ms; null = never on this checkout. */
function lastCheckAge() {
try {
const at = Date.parse(readFileSync(LAST_CHECK_FILE, 'utf8').split(/\s/, 1)[0]);
return Date.now() - (Number.isFinite(at) ? at : statSync(LAST_CHECK_FILE).mtimeMs);
} catch {
return null;
}
}
const fmtAge = (ms) => (ms >= 90 * 60 * 1000 ? `${Math.round(ms / 3_600_000)} h` : `${Math.round(ms / 60_000)} min`);
/**
* The fields PM2 adds to an app's environment.
*
* PM2 hands every app the scalar fields of its own record as environment
* (name, pm_id, autorestart, …), and `pm2 start` captures the caller's
* environment for the new app, then copies it over the new app's record. A
* checker registered by a heal, which the dashboard app starts, would take
* that app's name and id, and its autorestart would override
* --no-autorestart. These are the fields PM2 put on real records, plus the
* ones only some records carry. PORT and AUTOFIRMER_DB are the instance's
* own, and the checker serves every instance.
*/
const INHERITED_FIELDS = new Set([
'NODE_APP_INSTANCE', 'automation', 'autorestart', 'autostart', 'created_at', 'cron_restart',
'exec_interpreter', 'exec_mode', 'exit_code', 'filter_env', 'instance_var', 'instances',
'kill_retry_time', 'km_link', 'merge_logs', 'name', 'namespace', 'node_version', 'pmx',
'prev_restart_delay', 'restart_time', 'status', 'stop_exit_codes', 'treekill', 'unique_id',
'unstable_restarts', 'username', 'version', 'versioning', 'vizion', 'vizion_running', 'watch',
'windowsHide', 'PORT', 'AUTOFIRMER_DB',
]);
/** Drop what PM2 (and the instance) put in this process's environment, so
* nothing started from here inherits it. */
function scrubInheritedEnv() {
// PM2 also files the app's own config under its name.
if (process.env.name) delete process.env[process.env.name];
for (const key of Object.keys(process.env)) {
if (INHERITED_FIELDS.has(key) || key.startsWith('pm_') || key.startsWith('axm_')) delete process.env[key];
}
}
/**
* Get PM2 firing this script again once its checks have stopped. Returns
* false when this checkout is opted out of updates altogether.
*
* What the fleet shows (2026-10, six Windows machines): the cron dies inside
* a live PM2 daemon, nobody involved. Every fire runs restartProcessId,
* whose first step drops the cron; only a restart that gets as far as
* launching the app registers it again. croner sometimes fires twice in
* one second, the second restart has to stop a check that is still
* launching, and a refused or timed-out stop leaves the checker `stopped`
* or `errored` with no cron at all.
*
* So a checker PM2 still lists is restarted, which registers its cron again
* with the schedule and environment it already had, and nothing is deleted:
* `pm2 delete` kills the pid an `errored` entry kept, and Windows hands pids
* out again quickly, so that could be any program by now. If that pid is
* alive, PM2 is left alone and this run checks instead. Only a checker PM2
* has lost entirely is registered again, the way install-autostart.* does.
*/
function heal() {
if (existsSync(OPT_OUT_FILE)) {
console.log('scripts/.no-auto-update is here — this checkout is not auto-updated');
return false;
}
// Before anything is started from here, the worker below included: the
// app that asked for this heal is a PM2 app, and may be instance 2.
scrubInheritedEnv();
const age = lastCheckAge();
if (age !== null && age <= STALE_CHECK_MS) return true; // checking on schedule
const why = age === null ? 'the update checker had never run here' : `the update checker had stopped (last check ${fmtAge(age)} ago)`;
const pm2 = (...args) => run('pm2', args.map(shellArg), { capture: true, timeout: PM2_TIMEOUT_MS });
const lastLine = (r) => (((r.stderr || r.stdout) ?? '').trim().split('\n').pop() || `exit ${r.status}`);
const listed = pm2List()?.find((p) => p?.name === CHECKER);
const pid = Number(listed?.pid);
if (listed && pid > 0 && pidAlive(pid)) {
log(`${why}, but PM2 still has it on pid ${pid}, which is alive — left PM2 alone (a restart would kill that pid); checking from here`);
return true;
}
let how;
if (listed && pm2('restart', CHECKER).status === 0) {
how = `restarted it in PM2 (${listed.pm2_env?.status ?? 'unknown'}), which registers its cron again`;
} else {
const cron = listed?.pm2_env?.cron_restart || DEFAULT_CRON;
if (listed) pm2('delete', CHECKER); // its restart failed, and its pid is dead
const r = pm2('start', SELF, '--name', CHECKER, '--no-autorestart', '--cron-restart', cron);
if (r.status !== 0) {
log(`${why} — ERROR: could not register it with PM2: ${lastLine(r)}`);
return true; // the check below can still update this machine
}
how = `registered it with PM2 again (${cron})`;
}
const saved = pm2('save').status === 0;
log(`${why} — ${how}${saved ? '' : ', but pm2 save failed: a reboot would lose it'}`);
return true;
}
// ── check ───────────────────────────────────────────────────────────────────
/**
@@ -688,7 +933,7 @@ function check() {
let outcome = null;
try {
const plan = inspect({ adopt: !DRY_RUN });
const plan = inspectNamingOwnership({ adopt: !DRY_RUN });
if (!plan.needsPull && !plan.needsBuild && !plan.needsRestart) {
outcome = `up to date at ${short(plan.target)}`; // silent in the log by design
if (DRY_RUN) console.log(outcome);
@@ -699,9 +944,11 @@ function check() {
return 0;
}
// Silent in update.log too: the failure itself was logged once, and a
// line a minute until the retry would bury it.
if (plan.pending && Date.now() < retryAt(plan.pending)) {
outcome = `waiting to retry ${short(plan.target)}: ${plan.pending.step} (attempt ${plan.pending.count}), next try after ${new Date(retryAt(plan.pending)).toISOString()}`;
// line a minute until the retry would bury it. --now (the master's
// "Update now") is a person asking for the retry.
if (plan.pending && !NOW && Date.now() < retryAt(plan.pending)) {
outcome = `waiting to retry ${short(plan.target)}: ${plan.pending.step} (attempt ${plan.pending.count}), next try after ${new Date(retryAt(plan.pending)).toISOString()}`
+ (plan.pending.detail ? ` — ${plan.pending.detail}` : '');
return 0;
}
const pid = startWorker();
@@ -748,15 +995,15 @@ async function apply() {
let outcome = 'interrupted';
let attempt = null;
const abort = (step, why) => {
log(`ABORTED: ${why}`);
outcome = why;
if (attempt) writeFailure({ ...attempt, step, at: Date.now() });
const abort = (step, why, detail = null) => {
log(`ABORTED: ${why}${detail ? ` (${detail})` : ''}`);
outcome = detail ? `${why}: ${detail}` : why;
if (attempt) writeFailure({ ...attempt, step, at: Date.now(), ...(detail ? { detail } : {}) });
return 1;
};
try {
const plan = inspect({ adopt: true });
const plan = inspectNamingOwnership({ adopt: true });
if (!plan.needsPull && !plan.needsBuild && !plan.needsRestart) {
outcome = `up to date at ${short(plan.target)}`;
clearFailure();
@@ -780,14 +1027,19 @@ async function apply() {
else if (plan.needsBuild) log('last build unknown — treating every file as changed');
// The exact commit the check saw, not whatever `git pull` would fetch now.
if (plan.needsPull && run('git', ['merge', '--ff-only', target]).status !== 0) {
return abort('pull', `git pull --ff-only to ${short(target)} failed — see scripts/update-worker.log`);
if (plan.needsPull) {
restoreRewrittenLockfile();
const from = workerLogSize();
if (run('git', ['merge', '--ff-only', target]).status !== 0) {
return abort('pull', `git pull --ff-only to ${short(target)} failed — see scripts/update-worker.log`, explainFailure(from));
}
}
if (plan.needsBuild) {
if (work.npmInstall) {
log(`${because('package-lock.json')} — npm install`);
if (run('npm', ['install']).status !== 0) return abort('npm install', 'npm install failed — nothing was built or restarted');
const from = workerLogSize();
if (run('npm', ['install']).status !== 0) return abort('npm install', 'npm install failed — nothing was built or restarted', explainFailure(from));
}
if (work.pythonDeps) {
log(`${because('clicker/requirements.txt')} — refreshing python deps`);
@@ -796,7 +1048,8 @@ async function apply() {
// Build BEFORE restarting. A failed build leaves the running process
// untouched, which is the whole point of doing it in this order.
if (!build()) return abort('build', 'build failed — not deployed, nothing was restarted');
const from = workerLogSize();
if (!build()) return abort('build', 'build failed — not deployed, nothing was restarted', explainFailure(from));
// .built-commit says what .next holds, so it changes the moment .next
// does. From here a failed restart must not rebuild, only restart,
@@ -898,6 +1151,7 @@ async function logHealth(instance, base) {
async function main() {
mkdirSync(path.join(ROOT, 'scripts'), { recursive: true });
trimLog();
if (HEAL && !DRY_RUN && !heal()) return 0;
return APPLY && !DRY_RUN ? apply() : check();
}
+69 -2
View File
@@ -1,4 +1,10 @@
import type { FirmPlatform } from './lib/master-protocol';
import type { KeptRulesWindow } from './lib/kept-rules';
/** When one of an account type's old rule sets applies: accounts whose rules
* date is inside [openedAt, closedAt) keep those rules (openedAt null = no
* lower bound). `id` is the kept set's own, never an account type id. */
export type { KeptRulesWindow };
export interface AccountConfig {
/** Master's account type id — what a manual assignment pins to. */
@@ -32,10 +38,21 @@ 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;
}
/** An account type as the firm listing (/api/firms) carries it: the current
* rules, plus the windows of the old rule sets it keeps, so the dashboard can
* preview which rules a rules date lands on. */
export interface FirmAccountType extends AccountConfig {
/** Sorted by closedAt; empty when every account is on the current rules. */
kept: KeptRulesWindow[];
}
export interface FirmConfig {
/** Master's firm id (uuid). */
id: string;
@@ -57,7 +74,7 @@ export interface FirmConfig {
instrumentsSource: 'master' | 'legacy';
/** The firm's account types, straight from the catalog. Doubles as the
* option list for a manual account-type override. */
accounts: AccountConfig[];
accounts: FirmAccountType[];
catalogUpdatedAt: string;
}
@@ -67,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) */
@@ -82,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;
@@ -122,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. */
@@ -138,6 +164,20 @@ export interface AccountState {
inferredAccountSize: number | null;
/** True when more than one account type matched and updatedAt decided it. */
accountTypeAmbiguous: boolean;
/** ISO. Decides which of the type's rule sets the account trades on: one
* dated before a firm's rule change keeps the old rules. Defaults to when
* the account started (broker's first deposit / registration), else when
* this machine first saw it. null = no assignment row yet. */
rulesDate: string | null;
/** True when the rules date was set by hand — broker evidence never moves it. */
rulesDateManual: boolean;
/** The old rule set the account trades on; null = the type's current
* rules. accountType above already carries these rules. */
keptRules: (KeptRulesWindow & {
/** Rule fields where the kept set differs from the type's current
* rules — raw values; the dashboard formats them. */
differs: { field: string; kept: unknown; now: unknown }[];
}) | null;
}
export interface FirmState {
@@ -148,3 +188,30 @@ export interface FirmState {
connected: boolean;
accounts: AccountState[];
}
/** One recorded position (GET /api/trades) — Vest's position history plus
* what this instance did itself. */
export interface TradeView {
positionId: string;
accountId: number;
accountName: string;
symbol: string;
side: 'long' | 'short';
quantity: number;
openPrice: number | null;
closePrice: number | null;
/** Realized P&L before fees and funding. */
pnl: number | null;
fee: number | null;
funding: number | null;
/** pnl − fee + funding; null while open (or unknown). */
net: number | null;
openedAt: number;
closedAt: number | null;
/** null: opened before this instance began marking its own trades. */
openedBy: 'bot' | 'outside' | null;
/** null while open; else 'bot', 'outside' (closed by hand or another
* tool), or the platform's own reason (stop_loss, take_profit,
* liquidation, …; user_requested from before marking began). */
closedBy: string | null;
}