Compare commits

..
155 Commits
Author SHA1 Message Date
Brandon LiandClaude Opus 5.5 10da0ca83e Vest: a crypto leg's stop holds back the engine's default slippage, not NDX's
Crypto (exchangeInfo asset "crypto": BTC, ETH, SOL, HYPE) trades on weekends
when NDX, SPX and GC are shut, but its stop slippage on Vest is unmeasured, and
its book is a fraction of NDX's: about $10M an hour on a weekend against $600M,
with a batch's stops firing together. Its stops now leave 0.1% of the
position unspent (the engine's default) instead of the 0.02% measured on NDX
stop-outs; every other symbol keeps 0.02%.

The share rides on the picked contract and is used wherever a Vest leg's stop
is sized: the batch's plan, the leg's own stop, the dashboard's next stop, and
the 7:55 PM ET reset guard, which now sums each open position's own share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 17:26:53 -05:00
Brandon LiandClaude Opus 5.5 618976bf5f Dashboard: accounts list in name order, counting the numbers in names
The accounts table kept the broker's order until the Account header was
clicked, and that sort compared names as plain text, so XXX10 came before
XXX2. It now opens sorted by name, comparing numbers as numbers (XXX1,
XXX2 … XXX10; Vest's padded -02 between -1 and -10), and the other
columns break ties by name so equal rows hold still between syncs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:55:36 -05:00
Brandon LiandClaude Opus 5.5 8c863bdc1d Vest: an account's name ends in the number the Vest app shows
Vest's app shows attempt_index + 1 ("Account 73"), padded below 10
("Account 01"); the name ended in attempt_index, one lower, so the
dashboard's V50K-E-D3-T10-73 was Vest's Account 74. It now ends in the
app's number, and 0 for an account Vest gives no attempt index (its
"Legacy Account"). Account types match on the prefix, and everything saved
locally is keyed by account id, so nothing else moves.

Reports mark a Vest firm vestAppNumbers. The master (634d52f) renames what
it saved under the old numbers on the first such report from a person, and
from then on leaves out a Vest firm reported under the old ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 10:07:21 -05:00
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
Brandon LiandClaude Opus 5.5 ed62788480 Drain and extra-day legs always trade MNQ
They traded the micro of whatever the firm listed: MNQ with NQ, but MGC
for a firm listing only GC and CL. Now every futures firm's tiny legs go on
MNQ, the one micro the bot has always drained on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 18:54:41 -05:00
Brandon LiandClaude Opus 5.5 5696f2b036 Report each firm's trading mix: open positions and today's entries by instrument
The master knew how many accounts were in a trade, not in what. Each firm's
report now carries its open positions by instrument (accounts long and
short, contracts held) and today's entries the same way.

A position's contract is named by its instrument: DXtrade's carries the
symbol; a futures one is looked up among the contracts this machine has
entered (recorded at the fill and kept), then the resolver's cache and
TopstepX's own. Entries are counted as they fill, per trade date, in a
setting, so a restart doesn't lose the day.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 18:50:56 -05:00
Brandon LiandClaude Opus 5.5 ab3132182a Report each tick's outcome and the updater's state to the master
The master saw a scheduler's last run time and nothing of what it did. Each
tick now records, per lane, its outcome, a line on what happened (what
fired, or the spread, contract or hold cap that stopped it) and why the
lane's accounts sat it out, counted by reason with a few names each. It
rides the scheduler status as lastTicks.

Each report also carries the auto-updater's state from the files it keeps
and from git: its last check, a failed update waiting to retry, how far the
checkout is behind origin, and tracked files edited by hand, which stop the
fast-forward pull.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 18:31:21 -05:00
Brandon LiandClaude Opus 5.5 db0ad99a76 CME evening session: first entry at 5:45 PM CT, not 5:05
Tradovate moves each account's cash record to the new trade date on its own
schedule, an idle one perhaps not until it trades, and the bot holds any
account still on the old day until 5:45. Opening at 5:05 fired the ready
accounts alone (TopstepX, the early rollers) while the rest, released at
5:45, waited an hour or more for them to go flat. A 5:45 open puts every
account in the first batch and skips the open's widest spreads. The day's
0–180 minute delay still comes on top: first entries land 5:45–8:45 PM.

Every-day firms keep their own 5:05 PM end of the roll guard; the equity
hours open is unchanged. The draw's session progress now starts at 5:45.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 17:45:08 -05:00
Brandon LiandClaude Opus 5.5 ff97a59b49 Session open: a random 0–180 minute delay, kept across restarts
The day's delay was 1–20 minutes. It is now 0–180, so the evening open
lands anywhere from 5:05 to 8:05 PM CT. The day's roll is saved as a
setting: with a three-hour window, a restart during the wait (an update, a
crash) would otherwise roll again and could start the session early.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 17:30:01 -05:00
Brandon LiandClaude Opus 5.5 7866d67676 TopstepX: look back 90 days for trade history, not 27
A first funds check can only explain an account's balance with the trades
the history window returns. P&L older than that is booked as a deposit,
which the consistency math leaves out: an Express account whose only
earlier trade was 64 days back read $5,071 short of its 40% rule instead
of $1,542, and aimed tonight's leg at its best day. Tradovate is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:56:18 -05:00
Brandon LiandClaude Opus 5.5 db1d12327a Live futures spreads off one client, and the master's max entry spread
One quote feed per machine: a TopstepX client's market hub, else a
Tradovate client's market-data socket, subscribed to every listed root's
front month and the drain micro. A supervisor keeps it on a working client,
dropping one that fails or goes quiet while the market trades.

The Auto draw weighs each instrument by its live spread (the typical tick
when no fresh quote is at hand). No futures entry pays more than the
master's max entry spread ($15 until set) to cross the book: the draw
leaves such an instrument out, an explicit symbol sits the tick out, and
fireLeg and the extra-day trade check it last before sending. The Settings
page and the master's stats show the live spreads and the feed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:06:44 -05:00
Brandon LiandClaude Opus 5.5 1782c16145 Sizing: the broker's largest and smallest order bind the leg's size
fireLeg clamped the order to the instrument's max and min size, but the
size the pick ranked by (and the Firms page shows) didn't: an instrument
whose largest order falls short of the ideal notional ranked on a hold it
can't reach. planLegSize now holds the notional to the margin cap and the
broker's largest order, then its smallest, so the ranking, the stats and
the trade agree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:59:27 -05:00
Brandon LiandClaude Opus 5.5 7f55e49755 DXtrade: date transactions by the firm's day, not the CT trade date
The firm rolls its day at 22:00 UTC; our trade date at 5 PM CT. In summer
they coincide, in winter the firm's rolls an hour earlier. New entries are
already kept out of that hour, but a take-profit or stop that fills in it
was booked to the day before. firmDayOf labels the firm's day the way
currentTradeDateCT labels ours, and the sync's transfer window now starts
at the firm day behind today's trade date.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:59:25 -05:00
Brandon LiandClaude Opus 5.5 934620e8e9 Manual leg: take the lane's lock before reading the account
It checked the account, then awaited the instrument draw and the contract
lookup, and only then locked the lane. A batch that fired in that gap left
it trading on numbers from before: a second leg, or on DXtrade the other
side of the position the batch had just opened. Now it locks first, as the
batch and copy trade do, and checks the lease still holds before firing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:59:23 -05:00
Brandon LiandClaude Opus 5.5 5a4e85cc18 Last stand: measured against the full daily loss, not the stop after its reserve
The rule is: when hitting the day's loss limit would leave the account 15%
of that loss or less above its max-drawdown floor, ride without a
daily-loss stop. It had compared against the stop-out after the 10%
reserve, which made it stricter — $3,294.70 to the floor with $3,000 of
daily room (9.8% left) wasn't a last stand, and now is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:43:07 -05:00
Brandon LiandClaude Opus 5.5 ff9b107452 Instruments: no fee lookup — commissions barely differ between instruments
Entry cost is one tick of spread; fees can be added later if they matter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 19:51:56 -05:00
Brandon LiandClaude Opus 5.5 ee4550512c Settings: the instruments table scrolls inside its card on narrow screens
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 19:49:20 -05:00
Brandon LiandClaude Opus 5.5 02544fbdf5 Instruments: each firm's list from the master, and a session-aware futures draw
- lib/instrument-specs.ts (pure, tested): the CME specs — point value,
  tick size, one-tick spread, Yahoo ticker — replacing POINT_VALUES; the
  draw's maths; the 12-hour hold cap; the micro for drain and extra-day
  legs; reading a firm's list (the master's, or the old rules against an
  older master: this machine's enabled set less bans, the symbol map).
- lib/instruments.ts: the one module the trading paths ask. Measures every
  future's daily move from Yahoo's hourly candles once per trade date, and
  shows entry cost (a tick of spread plus Tradovate's fee parameters or
  TopstepX's last measured fill) without ever choosing by it.
- Futures Auto draws one root per batch from the batch firm's list: movement
  per dollar of spread early in the session with a 10% floor each, sliding
  to the fastest movers by the last entry; nothing expected to hold 12h or
  more; micros only if nothing else is listed. Every draw logs its odds.
- The batch is chosen before its instrument, and a group that can't trade
  anything yields to the next. Copy trade matches the held contract against
  the listed roots and won't copy an instrument it can't identify. Manual
  legs, manual orders and drain/extra-day legs follow the lists.
- DXtrade firms rank their own list whatever root was drawn.
- Capabilities, the dashboard's trade bar and contract resolves follow the
  lists; the trade bar and scheduler default to Auto. The master link
  reconnects when master_dashboard_url changes, and the catalog drops
  ?since after a switch so two masters' versions can't collide.
- Reports carry each futures firm's instrumentStats for the master's Firms
  page; GET /api/instruments shows them (?simulate=N runs the draw, no
  orders); the Settings page shows the lists read-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 19:47:32 -05:00
Brandon LiandClaude Opus 5.5 302e9b4911 Symbol stats: the win chance with no commission, spread or swap beside the real one
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 18:47:50 -05:00
Brandon LiandClaude Opus 5.5 957b9a50fe Sizing: no financing floor when a trade costs nothing (zero commission and spread)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 18:46:04 -05:00
Brandon LiandClaude Opus 5.5 169fe19fad Report each symbol's next-leg stats to the master; drop per-symbol commission
- lib/leg-estimate: an account's next leg sized on every surveyed
  candidate (planLegSize, ranked by rankForTarget) — notional, its
  multiple of the account and the broker's maximum, expected hold, cost
  and win chance. The dashboard's hold estimate and the reporter share it.
- The reporter sends it per DXtrade firm as symbolStats, for one live
  account from the largest prefix group with a leg left to trade.
- The symbol map's per-symbol commission is gone: the firm's applies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 18:41:01 -05:00
Brandon LiandClaude Opus 5.5 a2cd3a94c9 Legs: cheapest symbol that reaches the target hold, last stands, a reserve that grows with size
- A batch sizes its leg on every mapped candidate, each at the leverage
  the broker states for it, and trades the cheapest (spread, commissions,
  expected swap) that reaches the target hold — else the quickest. The
  dashboard ranks the same way from a half-hourly candidate survey.
- Last stand: when a daily-loss stop-out would leave the account within
  15% of that loss above its max-drawdown floor (the account type's static
  max loss, or the broker's liquidation floor), the leg carries no
  daily-loss stop and is sized against the firm's own breach.
- The daily-loss reserve is 10% of the limit or 0.1% of the position,
  whichever is more, since a stop's slippage grows with the position.
- The account type's max notional is gone: the broker's margin rates cap
  each symbol. planLegSize holds the one sizing rule fireLeg, the pick and
  the dashboard share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 18:26:12 -05:00
Brandon LiandClaude Opus 5.5 a2d9df5ba4 DXtrade: count closed-trade P&L, week-long volatility, broker leverage caps, swap in the take-profit
- Transfers: a close books its SETTLEMENT and COMMISSION under one
  transaction code, and keying by the code alone let the commission
  overwrite the P&L. Keys now carry the type, so the day-start balance,
  daily-loss room, realized P&L, daily history and books check all see
  closed-trade profit and loss.
- Volatility from a week of hourly closes (was 48 h), still measured once
  per trade date, so one quiet weekend can't triple a hold estimate.
- Each symbol's margin rate from the account's instrument details caps
  the position at available funds / rate (Tradeify: 5:1 crypto, 2:1
  stocks); the account type's max notional stays as an optional ceiling.
- Firms with overnight financing add the swap a winning leg is expected
  to pay (the winners' mean hold, (A²+2AB)/3σ²) to its take-profit, and
  the sizing and hold estimate count it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 18:11:42 -05:00
Brandon LiandClaude Opus 5.5 675b4c89f8 Manual leg: may join a lane on the same instrument and side, never the other
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 17:46:16 -05:00
Brandon LiandClaude Opus 5.5 b5ed423145 Manual one-leg trigger: POST /api/accounts/[id]/fire-leg
Fires one leg on one account through the scheduler's own fire path (sizing,
take-profit, daily-loss stop, exit bracket), overriding only the one-trade-
per-day rule for unsplit accounts. Flat, history, daily-loss room, trading
hours, a flat lane and the lane mutex still apply. The daily-loss check is
factored out so both paths share it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 17:43:51 -05:00
Brandon LiandClaude Opus 5.5 59af95f0b1 Smoke test: closing legs without quantity, the stop-first fallback, and a 22 s hold before closing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 17:42:02 -05:00
Brandon LiandClaude Opus 5.5 6876223fa4 DXtrade: volatility measured once a day, next-leg hold on the dashboard, closed accounts dead
Each instrument's volatility is measured at the first good sync (or pick) of
a trade date and held for the day, so a day's sizing and hold estimate stay
put. The dashboard shows each DXtrade account's next-leg hold ("hold ~Nh")
with its symbol, notional, volatility and win odds. Accounts the firm has
closed (TERMINATED, EXPIRED, NO_TRADING) read as dead instead of inactive,
and skip the books check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 17:40:42 -05:00
Brandon LiandClaude Opus 5.5 9fc1ed6908 DXtrade: Hyperliquid prices when /marketdata is denied; closing stop/limit without quantity
Tradeify247's API user may not read DXtrade market data (code 34), which
skipped every batch at the instrument pick. Quotes and hourly candles then
come from Hyperliquid's public API (BTC, ETH on the main dex; XYZ100, SP500
and tokenized stocks on trade[XYZ]) — for the pick and sizing only; orders,
fills and exit prices stay DXtrade's.

Closing STOP/LIMIT orders close the whole position by positionCode and must
not carry a quantity (code 33) — the exit bracket was refused, and the
daily-loss guard flattened both accounts. A close's fill price also falls
back to its executions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 17:35:23 -05:00
Brandon LiandClaude Opus 5.5 d01f03b293 DXtrade sizing: volatility only (12h default hold); fees and every-day only for DXtrade
The symbol map's fixed notional per contract is gone: every DXtrade leg is
sized from the instrument's recent volatility for the firm's target hold
(blank = 12 h), and a candidate without a volatility isn't picked. Percent
commission and every-day trading are ignored on non-DXtrade firms, which keep
measured per-contract fees and the CME calendar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 17:29:32 -05:00
Brandon LiandClaude Opus 5.5 28f465508d DXtrade platform for Tradeify247: percent fees, hard daily-loss stops, volatility sizing, every-day lanes
- DXtrade client (lib/dxtrade-class.ts): REST session login with lockout-safe
  backoff, 60s sync of accounts/metrics/positions/orders/transfers, history and
  fund transactions from cash transactions, day-start balance at the 22:00 UTC
  reset, CLOSE-by-positionCode exits (native OCO group, else a stop-first
  emulated pair), a cross-account hedge guard, stray-order reaper.
- Instrument selector: the firm's symbol map lists candidate symbols per CME
  root; the cheapest tradable one per unit of recent hourly volatility wins
  (spread + 2x commission [+ financing] / sigma).
- Fees: a firm's commission % of notional prices every fee off the fill; the
  take-profit is solved in closed form so the leg nets its target.
- Hard daily loss limit per account type: every leg carries a stop keeping the
  firm's day inside the limit less a 10% reserve and both fees (rounded in);
  a green day never widens it; no room or a refused bracket flattens.
- Notional sizing where the platform prices positions: target-hold volatility
  sizing or the symbol map's fixed notional, under a per-type notional cap.
- Every-day firms trade weekends/holidays; the scheduler runs lanes (futures,
  one per DXtrade firm) with their own hours, flat gate, mutex and batches.
- The catalog sync skips platforms this build doesn't know instead of treating
  them as Tradovate. One shared fireLeg replaces the duplicated batch/copy code.
- npm run check: fee/stop/sizing/hours/selector checks; scripts/dxtrade-smoke.mjs
  confirms the API against a real login before trading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 16:04:15 -05:00
Brandon LiandClaude Opus 5 49c4725e0c Reporter: name each account's withdrawal count
The master clears a completed cycle once the payout it was waiting for has
left the account, and the balance going down is only visible from here.
Omitted rather than zeroed while an account's fund history hasn't loaded:
undercounting leaves an entry standing, a wrong zero would clear one early.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-25 00:17:08 -05:00
Brandon LiandClaude Opus 5.5 809f337055 Unknown entries: judge only on a fresh good sync; all live orders count as working
Seventh review round (57835c0):

- An entry of unknown outcome is judged — filled after all, or never
  filled — only on a good sync that started after its settle window and
  with no order still working. A partial pass (position read failed or
  predated the fill, trades read after it) can no longer release it as
  "filled and flat" into a second entry.
- Tradovate counts every order not Filled/Canceled/Rejected/Expired/
  Completed as working (PendingCancel, Unknown included).
- TopstepX: an account whose order ended with its outcome unknown gets its
  open orders checked (Order/searchOpen) on each sync until none show, and
  counts as holding a working order until then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 22:13:47 -05:00
Brandon LiandClaude Opus 5.5 57835c01f0 Unknown entries settle only on a sync started after them; filled-after-all and working orders seen
Sixth review round (8e66631):

- An entry of unknown outcome is judged against a good sync that STARTED
  at least 90s after it was declared unknown (lastSyncStartedAt: TopstepX's
  pass start, Tradovate's request time), not one that merely ended then —
  a sequential pass can read the account before a late fill.
- Once the broker shows its fill (a position, or realized P&L moved), the
  entry becomes an ordinary filled one instead of holding the account for
  the rest of the day.
- It never settles while the broker still shows a working order on the
  account: Tradovate now reads working orders from the sync snapshot, and
  its fill-timeout cancel checks Tradovate's answer instead of assuming it.
- A second wipe (after a restart) keeps the pending wipe's pre-wipe fund
  signature instead of overwriting it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 21:38:57 -05:00
Brandon LiandClaude Opus 5.5 8e66631b0e Entries of unknown outcome hold the account until settled; wipes keep their guards
Fifth review round (1eb0736):

- An entry now has three outcomes. Filled (confirmed): one-and-done is done
  for the day. Never placed: leg taken back, free to retry. Unknown (an ack
  or fill that never came back): the account — any mode — is held until a
  good sync at least 90s later shows it flat with realized P&L unchanged,
  then the leg is taken back and it may retry. Stop-after-all counts it in
  play meanwhile. This closes both the day-long bench of 52ed9c7 and the
  stacked re-entries of 1eb0736.
- Tradovate cancels an acked order whose fill didn't come within 30s (as
  TopstepX already does), so a retry can't sit beside a still-working one.
- A wipe keeps the rebuild's guards: wipeAccountData leaves the Cash History
  baseline, roll base, wipe hold, pre-wipe fund signature and the
  resolver's inferred size in account_meta; the routes forget history
  before wiping (so the stored rows are still there to read), and a wipe
  hold restored after a restart keeps its pre-wipe yardstick.
- Wipe routes take the set of typed accounts before wiping, so an account
  the resolver un-assigns mid-rebuild still counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 21:12:51 -05:00
Brandon LiandClaude Opus 5.5 1eb0736fbe One-and-done counts confirmed fills; holds need a persistent refusal; wipes keep their yardstick
Fourth review round (52ed9c7):

- The one-and-done guard counts only entries the broker confirmed filled,
  so an entry that timed out or was rejected unfilled no longer benches the
  account for the day.
- TopstepX: an account is promoted only by a sync that actually checked its
  funds; one with no account type stays out (not traded) without costing a
  sync per pass, and once typed waits for that check. Wipe routes don't
  count untyped accounts as failures.
- Tradovate: an unchecked balance-hold release needs Cash History refused
  for a full hour straight (a 4xx other than 401/403/408/429, or an answer
  without data); any other outcome resets it, so one stray reply during a
  5xx outage can't release a payout.
- A wipe keeps the pre-wipe baseline and fund rows as its yardstick: the
  rebuild must explain today's balance against them, so a payout the
  rebuilt report hasn't booked yet stays held.
- The roll base of an unrolled record is kept per trade date in
  account_meta, so a restart later the same trade date counts from the same
  point instead of from a value that already includes the day's fills.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:46:35 -05:00
Brandon LiandClaude Opus 5.5 52ed9c75e4 Roll window instead of a roll-time base; transient outages keep payout holds
Third review round (088886e):

- Tradovate: a record still on the ended trade date waits only inside
  17:00-17:45 CT (every account rolled 17:17-17:21 on 2026-09-24); nothing
  trades on it there, so no pre-roll fill can be lost or double-counted by
  the broker's roll. Past the window an unrolled record (an idle account)
  counts from its value when first read, so nothing waits indefinitely —
  across restarts, stale bases or accounts the broker rolls only on a fill.
- One-and-done accounts also stop once this process fired a leg today.
- A balance hold is released unchecked only when Cash History is refused
  outright (4xx / errorText, e.g. a passed account's 404s); a 5xx, timeout
  or queue outage keeps the payout held until it recovers.
- A Cash History is judged against the baseline as it stood when requested,
  so a fold observed while it was in flight is not taken for a payout.
- TopstepX: a cache fallback for an account not loaded before waits for a
  funds check; accounts with no account type (not ours) aren't left
  pending; a fetch awaits a sync already running and syncs again if its
  accounts still wait, so wipe routes report the real outcome.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:37:14 -05:00
Brandon LiandClaude Opus 5.5 088886ed73 Pre-roll fills stay visible; holds survive restarts and judge fund amounts
Second review round (e496404, 9b2d677):

- Tradovate: past the 5 PM roll an old-dated cash record counts only what
  it gained since the roll (its realizedPnL as last seen while current is
  kept as the roll base), so tonight's fills booked onto it before
  Tradovate rolls the account stay visible — no re-trading a one-and-done
  account. A record this process never saw before the roll leaves the
  account waiting for the broker's roll (ROLL_PENDING, counted in play for
  stop-after-all). The 60s snapshot carries the roll base and can't undo a
  newer record the socket delivered.
- A wipe's hold is kept in account_meta, so a restart onto a failing Cash
  History still holds the account.
- A Cash History whose pre-request balance the last vouched-for baseline
  can't explain, and which booked nothing, holds the account instead of
  rebasing onto an unbooked payout.
- Holds compare fund transactions by date and amount (a second payout on a
  date that already has one), and an in-flight hold is judged against the
  rows from before its report.
- The saved baseline tracks the day's realized P&L and folds, so a restart
  judges against current figures; held refetches back off to an hour, and
  a balance hold no Cash History can check is released after an hour.
- TopstepX: a fetched account is promoted only by a sync whose
  reconcileFunds actually checked the books (not one skipped for an open
  position), and a direct fetch awaits that sync, so wipe routes report
  the real outcome.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 18:45:45 -05:00
Brandon LiandClaude Opus 5.5 9b2d677662 History holds: only a booked fund transaction lifts them; pace, persist, and cover wipes
Follow-up to 77d8c2e from its adversarial review:

- Stop-after-all no longer shuts down before an open while histories are
  still loading (a regression in 77d8c2e): a pending account counts as in
  play everywhere but the weekday 2:55-5:00 PM CT stretch after the close.
- Tradovate balance holds lift only when a Cash History books a fund
  transaction the account didn't have when the hold began; one that never
  does is released after 3 clean reports over 15 minutes, with a warning.
  The baseline comes from the balance when the report was requested, so a
  payout landing mid-report is held for the next one. Held accounts
  refetch every 5 minutes, not every sync.
- The Cash History baseline is kept in account_meta, so a restart onto a
  failing report server still catches a payout from the DB cache.
- A wipe holds the account until a Cash History rebuilds its fund
  transactions (the Fills report and the cache can't).
- A report still queued after its polls, or answered without data, is a
  failure, not an empty history.
- TopstepX: a newly fetched account (new, wiped, failed) waits for a sync
  that reconciles its funds, and the fetch starts that sync right away.
- Accounts a fetch is already working through aren't fetched again by the
  targeted pass; the TopstepX REST fill search spans ±15 minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:55:32 -05:00
Brandon LiandClaude Opus 5.5 e49640498a Tradovate: yesterday's realized P&L no longer reads as today's after the roll
Tradovate rolls each account's cash record to the new trade date on its own
schedule (18 minutes after the 17:00 CT open on 2026-09-24, or not until the
account trades), and until then the latest record still carries the ended
day's realizedPnL. Plans sized legs off it, one-and-done accounts read as
already traded, the reporter scored every account partial/loss, and an entry
across the record roll took a whole day's P&L as its "commission", parking
the take-profit out of reach.

- Cash records keep their trade date; realized P&L from an earlier trade
  date reads as 0 (judged when read, so a record stored before the roll
  counts as ended after it). Archived or older records never overwrite a
  newer one, whichever order the roll pair arrives in.
- The entry-commission delta falls back to $2.50/contract when it exceeds
  any real fee ($10/contract).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:22:43 -05:00
Brandon LiandClaude Opus 5.5 77d8c2ecdb Trade only on loaded history; catch early TopstepX fills; fix SI/M2K point values
- Both clients track which accounts this process has loaded history for
  (historyReady). An account restarted, rebuilt, wiped or newly listed is
  ineligible until its fetch lands, instead of trading as a fresh stage-1
  first day; a failed fetch with no cache no longer reads as empty history.
  Waiting accounts get a targeted fetch on the next sync.
- Tradovate holds an account whose start-of-day balance moves without a fill
  (a payout between hourly Cash History runs) until a Cash History books it,
  so a payout-pending account isn't re-traded on its ended cycle.
- Wipe routes forget history through the client and count accounts whose
  history didn't come back as failures.
- TopstepX sendOrder picks up a fill that arrived before the place response,
  and its REST backstop uses the documented Trade/search and Order/search.
- POINT_VALUES: SI 5000 (was 50), M2K 5 (was 10).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:04:09 -05:00
Brandon LiandClaude Opus 5.5 61384c9fa6 Drain: random first day, then size the rest by consistency
Only the drain cycle's first day draws from the $4-$15 grid. Under a
consistency rule, later days follow the standard consistency math and
the day that can close the cycle aims at the midpoint of the amounts
that close it, so accounts drained together finish together whatever
their first draw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 16:34:43 -05:00
Brandon LiandClaude Opus 5.5 b932452e98 Self-healing broker clients, per-firm scheduler isolation, eager boot
Why the autotrader sometimes broke after a weekend until `pm2 restart`:
`next start` only logs uncaught errors, so nothing ever crashed, and a few
paths could leave a client dead for the life of the process.

Tradovate: a closed socket scheduled exactly one re-login, with no catch —
one failure during weekend maintenance (or a rate-limit penalty returned as
a 200 without a token) and the client never came back. connectAndAuth is now
a single-flight loop that never throws: jittered backoff capped at 5 min,
penalty tickets waited out, captcha demands backed off. A 15s watchdog
abandons silent, never-authorized or stuck-closing sockets and stale syncs;
renewal falls back to a fresh login; orders fail fast (OrderNotPlacedError)
on a dead socket and time out instead of hanging; a bad sync answer no
longer wipes accounts and positions.

TopstepX: hub start/stop/invoke and every REST call are bounded; a wedged
hub is discarded and rebuilt; a stuck sync is superseded after 3 min;
re-login no longer tears down the sync loop; a watchdog re-logs when syncs
go stale; a no-fill timeout cancels the order.

Scheduler: one unready client used to hold every firm forever. It now holds
the tick for a 10-minute grace, then trades the other firms without it.
Per-firm try/catch in batch collection, one batch at a time, accounts marked
in flight, legs un-counted only when the entry never reached the broker, and
the scheduler starts before the first batch fires.

Boot and supervision: instrumentation.ts boots the process at server start
(it used to wait for the first dashboard request); a supervisor rebuilds a
client that has been unhealthy for 20 min (not a TopstepX one holding a
position); broker HTTP calls default to a 60s timeout; GET /api/health shows
each client's liveness and the scheduler's last decision. Shared state the
eager boot would otherwise split across bundles lives on globalThis. Logs no
longer carry access tokens or whole AxiosErrors (which include the encoded
Tradovate password).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 20:18:33 -05:00
Brandon LiandClaude Opus 5.5 6146031a38 Updater: detached worker, built-commit marker, restorable builds
PM2 runs update-check.mjs as a cron *restart*, and PM2 7 stops a process
that is still running at the next minute boundary. Any update that needed
npm install or next build (more than ~55s) was killed half-way: the lock
was left behind, HEAD had already moved at the pull so the next run saw
nothing to do, and next build had already wiped .next under the live
server — a box that broke after a weekend push and stayed broken.

- The cron run is now a quick CHECK: fetch, decide, and hand the work to a
  detached worker (--apply) that PM2's next tick can't reach.
- "Up to date" is judged against scripts/.built-commit (what .next was built
  from), not HEAD, so a pull that was never built finishes on the next check.
  First run on a machine adopts a complete build that is newer than HEAD's
  last move and rebuilds anything else — that rescues boxes the old updater
  already stranded.
- The previous build is set aside as .next-prev and restored if the build
  fails, so a broken push never leaves a box without a build; failures retry
  after 15 min, then hourly (scripts/.update-failed).
- The lock holds a pid and is atomic; build dirs are deleted without ever
  following the links in .next/node_modules into the real node_modules.
- Every instance in scripts/instances.json is restarted, and python/pip
  children stay hidden on Windows now that the worker has no console.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 20:18:33 -05:00
Brandon LiandClaude Opus 5.5 ccad48cf0a Instance scripts: read node - arguments from argv[2]
`node -` puts '-' in argv[1], so the registry snippets in add-instance.sh
and remove-instance.sh were one slot off: they wrote the registry to a file
named "-" in the working directory, with the instances.json path as the
instance name, a null port and the port as the db. scripts/instances.json
was never created, so the updater and start-all only ever knew instance 1 —
after an update, instance 2 and up kept running old code against the new
build until someone ran `pm2 restart all`.

Devices that already ran add-instance.sh need their scripts/instances.json
written by hand (and the stray "-" file deleted).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 20:18:33 -05:00
Brandon LiandClaude Opus 5.5 dd73222e00 Reporter: day stats keyed by Days Traded, matching the instance dashboard
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 20:16:28 -05:00
Brandon LiandClaude Opus 5.5 a7e01b0138 Reporter: catch up completed cycles on reset, report each firm's account list
When a fund transaction starts a new cycle (the days revert to 0), the
cycle it ended is checked against the completion rule for a week after and
named if it stood complete — its final day may never have been reported.
Only for a cycle this instance saw begin: the clients now note where the
ended cycle started as they move last_fund_date forward. Completions carry
the cycle they belong to, and each firm reports its whole account list so
the master can tell erased accounts and spot new funded ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 13:08:16 -05:00
Brandon LiandClaude Opus 5.5 acb8879575 Reporter: name the accounts whose final day completed their cycle
cycleCompletion() says whether today's P&L finished the current cycle
(profit target, consistency and minimum days all met) — projectPerfectPath's
completion probe run without and with today. The reporter counts those as
`completed` (a subset of targetHit) and sends their names, so the master
can keep an account a firm erases after it passes. Checked ahead of the
lockout branch: a firm locking a passed account is no DLL loss. The local
dashboard shows them with a purple Completed pill.

Because the master keeps a completion for good, doubtful reads stay
'none': weekends, a Monday with a weekend-dated row, the first minutes
after the 5 PM roll, and history that hasn't freshly loaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 12:28:41 -05:00
Brandon LiandClaude Opus 5.5 a47a86a59c Reporter: per-day target-hit counts for each account type
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 10:11:01 -05:00
Brandon LiandClaude Opus 5.5 1d4d45bd79 Fix build SQLITE_BUSY: busy_timeout before a best-effort WAL switch
lib/db.ts switched to WAL at module load before setting busy_timeout.
next build imports it from several workers while the live server holds
the same file, and a journal-mode change needs the file to itself — so
the pragma failed instantly with SQLITE_BUSY and took the whole build
down ("Failed to collect page data"), blocking updates on any box whose
database was still in rollback mode. The timeout is now set first and
the WAL switch is wrapped: a held file keeps its current mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-22 22:07:38 -05:00
Brandon LiandClaude Fable 5 d462cc284f Untrack yarn.lock — npm/package-lock.json governs the fleet
The fleet tooling is npm-only (setup-linux.sh installs with npm,
update-check.mjs triggers installs off package-lock.json), so the
committed yarn.lock did nothing for any VPS — but yarn regenerating it
locally dirtied working trees and blocked git pull on machines where
yarn had ever been run. Now gitignored: yarn dev still works wherever
someone wants it, with a local lockfile git never sees.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-22 14:13:15 -05:00
Brandon LiandClaude Fable 5 ba8bd50da0 Backups push automatically; the token is only for restore
Matches the master's tokenless-push change: pushBackup no longer waits
for master_backup_token — every instance with a uuid backs up on its
own, and the 5-minute tick force-pushes so a poisoned master copy
self-heals. The token now only gates pulling a backup back (restore),
so setup-linux.sh asks for it only when restoring and the Settings
help text says so.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-22 14:08:03 -05:00
Brandon LiandClaude Fable 5 8154f25bb9 Backups to master, replacement-VPS restore, broker proxy, multi-instance
Four features:

- Backup: lib/backup-sync.ts pushes the full settings table and firm
  credentials (the deliberate exception to "credentials never leave the
  machine") to the master's bearer-gated /api/instances/backup — every
  5 minutes when the content hash changed, within seconds of a settings
  or credential edit, and on the new backup-now command. Enabled by the
  master_backup_token setting.

- Restore: a dead VPS's replacement pulls the backup and adopts its
  uuid/name, so roster, payouts and card links carry on. Pre-boot via
  seed-settings.js --restore-from (setup-linux.sh prompts for it) or
  the Settings page's restore picker (lib/backup-restore.ts). Stop the
  old machine first; the master 409s a live-name rename.

- Proxy: the proxy_url setting routes BROKER traffic only — TopstepX
  REST + SignalR hub, Tradovate REST + websocket, Yahoo quotes — via
  lib/outbound-proxy.ts (axios agents, undici dispatcher, ws agent).
  The master link, reporter, catalog and backup push stay direct, so a
  dead proxy cannot take down the control channel. Restart to apply.

- Multi-instance: AUTOFIRMER_DB moves the SQLite file so one checkout
  runs N instances; scripts/instances.json (per-device, gitignored)
  drives start-all/update-check, and add-instance.sh / remove-instance.sh
  manage the extra PM2 processes. Absent registry = single-instance
  behavior, unchanged. Instance 1 keeps port 3000 for the clicker and
  extension.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-22 13:55:35 -05:00
Brandon LiandClaude Fable 5 97947bffde Drain stages keep the firm's consistency rule
A drain stage zeroed consistency, so the payout was planned (and the
account stopped trading) as soon as the min days were booked — even when
the cycle's biggest day still exceeded the firm's ratio (Topstep holds
its 40% through the drain). Carry stage.consistency into the effective
config and keep booking drain days until the cycle total covers
maxDay / consistency.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-16 05:43:55 -05:00
Brandon LiandClaude Fable 5 1bf8ba8693 extension: detect activatable Topstep accounts
Add a popup button that scans the Topstep dashboard's GraphQL API
(crystal.topstep.com) for passed evaluations that can still be
activated into an Express Funded account.

An eval is activatable when its status is "Passed" and its subscription
id is not shared by ANY funded account it ever spawned — regardless of
that funded account's current status, since activation is one-way. The
result and its progress are mirrored to chrome.storage so the popup
renders them independent of the message port (avoids the "message port
closed" error on this ~20s scan). Activatable accounts link to their
Topstep detail page.

Adds https://*.topstep.com/* host permission; bumps to 0.15.0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-15 10:33:56 -05:00
Brandon LiandClaude Fable 5 b1dccd7dec Revamp login guards into general per-firm page guards
Replace the single hardcoded signed-out check with a guards list on each
firm: any guard can trip mid-run via a URL substring (checked on every
step) or a selector probe (checked when a step fails). Each guard defines
recovery steps or a human-wait fallback, a per-run trigger cap, and a
resume policy — 'continue' retries the tripped step, 'restart' rewinds
the run to step 1.

Runner bumped to 0.20.0; the server still ships the legacy
signedOut/authSteps shape for stale runners, and a new runner synthesizes
a guard from a legacy payload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-14 12:50:14 -05:00
Brandon Li 82b98b9d2d Agree to terms if it didnt work lucid 2026-09-13 16:31:04 -05:00
Brandon Li 5632d347c3 Wait for success modal to close 2026-09-13 15:27:05 -05:00
Brandon Li 00d30d1745 Click success modal 2026-09-13 15:25:34 -05:00
Brandon Li d97d3aa221 Update selector again 2026-09-13 15:22:08 -05:00
Brandon Li 2dffffd20b Updated agree to terms 2026-09-13 15:19:32 -05:00
Brandon Li 9588e3896d Push automations for lucid update 2026-09-13 15:06:12 -05:00
Brandon LiandClaude Opus 4.8 783e00755e Reconstruct MLL and reconcile funds against the real starting balance
Both the MLL floor and the books-invariant fund detector assumed a $0
starting balance (correct for Express, wrong for funded/eval sizes). A
150K account (accountSize 150000) built its balance curve from $0, so
the floor came out near -$4,500 and a $145k balance never crossed it —
the account never marked dead — while reconcileFunds saw the whole
$150k as an unexplained deposit. Seed both with accountSize so the floor
is an absolute balance (150K/$4,500 drawdown → 145,500, locked at the
funded size once the EOD high climbs a drawdown above it) and the books
invariant nets out the starting balance. Skip reconcile until the
account type is known, since the start balance is unknowable before then.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-13 15:02:16 -05:00
Brandon LiandClaude Fable 5 fd6a2e33b6 Payout projection counts days the way the firm does (CME-holiday combining)
projectPerfectPath worked on the raw daily P&L, so for a combining firm a
holiday-session trade (e.g. the Labor Day session) counted as its own
trading day — an account one real day short of its min days looked
complete and anchored its payout to the last traded day. The projection
now folds sessions through combineCmeSessions like the live path, and
projected days skip dates that clear into the next full day.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-13 14:56:11 -05:00
Brandon LiandClaude Fable 5 9597db08f7 Anchor payouts to the trading day the cycle finishes, not the report day
A completed cycle's payout now lands on its last traded day (requestable
after that session's close) instead of whatever day the projection ran —
a Friday finish viewed over the weekend stayed pinned to Sunday. Also
stop inserting the platform's stale weekend realizedPnL as a Sat/Sun
P&L row, which fabricated an extra qualifying day and made accounts one
day short look already complete.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-13 14:39:14 -05:00
Brandon LiandClaude Fable 5 1996937ef2 Mark TopstepX accounts dead only off landed fills and settled books
The MLL floor no longer recomputes while a position is open or while
the balance disagrees with the books beyond fee noise — the last
settled floor stands, so balance ≤ floor can only come true when a
booked fill crosses a settled floor (Topstep's own liquidation fill
included). The floor also survives canTrade flipping off, so a blown
account stays ☠ instead of demoting to inactive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-13 14:10:36 -05:00
Brandon LiandClaude Fable 5 5e14daa669 Drain days draw from a $4–$15 half-dollar grid
23 deterministic values per (account, date) instead of $5/$10/$15; the
qualifying floor moves to the grid minimum so every draw counts toward
the cycle. The per-stage min-day field no longer drives the amount.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-13 13:57:06 -05:00
Brandon LiandClaude Fable 5 fc65b4340d Reconstruct TopstepX MLL floors from the EOD balance curve
TopstepX never reports the Maximum Loss Limit, but it's deterministic:
floor = min(0, eodBalanceHigh − trailingDrawdown), locking at the $0
starting balance. The EOD curve rebuilds from daily P&L + fund events,
with off-book fee drift spread across days proportionally to reported
fees (validated against a platform-reported MLL to ~$0.10). Stored in
autoLiqThresholds — negative, or +$0.01 once locked — so dead detection,
dashboards, and the ruin model's startThreshold all use the real room.
Guards move from '> 0' to '!== 0'; 0 still means no floor. canTrade=false
accounts are skipped. daily_pnl gains a fees column for the weighting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-13 13:57:06 -05:00
Brandon LiandClaude Fable 5 4edb771701 Fix TopstepX login loop on bundled builds: signalr's dynamic require
@microsoft/signalr does an unconditional require('ws')/require('eventsource')
when constructing a connection; bundled (turbopack) builds throw 'dynamic
usage of require is not supported', which the login chain mislabelled as a
login failure and retried every 60s. Externalize the package and never let
hub setup fail the login — REST polling covers everything the hub does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-13 13:28:09 -05:00
Brandon LiandClaude Opus 5 153b1273c3 Refuse to run the Linux setup as root
setup-linux.sh accepted being run as root and simply set SUDO="". That quietly
broke everything user-scoped: PM2 keeps its process list in $HOME/.pm2 and the
login entry goes in $HOME/.config/autostart, so under sudo both landed in /root.
The install reported success, then `pm2 list` as the real user showed nothing -
it was talking to a different daemon - and the autostart entry sat in a home
directory that never logs in.

Both scripts now refuse, and name the account to use when SUDO_USER says how
they were invoked. setup-linux.sh still uses sudo for apt, which is the only
part that needs it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 13:21:59 -05:00
Brandon LiandClaude Opus 4.8 a6a79bba7e Payout projection: payoutable same-equity accounts loop without explicit stages
A payoutable account with no withdrawal stages (e.g. MFFU same-equity)
now withdraws its share and re-targets the same equity each cycle instead
of stopping after the first payout. Non-payoutable evals still stop at
one completion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-12 19:04:22 -05:00
Brandon LiandClaude Fable 5 70eab33d1b Payout projection: a completed cycle is requestable today, not next session
When target and min trading days are already satisfied, the payout fires
on the current trade date instead of being pushed to the next session —
so an account that has already hit its target with its days in shows the
payout as requestable now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-12 19:01:27 -05:00
Brandon LiandClaude Fable 5 bf56a76a6d Account calendar: completion cell says 'Passed' for non-payoutable types
An eval that completes its target passes to the next account rather than
paying out — the projected completion marker reads 'Passed ✓' unless the
type is marked payoutable on the master.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-12 18:55:58 -05:00
Brandon LiandClaude Fable 5 38a9c6c1b7 Report the running build's version to the master
The version is the checkout's HEAD commit (short hash + commit time),
read once at startup — updates arrive as `git pull`, so there is no
release number to bump. It rides on the socket hello, socket reports,
and the HTTP report fallback; the field is optional in the protocol so
older masters ignore it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-12 18:54:19 -05:00
Brandon LiandClaude Fable 5 c493796602 Drain-day randomness in $0.50 steps (MNQ tick value), $5.00–$15.00
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-12 18:12:12 -05:00
Brandon LiandClaude Fable 5 9b2d823210 Account calendar: reach probabilities, 2-payout horizon, outline cells, same-day payouts, real drawdown floors
- projectPerfectPath books each cycle's payout ON its final trading day
  and anchors ruin probabilities to the platform's real liquidation floor
  (Tradovate auto-liq) when reported, else the configured trailing
  drawdown with the maxLoss/$2,000 fallback
- Account calendar shows the cumulative reach % on every projected day,
  caps the projection at two payouts ahead, and renders potential days
  as dashed outlines instead of shaded cells

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-12 18:09:00 -05:00
Brandon LiandClaude Fable 5 52d51afebb Report each account's future payout chain with closed-form reach probabilities
projectPerfectPath prices every projected withdrawal as
min(withdrawalPct × equity profit, cap) × profit split, and tracks the
cumulative probability of reaching it under the fair-walk ruin model —
room/(room + dayTarget) per day (the per-leg product telescopes), trailing
threshold locked at breakeven after the first withdrawal, no obfuscation
branches. The reporter attaches the first 6 payouts per account so the
master's payout calendar renders real amounts and probabilities instead of
one static expectedPayout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-12 17:01:09 -05:00
Brandon LiandClaude Fable 5 50a5eb4713 Add TopstepX platform support, drain withdrawal stages, and perfect-path calendar projection
- TopstepXClient (ProjectX Gateway): API-key auth, SignalR user hub,
  Trade/search daily P&L, emulated OCO exits, books-invariant
  withdrawal detection for $0-start accounts
- Platform-aware client pool, credentials (api_key column), and
  credential form; $0-size account types match by prefix alone
- Drain stages: min-day ($5-$15 randomized) MNQ legs for the
  configured days, then wait for the 50% withdrawal; stage pills
  show 'Stage N – Drain'
- Account calendar projects the perfect path: future days book their
  planned targets, simulated 50% withdrawals, payout marker

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-12 16:25:52 -05:00
Brandon LiandClaude Fable 5 1bca441247 Show the next trade's stop next to the daily target
Stop-enabled accounts show an amber "stop $X" beside the day target:
the day-1 net floor (live against realized P&L) or 2x a min-day's
target, mirroring exactly what the trade path will place — and nothing
when the next trade rides stopless.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-11 20:24:44 -05:00
Brandon LiandClaude Fable 5 6685a75864 Report and badge Personal DLL days
A stop-enabled account whose day booked red via its own stop shows an
amber "Personal DLL" badge on the dashboard (the firm's lockout keeps
its red "Hit DLL") and reports as personalDll in the day outcomes
instead of loss, per account type and per firm.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-11 18:51:03 -05:00
Brandon LiandClaude Fable 5 c58e981b1e Place OCO stop brackets for the catalog's obfuscation options
When an account type opts in, legs are placed as an OCO pair via
order/placeoco - the usual limit plus a stop: the first cycle day's stop
keeps the day's net floor at -$1,000, min-day trades stop at 2x their
target, and the day after any red day rides stopless to make it back. A
stop-out books the day: eligibility refuses the account once realized
P&L falls short of the legs fired (or is simply negative on an opted-in
account, which survives restarts). If placeoco is rejected the leg falls
back to the classic lone limit with a loud log rather than leave a
position without an exit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-11 18:28:21 -05:00
Brandon LiandClaude Fable 5 ae0f93a44f Honor the catalog's min-legs setting and carve $500 pieces
Big days (> the $1,700 split threshold) must carve at least the account
type's minLegs from the catalog — pickLegAmount reserves the minimum
piece for each leg still owed, forcing a carve even once the residual
dips below the threshold, clamped to what the pieces allow. Days at or
below the threshold still trade as one leg. The smallest split piece
drops from $800 to $500. An older master without the field means no
minimum.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-11 15:32:40 -05:00
Brandon LiandClaude Fable 5 620ab54d42 Report each account's last traded trade date
Sessions roll at 5 PM CT, so after tonight's open a day booked on
tomorrow's trade date is already in the day count — lastDay is what lets
the master tell those accounts apart from ones that have not traded the
current session yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 18:51:59 -05:00
Brandon LiandClaude Fable 5 79ee5773cb Report each live account's payout state to the master
payoutStates: one entry per live account (negative included, dead not)
with its current-cycle progress — qualifying days, full equity profit,
max qualifying day, P&L sum, and the cycle's end-of-day high-water mark —
plus the withdrawal count and the cycle's effective target/consistency/
min-days from resolveEffectiveConfig. client.dailyPnL is already the
active-cycle slice, so a withdrawal resets the reported path on its own.
Feeds the master's payout calendar; an older master ignores the field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 16:43:27 -05:00
Brandon LiandClaude Fable 5 abfb703f0e Keep negative and DLL-locked accounts out of the payout histogram
The payout calendar projects accounts still on the winning path, so the
daysHist now skips accounts below their starting balance or flipped to
liquidate-only, alongside the dead ones the report already drops. They
still count in the slice totals — only the calendar stops seeing them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 16:28:37 -05:00
Brandon LiandClaude Fable 5 6c501d00ed Report per-account-type days-traded histograms to the master
Adds daysHist ({daysTraded: accountCount}) to AccountTypeStats, computed
from the same combined-day view the trade path uses and covering every
live account including ones mid-trade. Feeds the master's payout
calendar; an older master simply ignores the field.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 16:21:17 -05:00
Brandon LiandClaude Fable 5 0d01539338 Report DLL-locked accounts as finished at a loss
An account Tradovate flips to liquidate-only at the daily loss limit is
done for the day, but the reporter only counted a loss when the booked
realizedPnL was already negative — so a locked-out account whose P&L
hadn't caught up (or that had no matching config) showed as still to
trade. Count inactive accounts as traded at a loss.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 02:18:01 -05:00
Brandon LiandClaude Fable 5 5d2f4d607a Seed leg splits per prefix group so like accounts split targets alike
Like accounts in a batch previously carved legs with independent
Math.random, so near-identical day targets could split very differently.
Leg fractions are now drawn from groupLegFraction(prefix, tradeDate,
legIndex) — FNV-1a seeded like dailyBump — so accounts of the same
account type on the same leg of the same day split identically when
their remaining targets match, and proportionally close otherwise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 22:03:23 -05:00
Brandon LiandClaude Fable 5 3516e6c288 Add a bulk wipe & recalculate button to the dashboard
The per-account wipe works one account at a time; when every account is
carrying stale cached rows (the pre-roll dates again), that's a lot of
clicking. POST /api/accounts/wipe-all drops the cached rows for every
account on every firm and rebuilds them all from fresh reports — firms
in parallel, accounts within a firm at fetchDaysTraded's own pacing.
The toolbar button confirms inline with the same 28-day-window warning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 21:36:36 -05:00
Brandon LiandClaude Opus 4.8 ef476ccbb7 Add day-plan double-count diagnostics
The daily target sometimes inflates to base + consistency×todayPnL (e.g.
1200 + 0.4×1218.48 = 1687.39), which happens when a session's cached
daily-P&L row is dated something other than the current tradeDate: it
then lands in BOTH the prior-day list and realizedPnL, so day-start
equity reads too low while the prior-day sum stays high.

Detect it with a book-balance invariant that is coincidence-proof (it
does not care whether prior and today P&L happen to be equal): the
reconstructed day-start equity (equityProfit − realizedPnL) must equal
priorProfit + sum(prior days). The state route now logs the full account
state whenever those disagree by more than $50, and GET /api/debug
reports the same per-account breakdown on demand (?account=<substring>)
so the exact inputs can be captured the moment a number looks wrong.

Read-only; no behavior change. Remove once the trigger is fixed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-08 21:28:39 -05:00
Brandon LiandClaude Fable 5 5a69ea2f64 Add a wipe & recalculate button to the account detail page
Cached daily P&L rows outlive what the Cash History report still
produces (stale pre-roll dates being the recent example), and nothing
deletes them any more. POST /api/accounts/[id]/wipe drops the account's
cached P&L, fund transactions and meta, clears the in-memory view, and
rebuilds from a fresh report — fetchDaysTraded now takes an optional
account filter so one account doesn't cost a report round-trip on every
other. The button confirms inline first: rows older than the report's
28-day window cannot be refetched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 21:27:34 -05:00
Brandon LiandClaude Fable 5 bd72c4b1f6 Fold weekend-dated P&L rows into the next settling day when combining
Cash History rows can carry a weekend calendar date (Sunday-evening
activity attributed pre-roll), and those slipped through the holiday
combining as their own trading day — accounts traded only across the
Labor Day Sun/Mon/Tue stretch read 2/5 instead of 1/5. Weekends never
clear at CME any more than holidays do, so treat Saturday and Sunday as
unsettled dates and roll them forward with the rest of the stretch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 21:23:12 -05:00
Brandon Li 3443fac75c Revert "Attribute evening-session Cash History rows to the next trade date"
This reverts commit 57846d2b5f.
2026-09-08 21:21:09 -05:00
Brandon LiandClaude Fable 5 f07721019b Count CME holiday sessions as one combined trading day when the type opts in
Account types with the catalog's new combineCmeHolidays flag are read
through a combined-day view: holiday-session P&L entries fold into the
next full trading day (computed from the federal-holiday rules, so any
year works), the day count matches how the firm records it (Apex: Sun/
Mon/Tue around a Monday holiday is ONE day), and on the later sessions
of the stretch the earlier sessions' booked P&L carries into realized —
so day targets, already-traded gates, target-hit status and the panel's
day P&L all treat the stretch as a single day. Off by default; older
masters that don't send the flag behave exactly as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 21:12:37 -05:00
Brandon LiandClaude Fable 5 57846d2b5f Attribute evening-session Cash History rows to the next trade date
Cash History rows are stamped with the Central calendar date, but trades
in the evening session (>= 5:00 PM CT) belong to the next trade date -
matching currentTradeDateCT and when Tradovate resets realizedPnL. The
mismatch made computeDayPlan count the same session twice (as a phantom
prior day and as today's realized PnL), inflating the day target into a
moving one: 40% consistency accounts showed dayTarget = base + 0.4 x
today's PnL instead of the anchored day-start target.

Roll those rows forward at ingestion, and make a successful Cash History
report authoritative for its 28-day window so rows cached under the old
calendar date are dropped rather than double-counted. An empty report
never deletes anything (it can't distinguish "no trades" from "no
data"), and a report still pending after polling now throws instead of
masquerading as an empty one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 20:45:19 -05:00
Brandon LiandClaude Fable 5 77ae4971f8 Batch like accounts together by prefix before any mismatches
runTrade now draws each batch from a single account-type prefix group
(largest group first) instead of slicing across prefixes; a group's
remainder fires alone on a later tick. copyTrade fills open slots with
accounts matching the positioned accounts' prefix before mismatched ones.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 11:37:09 -05:00
Brandon LiandClaude Fable 5 48f3c97ec5 Report day outcomes (target hit / partial / loss) to the master
The reporter already computed targetHit per account to count it as
traded; now it also classifies each account that finished the day flat
and sends targetHit/partial/loss counts per firm and account type. A
realized loss reports as loss even on a grind day where any nonzero PnL
counts as done. Fields are additive, so an older master ignores them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 11:21:21 -05:00
Brandon LiandClaude Fable 5 346c356222 Report daily session-open delay to the master dashboard
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-06 15:35:03 -05:00
Brandon LiandClaude Fable 5 906275cc33 Add random 1-20 min daily jitter to trading session open
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-06 15:26:30 -05:00
Brandon LiandClaude Fable 5 a36e227e74 Add per-account-type splitEnabled flag from the catalog
When the master marks an account type splitEnabled=false, big days trade
as one unsplit leg (the legacy one-and-done behavior); min-day bumps are
unaffected. Missing field — older master or pre-toggle cached catalog —
defaults to enabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-04 12:15:08 -05:00
Brandon Li 3ef9486977 Place order lucid button 2026-09-03 13:20:35 -05:00
Brandon Li f8bc862753 Agree to terms 2 2026-09-03 13:17:22 -05:00
106 changed files with 30533 additions and 4865 deletions
+17
View File
@@ -59,6 +59,15 @@ scripts/.deps-hash
scripts/.python-cmd
scripts/.update.lock
scripts/update.log
# updater bookkeeping (scripts/update-check.mjs)
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/
# clicker virtualenv (Linux/Pi)
clicker/.venv/
@@ -71,3 +80,11 @@ scripts/.last-check
deploy/publish.log
deploy/.last-check
deploy/.publish.lock
*.sqlite-shm
*.sqlite-wal
# per-instance databases (add-instance.sh)
.data/
# per-device instance registry (add-instance.sh)
scripts/instances.json
# yarn is dev-convenience only; npm/package-lock.json governs the fleet
yarn.lock
+232 -13
View File
@@ -1,6 +1,8 @@
# AutoFirmer
Automated futures trading dashboard for prop firm accounts via Tradovate.
Automated trading dashboard for prop firm accounts via Tradovate and TopstepX
(ProjectX Gateway) for CME futures, and DXtrade for 24/7 crypto/CFD firms
(Tradeify247).
---
@@ -72,7 +74,7 @@ On first run, the SQLite database (`autotrader.sqlite`) is created automatically
Firms and their account types live on the master dashboard, not here — every
machine pulls the same catalog so they cannot drift apart. This machine only
stores the Tradovate login for each firm.
stores the platform login for each firm.
1. On the **master dashboard**, open **Firms**, add the firm, and give it one
account type per prefix + account size combination (profit target,
@@ -81,7 +83,14 @@ stores the Tradovate login for each firm.
master. The catalog is pulled at startup and then every 60 seconds
3. On the main page, each firm from the catalog appears. Firms without stored
credentials are greyed out — enter the Tradovate username and password in the
fields beside the firm name and click **Save**
fields beside the firm name and click **Save**. A firm whose platform is
**TopstepX** on the master asks for an API key instead (generated in the
TopstepX platform under Settings → API); the username is optional to store
but the ProjectX login needs it before the firm can connect, so add it when
you have it. Password is unused on TopstepX. A **DXtrade** firm asks for
its DXtrade username and password (the ones behind the key icon on the
firm's dashboard, not the dashboard login); the server URL and login domain
come from the master
4. Accounts populate once the firm connects. Each is matched to an account type
by its name prefix *and* its size, so a new account shows as **Unassigned**
until its opening funding transaction has been fetched. Use the **Account
@@ -89,6 +98,182 @@ stores the Tradovate login for each firm.
---
## Instruments: what each firm trades
Each firm's **Instruments** list on the master's Firms page is the one place
that says what it trades — there are no per-machine toggles or banned symbols
any more (the instance's Settings page shows the lists read-only).
- **Futures firms** (Tradovate, TopstepX) list CME roots — the same names on
both platforms; each client turns a root into its own front-month contract.
- **DXtrade firms** list their own symbols (`ETH/USD`), whatever root a batch
drew.
With the scheduler on **Auto**, each futures batch draws one instrument from
the batch firm's list, at random but weighted:
- **Efficiency** — one contract's typical daily move ÷ one tick of spread
(from Yahoo's hourly candles over the past week, measured once a day).
Commissions don't count — they barely differ between instruments.
- **Session timing** — at the open every instrument gets a 10% floor and the
weights follow efficiency; toward the last entry (14:55 CT) the floor fades
and the weight moves sharply to the fastest movers.
- **12-hour cap** — sizing is unchanged (a contract per $1,000 of leg, up to
the type's max contracts); an instrument whose expected hold for the
batch's leg is 12 h or more is out of the draw (e.g. 6E at a $1,000 leg,
about a week).
- Micros stay out of the draw; drain and extra-day legs always trade MNQ.
Every draw logs its odds. `GET /api/instruments` shows each firm's list with
spread, daily move, hold and chance (`?simulate=10000` runs the real draw
with no orders), and the master's Firms page shows the same next to each
instrument.
Against a master from before the lists, an instance falls back to the old
rules: this machine's enabled instruments less each firm's banned symbols,
and a DXtrade firm's symbol map.
---
## DXtrade firms (Tradeify247)
A DXtrade firm is set up on the master's **Firms** page like any other, plus:
- **Server URL** (`https://dx.tradeify247.co/dxsca-web`) and **login domain**
(usually `default`). Changing the URL re-keys the firm's accounts.
These settings only exist for DXtrade firms; futures firms keep their measured
per-contract fees and the CME calendar.
- **Commission %** of notional per side (Tradeify: `0.04`). The engine prices
every fee off the fill with it.
- **Trades every day** — weekends and CME holidays included. Such a firm runs
in its own scheduler *lane*: its own trading hours (closed only around the
daily roll), its own wait-until-flat gate and its own batches, so a
multi-hour crypto position never holds up the futures open.
- **Instruments** — the firm's own symbols (see above). The cheapest
tradable one right now wins: spread plus both commissions, per unit of its
volatility (hourly closes over the past week, measured once a day).
- **Target hold hours** — each leg's notional is sized from that volatility so
its expected hold is about this long (blank = 12 h). The position never
exceeds what the account's available funds can margin at the broker's
rate for the symbol (Tradeify: 5:1 crypto, 2:1 stocks — read from DXtrade,
nothing to set). A batch sizes its leg on every candidate and trades the
cheapest (spread, commissions, swap) that reaches the target hold; if the
leverage caps keep all of them short of it, the quickest.
- **Financing %/day** if the firm charges swap (Tradeify 1-Step: `0.033`).
Each take-profit then also carries the swap a winning leg is expected to pay
before it fills, so a win still clears the full leg.
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 — 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
breach.
Before the scheduler trades a DXtrade firm, run the smoke test against a real
login (with this instance's client for the firm disconnected):
```bash
DX_BASE=https://dx.tradeify247.co/dxsca-web DX_USER=… DX_PASS=… DX_SYMBOLS=BTC/USD,ETH/USD node scripts/dxtrade-smoke.mjs
```
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), 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).
---
## The master dashboard
Set **Instance Name** and **Dashboard URL** on the Settings page and this
@@ -96,6 +281,13 @@ instance does two things with them: it reports its firm and account counts every
30 seconds, and it holds a WebSocket open to `<url>/ws/instance` so the master
can drive it.
Every report also carries this checkout's version — the HEAD commit and its
time, since updates arrive as `git pull` and there is no release number to
bump. The master shows it in a **Version** column and flags any machine running
something older than the newest build in the fleet, which is how you check at a
glance that every machine took the last update. An instance showing *old build*
predates version reporting entirely.
What the master can trigger here:
| | |
@@ -343,13 +535,32 @@ nothing updates** — signs it, and publishes the CRX and update manifest. See
## Updating
Once auto-start is installed, nothing here is manual. Every 5 minutes the update
task fetches `master`, and when it has moved it pulls, reinstalls dependencies if
`package-lock.json` or `clicker/requirements.txt` changed, rebuilds, restarts
AutoFirmer, and restarts the clicker if anything under `clicker/` changed.
Once auto-start is installed, nothing here is manual. Every minute (the
installer's default interval) the update check fetches `master`. When there is something to do, it starts a background
worker. The worker pulls, reinstalls dependencies if `package-lock.json` or
`clicker/requirements.txt` changed, rebuilds, restarts every instance, and
restarts the clicker if anything under `clicker/` changed.
**A failed build is never deployed.** The build runs before anything restarts, so
a broken push leaves the previous build serving and logs the failure instead.
**Why a check and a separate worker.** PM2 runs the check as a cron *restart*,
and a restart kills a run that is still going at the next minute boundary. An
npm install or a build takes longer than that, so doing the work in the cron
process itself got it killed half-way through. The check finishes in seconds,
and the worker runs outside PM2's process tree, where the next tick can't
reach it.
**Deployed means built, not pulled.** `scripts/.built-commit` records the commit
`.next` was last built from, and the check compares `master` with that, not with
HEAD. A pull that was never built is therefore finished on the next check, and
so is a missing `.next/BUILD_ID`. The first time this version runs on a machine
it judges the build already on disk: one that is complete, newer than HEAD and
already serving is adopted, and anything else is rebuilt.
**A failed build is never deployed.** The build runs before anything restarts,
and the previous build is set aside as `.next-prev` first. A failed build is
rolled back to it, so the box is left serving the previous build and a later
restart or reboot still has one. A failure is retried 15 minutes later, then
hourly until it succeeds or a new commit lands. `scripts/.update-failed` records
which step failed and when the next try is due.
### Checking it is alive
@@ -366,19 +577,27 @@ cron-restart process as `stopped` with a restart count of `0` even while it is
firing on schedule. Neither is evidence of a problem; `.last-check` is the
signal to trust.
Everything that actually happens is appended to `scripts/update.log`. To see
what it would do without touching anything:
While a worker is running, `.last-check` says `update in progress` with its pid.
After a failure it says `waiting to retry` with the time of the next attempt.
Everything that actually happens is appended to `scripts/update.log`. The full
npm and build output of the latest worker run is in `scripts/update-worker.log`,
which is the first place to look when an update failed. To see what it would do
without touching anything:
```powershell
node scripts\update-check.mjs --dry-run
```
To apply an update immediately rather than waiting for the next check:
To apply an update immediately in the foreground, bypassing the retry wait:
```powershell
node scripts\update-check.mjs
node scripts\update-check.mjs --apply
```
Without `--apply` it does what the schedule does: it checks, starts a worker if
there is anything to do, and returns straight away.
### What reloads itself, and what does not
- **The extension** updates itself now. `/api/autobuyer/status` reports the
+351 -32
View File
@@ -16,6 +16,9 @@ import {
} from 'recharts';
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. */
@@ -80,6 +83,169 @@ 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. */
function WipeDataButton({ accountId, onDone }: { accountId: number; onDone: () => void }) {
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
async function wipe() {
setBusy(true);
setError('');
try {
const res = await fetch(`/api/accounts/${accountId}/wipe`, { method: 'POST' });
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error ?? 'Wipe failed');
if (body.fetchError) setError(`Wiped, but refetch: ${body.fetchError}`);
setConfirming(false);
onDone();
} catch (err) {
setError(err instanceof Error ? err.message : 'Wipe failed');
} finally {
setBusy(false);
}
}
if (confirming) {
return (
<div className="ml-auto flex items-center gap-2">
<span className="text-xs text-slate-500">
Wipe cached data and refetch? Days older than the 28-day report window are lost for good.
</span>
<button
onClick={wipe}
disabled={busy}
className="px-2.5 py-1 bg-red-100 hover:bg-red-200 disabled:opacity-50 text-red-700 text-xs font-semibold rounded-md transition-colors"
>
{busy ? 'Refetching…' : 'Wipe & recalculate'}
</button>
<button
onClick={() => { setConfirming(false); setError(''); }}
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="ml-auto flex items-center gap-2">
{error && <span className="text-xs text-red-500">{error}</span>}
<button
onClick={() => setConfirming(true)}
className="px-2.5 py-1 text-xs font-semibold text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-md transition-colors"
title="Delete this account's cached daily P&L, fund transactions and meta, then rebuild them from a fresh Tradovate report"
>
Wipe & recalculate
</button>
</div>
);
}
function fmtDate(iso: string) {
const [, m, d] = iso.split('-');
return `${parseInt(m)}/${parseInt(d)}`;
@@ -116,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
@@ -140,7 +307,22 @@ const MONTH_NAMES = [
];
const DOW_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
function CalendarMonth({ year, month, pnlMap, fundMap, stageMap }: { year: number; month: number; pnlMap: Map<string, number>; fundMap: Map<string, number>; stageMap: Map<string, number> }) {
function CalendarMonth({ year, month, pnlMap, fundMap, stageMap, projPnlMap, projFundMap, projStageMap, projProbMap, payoutDate, payoutLabel }: {
year: number;
month: number;
pnlMap: Map<string, number>;
fundMap: Map<string, number>;
stageMap: Map<string, number>;
/** Perfect-path projection — rendered as dashed outlines on days with no real data. */
projPnlMap?: Map<string, number>;
projFundMap?: Map<string, number>;
projStageMap?: Map<string, number>;
/** Cumulative probability of reaching each projected day (fair-walk ruin model). */
projProbMap?: Map<string, number>;
payoutDate?: string;
/** "Payout ✓" for payoutable types; "Passed ✓" for evals that just pass. */
payoutLabel?: string;
}) {
const daysInMonth = new Date(year, month, 0).getDate();
const firstDow = new Date(year, month - 1, 1).getDay(); // 0 = Sunday
@@ -187,9 +369,18 @@ function CalendarMonth({ year, month, pnlMap, fundMap, stageMap }: { year: numbe
const key = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const pnl = pnlMap.get(key);
const fundAmt = fundMap.get(key);
const stage = stageMap.get(key);
const hasData = pnl !== undefined;
const positive = hasData && pnl! >= 0;
// Projection only fills days with no real data. Potential
// days render as dashed OUTLINES — no fill.
const projPnl = !hasData && fundAmt === undefined ? projPnlMap?.get(key) : undefined;
const projFund = !hasData && fundAmt === undefined ? projFundMap?.get(key) : undefined;
const isPayout = !hasData && key === payoutDate;
const isProj = projPnl !== undefined || projFund !== undefined || isPayout;
const projProb = isProj ? projProbMap?.get(key) : undefined;
const stage = stageMap.get(key) ?? (isProj ? projStageMap?.get(key) : undefined);
return (
<div
key={key}
@@ -200,7 +391,13 @@ function CalendarMonth({ year, month, pnlMap, fundMap, stageMap }: { year: numbe
: 'bg-red-50 border border-red-100'
: fundAmt !== undefined
? 'bg-amber-50 border border-amber-100'
: 'bg-slate-50 border border-transparent'
: isPayout
? 'border border-dashed border-blue-300'
: projFund !== undefined
? 'border border-dashed border-amber-300'
: projPnl !== undefined
? 'border border-dashed border-green-300'
: 'bg-slate-50 border border-transparent'
}`}
>
<div className="flex items-start justify-between">
@@ -210,7 +407,7 @@ function CalendarMonth({ year, month, pnlMap, fundMap, stageMap }: { year: numbe
{day}
</span>
{stage !== undefined && (
<span className="text-[8px] font-semibold text-slate-400 leading-none">
<span className={`text-[8px] font-semibold leading-none ${isProj ? 'text-slate-300' : 'text-slate-400'}`}>
#{stage}
</span>
)}
@@ -227,6 +424,29 @@ function CalendarMonth({ year, month, pnlMap, fundMap, stageMap }: { year: numbe
W/D {fundAmt >= 0 ? '+' : '−'}${fmt(Math.abs(fundAmt))}
</span>
)}
{projPnl !== undefined && (
<span className="text-[11px] font-semibold tabular-nums leading-tight text-green-600/70">
+${fmt(projPnl)}
</span>
)}
{projFund !== undefined && (
<span className="text-[10px] font-semibold tabular-nums leading-tight mt-0.5 text-amber-500/80">
W/D −${fmt(Math.abs(projFund))}
</span>
)}
{isPayout && (
<span className="text-[10px] font-bold leading-tight mt-0.5 text-blue-600/80">
{payoutLabel ?? 'Payout ✓'}
</span>
)}
{projProb !== undefined && (
<span
className="text-[8px] font-medium tabular-nums leading-tight mt-0.5 text-slate-400"
title="Probability of the perfect path reaching this day (fair-walk ruin model, cumulative)"
>
{Math.round(projProb * 100)}%
</span>
)}
</div>
);
})}
@@ -268,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) {
@@ -292,11 +514,19 @@ 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;
// Dead when balance hits Tradovate's auto-liquidation floor
const isDead = account.autoLiqThreshold > 0 && account.amount <= account.autoLiqThreshold;
const liqFloor = account.autoLiqThreshold > 0 ? account.autoLiqThreshold : null;
// 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.
const isDead = account.autoLiqThreshold !== 0 && account.amount <= account.autoLiqThreshold;
const liqFloor = account.autoLiqThreshold !== 0 ? account.autoLiqThreshold : null;
// Ground-truth profit = balance minus funded account size.
// Falls back to FIFO total when accountSize is unavailable.
@@ -308,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.
@@ -329,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,
@@ -370,19 +605,85 @@ export default function AccountPage() {
const strokeGradId = 'equityStroke';
// Perfect-path projection: what the future looks like if every trading day
// 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 payout days ahead.
const rawProjection = (cfg && !isDead) ? projectPerfectPath({
accountId: account.id,
cfg,
cycleDailyPnL: account.dailyPnL ?? [],
fundTransactions,
balance: account.amount,
realizedPnL: account.realizedPnL,
// 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,
profitSplit: cfg.profitSplit,
// Same default chain the reporter uses for reach probabilities.
drawdown: (cfg.trailingDrawdown ?? 0) > 0
? cfg.trailingDrawdown!
: Math.abs(cfg.maxLoss) > 0 ? Math.abs(cfg.maxLoss) : 2000,
// The platform's real liquidation floor, when one is known —
// reported by Tradovate, reconstructed for TopstepX.
startThreshold: account.autoLiqThreshold !== 0
? Math.round((account.autoLiqThreshold - cfg.accountSize) * 100) / 100
: undefined,
},
}) : [];
// 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. 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]));
const payoutDate = projection.find((p) => p.payoutReady)?.date;
// Build calendar data
const pnlMap = new Map(displayPnL.map((d) => [d.date, d.pnl]));
const allCalendarDates = [...displayPnL.map((d) => d.date), ...fundTransactions.map((f) => f.date)];
const allCalendarDates = [
...displayPnL.map((d) => d.date),
...fundTransactions.map((f) => f.date),
...projection.map((p) => p.date),
];
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);
}
@@ -403,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 && (
@@ -410,6 +712,10 @@ export default function AccountPage() {
☠ DEAD
</span>
)}
<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 && (
@@ -418,7 +724,7 @@ export default function AccountPage() {
<div>
<p className="font-bold text-sm">Account Blown</p>
<p className="text-slate-300 text-xs mt-0.5">
Balance ${fmt(account.amount)} has breached the Tradovate auto-liquidation floor
Balance ${fmt(account.amount)} has breached the auto-liquidation floor
{liqFloor != null ? ` of $${fmt(liqFloor)}` : ''}.
</p>
</div>
@@ -436,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">
@@ -447,7 +754,7 @@ export default function AccountPage() {
<span className="inline-flex items-center gap-1.5">
Profit Target
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold ${stagePillClasses(account.stage)}`}>
Stage {account.stage}
Stage {account.stage}{account.stageDrain ? ' – Drain' : ''}
</span>
</span>
}
@@ -456,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>
@@ -616,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 (
@@ -640,16 +941,34 @@ export default function AccountPage() {
{/* Daily P&L Calendar */}
{calendarMonths.length > 0 && (
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-6">
<h2 className="text-slate-900 font-bold text-base mb-6">Daily P&L</h2>
<div className="flex items-baseline justify-between mb-6">
<h2 className="text-slate-900 font-bold text-base">Daily P&L</h2>
{projection.length > 0 && (
<span className="text-xs text-slate-400">
Dashed outlines: the perfect path, two payouts ahead — planned targets daily, the projected withdrawal on each cycle&apos;s final day, % = odds of reaching that day
</span>
)}
</div>
<div className="grid grid-cols-1 gap-8" style={{ gridTemplateColumns: `repeat(${Math.min(calendarMonths.length, 3)}, minmax(0, 1fr))` }}>
{calendarMonths.map((ym) => {
const [y, m] = ym.split('-').map(Number);
return <CalendarMonth key={ym} year={y} month={m} pnlMap={pnlMap} fundMap={fundMap} stageMap={stageMap} />;
return (
<CalendarMonth
key={ym} year={y} month={m}
pnlMap={pnlMap} fundMap={fundMap} stageMap={stageMap}
projPnlMap={projPnlMap} projFundMap={projFundMap}
projStageMap={projStageMap} projProbMap={projProbMap}
payoutDate={payoutDate}
payoutLabel={cfg?.payoutable === true ? 'Payout ✓' : 'Passed ✓'}
/>
);
})}
</div>
</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 });
}
}
+6 -1
View File
@@ -1,5 +1,7 @@
import { NextResponse } from 'next/server';
import { getClients } from '@/lib/clients';
import { getAccountConfig } from '@/lib/account-type-resolver';
import { combineCmeSessions, currentTradeDateCT } from '@/lib/trading-logic';
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
@@ -9,7 +11,10 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
for (const client of clients.values()) {
const daily = client.dailyPnL?.[accountId];
if (daily !== undefined) {
return NextResponse.json(daily);
// Serve the same combined-day view the rest of the app computes
// with, so a combining firm's holiday stretch shows as one row.
const combine = getAccountConfig(accountId).accountType?.combineCmeHolidays === true;
return NextResponse.json(combineCmeSessions(combine, daily, currentTradeDateCT(), 0).dailyPnL);
}
}
+20
View File
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server';
import { fireManualLeg } from '@/lib/auto-trade';
import { errorMessage } from '@/lib/platform-client';
type Params = { params: Promise<{ id: string }> };
/** Fire one leg on this account now, through the scheduler's own path (see
* fireManualLeg). Body: { action?: 'Buy' | 'Sell' | 'Auto' } (default Auto). */
export async function POST(req: NextRequest, { params }: Params) {
const { id } = await params;
const accountId = Number(id);
if (!Number.isSafeInteger(accountId)) return NextResponse.json({ error: 'bad account id' }, { status: 400 });
const body = (await req.json().catch(() => ({}))) as { action?: unknown };
const action = body.action === 'Buy' || body.action === 'Sell' ? body.action : 'Auto';
try {
return NextResponse.json(await fireManualLeg(accountId, action));
} catch (err) {
return NextResponse.json({ error: errorMessage(err) }, { status: 409 });
}
}
+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 });
}
+47
View File
@@ -0,0 +1,47 @@
import { NextResponse } from 'next/server';
import { getClients } from '@/lib/clients';
import { getAccountConfig } from '@/lib/account-type-resolver';
import { wipeAccountData } from '@/lib/db';
/**
* POST /api/accounts/[id]/wipe — drop every cached row for the account and
* rebuild it from a fresh Tradovate report.
*
* Destructive on purpose: cached rows older than the report's 28-day window
* cannot be refetched. The UI warns and confirms before calling this.
*/
export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const accountId = Number(id);
if (!Number.isInteger(accountId)) {
return NextResponse.json({ error: 'Bad account id' }, { status: 400 });
}
for (const client of getClients().values()) {
const account = client.accountList.find((a) => a.id === accountId);
if (!account) continue;
// Read before the wipe: the resolver may un-assign it mid-rebuild.
const ours = Boolean(getAccountConfig(accountId).accountType);
// Clear the in-memory view too — a failed refetch must leave the
// account visibly empty, not showing the data that was just wiped —
// and out of trading until a fetch rebuilds it. First, so the client
// reads the stored rows it keeps as the rebuild's yardstick.
client.forgetHistory(accountId, account.name);
wipeAccountData(accountId);
await client.fetchDaysTraded([accountId]);
const loaded = client.historyReady(accountId) || !ours;
return NextResponse.json({
ok: true,
entries: (client.dailyPnL[accountId] ?? []).length,
daysTraded: client.daysTraded[accountId] ?? 0,
fetchError: client.lastFetchErrors[account.name]
?? (loaded ? null : 'History did not reload — the account stays out of trading until it does'),
});
}
return NextResponse.json({ error: 'Account not found' }, { status: 404 });
}
+43
View File
@@ -0,0 +1,43 @@
import { NextResponse } from 'next/server';
import { getClients } from '@/lib/clients';
import { getAccountConfig } from '@/lib/account-type-resolver';
import { wipeAccountData } from '@/lib/db';
/**
* POST /api/accounts/wipe-all — the bulk form of /api/accounts/[id]/wipe:
* drop every account's cached rows on every firm and rebuild them all from
* fresh Tradovate reports. One report per account, so this takes a while on
* big rosters; firms run in parallel, accounts within a firm sequentially
* (that's fetchDaysTraded's own pacing against the flaky report server).
*
* Same destructive caveat as the single wipe: cached rows older than the
* report's 28-day window cannot be refetched. The UI confirms first.
*/
export async function POST() {
const clients = [...getClients().values()];
let wiped = 0;
let failedAccounts = 0;
await Promise.all(clients.map(async (client) => {
// Forgotten, the accounts read unloaded and stay out of trading
// until the refetch below (or, if it fails, a later one) lands.
// Taken before the wipe: the resolver may un-assign an account while
// the rebuild runs, and it still counts. Accounts with no type aren't
// traded, so they aren't failures.
const ours = client.accountList.filter((account) => getAccountConfig(account.id).accountType).map((a) => a.id);
for (const account of client.accountList) {
// History first: the client reads the stored rows it keeps as the
// rebuild's yardstick before they go.
client.forgetHistory(account.id, account.name);
wipeAccountData(account.id);
wiped++;
}
await client.fetchDaysTraded();
// Counted from the result, not the fetch's own tally: a client with
// no session fetches nothing and reports no failures.
failedAccounts += ours.filter((id) => !client.historyReady(id)).length;
}));
return NextResponse.json({ ok: true, wiped, failedAccounts });
}
+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();
}
+4 -1
View File
@@ -1,5 +1,5 @@
import { claimRun } from '@/lib/db';
import { findAutomation, resolveSteps, resolveAuthSteps } from '@/lib/automations';
import { findAutomation, resolveSteps, resolveGuards, resolveAuthSteps } from '@/lib/automations';
import { corsJson, corsPreflight } from '../../cors';
/** The Python runner polls this. Returns the run plus the steps to execute, so
@@ -20,6 +20,9 @@ export async function POST() {
firm: found.firm.label,
label: found.automation.label,
steps: resolveSteps(found.firm, found.automation, safeInputs(row.inputs)),
guards: resolveGuards(found.firm),
// Pre-0.20 runners know nothing of guards; keep feeding them the
// old shape until they are restarted.
authSteps: resolveAuthSteps(found.firm),
},
});
+68
View File
@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSetting } from '@/lib/db';
import { applyBackup, fetchBackup, fetchBackupList } from '@/lib/backup-restore';
/**
* Restore-from-master for the settings page.
*
* The browser talks to this route and this route talks to the master with the
* machine's own stored master_dashboard_url + master_backup_token, so the
* token never reaches the browser.
*
* GET — the available backups, for the picker.
* POST { id } — pull that backup and apply it, adopting its identity.
* The process must be restarted afterwards; the response says so.
*/
function config(): { masterUrl: string; token: string } | NextResponse {
const masterUrl = getSetting('master_dashboard_url');
const token = getSetting('master_backup_token');
if (!masterUrl) return NextResponse.json({ error: 'master_dashboard_url is not set' }, { status: 400 });
if (!token) return NextResponse.json({ error: 'master_backup_token is not set' }, { status: 400 });
return { masterUrl, token };
}
export async function GET() {
const cfg = config();
if (cfg instanceof NextResponse) return cfg;
try {
const backups = await fetchBackupList(cfg.masterUrl, cfg.token);
return NextResponse.json({ backups });
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error }, { status: 502 });
}
}
export async function POST(req: NextRequest) {
const cfg = config();
if (cfg instanceof NextResponse) return cfg;
let id = '';
try {
const body = await req.json();
id = typeof body?.id === 'string' ? body.id.trim() : '';
} catch { /* falls through to the check below */ }
if (!id) return NextResponse.json({ error: 'id is required' }, { status: 400 });
try {
const payload = await fetchBackup(cfg.masterUrl, cfg.token, id);
const restored = applyBackup(payload);
return NextResponse.json({
ok: true,
restored,
savedAt: payload.savedAt,
adopted: {
name: payload.settings.instance_name ?? '',
id: payload.settings.instance_id ?? '',
},
// Live platform clients, the master link's hello and the proxy all
// predate the restore — a restart is the only honest way in.
restartRequired: true,
});
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error }, { status: 502 });
}
}
+54 -3
View File
@@ -1,12 +1,60 @@
import { NextResponse } from 'next/server';
import { getClients, resetClients } from '@/lib/clients';
import { getTradableFirms } from '@/lib/firm-catalog';
import axios from 'axios';
import { getAccountConfig } from '@/lib/account-type-resolver';
import { combineCmeSessions, computeDayPlan, resolveEffectiveConfig, currentTradeDateCT } from '@/lib/trading-logic';
import axios from '@/lib/http-client';
export async function GET() {
/**
* Per-account snapshot of everything computeDayPlan sees, plus the book-balance
* check the state route warns on: day-start equity (equityProfit − realizedPnL)
* must equal priorProfit + sum(prior days). A large `drift` means a session is
* counted in both the prior-day list and realizedPnL — the daily-target inflation
* bug. Filter with ?account=<substring> (case-insensitive). Read-only.
*/
function dayPlanDiag(client: any, acc: { id: number; name: string }, tradeDate: string) {
const cfg = getAccountConfig(acc.id).accountType;
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
if (!cfg) return { tradeDate, note: 'no account type assigned', cash, rawDailyPnL: client.dailyPnL[acc.id] ?? [] };
const priorProfit = client.priorProfit?.[acc.id] ?? 0;
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
const combined = combineCmeSessions(cfg.combineCmeHolidays === true, client.dailyPnL[acc.id] ?? [], tradeDate, cash.realizedPnL);
const effective = resolveEffectiveConfig(cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, priorProfit, allFundTxns, cfg.minDayPnL);
const equityProfit = cash.amount - cfg.accountSize;
const plan = computeDayPlan(acc.id, combined.tradeDate, effective.profitTarget, effective.consistency, combined.dailyPnL, effective.minDayPnL, effective.minTradingDays, equityProfit, combined.realizedPnL, cfg.splitEnabled, effective.drain);
const priorDaysSum = combined.dailyPnL.filter((d) => d.date !== combined.tradeDate).reduce((s, d) => s + d.pnl, 0);
const dayStartEquity = Math.round((equityProfit - combined.realizedPnL) * 100) / 100;
const expectedDayStart = Math.round((priorProfit + priorDaysSum) * 100) / 100;
return {
tradeDate: combined.tradeDate,
drift: Math.round((dayStartEquity - expectedDayStart) * 100) / 100,
dayStartEquity,
expectedDayStart,
equityProfit,
realizedPnL: combined.realizedPnL,
priorProfit,
priorDaysSum: Math.round(priorDaysSum * 100) / 100,
dayTarget: plan?.dayTarget ?? null,
remainingToday: plan?.remainingToday ?? null,
path: plan?.path ?? null,
drain: effective.drain,
consistency: effective.consistency,
hasTodayRow: combined.dailyPnL.some((d) => d.date === combined.tradeDate),
combineCmeHolidays: cfg.combineCmeHolidays === true,
combinedDailyPnL: combined.dailyPnL,
rawDailyPnL: client.dailyPnL[acc.id] ?? [],
};
}
export async function GET(req: Request) {
try {
const clients = getClients();
const firms = getTradableFirms();
const tradeDate = currentTradeDateCT();
const nameFilter = (new URL(req.url).searchParams.get('account') ?? '').toLowerCase();
const debug = firms.map((firm) => {
const client = clients.get(firm.id);
@@ -20,13 +68,16 @@ export async function GET() {
fetchDaysComplete: (client as any).fetchDaysComplete ?? 'n/a (old instance)',
lastFetchErrors: (client as any).lastFetchErrors ?? {},
lastFetchRaw: (client as any).lastFetchRaw ?? {},
accounts: client.accountList.map((acc) => ({
accounts: client.accountList
.filter((acc) => !nameFilter || acc.name.toLowerCase().includes(nameFilter))
.map((acc) => ({
id: acc.id,
name: acc.name,
cash: client.accountCashBalances[acc.id] ?? null,
daysTraded: client.daysTraded[acc.id] ?? '(not set)',
dailyPnLEntries: (client.dailyPnL[acc.id] ?? []).length,
dailyPnL: client.dailyPnL[acc.id] ?? [],
dayPlan: dayPlanDiag(client, acc, tradeDate),
})),
recentEntityEvents: client.recentEntityEvents.slice(-10).map((e) => ({
ts: new Date(e.ts).toISOString(),
+30 -8
View File
@@ -1,30 +1,51 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCachedFirm } from '@/lib/firm-catalog';
import { getCachedFirm, hasPlatformCredentials } from '@/lib/firm-catalog';
import { setCredentials, clearCredentials } from '@/lib/db';
import { reconcileClients, removeClient } from '@/lib/clients';
import { scheduleBackupPush } from '@/lib/backup-sync';
type Params = { params: Promise<{ id: string }> };
/** The Tradovate login for one firm. This is the only firm data this machine
* owns — everything else comes from the master catalog, and these credentials
* never go the other way. */
/** The platform login for one firm. This is the only firm data this machine
* owns — everything else comes from the master catalog. The one path these
* credentials travel is the encrypted, bearer-gated backup to the master
* (lib/backup-sync.ts); they are still absent from the catalog and the
* control socket.
*
* Tradovate and DXTrade firms need username + password (DXTrade's server URL
* 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. 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;
const firm = getCachedFirm(id);
if (!firm) return NextResponse.json({ error: 'Firm not found' }, { status: 404 });
const body = await req.json() as { username?: string; password?: string };
const body = await req.json() as { username?: string; password?: string; apiKey?: string };
const username = body.username?.trim() ?? '';
const password = body.password?.trim() ?? '';
if (!username || !password) {
return NextResponse.json({ error: 'username and password are required' }, { status: 400 });
const apiKey = body.apiKey?.trim() ?? '';
if (!hasPlatformCredentials(firm.platform, { username, password, apiKey })) {
const error = firm.platform === 'topstepx'
? 'apiKey is required for a TopstepX firm'
: firm.platform === 'dxtrade'
? 'username and password are required for a DXtrade firm'
: 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 });
}
try {
setCredentials(id, firm.name, username, password);
setCredentials(id, firm.name, username, password, apiKey);
// Picks up the new login immediately rather than at the next restart.
reconcileClients();
scheduleBackupPush();
return NextResponse.json({ ok: true });
} catch (err) {
console.error('[PUT /api/firms/:id/credentials]', err);
@@ -36,5 +57,6 @@ export async function DELETE(_req: NextRequest, { params }: Params) {
const { id } = await params;
const cleared = clearCredentials(id);
removeClient(id);
scheduleBackupPush();
return NextResponse.json({ ok: cleared });
}
+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,
+9
View File
@@ -1,4 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCachedFirm } from '@/lib/firm-catalog';
import { firmInstruments, isFuturesFirm } from '@/lib/instruments';
import { getClients } from '@/lib/clients';
export async function POST(
@@ -26,6 +28,13 @@ export async function POST(
const client = clients.get(firmId);
if (!client) return NextResponse.json({ error: 'Client not found for firm' }, { status: 404 });
// A futures firm trades only what its instrument list (master Firms
// page) names.
const firm = getCachedFirm(firmId);
if (firm && isFuturesFirm(firm) && !firmInstruments(firm).includes(symbol)) {
return NextResponse.json({ error: `${symbol} isn't on ${firm.name}'s instrument list` }, { status: 400 });
}
// 1. Find the active contract chosen by the shared resolver
const contract = await client.findFrontMonthContract(symbol);
if (!contract) {
+9 -3
View File
@@ -1,5 +1,6 @@
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';
/** One firm from the cached catalog. Read-only: firms and their account types
@@ -22,8 +23,13 @@ export async function GET(
firm: firm.name,
username: creds?.username ?? '',
hasCredentials: !!creds,
bannedSymbols: firm.bannedSymbols,
accounts: firm.accountTypes,
instruments: firmInstruments(firm),
instrumentsSource: instrumentsSource(firm),
// 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,
});
}
+24 -4
View File
@@ -1,7 +1,13 @@
import { NextResponse } from 'next/server';
import { getCachedCatalog } from '@/lib/firm-catalog';
import { firmInstruments, instrumentsSource } from '@/lib/instruments';
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
@@ -13,17 +19,23 @@ 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 {
const result: FirmConfig[] = getCachedCatalog().map((firm) => {
const creds = getCredentials(firm.id);
const hasCredentials = hasPlatformCredentials(firm.platform, creds);
return {
id: firm.id,
firm: firm.name,
platform: firm.platform,
username: creds?.username ?? '',
hasCredentials: !!creds,
bannedSymbols: firm.bannedSymbols,
hasCredentials,
instruments: firmInstruments(firm),
instrumentsSource: instrumentsSource(firm),
accounts: firm.accountTypes.map((a) => ({
id: a.id,
label: a.label,
@@ -36,7 +48,15 @@ export async function GET() {
maxLoss: a.maxLoss,
maxPositionSize: a.maxPositionSize,
targetSameEquity: a.targetSameEquity,
splitEnabled: a.splitEnabled,
minLegs: a.minLegs,
firstDayStop: a.firstDayStop,
minDayStop: a.minDayStop,
combineCmeHolidays: a.combineCmeHolidays,
withdrawalStages: a.withdrawalStages,
dailyLossLimit: a.dailyLossLimit ?? 0,
tradesEveryDay: a.tradesEveryDay === true,
kept: keptWindows(a.id),
})),
catalogUpdatedAt: firm.updatedAt,
};
+72
View File
@@ -0,0 +1,72 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { NextResponse } from 'next/server';
import { getClients } from '@/lib/clients';
import { getTradableFirms } from '@/lib/firm-catalog';
import { getSchedulerHealth, getSchedulerStatus } from '@/lib/auto-trade';
import { getBuildVersion } from '@/lib/version';
import { errorMessage, isClientReady } from '@/lib/platform-client';
// Liveness has to be computed now, never served from a cache.
export const dynamic = 'force-dynamic';
/** A one-line marker file, or null when it isn't there. */
function readMarker(...segments: string[]): string | null {
try {
return readFileSync(path.join(process.cwd(), ...segments), 'utf8').trim() || null;
} catch {
return null;
}
}
/**
* GET /api/health — is this instance able to trade, and if not, why.
*
* The numbers to read before restarting a broken box, and what start-all
* prints after boot. Always 200 when the handler runs: `ok` in the body is the
* verdict (every tradable firm that has a client is synced, and synced
* recently), so a caller can tell "unhealthy" from "not answering".
*
* Calls getClients(), so hitting it also boots an instance that somehow
* hasn't been.
*
* Built field by field on purpose — firm rows carry passwords and API keys,
* and nothing here may echo a credential or a token.
*/
export async function GET() {
try {
const now = Date.now();
const clients = getClients();
const entries = getTradableFirms().map((firm) => {
const base = { firmId: firm.id, firm: firm.name, platform: firm.platform };
const client = clients.get(firm.id);
if (!client) return { ...base, hasClient: false, ready: false, accounts: 0 };
const ready = isClientReady(client, now);
const accounts = client.accountList.length;
try {
return { ...base, hasClient: true, ready, accounts, ...client.health() };
} catch (err) {
// One client's broken health() must not hide the rest.
return { ...base, hasClient: true, ready, accounts, healthError: errorMessage(err) };
}
});
return NextResponse.json({
ok: entries.every((e) => !e.hasClient || e.ready),
now,
uptimeSec: Math.round(process.uptime()),
version: getBuildVersion(),
// Read from disk on every call: the build .next holds now (the
// running one, unless a build landed since this process started),
// and the commit the updater last finished building.
buildId: readMarker('.next', 'BUILD_ID'),
builtCommit: readMarker('scripts', '.built-commit'),
scheduler: { ...getSchedulerStatus(), ...getSchedulerHealth() },
clients: entries,
});
} catch (err) {
return NextResponse.json({ ok: false, error: errorMessage(err) }, { status: 500 });
}
}
+3 -3
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getClients } from '@/lib/clients';
import { getInstruments } from '@/lib/db';
import { futuresRootsHere } from '@/lib/instruments';
import { resolveContracts, getAllCachedContracts } from '@/lib/contract-resolver';
/**
@@ -15,7 +15,7 @@ export async function GET() {
* POST /api/instruments/contracts
* Triggers a fresh resolve via Tradovate + Yahoo Finance.
* Body (optional): { symbols: string[] } — when provided, resolves only those symbols.
* Otherwise resolves all enabled instruments.
* Otherwise resolves every root this machine's futures firms list.
*/
export async function POST(req: NextRequest) {
try {
@@ -35,7 +35,7 @@ export async function POST(req: NextRequest) {
const symbols = requestedSymbols?.length
? requestedSymbols
: getInstruments().filter((i) => i.enabled).map((i) => i.symbol);
: futuresRootsHere();
const resolved = await resolveContracts(symbols, accessToken);
return NextResponse.json(resolved);
+37 -4
View File
@@ -1,6 +1,39 @@
import { NextResponse } from 'next/server';
import { getInstruments } from '@/lib/db';
import { NextRequest, NextResponse } from 'next/server';
import { getCachedCatalog } from '@/lib/firm-catalog';
import {
ensureMarket, firmInstruments, futuresRootsHere, instrumentStatsFor, instrumentsSource, isFuturesFirm, maxEntrySpread, simulateDraws,
} from '@/lib/instruments';
import { quoteStatus } from '@/lib/live-quotes';
import { CME_ROOTS } from '@/lib/instrument-specs';
export async function GET() {
return NextResponse.json(getInstruments());
export const dynamic = 'force-dynamic';
/** The leg the overview sizes holds for: $1,000, stopless, one contract. */
const EXAMPLE_LEG = { leg: 1000, stopDollars: Infinity, maxPositionSize: 0 };
/**
* GET /api/instruments — what each firm trades (the master's lists) and, for
* futures firms, how the Auto draw sees every instrument right now: its
* spread (live off the quote feed, else a tick), one contract's daily move,
* the hold of a $1,000 leg, the chance. `quotes` is the feed itself and
* `maxEntrySpreadDollars` the master's entry limit. ?simulate=N adds N draws
* from the real draw code — no orders.
*/
export async function GET(req: NextRequest) {
await ensureMarket(CME_ROOTS, 8_000);
const n = Math.min(100_000, Math.max(0, Math.floor(Number(req.nextUrl.searchParams.get('simulate')) || 0)));
const firms = getCachedCatalog().map((firm) => {
const instruments = firmInstruments(firm);
const futures = isFuturesFirm(firm);
return {
id: firm.id,
name: firm.name,
platform: firm.platform,
source: instrumentsSource(firm),
instruments,
...(futures ? { stats: instrumentStatsFor(firm, { ...EXAMPLE_LEG, account: 'a $1,000 leg' }) } : {}),
...(futures && n > 0 ? { simulated: simulateDraws(instruments, EXAMPLE_LEG, n) } : {}),
};
});
return NextResponse.json({ roots: futuresRootsHere(), maxEntrySpreadDollars: maxEntrySpread(), quotes: quoteStatus(), firms });
}
+20 -1
View File
@@ -1,7 +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'] 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() {
@@ -12,6 +14,10 @@ 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);
}
@@ -19,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];
@@ -27,6 +42,10 @@ export async function PATCH(req: NextRequest) {
}
}
// The edit should reach the master's backup within seconds, not at the
// next poll.
scheduleBackupPush();
return NextResponse.json({ ok: true });
} catch (err: any) {
return NextResponse.json({ error: err?.message ?? 'Failed to save settings' }, { status: 500 });
+239 -31
View File
@@ -1,15 +1,24 @@
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 { computeDayPlan, resolveEffectiveConfig, currentTradeDateCT, LEG_COMPLETION_EPSILON } from '@/lib/trading-logic';
import { historyFresh, type PlatformContract } from '@/lib/platform-client';
import { rankNextLeg, dayLossRoom } from '@/lib/leg-estimate';
import { obfuscationDayBooked } from '@/lib/auto-trade';
import type { AccountState } from '@/types';
import {
combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, tradeDateFor,
obfuscationStopDollars, dllStopDollars, botStopsActive, stageInputsFor, stagePayouts,
maxLossRoom, lastStand as isLastStand, shortestNextLeg,
LEG_COMPLETION_EPSILON,
} from '@/lib/trading-logic';
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);
@@ -20,10 +29,16 @@ 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 };
const dailyPnL: { date: string; pnl: number }[] = client.dailyPnL[acc.id] ?? [];
const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0);
// One lookup against the assignment table — no prefix matching
// here any more. An unassigned account gets no targets, which
@@ -31,17 +46,46 @@ export async function GET() {
const resolved = getAccountConfig(acc.id);
const cfg = resolved.accountType;
const inferredAccountSize = getInferredSize(acc.id, client);
// Days traded counts only days that hit minDayPnL (when set) — that's what
// the firm requires toward the min trading day rule.
const daysTraded: number = cfg && cfg.minDayPnL > 0
? dailyPnL.filter((d) => d.pnl >= cfg.minDayPnL).length
: (client.daysTraded[acc.id] ?? 0);
// A combining firm records holiday sessions with the next full
// trading day — everything below reads the account through that
// view (the identity view when the account type doesn't combine).
const combined = combineCmeSessions(
cfg?.combineCmeHolidays === true,
client.dailyPnL[acc.id] ?? [],
tradeDate,
cash.realizedPnL,
);
const dailyPnL = combined.dailyPnL;
const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0);
const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0;
const isDead = autoLiqThreshold > 0 && cash.amount <= autoLiqThreshold;
// 0 = no floor; negative floors are reconstructed TopstepX MLLs.
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(
cfg.profitTarget,
cfg.consistency,
cfg.minTradingDays,
cfg.targetSameEquity,
cfg.withdrawalStages,
priorProfit,
allFundTxns,
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.
const daysTraded: number = effective && effective.minDayPnL > 0
? dailyPnL.filter((d) => d.pnl >= effective.minDayPnL).length
: combined.daysTraded;
// Hide initial funding (amount === accountSize) from display
const fundingAmount = cfg?.accountSize ?? inferredAccountSize;
const displayFundTxns = fundingAmount !== null
@@ -52,64 +96,225 @@ 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;
let effectiveProfitTarget: number | null = null;
if (cfg && !isDead) {
const effective = resolveEffectiveConfig(
cfg.profitTarget,
cfg.consistency,
cfg.minTradingDays,
cfg.targetSameEquity,
cfg.withdrawalStages,
priorProfit,
allFundTxns
);
let nextStop: number | null = null;
let dllRoom: number | null = null;
let holdEstimate: AccountState['holdEstimate'] = null;
let lastStandNext = false;
if (cfg && effective && !isDead) {
const equityProfit = cash.amount - cfg.accountSize;
const plan = computeDayPlan(
acc.id, tradeDate, effective.profitTarget, effective.consistency,
dailyPnL, cfg.minDayPnL, effective.minTradingDays, equityProfit, cash.realizedPnL
acc.id, combined.tradeDate, effective.profitTarget, effective.consistency,
dailyPnL, effective.minDayPnL, effective.minTradingDays, equityProfit, combined.realizedPnL,
cfg.splitEnabled, effective.drain
);
dailyTarget = plan ? { amount: plan.dayTarget, path: plan.path } : null;
remainingToday = plan?.remainingToday ?? null;
// The stop the next trade would carry, from the same helpers
// the fire path uses: the obfuscation stop (day 1 keeps the
// day's net floor at −$1,000, min days stop at 2× the leg,
// 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 = 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],
});
// 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 && !dayBooked && plan.remainingToday > 0) {
const best = rankNextLeg({
client, accountId: acc.id, cfg: { ...cfg, dailyLossLimit: dll }, firm: f, leg: plan.remainingToday, dllRoom,
obfuscationDollars: obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday),
cash, tradeDate: combined.tradeDate,
})[0];
if (best) {
holdEstimate = {
hours: best.holdHours,
symbol: best.symbol,
notional: best.notional,
multiplier: best.multiplier,
volatilityPerDay: best.volatilityPerDay,
winProbability: best.winProbability,
winProbabilityNoFees: best.winProbabilityNoFees,
swapReserve: best.swapReserve,
leverage: best.leverage,
cost: best.cost,
};
}
}
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, on its instrument,
// where it's known.
const nextContract = holdEstimate
? (client as { lastCandidates?: PlatformContract[] }).lastCandidates?.find((c) => String(c.id) === holdEstimate!.symbol)
: undefined;
const hard = dllRoom !== null && !lastStandNext
? dllStopDollars({
room: dllRoom, dailyLossLimit: dll, entryFee: 0, commissionPct: 0, notional: holdEstimate?.notional,
slippageFraction: nextContract?.stopSlippageFraction ?? 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
// in total trading profit for the stage, not just profitTarget.
const qualifying = cfg.minDayPnL === 0 ? dailyPnL : dailyPnL.filter((d) => d.pnl >= cfg.minDayPnL);
const qualifying = effective.minDayPnL === 0 ? dailyPnL : dailyPnL.filter((d) => d.pnl >= effective.minDayPnL);
const maxDay = qualifying.length > 0 ? Math.max(...qualifying.map((d) => d.pnl)) : 0;
const consistencyReal = (effective.consistency > 0 && effective.consistency < 1 && maxDay > 0)
? maxDay / effective.consistency : 0;
effectiveProfitTarget = Math.max(effective.profitTarget, consistencyReal);
// ── Double-count diagnostic ───────────────────────────────
// computeDayPlan reconstructs day-start equity as
// equityProfit − realizedPnL, then treats every dailyPnL row
// NOT dated today as a prior day. Those two views must agree:
// day-start equity should equal priorProfit + sum(prior days).
// When a session's cached row is dated something other than
// today's tradeDate, that session lands in BOTH the prior-day
// list and realizedPnL — day-start equity reads too low while
// the prior sum stays high, and the daily target inflates to
// base + consistency×todayPnL (the $1,687.39 symptom). Fires
// only when the books disagree by > $50, logging the exact
// state needed to diagnose the trigger. Remove once resolved.
const priorDaysSum = dailyPnL
.filter((d) => d.date !== combined.tradeDate)
.reduce((s, d) => s + d.pnl, 0);
const dayStartEquity = equityProfit - combined.realizedPnL;
const expectedDayStart = priorProfit + priorDaysSum;
const drift = Math.round((dayStartEquity - expectedDayStart) * 100) / 100;
if (Math.abs(drift) > 50) {
console.warn('[day-plan][equity-drift] ' + JSON.stringify({
account: acc.name,
id: acc.id,
serverNow: new Date().toISOString(),
tradeDate: combined.tradeDate,
drift,
dayStartEquity,
expectedDayStart,
equityProfit,
realizedPnL: combined.realizedPnL,
priorProfit,
priorDaysSum,
dayTarget: plan?.dayTarget ?? null,
remainingToday: plan?.remainingToday ?? null,
consistency: effective.consistency,
effectiveProfitTarget,
hasTodayRow: dailyPnL.some((d) => d.date === combined.tradeDate),
combineCmeHolidays: cfg.combineCmeHolidays === true,
combinedDailyPnL: dailyPnL,
rawDailyPnL: client.dailyPnL[acc.id] ?? [],
}));
}
// Condition 1: profit target already exceeded (target=0), still need days → any activity counts
// Condition 2: target > 0 → all legs done (day's remaining target used up)
if (plan) {
targetHit =
(plan.dayTarget === 0 && Math.abs(cash.realizedPnL) > 0 && client.daysTraded[acc.id] <= effective.minTradingDays) ||
(cash.realizedPnL > 0 && plan.remainingToday <= LEG_COMPLETION_EPSILON);
(plan.dayTarget === 0 && Math.abs(combined.realizedPnL) > 0 && combined.daysTraded <= effective.minTradingDays) ||
(combined.realizedPnL > 0 && plan.remainingToday <= LEG_COMPLETION_EPSILON);
}
}
// Today finished the cycle — the reporter's check and guards,
// so this pill and the master's purple count agree.
let completed = false;
const historyLoaded = client.dailyPnL[acc.id] !== undefined
&& client.fundTransactions?.[acc.id] !== undefined;
if (cfg && effective && historyLoaded && historyFresh(client, acc) && !client.positions[acc.id]) {
completed = cycleCompletion({
accountId: acc.id,
rawTradeDate: tradeDate,
client,
cycleDailyPnL: client.dailyPnL[acc.id],
rawRealizedPnL: cash.realizedPnL,
combineCmeHolidays: cfg.combineCmeHolidays,
tradesEveryDay: cfg.tradesEveryDay === true,
effective,
equityProfit: cash.amount - cfg.accountSize,
splitEnabled: cfg.splitEnabled,
}) === 'today';
}
return {
id: acc.id,
name: acc.name,
active: acc.active,
amount: cash.amount,
realizedPnL: cash.realizedPnL,
// 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 && !!effective && botStopsActive(cfg, effective)
&& acc.active && !client.positions[acc.id]
&& combined.realizedPnL <= -25,
positionDirection: client.positions[acc.id]
? (client.positions[acc.id].netPos > 0 ? 'long' as const : 'short' as const)
: null,
autoLiqThreshold,
totalProfit,
targetHit,
completed,
stage,
// Current stage is a drain stage — dashboards label it "Stage N – Drain".
stageDrain: effective?.drain ?? false,
dailyTarget,
nextStop,
dllRoom,
holdEstimate,
lastStand: lastStandNext,
remainingToday,
effectiveProfitTarget,
effectiveDailyLossLimit: effective?.dailyLossLimit ?? null,
effectiveMinTradingDays: effective?.minTradingDays ?? null,
dailyPnL,
fullDailyPnL: client.fullDailyPnL?.[acc.id] ?? loadDailyPnL(acc.id),
fullDailyPnL: combineCmeSessions(
cfg?.combineCmeHolidays === true,
client.fullDailyPnL?.[acc.id] ?? loadDailyPnL(acc.id),
tradeDate,
0,
).dailyPnL,
fundTransactions: displayFundTxns,
accountTypeId: resolved.accountTypeId,
accountTypeLabel: cfg?.label ?? null,
@@ -117,6 +322,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 };
+13 -8
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { runTrade, startScheduler } from '@/lib/auto-trade';
import { POINT_VALUES } from '@/lib/trading-logic';
import { errorMessage } from '@/lib/platform-client';
import { CME_SPECS } from '@/lib/instrument-specs';
export async function POST(req: NextRequest) {
try {
@@ -11,19 +12,23 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Missing required fields: action, symbol' }, { status: 400 });
}
if (symbol !== 'Auto' && !POINT_VALUES[symbol]) {
if (symbol !== 'Auto' && !CME_SPECS[symbol]) {
return NextResponse.json({ error: `Unknown symbol: ${symbol}` }, { status: 400 });
}
// (Re)start the scheduler with this action + symbol first, so a batch
// that throws or runs long can never leave it unstarted. It can't
// double-fire against the batch below: the tick waits for flat
// positions, skips while this batch holds the trade mutex, and never
// picks an account whose order is still in flight.
startScheduler(action, symbol, stopAfterAll ?? false);
// Execute trade immediately for all eligible accounts
const results = await runTrade(action, symbol);
// (Re)start the scheduler with this action + symbol
startScheduler(action, symbol, stopAfterAll ?? false);
return NextResponse.json(results);
} catch (err: any) {
console.error('[POST /api/trade]', err);
return NextResponse.json({ error: err?.message ?? 'Trade failed' }, { status: 500 });
} catch (err: unknown) {
console.error(`[POST /api/trade] ${errorMessage(err)}`);
return NextResponse.json({ error: errorMessage(err) || 'Trade failed' }, { status: 500 });
}
}
+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>
);
}
+10 -5
View File
@@ -137,20 +137,25 @@ export default function FirmSettingsPage() {
</div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mt-10 mb-3">
Banned Symbols
Instruments
</h2>
<div className="bg-white border border-slate-200 rounded-xl p-4 shadow-sm">
{!firm || firm.bannedSymbols.length === 0 ? (
<span className="text-sm text-slate-400 italic">None</span>
{!firm || firm.instruments.length === 0 ? (
<span className="text-sm text-slate-400 italic">None — this firm won&apos;t trade</span>
) : (
<div className="flex flex-wrap gap-1.5">
{firm.bannedSymbols.map((sym) => (
<span key={sym} className="px-2 py-1 rounded-md text-xs font-mono font-semibold bg-red-100 text-red-700">
{firm.instruments.map((sym) => (
<span key={sym} className="px-2 py-1 rounded-md text-xs font-mono font-semibold bg-slate-100 text-slate-700">
{sym}
</span>
))}
</div>
)}
<p className="mt-2 text-xs text-slate-400">
{firm?.instrumentsSource === 'legacy'
? 'The master doesn\'t send instrument lists yet: this machine\'s enabled instruments, less the firm\'s banned symbols.'
: 'Edited on the master\'s Firms page.'}
</p>
</div>
</div>
+380 -70
View File
@@ -4,27 +4,36 @@ 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';
function statusRank(account: AccountState): number {
if (isAccountDead(account)) return 0;
if (account.completed) return 5; // Completed — finished its cycle today
if (!account.active) return 1;
if (!account.positionDirection) {
if (account.targetHit) return 4; // Target Hit — most accomplished
if (account.targetHit) return 4; // Target Hit — next to Completed
return 2; // Flat
}
return 3; // In Trade
}
/** Account names in counting order: XXX2 before XXX10, not after it. */
function byName(a: AccountState, b: AccountState): number {
return a.name.localeCompare(b.name, undefined, { numeric: true });
}
function fmt(value: number) {
return value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
/** Returns true when an account's balance has breached Tradovate's auto-liquidation floor. */
function isAccountDead(account: AccountState): boolean {
return account.autoLiqThreshold > 0 && account.amount <= account.autoLiqThreshold;
// 0 = no floor; negative floors are reconstructed TopstepX MLLs.
return account.autoLiqThreshold !== 0 && account.amount <= account.autoLiqThreshold;
}
function SettingsIcon() {
@@ -94,7 +103,7 @@ function maskName(name: string, privacy: boolean): string {
function SortHeader({ label, col, sortKey, sortDir, onSort }: {
label: string;
col: SortKey;
sortKey: SortKey | null;
sortKey: SortKey;
sortDir: SortDir;
onSort: (col: SortKey) => void;
}) {
@@ -186,26 +195,122 @@ function AccountTypeCell({ account, firm, onAssigned }: {
);
}
/** Inline credential entry, shown to the right of a firm that has none. */
/** What a firm's saved login is called in the dashboard's prompts. */
function credentialNoun(platform: FirmConfig['platform']): string {
switch (platform) {
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); 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 }) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
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('');
async function save() {
if (!username.trim() || !password.trim()) return;
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({ username, password }),
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('');
const [apiKey, setApiKey] = useState('');
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const canSave = isTopstepX
? apiKey.trim().length > 0
: username.trim().length > 0 && password.trim().length > 0;
async function save() {
if (!canSave) return;
setSaving(true);
setError('');
try {
const res = await fetch(`/api/firms/${firm.id}/credentials`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(isTopstepX ? { username, apiKey } : { username, password }),
});
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? 'Failed');
setUsername('');
setPassword('');
setApiKey('');
onSaved();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed');
@@ -220,21 +325,32 @@ function CredentialForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => vo
<div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
<input
className={cls}
placeholder="Tradovate username"
placeholder={isTopstepX ? 'Username (optional)' : firm.platform === 'dxtrade' ? 'DXtrade username' : 'Tradovate username'}
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<input
className={cls}
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') void save(); }}
/>
{isTopstepX ? (
<input
className={`${cls} w-52`}
type="password"
placeholder="TopstepX API key"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') void save(); }}
/>
) : (
<input
className={cls}
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') void save(); }}
/>
)}
<button
onClick={save}
disabled={saving || !username.trim() || !password.trim()}
disabled={saving || !canSave}
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'}
@@ -244,12 +360,80 @@ function CredentialForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => vo
);
}
function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
/** Bulk form of the detail page's wipe: drops every account's cached rows on
* every firm and refetches them all. One report per account, so the busy state
* can run for minutes on a big roster. Same 28-day-window caveat, so the
* confirm step spells it out. */
function WipeAllButton() {
const [confirming, setConfirming] = useState(false);
const [busy, setBusy] = useState(false);
const [result, setResult] = useState('');
async function wipeAll() {
setBusy(true);
setResult('');
try {
const res = await fetch('/api/accounts/wipe-all', { method: 'POST' });
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error ?? 'Wipe failed');
setResult(body.failedAccounts > 0
? `Rebuilt ${body.wiped}, ${body.failedAccounts} refetch failure(s)`
: `Rebuilt ${body.wiped} account(s)`);
// No refresh hook needed — the dashboard polls /api/state every 5s.
setConfirming(false);
} catch (err) {
setResult(err instanceof Error ? err.message : 'Wipe failed');
} finally {
setBusy(false);
}
}
if (confirming) {
return (
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500 max-w-64 text-right">
Wipe every account&apos;s cached data and refetch? Days older than the 28-day report window are lost for good.
</span>
<button
onClick={wipeAll}
disabled={busy}
className="px-4 py-2 bg-red-100 hover:bg-red-200 disabled:opacity-50 text-red-700 text-sm font-semibold rounded-lg transition-colors whitespace-nowrap"
>
{busy ? 'Refetching…' : 'Wipe all'}
</button>
<button
onClick={() => setConfirming(false)}
disabled={busy}
className="px-4 py-2 bg-white hover:bg-slate-100 disabled:opacity-50 text-slate-600 text-sm font-semibold rounded-lg border border-slate-200 transition-colors"
>
Cancel
</button>
</div>
);
}
return (
<div className="flex items-center gap-2">
{result && <span className="text-xs text-slate-400">{result}</span>}
<button
onClick={() => setConfirming(true)}
className="px-4 py-2 bg-white text-slate-600 border border-slate-200 hover:text-red-600 hover:bg-red-50 hover:border-red-200 text-sm font-semibold rounded-lg transition-colors whitespace-nowrap"
title="Delete every account's cached daily P&L, fund transactions and meta, then rebuild them from fresh Tradovate reports"
>
Wipe & recalc all
</button>
</div>
);
}
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);
@@ -261,8 +445,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>
@@ -273,18 +458,43 @@ 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">
<div className="flex items-center gap-1.5">
<span>${(account.effectiveProfitTarget ?? cfg?.profitTarget ?? 0).toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold ${stagePillClasses(account.stage)}`}>
Stage {account.stage}
Stage {account.stage}{account.stageDrain ? ' – Drain' : ''}
</span>
</div>
{account.dailyTarget && (
<span className="text-xs text-slate-400">${fmt(account.dailyTarget.amount)} today</span>
<span className="text-xs text-slate-400">
${fmt(account.dailyTarget.amount)} today
{account.nextStop !== null && (
<span className="text-amber-600" title="The next trade's stop — dollars of price movement it may lose before booking the day (fees come on top; the daily-loss stop already reserves for them)"> · stop ${fmt(account.nextStop)}</span>
)}
{account.lastStand && (
<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 (0.02% on Vest) — is never spent."> · DLL room ${fmt(account.dllRoom)}</span>
)}
{account.holdEstimate && (
<span
className="text-slate-500"
title={`Next leg on ${account.holdEstimate.symbol}: $${Math.round(account.holdEstimate.notional).toLocaleString()} notional, ${account.holdEstimate.multiplier}× the account`
+ (account.holdEstimate.leverage !== null ? ` (${account.holdEstimate.leverage}× max)` : '')
+ ` at a measured volatility of ${(account.holdEstimate.volatilityPerDay * 100).toFixed(2)}%/day over the past week`
+ (account.holdEstimate.winProbability !== null ? `, wins ~${Math.round(account.holdEstimate.winProbability * 100)}% of the time` : '')
+ (account.holdEstimate.winProbabilityNoFees !== null ? ` (~${Math.round(account.holdEstimate.winProbabilityNoFees * 100)}% with no commission, spread or swap)` : '')
+ (account.holdEstimate.swapReserve > 0 ? `; its take-profit carries ~$${fmt(account.holdEstimate.swapReserve)} of expected swap` : '')
+ `. Expected cost ~$${fmt(account.holdEstimate.cost)} (spread, commissions, swap) — the cheapest candidate that reaches the target hold`}
>
{' · hold ~'}{account.holdEstimate.hours < 48 ? `${Math.round(account.holdEstimate.hours)}h` : `${(account.holdEstimate.hours / 24).toFixed(1)}d`}
</span>
)}
</span>
)}
</div>
</td>
@@ -292,13 +502,17 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
<div className="flex items-center justify-between">
{dead
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-800 text-white">Dead</span>
: !account.active
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-700">Hit DLL</span>
: account.positionDirection
? <span className={`inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold ${account.positionDirection === 'long' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>{account.positionDirection === 'long' ? 'Long' : 'Short'}</span>
: account.targetHit
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-700">Target Hit</span>
: <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-100 text-slate-500">Flat</span>}
: account.completed
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-purple-100 text-purple-700" title="Finished its cycle today — target, consistency and min days met">Completed</span>
: !account.active
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-red-100 text-red-700">Hit DLL</span>
: account.positionDirection
? <span className={`inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold ${account.positionDirection === 'long' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>{account.positionDirection === 'long' ? 'Long' : 'Short'}</span>
: account.personalDll
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-700" title="Booked the day red via its own stop (obfuscation or daily-loss) — trading resumes next session">Personal DLL</span>
: account.targetHit
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-700">Target Hit</span>
: <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-100 text-slate-500">Flat</span>}
<Link
href={`/accounts/${account.id}`}
className="text-slate-400 hover:text-slate-600 hover:bg-slate-200 p-1 rounded-md transition-colors inline-flex"
@@ -313,17 +527,19 @@ 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;
privacy: boolean;
sortKey: SortKey | null;
sortKey: SortKey;
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;
@@ -343,20 +559,22 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
}
}
const sortedAccounts = sortKey === null ? state.accounts : [...state.accounts].sort((a, b) => {
// Ties on any other column fall back to name order, so equal rows don't
// shuffle with the broker's list between syncs.
const sortedAccounts = [...state.accounts].sort((a, b) => {
const dir = sortDir === 'asc' ? 1 : -1;
switch (sortKey) {
case 'name': return dir * a.name.localeCompare(b.name);
case 'balance': return dir * (a.amount - b.amount);
case 'dayPnL': return dir * (a.realizedPnL - b.realizedPnL);
case 'daysTraded': return dir * (a.daysTraded - b.daysTraded);
case 'name': return dir * byName(a, b);
case 'balance': return dir * (a.amount - b.amount) || byName(a, b);
case 'dayPnL': return dir * (a.realizedPnL - b.realizedPnL) || byName(a, b);
case 'daysTraded': return dir * (a.daysTraded - b.daysTraded) || byName(a, b);
case 'target': {
const at = a.effectiveProfitTarget ?? a.accountType?.profitTarget ?? 0;
const bt = b.effectiveProfitTarget ?? b.accountType?.profitTarget ?? 0;
return dir * (at - bt);
return dir * (at - bt) || byName(a, b);
}
case 'status': return dir * (statusRank(a) - statusRank(b));
default: return 0;
case 'status': return dir * (statusRank(a) - statusRank(b)) || byName(a, b);
default: return byName(a, b);
}
});
@@ -371,9 +589,32 @@ 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.bannedSymbols?.includes(tradeSymbol) && (
<span className="text-xs bg-red-100 text-red-700 px-1.5 py-0.5 rounded font-semibold">
{tradeSymbol} BANNED
{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>
)}
{firm.platform === 'topstepx' && (
<span className="text-xs bg-indigo-100 text-indigo-700 px-1.5 py-0.5 rounded font-semibold">
TopstepX
</span>
)}
{firm.platform === 'dxtrade' && (
<span className="text-xs bg-emerald-100 text-emerald-700 px-1.5 py-0.5 rounded font-semibold">
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"
title="TopstepX login needs the platform username the API key was generated for — remove and re-save the credentials with it"
>
Username missing
</span>
)}
{hasCredentials && !state.connected && (
@@ -396,7 +637,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
<td colSpan={5} className="px-4 py-2.5">
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<span className="text-xs text-slate-500">
Remove the saved Tradovate login for {firm.firm}? Its accounts disappear until you enter one again.
Remove the saved {credentialNoun(firm.platform)} for {firm.firm}? Its accounts disappear until you enter one again.
</span>
<button
onClick={removeCredentials}
@@ -418,11 +659,20 @@ 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); }}
className="text-slate-400 hover:text-red-500 hover:bg-red-50 p-1 rounded-md transition-colors inline-flex"
title="Remove saved Tradovate login"
title={`Remove saved ${credentialNoun(firm.platform)}`}
>
<KeyOffIcon />
</button>
@@ -440,11 +690,19 @@ 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>
<td colSpan={7} className="px-4 py-5 pl-12 text-sm text-slate-400 italic">
{state.connected ? 'No accounts at this firm' : 'Connecting to Tradovate…'}
{state.connected ? 'No accounts at this firm' : 'Connecting…'}
</td>
</tr>
) : (
@@ -456,6 +714,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
hideDead={hideDead}
privacy={privacy}
onAssigned={onChanged}
onStateChanged={onStateChanged}
/>
))
)
@@ -480,17 +739,20 @@ export default function Home() {
const [catalog, setCatalog] = useState<{ lastSyncAt: string | null; lastError: string | null; masterUrl: string | null } | null>(null);
const [hideDead, setHideDead] = useState(false);
const [privacy, setPrivacy] = useState(false);
const [sortKey, setSortKey] = useState<SortKey | null>(null);
// Name order until a header is clicked, so XXX1 always sits above XXX2.
const [sortKey, setSortKey] = useState<SortKey>('name');
const [sortDir, setSortDir] = useState<SortDir>('asc');
// Trade controls
const [scheduler, setScheduler] = useState<SchedulerStatus>({ running: false, action: 'Buy', symbol: 'NQ', lastRun: null, intervalSeconds: 60, stopAfterAll: false });
const [scheduler, setScheduler] = useState<SchedulerStatus>({ running: false, action: 'Buy', symbol: 'Auto', lastRun: null, intervalSeconds: 60, stopAfterAll: false });
const [enabledSymbols, setEnabledSymbols] = useState<string[]>([]);
const [tradeSymbol, setTradeSymbol] = useState('');
const [tradeAction, setTradeAction] = useState<'Buy' | 'Sell' | 'Auto'>('Buy');
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) {
@@ -528,6 +790,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');
@@ -536,38 +810,28 @@ export default function Home() {
};
useEffect(() => {
// The roots this machine's futures firms list; 'Auto' draws among
// each batch firm's list and leads the dropdown.
fetch('/api/instruments')
.then((r) => r.json() as Promise<{ symbol: string; enabled: boolean }[]>)
.then((instruments) => {
const syms = instruments.filter((i) => i.enabled).map((i) => i.symbol);
setEnabledSymbols(syms);
setTradeSymbol((prev) => prev || syms[0] || 'NQ');
.then((r) => r.json() as Promise<{ roots: string[] }>)
.then(({ roots }) => {
setEnabledSymbols(roots ?? []);
setTradeSymbol((prev) => prev || 'Auto');
})
.catch(() => {});
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);
@@ -623,6 +887,7 @@ export default function Home() {
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-slate-900">AutoTrader</h1>
<div className="flex items-center gap-2">
<WipeAllButton />
{deadCount > 0 && (
<button
onClick={() => setHideDead((h) => !h)}
@@ -693,8 +958,8 @@ export default function Home() {
onChange={(e) => setTradeSymbol(e.target.value)}
className="rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-1.5 text-sm font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{enabledSymbols.map((s) => <option key={s} value={s}>{s}</option>)}
<option value="Auto">Auto</option>
{enabledSymbols.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<div className="flex rounded-lg border border-slate-200 overflow-hidden text-sm font-semibold">
<button
@@ -747,6 +1012,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
@@ -818,6 +1127,7 @@ export default function Home() {
sortDir={sortDir}
tradeSymbol={tradeSymbol}
onChanged={fetchConfig}
onStateChanged={fetchState}
/>
);
})
+301 -90
View File
@@ -3,9 +3,35 @@
import { useEffect, useState } from 'react';
import Link from 'next/link';
interface Instrument {
symbol: string;
enabled: boolean;
/** GET /api/instruments: each firm's list (from the master) and, for
* futures firms, how the Auto draw sees each instrument. */
interface InstrumentsOverview {
roots: string[];
maxEntrySpreadDollars?: number;
quotes?: { source: string | null; platform: string | null; lastQuoteAt: string | null; lastError: string | null };
firms: {
id: string;
name: string;
platform: string;
source: 'master' | 'legacy';
instruments: string[];
stats?: {
maxHoldHours: number;
quoteSource?: string | null;
sessionProgress: number | null;
instruments: {
symbol: string;
listed: boolean;
spreadDollars: number;
spreadLive?: boolean;
spreadBlocked?: boolean;
dailyMoveDollars: number | null;
holdHours: number | null;
disabled: boolean;
chance: number | null;
}[];
};
}[];
}
interface ResolvedContract {
@@ -19,10 +45,22 @@ interface AppSettings {
instance_name: string | null;
instance_id: string | null;
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;
}
interface BackupMeta {
id: string;
name: string;
savedAt: string;
sizeBytes: number;
}
export default function SettingsPage() {
const [instruments, setInstruments] = useState<Instrument[]>([]);
const [overview, setOverview] = useState<InstrumentsOverview | null>(null);
const [contracts, setContracts] = useState<Record<string, ResolvedContract | null>>({});
const [maxConcurrent, setMaxConcurrent] = useState<string>('5');
const [tradingHours, setTradingHours] = useState<string>('full_cme');
@@ -38,6 +76,13 @@ export default function SettingsPage() {
const [instanceId, setInstanceId] = useState<string>('');
const [saving, setSaving] = useState(false);
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);
const [restoreMsg, setRestoreMsg] = useState<string>('');
const fetchCatalog = () => {
fetch('/api/firm-catalog')
@@ -58,7 +103,8 @@ export default function SettingsPage() {
useEffect(() => {
fetch('/api/instruments')
.then((r) => r.json())
.then(setInstruments);
.then(setOverview)
.catch(() => {});
fetchCatalog();
@@ -80,6 +126,14 @@ export default function SettingsPage() {
if (s.instance_id != null) {
setInstanceId(s.instance_id);
}
if (s.master_backup_token != null) {
setBackupToken(s.master_backup_token);
}
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 });
});
// Load cached contracts; if cache is empty, auto-resolve
@@ -101,46 +155,67 @@ export default function SettingsPage() {
.catch(() => {});
}, []);
async function toggle(symbol: string, enabled: boolean) {
setInstruments((prev) =>
prev.map((i) => (i.symbol === symbol ? { ...i, enabled } : i))
);
await fetch(`/api/instruments/${symbol}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
});
// When enabling a symbol, auto-resolve its active contract
if (enabled) {
fetch('/api/instruments/contracts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ symbols: [symbol] }),
})
.then((r) => r.json())
.then((fresh: Record<string, ResolvedContract | null>) => {
if (!fresh.error) {
setContracts((prev) => ({ ...prev, ...fresh }));
}
})
.catch(() => {});
}
}
async function saveSettings() {
setSaving(true);
setSaved(false);
await fetch('/api/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ max_concurrent_accounts: maxConcurrent, trading_hours: tradingHours, master_dashboard_url: masterUrl, instance_name: instanceName }),
body: JSON.stringify({ max_concurrent_accounts: maxConcurrent, trading_hours: tradingHours, master_dashboard_url: masterUrl, instance_name: instanceName, master_backup_token: backupToken, proxy_url: proxyUrl }),
});
setSaving(false);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}
async function loadRestoreList() {
setRestoreBusy(true);
setRestoreMsg('');
try {
const res = await fetch('/api/backup/restore');
const body = await res.json();
if (!res.ok) throw new Error(body?.error ?? `HTTP ${res.status}`);
setRestoreList(body.backups ?? []);
} catch (err) {
setRestoreMsg(err instanceof Error ? err.message : String(err));
}
setRestoreBusy(false);
}
async function restoreBackup(b: BackupMeta) {
const warning =
`Restore "${b.name}" (saved ${new Date(b.savedAt).toLocaleString()})?\n\n` +
'This OVERWRITES this machine\'s settings and firm credentials and adopts ' +
'that instance\'s identity on the master.\n\n' +
'Make sure the old machine is STOPPED first — two live instances with one ' +
'identity will fight over the connection.';
if (!window.confirm(warning)) return;
setRestoreBusy(true);
setRestoreMsg('');
try {
const res = await fetch('/api/backup/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: b.id }),
});
const body = await res.json();
if (!res.ok) throw new Error(body?.error ?? `HTTP ${res.status}`);
setRestoreMsg(
`Restored ${body.restored.settings} settings and ${body.restored.credentials} credential(s) as ` +
`"${body.adopted.name}". Restart the autotrader now (pm2 restart) to finish.`,
);
setRestoreList(null);
} catch (err) {
setRestoreMsg(err instanceof Error ? err.message : String(err));
}
setRestoreBusy(false);
}
/** >24h old backups get flagged — a dead VPS's last push may be stale. */
function isStale(savedAt: string): boolean {
return Date.now() - new Date(savedAt).getTime() > 24 * 60 * 60 * 1000;
}
return (
<div className="min-h-screen bg-slate-50 p-8">
<div className="max-w-7xl mx-auto">
@@ -271,71 +346,207 @@ export default function SettingsPage() {
{syncing ? 'Syncing…' : 'Sync now'}
</button>
</div>
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-100">
<div>
<p className="text-sm font-medium text-slate-800">Backup token</p>
<p className="text-xs text-slate-400 mt-0.5">
Backups push automatically — this token (the master&apos;s
INSTANCE_BACKUP_TOKEN) is only needed to restore one.
</p>
<p className="text-xs text-slate-400 mt-0.5">
{backupStatus.at
? `Last backed up ${new Date(backupStatus.at).toLocaleString()}`
: 'Never backed up'}
</p>
{backupStatus.error && (
<p className="text-xs text-red-600 mt-0.5">{backupStatus.error}</p>
)}
</div>
<input
type="password"
placeholder="shared token"
value={backupToken}
onChange={(e) => setBackupToken(e.target.value)}
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="px-4 py-3 border-t border-slate-100">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-800">Restore from master</p>
<p className="text-xs text-slate-400 mt-0.5">
Replace a dead VPS: pull its backup, overwrite this machine
and adopt its identity. Stop the old machine first.
</p>
</div>
<button
onClick={loadRestoreList}
disabled={restoreBusy}
className="text-sm px-3 py-1.5 rounded-lg font-medium bg-slate-100 hover:bg-slate-200 text-slate-700 disabled:opacity-50 transition-colors"
>
{restoreBusy ? 'Working…' : restoreList ? 'Refresh list' : 'List backups'}
</button>
</div>
{restoreMsg && (
<p className="text-xs mt-2 text-slate-600">{restoreMsg}</p>
)}
{restoreList && (
<div className="mt-3 border border-slate-200 rounded-lg divide-y divide-slate-100">
{restoreList.length === 0 ? (
<p className="px-3 py-2 text-xs text-slate-400 italic">No backups on the master yet</p>
) : restoreList.map((b) => (
<div key={b.id} className="flex items-center justify-between px-3 py-2">
<div>
<span className="text-sm font-mono text-slate-800">{b.name}</span>
<span className={`text-xs ml-2 ${isStale(b.savedAt) ? 'text-amber-600 font-semibold' : 'text-slate-400'}`}>
saved {new Date(b.savedAt).toLocaleString()}
{isStale(b.savedAt) ? ' — over a day old' : ''}
</span>
<span className="text-xs text-slate-300 ml-2 font-mono">{b.id.slice(0, 8)}</span>
</div>
<button
onClick={() => restoreBackup(b)}
disabled={restoreBusy}
className="text-sm px-3 py-1 rounded-lg font-medium bg-red-50 hover:bg-red-100 text-red-700 disabled:opacity-50 transition-colors"
>
Restore
</button>
</div>
))}
</div>
)}
</div>
</div>
{/* ── Network ── */}
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
Network
</h2>
<div className="bg-white border border-slate-200 rounded-xl shadow-sm mb-8">
<div className="flex items-center justify-between px-4 py-3">
<div>
<p className="text-sm font-medium text-slate-800">Broker proxy</p>
<p className="text-xs text-slate-400 mt-0.5">
Routes broker traffic (TopstepX, Tradovate, quotes) through a proxy —
http://, https:// or socks5:// with optional user:pass. Use socks5h://
to resolve DNS on the proxy. The master connection stays direct.
</p>
<p className="text-xs text-amber-600 mt-0.5">
Takes effect after a restart (pm2 restart).
</p>
</div>
<input
type="text"
placeholder="e.g. socks5://user:pass@1.2.3.4:1080"
value={proxyUrl}
onChange={(e) => setProxyUrl(e.target.value)}
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 ── */}
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-1">
Instruments
</h2>
<p className="text-xs text-slate-400 mb-3">
What each firm trades is set on the master&apos;s{' '}
{masterUrl ? <a className="text-blue-500 hover:underline" href={`${masterUrl.replace(/\/+$/, '')}/firms`} target="_blank" rel="noreferrer">Firms page</a> : 'Firms page'}.
Futures &ldquo;Auto&rdquo; draws one per batch: cheaper per move early in the session (every instrument gets a floor),
the fastest near the last entry, never one expected to hold 12h or more. Holds below are for a $1,000 leg.
Spreads are live off one futures client&apos;s quotes (a tick when there are none); no entry pays more than
{overview?.maxEntrySpreadDollars ? ` $${overview.maxEntrySpreadDollars}` : ' the master\'s limit'} per contract to cross the spread.
</p>
{overview?.quotes && (
<p className="text-xs mb-3 text-slate-500">
{overview.quotes.source
? <>Live quotes from <span className="font-semibold">{overview.quotes.source}</span> ({overview.quotes.platform})
{overview.quotes.lastQuoteAt ? `, last ${new Date(overview.quotes.lastQuoteAt).toLocaleTimeString()}` : ', none yet'}</>
: <span className="text-amber-700">No live quotes — spreads are the typical tick{overview.quotes.lastError ? ` (${overview.quotes.lastError})` : ''}</span>}
</p>
)}
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm">
{instruments.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-slate-400 italic">
Loading…
{!overview ? (
<div className="bg-white border border-slate-200 rounded-xl px-4 py-8 text-center text-sm text-slate-400 italic shadow-sm">Loading…</div>
) : overview.firms.map((firm) => (
<div key={firm.id} className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm mb-3">
<div className="flex items-center gap-2 px-4 py-2.5 bg-slate-50 border-b border-slate-200">
<span className="font-semibold text-sm text-slate-800">{firm.name}</span>
<span className="text-xs text-slate-400">{firm.platform}</span>
{firm.source === 'legacy' && (
<span className="text-xs bg-amber-50 text-amber-700 border border-amber-200 rounded px-1.5 py-0.5" title="The master doesn't send instrument lists yet: this machine's enabled instruments, less the firm's banned symbols">
from this machine (older master)
</span>
)}
{firm.instruments.length === 0 && <span className="text-xs text-amber-700">no instruments — won&apos;t trade</span>}
</div>
) : (
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-slate-200 bg-slate-50">
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Symbol</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Active Contract</th>
<th className="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-slate-400">Enabled</th>
</tr>
</thead>
<tbody>
{instruments.map((instr) => {
const c = contracts[instr.symbol];
const wasRolled = !!c?.alternative;
return (
<tr key={instr.symbol} className="border-b border-slate-100 last:border-0">
<td className="px-4 py-2.5 font-mono font-semibold text-slate-800">
{instr.symbol}
</td>
<td className="px-4 py-2.5">
{instr.enabled && c ? (
<div className="flex items-center gap-2">
<span className={`font-mono text-sm ${wasRolled ? 'text-amber-600 font-semibold' : 'text-slate-600'}`}>
{c.name}
</span>
{wasRolled && (
<span className="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-medium">
rolled from {c.alternative}
</span>
)}
</div>
) : null}
</td>
<td className="px-4 py-2.5 text-right">
<button
onClick={() => toggle(instr.symbol, !instr.enabled)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none ${
instr.enabled ? 'bg-blue-500' : 'bg-slate-200'
}`}
{firm.stats ? (
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse whitespace-nowrap">
<thead>
<tr className="border-b border-slate-100 text-xs uppercase tracking-wider text-slate-400">
<th className="px-4 py-2 text-left font-semibold">Symbol</th>
<th className="px-4 py-2 text-left font-semibold">Contract</th>
<th className="px-4 py-2 text-right font-semibold">Spread</th>
<th className="px-4 py-2 text-right font-semibold">Daily move</th>
<th className="px-4 py-2 text-right font-semibold">Hold</th>
<th className="px-4 py-2 text-right font-semibold">Chance now</th>
</tr>
</thead>
<tbody>
{firm.instruments.map((sym) => {
const x = firm.stats!.instruments.find((i) => i.symbol === sym);
const c = contracts[sym];
return (
<tr key={sym} className="border-b border-slate-100 last:border-0">
<td className="px-4 py-2 font-mono font-semibold text-slate-800">{sym}</td>
<td className="px-4 py-2 font-mono text-slate-600">
{c?.name ?? '—'}
{c?.alternative && <span className="ml-2 text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-sans">rolled from {c.alternative}</span>}
</td>
<td
className={`px-4 py-2 text-right tabular-nums ${x?.spreadBlocked ? 'text-amber-600' : 'text-slate-600'}`}
title="Crossing the spread on one contract — what entering costs, commissions aside. Live off the quote feed, else the typical tick"
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
instr.enabled ? 'translate-x-4' : 'translate-x-1'
}`}
/>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{x ? `$${x.spreadDollars.toFixed(2)}` : '—'}
{x && <span className="text-xs text-slate-400"> {x.spreadLive ? 'live' : 'typical'}</span>}
{x?.spreadBlocked && ' · over limit'}
</td>
<td className="px-4 py-2 text-right tabular-nums text-slate-600">
{x?.dailyMoveDollars != null ? `$${x.dailyMoveDollars.toLocaleString()}` : '—'}
</td>
<td className={`px-4 py-2 text-right tabular-nums ${x?.disabled ? 'text-amber-600' : 'text-slate-600'}`}>
{x?.holdHours != null ? (x.holdHours < 48 ? `${x.holdHours.toFixed(1)}h` : `${(x.holdHours / 24).toFixed(1)}d`) : '—'}
{x?.disabled && ' · disabled'}
</td>
<td className="px-4 py-2 text-right tabular-nums font-semibold text-slate-700">
{x?.chance != null ? `${Math.round(x.chance * 100)}%` : '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<div className="flex flex-wrap gap-1.5 px-4 py-3">
{firm.instruments.map((sym) => (
<span key={sym} className="px-2 py-1 rounded-md text-xs font-mono font-semibold bg-slate-100 text-slate-700">{sym}</span>
))}
</div>
)}
</div>
))}
</div>
</div>
);
+38 -19
View File
@@ -15,7 +15,8 @@ class StepError(Exception):
"""A step refused to run, or ran and could not be verified.
`code` mirrors the CLI's exit codes: 3 = refused before acting,
4 = acted but verification failed, 5 = the session is signed out.
4 = acted but verification failed, 5 = a page guard tripped (login
redirect, blocking modal, and the like).
"""
def __init__(self, message: str, code: int = 3):
@@ -23,33 +24,44 @@ class StepError(Exception):
self.code = code
class SignedOutError(StepError):
"""The tab is on the login page, so the session has expired.
class GuardTripped(StepError):
"""A page guard's condition is on the page — the run cannot proceed past it.
Distinct from an ordinary failure because nothing is wrong with the
automation — it just cannot proceed until a human signs in.
Distinct from an ordinary failure because nothing is wrong with the step:
the page has been taken over by something (a login redirect, an expired-
session modal) and every selector now resolves against the wrong document.
A click or a keystroke aimed at the old page would land on whatever the
guard page happens to render in the same place. The runner decides how to
recover; the CLI just stops with exit code 5.
"""
def __init__(self, url: str):
def __init__(self, guard: dict, url: str):
name = guard.get("label") or guard.get("id") or "page"
super().__init__(
f"signed out — the tab is on {url}. Log in to the site, then run this again.",
f"{name} guard tripped — the tab is on {url}. "
"Deal with the page, then run this again.",
code=5,
)
self.guard = guard
self.url = url
def check_signed_in(found: dict, signed_out: str) -> None:
"""Abort if the page redirected to a login screen.
def check_guards(found: dict, guards: list | None) -> None:
"""Abort if the page has tripped any URL guard.
Worth doing before every action, not just at the start: a session can expire
mid-run, and once it does, every selector resolves against a login form. A
click or a keystroke aimed at the old page would land on whatever that form
happens to render in the same place.
Worth doing before every action, not just at the start: a session can
expire (or a guard page appear) mid-run. Only URL guards are checked here —
the URL rides along with every locate answer, so they are free. Selector
guards cost a locate round trip each and are probed by the runner only once
a step has already failed.
"""
if not signed_out:
if not guards:
return
url = found.get("url") or ""
if signed_out in url:
raise SignedOutError(url)
for guard in guards:
pattern = guard.get("urlContains") or ""
if pattern and pattern in url:
raise GuardTripped(guard, url)
# Display scaling factors that actually exist. Anything else means the two sides
@@ -117,6 +129,7 @@ def perform(
url: str = "",
open_url: str = "",
signed_out: str = "",
guards: list | None = None,
frame: str = "",
text: str | None = None,
clear: bool = False,
@@ -142,6 +155,12 @@ def perform(
rng = rng or random.Random()
say = report or (lambda _m: None)
# `guards` is the runner's list of page guards; `signed_out` is the CLI's
# single --signed-out pattern. Same check either way, so fold them together.
guards = list(guards or [])
if signed_out:
guards.append({"id": "signed-out", "label": "signed out", "urlContains": signed_out})
# `frame` names the <iframe> the element lives in; the extension resolves the
# selector inside it and offsets the coordinates back. Absent for the common
# top-document case, and then locate behaves exactly as before.
@@ -150,7 +169,7 @@ def perform(
found = dash.locate(selector, index, url, timeout, open_url, options=frame_opts)
# Before any check that assumes we are on the real page.
check_signed_in(found, signed_out)
check_guards(found, guards)
# Give a transient overlay a chance to finish leaving before giving up on it.
if found.get("covered") and not force:
@@ -160,7 +179,7 @@ def perform(
while found.get("covered") and time.time() < deadline:
time.sleep(COVERED_POLL_SECONDS)
found = dash.locate(selector, index, url, timeout, open_url, options=frame_opts)
check_signed_in(found, signed_out)
check_guards(found, guards)
if not found.get("covered"):
say("it cleared")
@@ -231,7 +250,7 @@ def perform(
# Typing into a field that never took focus, or that ignores the input, fails
# silently and is otherwise indistinguishable from success.
after = dash.locate(selector, index, url, timeout, open_url, options=frame_opts)
check_signed_in(after, signed_out)
check_guards(after, guards)
got = after.get("value")
if got is None:
say("element exposes no value to verify against")
+206 -99
View File
@@ -34,7 +34,8 @@ HEARTBEAT_SECONDS = 2.0
# Bumped whenever the step vocabulary or the locate protocol changes. Reported in
# the heartbeat so the dashboard can say "restart your runner" instead of letting
# a stale process fail on a step type it has never heard of.
VERSION = "0.19.0"
# 0.20.0: page guards — run-level `guards` replace per-step signedOut/authSteps.
VERSION = "0.20.0"
# Shared with the heartbeat thread: whether a run is currently executing.
_busy = threading.Event()
@@ -57,8 +58,14 @@ def heartbeat_loop(dash: Dashboard, opts, stop: threading.Event) -> None:
stop.wait(HEARTBEAT_SECONDS)
def run_one_step(dash: Dashboard, step: dict, opts, rng, report, run_id: int | None = None) -> None:
"""Dispatch a single step. Raises StepError / DashboardError on failure."""
def run_one_step(dash: Dashboard, step: dict, opts, rng, report,
run_id: int | None = None, guards: list | None = None) -> None:
"""Dispatch a single step. Raises StepError / DashboardError on failure,
GuardTripped when a URL guard's condition shows up on the page.
`guards` is empty for recovery steps: they run while a guard's condition
holds, so checking it again would trip on the very page they exist to fix.
"""
if step["action"] == "wait":
time.sleep(float(step.get("seconds", 1)))
report(f"waited {step.get('seconds', 1)}s")
@@ -72,7 +79,7 @@ def run_one_step(dash: Dashboard, step: dict, opts, rng, report, run_id: int | N
navigate_url=target)
# Navigating to a protected page is where an expired session shows up:
# the site answers with a redirect to its login form.
actions.check_signed_in(landed, step.get("signedOut", "") or "")
actions.check_guards(landed, guards)
report(f"tab is on {landed.get('url', target)}")
elif step["action"] == "waitFor":
@@ -110,6 +117,10 @@ def run_one_step(dash: Dashboard, step: dict, opts, rng, report, run_id: int | N
step.get("urlPattern", "") or opts.url,
opts.timeout, step.get("openUrl", "") or "",
options=loc_opts or None)
# A guard page appearing mid-gate would otherwise burn the whole
# gate timeout before anyone noticed. GuardTripped is not caught
# below, so it escapes the wait as it should.
actions.check_guards(seen, guards)
if by_sight:
# An extension too old for the probe quietly does an ordinary
# locate instead, whose result has no `visible` key — reading
@@ -197,7 +208,7 @@ def run_one_step(dash: Dashboard, step: dict, opts, rng, report, run_id: int | N
index=int(step.get("index", 0)),
url=step.get("urlPattern", "") or opts.url,
open_url=step.get("openUrl", "") or "",
signed_out=step.get("signedOut", "") or "",
guards=guards,
frame=step.get("frame", "") or "",
text=step.get("text"),
clear=bool(step.get("clear")),
@@ -224,73 +235,195 @@ def run_status(dash: Dashboard, run_id: int) -> str | None:
return None
def wait_for_sign_in(dash: Dashboard, step: dict, run_id: int, opts, deadline: float) -> bool:
"""Block until the tab leaves the login page.
def guard_present(dash: Dashboard, guard: dict, step: dict, opts,
probe_timeout: float = 6.0) -> bool | None:
"""Is the guard's condition on the page right now? None = could not tell.
The runner does not log in — entering credentials is the human's job, and a
keystroke aimed at the wrong field is not something to risk automating. All
this does is watch the tab and pick the run back up once you are through.
A URL guard reads the tab's URL off a cheap body locate; a selector guard
needs a real DOM probe, whose timeout is kept short — a guard element that
exists shows up immediately, so most of the timeout is only ever spent
confirming absence. A guard defining both trips when either is present.
"""
signed_out = step.get("signedOut", "") or ""
pattern = step.get("urlPattern", "") or opts.url
open_url = step.get("openUrl", "") or ""
while time.time() < deadline:
if run_status(dash, run_id) == "cancelled":
return False
time.sleep(3.0)
if guard.get("urlContains"):
try:
landed = dash.locate("body", 0, pattern, opts.timeout, open_url)
except DashboardError:
continue # tab busy mid-redirect; look again shortly
if signed_out not in (landed.get("url") or ""):
return None # tab busy mid-redirect; the caller decides
if guard["urlContains"] in (landed.get("url") or ""):
return True
if not guard.get("selector"):
return False
if guard.get("selector"):
try:
dash.locate(guard["selector"], 0, pattern, probe_timeout, open_url)
return True
except NotFoundError:
return False
except DashboardError:
return None
return False
def signed_in_now(dash: Dashboard, step: dict, opts, settle_seconds: float = 20.0) -> bool:
"""Did we actually get back in?
def guard_cleared(dash: Dashboard, guard: dict, step: dict, opts,
settle_seconds: float = 20.0) -> bool:
"""Did recovery actually clear the condition?
Auth steps can 'succeed' — every click landing — while the site still rejects
the sign-in, so this confirms rather than assumes before the failed step is
retried.
Recovery steps can 'succeed' — every click landing — while the page stays in
the guarded state, so this confirms rather than assumes before resuming.
It polls rather than checking once: submitting a login form starts a network
round trip and then a redirect, so the tab is still sitting on the login URL
for a second or two afterwards. Checking immediately reports a failure for a
sign-in that is merely still in flight.
round trip and then a redirect, so the tab is still sitting on the guard
page for a second or two afterwards. Checking immediately reports a failure
for a recovery that is merely still in flight.
"""
signed_out = step.get("signedOut", "") or ""
if not signed_out:
return True
deadline = time.time() + settle_seconds
while True:
try:
landed = dash.locate("body", 0, step.get("urlPattern", "") or opts.url,
opts.timeout, step.get("openUrl", "") or "")
if signed_out not in (landed.get("url") or ""):
return True
except DashboardError:
pass # mid-redirect the tab can be briefly un-injectable
if guard_present(dash, guard, step, opts) is False:
return True
if time.time() >= deadline:
return False
time.sleep(1.0)
def wait_for_clear(dash: Dashboard, guard: dict, step: dict, run_id: int, opts,
deadline: float) -> bool:
"""Block until a human deals with the guard's condition.
The runner does not act here — for the login guard, entering credentials is
the human's job, and a keystroke aimed at the wrong field is not something
to risk automating. All this does is watch the page and pick the run back
up once the condition is gone.
"""
while time.time() < deadline:
if run_status(dash, run_id) == "cancelled":
return False
time.sleep(3.0)
if guard_present(dash, guard, step, opts) is False:
return True
return False
def find_tripped_guard(dash: Dashboard, guards: list, step: dict, opts) -> dict | None:
"""After a step failure: is a guard's condition what actually broke it?
URL guards are caught in flight by check_guards; selector guards cost a
locate round trip each, so they are only probed once a step has already
failed — a session-expired modal explains a NotFound far better than a
wrong selector does.
"""
for guard in guards:
if not guard.get("selector"):
continue # URL-only guards were already checked in flight
if guard_present(dash, guard, step, opts):
return guard
return None
def legacy_guards(run: dict) -> list:
"""Guards synthesized from the pre-guard payload shape, so this runner still
works against a dashboard that predates run-level guards."""
for step in run.get("steps") or []:
if step.get("signedOut"):
return [{
"id": "signed-out",
"label": "signed out",
"urlContains": step["signedOut"],
"resume": "continue",
"waitSeconds": float(step.get("signedOutWait", 0) or 0),
"recoverySteps": run.get("authSteps") or [],
}]
return []
def handle_guard(dash: Dashboard, run_id: int, step_index: int, label: str,
guard: dict, step: dict, opts, rng, report,
trigger_counts: dict) -> str:
"""A guard tripped mid-run: recover, then say where the run picks up.
Returns "retry" (redo the step that tripped), "restart" (back to step 1),
"cancelled", or "fail" — the terminal outcomes have already been reported
and the run finished by the time they are returned.
"""
gid = guard.get("id") or "guard"
name = guard.get("label") or gid
def fail(msg: str) -> str:
post_progress(dash, run_id, step_index, label, False, msg)
finish(dash, run_id, msg)
print(f"✗ run #{run_id} stopped: {msg}", file=sys.stderr)
return "fail"
# A guard whose recovery "works" without clearing the cause would trip
# again on the next step, forever. Cap it per run, not per step: a restart
# revisits every step, so a per-step cap would never fill up.
trigger_counts[gid] = trigger_counts.get(gid, 0) + 1
limit = int(guard.get("maxTriggers") or 3)
if trigger_counts[gid] > limit:
return fail(f"{name} guard tripped {trigger_counts[gid]} times in one run — giving up")
recovery = guard.get("recoverySteps") or []
if recovery:
post_progress(dash, run_id, step_index,
f"{name} guard tripped — running {len(recovery)} recovery step(s)",
False, None)
try:
for rstep in recovery:
rlabel = rstep.get("label") or f"recovery: {rstep['action']}"
print(f" [{name}] {rlabel}")
# No guards passed: recovery runs on the guard page itself.
run_one_step(dash, rstep, opts, rng, report, run_id)
except (actions.StepError, DashboardError) as exc:
return fail(f"{name} guard recovery failed: {exc}")
if not guard_cleared(dash, guard, step, opts):
return fail(f"{name} guard recovery ran but the condition is still on the page")
report(f"{name} guard cleared")
else:
wait_seconds = float(guard.get("waitSeconds") or 0)
if wait_seconds <= 0:
return fail(f"{name} guard tripped and defines no recovery — stopping")
mins = wait_seconds / 60
note = f"{name} guard tripped — waiting up to {mins:.0f} min for you to deal with it"
print(f" {note}", file=sys.stderr)
post_progress(dash, run_id, step_index, note, False, None)
if not wait_for_clear(dash, guard, step, run_id, opts, time.time() + wait_seconds):
if run_status(dash, run_id) == "cancelled":
print(f"■ run #{run_id} cancelled while waiting on the {name} guard")
return "cancelled"
return fail(f"{name} guard still tripped after {mins:.0f} min")
report(f"{name} guard cleared")
return "restart" if (guard.get("resume") or "continue") == "restart" else "retry"
def run_steps(dash: Dashboard, run: dict, opts) -> None:
"""Work through one automation. Reports every step; stops on the first
failure, because a half-completed purchase flow should not barrel on."""
failure, because a half-completed purchase flow should not barrel on.
Page guards can interrupt any step. Once one is handled, its `resume`
policy decides where the run picks up: "continue" retries the step that
tripped, "restart" goes back to the first step — hence a while loop over
the step index rather than a plain for."""
run_id = run["id"]
steps = run["steps"]
auth_steps = run.get("authSteps") or []
guards = run.get("guards")
if guards is None:
guards = legacy_guards(run)
rng = random.Random(opts.seed) if opts.seed is not None else random.Random()
print(f"\n▶ run #{run_id} — {run['label']} ({len(steps)} steps)")
for i, step in enumerate(steps):
trigger_counts: dict[str, int] = {}
i = 0
while i < len(steps):
step = steps[i]
label = step.get("label") or f"{step['action']} {step.get('selector', '')}".strip()
print(f" [{i + 1}/{len(steps)}] {label}")
@@ -300,74 +433,46 @@ def run_steps(dash: Dashboard, run: dict, opts) -> None:
detail_parts.append(message)
print(f" {message}")
# One retry after a sign-in: the session can only be expired once per step.
for attempt in range(2):
try:
run_one_step(dash, step, opts, rng, report, run_id)
break
outcome = None
try:
run_one_step(dash, step, opts, rng, report, run_id, guards=guards)
except actions.SignedOutError as exc:
wait_seconds = float(step.get("signedOutWait", 0) or 0)
except actions.GuardTripped as exc:
print(f" {exc}", file=sys.stderr)
outcome = handle_guard(dash, run_id, i, label, exc.guard, step,
opts, rng, report, trigger_counts)
# Scripted auth first, if the firm defines any; otherwise fall
# back to pausing for a human. Only ever attempted once per step —
# auth that "succeeds" without signing in would otherwise loop.
if auth_steps and attempt == 0:
post_progress(dash, run_id, i, f"signed out — running {len(auth_steps)} auth step(s)",
False, exc.args[0])
try:
for astep in auth_steps:
alabel = astep.get("label") or f"auth: {astep['action']}"
print(f" [auth] {alabel}")
run_one_step(dash, astep, opts, rng, report, run_id)
except (actions.StepError, DashboardError) as aexc:
msg = f"auth steps failed: {aexc}"
post_progress(dash, run_id, i, label, False, msg)
finish(dash, run_id, msg)
print(f"✗ run #{run_id} stopped: {msg}", file=sys.stderr)
return
except (actions.StepError, DashboardError) as exc:
if run_status(dash, run_id) == "cancelled":
print(f"■ run #{run_id} cancelled from the dashboard")
return
if not signed_in_now(dash, step, opts):
msg = "auth steps ran but the session is still signed out"
post_progress(dash, run_id, i, label, False, msg)
finish(dash, run_id, msg)
print(f"✗ run #{run_id} stopped: {msg}", file=sys.stderr)
return
report("signed in via auth steps — retrying")
continue
if wait_seconds <= 0 or attempt > 0:
print(f" FAILED: {exc}", file=sys.stderr)
post_progress(dash, run_id, i, label, False, str(exc))
finish(dash, run_id, str(exc))
print(f"✗ run #{run_id} stopped at step {i + 1}")
return
mins = wait_seconds / 60
note = f"signed out — waiting up to {mins:.0f} min for you to log in"
print(f" {note}", file=sys.stderr)
post_progress(dash, run_id, i, note, False, exc.args[0])
if not wait_for_sign_in(dash, step, run_id, opts, time.time() + wait_seconds):
if run_status(dash, run_id) == "cancelled":
print(f"■ run #{run_id} cancelled while waiting for sign-in")
return
msg = f"still signed out after {mins:.0f} min"
post_progress(dash, run_id, i, label, False, msg)
finish(dash, run_id, msg)
print(f"✗ run #{run_id} gave up waiting for sign-in")
return
report("signed in — retrying the step")
except (actions.StepError, DashboardError) as exc:
# Before writing the failure off, see whether a selector guard's
# condition is sitting on the page — an expired-session modal
# explains a missing button better than a wrong selector does.
tripped = find_tripped_guard(dash, guards, step, opts) if guards else None
if tripped is not None:
name = tripped.get("label") or tripped.get("id")
print(f" step failed with the {name} guard's condition on the page",
file=sys.stderr)
outcome = handle_guard(dash, run_id, i, label, tripped, step,
opts, rng, report, trigger_counts)
else:
print(f" FAILED: {exc}", file=sys.stderr)
post_progress(dash, run_id, i, label, False, str(exc))
finish(dash, run_id, str(exc))
print(f"✗ run #{run_id} stopped at step {i + 1}")
return
if outcome == "retry":
continue # same step, another go
if outcome == "restart":
print(f" ↺ restarting run #{run_id} from step 1")
i = 0
continue
if outcome in ("fail", "cancelled"):
return # handle_guard already reported and finished
status = post_progress(dash, run_id, i + 1, label, True, "; ".join(detail_parts) or None)
# The dashboard's Stop button shows up here.
@@ -375,6 +480,8 @@ def run_steps(dash: Dashboard, run: dict, opts) -> None:
print(f"■ run #{run_id} cancelled from the dashboard")
return
i += 1
finish(dash, run_id, None)
print(f"✓ run #{run_id} complete")
+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
+638 -6
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 = {
@@ -450,6 +455,171 @@ async function pageScrollToLoad(selector, containerSelector, maxScrolls, settleM
};
}
// ── Topstep: which passed evaluations can still be activated? ────────────────
//
// "Activatable" is decided against the Topstep dashboard's own GraphQL API
// (crystal.topstep.com), not by scraping the page. An account can be activated
// into an Express Funded Account when it has PASSED its evaluation and has not
// already been activated. Passing shows as status "Passed"; activation spawns a
// funded account (express-funded, or later live-funded) that carries the SAME
// subscription id as the eval it came from. So: a Passed eval whose subscription
// id is not shared by ANY funded account it ever spawned is one you can still
// activate.
//
// The funded account's CURRENT status is irrelevant — activation is one-way, so
// an eval whose Express account has since gone Closed/Violation is still spent,
// not activatable. Filtering funded accounts to "live only" was an early bug: it
// mislabelled long-since-activated-then-closed evals as activatable. Verified
// against the dashboard's own "Activate Express Funded Account" button, which is
// enabled for exactly the evals with no funded account of any status.
const TOPSTEP_PATTERN = 'https://*.topstep.com/*';
// The dashboard host is where the app (and its auth) lives; app.topstep.com just
// redirects here. Landing on the accounts route guarantees the account queries
// run, which is how we get hold of the bearer token below.
const TOPSTEP_URL = 'https://dashboard.topstep.com/dashboard/accounts';
/** Runs in the page's MAIN world on the Topstep dashboard. Returns the set of
* passed evaluations that can still be activated.
*
* Self-contained: nothing from this file's scope exists inside it.
*
* The API is authenticated with a bearer token the dashboard keeps out of
* reach of injected code, so rather than dig it out we borrow it: hook fetch,
* nudge the app to re-run its own queries, and lift the Authorization header
* off the first request that carries one. Once we have it we call the same
* GraphQL endpoint the dashboard uses. */
async function pageTopstepScan() {
if (/passwordless|login|sign-?in/i.test(location.pathname)
|| document.querySelector('input[type="password"]')) {
return { signedOut: true, url: location.href };
}
// Borrow the bearer token from the app's own traffic.
const auth = await new Promise((resolve) => {
if (window.__afTsxAuth) return resolve(window.__afTsxAuth);
const orig = window.fetch;
let settled = false;
const finish = (token) => {
if (settled) return;
settled = true;
window.fetch = orig;
resolve(token);
};
window.fetch = function (...args) {
try {
const h = args[1] && args[1].headers;
let a = null;
if (h) a = typeof h.get === 'function' ? h.get('authorization') : (h.authorization || h.Authorization);
if (a && /^bearer /i.test(a)) {
window.__afTsxAuth = a;
finish(a);
}
} catch { /* keep going */ }
return orig.apply(this, args);
};
// Make the app issue an authenticated request so the hook has something
// to read. Its Apollo client re-runs every active query on demand.
try { window.__APOLLO_CLIENT__?.reFetchObservableQueries?.(); } catch { /* ignore */ }
setTimeout(() => finish(null), 8000);
});
if (!auth) {
return { error: 'Could not read the Topstep session token — open the Topstep dashboard, sign in, and try again.' };
}
const ENDPOINT = 'https://crystal.topstep.com/graphql/TraderAccounts';
const QUERY = `query AfActivatableScan($filter: AccountFilter) {
accounts(first: 500, filter: $filter) {
nodes { rowId accountName status stage nmiSubscriptionId subscriptionId accountLabels }
}
}`;
const run = async (filter) => {
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: auth },
body: JSON.stringify({ operationName: 'AfActivatableScan', query: QUERY, variables: { filter } }),
});
if (!res.ok) throw new Error(`Topstep API HTTP ${res.status}`);
const body = await res.json();
if (body.errors) throw new Error(body.errors.map((e) => e.message).join('; '));
return body.data.accounts.nodes;
};
try {
// Every passed evaluation, and every funded account of ANY status — an
// activation that has already happened, whatever became of the funded
// account since (still Active, or long since Closed/Violation). Status is
// deliberately not filtered: activation is one-way, so a spent eval stays
// spent even after its Express account closes.
const [passed, funded] = await Promise.all([
run({ status: { equalTo: 'Passed' } }),
run({ stage: { in: ['express-funded', 'live-funded'] } }),
]);
const claimedNmi = new Set(funded.map((f) => f.nmiSubscriptionId).filter(Boolean));
const claimedSub = new Set(funded.map((f) => f.subscriptionId).filter(Boolean));
const activatable = [];
let alreadyActivated = 0;
for (const acc of passed) {
const claimed = (acc.nmiSubscriptionId && claimedNmi.has(acc.nmiSubscriptionId))
|| (acc.subscriptionId && claimedSub.has(acc.subscriptionId));
if (claimed) { alreadyActivated++; continue; }
activatable.push({
id: acc.rowId,
name: acc.accountName,
type: acc.accountLabels?.accountType || null,
});
}
activatable.sort((a, b) => a.name.localeCompare(b.name));
return {
activatable,
activatableCount: activatable.length,
alreadyActivated,
passedTotal: passed.length,
};
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}
/** Scan Topstep for activatable accounts. Reuses an open dashboard tab; opens
* one in the background otherwise. Never raises the window — this is a read,
* and stealing focus for it would be rude. */
async function checkTopstep() {
const tabs = await chrome.tabs.query({ url: TOPSTEP_PATTERN });
let tab = tabs.find((t) => /^https?:/.test(t.url || ''));
let opened = false;
if (!tab) {
tab = await chrome.tabs.create({ url: TOPSTEP_URL, active: false });
opened = true;
await waitForTabLoad(tab.id);
}
// `complete` only means the document loaded — the SPA still has to mount,
// authenticate and expose its Apollo client before the scan can borrow a
// token. Retry across a budget rather than declaring "nothing" on the first
// miss; a signed-out result or a real answer both end it early.
const deadline = Date.now() + (opened ? 20000 : 10000);
let out = null;
for (;;) {
const [injection] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
world: 'MAIN',
func: pageTopstepScan,
});
out = injection?.result || out;
if (out && (out.signedOut || out.error || typeof out.activatableCount === 'number')) break;
if (Date.now() >= deadline) break;
await new Promise((r) => setTimeout(r, 1000));
}
if (!out) throw new Error('Topstep scan returned nothing');
return { ...out, openedTab: opened, checkedAt: Date.now() };
}
// ── Captcha: read the widget, then hand a solved token back to the page ─────
/** Runs in the page's MAIN world. Describes the captcha widget well enough for
@@ -735,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,
@@ -1011,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 ───────────────────────────────────────────────
@@ -1142,12 +1742,44 @@ chrome.storage.onChanged.addListener((changes, area) => {
});
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg?.type !== 'capture-now') return;
getConfig()
.then(captureAndSend)
.then(() => sendResponse({ ok: true }))
.catch((err) => sendResponse({ ok: false, error: err.message }));
return true; // keep the message channel open for the async response
if (msg?.type === 'capture-now') {
getConfig()
.then(captureAndSend)
.then(() => sendResponse({ ok: true }))
.catch((err) => sendResponse({ ok: false, error: err.message }));
return true; // keep the message channel open for the async response
}
if (msg?.type === 'topstep-check') {
// Fire-and-forget: a scan can run ~20s (open a tab, wait for the SPA to
// mount, poll for a token), which is longer than a popup or an MV3
// service worker reliably lives. Relying on sendResponse across that gap
// is what produced "message port closed before a response was received":
// the popup closes, and the reply lands on a dead port. So the answer
// goes to chrome.storage instead — durable, and the popup renders it from
// there whether or not it was still open when the scan finished.
runTopstepCheck();
// ACK immediately on the live port so the sender's callback resolves now,
// long before the scan does.
sendResponse({ ok: true, started: true });
return false;
}
});
/** Run a Topstep scan and mirror its lifecycle into storage for the popup:
* a `running` marker up front, then the result (or error). Never throws. */
async function runTopstepCheck() {
await setTopstepState({ running: true, error: null, result: null });
try {
const result = await checkTopstep();
await setTopstepState({ running: false, error: null, result });
} catch (err) {
await setTopstepState({ running: false, error: err instanceof Error ? err.message : String(err), result: null });
}
}
async function setTopstepState(patch) {
await chrome.storage.local.set({ topstepScan: { ...patch, updatedAt: Date.now() } });
}
restart();
+11 -2
View File
@@ -1,16 +1,19 @@
{
"manifest_version": 3,
"name": "AutoFirmer Capture",
"version": "0.14.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/*"
],
@@ -27,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": {
+14
View File
@@ -15,6 +15,15 @@
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
.muted { color: #64748b; font-size: 11px; }
.err { color: #dc2626; font-size: 11px; margin-top: 4px; word-break: break-word; }
hr { border: 0; border-top: 1px solid #e2e8f0; margin: 14px 0 4px; }
.section-title { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: #64748b; margin: 10px 0 6px; }
button.wide { width: 100%; margin-left: 0; }
#tsResult { margin-top: 10px; font-size: 12px; }
#tsResult .big { font-size: 22px; font-weight: 600; color: #0f172a; }
#tsResult ul { margin: 8px 0 0; padding-left: 16px; max-height: 160px; overflow-y: auto; }
#tsResult li { font: 11px ui-monospace, monospace; color: #334155; margin-bottom: 2px; }
#tsResult li a { color: #2563eb; text-decoration: none; }
#tsResult li a:hover { text-decoration: underline; }
</style>
</head>
<body>
@@ -33,6 +42,11 @@
<button id="save">Save</button>
<button id="now" class="ghost">Capture now</button>
<hr />
<div class="section-title">Topstep accounts</div>
<button id="tsCheck" class="wide">Check activatable accounts</button>
<div id="tsResult"></div>
<script src="popup.js"></script>
</body>
</html>
+59
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>` : '');
}
@@ -45,6 +46,64 @@ document.getElementById('now').addEventListener('click', () => {
chrome.runtime.sendMessage({ type: 'capture-now' }, () => setTimeout(render, 200));
});
document.getElementById('tsCheck').addEventListener('click', () => {
// Kick off the scan and return. The worker writes progress and the result to
// storage; renderTopstepState (below) reflects whatever is there, so the
// popup can be closed and reopened mid-scan without losing the answer. The
// callback only acknowledges the request was received — it carries no result.
chrome.runtime.sendMessage({ type: 'topstep-check' }, () => void chrome.runtime.lastError);
});
/** Paint the Topstep panel from the stored scan state. */
function renderTopstepState(state) {
const btn = document.getElementById('tsCheck');
const out = document.getElementById('tsResult');
if (!state) { out.innerHTML = ''; return; }
if (state.running) {
btn.disabled = true;
btn.textContent = 'Checking…';
out.innerHTML = '<div class="muted">Scanning the Topstep dashboard…</div>';
return;
}
btn.disabled = false;
btn.textContent = 'Check activatable accounts';
if (state.error) {
out.innerHTML = `<div class="err">${esc(state.error)}</div>`;
return;
}
const r = state.result;
if (!r) { out.innerHTML = ''; return; }
if (r.signedOut) {
out.innerHTML = '<div class="err">Not signed in to Topstep. Open the dashboard, sign in, then check again.</div>';
return;
}
if (r.error) {
out.innerHTML = `<div class="err">${esc(r.error)}</div>`;
return;
}
const n = r.activatableCount || 0;
const acctUrl = (id) => `https://dashboard.topstep.com/dashboard/accounts/${encodeURIComponent(id)}`;
const list = (r.activatable || [])
.map((a) => `<li><a href="${acctUrl(a.id)}" target="_blank" rel="noopener">${esc(a.name)}</a>${a.type ? ` · ${esc(a.type)}` : ''}</li>`)
.join('');
const when = r.checkedAt ? ` · ${new Date(r.checkedAt).toLocaleTimeString()}` : '';
out.innerHTML =
`<div class="big">${n} activatable</div>` +
`<div class="muted">${r.alreadyActivated || 0} already activated · ${r.passedTotal || 0} passed total${when}</div>` +
(list ? `<ul>${list}</ul>` : '<div class="muted" style="margin-top:6px">No passed accounts waiting on activation.</div>');
}
// Render whatever the last scan left behind when the popup opens…
chrome.storage.local.get('topstepScan').then(({ topstepScan }) => renderTopstepState(topstepScan));
// …and keep up with a scan that finishes while the popup is open.
chrome.storage.onChanged.addListener((c, area) => {
if (area === 'local' && 'topstepScan' in c) renderTopstepState(c.topstepScan.newValue);
});
chrome.storage.onChanged.addListener((c, area) => { if (area === 'local' && 'state' in c) render(); });
function esc(s) {
+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;
};
})();
+40
View File
@@ -0,0 +1,40 @@
/**
* Server start hook — Next runs register() once per server process, before
* it serves the first request (a root instrumentation.ts is picked up with no
* config in Next 15+).
*
* It boots the trading background work eagerly. getClients() in lib/clients.ts
* starts the broker clients, the contract resolver, the reporter, the master
* link, the client supervisor and the persisted-schedule resume — and it used
* to run only on the first HTTP request. So a restarted instance (a crash, an
* update, pm2 after a reboot) sat idle, not trading and invisible to the
* master, until someone opened the dashboard or start-all's warm-up curl
* happened to reach it.
*
* The boot must never take the server down with it: a failure is logged, and
* if the boot never got going the first request still starts it through the
* same lazy getClients() path as before.
*
* This module is its own server bundle, so it can hold its own instances of
* lib/ modules, separate from the route handlers'. Anything the two must share
* (the client pool, the scheduler, timers) lives on globalThis for that reason.
*/
export async function register(): Promise<void> {
// The nodejs check is the documented shape for a Node-only import, kept
// exactly: the edge compile (Turbopack always builds one) inlines
// NEXT_RUNTIME and drops the block, so lib/clients — sqlite, sockets —
// never reaches the edge bundle, where it would fail the build. The build
// check is defense in depth (Next itself doesn't call register() during
// `next build`) — a build must never dial brokers or resume the scheduler.
if (process.env.NEXT_RUNTIME === 'nodejs') {
if (process.env.NEXT_PHASE === 'phase-production-build') return;
try {
const { getClients } = await import('./lib/clients');
getClients();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`[instrumentation] eager boot failed — clients will start on the first request: ${message}`);
}
}
}
+179 -26
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 { TradovateClient } from './tradovate-class';
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. */
@@ -69,10 +111,14 @@ export function matchAccountType(
const byPrefix = types.filter((t) => accountName.startsWith(t.prefix));
if (byPrefix.length === 0) return { type: null, ambiguous: false };
// No size yet — stay Unassigned and try again next sync.
if (accountSize === null) return { type: null, ambiguous: false };
const candidates = byPrefix.filter((t) => Math.abs(t.accountSize - accountSize) < 0.01);
// Exact size match first. An account type with size 0 means the account's
// balance starts at $0 (TopstepX Express style) — there is no funding
// transaction to infer a size from, so it matches on prefix alone, but
// only once every sized candidate has had its chance.
const sized = accountSize !== null
? byPrefix.filter((t) => t.accountSize > 0 && Math.abs(t.accountSize - accountSize) < 0.01)
: [];
const candidates = sized.length > 0 ? sized : byPrefix.filter((t) => t.accountSize === 0);
if (candidates.length === 0) return { type: null, ambiguous: false };
if (candidates.length === 1) return { type: candidates[0], ambiguous: false };
@@ -89,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.
@@ -104,20 +157,26 @@ 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,
};
}
/** The size this account resolved to, cached once found so a later reset
* deposit can't move it. */
export function getInferredSize(accountId: number, client: TradovateClient): number | null {
export function getInferredSize(accountId: number, client: PlatformClient): number | null {
const cached = loadAccountMeta(accountId, INFERRED_SIZE_KEY);
if (cached !== null) {
const value = Number(cached);
@@ -126,28 +185,106 @@ export function getInferredSize(accountId: number, client: TradovateClient): num
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: TradovateClient,
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;
@@ -184,27 +321,43 @@ export function resolveFirmAssignments(
}
if (matched.type) {
// A $0-start type matched on prefix alone — whatever number rode
// along (usually the live balance) is not an inferred size.
if (matched.type.accountSize === 0) matchedSize = 0;
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 };
+2196 -360
View File
File diff suppressed because it is too large Load Diff
+131 -51
View File
@@ -110,7 +110,11 @@ export interface Automation {
}
/** A step as handed to the runner: the firm's tab pattern and fallback URL
* filled in, so the runner never has to know which firm it is working on. */
* filled in, so the runner never has to know which firm it is working on.
*
* `signedOut`/`signedOutWait` are the pre-guard (runner < 0.20) shape of the
* login guard, still stamped on so a stale runner keeps working — a 0.20+
* runner ignores them in favour of the run's `guards`. */
export type ResolvedStep = AutomationStep & {
urlPattern?: string;
openUrl?: string;
@@ -118,6 +122,59 @@ export type ResolvedStep = AutomationStep & {
signedOutWait?: number;
};
/** A condition that can take over the page at any point in a run — a login
* redirect, a session-expired modal, a maintenance page, a rate-limit
* interstitial. Guards belong to the firm, not to any step: once the page is
* in that state, every selector resolves against the wrong document, so the
* run must deal with the guard before anything else can proceed.
*
* How a guard trips:
* - `urlContains` — the tab's URL carries this substring. Checked on every
* step, for free: the URL rides along with every locate answer.
* - `selector` — this element is on the page. A DOM probe costs a round
* trip, so the runner checks these when a step fails, where a guard is
* the likeliest explanation. A guard may define either or both (both =
* tripped when either is present).
*
* What happens then:
* 1. `recoverySteps` run, if there are any. They execute *while the guard
* condition holds* — on the login form, under the modal — so they carry
* no guard checks of their own; a wrong selector here is worse than
* elsewhere. Anything written here lives in this file in plain text,
* and this file is in the repo.
* 2. With no recovery steps, the run pauses up to `waitSeconds` for a human
* to deal with it (sign in, dismiss the thing). No recovery steps and no
* wait means tripping the guard fails the run.
* 3. Once the condition has verifiably cleared, `resume` decides where the
* run picks up: `'continue'` retries the step that tripped and carries
* on; `'restart'` goes back to the automation's first step — for
* conditions that reset page state (a re-login that lands on the
* dashboard, a modal that closes the checkout flow).
*/
export interface PageGuard {
/** Stable id — names the guard in run logs and counts its triggers per run. */
id: string;
/** Shown in the run log; falls back to `id`. */
label?: string;
/** Trip when the tab's URL contains this substring. */
urlContains?: string;
/** Trip when this element is on the page. */
selector?: string;
/** Steps that clear the condition. Run guard-free, on the guard page itself. */
recoverySteps?: AutomationStep[];
/** Where the run picks up once the condition clears. */
resume: 'restart' | 'continue';
/** Seconds to pause for a human when there are no `recoverySteps`. */
waitSeconds?: number;
/** Times this guard may trip in one run before the run fails; a guard whose
* recovery "works" without clearing the cause would otherwise loop.
* Default 3. */
maxTriggers?: number;
}
/** A guard as handed to the runner: recovery steps expanded and resolved. */
export type ResolvedGuard = Omit<PageGuard, 'recoverySteps'> & { recoverySteps: ResolvedStep[] };
export interface Firm {
id: string;
label: string;
@@ -127,27 +184,11 @@ export interface Firm {
/** Concrete page to open when no tab matches `urlPattern`. A match pattern
* can't be navigated to, so this has to be spelled out separately. */
url: string;
/** Substring identifying the signed-out page. When a session expires the site
* redirects here, and every subsequent selector resolves against a login form
* — so a run that lands on it must stop rather than click through it. */
signedOutPattern: string;
/** Seconds to pause and let a human sign in when a run hits the login page.
* 0 aborts instead. Used only when `authSteps` is empty. */
signedOutWaitSeconds: number;
/** Steps run when a run lands on the login page, before retrying the step
* that hit it. Left empty here on purpose — fill it in yourself.
*
* Two things to know if you do:
* - These run *while on the signed-out page*, so unlike normal steps they
* carry no signed-out guard. Nothing stops them clicking around a login
* form; that is the point, and also why a wrong selector here is worse
* than elsewhere.
* - Anything written here lives in this file in plain text, and this file
* is in the repo.
*
* While empty, a run that hits the login page falls back to pausing for
* `signedOutWaitSeconds` so you can sign in by hand. */
authSteps: AutomationStep[];
/** Conditions that can interrupt a run at any point, and how to get past
* them. Every firm wants at least the signed-out guard: when a session
* expires the site redirects to its login page, and a run that lands there
* must stop rather than click through the form. See PageGuard. */
guards: PageGuard[];
automations: Automation[];
}
@@ -160,10 +201,17 @@ export const FIRMS: Firm[] = [
label: 'Tradeify',
urlPattern: 'https://app-f.tradeify.co/*',
url: 'https://app-f.tradeify.co/',
signedOutPattern: '/auth/',
signedOutWaitSeconds: 300,
authSteps: [
{ action: 'click', selector: 'form > div > div:last-child button', label: 'Login' },
guards: [
{
id: 'signed-out',
label: 'Signed out',
urlContains: '/auth/',
resume: 'continue',
waitSeconds: 300,
recoverySteps: [
{ action: 'click', selector: 'form > div > div:last-child button', label: 'Login' },
],
},
],
automations: [
{
@@ -248,14 +296,20 @@ export const FIRMS: Firm[] = [
// well as subdomains, confirmed against a live tab on lucidtrading.com.
urlPattern: 'https://*.lucidtrading.com/*',
url: 'https://dash.lucidtrading.com/',
// Signed out lands on the apex; signed in stays on dash. The leading `//`
// anchors this to the start of the host — without it, the substring also
// matches dash.lucidtrading.com and every step would abort while logged in.
signedOutPattern: '//lucidtrading.com/',
signedOutWaitSeconds: 300,
authSteps: [
{ action: 'click', selector: 'button.lucid-login-btn', label: 'Login' },
guards: [
{
id: 'signed-out',
label: 'Signed out',
// Signed out lands on the apex; signed in stays on dash. The leading `//`
// anchors this to the start of the host — without it, the substring also
// matches dash.lucidtrading.com and every step would abort while logged in.
urlContains: '//lucidtrading.com/',
resume: 'continue',
waitSeconds: 300,
recoverySteps: [
{ action: 'click', selector: 'button.lucid-login-btn', label: 'Login' },
],
},
],
automations: [
{
@@ -308,20 +362,22 @@ export const FIRMS: Firm[] = [
// opt-ins). No skipIfNotFound: silently skipping the
// second agreement just moves the failure to the
// modal-close gate below with a misleading timeout.
{ action: 'click', selector: '.validate-required input[type="checkbox"]:not(:checked)', frame: '#woocommerceModal iframe', label: 'Agree to terms' },
// Ticking box one can re-render the checkout (update_checkout);
// give it a beat so the second locate sees the settled DOM.
{ action: 'wait', seconds: 1 },
{ action: 'click', selector: '.validate-required input[type="checkbox"]:not(:checked), form.checkout input[type="checkbox"]:not(:checked):not(#ship-to-different-address-checkbox):not([name*="subscribe"]):not([name*="newsletter"]):not([id*="newsletter"]):not([id*="mailchimp"]):not([id*="mc4wp"])', frame: '#woocommerceModal iframe', label: 'Agree to terms 2' },
{ action: 'click', selector: 'div#payment > div > div.woocommerce-terms-and-conditions-wrapper > p > label', frame: '#woocommerceModal iframe', label: 'Agree to terms' },
{ action: 'click', selector: '.woocommerce-invalid-required-field input', frame: '#woocommerceModal iframe', skipIfNotFound: true, label: 'Agree to terms if it didnt work' },
{ action: 'click', selector: '.woocommerce-terms-and-conditions-wrapper+p input', frame: '#woocommerceModal iframe', label: 'Agree to refund policy' },
{ action: 'click', selector: 'button#place_order', frame: '#woocommerceModal iframe', label: 'Place order' },
{ action: 'waitFor', selector: '#woocommerceModal', absent: true, hidden: true, timeoutSeconds: 30, label: 'Wait for the modal to close' },
{ action: 'waitFor', selector: 'div#successModal', label: 'Wait to click the success modal' },
{ action: 'click', selector: 'div#successModal button', label: 'Close the success modal' },
]},
],
},
],
],
},
];
@@ -369,8 +425,17 @@ function labelled(step: ResolvedStep, iteration: number, total: number): Resolve
return { ...step, label: `${step.label ?? describeStep(step)} (${iteration}/${total})` };
}
/** The guard a pre-0.20 runner knew as "the" login guard: the first URL guard.
* Its pattern is still stamped onto steps (`signedOut`) and its recovery steps
* still ship as `authSteps`, so a stale runner keeps working until restarted.
* A 0.20+ runner ignores both in favour of the run-level `guards`. */
function legacyLoginGuard(firm: Firm): PageGuard | undefined {
return firm.guards.find((g) => g.urlContains);
}
/** One non-repeat step, with the firm's tab pattern and URLs filled in. */
function resolveOne(firm: Firm, step: AutomationStep): ResolvedStep {
const login = legacyLoginGuard(firm);
if (step.action === 'wait') return step;
if (step.action === 'navigate') {
return {
@@ -378,8 +443,8 @@ function resolveOne(firm: Firm, step: AutomationStep): ResolvedStep {
url: step.url ?? firm.url,
urlPattern: firm.urlPattern,
openUrl: firm.url,
signedOut: firm.signedOutPattern,
signedOutWait: firm.signedOutWaitSeconds,
signedOut: login?.urlContains,
signedOutWait: login?.waitSeconds,
};
}
if (step.action === 'waitFor') {
@@ -402,8 +467,8 @@ function resolveOne(firm: Firm, step: AutomationStep): ResolvedStep {
...step,
urlPattern: step.urlPattern ?? firm.urlPattern,
openUrl: firm.url,
signedOut: firm.signedOutPattern,
signedOutWait: firm.signedOutWaitSeconds,
signedOut: login?.urlContains,
signedOutWait: login?.waitSeconds,
};
}
@@ -449,19 +514,34 @@ export function resolveSteps(firm: Firm, automation: Automation, inputs: RunInpu
return expand(automation.steps, firm, automation, inputs);
}
/** Auth steps run on the login page itself, so they get the firm's tab pattern
* but deliberately no `signedOut` guard — that guard exists to stop ordinary
/** Recovery steps run on the guard page itself, so they get the firm's tab
* pattern but deliberately no `signedOut` stamp — that exists to stop ordinary
* steps acting on a login form, and these are the exception.
*
* They share the expansion path, so a repeat works here too; there are no
* per-run inputs during auth, so `timesFrom` falls back to a single pass. */
export function resolveAuthSteps(firm: Firm): ResolvedStep[] {
const stub: Automation = { id: '_auth', label: 'auth', description: '', steps: firm.authSteps };
return expand(firm.authSteps, firm, stub, {}).map((step) =>
* per-run inputs during recovery, so `timesFrom` falls back to a single pass. */
function resolveRecoverySteps(firm: Firm, steps: AutomationStep[]): ResolvedStep[] {
const stub: Automation = { id: '_guard', label: 'guard recovery', description: '', steps };
return expand(steps, firm, stub, {}).map((step) =>
step.action === 'wait' ? step : { ...step, signedOut: undefined, signedOutWait: undefined }
);
}
/** Guards as handed to the runner, recovery steps expanded. */
export function resolveGuards(firm: Firm): ResolvedGuard[] {
return firm.guards.map((guard) => ({
...guard,
recoverySteps: resolveRecoverySteps(firm, guard.recoverySteps ?? []),
}));
}
/** The pre-0.20 payload shape: the login guard's recovery steps as `authSteps`.
* Kept so a stale runner still recovers from a signed-out redirect. */
export function resolveAuthSteps(firm: Firm): ResolvedStep[] {
const login = legacyLoginGuard(firm);
return login ? resolveRecoverySteps(firm, login.recoverySteps ?? []) : [];
}
/** What a step is doing, for the run log and the dashboard. */
export function describeStep(step: AutomationStep): string {
if (step.label) return step.label;
+128
View File
@@ -0,0 +1,128 @@
/**
* Restores a backup pulled from the master onto THIS machine — the other half
* of lib/backup-sync.ts, for replacing a dead VPS.
*
* Applying a backup overwrites this machine's settings, broker credentials,
* instruments and assignments, and — the point of the exercise — adopts the
* backed-up instance's uuid and name, so the master's roster, payouts and card
* links carry straight on. The master already handles adoption: a hello
* presenting a known uuid lands on that person's roster row.
*
* Run it before first boot via scripts/seed-settings.js --restore-from (which
* duplicates this apply logic — keep them in sync), or from the settings UI
* via POST /api/backup/restore. Either way the old machine must be stopped
* first: two live instances presenting one uuid fight over the socket.
*/
import {
setSetting,
getSetting,
setCredentials,
setInstrumentEnabled,
restoreAssignment,
runInTransaction,
} from './db';
import type { BackupPayload } from './backup-sync';
/** Mirror of the master's listing rows (lib/backups.ts BackupMeta). */
export interface BackupMeta {
id: string;
name: string;
savedAt: string;
sizeBytes: number;
}
const FETCH_TIMEOUT_MS = 15_000;
// Runtime bookkeeping describing the machine the backup was taken on, not
// this one. Kept in sync with the skip-set in scripts/seed-settings.js.
const RESTORE_SKIP_KEYS = new Set([
'runner_heartbeat',
'catalog_last_sync_at',
'catalog_last_error',
'backup_last_push_at',
'backup_last_error',
]);
function authedFetch(masterUrl: string, token: string, path: string) {
const url = new URL(path, masterUrl);
return fetch(url, {
headers: { authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
}
export async function fetchBackupList(masterUrl: string, token: string): Promise<BackupMeta[]> {
const res = await authedFetch(masterUrl, token, '/api/instances/backup');
if (!res.ok) {
const detail = await res.json().then((b) => b?.error).catch(() => '');
throw new Error(`master returned ${res.status}${detail ? ` — ${detail}` : ''}`);
}
const body = await res.json();
if (!Array.isArray(body?.backups)) throw new Error('unrecognized backup list');
return body.backups as BackupMeta[];
}
export async function fetchBackup(masterUrl: string, token: string, id: string): Promise<BackupPayload> {
const res = await authedFetch(masterUrl, token, `/api/instances/backup/${encodeURIComponent(id)}`);
if (!res.ok) {
const detail = await res.json().then((b) => b?.error).catch(() => '');
throw new Error(`master returned ${res.status}${detail ? ` — ${detail}` : ''}`);
}
const payload = await res.json() as BackupPayload;
if (payload?.v !== 1 || typeof payload.settings !== 'object' || payload.settings === null) {
throw new Error('unrecognized backup payload');
}
return payload;
}
export interface RestoreCounts {
settings: number;
credentials: number;
instruments: number;
assignments: number;
}
/**
* Overwrites this machine's configuration with the backup, including
* instance_id and instance_name — the identity adoption. Callers restart the
* process afterwards: live platform clients, the master link's hello and any
* cached settings all predate the restore.
*/
export function applyBackup(payload: BackupPayload, opts: { keepMasterUrl?: boolean } = {}): RestoreCounts {
// Defaults to keeping this machine's master url: the machine is talking to
// that master right now, while an old backup may point at a dead host.
const keepMasterUrl = opts.keepMasterUrl ?? true;
const ownMasterUrl = getSetting('master_dashboard_url');
const ownToken = getSetting('master_backup_token');
const counts: RestoreCounts = { settings: 0, credentials: 0, instruments: 0, assignments: 0 };
runInTransaction(() => {
for (const [key, value] of Object.entries(payload.settings)) {
if (RESTORE_SKIP_KEYS.has(key)) continue;
if (typeof value !== 'string') continue;
setSetting(key, value);
counts.settings++;
}
if (keepMasterUrl && ownMasterUrl) setSetting('master_dashboard_url', ownMasterUrl);
if (ownToken) setSetting('master_backup_token', ownToken);
for (const c of payload.credentials ?? []) {
setCredentials(c.firm_id, c.firm_name, c.username, c.password, c.api_key ?? '');
counts.credentials++;
}
for (const i of payload.instruments ?? []) {
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 ?? []) {
restoreAssignment(a);
counts.assignments++;
}
});
return counts;
}
+171
View File
@@ -0,0 +1,171 @@
/**
* Pushes this machine's full configuration — settings, broker credentials,
* instruments, manual assignments — to the master, so a dead VPS can be
* replaced by restoring the backup (lib/backup-restore.ts) and adopting this
* instance's uuid.
*
* This is the deliberate exception to "credentials never leave the machine":
* the payload goes over HTTPS to /api/instances/backup and is AES-encrypted
* at rest on the master. It never rides the control socket. Pushing needs no
* token — every instance backs up automatically once it has its uuid; only
* PULLING a backup back out (lib/backup-restore.ts) is token-gated, because
* that is the direction credentials come out in the clear.
*
* Push policy: on bootstrap, then every POLL_INTERVAL, but only when the
* content actually changed (hash comparison) — plus an immediate debounced
* push whenever settings or credentials are edited (scheduleBackupPush, called
* from the mutation routes).
*/
import { createHash } from 'node:crypto';
import {
getSetting,
setSetting,
listSettings,
listCredentials,
getInstruments,
listAllAssignments,
type FirmCredentialRow,
type BackupAssignmentRow,
} from './db';
export interface BackupPayload {
v: 1;
savedAt: string;
settings: Record<string, string>;
credentials: FirmCredentialRow[];
instruments: { symbol: string; enabled: boolean }[];
/** Whole rows (SELECT *), rules dates included — absent from a backup
* an older build pushed. */
assignments: BackupAssignmentRow[];
}
const BACKUP_PATH = '/api/instances/backup';
const POLL_INTERVAL_MS = 5 * 60_000;
const DEBOUNCE_MS = 5_000;
const FETCH_TIMEOUT_MS = 10_000;
const g = globalThis as typeof globalThis & {
__backupSyncTimer?: ReturnType<typeof setTimeout>;
__backupDebounceTimer?: ReturnType<typeof setTimeout>;
__backupLastHash?: string;
};
// Bookkeeping that describes this process run, not the configuration worth
// restoring. Excluded from the hash so a heartbeat doesn't look like a change
// worth pushing. (The restore side has its own skip-set; these still travel in
// the payload so a restore debugger can see them.)
const VOLATILE_KEYS = new Set([
'runner_heartbeat',
'catalog_last_sync_at',
'catalog_last_error',
'backup_last_push_at',
'backup_last_error',
]);
export function collectBackup(): BackupPayload {
return {
v: 1,
savedAt: new Date().toISOString(),
settings: listSettings(),
credentials: listCredentials(),
instruments: getInstruments().map((i) => ({ symbol: i.symbol, enabled: Boolean(i.enabled) })),
assignments: listAllAssignments(),
};
}
/** Stable digest of everything restorable, so unchanged config never pushes. */
function contentHash(payload: BackupPayload): string {
const settings = Object.entries(payload.settings)
.filter(([k]) => !VOLATILE_KEYS.has(k))
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
return createHash('sha256')
.update(JSON.stringify({
settings,
credentials: payload.credentials,
instruments: payload.instruments,
assignments: payload.assignments,
}))
.digest('hex');
}
function preconditions(): { masterUrl: string; id: string } | null {
const masterUrl = getSetting('master_dashboard_url');
// No uuid yet = the master has never said welcome; there is nothing stable
// to key the backup by, so wait for the first hello to complete. No token
// needed: pushes are open on the master (only PULLING a backup is
// token-gated), so every instance backs up automatically.
const id = getSetting('instance_id');
if (!masterUrl || !id) return null;
return { masterUrl, id };
}
export async function pushBackup(force = false): Promise<{ ok: boolean; skipped?: boolean; error?: string }> {
const pre = preconditions();
if (!pre) return { ok: false, skipped: true, error: 'master url or instance uuid not set' };
const payload = collectBackup();
const hash = contentHash(payload);
if (!force && hash === g.__backupLastHash) return { ok: true, skipped: true };
let url: URL;
try {
url = new URL(BACKUP_PATH, pre.masterUrl);
} catch {
return { ok: false, error: `master_dashboard_url is not a URL: ${pre.masterUrl}` };
}
try {
const res = await fetch(url, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
id: pre.id,
name: getSetting('instance_name') ?? '',
payload,
}),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!res.ok) {
const detail = await res.json().then((b) => b?.error).catch(() => '');
throw new Error(`master returned ${res.status}${detail ? ` — ${detail}` : ''}`);
}
g.__backupLastHash = hash;
setSetting('backup_last_push_at', new Date().toISOString());
setSetting('backup_last_error', '');
console.log('[backup] pushed to master');
return { ok: true };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
console.error('[backup] push failed:', error);
setSetting('backup_last_error', error);
return { ok: false, error };
}
}
/** Debounced push for the mutation routes: a settings PATCH or credential PUT
* lands on the master seconds later instead of at the next poll. */
export function scheduleBackupPush(): void {
if (g.__backupDebounceTimer) clearTimeout(g.__backupDebounceTimer);
g.__backupDebounceTimer = setTimeout(() => { void pushBackup(); }, DEBOUNCE_MS);
}
export function startBackupSync(): void {
stopBackupSync();
const tick = async () => {
// Forced: pushes need no token, so anyone who reaches the master and
// knows this uuid could overwrite our backup there. Re-asserting the
// real state every tick bounds that poisoning window to 5 minutes.
await pushBackup(true);
// Jitter so a fleet restarting together doesn't push in lockstep.
g.__backupSyncTimer = setTimeout(tick, POLL_INTERVAL_MS + Math.floor(Math.random() * 30_000));
};
void tick();
}
export function stopBackupSync(): void {
if (g.__backupSyncTimer) {
clearTimeout(g.__backupSyncTimer);
g.__backupSyncTimer = undefined;
}
}
BIN
View File
Binary file not shown.
+25 -5
View File
@@ -12,7 +12,8 @@
* 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 {
id: number;
@@ -31,7 +32,18 @@ const PROBE_WINDOW = 6;
// ── Cache ────────────────────────────────────────────────────────────────────
const cache = new Map<string, { contract: ResolvedContract; resolvedAt: number }>();
// On globalThis, not module scope: instrumentation.ts boots the process from its
// own bundle, so the boot and midnight resolves would otherwise fill a cache the
// 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 {
@@ -44,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) {
@@ -64,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;
@@ -99,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;
@@ -198,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)`);
@@ -205,7 +224,8 @@ export async function resolveContracts(
console.log(`[contract-resolver] ${sym}: ${match.name} resolved`);
}
} catch (err) {
console.error(`[contract-resolver] ${sym}: Tradovate lookup failed`, err);
// Never the whole error: an AxiosError carries the Bearer token in its config.
console.error(`[contract-resolver] ${sym}: Tradovate lookup failed — ${errorMessage(err)}`);
results[sym] = null;
}
}));
+588 -28
View File
@@ -1,9 +1,31 @@
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';
const db = new Database(path.join(process.cwd(), 'autotrader.sqlite'));
// 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
// import side effects — so it must be in the environment at process start.
const DB_PATH = process.env.AUTOFIRMER_DB
? path.resolve(process.env.AUTOFIRMER_DB)
: path.join(process.cwd(), 'autotrader.sqlite');
mkdirSync(path.dirname(DB_PATH), { recursive: true });
const db = new Database(DB_PATH);
db.pragma('foreign_keys = ON');
// Timeout first: every later statement, including the journal switch below,
// waits out a concurrent holder instead of failing with SQLITE_BUSY.
db.pragma('busy_timeout = 5000');
// WAL is best-effort. Switching journal mode needs the file to itself, and
// `next build` imports this module from several workers while the live server
// still holds the same file — a hard failure here fails the whole build.
try {
db.pragma('journal_mode = WAL');
} catch {
// Another connection holds the file; keep its journal mode.
}
db.exec(`
CREATE TABLE IF NOT EXISTS instruments (
@@ -49,6 +71,71 @@ db.exec(`
// Tradovate credentials, and a silent DROP would be unrecoverable if this
// change had to be rolled back. Safe to drop by hand once it has stuck.
// Migration: TopstepX firms authenticate with an API key instead of a
// password. Kept in the same table so re-keying and reconciliation treat both
// platforms alike; a Tradovate row simply leaves it blank.
try {
db.exec("ALTER TABLE firm_credentials ADD COLUMN api_key TEXT NOT NULL DEFAULT ''");
} catch {
// 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 {
@@ -56,13 +143,15 @@ export interface FirmCredentialRow {
firm_name: string;
username: string;
password: string;
api_key: string;
updated_at: string;
}
export function getCredentials(firmId: string): { username: string; password: string } | undefined {
const row = db.prepare('SELECT username, password FROM firm_credentials WHERE firm_id = ?').get(firmId) as
{ username: string; password: string } | undefined;
return row;
export function getCredentials(firmId: string): { username: string; password: string; apiKey: string } | undefined {
const row = db.prepare('SELECT username, password, api_key FROM firm_credentials WHERE firm_id = ?').get(firmId) as
{ username: string; password: string; api_key: string } | undefined;
if (!row) return undefined;
return { username: row.username, password: row.password, apiKey: row.api_key };
}
export function listCredentials(): FirmCredentialRow[] {
@@ -72,16 +161,17 @@ export function listCredentials(): FirmCredentialRow[] {
/** firm_name is stored alongside the id so credentials can be re-keyed by name
* if the master's database is ever rebuilt and the firms come back with new
* ids. See rekeyCredentialsByName in lib/firm-catalog.ts. */
export function setCredentials(firmId: string, firmName: string, username: string, password: string): void {
export function setCredentials(firmId: string, firmName: string, username: string, password: string, apiKey = ''): void {
db.prepare(`
INSERT INTO firm_credentials (firm_id, firm_name, username, password, updated_at)
VALUES (?, ?, ?, ?, ?)
INSERT INTO firm_credentials (firm_id, firm_name, username, password, api_key, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(firm_id) DO UPDATE SET
firm_name = excluded.firm_name,
username = excluded.username,
password = excluded.password,
api_key = excluded.api_key,
updated_at = excluded.updated_at
`).run(firmId, firmName, username, password, new Date().toISOString());
`).run(firmId, firmName, username, password, apiKey, new Date().toISOString());
}
export function clearCredentials(firmId: string): boolean {
@@ -101,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;
}
@@ -111,33 +211,121 @@ export function listAssignments(firmId: string): AssignmentRow[] {
return db.prepare('SELECT * FROM account_assignments WHERE firm_id = ?').all(firmId) as AssignmentRow[];
}
/** Every assignment, for the backup pushed to the master. */
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 ─────────────────────────────────────────────────────────────────
@@ -167,12 +355,32 @@ seedSetting.run('twocaptcha_api_key', '');
// Blank falls back to the proxy hardcoded in app/api/autobuyer/captcha; `none`
// solves without one.
seedSetting.run('twocaptcha_proxy', '');
// Bearer for PULLING an encrypted settings+credentials backup back from the
// master (its INSTANCE_BACKUP_TOKEN) — pushing needs no token and happens
// automatically. Blank = restore unavailable from this machine's UI.
seedSetting.run('master_backup_token', '');
// Routes broker traffic (TopstepX, Tradovate, Yahoo quotes) through a proxy:
// http(s):// or socks5(h)://, user:pass ok. The master link stays direct on
// purpose. Blank = direct. Takes effect on restart.
seedSetting.run('proxy_url', '');
// Mirrors the in-memory scheduler state so a restart can resume it.
seedSetting.run('scheduler_running', '0');
seedSetting.run('scheduler_action', 'Buy');
seedSetting.run('scheduler_symbol', 'NQ');
seedSetting.run('scheduler_stop_after_all', '0');
/** Wraps several writes in one transaction (used by the backup restore, so a
* half-applied backup cannot survive a crash). */
export function runInTransaction<T>(fn: () => T): T {
return db.transaction(fn)();
}
/** The whole table, for the backup pushed to the master. */
export function listSettings(): Record<string, string> {
const rows = db.prepare('SELECT key, value FROM settings').all() as { key: string; value: string }[];
return Object.fromEntries(rows.map((r) => [r.key, r.value]));
}
export function getSetting(key: string): string | null {
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined;
return row?.value ?? null;
@@ -182,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'];
@@ -190,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;
@@ -224,18 +449,27 @@ db.exec(`
);
`);
export function saveDailyPnL(accountId: number, accountName: string, entries: { date: string; pnl: number }[]): void {
const ins = db.prepare('INSERT OR REPLACE INTO daily_pnl (account_id, account_name, date, pnl) VALUES (?, ?, ?, ?)');
// Migration: the day's reported fees (TopstepX). The MLL reconstruction
// distributes off-book fee drift across days proportionally to these; rows
// from before the migration read 0 until the next fetch overwrites them.
try {
db.exec('ALTER TABLE daily_pnl ADD COLUMN fees REAL NOT NULL DEFAULT 0');
} catch {
// Column already exists
}
export function saveDailyPnL(accountId: number, accountName: string, entries: { date: string; pnl: number; fees?: number }[]): void {
const ins = db.prepare('INSERT OR REPLACE INTO daily_pnl (account_id, account_name, date, pnl, fees) VALUES (?, ?, ?, ?, ?)');
const txn = db.transaction(() => {
for (const e of entries) {
ins.run(accountId, accountName, e.date, e.pnl);
ins.run(accountId, accountName, e.date, e.pnl, e.fees ?? 0);
}
});
txn();
}
export function loadDailyPnL(accountId: number): { date: string; pnl: number }[] {
return (db.prepare('SELECT date, pnl FROM daily_pnl WHERE account_id = ? ORDER BY date').all(accountId) as { date: string; pnl: number }[]);
export function loadDailyPnL(accountId: number): { date: string; pnl: number; fees: number }[] {
return (db.prepare('SELECT date, pnl, fees FROM daily_pnl WHERE account_id = ? ORDER BY date').all(accountId) as { date: string; pnl: number; fees: number }[]);
}
// ── Fund Transactions Cache ───────────────────────────────────────────────────
@@ -262,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(`
@@ -282,6 +532,316 @@ export function loadAccountMeta(accountId: number, key: string): string | null {
return row?.value ?? null;
}
/** Where the cycle the latest fund transaction ended began, saved by the
* platform client as it moves last_fund_date forward — and only from a cycle
* start this instance saw, so the reporter's catch-up never judges a cycle
* whose older rows it lacks. fundDate = that cycle's fund date ('' for the
* account's first cycle); includesDay = whether that date's own trades were
* the cycle's (a deposit's are; a Tradovate withdrawal's only when it came
* before the day's first trade, which the reporter can't work out again). */
export function saveEndedCycleStart(accountId: number, start: { fundDate: string; includesDay: boolean }): void {
saveAccountMeta(accountId, 'ended_cycle_start', JSON.stringify(start));
}
/** null when this instance never saw the ended cycle start: a new instance
* or a wipe (which clears it with the rest of the meta), or TopstepX's
* first-check lump. */
export function loadEndedCycleStart(accountId: number): { fundDate: string; includesDay: boolean } | null {
const raw = loadAccountMeta(accountId, 'ended_cycle_start');
if (raw === null) return null;
try {
const { fundDate, includesDay } = JSON.parse(raw) as { fundDate?: unknown; includesDay?: unknown };
return typeof fundDate === 'string' && typeof includesDay === 'boolean' ? { fundDate, includesDay } : null;
} catch {
return null;
}
}
/** 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
* pre-roll dates), but it is why callers confirm first. */
/** account_meta keys a wipe leaves in place (see wipeAccountData). */
const WIPE_KEEPS = ['cash_baseline', 'roll_base', 'history_hold', 'wipe_fund_sig', 'inferred_account_size'];
export function wipeAccountData(accountId: number): void {
const txn = db.transaction(() => {
db.prepare('DELETE FROM daily_pnl WHERE account_id = ?').run(accountId);
db.prepare('DELETE FROM fund_transactions WHERE account_id = ?').run(accountId);
// History goes; what guards the rebuild stays — the balance baseline
// and roll base a Cash History vouched for, the wipe's own hold and
// pre-wipe fund signature — and the resolver's inferred size, which
// nothing in the rebuilt history can infer again.
db.prepare(`DELETE FROM account_meta WHERE account_id = ? AND key NOT IN (${WIPE_KEEPS.map(() => '?').join(', ')})`)
.run(accountId, ...WIPE_KEEPS);
});
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(`
@@ -575,7 +1135,7 @@ export const RUNNER_TIMEOUT_MS = 7000;
/** The runner version this server's step vocabulary requires. A running process
* doesn't reload when the source changes, so an older one silently fails on
* steps it predates — the dashboard warns instead. */
export const RUNNER_EXPECTED_VERSION = '0.19.0';
export const RUNNER_EXPECTED_VERSION = '0.20.0';
export interface RunnerHeartbeat {
at: number;
+1929
View File
File diff suppressed because it is too large Load Diff
+483
View File
@@ -0,0 +1,483 @@
/**
* Pure helpers for the DXTrade client (lib/dxtrade-class.ts): account ids,
* contract names, the firm-day clock, parsing the API's loosely typed
* answers, and the instrument selector's volatility and cost scoring.
*
* No imports beyond node built-ins, so scripts/check-trading-logic.mjs can
* test them directly.
*/
import { createHash } from 'crypto';
// ── account ids ──────────────────────────────────────────────────────────────
/**
* A stable numeric id for a DXTrade account code ("clearing:account"). Every
* account table, route and backup keys on a number, and DXTrade codes are
* strings, so the code is hashed — deterministically, which is what lets a
* backup restored onto a new machine find its manual assignments again (a
* per-machine id table would renumber them).
*
* 52 bits of SHA-256 folded into [2^50, 2^53): safe integers, far above any
* Tradovate or TopstepX id. The host is part of the key, so the same code on
* two DXTrade servers never collides — and changing a firm's server URL
* re-keys its accounts.
*/
export function dxAccountId(host: string, code: string): number {
const digest = createHash('sha256').update(`dxtrade|${host.toLowerCase()}|${code}`).digest();
return 2 ** 50 + digest.readUIntBE(0, 6) * 16 + (digest[6] >> 4);
}
/** "default:TFY-50K-123" → "TFY-50K-123": what the dashboard shows and what
* account-type prefixes match against. */
export function accountDisplayName(code: string): string {
const i = code.indexOf(':');
return i >= 0 && i < code.length - 1 ? code.slice(i + 1) : code;
}
/** DXTrade answers carry account codes either as "clearing:account" or as an
* {clearing, account} object, depending on the endpoint's serializer. */
export function accountCodeOf(raw: unknown): string | null {
if (typeof raw === 'string' && raw) return raw;
if (raw && typeof raw === 'object') {
const r = raw as { clearing?: unknown; account?: unknown };
if (typeof r.account === 'string') return typeof r.clearing === 'string' ? `${r.clearing}:${r.account}` : r.account;
}
return null;
}
// ── contract names ───────────────────────────────────────────────────────────
/** An engine "contract" on DXTrade is one quantity step of a symbol; the
* step rides in the name the engine hands back to sendOrder, which keeps
* order placement stateless. Symbols never contain '|' (the master refuses
* them). */
export function encodeContractName(symbol: string, step: number): string {
return `${symbol}|${step}`;
}
export function decodeContractName(name: string): { symbol: string; step: number } | null {
const i = name.lastIndexOf('|');
if (i <= 0) return null;
const step = Number(name.slice(i + 1));
if (!Number.isFinite(step) || step <= 0) return null;
return { symbol: name.slice(0, i), step };
}
/** Decimal places of a step like 0.001 (or 1e-7), for exact rounding. */
export function decimalsOf(step: number): number {
if (!Number.isFinite(step) || step <= 0) return 0;
const s = step.toString();
const exp = s.match(/e-(\d+)$/);
if (exp) return Number(exp[1]) + (s.split('e')[0].split('.')[1]?.length ?? 0);
return s.split('.')[1]?.length ?? 0;
}
/** A multiple of `step` without float fuzz (0.1 + 0.2 style). */
export function roundToStep(value: number, step: number, mode: 'nearest' | 'down' | 'up' = 'nearest'): number {
if (!(step > 0)) return value;
const n = value / step;
const k = mode === 'down' ? Math.floor(n + 1e-9) : mode === 'up' ? Math.ceil(n - 1e-9) : Math.round(n);
return Number((k * step).toFixed(decimalsOf(step)));
}
// ── clocks ───────────────────────────────────────────────────────────────────
/** The most recent 22:00 UTC at or before `now` — when Tradeify (and DXtrade
* brokers generally) snapshot the closed balance and start a new day. */
export function lastUtcReset(now: Date, hourUtc = 22): Date {
const reset = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), hourUtc));
if (reset.getTime() > now.getTime()) reset.setUTCDate(reset.getUTCDate() - 1);
return reset;
}
/** The trading day the firm books a moment to: the day that began at the
* reset before it, labelled by the calendar date that follows — the label
* currentTradeDateCT gives the same day. In summer the two roll together
* (22:00 UTC = 5 PM CDT); in winter the firm's rolls an hour earlier, and a
* close in that hour belongs to the new day, as the firm counts it. */
export function firmDayOf(at: Date, hourUtc = 22): string {
return new Date(at.getTime() + (24 - hourUtc) * 3_600_000).toISOString().slice(0, 10);
}
/** When the current Central trade date began (5 PM CT) — the window today's
* realized P&L covers, matching currentTradeDateCT. */
export function ctTradeDayStart(now: Date): Date {
const parts = new Intl.DateTimeFormat('en-US', {
hour: 'numeric', minute: 'numeric', second: 'numeric', hour12: false, timeZone: 'America/Chicago',
}).formatToParts(now);
const get = (t: string) => Number(parts.find((p) => p.type === t)?.value ?? '0');
const secondsIntoDay = (get('hour') % 24) * 3600 + get('minute') * 60 + get('second');
const sinceRoll = (secondsIntoDay - 17 * 3600 + 86_400) % 86_400;
return new Date(Math.floor(now.getTime() / 1000) * 1000 - sinceRoll * 1000);
}
/** DXTrade timestamps: ISO strings on the wire (the schema calls them
* objects); epoch ms tolerated. NaN when unreadable. */
export function timeOf(raw: unknown): number {
if (typeof raw === 'number') return raw;
if (typeof raw === 'string') return Date.parse(raw);
return NaN;
}
/** The login answer's inactivity timeout, in ms: "PT30M", "01:05:08", a
* {seconds} object or a number of seconds. Falls back to 30 minutes. */
export function parseDurationMs(raw: unknown): number {
const fallback = 30 * 60_000;
if (typeof raw === 'number' && raw > 0) return raw * 1000;
if (raw && typeof raw === 'object') {
const s = (raw as { seconds?: unknown }).seconds;
if (typeof s === 'number' && s > 0) return s * 1000;
}
if (typeof raw === 'string') {
const iso = raw.match(/^P(?:T)?(?:(\d+(?:\.\d+)?)H)?(?:(\d+(?:\.\d+)?)M)?(?:(\d+(?:\.\d+)?)S)?$/i);
if (iso && (iso[1] || iso[2] || iso[3])) {
return ((Number(iso[1] ?? 0) * 60 + Number(iso[2] ?? 0)) * 60 + Number(iso[3] ?? 0)) * 1000;
}
const hms = raw.match(/^(\d+):(\d{2}):(\d{2})$/);
if (hms) return ((Number(hms[1]) * 60 + Number(hms[2])) * 60 + Number(hms[3])) * 1000;
}
return fallback;
}
// ── loose parsing ────────────────────────────────────────────────────────────
/** A number field read case-insensitively — the live server says openPL
* where the docs say openPl. */
export function ciNum(obj: unknown, key: string): number | undefined {
if (!obj || typeof obj !== 'object') return undefined;
const want = key.toLowerCase();
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
if (k.toLowerCase() === want && typeof v === 'number' && Number.isFinite(v)) return v;
}
return undefined;
}
/** Deposits and withdrawals move money in or out; every other cash
* transaction (SETTLEMENT, COMMISSION, FINANCING, ADJUSTMENT…) is trading. */
export function isFundType(type: unknown): boolean {
return type === 'DEPOSIT' || type === 'WITHDRAWAL';
}
/** A withdrawal always takes money out, whatever sign the server files it with. */
export function signedFundValue(type: string, value: number): number {
return type === 'WITHDRAWAL' ? -Math.abs(value) : Math.abs(value);
}
export interface DxTxn {
key: string;
account: string;
type: string;
value: number;
time: number;
orderCode: string | undefined;
}
/**
* The cash transactions in a /transfers answer, flattened. A close books its
* SETTLEMENT (the trade's P&L) and its COMMISSION under one transaction code,
* so the key carries the type — and a running count, should a transfer ever
* hold two of one type — or one would overwrite the other.
*/
export function parseTransfers(body: unknown): DxTxn[] {
const transfers = (body as { cashTransfers?: unknown[] })?.cashTransfers ?? [];
const out: DxTxn[] = [];
for (const t of transfers) {
const transfer = t as Record<string, unknown>;
const txns = Array.isArray(transfer.cashTransactions) ? transfer.cashTransactions : [];
const seen = new Map<string, number>();
for (const x of txns) {
const tx = x as Record<string, unknown>;
const account = accountCodeOf(tx.account) ?? accountCodeOf(transfer.account);
const value = ciNum(tx, 'value');
const time = timeOf(tx.transactionTime ?? transfer.transactionTime);
if (!account || value === undefined || !Number.isFinite(time) || typeof tx.type !== 'string') continue;
const code = typeof tx.transactionCode === 'string' ? tx.transactionCode : '';
const base = code
? `${code}|${tx.type}`
: `${transfer.transferCode ?? ''}|${tx.type}|${time}|${value}|${tx.orderCode ?? ''}|${tx.tradeCode ?? ''}`;
const n = seen.get(base) ?? 0;
seen.set(base, n + 1);
out.push({
key: n === 0 ? base : `${base}|${n}`,
account,
type: tx.type,
value,
time,
orderCode: typeof tx.orderCode === 'string' ? tx.orderCode : undefined,
});
}
}
return out;
}
/**
* The margin an account posts per dollar of notional on an instrument — its
* leverage cap, inverted (0.2 = 5:1). A tiered rate reads as its steepest
* tier. undefined when the details don't say.
*/
export function marginRateOf(details: unknown): number | undefined {
const rate = (details as { marginRate?: { tiers?: { value?: unknown }[]; value?: unknown } } | undefined)?.marginRate;
const values = [...(rate?.tiers ?? []).map((t) => Number(t?.value)), Number(rate?.value)]
.filter((v) => Number.isFinite(v) && v > 0 && v <= 1);
return values.length > 0 ? Math.max(...values) : undefined;
}
/** A client order code: unique, ≤ 64 chars, and recognisably ours. */
export function newOrderCode(tag: string): string {
const rand = Math.random().toString(36).slice(2, 8);
return `af-${Date.now().toString(36)}-${rand}-${tag}`.slice(0, 64);
}
// ── trading hours ────────────────────────────────────────────────────────────
const WEEKDAYS = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY'];
/**
* Whether an instrument's sessions have it open at `now`. Sessions are
* SESSION_OPEN / SESSION_CLOSE events on a UTC week; the latest event at or
* before now (wrapping around the week) decides. No sessions, or any it
* can't read, means always open — 24/7 crypto lists none.
*/
export function inTradingHours(sessions: unknown, now: Date): boolean {
if (!Array.isArray(sessions) || sessions.length === 0) return true;
const events: { at: number; open: boolean }[] = [];
for (const s of sessions) {
const e = s as { eventType?: unknown; weekDay?: { dayOfWeek?: unknown; time?: { hour?: unknown; minute?: unknown } } };
const day = WEEKDAYS.indexOf(String(e.weekDay?.dayOfWeek ?? ''));
const hour = Number(e.weekDay?.time?.hour ?? NaN);
const minute = Number(e.weekDay?.time?.minute ?? 0);
if (day < 0 || !Number.isFinite(hour) || (e.eventType !== 'SESSION_OPEN' && e.eventType !== 'SESSION_CLOSE')) return true;
events.push({ at: day * 1440 + hour * 60 + minute, open: e.eventType === 'SESSION_OPEN' });
}
events.sort((a, b) => a.at - b.at);
const t = now.getUTCDay() * 1440 + now.getUTCHours() * 60 + now.getUTCMinutes();
const past = events.filter((e) => e.at <= t);
const latest = past.length > 0 ? past[past.length - 1] : events[events.length - 1];
return latest.open;
}
// ── instrument selector ──────────────────────────────────────────────────────
/** Fewest hourly candles a volatility estimate is trusted on. */
export const MIN_VOL_CANDLES = 24;
/**
* σ of hourly log returns (per √hour) from hourly closes, oldest first, in
* wall-clock time — flat hours count, so an instrument that sits still all
* weekend really does read as quiet on a weekend. null on too few candles.
*/
export function realizedVolPerHour(closes: number[]): number | null {
const clean = closes.filter((c) => Number.isFinite(c) && c > 0);
if (clean.length < MIN_VOL_CANDLES + 1) return null;
const returns: number[] = [];
for (let i = 1; i < clean.length; i++) returns.push(Math.log(clean[i] / clean[i - 1]));
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);
}
/**
* A candidate's cost score — lower is better.
*
* A leg sized for a hold of T hours needs notional ∝ 1/σ, so its cost —
* spread plus both commissions plus financing over the hold, all
* proportional to notional — is proportional to (spread + 2c + f·T/24) / σ.
* A volatile instrument reaches the take-profit with less notional, which is
* what makes a tight spread alone not enough.
*
* null = can't be scored (no σ).
*/
export function scoreCandidate(o: {
spreadFraction: number;
commissionPct: number;
financingPctPerDay: number;
targetHoldHours: number;
volatilityPerHour: number | null;
}): number | null {
if (!o.volatilityPerHour || !(o.volatilityPerHour > 0)) return null;
const financing = (o.financingPctPerDay / 100) * (Math.max(0, o.targetHoldHours) / 24);
return (o.spreadFraction + 2 * (o.commissionPct / 100) + financing) / o.volatilityPerHour;
}
/** Most a spread may be, as a share of the mid price, for a candidate to trade. */
export const MAX_SPREAD_FRACTION = 0.005;
/** A quote older than this doesn't count as live. */
export const MAX_QUOTE_AGE_MS = 5 * 60_000;
/** Why a candidate can't trade right now, or null when it can. */
export function candidateRejection(o: {
tradingStatus: string | undefined;
bid: number | undefined;
ask: number | undefined;
quoteAgeMs: number | null;
inHours: boolean;
candles: number;
minContractsOk: boolean;
currency: string | undefined;
accountCurrency: string;
}): string | null {
if (o.tradingStatus !== undefined && o.tradingStatus !== 'FULL') return `trading status ${o.tradingStatus}`;
if (!o.inHours) return 'outside trading hours';
if (!(typeof o.bid === 'number' && o.bid > 0 && typeof o.ask === 'number' && o.ask >= o.bid)) return 'no live quote';
if (o.quoteAgeMs !== null && o.quoteAgeMs > MAX_QUOTE_AGE_MS) return `quote ${Math.round(o.quoteAgeMs / 60_000)}m old`;
const mid = (o.bid + o.ask) / 2;
if ((o.ask - o.bid) / mid > MAX_SPREAD_FRACTION) return `spread ${(((o.ask - o.bid) / mid) * 100).toFixed(2)}%`;
if (o.candles < MIN_VOL_CANDLES + 1) return `only ${o.candles} hourly candles`;
if (!o.minContractsOk) return 'minimum order size out of reach';
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();
}
+275 -43
View File
@@ -17,7 +17,14 @@
* the two are not linked yet.)
*/
import type { CatalogAccountType, CatalogFirm, FirmCatalogPayload } from './master-protocol';
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, KeptAccountRules, SymbolMap,
SymbolMapCandidate,
} from './master-protocol';
import {
getSetting,
setSetting,
@@ -35,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 {
@@ -62,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');
}
@@ -83,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;
@@ -99,29 +177,107 @@ export function getCatalogMeta(): {
};
}
/** Firms this machine can actually trade: in the catalog AND holding
* credentials. Everything that starts a Tradovate client goes through here. */
export function getTradableFirms(): (CatalogFirm & { username: string; password: string })[] {
const result: (CatalogFirm & { username: string; password: string })[] = [];
/** Whether stored credentials are what the firm's platform logs in with — a
* 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,
): boolean {
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;
}
}
/** Firms this machine can actually trade: in the catalog AND holding the
* credentials their platform needs. Everything that starts a platform client
* goes through here. */
export function getTradableFirms(): (CatalogFirm & { username: string; password: string; apiKey: string })[] {
const result: (CatalogFirm & { username: string; password: string; apiKey: string })[] = [];
for (const firm of getCachedCatalog()) {
// Only Tradovate has a client. A TopstepX firm in the catalog is
// config-only for now; handing it to the Tradovate login would just
// burn attempts against the wrong platform.
if (firm.platform !== 'tradovate') continue;
const creds = getCredentials(firm.id);
if (creds) result.push({ ...firm, ...creds });
if (creds && hasPlatformCredentials(firm.platform, creds)) result.push({ ...firm, ...creds });
}
return result;
}
// ── Sync ─────────────────────────────────────────────────────────────────────
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');
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 {
const warned = (g2.__catalogUnknownPlatformWarned ??= new Set());
const key = `${id}|${String(platform)}`;
if (warned.has(key)) return;
warned.add(key);
console.warn(`[catalog] skipping "${name}": platform ${JSON.stringify(platform)} is unknown to this build — update this instance`);
}
function nonNegative(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0;
}
/** Drops anything malformed rather than failing the sync: a bad symbol-map
* entry only means that candidate isn't traded. */
function sanitizeSymbolMap(raw: unknown): SymbolMap {
const map: SymbolMap = {};
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return map;
for (const [root, list] of Object.entries(raw as Record<string, unknown>)) {
if (!Array.isArray(list)) continue;
const candidates = list.flatMap((c): SymbolMapCandidate[] => {
if (typeof c !== 'object' || c === null) return [];
const cand = c as Record<string, unknown>;
if (typeof cand.symbol !== 'string' || !cand.symbol || cand.symbol.includes('|')) return [];
return [{ symbol: cand.symbol }];
});
if (candidates.length > 0) map[root.toUpperCase()] = candidates;
}
return map;
}
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
@@ -133,44 +289,91 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
if (typeof body.updatedAt !== 'string') throw new Error('missing updatedAt');
if (!Array.isArray(body.firms)) throw new Error('missing firms');
const firms: CatalogFirm[] = body.firms.map((f: unknown, i: number) => {
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>;
if (typeof firm.id !== 'string' || typeof firm.name !== 'string') {
throw new Error(`firm ${i} is missing id or name`);
}
// An old master sends no platform: that is Tradovate. A platform this
// build doesn't know is NOT — coercing it would start the wrong client
// with another platform's credentials. Skip the firm (before its
// account types can fail the whole sync); it comes back once this
// build knows the platform.
const platform = firm.platform === undefined ? 'tradovate' : firm.platform;
if (!isFirmPlatform(platform)) {
warnUnknownPlatform(firm.id, firm.name, platform);
skippedPlatforms.add(String(platform));
return;
}
// Percent commission and every-day trading are settings of the
// 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`);
return type 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;
return {
firms.push({
id: firm.id,
name: firm.name,
// Lenient on purpose: a master from before the platform field (or
// with a platform this build doesn't know) must not fail the whole
// sync, and 'tradovate' is what every firm was until then.
platform: firm.platform === 'topstepx' ? 'topstepx' : 'tradovate',
platform,
// 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,
tradesEveryDay,
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(),
};
});
});
return { version: body.version, updatedAt: body.updatedAt, firms };
const trading = parseTradingSettings(body.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
* usable), so the defaults apply. */
function parseTradingSettings(raw: unknown): CatalogTradingSettings | undefined {
if (typeof raw !== 'object' || raw === null) return undefined;
const max = (raw as Record<string, unknown>).maxEntrySpreadDollars;
return typeof max === 'number' && Number.isFinite(max) && max >= 0 ? { maxEntrySpreadDollars: max } : undefined;
}
/** What the master's Settings page says, or the defaults from before it. */
export function getTradingSettings(): CatalogTradingSettings {
return cache().payload?.trading ?? { maxEntrySpreadDollars: DEFAULT_MAX_ENTRY_SPREAD };
}
function catalogUrl(): URL | null {
@@ -190,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. */
@@ -218,15 +432,26 @@ export async function syncCatalog(): Promise<{ ok: boolean; changed: boolean; ve
// Only the URL is required — deliberately not instance_name, unlike the
// reporter. A freshly imaged machine has to be able to pull its catalog
// 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. 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;
if (hasCache) url.searchParams.set('since', String(version));
const source = url.origin;
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) {
setSetting('catalog_source_url', source);
setSetting('catalog_last_sync_at', new Date().toISOString());
setSetting('catalog_last_error', '');
return { ok: true, changed: false, version };
@@ -245,19 +470,26 @@ 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);
}
}
setSetting('catalog_payload', JSON.stringify(payload));
setSetting('catalog_version', String(payload.version));
setSetting('catalog_source_url', source);
setSetting('catalog_updated_at', payload.updatedAt);
setSetting('catalog_last_sync_at', new Date().toISOString());
setSetting('catalog_last_error', suspicious.length > 0
+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);
+89
View File
@@ -0,0 +1,89 @@
/**
* Public Hyperliquid market data — the price source for DXtrade firms whose
* API user isn't allowed DXtrade's own /marketdata (Tradeify247 answers
* "No proper permission is configured", code 34). Tradeify247's instruments
* track Hyperliquid's: crypto on the main dex (BTC, ETH), indices and
* tokenized stocks on the trade[XYZ] HIP-3 dex (xyz:XYZ100, xyz:SP500…).
*
* Only the instrument pick uses it — spread for the cost score, hourly closes
* for volatility, the mid for sizing. Orders, fills, balances and every
* take-profit/stop price still come from DXtrade and its own fill prices.
*
* No key, no account: POST https://api.hyperliquid.xyz/info.
*/
const INFO_URL = 'https://api.hyperliquid.xyz/info';
const TIMEOUT_MS = 10_000;
const META_TTL_MS = 60 * 60_000;
/** Dexes whose coins a DXtrade symbol may map to, main dex first. */
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),
headers: { 'Content-Type': 'application/json' },
});
return res.data as T;
}
const g = globalThis as typeof globalThis & { __hlCoins?: { coins: Set<string>; at: number } };
/** Every coin on the dexes above ("BTC", "xyz:XYZ100", …), cached an hour. */
export async function hyperliquidCoins(): Promise<Set<string>> {
if (g.__hlCoins && Date.now() - g.__hlCoins.at < META_TTL_MS) return g.__hlCoins.coins;
const coins = new Set<string>();
for (const dex of DEXES) {
const meta = await info<{ universe?: { name?: unknown; isDelisted?: unknown }[] }>(dex ? { type: 'meta', dex } : { type: 'meta' });
for (const u of meta?.universe ?? []) {
if (typeof u.name === 'string' && u.isDelisted !== true) coins.add(u.name);
}
}
g.__hlCoins = { coins, at: Date.now() };
return coins;
}
/**
* The Hyperliquid coin for a DXtrade symbol, or null. "BTC/USD" → "BTC" on
* the main dex; "XYZ100" or "AAPL/USD" → "xyz:XYZ100" / "xyz:AAPL" on the
* trade[XYZ] dex when the main dex doesn't list it.
*/
export function hyperliquidCoinFor(symbol: string, coins: Set<string>): string | null {
const base = symbol.toUpperCase().replace(/[/_-]?(USDT|USDC|USD)$/, '');
if (!base) return null;
for (const candidate of [base, `xyz:${base}`]) {
if (coins.has(candidate)) return candidate;
}
return null;
}
/** Best bid/ask of an l2Book answer; undefined sides when the book is empty. */
export function parseL2Book(book: unknown): { bid?: number; ask?: number; time: number } {
const b = book as { levels?: { px?: unknown }[][]; time?: unknown };
const px = (side: number) => {
const v = Number(b?.levels?.[side]?.[0]?.px);
return Number.isFinite(v) && v > 0 ? v : undefined;
};
const time = Number(b?.time);
return { bid: px(0), ask: px(1), time: Number.isFinite(time) ? time : NaN };
}
export async function hyperliquidQuote(coin: string): Promise<{ bid?: number; ask?: number; time: number }> {
return parseL2Book(await info({ type: 'l2Book', coin }));
}
/** Hourly closes, oldest first, over the last `hours`. */
export async function hyperliquidHourlyCloses(coin: string, hours: number): Promise<number[]> {
const endTime = Date.now();
const candles = await info<{ t?: unknown; c?: unknown }[]>({
type: 'candleSnapshot',
req: { coin, interval: '1h', startTime: endTime - (hours + 1) * 3_600_000, endTime },
});
return (Array.isArray(candles) ? candles : [])
.map((c) => ({ t: Number(c.t), c: Number(c.c) }))
.filter((c) => Number.isFinite(c.t) && Number.isFinite(c.c) && c.c > 0)
.sort((a, b) => a.t - b.t)
.map((c) => c.c);
}
+293
View File
@@ -0,0 +1,293 @@
/**
* Contract specs for the CME futures the bot trades, and the pure maths of
* choosing between them — the session-aware Auto draw, the 12-hour hold cap,
* the legs that always trade a micro — plus reading a firm's instrument list
* off the catalog.
*
* Specs live here, not on the master: point value and tick size size real
* orders, so they aren't editable remotely. The master only knows the names.
*
* No imports: scripts/check-instruments.mjs loads this file directly.
*/
export interface CmeSpec {
name: string;
/** Dollars per 1.0 of price, per contract. */
pointValue: number;
tickSize: number;
/** Typical bid/ask spread in ticks — a liquid front month is one tick
* wide. Used when no live quote is at hand (lib/live-quotes.ts). */
spreadTicks: number;
/** Yahoo's continuous-contract ticker, for hourly candles. */
yahoo: string;
micro?: boolean;
}
const spec = (name: string, pointValue: number, tickSize: number, yahoo: string, micro = false): CmeSpec =>
({ name, pointValue, tickSize, spreadTicks: 1, yahoo, ...(micro ? { micro } : {}) });
export const CME_SPECS: Readonly<Record<string, CmeSpec>> = {
NQ: spec('Nasdaq-100', 20, 0.25, 'NQ=F'),
MNQ: spec('Micro Nasdaq-100', 2, 0.25, 'MNQ=F', true),
ES: spec('S&P 500', 50, 0.25, 'ES=F'),
MES: spec('Micro S&P 500', 5, 0.25, 'MES=F', true),
YM: spec('Dow', 5, 1, 'YM=F'),
MYM: spec('Micro Dow', 0.5, 1, 'MYM=F', true),
RTY: spec('Russell 2000', 50, 0.1, 'RTY=F'),
M2K: spec('Micro Russell 2000', 5, 0.1, 'M2K=F', true),
GC: spec('Gold', 100, 0.1, 'GC=F'),
MGC: spec('Micro Gold', 10, 0.1, 'MGC=F', true),
SI: spec('Silver', 5000, 0.005, 'SI=F'),
CL: spec('Crude Oil', 1000, 0.01, 'CL=F'),
MCL: spec('Micro Crude Oil', 100, 0.01, 'MCL=F', true),
NG: spec('Natural Gas', 10000, 0.001, 'NG=F'),
ZB: spec('30-Year T-Bond', 1000, 1 / 32, 'ZB=F'),
ZN: spec('10-Year T-Note', 1000, 1 / 64, 'ZN=F'),
ZF: spec('5-Year T-Note', 1000, 1 / 128, 'ZF=F'),
'6E': spec('Euro FX', 125000, 0.00005, '6E=F'),
'6J': spec('Japanese Yen', 12500000, 0.0000005, '6J=F'),
'6B': spec('British Pound', 62500, 0.0001, '6B=F'),
};
export const CME_ROOTS: readonly string[] = Object.keys(CME_SPECS);
/** $ per point, per contract — the table the engine has always used. */
export const POINT_VALUES: Readonly<Record<string, number>> = Object.fromEntries(
CME_ROOTS.map((root) => [root, CME_SPECS[root].pointValue]),
);
/** Each full-size contract's micro. */
export const MICRO_OF: Readonly<Record<string, string>> = { NQ: 'MNQ', ES: 'MES', YM: 'MYM', RTY: 'M2K', GC: 'MGC', CL: 'MCL' };
export function tickValue(root: string): number {
const s = CME_SPECS[root];
return s ? s.tickSize * s.pointValue : NaN;
}
/** One contract crossing the typical spread, in dollars. */
export function spreadDollars(root: string): number {
const s = CME_SPECS[root];
return s ? s.spreadTicks * s.tickSize * s.pointValue : NaN;
}
/** The most an entry may pay to cross the spread, per contract, when the
* master's catalog doesn't say (its Settings page sets it). */
export const DEFAULT_MAX_ENTRY_SPREAD = 15;
/** Whether a spread is over the entry limit. A limit of 0 or less is off. */
export function spreadTooWide(spreadDollars: number, maxDollars: number): boolean {
return maxDollars > 0 && Number.isFinite(spreadDollars) && spreadDollars > maxDollars + 1e-9;
}
/** Hold cap: an instrument whose next leg is expected to take this long or
* longer is out of the draw. */
export const MAX_HOLD_HOURS = 12;
/** Every drawable instrument's minimum share at the session's open; it fades
* to nothing by the last entry. */
export const DRAW_FLOOR = 0.10;
/** How hard the end of the session leans on the fastest instruments. */
export const LATE_SHARPNESS = 8;
/** Hourly bars in a CME trading day (23 of 24 hours trade). */
export const BARS_PER_DAY = 23;
const MIN_CLOSES = 25;
/**
* One contract's typical daily move (1σ), in dollars, from hourly closes
* oldest first: σ of the hourly log returns × √(bars per day) × the last
* price × the point value. null on too few closes.
*/
export function dailyMoveDollars(closes: number[], pointValue: number, barsPerDay = BARS_PER_DAY): number | null {
const clean = closes.filter((c) => Number.isFinite(c) && c > 0);
if (clean.length < MIN_CLOSES || !(pointValue > 0)) return null;
const r: number[] = [];
for (let i = 1; i < clean.length; i++) r.push(Math.log(clean[i] / clean[i - 1]));
const mean = r.reduce((s, x) => s + x, 0) / r.length;
const sd = Math.sqrt(r.reduce((s, x) => s + (x - mean) ** 2, 0) / (r.length - 1));
return sd * Math.sqrt(barsPerDay) * clean[clean.length - 1] * pointValue;
}
/** Contracts a futures leg trades: one per $1,000, capped by the account
* 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));
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;
}
/**
* Expected hours for a leg to reach its take-profit or stop, as a fair walk:
* (A / move)(B / move) days, with the move scaled by the contracts. A
* stopless leg is taken as B = A. null without a move.
*/
export function expectedHoldHours(o: { leg: number; stopDollars: number; contracts: number; dailyMove: number | null }): number | null {
if (o.dailyMove === null || !(o.dailyMove > 0) || !(o.leg > 0)) return null;
const move = o.dailyMove * Math.max(1, o.contracts);
const b = Number.isFinite(o.stopDollars) && o.stopDollars > 0 ? o.stopDollars : o.leg;
return (o.leg / move) * (b / move) * 24;
}
function centralMinutes(now: Date): { day: string; minutes: number } {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Chicago', weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
}).formatToParts(now);
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '';
return { day: get('weekday'), minutes: (parseInt(get('hour'), 10) % 24) * 60 + parseInt(get('minute'), 10) };
}
/**
* How far through the trading session `now` is: 0 at the open, 1 at the last
* entry. full_cme runs 17:45 → 14:55 CT the next day (trading-logic's
* CME_EVENING_OPEN_MIN — no imports here); equity_hours 8:35 → 14:55. null
* outside the session.
*/
export function sessionProgress(now: Date, mode: string): number | null {
const { day, minutes } = centralMinutes(now);
if (mode === 'equity_hours') {
if (day === 'Sat' || day === 'Sun' || minutes < 515 || minutes > 895) return null;
return (minutes - 515) / (895 - 515);
}
const open = 17 * 60 + 45;
const length = 24 * 60 - open + 14 * 60 + 55; // 21h10m
const since = (minutes - open + 1440) % 1440;
if (since > length) return null;
if (day === 'Sat' || (day === 'Fri' && minutes > 895) || (day === 'Sun' && minutes < open)) return null;
return since / length;
}
export interface DrawCandidate {
symbol: string;
spreadDollars: number;
/** One contract's daily move in dollars; null = not measured. */
dailyMove: number | null;
/** The next leg's expected hold on it; null = unknown. */
holdHours: number | null;
micro?: boolean;
/** Its live spread is over the entry limit right now. */
wide?: boolean;
}
/**
* Each instrument's share of the Auto draw at session progress `p`.
*
* Efficiency = daily move ÷ spread: movement per dollar paid to cross the
* spread (commissions don't count). Speed ∝ move², since a fair walk's hold
* time goes with 1/move². With q = p², the weight slides from efficiency
* early to a sharp preference for the fastest late:
* weight = efficiency^(1−q) × move^(sharpness·q)
* and every drawable instrument gets a floor of DRAW_FLOOR × (1−q).
*
* Out of the draw: anything whose expected hold is MAX_HOLD_HOURS or more
* (`disabled`), anything whose live spread is over the entry limit (`wide`),
* and micros unless nothing else is left. An unmeasured instrument gets the
* median weight; if none are measured, the draw is even.
*/
export function drawChances(
candidates: DrawCandidate[],
p: number | null,
opts: { floor?: number; sharpness?: number; maxHoldHours?: number } = {},
): { chances: { symbol: string; chance: number }[]; disabled: string[]; wide: string[] } {
const floorBase = opts.floor ?? DRAW_FLOOR;
const sharpness = opts.sharpness ?? LATE_SHARPNESS;
const maxHold = opts.maxHoldHours ?? MAX_HOLD_HOURS;
const progress = p === null ? 0 : Math.min(1, Math.max(0, p));
const q = progress * progress;
const disabled = candidates.filter((c) => c.holdHours !== null && c.holdHours >= maxHold).map((c) => c.symbol);
const wide = candidates.filter((c) => c.wide === true).map((c) => c.symbol);
let pool = candidates.filter((c) => !disabled.includes(c.symbol) && !wide.includes(c.symbol));
if (pool.some((c) => !c.micro)) pool = pool.filter((c) => !c.micro);
if (pool.length === 0) return { chances: [], disabled, wide };
const logWeight = (c: DrawCandidate): number | null => (c.dailyMove !== null && c.dailyMove > 0 && c.spreadDollars > 0
? (1 - q) * Math.log(c.dailyMove / c.spreadDollars) + sharpness * q * Math.log(c.dailyMove)
: null);
const measured = pool.map(logWeight).filter((w): w is number => w !== null).sort((a, b) => a - b);
const median = measured.length === 0 ? 0 : measured.length % 2
? measured[(measured.length - 1) / 2]
: (measured[measured.length / 2 - 1] + measured[measured.length / 2]) / 2;
const logs = pool.map((c) => logWeight(c) ?? median);
const top = Math.max(...logs);
const weights = logs.map((l) => Math.exp(l - top));
const total = weights.reduce((s, w) => s + w, 0);
const n = pool.length;
const floor = Math.min(floorBase * (1 - q), 1 / n);
return {
chances: pool.map((c, i) => ({ symbol: c.symbol, chance: floor + (1 - n * floor) * (weights[i] / total) })),
disabled,
wide,
};
}
/** The instrument a uniform random number in [0, 1) lands on. */
export function pickByChance(chances: { symbol: string; chance: number }[], rand: number): string | null {
let acc = 0;
for (const c of chances) {
acc += c.chance;
if (rand < acc) return c.symbol;
}
return chances.length > 0 ? chances[chances.length - 1].symbol : null;
}
/** What every futures firm's drain and extra-day legs trade. */
export const HOUSEKEEPING_ROOT = 'MNQ';
/**
* What a firm's drain and extra-day legs trade: always MNQ — tiny targets
* belong on a micro, whatever the firm lists for its full-size legs. null
* for an empty list: that firm trades nothing.
*/
export function housekeepingRoot(listed: readonly string[]): string | null {
return listed.some((r) => CME_SPECS[r]) ? HOUSEKEEPING_ROOT : null;
}
const DXTRADE_SYMBOL = /^[A-Za-z0-9./_:-]{1,32}$/;
/** A catalog list, cleaned for the firm's family: CME roots this build has
* specs for, or valid DXtrade symbols. Deduplicated, in order. */
export function sanitizeInstruments(platform: string, raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
// 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;
const symbol = futures ? value.trim().toUpperCase() : value.trim();
const ok = futures ? Boolean(CME_SPECS[symbol]) : DXTRADE_SYMBOL.test(symbol);
if (ok && !out.includes(symbol)) out.push(symbol);
}
return out;
}
/**
* The instruments a firm trades. The master's list when the catalog carries
* one; otherwise (a master from before the lists) the old rules: a futures
* firm trades this machine's enabled instruments less its banned symbols; a
* DXtrade firm every symbol in its map, the '*' row first.
*/
export function resolveAllowlist(
firm: { platform: string; instruments?: string[]; bannedSymbols?: string[]; symbolMap?: Record<string, { symbol: string }[]> },
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' || 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(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' };
}
+297
View File
@@ -0,0 +1,297 @@
/**
* The instruments each firm trades, and the futures Auto draw — the one
* module every trading path asks.
*
* A firm's list comes from the master's catalog (`instruments`); against a
* master from before the lists, from the old rules (this machine's enabled
* instruments less the firm's bans; a DXtrade firm's symbol map). Futures
* "Auto" draws one listed root per batch, weighted by lib/instrument-specs'
* drawChances: cheap-per-move with a floor early in the session, the fastest
* near the last entry, nothing expected to hold 12 hours or more.
*
* Movement comes from Yahoo's hourly candles (7 days, measured once per trade
* date); the spread is live off one futures client's quotes (lib/live-quotes)
* when a fresh one is at hand, else the typical one tick. Commissions play no
* part — they barely differ between instruments.
*
* No entry pays more than the master's max entry spread (its Settings page)
* to cross the book: an instrument over it is out of the draw, and every
* entry path asks spreadBlock before sending.
*/
import axios from './http-client';
import { getInstruments, getSetting } from './db';
import { getTradableFirms, getTradingSettings } from './firm-catalog';
import { entrySpread, quoteSource } from './live-quotes';
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,
} from './instrument-specs';
export interface FirmLike {
id?: string;
platform: string;
instruments?: string[];
instrumentLimits?: Record<string, number>;
bannedSymbols?: string[];
symbolMap?: Record<string, { symbol: string }[]>;
}
function localEnabled(): string[] {
return getInstruments().filter((i) => i.enabled).map((i) => i.symbol);
}
/** What the firm trades (see resolveAllowlist). */
export function firmInstruments(firm: FirmLike): string[] {
return resolveAllowlist(firm, localEnabled()).roots;
}
/** Whether the list came from the master or the pre-list fallback. */
export function instrumentsSource(firm: FirmLike): 'master' | 'legacy' {
return resolveAllowlist(firm, localEnabled()).source;
}
export function isFuturesFirm(firm: { platform: string }): boolean {
return !isNotionalPlatform(firm.platform);
}
/** Every root a futures firm on this machine lists, in CME order — what the
* master's trade bar may send here. */
export function futuresRootsHere(): string[] {
const listed = new Set(getTradableFirms().filter(isFuturesFirm).flatMap((f) => firmInstruments(f)));
return CME_ROOTS.filter((root) => listed.has(root));
}
// ── market data (Yahoo hourly candles) ─────────────────────────────────────
interface Market {
tradeDate: string;
price: number | null;
dailyMove: number | null;
at: number;
error: string | null;
}
const RETRY_MS = 15 * 60_000;
const FETCH_TIMEOUT_MS = 10_000;
const CONCURRENCY = 4;
const g = globalThis as typeof globalThis & {
__futuresMarket?: Map<string, Market>;
__futuresMarketInflight?: Map<string, Promise<void>>;
};
const market = (g.__futuresMarket ??= new Map());
const inflight = (g.__futuresMarketInflight ??= new Map());
function isFresh(m: Market | undefined): boolean {
if (!m || m.tradeDate !== currentTradeDateCT()) return false;
return m.error === null || Date.now() - m.at < RETRY_MS;
}
async function fetchMarket(root: string): Promise<void> {
const spec = CME_SPECS[root];
const tradeDate = currentTradeDateCT();
try {
const res = await axios.get(`https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(spec.yahoo)}`, {
params: { interval: '60m', range: '7d' },
timeout: FETCH_TIMEOUT_MS,
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS + 5_000),
});
const raw: unknown[] = res.data?.chart?.result?.[0]?.indicators?.quote?.[0]?.close ?? [];
const closes = raw.filter((c): c is number => typeof c === 'number' && Number.isFinite(c) && c > 0);
const dailyMove = dailyMoveDollars(closes, spec.pointValue);
market.set(root, {
tradeDate, price: closes.at(-1) ?? null, dailyMove, at: Date.now(),
error: dailyMove === null ? `only ${closes.length} hourly closes` : null,
});
} catch (err) {
market.set(root, { tradeDate, price: null, dailyMove: null, at: Date.now(), error: errorMessage(err) });
}
}
/** Refresh whichever roots aren't measured for today, waiting at most
* `deadlineMs` — a draw never stalls on Yahoo. */
export async function ensureMarket(roots: readonly string[], deadlineMs = 4_000): Promise<void> {
const stale = [...new Set(roots)].filter((r) => CME_SPECS[r] && !isFresh(market.get(r)));
if (stale.length === 0) return;
const run = (async () => {
for (let i = 0; i < stale.length; i += CONCURRENCY) {
await Promise.all(stale.slice(i, i + CONCURRENCY).map((root) => {
let p = inflight.get(root);
if (!p) {
p = fetchMarket(root).finally(() => inflight.delete(root));
inflight.set(root, p);
}
return p;
}));
}
})();
await Promise.race([run, new Promise((r) => setTimeout(r, deadlineMs))]);
}
/** Measure every root in the background — at boot and at the midnight
* contract resolve — so the stats cover unlisted instruments too. */
export function prewarmMarket(): void {
void ensureMarket(CME_ROOTS, 120_000).then(() => {
const measured = CME_ROOTS.filter((r) => market.get(r)?.dailyMove != null).length;
console.log(`[instruments] measured ${measured}/${CME_ROOTS.length} futures from hourly closes`);
});
}
export function marketOf(root: string): { price: number | null; dailyMove: number | null; error: string | null } | null {
const m = market.get(root);
return m ? { price: m.price, dailyMove: m.dailyMove, error: m.error } : null;
}
// ── the draw ────────────────────────────────────────────────────────────────
/** The batch's leg, for the hold estimate: its dollars, its stop (Infinity =
* none) and the account type's max contracts. */
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 {
return sessionProgress(now, getSetting('trading_hours') ?? 'full_cme');
}
/** The master's limit on what an entry pays to cross the spread (0 = none). */
export function maxEntrySpread(): number {
return getTradingSettings().maxEntrySpreadDollars;
}
/**
* Why an entry on `root` can't go out right now — its spread (live, else the
* typical tick) is over the master's limit — or null when it can. Every
* futures entry asks this before sending.
*/
export function spreadBlock(root: string): string | null {
if (!CME_SPECS[root]) return null;
const max = maxEntrySpread();
const spread = entrySpread(root);
if (!spreadTooWide(spread.dollars, max)) return null;
return `${root} spread $${spread.dollars.toFixed(2)} (${spread.ticks} tick${spread.ticks === 1 ? '' : 's'}${spread.live ? '' : ', typical'})`
+ ` is over the $${max} entry limit`;
}
/** Every root sized for the leg, and the draw's chances. */
export function futuresBoard(roots: readonly string[], leg: LegShape | null, now = new Date()): {
progress: number | null;
candidates: (DrawCandidate & { spreadLive: boolean })[];
chances: { symbol: string; chance: number }[];
disabled: string[];
wide: string[];
} {
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,
spreadLive: spread.live,
dailyMove,
holdHours: leg ? expectedHoldHours({ leg: leg.leg, stopDollars: leg.stopDollars, contracts, dailyMove }) : null,
micro: CME_SPECS[root].micro === true,
wide: spreadTooWide(spread.dollars, max),
};
});
const progress = progressNow(now);
const { chances, disabled, wide } = drawChances(candidates, progress);
return { progress, candidates, chances, disabled, wide };
}
/**
* Draw one of `roots` for a batch whose leg is `leg`. null when nothing
* listed fits (all over the hold cap) — the batch sits this tick out.
*/
export async function drawFuturesInstrument(roots: readonly string[], leg: LegShape | null, rand: () => number = Math.random): Promise<string | null> {
await ensureMarket(roots);
const board = futuresBoard(roots, leg);
const root = pickByChance(board.chances, rand());
const cand = (s: string) => board.candidates.find((c) => c.symbol === s);
const parts = board.chances.map((c) => `${c.symbol} ${Math.round(c.chance * 100)}%`).join(' · ');
const off = board.disabled.map((s) => `${s} (~${Math.round(cand(s)?.holdHours ?? 0)}h)`).join(', ');
const wide = board.wide.map((s) => `${s} $${cand(s)?.spreadDollars.toFixed(2)}`).join(', ');
const live = board.candidates.filter((c) => c.spreadLive).length;
console.log(`[instruments] futures: ${root ? `drew ${root}` : 'nothing fits'} — ${parts || 'none drawable'}`
+ `${off ? `; over ${MAX_HOLD_HOURS}h: ${off}` : ''}`
+ `${wide ? `; spread over $${maxEntrySpread()}: ${wide}` : ''}`
+ ` (${live}/${board.candidates.length} live spreads`
+ `${board.progress !== null ? `, session ${Math.round(board.progress * 100)}%` : ''})`);
return root;
}
/** The draw's odds over `n` simulated batches — the real draw code, no
* orders — for GET /api/instruments?simulate=. */
export function simulateDraws(roots: readonly string[], leg: LegShape | null, n: number): Record<string, number> {
const board = futuresBoard(roots, leg);
const counts: Record<string, number> = {};
for (let i = 0; i < n; i++) {
const s = pickByChance(board.chances, Math.random());
if (s) counts[s] = (counts[s] ?? 0) + 1;
}
return counts;
}
// ── stats for the master and the Settings page ─────────────────────────────
/**
* A futures firm's instruments as the draw sees them right now: every CME
* root with its spread, movement and contract, and — sized for `lead`'s
* next leg — the listed ones' holds, the ones over the cap, and each one's
* chance.
*/
export function instrumentStatsFor(
firm: FirmLike & { id: string },
lead: (LegShape & { account: string }) | null,
): InstrumentStats {
const listed = firmInstruments(firm);
const limits = firm.instrumentLimits ?? {};
const board = futuresBoard(listed, lead ? { ...lead, limits } : null);
const disabled = new Set(board.disabled);
const max = maxEntrySpread();
return {
at: new Date().toISOString(),
account: lead?.account ?? null,
leg: lead?.leg ?? null,
sessionProgress: board.progress,
maxHoldHours: MAX_HOLD_HOURS,
maxEntrySpreadDollars: max,
quoteSource: quoteSource(),
instruments: CME_ROOTS.map((root) => {
const dailyMove = market.get(root)?.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,
listed: listed.includes(root),
contract: getCachedContract(root)?.name ?? null,
spreadDollars: spread.dollars,
spreadLive: spread.live,
spreadBlocked: spreadTooWide(spread.dollars, max),
dailyMoveDollars: dailyMove === null ? null : Math.round(dailyMove),
holdHours: holdHours === null ? null : Math.round(holdHours * 10) / 10,
disabled: listed.includes(root) ? disabled.has(root) : holdHours !== null && holdHours >= MAX_HOLD_HOURS,
chance: board.chances.find((c) => c.symbol === root)?.chance ?? null,
};
}),
};
}
+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);
}
+202
View File
@@ -0,0 +1,202 @@
/**
* An account's next leg, sized on every instrument its DXtrade client last
* surveyed — the numbers the local dashboard's hold estimate and the
* master's Firms page show. Same rule as the trade: planLegSize for the size,
* rankForTarget for the order (the cheapest that reaches the target hold,
* else the quickest), so what is shown is what a batch would pick.
*/
import { getAccountConfig } from './account-type-resolver';
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, 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;
winProbabilityNoFees: number | null;
};
interface FirmSizing {
commissionPct?: number;
targetHoldHours?: number;
financingPctPerDay?: number;
}
/** The next leg of `leg` dollars on each surveyed candidate, best first. */
export function rankNextLeg(o: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any;
accountId: number;
cfg: { accountSize: number; maxLoss?: number; dailyLossLimit?: number };
firm: FirmSizing;
leg: number;
/** null = daily loss limit set but its day start isn't known yet. */
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 = 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],
});
// The next leg opens flat: while one is open its margin is tied up, so
// size against the balance then.
const funds = o.client.positions?.[o.accountId] ? o.cash.amount : (o.cash.availableFunds ?? o.cash.amount);
const sized = candidates.flatMap((c): CandidateLeg[] => {
if (!c.volatilityPerHour) return [];
const plan = (costs: boolean) => planLegSize({
leg: o.leg,
contract: costs ? c : { ...c, spreadFraction: 0 },
commissionPct: costs ? (c.commissionPct ?? o.firm.commissionPct ?? 0) : 0,
targetHoldHours: target,
financingPctPerDay: costs ? (o.firm.financingPctPerDay ?? 0) : 0,
dailyLossLimit: o.cfg.dailyLossLimit ?? 0,
// Without fees the day's room would also have its fees back — but
// only this leg's are known, so the room stays as it is.
dllRoom: o.dllRoom ?? Infinity,
maxLossRoom: floorRoom,
obfuscationDollars: o.obfuscationDollars,
availableFunds: funds,
slippageFraction: c.stopSlippageFraction ?? o.client.stopSlippageFraction,
});
const p = plan(true);
if (!p.sizing || p.cost === null) return [];
const leverage = c.marginRate ? Math.round(10 / c.marginRate) / 10 : null;
return [{
symbol: String(c.id),
notional: Math.round(p.sizing.notional),
multiplier: o.cfg.accountSize > 0 ? Math.round((p.sizing.notional / o.cfg.accountSize) * 100) / 100 : 0,
maxMultiplier: leverage,
leverage,
holdHours: Math.round(p.sizing.predictedHoldHours * 10) / 10,
cost: Math.round(p.cost),
winProbability: p.sizing.winProbability,
winProbabilityNoFees: plan(false).sizing?.winProbability ?? null,
volatilityPerDay: c.volatilityPerHour * Math.sqrt(24),
spreadFraction: c.spreadFraction ?? 0,
swapReserve: p.sizing.swapReserve,
}];
});
return rankForTarget(sized, target).map((i) => sized[i]);
}
/** An account's day plan and daily-loss room, worked out from scratch the
* way the dashboard does. null without an account type or a plan. */
function accountPlan(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any,
acc: { id: number },
tradeDate: string,
) {
const cfg = getAccountConfig(acc.id).accountType;
if (!cfg) return null;
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
const combined = combineCmeSessions(cfg.combineCmeHolidays === true, client.dailyPnL[acc.id] ?? [], tradeDate, cash.realizedPnL);
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,
combined.dailyPnL, effective.minDayPnL, effective.minTradingDays, cash.amount - cfg.accountSize, combined.realizedPnL,
cfg.splitEnabled, effective.drain,
);
if (!plan) return null;
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 };
}
/**
* The same for an account worked out from scratch — its day plan, daily-loss
* room and stops as the dashboard computes them. null when it has no
* account type, no plan, or nothing left to trade today.
*/
export function accountNextLeg(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any,
acc: { id: number },
firm: FirmSizing,
tradeDate: string,
): { leg: number; lastStand: boolean; candidates: CandidateLeg[] } | null {
const p = accountPlan(client, acc, tradeDate);
if (!p || !(p.plan.remainingToday > 0)) return null;
const floorRoom = maxLossRoom({
balance: p.cash.amount, accountSize: p.cfg.accountSize, maxLoss: p.cfg.maxLoss ?? 0,
autoLiqThreshold: client.autoLiqThresholds?.[acc.id],
});
return {
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, dailyLossLimit: p.dll }, firm, leg: p.plan.remainingToday, dllRoom: p.room,
obfuscationDollars: p.obfuscation, cash: p.cash, tradeDate,
}),
};
}
/**
* A futures account's next leg as the Auto draw sizes its hold: the day's
* remaining target, its stop (the tighter of the obfuscation and daily-loss
* stops; Infinity = none) and the account type's max contracts. null with
* nothing left to trade today.
*/
export function accountLegShape(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any,
acc: { id: number },
tradeDate: string,
): { leg: number; stopDollars: number; maxPositionSize: number } | null {
const p = accountPlan(client, acc, tradeDate);
if (!p || !(p.plan.remainingToday > 0)) return null;
const obf = Number.isFinite(p.obfuscation) && p.obfuscation > 0 ? p.obfuscation : Infinity;
const hard = p.room !== null && p.dll > 0
? dllStopDollars({ room: p.room, dailyLossLimit: p.dll, entryFee: FALLBACK_COMMISSION_PER_CONTRACT, commissionPct: 0 })
: Infinity;
return { leg: p.plan.remainingToday, stopDollars: Math.min(obf, hard), maxPositionSize: p.cfg.maxPositionSize };
}
+391
View File
@@ -0,0 +1,391 @@
/**
* Live futures quotes: one feed per machine, on one futures client, and the
* spread each entry pays read off it.
*
* Every firm trades the same CME book, so one feed serves them all. It rides
* a TopstepX client's market hub when this machine has one, else a Tradovate
* client's market-data socket (when its login carries market data). Each
* listed root's front month is subscribed, plus the drain micro, and
* resubscribed when the front month rolls.
*
* A quote older than QUOTE_FRESH_MS isn't live: the spread falls back to the
* typical one tick (instrument-specs). A feed that fails, loses its client or
* goes quiet while the market trades is dropped, and the next supervision
* starts one on another client (the failed one rests RETRY_AFTER_MS).
*/
import * as signalR from '@microsoft/signalr';
import { brokerWebSocket, signalrWebSocket } from './outbound-proxy';
import { errorMessage } from './platform-client';
import { isNoTradeTime } from './trading-logic';
import { CME_SPECS, spreadDollars, tickValue } from './instrument-specs';
import { mergeTopstepxQuote, parseTradovateFrame, bookSpread, type BookTop } from './quote-helpers';
const MARKET_HUB = 'https://rtc.topstepx.com/hubs/market';
const TRADOVATE_MD_WS = 'wss://md.tradovateapi.com/v1/websocket';
/** A quote older than this is stale: the typical spread stands in. */
export const QUOTE_FRESH_MS = 2 * 60_000;
const START_TIMEOUT_MS = 30_000;
const INVOKE_TIMEOUT_MS = 15_000;
/** Silent this long while the market trades: dead, or a login with no data. */
const SILENT_MS = 5 * 60_000;
/** A client whose feed was dropped waits this long to be tried again. */
const RETRY_AFTER_MS = 3 * 60_000;
export const SUPERVISE_INTERVAL_MS = 30_000;
type Platform = 'topstepx' | 'tradovate';
type Contract = { id: number | string; name: string };
type QuoteSink = (root: string, contract: string, patch: BookTop) => void;
/** What the feed needs of a futures client. */
export interface QuoteClient {
quoteToken?: () => string | null;
findFrontMonthContract(root: string): Promise<Contract | null>;
}
interface Feed {
readonly platform: Platform;
readonly firmId: string;
readonly firmName: string;
readonly startedAt: number;
lastQuoteAt: number;
/** Why it gave up; null while it works. */
failed: string | null;
/** The contract id `root` is subscribed under. */
contractOf(root: string): string | null;
subscribe(root: string, contract: Contract): void;
stop(): void;
}
interface LiveBook extends BookTop {
at: number;
contract: string;
}
interface State {
books: Map<string, LiveBook>;
feed: Feed | null;
droppedAt: Map<string, number>;
lastError: string | null;
supervising: boolean;
}
// On globalThis: instrumentation.ts boots the feed from its own bundle, and
// the route handlers (the Settings page, the draw) must read the same books.
const g = globalThis as typeof globalThis & { __liveQuotes?: State };
function state(): State {
return (g.__liveQuotes ??= { books: new Map(), feed: null, droppedAt: new Map(), lastError: null, supervising: false });
}
function withTimeout<T>(p: Promise<T>, ms: number, what: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`${what} timed out after ${ms / 1000}s`)), ms);
p.then((v) => { clearTimeout(timer); resolve(v); }, (err) => { clearTimeout(timer); reject(err); });
});
}
const onQuote: QuoteSink = (root, contract, patch) => {
const books = state().books;
const prev = books.get(root);
// A new front month starts a fresh book: the old one's sides don't carry.
books.set(root, { ...(prev?.contract === contract ? prev : {}), ...patch, contract, at: Date.now() });
};
// ── TopstepX: the market hub ────────────────────────────────────────────────
class TopstepxFeed implements Feed {
readonly platform = 'topstepx' as const;
readonly startedAt = Date.now();
lastQuoteAt = 0;
failed: string | null = null;
private ready = false;
private stopped = false;
private readonly hub: signalR.HubConnection;
private readonly byContract = new Map<string, { root: string; name: string }>();
private readonly byRoot = new Map<string, string>();
constructor(readonly firmId: string, readonly firmName: string, token: () => string | null) {
// Like the user hub: one websocket, through the operator's proxy.
const configuredWs = signalrWebSocket();
this.hub = new signalR.HubConnectionBuilder()
.withUrl(MARKET_HUB, {
skipNegotiation: true,
transport: signalR.HttpTransportType.WebSockets,
accessTokenFactory: () => token() ?? '',
...{ WebSocket: configuredWs },
})
.withAutomaticReconnect()
.configureLogging(signalR.LogLevel.Warning)
.build();
this.hub.on('GatewayQuote', (contractId: unknown, data: unknown) => {
const sub = typeof contractId === 'string' ? this.byContract.get(contractId) : undefined;
if (!sub || this.stopped) return;
this.lastQuoteAt = Date.now();
onQuote(sub.root, sub.name, mergeTopstepxQuote(undefined, data));
});
this.hub.onreconnected(() => {
if (!this.stopped) for (const id of this.byContract.keys()) void this.send(id);
});
this.hub.onclose((err) => {
if (!this.stopped) this.failed ??= `market hub closed${err ? `: ${errorMessage(err)}` : ''}`;
});
withTimeout(this.hub.start(), START_TIMEOUT_MS, 'market hub start')
.then(() => {
if (this.stopped) return;
this.ready = true;
for (const id of this.byContract.keys()) void this.send(id);
})
.catch((err) => { this.failed ??= `market hub: ${errorMessage(err)}`; });
}
contractOf(root: string): string | null {
return this.byRoot.get(root) ?? null;
}
subscribe(root: string, contract: Contract): void {
const id = String(contract.id);
const old = this.byRoot.get(root);
if (old && old !== id) {
this.byContract.delete(old);
if (this.ready) void this.hub.invoke('UnsubscribeContractQuotes', old).catch(() => { /* rolled away anyway */ });
}
this.byRoot.set(root, id);
this.byContract.set(id, { root, name: contract.name });
if (this.ready) void this.send(id);
}
private async send(id: string): Promise<void> {
try {
await withTimeout(this.hub.invoke('SubscribeContractQuotes', id), INVOKE_TIMEOUT_MS, `subscribing ${id}`);
} catch (err) {
if (!this.stopped) this.failed ??= errorMessage(err);
}
}
stop(): void {
this.stopped = true;
void this.hub.stop().catch(() => { /* discarded */ });
}
}
// ── Tradovate: the market-data socket ───────────────────────────────────────
class TradovateFeed implements Feed {
readonly platform = 'tradovate' as const;
readonly startedAt = Date.now();
lastQuoteAt = 0;
failed: string | null = null;
private authorized = false;
private stopped = false;
private nextId = 2;
private heartbeat: ReturnType<typeof setInterval> | null = null;
private readonly ws: WebSocket;
private readonly byContract = new Map<number, { root: string; name: string }>();
private readonly byRoot = new Map<string, { id: number; name: string }>();
private readonly pending = new Map<number, string>();
constructor(readonly firmId: string, readonly firmName: string, token: () => string | null) {
const ws = brokerWebSocket(`${TRADOVATE_MD_WS}?r=${Math.random().toString(36).slice(2, 12)}`);
this.ws = ws;
ws.onopen = () => {
const t = token();
if (!t) this.failed ??= 'no market-data token';
else this.send(`authorize\n1\n\n${t}`);
};
ws.onmessage = (event) => this.onFrame(event.data);
ws.onerror = () => { /* onclose follows */ };
ws.onclose = (event) => {
if (this.heartbeat) clearInterval(this.heartbeat);
this.heartbeat = null;
if (!this.stopped) this.failed ??= `market-data socket closed (code ${event.code})`;
};
}
private send(frame: string): void {
if (this.ws.readyState === 1) this.ws.send(frame);
}
private request(name: string): void {
const id = this.nextId++;
this.pending.set(id, name);
this.send(`md/subscribeQuote\n${id}\n\n${JSON.stringify({ symbol: name })}`);
}
private onFrame(raw: unknown): void {
if (this.stopped) return;
const { responses, quotes } = parseTradovateFrame(raw);
for (const r of responses) {
if (r.id === 1) {
if (r.status !== 200) { this.failed ??= `market data refused (status ${r.status})`; continue; }
this.authorized = true;
this.heartbeat = setInterval(() => this.send('[]'), 2500);
for (const { name } of this.byRoot.values()) this.request(name);
continue;
}
const name = this.pending.get(r.id);
this.pending.delete(r.id);
if (name && r.status !== 200) {
const detail = typeof r.data === 'string' ? `: ${r.data.slice(0, 120)}` : '';
this.failed ??= `quotes for ${name} refused (status ${r.status}${detail})`;
}
}
for (const q of quotes) {
const sub = this.byContract.get(q.contractId);
if (!sub) continue;
this.lastQuoteAt = Date.now();
onQuote(sub.root, sub.name, { ...(q.bid !== undefined ? { bid: q.bid } : {}), ...(q.ask !== undefined ? { ask: q.ask } : {}) });
}
}
contractOf(root: string): string | null {
const sub = this.byRoot.get(root);
return sub ? String(sub.id) : null;
}
subscribe(root: string, contract: Contract): void {
const id = Number(contract.id);
if (!Number.isFinite(id)) return;
const old = this.byRoot.get(root);
if (old && old.id !== id) {
this.byContract.delete(old.id);
if (this.authorized) this.send(`md/unsubscribeQuote\n${this.nextId++}\n\n${JSON.stringify({ symbol: old.name })}`);
}
this.byRoot.set(root, { id, name: contract.name });
this.byContract.set(id, { root, name: contract.name });
if (this.authorized) this.request(contract.name);
}
stop(): void {
this.stopped = true;
if (this.heartbeat) clearInterval(this.heartbeat);
this.heartbeat = null;
try { this.ws.close(); } catch { /* already closed */ }
}
}
// ── supervision ─────────────────────────────────────────────────────────────
export interface QuoteCandidate {
firmId: string;
firmName: string;
platform: string;
client: QuoteClient;
}
/**
* Keep one feed running and subscribed to `roots`: drop one that failed,
* lost its client or went quiet while the market trades; start one on the
* first client that can (TopstepX first, then Tradovate) and hasn't just
* been dropped; subscribe each root's current front month.
*/
export async function superviseQuotes(candidates: QuoteCandidate[], roots: readonly string[], now = Date.now()): Promise<void> {
const st = state();
if (st.supervising) return;
st.supervising = true;
try {
const marketOpen = !isNoTradeTime(new Date(now), { everyDay: false, mode: 'full_cme', jitterMin: 0 });
let feed = st.feed;
if (feed) {
const owner = candidates.find((c) => c.firmId === feed!.firmId);
const silentMs = now - Math.max(feed.lastQuoteAt, feed.startedAt);
const reason = feed.failed
?? (!owner ? 'its firm is gone'
: !owner.client.quoteToken?.() ? 'its client is logged out'
: marketOpen && silentMs > SILENT_MS ? `no quotes for ${Math.round(silentMs / 60_000)} min`
: null);
if (reason) {
console.warn(`[quotes] dropping the ${feed.platform} feed on ${feed.firmName} — ${reason}`);
feed.stop();
st.droppedAt.set(feed.firmId, now);
st.lastError = `${feed.firmName}: ${reason}`;
st.feed = feed = null;
}
}
if (!feed) {
const byPreference = [
...candidates.filter((c) => c.platform === 'topstepx'),
...candidates.filter((c) => c.platform === 'tradovate'),
];
const pick = byPreference.find((c) => !!c.client.quoteToken?.() && now - (st.droppedAt.get(c.firmId) ?? -Infinity) >= RETRY_AFTER_MS);
if (!pick) return;
const token = () => pick.client.quoteToken?.() ?? null;
feed = pick.platform === 'topstepx'
? new TopstepxFeed(pick.firmId, pick.firmName, token)
: new TradovateFeed(pick.firmId, pick.firmName, token);
st.feed = feed;
console.log(`[quotes] live quotes from ${pick.firmName} (${pick.platform}) for ${roots.join(', ')}`);
}
const client = candidates.find((c) => c.firmId === feed!.firmId)?.client;
if (!client) return;
for (const root of roots) {
if (!CME_SPECS[root]) continue;
try {
const contract = await client.findFrontMonthContract(root);
if (contract && feed.contractOf(root) !== String(contract.id)) feed.subscribe(root, contract);
} catch (err) {
console.warn(`[quotes] ${root}: contract lookup failed — ${errorMessage(err)}`);
}
}
} finally {
st.supervising = false;
}
}
// ── reading ─────────────────────────────────────────────────────────────────
export interface LiveSpread {
dollars: number;
ticks: number;
bid: number;
ask: number;
ageMs: number;
contract: string;
}
/** `root`'s spread off a fresh quote; null when there is none. */
export function liveSpread(root: string, now = Date.now()): LiveSpread | null {
const book = state().books.get(root);
const spec = CME_SPECS[root];
if (!book || !spec || now - book.at > QUOTE_FRESH_MS) return null;
const spread = bookSpread(book, spec.pointValue, spec.tickSize);
if (!spread) return null;
return { ...spread, bid: book.bid!, ask: book.ask!, ageMs: now - book.at, contract: book.contract };
}
/**
* The spread an entry on `root` pays, per contract: the live one when a
* fresh quote is at hand, else the typical one tick. Never under a tick —
* a locked book is momentary.
*/
export function entrySpread(root: string, now = Date.now()): { dollars: number; ticks: number; live: boolean } {
const live = liveSpread(root, now);
if (live) return { dollars: Math.max(live.dollars, tickValue(root)), ticks: Math.max(live.ticks, 1), live: true };
return { dollars: spreadDollars(root), ticks: CME_SPECS[root]?.spreadTicks ?? 1, live: false };
}
/** The feed as the Settings page and /api/instruments show it. */
export function quoteStatus(now = Date.now()): {
source: string | null;
platform: Platform | null;
since: string | null;
lastQuoteAt: string | null;
lastError: string | null;
spreads: Record<string, LiveSpread | null>;
} {
const st = state();
const feed = st.feed;
return {
source: feed?.firmName ?? null,
platform: feed?.platform ?? null,
since: feed ? new Date(feed.startedAt).toISOString() : null,
lastQuoteAt: feed?.lastQuoteAt ? new Date(feed.lastQuoteAt).toISOString() : null,
lastError: feed?.failed ?? st.lastError,
spreads: Object.fromEntries([...st.books.keys()].map((root) => [root, liveSpread(root, now)])),
};
}
/** The firm the feed runs on, for the master's stats; null with no feed. */
export function quoteSource(): string | null {
const feed = state().feed;
return feed ? `${feed.firmName} (${feed.platform})` : null;
}
+48 -14
View File
@@ -15,11 +15,17 @@
* master you control, and prefer https so it cannot be redirected in transit.
*/
import { getSetting, setSetting, getInstruments, getRunnerHeartbeat, RUNNER_TIMEOUT_MS } from './db';
import { getSetting, setSetting, getRunnerHeartbeat, RUNNER_TIMEOUT_MS } from './db';
import { runTrade, startScheduler, stopScheduler, getSchedulerStatus } from './auto-trade';
import { FIRMS, automationKey } from './automations';
import { POINT_VALUES } from './trading-logic';
import { CME_ROOTS, CME_SPECS } from './instrument-specs';
import { futuresRootsHere } from './instruments';
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,
@@ -33,6 +39,8 @@ const RECONNECT_MAX_MS = 60_000;
interface LinkState {
socket: WebSocket | null;
/** The master_dashboard_url the socket was opened for. */
url: string | null;
/** Only true once the master has answered the hello. */
ready: boolean;
attempt: number;
@@ -45,7 +53,7 @@ const _g = globalThis as typeof globalThis & { __masterLink?: LinkState };
function state(): LinkState {
if (!_g.__masterLink) {
_g.__masterLink = { socket: null, ready: false, attempt: 0, reconnectTimer: null, stopped: false };
_g.__masterLink = { socket: null, url: null, ready: false, attempt: 0, reconnectTimer: null, stopped: false };
}
return _g.__masterLink;
}
@@ -54,16 +62,16 @@ function state(): LinkState {
/** What this instance can be asked to do, as of right now. */
export function collectCapabilities(): InstanceCapabilities {
// Only symbols the operator has enabled here. An empty instruments table
// means nothing has been configured yet, so offer everything we can price.
const enabled = getInstruments().filter((i) => i.enabled).map((i) => i.symbol);
// The roots this machine's futures firms list (the master's per-firm
// instrument lists); none listed yet means offer everything we can price.
const listed = futuresRootsHere();
// Same test the AutoBuyer page uses: `at` is epoch ms from the last ping.
const heartbeat = getRunnerHeartbeat();
const runnerOnline = heartbeat !== null && Date.now() - heartbeat.at < RUNNER_TIMEOUT_MS;
return {
symbols: enabled.length > 0 ? enabled : Object.keys(POINT_VALUES),
symbols: listed.length > 0 ? listed : [...CME_ROOTS],
automations: FIRMS.flatMap((firm) =>
firm.automations
.filter((a) => a.steps.length > 0)
@@ -81,6 +89,7 @@ export function collectCapabilities(): InstanceCapabilities {
}))
),
runnerOnline,
updateNow: true,
};
}
@@ -94,17 +103,21 @@ export function collectCapabilities(): InstanceCapabilities {
async function execute(command: Command): Promise<unknown> {
switch (command.kind) {
case 'trade': {
if (command.symbol !== 'Auto' && !POINT_VALUES[command.symbol]) {
if (command.symbol !== 'Auto' && !CME_SPECS[command.symbol]) {
throw new Error(`Unknown symbol: ${command.symbol}`);
}
// Same order as POST /api/trade: fire once now, then leave the
// scheduler running to pick up accounts that were busy.
const results = await runTrade(command.action, command.symbol);
// Same order as POST /api/trade: (re)start the scheduler first,
// then fire once now — the scheduler picks up accounts that were
// busy. Starting it first means a batch that throws or runs long
// can never leave it unstarted. It can't double-fire against this
// batch: the tick waits for flat positions, skips while this batch
// holds the trade mutex, and never picks an account in flight.
startScheduler(command.action, command.symbol, command.stopAfterAll ?? false);
const results = await runTrade(command.action, command.symbol);
const filled = results
.flatMap((r: any) => r.results ?? [])
.filter((r: any) => r.status === 'filled').length;
.flatMap((r) => r.results)
.filter((r) => r.status === 'filled').length;
return { filled, scheduler: getSchedulerStatus() };
}
@@ -118,6 +131,12 @@ async function execute(command: Command): Promise<unknown> {
case 'report-now':
return { reported: true };
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.
@@ -154,13 +173,26 @@ function send(message: InstanceMessage): boolean {
}
}
/** A changed master_dashboard_url drops the socket, and the reconnect dials
* the new master — otherwise reports and commands would stay with the old
* one until a restart, while the catalog followed the setting. */
function followMasterUrl(): void {
const s = state();
if (!s.socket || s.url === getSetting('master_dashboard_url')) return;
console.log('[master-link] master_dashboard_url changed — reconnecting');
try { s.socket.close(1000, 'master changed'); } catch { /* the close handler reconnects */ }
}
/** True when the report went up the socket, so the caller can skip the POST. */
export function pushReport(firms: unknown[], scheduler: unknown): boolean {
followMasterUrl();
return send({
type: 'report',
firms: firms as never,
scheduler: scheduler as never,
capabilities: collectCapabilities(),
version: getBuildVersion() ?? undefined,
updater: updaterStatus(),
});
}
@@ -208,12 +240,13 @@ function connect() {
let socket: WebSocket;
try {
socket = new WebSocket(url);
socket = directWebSocket(url);
} catch (err: any) {
scheduleReconnect(err?.message ?? 'could not open socket');
return;
}
s.socket = socket;
s.url = masterUrl;
s.ready = false;
socket.addEventListener('open', () => {
@@ -226,6 +259,7 @@ function connect() {
instance,
id: instanceId || undefined,
capabilities: collectCapabilities(),
version: getBuildVersion() ?? undefined,
} satisfies InstanceMessage));
});
+447 -8
View File
@@ -14,6 +14,85 @@ export interface AccountTypeStats {
totalAccounts: number;
accountsTraded: number;
inTrade: number;
/** Of the accounts that finished trading today (traded, no open position):
* how many ended with the day's profit target hit, in profit but short of
* the target, or at a realized loss (an account locked out at the daily
* loss limit counts as a loss). Optional so an older master simply
* ignores them. */
targetHit?: number;
partial?: number;
loss?: number;
/** Of the finished accounts, how many booked the day via their own
* obfuscation stop (personal DLL) rather than a genuine loss. */
personalDll?: number;
/** Of targetHit: accounts whose day finished the current cycle — profit
* target, consistency and minimum days all met once today's P&L booked
* (the final day). Optional: older instances omit it. */
completed?: number;
/** The accounts behind `completed`, by name, so the master can keep them
* after a firm erases a passed account. date = the trade date it
* completed on; stage = 1 + withdrawals taken (its meaning depends on the
* account type); kind: 'pass' = an eval passing, 'payout' = a funded
* 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, 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 — 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
* calendar only projects accounts still on the winning path. Optional so
* an older master simply ignores it. */
daysHist?: Record<number, number>;
/** Live accounts by days traded in the current cycle — the instance
* dashboard's Days Traded number (minDayPnL-qualifying days, today
* included once booked) —
* with how many of them hit today's target; completed = how many of those
* hits finished the cycle. Lets the master show "Day 1 3/5 · Day 2 1/4".
* Optional: older instances omit it. */
dayStats?: Record<number, { total: number; targetHit: number; completed?: number }>;
/** One entry per live account (negative ones included, dead ones not):
* its actual progress toward the current target, so the master can
* simulate the real required profit instead of assuming the winning
* path. A withdrawal resets the path, so days/maxDay/sum cover only the
* current cycle (client.dailyPnL is already the active-cycle slice),
* while profit stays the full equity profit. withdrawals > 0 tells the
* master the trail locked long ago; target/consistency/minDays carry
* this cycle's effective values from resolveEffectiveConfig. Optional so
* an older master simply ignores it. */
payoutStates?: {
days: number; profit: number; maxDay: number; sum: number; high: number;
withdrawals?: number; target?: number; consistency?: number; minDays?: number;
/** Trade date (YYYY-MM-DD) of the account's most recent traded day —
* after the 5 PM CT roll a day booked on tomorrow's trade date is
* already counted in `days`. */
lastDay?: string;
/** This account's projected payout chain on the perfect path: one
* entry per future withdrawal, with the gross amount
* (min(withdrawalPct × balance, cap)), the net received
* (gross × profitSplit), and prob = the CUMULATIVE probability of
* reaching that payout under the fair-walk ruin model. Optional so
* an older master simply ignores it. */
futurePayouts?: {
date: string; gross: number; net: number; prob: number;
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 {
@@ -21,9 +100,160 @@ export interface FirmStats {
totalAccounts: number;
accountsTraded: number;
inTrade: number;
/** Day outcomes, as on AccountTypeStats. */
targetHit?: number;
partial?: number;
loss?: number;
personalDll?: number;
/** Of targetHit: accounts that finished their cycle today. Optional. */
completed?: number;
/** Every account in the firm's broker list right now, dead ones included,
* so the master can tell a saved account the firm erased from one still
* listed, and spot a newly arrived funded account. size = the account
* type's size (0 without one); payoutable = the type pays out — its
* payoutable flag or withdrawal stages, the test that reports its
* completions as 'payout' — null while the account has no type. Absent
* when the firm isn't synced (unknown, not empty). Optional: older
* instances omit it.
*
* withdrawals = how many times the balance has gone down for a payout
* (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;
/** 1 on a Vest firm: its account names end in the number the Vest app
* shows (Account 73 = V50K-E-D3-T10-73). Older builds' were one lower; on
* the first report with this, the master renames what it saved under
* those. */
vestAppNumbers?: 1;
/** Per account type, summing to the firm's own totals. Optional so a master
* on an older build simply ignores it. */
accountTypes?: AccountTypeStats[];
/** DXtrade firms: one account's next leg sized on every symbol-map
* candidate, best first — what the master's Firms page shows beside each
* symbol. Optional. */
symbolStats?: SymbolStats;
/** Futures firms: each CME root's entry cost, movement, expected hold and
* chance in the Auto draw right now. Optional. */
instrumentStats?: InstrumentStats;
/** What the firm is trading, by instrument: its open positions and
* today's entries (lib/trade-mix.ts). Optional. */
mix?: TradeMix;
}
/** One instrument's share of a firm's trading: accounts (open) or entries
* (today) on each side, and the contracts in them — units on DXtrade. */
export interface InstrumentCount {
instrument: string;
long: number;
short: number;
contracts: number;
}
export interface TradeMix {
open: InstrumentCount[];
today: InstrumentCount[];
}
/** A futures firm's instruments as the Auto draw sees them. */
export interface InstrumentStats {
/** ISO time computed. */
at: string;
/** Whose next leg the holds are sized for; null when no account has one. */
account: string | null;
leg: number | null;
/** 0 at the session's open, 1 at its last entry; null outside it. */
sessionProgress: number | null;
/** Holds at or over this are disabled. */
maxHoldHours: number;
/** Entries paying more than this to cross the spread are skipped (0 = no limit). */
maxEntrySpreadDollars?: number;
/** The firm whose client feeds the live quotes; null = none, typical spreads. */
quoteSource?: string | null;
instruments: {
symbol: string;
listed: boolean;
contract: string | null;
/** Crossing the spread on one contract, in dollars: live when
* `spreadLive`, else the typical one tick. */
spreadDollars: number;
spreadLive?: boolean;
/** Over the entry limit right now: out of the draw, not traded. */
spreadBlocked?: boolean;
/** One contract's typical daily move (1σ), in dollars. */
dailyMoveDollars: number | null;
holdHours: number | null;
disabled: boolean;
/** Share of Auto draws right now (0–1); null when not in the draw. */
chance: number | null;
}[];
}
/** How one account's next leg sizes on each candidate (see planLegSize). */
export interface SymbolStats {
account: string;
/** The leg's dollar target, before fees and swap. */
leg: number;
targetHoldHours: number;
/** The leg rides without a daily-loss stop (see lastStand). */
lastStand: boolean;
/** ISO time the stats were computed. */
at: string;
symbols: {
symbol: string;
notional: number;
/** Notional ÷ account size. */
multiplier: number;
/** The broker's leverage on the symbol (5 = 5:1); null when unstated. */
maxMultiplier: number | null;
holdHours: number;
/** Expected spread, commissions and swap. */
cost: number;
winProbability: number | null;
/** The same leg and daily room with no commission, spread or swap —
* what the costs take off the win chance. Optional. */
winProbabilityNoFees?: number | null;
volatilityPerDay: number;
spreadFraction: number;
}[];
}
export interface SchedulerState {
@@ -33,6 +263,62 @@ export interface SchedulerState {
lastRun: string | null;
intervalSeconds: number;
stopAfterAll?: boolean;
/** Today's random session-open delay in minutes (0–180, after 5:45 PM
* CT), drawn once per Central-time day. Optional so an older master
* simply ignores it. */
sessionOpenDelayMin?: number;
/** What the last tick did in each lane, and why accounts sat it out.
* Optional so an older master simply ignores it. */
lastTicks?: LaneTick[];
}
/** Accounts that sat a tick out for one reason (dollar amounts folded, so
* like reasons group), with a few of their names. */
export interface TickReason {
reason: string;
count: number;
accounts: string[];
}
/** What the scheduler's last tick did in one lane. */
export interface LaneTick {
/** "futures", or the every-day firm's name. */
lane: string;
/** ISO time of the tick. */
at: string;
/** fired, no-eligible, positions-open, closed, holding-for-sync,
* skipped-in-flight, or "error: …". */
outcome: string;
/** One line on what happened: what fired, or what stopped it. */
detail: string;
/** Why the lane's accounts weren't in the batch, most common first. */
reasons?: TickReason[];
}
/** The auto-updater on this machine (scripts/update-check.mjs), read from
* the files it keeps and from git. */
export interface UpdaterStatus {
/** Its last check (it runs every minute under PM2); null = never ran here. */
lastCheckAt: string | null;
/** What that check found, e.g. "up to date at 1a2b3c4d" or "ERROR: …". */
lastCheck: string | null;
/** An update that failed and waits to retry: at which step, how often. */
failed: { target: string; step: string; at: string; attempts: number } | null;
/** The commit the running build was made from. */
builtCommit: string | null;
/** Commits on origin this checkout doesn't have, as of the updater's
* last fetch; null when git can't say. */
behind: number | null;
/** Tracked files edited on this machine — they can block the pull. */
localChanges: string[];
}
/** The build this instance is running — its checkout's HEAD commit. The
* commit time is what orders two different versions. Optional in every
* message so an older instance or master simply ignores it. */
export interface BuildVersion {
commit: string;
time: string;
}
export interface AutomationSummary {
@@ -46,6 +332,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 ─────────────────────────────────────────────────────────────
@@ -70,27 +358,173 @@ export interface CatalogAccountType {
maxLoss: number;
maxPositionSize: number;
targetSameEquity: boolean;
withdrawalStages: { profit: number; consistency: number; minTradingDays: number }[];
/** False = big days trade as one unsplit leg instead of random ≥$500 pieces. */
splitEnabled: boolean;
/** Big days (> the $1,700 split threshold) must carve at least this many
* legs (0 = however the carve falls). Smaller days always trade as one
* leg; clamped to what ≥$500 pieces make possible. */
minLegs: number;
/** Obfuscation, part 1: the first trading day of a cycle carries a $1,000
* account stop — the day books red and ends instead of riding. */
firstDayStop: boolean;
/** Obfuscation, part 2: min-day (small) trades carry a 2×-target stop; a
* stopped-out day books, and the next day rides stopless. */
minDayStop: boolean;
/** True = CME holiday sessions count with the next full trading day, the way
* firms that follow CME clearing report them (e.g. Apex: Sun/Mon/Tue around
* a Monday holiday is ONE trading day, recorded on the Tuesday). */
combineCmeHolidays: boolean;
/** Per-stage config after the Nth withdrawal; the last stage repeats.
* drain=true marks a spam-withdraw stage: no profit target — book
* 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; 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. */
payoutable?: boolean;
/** Trailing drawdown in dollars for ruin-model probabilities. 0 = unset. */
trailingDrawdown?: number;
/** Legacy static payout estimate — superseded by the computed chain. */
expectedPayout?: number;
/** Share of the balance withdrawn when a cycle completes (default 0.5). */
withdrawalPct?: number;
/** Cap on the gross withdrawal in dollars. 0 = no cap. */
payoutCap?: number;
/** Share of the gross the person actually receives (default 0.9). */
profitSplit?: number;
/** Hard daily loss limit in dollars (0 = none). Every leg then carries a
* stop that keeps the firm's day from losing more than this, less a 10%
* reserve and fees. Optional: an older master doesn't send it. */
dailyLossLimit?: number;
/** Copied from the firm by the catalog sync, so every cfg-based path sees
* it: the firm trades every calendar day (24/7). */
tradesEveryDay?: boolean;
/** ISO. Ties between two matching account types are broken by this. */
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', '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
* is ignored). */
export interface SymbolMapCandidate {
/** The platform's symbol, e.g. "BTC/USD". */
symbol: string;
}
/** CME root the batch drew (NQ, MNQ, GC…) or '*' for any root not listed →
* the candidates to choose from, cheapest per unit of volatility wins. */
export type SymbolMap = Record<string, SymbolMapCandidate[]>;
export interface CatalogFirm {
id: string;
name: string;
/** Which platform the firm's accounts trade on. Only 'tradovate' firms get
* a client here; anything else is synced but sits idle until its trading
* logic exists. Older masters omit the field — treated as 'tradovate'. */
platform: 'tradovate' | 'topstepx';
/** Which platform the firm's accounts trade on. Older masters omit the
* field — treated as 'tradovate'; a value this build doesn't know makes
* the sync skip the firm. */
platform: FirmPlatform;
/** What the firm trades, in order: CME roots for futures firms, the firm's
* 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'). */
loginDomain?: string;
/** Commission as % of notional per side (e.g. 0.04). 0 = the platform's
* per-contract fees, measured off each fill. */
commissionPct?: number;
/** Trades every calendar day, weekends and CME holidays included. */
tradesEveryDay?: boolean;
/** DXTrade, older masters: which symbols each CME root may trade as.
* Newer ones derive it from `instruments`. */
symbolMap?: SymbolMap;
/** DXTrade sizing: aim each leg's expected hold at this many hours
* (0 = DEFAULT_TARGET_HOLD_HOURS, 12). */
targetHoldHours?: number;
/** Overnight financing the firm charges, % of notional per day (0 = none). */
financingPctPerDay?: number;
updatedAt: string;
}
/** Settings the master applies to every instance (its Settings page). */
export interface CatalogTradingSettings {
/** The most an entry may pay to cross the spread, per contract, in
* dollars (0 = no limit). */
maxEntrySpreadDollars: number;
}
export interface FirmCatalogPayload {
version: number;
updatedAt: string;
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 {
@@ -103,7 +537,12 @@ export type Command =
| { kind: 'trade'; action: 'Buy' | 'Sell' | 'Auto'; symbol: string; stopAfterAll?: boolean }
| { kind: 'stop-trading' }
| { kind: 'autobuyer-run'; automationId: string; inputs?: Record<string, number> }
| { kind: 'report-now' };
| { kind: 'report-now' }
/** Push a fresh settings/credentials backup right 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
@@ -113,6 +552,6 @@ export type MasterMessage =
| { type: 'command'; id: string; command: Command };
export type InstanceMessage =
| { type: 'hello'; instance: string; id?: string; capabilities: InstanceCapabilities }
| { type: 'report'; firms: FirmStats[]; scheduler: SchedulerState; capabilities?: InstanceCapabilities }
| { type: 'hello'; instance: string; id?: string; capabilities: InstanceCapabilities; version?: BuildVersion }
| { type: 'report'; firms: FirmStats[]; scheduler: SchedulerState; capabilities?: InstanceCapabilities; version?: BuildVersion; updater?: UpdaterStatus }
| { type: 'ack'; id: string; ok: boolean; result?: unknown; error?: string };
+195
View File
@@ -0,0 +1,195 @@
/**
* Routes BROKER traffic through the operator's proxy — and nothing else.
*
* Scope is deliberate: TopstepX REST + its SignalR hub, Tradovate REST + its
* websocket, and the Yahoo quote lookups go through the proxy so each instance
* can present its own IP to the firms. The master link, reporter, catalog sync
* and backup push stay direct — the control channel must keep working when the
* proxy dies, and there is no setGlobalDispatcher here for exactly that
* reason. (Corollary: never call the master through axios — axios defaults
* carry the proxy agents. Master calls use fetch, which stays untouched.)
*
* Configured by the `proxy_url` setting: http://, https://, socks5:// or
* socks5h:// with optional user:pass. Read once per process — changing it
* requires a restart, which the settings UI says out loud. Blank = direct.
*
* Also home of the broker HTTP timeout (applyBrokerHttpDefaults): axios waits
* forever by default, and one broker request that never answers — a
* maintenance window that accepts the connection and goes silent — used to
* wedge a re-login or sync await for the life of the process. Same scoping
* argument as the proxy: every axios call here is broker/quote traffic, so the
* process default is the scoped choice. It covers axios only; websockets and
* the SignalR hub keep their own liveness handling in the clients.
*
* lib/http-client applies these defaults to every Axios client it exports,
* including clients created with axios.create(), in every server bundle.
*/
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;
interface ProxyState {
url: URL | null;
dispatcher: Dispatcher | null;
httpAgent: Agent | null;
httpsAgent: Agent | null;
}
// HMR-safe, and the "read once per process" contract lives here.
const g = globalThis as typeof globalThis & { __outboundProxy?: ProxyState };
function state(): ProxyState {
if (g.__outboundProxy) return g.__outboundProxy;
const s: ProxyState = { url: null, dispatcher: null, httpAgent: null, httpsAgent: null };
g.__outboundProxy = s;
const raw = getSetting('proxy_url')?.trim();
if (!raw) return s;
let url: URL;
try {
url = new URL(raw);
} catch {
// Not echoed: the value carries the proxy's user:password.
console.error('[proxy] proxy_url is not a URL — trading DIRECT');
return s;
}
if (!SCHEMES.includes(url.protocol as (typeof SCHEMES)[number])) {
console.error(`[proxy] proxy_url scheme must be one of ${SCHEMES.join(' ')} — trading DIRECT: ${url.protocol}`);
return s;
}
// A literal '%' in the user or password (one not starting a %XX escape)
// makes decodeURIComponent throw — here, and inside every proxy library
// below. Escape it so the password is used as typed. This now runs when
// the module loads, where a throw would fail every route that imports it.
const escapeStray = (part: string) => part.replace(/%(?![0-9A-Fa-f]{2})/g, '%25');
url.username = escapeStray(url.username);
url.password = escapeStray(url.password);
try {
const socks = url.protocol.startsWith('socks');
if (socks) {
const dispatcher = socksDispatcher({
type: 5,
host: url.hostname,
port: Number(url.port || 1080),
userId: url.username ? decodeURIComponent(url.username) : undefined,
password: url.password ? decodeURIComponent(url.password) : undefined,
});
const agent = new SocksProxyAgent(url);
s.dispatcher = dispatcher;
s.httpAgent = agent;
s.httpsAgent = agent;
} else {
const dispatcher = new ProxyAgent(url.href);
const httpAgent = new HttpProxyAgent(url);
const httpsAgent = new HttpsProxyAgent(url);
s.dispatcher = dispatcher;
s.httpAgent = httpAgent;
s.httpsAgent = httpsAgent;
}
} catch (err) {
// All or nothing — nothing is kept until every piece is built: half a
// proxy (REST proxied, sockets direct) would be worse than the
// documented bad-setting behavior.
console.error(`[proxy] proxy_url could not be set up — trading DIRECT: ${err instanceof Error ? err.message : String(err)}`);
return s;
}
s.url = url;
console.log(`[proxy] broker traffic will use ${url.protocol}//${url.host} (master link stays direct)`);
return s;
}
export function getProxyUrl(): string | null {
const { url } = state();
return url ? url.href : 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 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(defaults: AxiosDefaults): void {
const s = state();
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
* inactivity timeout, so it bounds a server that has gone silent, not a
* big report that is still streaming. Nothing legitimate comes close: the
* Tradovate report endpoints answer with a p-ticket and are polled (each
* poll its own short request, already capped at 60s per call), logins and
* REST calls answer in seconds. A per-call `timeout` still overrides it.
*
* The clock starts once the socket has connected — a connect that never
* completes is left to the OS connect timeout. Calls that can't wait that
* long (the Tradovate login and renewal) add an AbortSignal of their own. */
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 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(defaults: AxiosDefaults): void {
if (!defaults.timeout) defaults.timeout = BROKER_HTTP_TIMEOUT_MS;
}
/** 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;
}
/** 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();
return new UndiciWebSocket(wsUrl, {
...(dispatcher ? { dispatcher } : {}),
headers: { 'User-Agent': getRequestUserAgent() },
}) as unknown as WebSocket;
}
/** 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();
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;
}
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);
}
+582
View File
@@ -0,0 +1,582 @@
/**
* The surface a platform trading client must expose. TradovateClient grew this
* shape organically; TopstepXClient implements the same one so the scheduler,
* reporter, state route and resolver can drive either platform without caring
* which is behind the map entry.
*
* Contract ids are `number` on Tradovate and `string` on TopstepX (e.g.
* "CON.F.US.ENQ.Z25") and DXTrade (the symbol, e.g. "BTC/USD") — everything
* that compares them only ever compares within one client, so the union is
* safe.
*/
import type { FirmPlatform, FirmStats } from './master-protocol';
export interface PlatformAccount {
id: number;
name: string;
active: boolean;
}
/** How the batch's leg would size on a candidate instrument (see planLegSize):
* null when it can't be sized there. */
export interface PickContext {
targetHoldHours: number;
evaluate(contract: PlatformContract): { notional: number; holdHours: number; cost: number } | null;
}
export interface PlatformContract {
id: number | string;
name: string;
tickSize: number;
/** Dollars per 1.0 of price per engine contract. Absent = the CME
* product's POINT_VALUES entry (futures). */
pointValue?: number;
/** Commission as % of notional per side, where the firm charges that way. */
commissionPct?: number;
/** Notional of one engine contract at the current mid — present where the
* platform prices positions, which is what enables notional sizing. */
stepNotional?: number;
/** σ of log returns per √hour, from recent candles. */
volatilityPerHour?: number;
/** (ask − bid) / mid when the contract was picked. */
spreadFraction?: number;
/** Largest order the broker takes, in engine contracts. */
maxContracts?: number;
/** Smallest order the broker takes, in engine contracts. */
minContracts?: number;
/** 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;
/** This instrument's share of notional a stop may fill past its price,
* where it differs from the client's stopSlippageFraction (Vest: crypto,
* unmeasured, keeps the engine's default). */
stopSlippageFraction?: 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
* the ended day's record and today's share of it can't be told apart —
* the account waits for the broker to roll it. equity: balance plus open
* P&L, where the platform reports it; availableFunds: what margin can
* still draw on, where the platform reports it. */
accountCashBalances: { [accountId: number]: { amount: number; realizedPnL: number; pendingRoll?: boolean; equity?: number; availableFunds?: number } };
positions: {
[accountId: number]: {
contractId: number | string;
netPos: number;
netPrice: number;
timestamp: Date;
};
};
daysTraded: { [accountId: number]: number };
dailyPnL: { [accountId: number]: { date: string; pnl: number }[] };
fullDailyPnL: { [accountId: number]: { date: string; pnl: number }[] };
lastFundDates: { [accountId: number]: string | null };
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;
/** Epoch ms of the last sync that came back good (0 = never). */
lastSyncAt: number;
/** When that good sync started reading: everything it reported is at
* least this recent (a sequential pass can read an account long before
* it ends). */
lastSyncStartedAt?: number;
/** Whether the broker still shows a working order on the account. */
hasWorkingOrders?(accountId: number): boolean;
lastFetchErrors: Record<string, string>;
lastFetchRaw: Record<string, string>;
recentEntityEvents: { entityType: string; eventType: string; entity: unknown; ts: number }[];
products: { id: number; name: string }[];
fetchDaysTraded(onlyAccountIds?: number[]): Promise<{ failedAccounts: number }>;
/**
* Whether the account's history can be planned on. False until this
* client has loaded it (right after a restart, a client rebuild, a wipe,
* or for an account that just appeared), and on Tradovate while a balance
* change the history can't explain — a payout between reports — waits
* for a Cash History. The missing rows and withdrawals would otherwise
* 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
* firm's own day implement it (DXTrade: 22:00 UTC); the rest leave it out
* and the engine falls back to the session's realized P&L. null = the
* platform knows it but can't tell yet (history not fetched): hold the
* 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;
/** The contract a product trades as. A platform that chooses between
* instruments (DXTrade's symbol map) ranks them with `pick` when given —
* how a leg would size on each — and platforms that don't, ignore it. */
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,
intent?: OrderIntent,
): Promise<Record<string, unknown>>;
placeOrderNoWait(
accountId: number,
contractSymbol: string,
quantity: number,
action: 'Buy' | 'Sell',
orderType: 'Market' | 'Limit',
price?: number
): Promise<{ orderId?: number }>;
placeOcoExit(
accountId: number,
contractSymbol: string,
quantity: number,
exitAction: 'Buy' | 'Sell',
limitPrice: number,
stopPrice: number,
): Promise<{ orderId?: number }>;
disconnect(): void;
onFill(orderId: number, callback: (fill: Record<string, unknown>) => void): void;
health(): ClientHealth;
}
/**
* Liveness snapshot of one client — what /api/health shows, what the registry
* supervisor judges, and the numbers to read before restarting a broken box.
* Timestamps are epoch ms, 0 = never. Never carries tokens or credentials.
*/
export interface ClientHealth {
platform: FirmPlatform;
/** Holding a session token right now. */
authenticated: boolean;
/** Tradovate: socket readyState name; TopstepX: hub state; DXTrade: 'rest'
* or its push socket's state. 'none' = no transport. */
transport: string;
syncComplete: boolean;
lastSyncAt: number;
lastLoginAt: number;
lastLoginError: string | null;
consecutiveLoginFailures: number;
/** When the next reconnect/login attempt fires (0 = none scheduled). */
nextReconnectAt: number;
tokenExpiresAt: number;
/** Last frame from the broker's realtime channel (socket or hub). */
lastInboundAt: number;
/** Requests still waiting on a broker answer (acks, fills). */
pendingRequests: number;
/** When the in-flight sync started (0 = none in flight). */
syncInFlightSince: number;
/** When the in-flight daily P&L report fetch started (0 = none in flight). */
daysFetchInFlightSince: number;
}
/** A sync older than this means positions and balances can't be traded on. */
export const SYNC_STALE_MS = 5 * 60 * 1000;
/** The one readiness rule shared by the scheduler, the supervisor and /api/health:
* synced, and synced recently. */
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.vestAppNumbers: on a Vest firm, that its account names end in
* the number the Vest app shows (vest-helpers.ts vestAccountName). */
export function vestAppNumbersField(platform: string): Pick<FirmStats, 'vestAppNumbers'> {
return platform === 'vest' ? { vestAppNumbers: 1 } : {};
}
/**
* 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.
* Anything ambiguous (a timeout waiting for an ack or a fill — it may still
* have filled) must stay a plain Error. The scheduler un-counts a leg only for
* this one, so a wrong "not placed" could let an account trade twice.
*/
export class OrderNotPlacedError extends Error {
readonly orderNotPlaced = true;
constructor(message: string) {
super(message);
this.name = 'OrderNotPlacedError';
}
}
/** Duck-typed so it survives module duplication across route bundles. */
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. */
export function errorMessage(err: unknown): string {
if (!err || typeof err !== 'object') return String(err);
const e = err as { message?: unknown; code?: unknown; response?: { status?: unknown } };
const status = typeof e.response?.status === 'number' ? ` (HTTP ${e.response.status})` : '';
const code = typeof e.code === 'string' && !String(e.message ?? '').includes(e.code) ? ` [${e.code}]` : '';
return `${typeof e.message === 'string' ? e.message : String(err)}${status}${code}`;
}
/** lastFetchErrors note for a Tradovate account whose Cash History is
* unavailable (passed accounts) and whose rows came from the Fills report —
* a fresh fetch, not a failure. */
export const FILLS_FALLBACK_NOTE = 'cash history unavailable (using fills fallback)';
/**
* Can this account's daily P&L be trusted for a verdict the master keeps
* for good (cycleCompletion 'today')? Not while a fetch run is in flight —
* at startup Tradovate serves the DB cache before each account's fetch —
* and not when the account's last fetch failed and left cached rows behind.
*/
export function historyFresh(client: PlatformClient, account: PlatformAccount): boolean {
if (!client.fetchDaysComplete) return false;
const error = client.lastFetchErrors?.[account.name];
return !error || error === FILLS_FALLBACK_NOTE;
}
+79
View File
@@ -0,0 +1,79 @@
/**
* Pure helpers for the live futures quote feed (lib/live-quotes.ts): reading
* what TopstepX's market hub and Tradovate's market-data socket send, and
* turning the top of the book into the spread an entry pays.
*
* No imports, so scripts/check-instruments.mjs can test them directly.
*/
export interface BookTop {
bid?: number;
ask?: number;
}
const price = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined);
/**
* A TopstepX GatewayQuote laid over the last one. The hub sends only the
* fields that changed, so a side it leaves out keeps its last price.
*/
export function mergeTopstepxQuote(prev: BookTop | undefined, raw: unknown): BookTop {
const r = raw as Record<string, unknown> | null;
const data = r && typeof r === 'object' && 'data' in r && typeof r.data === 'object' && r.data !== null
? (r.data as Record<string, unknown>)
: r;
const bid = price(data?.bestBid);
const ask = price(data?.bestAsk);
return { ...prev, ...(bid !== undefined ? { bid } : {}), ...(ask !== undefined ? { ask } : {}) };
}
export interface TradovateFrame {
/** Replies to requests: request id, HTTP-like status, payload. */
responses: { id: number; status: number; data?: unknown }[];
/** Quote updates by contract id; a side left out didn't change. */
quotes: { contractId: number; bid?: number; ask?: number }[];
}
/** One frame off Tradovate's market-data socket ('a[...]'; 'o', 'h' and 'c'
* frames carry nothing to read). */
export function parseTradovateFrame(frame: unknown): TradovateFrame {
const out: TradovateFrame = { responses: [], quotes: [] };
if (typeof frame !== 'string' || frame[0] !== 'a') return out;
let items: unknown;
try { items = JSON.parse(frame.slice(1)); } catch { return out; }
if (!Array.isArray(items)) return out;
for (const item of items) {
if (!item || typeof item !== 'object') continue;
const it = item as { e?: unknown; d?: unknown; i?: unknown; s?: unknown };
if (typeof it.i === 'number' && typeof it.s === 'number') {
out.responses.push({ id: it.i, status: it.s, data: it.d });
continue;
}
if (it.e !== 'md') continue;
const quotes = (it.d as { quotes?: unknown } | undefined)?.quotes;
if (!Array.isArray(quotes)) continue;
for (const q of quotes) {
const quote = q as { contractId?: unknown; entries?: Record<string, { price?: unknown } | undefined> } | null;
if (typeof quote?.contractId !== 'number') continue;
const bid = price(quote.entries?.Bid?.price);
const ask = price(quote.entries?.Offer?.price);
out.quotes.push({ contractId: quote.contractId, ...(bid !== undefined ? { bid } : {}), ...(ask !== undefined ? { ask } : {}) });
}
}
return out;
}
/**
* What crossing the book costs on one contract: the width between bid and
* ask, in dollars and in ticks. null for a book missing a side or crossed
* (ask below bid) — not a spread anyone can enter on.
*/
export function bookSpread(book: BookTop | undefined, pointValue: number, tickSize: number): { dollars: number; ticks: number } | null {
if (!book || book.bid === undefined || book.ask === undefined || book.ask < book.bid) return null;
if (!(pointValue > 0)) return null;
const width = book.ask - book.bid;
return {
dollars: Math.round(width * pointValue * 100) / 100,
ticks: tickSize > 0 ? Math.round(width / tickSize) : NaN,
};
}
+529 -29
View File
@@ -11,14 +11,24 @@
* POST keeps those working unchanged.
*/
import { getSetting } 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 { computeDayPlan, currentTradeDateCT, LEG_COMPLETION_EPSILON } from './trading-logic';
import type { AccountTypeStats } from './master-protocol';
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 { accountStatusFields, brokerFacts, emptyFirmStats, firmStatusFields, historyFresh, keptRuleIdsField, vestAppNumbersField } from './platform-client';
function collectFirmStats() {
// Only firms this machine has credentials for. A firm it cannot log into
@@ -26,50 +36,315 @@ function collectFirmStats() {
// the master's table with rows for firms it does not run.
const firms = getTradableFirms();
const clients = getClients();
// 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 =>
cfg.payoutable === true || cfg.withdrawalStages.length > 0;
const completionKind = (cfg: Parameters<typeof loops>[0], drain: boolean): 'pass' | 'payout' | 'drain' =>
(drain ? 'drain' : loops(cfg) ? 'payout' : 'pass');
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, 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), ...vestAppNumbersField(f.platform) };
// 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;
let inTrade = 0;
let targetHitCount = 0;
let partialCount = 0;
let lossCount = 0;
let personalDllCount = 0;
let completedCount = 0;
// The firm's whole broker list, dead accounts included — the master
// tells an account the firm erased from one still listed, and spots
// a newly arrived funded account.
const accounts: NonNullable<FirmStats['accounts']> = [];
// 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,
targetHit: 0,
partial: 0,
loss: 0,
};
byType.set(key, entry);
}
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 isDead = autoLiqThreshold > 0 && cash.amount <= autoLiqThreshold;
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
// at all rather than a zero: undercounting only leaves the entry
// 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,
...(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;
if (isDead) continue;
const cfg = getAccountConfig(acc.id).accountType;
const slice = bucket(cfg);
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
// account — including ones mid-trade — so the days-traded
// histogram below covers the whole slice. Accounts in the red or
// DLL-locked stay OUT of the histogram: the payout calendar
// projects from "on the winning path", which they are not.
// (Dead accounts never reach here — the auto-liq check above.)
const combined = cfg ? combineCmeSessions(
cfg.combineCmeHolidays, client.dailyPnL[acc.id] ?? [], rawToday, cash.realizedPnL,
) : null;
// Stage-resolved config, shared by the payout state and the
// day-outcome check below — a drain stage swaps in its own
// min-day and drops the profit target.
const effective = cfg ? resolveEffectiveConfig(
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
// (app/api/state): only days reaching minDayPnL when it is set,
// today included once it books.
const dayOn = combined
? effective && effective.minDayPnL > 0
? combined.dailyPnL.filter((d) => d.pnl >= effective.minDayPnL).length
: combined.daysTraded
: null;
const dayEntry = dayOn === null ? null : ((slice.dayStats ??= {})[dayOn] ??= { total: 0, targetHit: 0 });
if (dayEntry) dayEntry.total++;
if (cfg && combined && acc.active && cash.amount - cfg.accountSize >= 0) {
const hist = (slice.daysHist ??= {});
hist[combined.daysTraded] = (hist[combined.daysTraded] ?? 0) + 1;
}
// Exact payout state for every live account — negative ones
// included, since the master sims their real required profit.
// client.dailyPnL is already the active-cycle slice, so a
// withdrawal resets days/maxDay/sum here automatically.
if (cfg && combined && effective) {
const r2 = (n: number) => Math.round(n * 100) / 100;
const equityProfit = r2(cash.amount - cfg.accountSize);
const qualifying = effective.minDayPnL === 0
? combined.dailyPnL
: combined.dailyPnL.filter((d) => d.pnl >= effective.minDayPnL);
const sum = r2(combined.dailyPnL.reduce((s, d) => s + d.pnl, 0));
// End-of-day equity-profit high-water mark for this cycle,
// floored at the $0 start: walk the P&L from the cycle base.
let run = r2(equityProfit - sum);
let highWater = Math.max(0, run);
for (const d of combined.dailyPnL) {
run = r2(run + d.pnl);
if (run > highWater) highWater = run;
}
const traded = combined.dailyPnL.filter((d) => d.pnl !== 0);
// The account's future payout chain on the perfect path —
// dates, dynamic amounts (withdrawal % of balance, capped,
// × profit split) and cumulative fair-walk reach probability.
// The master renders these directly instead of running its
// own Monte-Carlo for this account.
let futurePayouts: NonNullable<AccountTypeStats['payoutStates']>[number]['futurePayouts'];
try {
futurePayouts = projectPerfectPath({
accountId: acc.id,
cfg,
cycleDailyPnL: client.dailyPnL[acc.id] ?? [],
fundTransactions: fundTxns,
balance: cash.amount,
realizedPnL: cash.realizedPnL,
rawTradeDate: rawToday,
stageInputs: stageInputsFor(client, acc.id, cfg),
payout: {
withdrawalPct: cfg.withdrawalPct,
payoutCap: cfg.payoutCap,
profitSplit: cfg.profitSplit,
// Same default chain the master's calendar always
// used: configured trailing drawdown, else the
// max-loss size, else $2,000.
drawdown: (cfg.trailingDrawdown ?? 0) > 0
? cfg.trailingDrawdown!
: Math.abs(cfg.maxLoss) > 0 ? Math.abs(cfg.maxLoss) : 2000,
startHigh: highWater,
// Tradovate reports the account's REAL liquidation
// floor — anchor the trail there for exact room.
startThreshold: autoLiqThreshold !== 0
? r2(autoLiqThreshold - cfg.accountSize)
: undefined,
},
})
.filter((p) => p.withdrawal !== undefined || p.payoutReady)
.slice(0, 6)
.map((p) => ({
date: p.date,
gross: p.gross ?? 0,
net: p.net ?? 0,
prob: Math.round(p.prob * 10000) / 10000,
stage: p.stage,
...(p.drain ? { drain: true } : {}),
}));
if (futurePayouts.length === 0) futurePayouts = undefined;
} catch (err) {
console.error(`[reporter] payout projection failed for account ${acc.id}`, err);
}
(slice.payoutStates ??= []).push({
days: qualifying.length,
profit: equityProfit,
maxDay: qualifying.length > 0 ? Math.max(...qualifying.map((d) => d.pnl)) : 0,
sum,
high: highWater,
withdrawals: withdrawalsTaken,
target: effective.profitTarget,
consistency: effective.consistency,
minDays: effective.minTradingDays,
lastDay: traded.length > 0 ? traded.reduce((a, b) => (a.date > b.date ? a : b)).date : undefined,
...(futurePayouts ? { futurePayouts } : {}),
});
}
// Only once the account's history has loaded — right after a
// restart the missing rows and withdrawals would read as a fresh
// stage-1 cycle.
const historyLoaded = client.dailyPnL[acc.id] !== undefined
&& loadedFundTxns !== undefined;
// Catch-up for the cycle a fund transaction just ended: if it stood
// complete, name it, since its final day may never have been
// reported (a guard held it back, or this instance was offline).
// Named only — it isn't today's outcome, so no counter moves. Ahead
// of the in-trade check: the new cycle trading doesn't hide it.
if (cfg && effective && historyLoaded && historyFresh(client, acc)) {
const ended = endedCycleCompletion({
accountId: acc.id,
rawTradeDate: rawToday,
client,
fullDailyPnL: client.fullDailyPnL[acc.id] ?? [],
cycleDailyPnL: client.dailyPnL[acc.id],
fundTransactions: fundTxns,
lastFundDate: client.lastFundDates?.[acc.id] ?? null,
endedCycleStart: loadEndedCycleStart(acc.id),
rawRealizedPnL: cash.realizedPnL,
equityProfit: cash.amount - cfg.accountSize,
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: 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++;
@@ -79,25 +354,124 @@ function collectFirmStats() {
continue;
}
// Check if target was hit today
if (cfg) {
const dailyPnL = client.dailyPnL[acc.id] ?? [];
const equityProfit = cash.amount - cfg.accountSize;
const plan = computeDayPlan(
acc.id, currentTradeDateCT(), cfg.profitTarget, cfg.consistency,
dailyPnL, cfg.minDayPnL, cfg.minTradingDays, equityProfit, cash.realizedPnL
);
const targetHit = !plan ? false :
((plan.dayTarget === 0 && Math.abs(cash.realizedPnL) > 0 && (client.daysTraded[acc.id] ?? 0) <= cfg.minTradingDays) ||
(cash.realizedPnL > 0 && plan.remainingToday <= LEG_COMPLETION_EPSILON));
if (targetHit || cash.realizedPnL !== 0) {
// Did today finish the cycle? Checked ahead of the lockout branch:
// firms lock a passed account (Tradovate liquidate-only, TopstepX
// canTrade=false), and a pass is no loss. A 'today' only on
// freshly fetched rows, since the master keeps it.
if (cfg && combined && effective && historyLoaded) {
const completion = cycleCompletion({
accountId: acc.id,
rawTradeDate: rawToday,
client,
cycleDailyPnL: client.dailyPnL[acc.id],
rawRealizedPnL: cash.realizedPnL,
combineCmeHolidays: cfg.combineCmeHolidays,
tradesEveryDay: cfg.tradesEveryDay === true,
effective,
equityProfit: cash.amount - cfg.accountSize,
splitEnabled: cfg.splitEnabled,
});
if (completion === 'today' && historyFresh(client, acc)) {
accountsTraded++;
slice.accountsTraded++;
targetHitCount++;
slice.targetHit = (slice.targetHit ?? 0) + 1;
completedCount++;
slice.completed = (slice.completed ?? 0) + 1;
if (dayEntry) {
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: place.stage,
kind: completionKind(cfg, effective.drain),
cycle,
...(place.cycleSeq > 0 ? { cycleSeq: place.cycleSeq } : {}),
});
continue;
}
// Passed on an earlier day and locked since: nothing traded
// today, so it reads as untraded — like a finished account
// that's still active — not as a DLL loss every day after.
if (completion === 'before' && !acc.active && combined.realizedPnL === 0) continue;
}
// DLL lockout: Tradovate flips the account to liquidate-only, so
// its day is over at a loss even when the booked realizedPnL
// hasn't caught up yet (or the account has no matching config).
if (!acc.active) {
accountsTraded++;
slice.accountsTraded++;
lossCount++;
slice.loss = (slice.loss ?? 0) + 1;
continue;
}
// Check if target was hit today — on the stage-resolved config, so
// a later stage's (or drain's) day counts the way it was traded.
if (cfg && combined && effective) {
const equityProfit = cash.amount - cfg.accountSize;
const plan = computeDayPlan(
acc.id, combined.tradeDate, effective.profitTarget, effective.consistency,
combined.dailyPnL, effective.minDayPnL, effective.minTradingDays, equityProfit, combined.realizedPnL,
cfg.splitEnabled, effective.drain
);
const targetHit = !plan ? false :
((plan.dayTarget === 0 && Math.abs(combined.realizedPnL) > 0 && combined.daysTraded <= effective.minTradingDays) ||
(combined.realizedPnL > 0 && plan.remainingToday <= LEG_COMPLETION_EPSILON));
if (targetHit || combined.realizedPnL !== 0) {
accountsTraded++;
slice.accountsTraded++;
// 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 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, effective) && combined.realizedPnL <= -25) {
personalDllCount++;
slice.personalDll = (slice.personalDll ?? 0) + 1;
} else if (combined.realizedPnL < 0) {
lossCount++;
slice.loss = (slice.loss ?? 0) + 1;
} else if (targetHit) {
targetHitCount++;
slice.targetHit = (slice.targetHit ?? 0) + 1;
if (dayEntry) dayEntry.targetHit++;
} else {
partialCount++;
slice.partial = (slice.partial ?? 0) + 1;
}
}
}
}
// 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.
@@ -105,12 +479,129 @@ function collectFirmStats() {
(b.totalAccounts - b.accountsTraded) - (a.totalAccounts - a.accountsTraded)
|| a.label.localeCompare(b.label));
return { firm: f.name, totalAccounts, accountsTraded, inTrade, accountTypes };
return {
firm: f.name, totalAccounts, accountsTraded, inTrade,
targetHit: targetHitCount, partial: partialCount, loss: lossCount,
personalDll: personalDllCount, completed: completedCount,
// Unknown until the firm has synced, not empty.
...(client.syncComplete ? { accounts } : {}),
accountTypes,
...symbolStatsFor(f, client, rawToday),
...instrumentStatsBlock(f, client, rawToday),
mix: tradeMixFor(f, client),
...firmStatusFields(f.id, client.syncComplete, client.lastSyncAt),
...keptRuleIdsField(keptRuleIds),
...vestAppNumbersField(f.platform),
};
});
}
/** 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
function leadCandidates(client: any): { id: number; name: string }[] {
const groups = new Map<string, { id: number; name: string }[]>();
for (const acc of client.accountList as { id: number; name: string; active: boolean }[]) {
const cfg = getAccountConfig(acc.id).accountType;
const floor = client.autoLiqThresholds?.[acc.id] ?? 0;
const cash = client.accountCashBalances?.[acc.id];
if (!cfg || !acc.active || !cash || (floor !== 0 && cash.amount <= floor)) continue;
groups.set(cfg.prefix, [...(groups.get(cfg.prefix) ?? []), acc]);
}
return [...groups.values()].sort((a, b) => b.length - a.length).flat();
}
/**
* A futures firm's instruments as the Auto draw sees them, for the master's
* Firms page: entry cost, movement and chance for each, with holds sized for
* the next leg of the first live account (largest prefix group) that has one.
*/
function instrumentStatsBlock(
f: { id: string; name: string; platform: string; instruments?: string[]; bannedSymbols?: string[] },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any,
tradeDate: string,
): { instrumentStats?: InstrumentStats } {
if (!isFuturesFirm(f)) return {};
let lead: { account: string; leg: number; stopDollars: number; maxPositionSize: number } | null = null;
for (const acc of leadCandidates(client)) {
const shape = accountLegShape(client, acc, tradeDate);
if (shape) { lead = { ...shape, account: acc.name }; break; }
}
return { instrumentStats: instrumentStatsFor(f, lead) };
}
/**
* A DXtrade firm's next leg on each symbol-map candidate, for the master's
* Firms page: one live account from the firm's largest prefix group — the
* group a batch fires — with something left to trade today. Nothing for
* other platforms, or when no account has a leg to size.
*/
function symbolStatsFor(
f: { name: string; commissionPct?: number; targetHoldHours?: number; financingPctPerDay?: number },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any,
tradeDate: string,
): { symbolStats?: SymbolStats } {
if (!(client.lastCandidates?.length > 0)) return {};
const groups = new Map<string, { id: number; name: string }[]>();
for (const acc of client.accountList as { id: number; name: string; active: boolean }[]) {
const cfg = getAccountConfig(acc.id).accountType;
const floor = client.autoLiqThresholds?.[acc.id] ?? 0;
const cash = client.accountCashBalances?.[acc.id];
if (!cfg || !acc.active || !cash || (floor !== 0 && cash.amount <= floor)) continue;
groups.set(cfg.prefix, [...(groups.get(cfg.prefix) ?? []), acc]);
}
for (const group of [...groups.values()].sort((a, b) => b.length - a.length)) {
for (const acc of group) {
const next = accountNextLeg(client, acc, f, tradeDate);
if (!next || next.candidates.length === 0) continue;
return {
symbolStats: {
account: acc.name,
leg: next.leg,
targetHoldHours: Math.round(sizingHoldHours(f, tradeDate) * 10) / 10,
lastStand: next.lastStand,
at: new Date().toISOString(),
symbols: next.candidates.map((c) => ({
symbol: c.symbol, notional: c.notional, multiplier: c.multiplier, maxMultiplier: c.maxMultiplier,
holdHours: c.holdHours, cost: c.cost, winProbability: c.winProbability, winProbabilityNoFees: c.winProbabilityNoFees,
volatilityPerDay: c.volatilityPerDay, spreadFraction: c.spreadFraction,
})),
},
};
}
}
return {};
}
// 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
@@ -127,7 +618,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',
@@ -139,6 +637,8 @@ export async function sendReport(): Promise<void> {
id: getSetting('instance_id') || undefined,
firms,
scheduler,
version: getBuildVersion() ?? undefined,
updater: updaterStatus(),
}),
});
} catch {
+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;
}
File diff suppressed because it is too large Load Diff
+102
View File
@@ -0,0 +1,102 @@
/**
* What each firm is trading, by instrument — its open positions now and its
* entries today — for the master's fleet-wide mix.
*
* 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), 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 { 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';
/** Contract ids remembered at most — the front months of a few roots. */
const MAX_NAMES = 200;
type Tally = Record<string, { long: number; short: number; contracts: number }>;
interface Today {
date: string;
byFirm: Record<string, Tally>;
}
function readJson<T>(key: string): T | null {
try {
return JSON.parse(getSetting(key) ?? 'null') as T | null;
} catch {
return null;
}
}
function today(date: string): Today {
const saved = readJson<Today>(TODAY_KEY);
return saved?.date === date && saved.byFirm && typeof saved.byFirm === 'object' ? saved : { date, byFirm: {} };
}
/**
* Count one filled entry toward the firm's mix for today, and remember which
* instrument its contract is. Bookkeeping: it never throws into a trade.
*/
export function recordEntry(firm: string, instrument: string, side: 'Buy' | 'Sell', contracts: number, contractId?: unknown): void {
try {
const t = today(currentTradeDateCT());
const tally = (t.byFirm[firm] ??= {});
const entry = (tally[instrument] ??= { long: 0, short: 0, contracts: 0 });
if (side === 'Buy') entry.long++;
else entry.short++;
entry.contracts += Number.isFinite(contracts) ? contracts : 0;
setSetting(TODAY_KEY, JSON.stringify(t));
if (contractId !== undefined && contractId !== null && contractId !== '') {
const names = readJson<Record<string, string>>(NAMES_KEY) ?? {};
names[String(contractId)] = instrument;
const kept = Object.entries(names).slice(-MAX_NAMES);
setSetting(NAMES_KEY, JSON.stringify(Object.fromEntries(kept)));
}
} catch (err) {
console.warn(`[trade-mix] could not record an entry: ${errorMessage(err)}`);
}
}
/** The firm's open positions and today's entries, by instrument. */
export function tradeMixFor(
firm: { name: string; platform: string },
// 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>();
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 => (isNotionalPlatform(firm.platform)
? String(contractId).split('|')[0]
: 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 }[]) {
const pos = client.positions?.[acc.id] as { contractId: unknown; netPos: number } | undefined;
if (!pos || !pos.netPos) continue;
const instrument = instrumentOf(pos.contractId);
const entry = open.get(instrument) ?? { instrument, long: 0, short: 0, contracts: 0 };
if (pos.netPos > 0) entry.long++;
else entry.short++;
entry.contracts += Math.abs(pos.netPos);
open.set(instrument, entry);
}
const tally = today(currentTradeDateCT()).byFirm[firm.name] ?? {};
return {
open: [...open.values()],
today: Object.entries(tally).map(([instrument, t]) => ({ instrument, long: t.long, short: t.short, contracts: t.contracts })),
};
}
+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));
}
}
+1879 -24
View File
File diff suppressed because it is too large Load Diff
+1767 -306
View File
File diff suppressed because it is too large Load Diff
+78
View File
@@ -0,0 +1,78 @@
/**
* The auto-updater's state on this machine (scripts/update-check.mjs), for
* the master: its last check, an update that failed and waits to retry, how
* far the checkout is behind origin, and tracked files edited by hand — which
* stop the fast-forward pull. Read from the files the updater keeps and from
* git, and cached: the reporter asks every 30 seconds.
*/
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import type { UpdaterStatus } from './master-protocol';
const CACHE_MS = 2 * 60_000;
const BRANCH = process.env.AUTOFIRMER_BRANCH ?? 'master';
// Per process: instrumentation.ts and the route handlers each load this module.
const g = globalThis as typeof globalThis & { __updaterStatus?: { at: number; status: UpdaterStatus } };
function readScript(file: string): string | null {
try {
return readFileSync(path.join(process.cwd(), 'scripts', file), 'utf8');
} catch {
return null;
}
}
function git(args: string[]): string | null {
try {
return execFileSync('git', args, {
cwd: process.cwd(),
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5_000,
}).trimEnd();
} catch {
return null;
}
}
function readFailure(): UpdaterStatus['failed'] {
try {
const f = JSON.parse(readScript('.update-failed') ?? 'null') as Record<string, unknown> | null;
if (!f || typeof f.target !== 'string') return null;
const at = Number(f.at);
return {
target: f.target.slice(0, 12),
step: String(f.step ?? '').slice(0, 40),
at: Number.isFinite(at) ? new Date(at).toISOString() : '',
attempts: Number(f.count) || 1,
};
} catch {
return null;
}
}
export function updaterStatus(now = Date.now()): UpdaterStatus {
const cached = g.__updaterStatus;
if (cached && now - cached.at < CACHE_MS) return cached.status;
// "<ISO time> <status>", rewritten on every check.
const last = /^(\S+)\s+(.*)$/.exec(readScript('.last-check')?.trim() ?? '');
const behind = git(['rev-list', '--count', `HEAD..origin/${BRANCH}`]);
const changed = git(['status', '--porcelain', '--untracked-files=no']);
const status: UpdaterStatus = {
lastCheckAt: last ? last[1] : null,
lastCheck: last ? last[2].slice(0, 200) : null,
failed: readFailure(),
builtCommit: readScript('.built-commit')?.trim().slice(0, 12) || null,
behind: behind !== null && /^\d+$/.test(behind) ? Number(behind) : null,
// "XY path" per line; the status column can start with a space.
localChanges: changed
? changed.split('\n').map((line) => line.slice(3).trim()).filter(Boolean).slice(0, 10)
: [],
};
g.__updaterStatus = { at: now, status };
return status;
}
+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']) };
}
+47
View File
@@ -0,0 +1,47 @@
/**
* The version of the running build.
*
* Updates arrive as `git pull` (scripts/update-check.mjs), so the checkout's
* HEAD commit *is* the version — there is no release number to bump and
* nothing for anyone to forget. The commit time rides along because hashes
* don't order: it is what lets the master say which of two different
* versions is the stale one.
*
* Read once and cached: the update task restarts the process after every
* pull, so HEAD cannot move under a running build — and when it briefly does
* (pull landed, restart pending), the cached value still describes the code
* that is actually executing.
*/
import { execFileSync } from 'node:child_process';
export interface BuildVersion {
/** Short commit hash of HEAD, e.g. "c493796". */
commit: string;
/** ISO commit time of HEAD — orders versions, which hashes cannot. */
time: string;
}
// Per process, not per bundle: instrumentation.ts and the route handlers load
// their own copies of this module, and each would otherwise read HEAD at its own
// first call — after an update's pull, a late first call reports code that isn't
// running yet.
const _g = globalThis as typeof globalThis & { __buildVersion?: BuildVersion | null };
/** null when this isn't a git checkout or git isn't installed — both mean
* "no version to report", and the master shows the machine as an old build. */
export function getBuildVersion(): BuildVersion | null {
if (_g.__buildVersion !== undefined) return _g.__buildVersion;
try {
const out = execFileSync('git', ['log', '-1', '--format=%h %cI'], {
cwd: process.cwd(),
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
const [commit, time] = out.split(' ');
_g.__buildVersion = commit && time ? { commit, time } : null;
} catch {
_g.__buildVersion = null;
}
return _g.__buildVersion;
}
+2466
View File
File diff suppressed because it is too large Load Diff
+844
View File
@@ -0,0 +1,844 @@
/**
* 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;
/** Crypto's (asset "crypto" in exchangeInfo: BTC, ETH, SOL, HYPE): unmeasured,
* so the engine's default share (trading-logic's DLL_SLIPPAGE_FRACTION).
* Vest's BTC book trades ~$10M an hour on a weekend against NDX's ~$600M, and
* a batch's stops fire together: a square-root impact estimate scaled to the
* NDX measurement puts the worst fill near 7bp. To come down once crypto
* stop-outs are measured, as NDX's was. */
export const VEST_CRYPTO_STOP_SLIPPAGE_FRACTION = 0.001;
/** The stop-slippage share for a symbol of this exchangeInfo asset class. */
export function vestStopSlippageFraction(asset: string | undefined): number {
return asset === 'crypto' ? VEST_CRYPTO_STOP_SLIPPAGE_FRACTION : VEST_STOP_SLIPPAGE_FRACTION;
}
/**
* 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;
/** Vest's attempt_index, from 0; -1 when Vest leaves it out. */
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) ?? -1,
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;
/**
* The number the Vest app shows for the account: attempt_index + 1, padded
* below 10 ("Account 01"). An account without an attempt index (the app's
* "Legacy Account") gets 0.
*/
export function vestAccountNumber(a: VestCapitalAccount): string {
if (!(a.attemptIndex >= 0)) return '0';
return `${a.attemptIndex < 9 ? '0' : ''}${a.attemptIndex + 1}`;
}
/**
* 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, ending in that same number:
* V50K-E-D3-T10-34 evaluation, $50k, 3% daily loss, 10% target, Account 34
* V50K-F-D3-34 the funded stage of an evaluation account
* V25K-I-07 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). Older builds ended the name in attempt_index, one
* lower; the master renames what it saved under those numbers when a report
* marks the firm vestAppNumbers.
*/
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)}`;
const n = vestAccountNumber(a);
if (/instant/i.test(a.productType)) return `V${size}-I-${n}`;
if (a.kind === 'funded') return `V${size}-F-${dll}-${n}`;
const target = a.initialCapital > 0 && a.profitTarget > 0 ? `-T${pct(a.profitTarget / a.initialCapital)}` : '';
return `V${size}-E-${dll}${target}-${n}`;
}
// ── 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;
}
+6 -1
View File
@@ -1,7 +1,12 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
serverExternalPackages: ['better-sqlite3'],
// @microsoft/signalr does dynamic require('ws')/require('eventsource') for
// its Node transports — bundling it (turbopack production builds) throws
// "dynamic usage of require is not supported" the moment a hub is built.
// undici and ws stay external for the same reason as signalr: the proxy
// module (lib/outbound-proxy.ts) needs the real Node packages, not a bundle.
serverExternalPackages: ['better-sqlite3', '@microsoft/signalr', 'undici', 'ws'],
};
export default nextConfig;
+329 -4
View File
@@ -8,12 +8,19 @@
"name": "autotrader-next",
"version": "0.1.0",
"dependencies": {
"@microsoft/signalr": "^10.0.11",
"axios": "^1.13.6",
"better-sqlite3": "^12.6.2",
"fetch-socks": "^1.3.3",
"http-proxy-agent": "^9.1.0",
"https-proxy-agent": "^9.1.0",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3",
"recharts": "^3.8.0"
"recharts": "^3.8.0",
"socks-proxy-agent": "^10.1.0",
"undici": "^7.29.1",
"ws": "^8.21.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -1025,6 +1032,40 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@microsoft/signalr": {
"version": "10.0.11",
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.11.tgz",
"integrity": "sha512-FulOJ2EEtKvLQcswe/U7v8pzyXyk7Jua5xgWJPPwyQU/2Z9ORvKcVjyO75VvLsem+CJ1ORRexJ+7Bz1FCei2aw==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"eventsource": "^2.0.2",
"fetch-cookie": "^2.0.3",
"node-fetch": "^2.6.7",
"ws": "^7.5.10"
}
},
"node_modules/@microsoft/signalr/node_modules/ws": {
"version": "7.5.13",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz",
"integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==",
"license": "MIT",
"engines": {
"node": ">=8.3.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@@ -2270,6 +2311,18 @@
"win32"
]
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -2293,6 +2346,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/agent-base": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz",
"integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==",
"license": "MIT",
"engines": {
"node": ">= 20"
}
},
"node_modules/ajv": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
@@ -3099,7 +3161,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -3904,12 +3965,30 @@
"node": ">=0.10.0"
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/eventsource": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz",
"integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
@@ -3980,6 +4059,26 @@
"reusify": "^1.0.4"
}
},
"node_modules/fetch-cookie": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz",
"integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==",
"license": "Unlicense",
"dependencies": {
"set-cookie-parser": "^2.4.8",
"tough-cookie": "^4.0.0"
}
},
"node_modules/fetch-socks": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/fetch-socks/-/fetch-socks-1.3.3.tgz",
"integrity": "sha512-jI22jzTf7x3GAcUoZARwuxKBPotb3PFkemVWqxlF2qV55FhRytRJrn95HeLoUMq2vkIbY3aQqNYytvZKqUoMaQ==",
"license": "MIT",
"dependencies": {
"socks": "^2.8.7",
"undici": ">=7"
}
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -4412,6 +4511,34 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/http-proxy-agent": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz",
"integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==",
"license": "MIT",
"dependencies": {
"agent-base": "9.0.0",
"debug": "^4.3.4",
"proxy-agent-negotiate": "1.1.0"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/https-proxy-agent": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz",
"integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==",
"license": "MIT",
"dependencies": {
"agent-base": "9.0.0",
"debug": "^4.3.4",
"proxy-agent-negotiate": "1.1.0"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -4515,6 +4642,15 @@
"node": ">=12"
}
},
"node_modules/ip-address": {
"version": "10.7.2",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
"integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -5514,7 +5650,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@@ -5688,6 +5823,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-releases": {
"version": "2.0.36",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
@@ -6042,12 +6197,41 @@
"react-is": "^16.13.1"
}
},
"node_modules/proxy-agent-negotiate": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz",
"integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==",
"license": "MIT",
"engines": {
"node": ">= 20"
},
"peerDependencies": {
"kerberos": "^2.0.0"
},
"peerDependenciesMeta": {
"kerberos": {
"optional": true
}
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/psl": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"funding": {
"url": "https://github.com/sponsors/lupomontero"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
@@ -6062,12 +6246,17 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
"license": "MIT"
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -6266,6 +6455,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"license": "MIT"
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
@@ -6439,6 +6634,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -6690,6 +6891,44 @@
"simple-concat": "^1.0.0"
}
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.10",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz",
"integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.1.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks-proxy-agent": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-10.1.0.tgz",
"integrity": "sha512-WlMj/67cEJ6MDI1OcsnjuYKDNDoyPCCYZ249kuuXPiMDw9F8PXkVaQ7YWu3siTydfQ/4BEZcvGzu+aYvz7dDCQ==",
"license": "MIT",
"dependencies": {
"agent-base": "9.0.0",
"debug": "^4.3.4",
"socks": "^2.8.3"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -7030,6 +7269,27 @@
"node": ">=8.0"
}
},
"node_modules/tough-cookie": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
"license": "BSD-3-Clause",
"dependencies": {
"psl": "^1.1.33",
"punycode": "^2.1.1",
"universalify": "^0.2.0",
"url-parse": "^1.5.3"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/ts-api-utils": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
@@ -7235,6 +7495,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/undici": {
"version": "7.29.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz",
"integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -7242,6 +7511,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/universalify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/unrs-resolver": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
@@ -7318,6 +7596,16 @@
"punycode": "^2.1.0"
}
},
"node_modules/url-parse": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"license": "MIT",
"dependencies": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
@@ -7355,6 +7643,22 @@
"d3-timer": "^3.0.1"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -7476,6 +7780,27 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+10 -2
View File
@@ -6,15 +6,23 @@
"dev": "next dev --webpack",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs scripts/check-instruments.mjs scripts/check-account-status.mjs scripts/check-kept-rules.mjs scripts/check-request-user-agent.mjs scripts/check-vest.mjs scripts/check-vest-client.mjs scripts/check-trades.mjs scripts/check-catalog.mjs scripts/check-extension-vest.mjs"
},
"dependencies": {
"@microsoft/signalr": "^10.0.11",
"axios": "^1.13.6",
"better-sqlite3": "^12.6.2",
"fetch-socks": "^1.3.3",
"http-proxy-agent": "^9.1.0",
"https-proxy-agent": "^9.1.0",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3",
"recharts": "^3.8.0"
"recharts": "^3.8.0",
"socks-proxy-agent": "^10.1.0",
"undici": "^7.29.1",
"ws": "^8.21.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
#
# Register an additional autofirmer instance on this device: same checkout,
# same build, its own PM2 process, port and database.
#
# ./scripts/add-instance.sh <N> [--name <instance_name>] [--master <url>]
# [--port <port>] [--token <backup_token>]
# [--restore-from <instance_uuid>]
#
# N instance number, 2 or higher (1 is the legacy install:
# pm2 name `autofirmer`, port 3000, ./autotrader.sqlite —
# registered by install-autostart.sh, never by this script)
# --name instance_name to seed (default: <hostname>-N)
# --master master dashboard url to seed (default: leave the app's seed)
# --port listen port (default: 3000 + N - 1; NOTE the master dev
# server uses 3001 — pick another port on a dev machine)
# --token master_backup_token to seed
# --restore-from pull that instance's backup from the master before first
# boot and adopt its identity (requires --master and --token)
#
# The instance lands in scripts/instances.json, which start-all.mjs and
# update-check.mjs iterate. The first run of this script also records the
# legacy instance 1 there so updates keep restarting it.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
REGISTRY="$ROOT/scripts/instances.json"
info() { printf ' %s\n' "$1"; }
die() { printf ' [X] %s\n' "$1" >&2; exit 1; }
[ $# -ge 1 ] || die "usage: $0 <N> [--name ...] [--master ...] [--port ...] [--token ...] [--restore-from ...]"
N="$1"; shift
case "$N" in (*[!0-9]*|'') die "N must be a number, got: $N" ;; esac
[ "$N" -ge 2 ] || die "N starts at 2 — instance 1 is the legacy install (install-autostart.sh)"
NAME="$(hostname)-$N"
MASTER=""
PORT=$((3000 + N - 1))
TOKEN=""
RESTORE_FROM=""
while [ $# -gt 0 ]; do
case "$1" in
--name) NAME="$2"; shift 2 ;;
--master) MASTER="$2"; shift 2 ;;
--port) PORT="$2"; shift 2 ;;
--token) TOKEN="$2"; shift 2 ;;
--restore-from) RESTORE_FROM="$2"; shift 2 ;;
*) die "unknown option: $1" ;;
esac
done
PM2_NAME="autofirmer-$N"
DB_REL=".data/instance-$N/autotrader.sqlite"
DB_ABS="$ROOT/$DB_REL"
if [ -n "$RESTORE_FROM" ]; then
[ -n "$MASTER" ] || die "--restore-from needs --master"
[ -n "$TOKEN" ] || die "--restore-from needs --token"
fi
NEXT_BIN="$ROOT/node_modules/next/dist/bin/next"
[ -f "$NEXT_BIN" ] || die "next not found at $NEXT_BIN - run npm install first"
[ -d "$ROOT/.next" ] || die "no production build - run npm run build first"
command -v pm2 >/dev/null 2>&1 || die "PM2 not installed - run install-autostart.sh first"
# ── registry: create with the legacy entry, refuse duplicates, append ───────
node - "$REGISTRY" "$PM2_NAME" "$PORT" "$DB_REL" <<'NODE'
const { existsSync, readFileSync, writeFileSync } = require('node:fs');
// `node -` puts '-' in argv[1], so the arguments start at argv[2].
const [file, name, port, db] = process.argv.slice(2);
const entries = existsSync(file)
? JSON.parse(readFileSync(file, 'utf8'))
: [{ name: 'autofirmer', port: 3000, db: 'autotrader.sqlite' }];
for (const e of entries) {
if (e.name === name) { console.error(` [X] ${name} is already registered`); process.exit(1); }
if (e.port === Number(port)) { console.error(` [X] port ${port} is already used by ${e.name}`); process.exit(1); }
if (e.db === db) { console.error(` [X] db ${db} is already used by ${e.name}`); process.exit(1); }
}
entries.push({ name, port: Number(port), db });
writeFileSync(file, JSON.stringify(entries, null, 4) + '\n');
console.log(` registry: ${name} on :${port} -> ${db}`);
NODE
mkdir -p "$(dirname "$DB_ABS")"
# ── seed (and optionally restore) the new database ──────────────────────────
cd "$ROOT"
SEED_ARGS=()
[ -n "$TOKEN" ] && SEED_ARGS+=(--token "$TOKEN")
[ -n "$RESTORE_FROM" ] && SEED_ARGS+=(--restore-from "$RESTORE_FROM")
AUTOFIRMER_DB="$DB_ABS" node scripts/seed-settings.js "$MASTER" "$NAME" ${SEED_ARGS[@]+"${SEED_ARGS[@]}"}
# ── PM2 ─────────────────────────────────────────────────────────────────────
pm2 delete "$PM2_NAME" >/dev/null 2>&1 || true
PORT="$PORT" AUTOFIRMER_DB="$DB_ABS" pm2 start "$NEXT_BIN" --name "$PM2_NAME" --interpreter node --update-env -- start
pm2 save >/dev/null
info "pm2: $PM2_NAME registered and saved"
echo
info "instance $N is up: http://localhost:$PORT"
info "database: $DB_REL"
if [ -n "$RESTORE_FROM" ]; then
info "restored from backup $RESTORE_FROM - stop the old machine if it is still running"
fi
info "remove later with: ./scripts/remove-instance.sh $N"
+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);
});
+297
View File
@@ -0,0 +1,297 @@
// Checks for the futures instrument specs and the Auto draw
// (lib/instrument-specs.ts).
//
// npm run check
//
// lib/instrument-specs.ts has no imports, 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 {
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, 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) {
let s = seed >>> 0;
return () => {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
test('specs: the old point-value table exactly, and the tick values', () => {
// The table lib/trading-logic.ts carried until the specs replaced it.
const old = {
NQ: 20, MNQ: 2, ES: 50, MES: 5, YM: 5, MYM: 0.5, RTY: 50, M2K: 5, GC: 100, MGC: 10, SI: 5000, CL: 1000,
MCL: 100, NG: 10000, ZB: 1000, ZN: 1000, ZF: 1000, '6E': 125000, '6J': 12500000, '6B': 62500,
};
assert.deepEqual({ ...POINT_VALUES }, old);
assert.equal(CME_ROOTS.length, 20);
const ticks = { NQ: 5, MNQ: 0.5, ES: 12.5, GC: 10, MGC: 1, CL: 10, SI: 25, NG: 10, ZB: 31.25, ZN: 15.625, ZF: 7.8125, '6E': 6.25, '6J': 6.25, '6B': 6.25, YM: 5, RTY: 5 };
for (const [root, value] of Object.entries(ticks)) assert.ok(Math.abs(tickValue(root) - value) < 1e-9, `${root} ${tickValue(root)}`);
assert.equal(spreadDollars('NQ'), 5);
for (const [full, micro] of Object.entries(MICRO_OF)) {
assert.ok(CME_SPECS[micro].micro && !CME_SPECS[full].micro);
assert.equal(CME_SPECS[full].pointValue / CME_SPECS[micro].pointValue, 10);
}
});
test('daily move from hourly closes; contracts; expected hold', () => {
// A constant 0.2% hourly swing: σ = 0.002, × √23 × price × pv.
const closes = Array.from({ length: 60 }, (_, i) => (i % 2 ? 20000 * 1.002 : 20000));
const move = dailyMoveDollars(closes, 20);
const r = Math.log(1.002);
const sd = Math.sqrt((r * r * 59) / 58); // mean ≈ 0 over alternating ±r
assert.ok(Math.abs(move - sd * Math.sqrt(23) * closes[59] * 20) / move < 0.01, `move ${move}`);
assert.equal(dailyMoveDollars(closes.slice(0, 10), 20), null);
assert.equal(futuresContracts(1000, 0), 1);
assert.equal(futuresContracts(2400, 0), 3);
assert.equal(futuresContracts(2400, 2), 2);
// $1,000 leg, stopless, 1 contract: (1000/5227)² days ≈ 0.88 h on NQ; ≈ 8 days on 6E.
assert.ok(Math.abs(expectedHoldHours({ leg: 1000, stopDollars: Infinity, contracts: 1, dailyMove: 5227 }) - 0.878) < 0.01);
assert.ok(expectedHoldHours({ leg: 1000, stopDollars: Infinity, contracts: 1, dailyMove: 351 }) > 190);
// More contracts, bigger move, shorter hold; a stop shortens it too.
assert.ok(Math.abs(expectedHoldHours({ leg: 2000, stopDollars: Infinity, contracts: 2, dailyMove: 5227 }) - 0.878) < 0.01);
assert.ok(expectedHoldHours({ leg: 1000, stopDollars: 500, contracts: 1, dailyMove: 5227 }) < 0.5);
assert.equal(expectedHoldHours({ leg: 1000, stopDollars: Infinity, contracts: 1, dailyMove: null }), null);
});
test('session progress: full CME 17:45 → 14:55 CT, equity hours 8:35 → 14:55', () => {
// 2026-09-28 is a Monday; CDT is UTC−5.
const ct = (iso) => new Date(`${iso}-05:00`);
assert.equal(sessionProgress(ct('2026-09-27T17:45'), 'full_cme'), 0);
assert.equal(sessionProgress(ct('2026-09-27T17:30'), 'full_cme'), null); // Sunday before the open
assert.ok(Math.abs(sessionProgress(ct('2026-09-28T04:00'), 'full_cme') - 615 / 1270) < 1e-9);
assert.equal(sessionProgress(ct('2026-09-28T14:55'), 'full_cme'), 1);
assert.equal(sessionProgress(ct('2026-09-28T15:30'), 'full_cme'), null); // maintenance
assert.equal(sessionProgress(ct('2026-09-26T12:00'), 'full_cme'), null); // Saturday
assert.equal(sessionProgress(ct('2026-09-27T12:00'), 'full_cme'), null); // Sunday before the open
assert.equal(sessionProgress(ct('2026-10-02T15:10'), 'full_cme'), null); // Friday after the close
assert.equal(sessionProgress(ct('2026-09-28T08:35'), 'equity_hours'), 0);
assert.equal(sessionProgress(ct('2026-09-28T14:55'), 'equity_hours'), 1);
assert.equal(sessionProgress(ct('2026-09-28T07:00'), 'equity_hours'), null);
});
// Measured 2026-09-26 from Yahoo hourly closes.
const MEASURED = [
{ symbol: 'NQ', spreadDollars: 5, dailyMove: 5227, holdHours: null },
{ symbol: 'GC', spreadDollars: 10, dailyMove: 4843, holdHours: null },
{ symbol: 'CL', spreadDollars: 10, dailyMove: 3550, holdHours: null },
{ symbol: '6E', spreadDollars: 6.25, dailyMove: 351, holdHours: null },
];
const pct = (chances) => Object.fromEntries(chances.map((c) => [c.symbol, Math.round(c.chance * 1000) / 10]));
test('the draw reproduces the plan\'s table: a floor early, the fastest late', () => {
const rows = [
[0, { NQ: 42.3, GC: 25.0, CL: 21.0, '6E': 11.7 }],
[0.5, { NQ: 49.0, GC: 27.5, CL: 16.0, '6E': 7.5 }],
[0.9, { NQ: 59.9, GC: 32.5, CL: 5.7, '6E': 1.9 }],
[1, { NQ: 63.0, GC: 34.2, CL: 2.9, '6E': 0.0 }],
];
for (const [p, expected] of rows) {
const { chances } = drawChances(MEASURED, p);
const got = pct(chances);
for (const [sym, v] of Object.entries(expected)) assert.ok(Math.abs(got[sym] - v) <= 0.15, `p=${p} ${sym} ${got[sym]} vs ${v}`);
assert.ok(Math.abs(chances.reduce((s, c) => s + c.chance, 0) - 1) < 1e-9);
}
// At the open every instrument has at least the floor.
for (const c of drawChances(MEASURED, 0).chances) assert.ok(c.chance >= DRAW_FLOOR - 1e-12);
});
test('the 12-hour cap disables slow instruments; nothing fitting means no draw', () => {
const withHolds = MEASURED.map((c) => ({ ...c, holdHours: c.symbol === '6E' ? 195 : 1 }));
const { chances, disabled } = drawChances(withHolds, 0);
assert.deepEqual(disabled, ['6E']);
assert.deepEqual(chances.map((c) => c.symbol), ['NQ', 'GC', 'CL']);
const got = pct(chances);
assert.ok(Math.abs(got.NQ - 48.9) < 0.2 && Math.abs(got.GC - 28.0) < 0.2 && Math.abs(got.CL - 23.2) < 0.2, JSON.stringify(got));
assert.equal(drawChances([{ ...MEASURED[0], holdHours: MAX_HOLD_HOURS }], 0).chances.length, 0);
assert.equal(drawChances([{ ...MEASURED[0], holdHours: MAX_HOLD_HOURS - 0.01 }], 0).chances.length, 1);
});
test('fallbacks: median weight when unmeasured, even when nothing is; micros only as a last resort', () => {
const one = drawChances([...MEASURED.slice(0, 3), { symbol: 'ES', spreadDollars: 12.5, dailyMove: null, holdHours: null }], 0);
const es = one.chances.find((c) => c.symbol === 'ES').chance;
const sorted = one.chances.filter((c) => c.symbol !== 'ES').map((c) => c.chance).sort();
assert.ok(es >= sorted[0] && es <= sorted[2], `ES ${es}`);
const none = drawChances(MEASURED.map((c) => ({ ...c, dailyMove: null })), 0.7);
for (const c of none.chances) assert.ok(Math.abs(c.chance - 0.25) < 1e-9);
const micros = [{ symbol: 'MNQ', spreadDollars: 0.5, dailyMove: 522, holdHours: 5, micro: true }];
assert.deepEqual(drawChances([...micros, MEASURED[0]], 0).chances.map((c) => c.symbol), ['NQ']);
assert.deepEqual(drawChances(micros, 0).chances.map((c) => c.symbol), ['MNQ']);
});
test('10k seeded draws land on the chances', () => {
const { chances } = drawChances(MEASURED, 0.5);
const r = rng(7);
const counts = {};
for (let i = 0; i < 10000; i++) {
const s = pickByChance(chances, r());
counts[s] = (counts[s] ?? 0) + 1;
}
for (const c of chances) assert.ok(Math.abs(counts[c.symbol] / 10000 - c.chance) < 0.02, `${c.symbol} ${counts[c.symbol]} vs ${c.chance}`);
assert.equal(pickByChance([], 0.5), null);
});
test('drain and extra-day legs: always MNQ', () => {
// Always MNQ, whatever the firm lists; nothing for a firm that lists nothing.
assert.equal(housekeepingRoot(['NQ', 'GC', 'CL', '6E']), 'MNQ');
assert.equal(housekeepingRoot(['MNQ']), 'MNQ');
assert.equal(housekeepingRoot(['GC', 'CL']), 'MNQ');
assert.equal(housekeepingRoot(['6E', 'ZN']), 'MNQ');
assert.equal(housekeepingRoot(['MCL', 'ZB']), 'MNQ');
assert.equal(housekeepingRoot([]), null);
});
test('reading lists: sanitize per family; master list first, the old rules otherwise', () => {
assert.deepEqual(sanitizeInstruments('tradovate', ['nq', 'GC', 'XX', 'NQ', 5]), ['NQ', 'GC']);
assert.deepEqual(sanitizeInstruments('dxtrade', ['ETH/USD', 'BAD|X', 'ETH/USD', 'btc/usd']), ['ETH/USD', 'btc/usd']);
const local = ['CL', 'GC', 'NQ', '6E'];
assert.deepEqual(resolveAllowlist({ platform: 'tradovate', instruments: ['NQ', 'ES'], bannedSymbols: ['NQ'] }, local), { roots: ['NQ', 'ES'], source: 'master' });
assert.deepEqual(resolveAllowlist({ platform: 'tradovate', bannedSymbols: ['GC'] }, local), { roots: ['CL', 'NQ', '6E'], source: 'legacy' });
assert.deepEqual(resolveAllowlist({ platform: 'topstepx', instruments: [] }, local), { roots: [], source: 'master' });
assert.deepEqual(
resolveAllowlist({ platform: 'dxtrade', symbolMap: { NQ: [{ symbol: 'XYZ100/USD' }], '*': [{ symbol: 'ETH/USD' }, { symbol: 'XYZ100/USD' }] } }, local),
{ roots: ['ETH/USD', 'XYZ100/USD'], source: 'legacy' },
);
});
// ── live quotes and the entry spread limit ──────────────────────────────────
test('TopstepX quotes: a side the hub leaves out keeps its last price', () => {
let book = mergeTopstepxQuote(undefined, { symbol: 'F.US.ENQ', bestBid: 24000.25, bestAsk: 24000.5, lastPrice: 24000.5 });
assert.deepEqual(book, { bid: 24000.25, ask: 24000.5 });
book = mergeTopstepxQuote(book, { bestAsk: 24001 });
assert.deepEqual(book, { bid: 24000.25, ask: 24001 });
// Wrapped in { data }, as the user hub's events are; junk prices ignored.
assert.deepEqual(mergeTopstepxQuote(book, { data: { bestBid: 24000.75, bestAsk: 0 } }), { bid: 24000.75, ask: 24001 });
assert.deepEqual(mergeTopstepxQuote(undefined, null), {});
});
test('Tradovate market-data frames: replies and quotes', () => {
const frame = 'a' + JSON.stringify([
{ s: 200, i: 1 },
{ s: 404, i: 3, d: 'Symbol not found' },
{ e: 'md', d: { quotes: [
{ contractId: 3267315, timestamp: '2026-09-27T22:10:00Z', entries: { Bid: { price: 24000.25, size: 3 }, Offer: { price: 24000.5, size: 5 }, Trade: { price: 24000.5 } } },
{ contractId: 3273057, entries: { Offer: { price: 71.24, size: 2 } } },
{ entries: { Bid: { price: 1 } } },
] } },
]);
const parsed = parseTradovateFrame(frame);
assert.deepEqual(parsed.responses, [{ id: 1, status: 200, data: undefined }, { id: 3, status: 404, data: 'Symbol not found' }]);
assert.deepEqual(parsed.quotes, [
{ contractId: 3267315, bid: 24000.25, ask: 24000.5 },
{ contractId: 3273057, ask: 71.24 },
]);
for (const other of ['o', 'h', 'c[1000,"bye"]', 'a[not json', 42]) assert.deepEqual(parseTradovateFrame(other), { responses: [], quotes: [] });
});
test('the spread off the book, in dollars and ticks', () => {
const nq = CME_SPECS.NQ;
assert.deepEqual(bookSpread({ bid: 24000.25, ask: 24000.5 }, nq.pointValue, nq.tickSize), { dollars: 5, ticks: 1 });
assert.deepEqual(bookSpread({ bid: 24000, ask: 24001 }, nq.pointValue, nq.tickSize), { dollars: 20, ticks: 4 });
const gc = CME_SPECS.GC;
assert.deepEqual(bookSpread({ bid: 3650.1, ask: 3650.3 }, gc.pointValue, gc.tickSize), { dollars: 20, ticks: 2 });
// A locked book is zero wide; a crossed or one-sided one isn't a spread.
assert.deepEqual(bookSpread({ bid: 71.2, ask: 71.2 }, 1000, 0.01), { dollars: 0, ticks: 0 });
assert.equal(bookSpread({ bid: 71.3, ask: 71.2 }, 1000, 0.01), null);
assert.equal(bookSpread({ bid: 71.2 }, 1000, 0.01), null);
assert.equal(bookSpread(undefined, 1000, 0.01), null);
});
test('the entry limit: $15 by default; one tick of the listed roots fits under it', () => {
assert.equal(DEFAULT_MAX_ENTRY_SPREAD, 15);
for (const root of ['NQ', 'GC', 'CL', 'ES', 'MNQ']) assert.equal(spreadTooWide(spreadDollars(root), 15), false, root);
assert.equal(spreadTooWide(15, 15), false);
assert.equal(spreadTooWide(15.01, 15), true);
assert.equal(spreadTooWide(20, 15), true);
// 0 = no limit; a NaN spread never blocks.
assert.equal(spreadTooWide(500, 0), false);
assert.equal(spreadTooWide(NaN, 15), false);
// One tick of these is over $15 by itself.
for (const root of ['SI', 'ZB', 'ZN']) assert.equal(spreadTooWide(spreadDollars(root), 15), true, root);
});
test('the draw leaves out what is over the spread limit', () => {
const cands = [
{ symbol: 'NQ', spreadDollars: 20, dailyMove: 5164, holdHours: 0.9, wide: true },
{ symbol: 'GC', spreadDollars: 10, dailyMove: 4899, holdHours: 1.0 },
{ symbol: 'CL', spreadDollars: 10, dailyMove: 3554, holdHours: 1.9 },
];
const { chances, wide, disabled } = drawChances(cands, 0);
assert.deepEqual(wide, ['NQ']);
assert.deepEqual(disabled, []);
assert.deepEqual(chances.map((c) => c.symbol), ['GC', 'CL']);
assert.ok(Math.abs(chances.reduce((s, c) => s + c.chance, 0) - 1) < 1e-9);
// 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);
});
File diff suppressed because it is too large Load Diff
+813
View File
@@ -0,0 +1,813 @@
// 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, resetSafeStop } = 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, asset = 'stock') => ({
symbol, assetId, sizeDecimals: 3, priceDecimals: 2, tick: 0.01, takerFee: 0.0001, marginRate: 0.05, tradingStatus: 'TRADING', capitalTradingStatus: 'TRADING', asset,
});
const specs = new Map([['NDX-USD', spec('NDX-USD', 7)], ['BTC-PERP', spec('BTC-PERP', 9, 'crypto')]]);
/** 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'));
// Crypto's stops keep the engine's slippage share; NDX's is measured.
assert.equal(pick.stopSlippageFraction, 0.001);
assert.equal(ndx.stopSlippageFraction, 0.0002);
// 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-82' }); // attempt_index 81 = Account 82
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();
}
});
test("the reset guard holds each position's own slippage reserve: crypto's wider than NDX's", async () => {
// 7:56 PM ET: four minutes to Vest's reset. acc-1 holds BTC, acc-2 NDX,
// neither with a stop.
clockAt('2026-10-29T23:56:00Z');
const stops = [];
const positions = [
rawPosition('9201', 'acc-1', iso('2026-10-29T22:00:00Z'), { symbol: 'BTC-PERP', quantity: '5', openPrice: '70000' }),
rawPosition('9202', 'acc-2', iso('2026-10-29T22:00:00Z'), { quantity: '15', openPrice: '20000' }),
];
const client = testClient((method, path, opts) => {
if (method === 'GET' && path === '/v3/positions/opened') return { data: { positions } };
if (method === 'POST' && path === '/v3/positions/stop-loss') { stops.push({ account: opts.account, ...opts.body }); return { data: {} }; }
throw new Error(`no gateway in this test: ${method} ${path}`);
});
client.refreshMarks = async () => {};
client.marks.set('BTC-PERP', { mark: 70000, at: Date.now() });
client.marks.set('NDX-USD', { mark: 20000, at: Date.now() });
for (const id of [1, 2]) {
client.capital.set(id, { status: VEST_STATUS_ACTIVE, maxDailyLossPct: 0.03, maxDrawdownLimit: 47000 });
client.accountCashBalances[id] = { amount: 50000, realizedPnL: 0 };
}
client.syncComplete = true;
const fitted = (quantity, openPrice, reserve) => resetSafeStop({
side: 'long', quantity, openPrice, mark: openPrice, balance: 50000, floorBalance: 50000, dailyLossPct: 0.03,
maxDrawdownFloor: 47000, reserve, exitFeeRate: 0.0001, currentStop: null, tick: 0.01,
});
try {
await client.guardReset();
const by = Object.fromEntries(stops.map((s) => [s.account, Number(s.triggerPrice)]));
// BTC: 0.1% of $350k = $350 held back, over the flat $150.
assert.equal(by['acc-1'], fitted(5, 70000, 350).price);
assert.notEqual(by['acc-1'], fitted(5, 70000, 150).price);
// NDX: 0.02% of $300k is $60, so the flat $150 stands.
assert.equal(by['acc-2'], fitted(15, 20000, 150).price);
} finally {
client.disconnect();
}
});
+672
View File
@@ -0,0 +1,672 @@
// 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, vestAccountNumber, 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, vestStopSlippageFraction, VEST_STOP_SLIPPAGE_FRACTION, VEST_CRYPTO_STOP_SLIPPAGE_FRACTION,
} 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);
// attempt_index 34 is what the Vest app shows as Account 35.
assert.equal(vestAccountName(a), 'V50K-E-D3-T10-35');
assert.equal(vestAccountName(parseCapitalAccount({ ...evalAccount, stage: 2 })), 'V50K-F-D3-35');
assert.equal(vestAccountName(parseCapitalAccount({ ...evalAccount, max_daily_loss_pct: '0' })), 'V50K-E-D0-T10-35');
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-13');
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-08');
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-02');
assert.equal(parseCapitalAccount({ nope: true }), null);
});
test('an account is numbered as the Vest app numbers it', () => {
// The app: `Account ${attempt_index < 9 ? '0' : ''}${attempt_index + 1}`,
// and "Legacy Account" without an attempt index.
const numbered = (attempt) => vestAccountNumber(parseCapitalAccount({ ...evalAccount, attempt_index: attempt }));
assert.equal(numbered(0), '01');
assert.equal(numbered(8), '09');
assert.equal(numbered(9), '10');
assert.equal(numbered(72), '73');
assert.equal(numbered(-1), '0');
const { attempt_index: _, ...legacy } = evalAccount;
assert.equal(vestAccountNumber(parseCapitalAccount(legacy)), '0');
assert.equal(vestAccountName(parseCapitalAccount({ ...evalAccount, attempt_index: 72 })), 'V50K-E-D3-T10-73');
});
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);
});
test("a crypto stop's slippage share is the engine default; everything else keeps NDX's measured one", () => {
assert.equal(VEST_CRYPTO_STOP_SLIPPAGE_FRACTION, 0.001);
assert.equal(vestStopSlippageFraction('crypto'), VEST_CRYPTO_STOP_SLIPPAGE_FRACTION);
for (const asset of ['stock', 'forex', '', undefined]) assert.equal(vestStopSlippageFraction(asset), VEST_STOP_SLIPPAGE_FRACTION);
});
+227
View File
@@ -0,0 +1,227 @@
#!/usr/bin/env node
// DXtrade smoke test — run against a real login before the scheduler trades a
// DXtrade firm. Confirms the API details lib/dxtrade-class.ts assumes.
//
// DX_BASE=https://dx.tradeify247.co/dxsca-web DX_USER=... DX_PASS=... \
// DX_DOMAIN=default DX_SYMBOLS=BTC/USD,ETH/USD,XYZ100 node scripts/dxtrade-smoke.mjs
//
// Read-only unless DX_CONFIRM=yes AND DX_ACCOUNT=<account code> are set, which
// adds a minimum-size round trip on that account (a real order: it pays two
// commissions). Credentials come from the environment only; the password and
// session token are never printed.
//
// Run it while the instance's client for the firm is disconnected (remove the
// credentials there, or stop the instance): two sessions on one login can
// collide, and logins are rate-limited per IP.
const BASE = (process.env.DX_BASE ?? '').replace(/\/+$/, '');
const USER = process.env.DX_USER ?? '';
const PASS = process.env.DX_PASS ?? '';
const DOMAIN = process.env.DX_DOMAIN || 'default';
const SYMBOLS = (process.env.DX_SYMBOLS ?? 'BTC/USD').split(',').map((s) => s.trim()).filter(Boolean);
const ORDER_ACCOUNT = process.env.DX_ACCOUNT ?? '';
const CONFIRM = process.env.DX_CONFIRM === 'yes';
if (!BASE.startsWith('https://') || !USER || !PASS) {
console.error('Set DX_BASE (https://…/dxsca-web), DX_USER and DX_PASS.');
process.exit(1);
}
let token = null;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const section = (title) => console.log(`\n── ${title} ${'─'.repeat(Math.max(0, 70 - title.length))}`);
const show = (label, value) => console.log(`${label}:`, typeof value === 'string' ? value : JSON.stringify(value, null, 2)?.slice(0, 4000));
async function call(method, path, { params, body, headers } = {}) {
const url = new URL(`${BASE}${path}`);
for (const [k, v] of Object.entries(params ?? {})) url.searchParams.set(k, String(v));
const res = await fetch(url, {
method,
headers: {
Accept: 'application/json',
...(token ? { Authorization: `DXAPI ${token}` } : {}),
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
...headers,
},
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(30_000),
});
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { data = text.slice(0, 500); }
return { status: res.status, headers: Object.fromEntries(res.headers), data };
}
async function step(label, fn) {
try {
await fn();
} catch (err) {
console.log(`${label} FAILED: ${err instanceof Error ? err.message : String(err)}`);
}
}
const codeOf = (a) => (typeof a === 'string' ? a : a && typeof a === 'object' ? `${a.clearing}:${a.account}` : String(a));
section('login');
{
const res = await call('POST', '/login', { body: { username: USER, domain: DOMAIN, password: PASS } });
if (res.status !== 200 || !res.data?.sessionToken) {
show(`login refused (HTTP ${res.status})`, res.data);
console.log('Check DX_DOMAIN (try "default", then the broker name) — and stop after a couple of tries: repeated failures lock the account.');
process.exit(1);
}
token = res.data.sessionToken;
show('session timeout field (format check)', res.data.timeout);
}
let accounts = [];
section('accounts (/users)');
for (const path of [`${USER}@${DOMAIN}`, USER]) {
const res = await call('GET', `/users/${encodeURIComponent(path)}`);
console.log(`/users/${path} → HTTP ${res.status}`);
if (res.status === 200) {
accounts = (res.data?.userDetails ?? []).flatMap((u) => u.accounts ?? []);
for (const a of accounts) {
console.log(` ${codeOf(a.account)} status=${a.accountStatus} positionBased=${a.positionBased} currency=${a.baseCurrency} registered=${a.registrationTime}`);
}
break;
}
}
const codes = accounts.map((a) => codeOf(a.account));
const ref = codes[0];
if (!ref) {
console.log('No accounts found — nothing more to check.');
process.exit(0);
}
section('metrics (key casing: openPL vs openPl)');
await step('metrics', async () => {
const res = await call('GET', '/accounts/metrics', { params: { accounts: codes.join(',') } });
show(`HTTP ${res.status}`, res.data?.metrics?.[0] ?? res.data);
});
section('positions / open orders');
await step('positions', async () => show('positions', (await call('GET', '/accounts/positions', { params: { accounts: codes.join(',') } })).data));
await step('orders', async () => show('open orders', (await call('GET', '/accounts/orders', { params: { accounts: codes.join(',') } })).data));
section('transfers — types, signs, paging, the opening DEPOSIT, FINANCING');
await step('transfers', async () => {
const from = new Date(Date.now() - 180 * 86_400_000).toISOString();
const res = await call('GET', '/accounts/transfers', { params: { accounts: codes.join(','), 'time-from': from, limit: 50 } });
const txns = (res.data?.cashTransfers ?? []).flatMap((t) => (t.cashTransactions ?? []).map((x) => ({ ...x, transferTime: t.transactionTime })));
const byType = {};
for (const t of txns) {
const e = (byType[t.type] ??= { count: 0, min: Infinity, max: -Infinity });
e.count++; e.min = Math.min(e.min, t.value); e.max = Math.max(e.max, t.value);
}
show('by type (value range → sign convention)', byType);
show('first / last transaction (ordering)', [txns[0], txns[txns.length - 1]]);
show('nextPageTransactionTime', res.data?.nextPageTransactionTime ?? '(none)');
});
section('instruments');
await step('instruments', async () => {
const res = await call('GET', '/instruments/query', { params: { symbols: SYMBOLS.join(',') } });
for (const i of res.data?.instruments ?? []) {
console.log(` ${i.symbol}: type=${i.type} priceIncrement=${i.priceIncrement} multiplier=${i.multiplier} lotSize=${i.lotSize} quantityIncrement=${i.quantityIncrement} currency=${i.currency} sessions=${(i.tradingHours ?? []).length}`);
}
show('first instrument, raw', res.data?.instruments?.[0]);
});
await step('account instrument details', async () => {
const res = await call('GET', `/accounts/${encodeURIComponent(ref)}/instruments/query`, { params: { symbols: SYMBOLS.join(',') } });
show('details', res.data?.instrumentDetails ?? res.data);
});
section('quotes and hourly candles (σ)');
await step('quotes', async () => {
const res = await call('POST', '/marketdata', { body: { account: ref, symbols: SYMBOLS, eventTypes: [{ type: 'Quote', format: 'COMPACT' }] } });
show(`Quote events (HTTP ${res.status})`, res.data?.events ?? res.data);
});
await step('candles', async () => {
const now = Date.now();
const res = await call('POST', '/marketdata', {
body: {
account: ref, symbols: SYMBOLS,
eventTypes: [{ type: 'Candle', format: 'COMPACT', candleType: '1h', fromTime: new Date(now - 49 * 3_600_000).toISOString(), toTime: new Date(now).toISOString() }],
},
});
const events = res.data?.events ?? [];
console.log(`Candle events: HTTP ${res.status}, ${events.length} event(s); first: ${JSON.stringify(events[0])}`);
for (const symbol of SYMBOLS) {
const closes = events.filter((e) => e.symbol === symbol && typeof e.close === 'number').map((e) => e.close);
const rets = closes.slice(1).map((c, i) => Math.log(c / closes[i]));
const mean = rets.reduce((s, r) => s + r, 0) / Math.max(1, rets.length);
const sd = Math.sqrt(rets.reduce((s, r) => s + (r - mean) ** 2, 0) / Math.max(1, rets.length - 1));
console.log(` ${symbol}: ${closes.length} closes, σ ≈ ${(sd * 100).toFixed(3)}%/h ≈ ${(sd * Math.sqrt(24) * 100).toFixed(2)}%/day`);
}
});
section('end-of-day metrics vs the 22:00 UTC reset');
await step('eodmetrics', async () => {
const d = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
const res = await call('GET', `/accounts/eodmetrics/${d}`, { params: { accounts: ref } });
show(`eodmetrics ${d} (HTTP ${res.status}) — compare balance with the instance's dayStart debug value`, res.data);
});
section('rate limits (two history calls back to back)');
await step('rate', async () => {
const a = await call('GET', '/accounts/transfers', { params: { accounts: ref, limit: 1 } });
const b = await call('GET', '/accounts/transfers', { params: { accounts: ref, limit: 1 } });
console.log(`HTTP ${a.status}, ${b.status}${b.status === 429 ? ' — history calls are limited to 1/s' : ''}`);
});
section('ping');
console.log(`POST /ping → HTTP ${(await call('POST', '/ping')).status}`);
if (CONFIRM && ORDER_ACCOUNT) {
section(`LIVE ORDERS on ${ORDER_ACCOUNT}`);
const acct = encodeURIComponent(ORDER_ACCOUNT);
const symbol = SYMBOLS[0];
const inst = (await call('GET', '/instruments/query', { params: { symbols: symbol } })).data?.instruments?.[0] ?? {};
const det = (await call('GET', `/accounts/${acct}/instruments/query`, { params: { symbols: symbol } })).data?.instrumentDetails?.[0] ?? {};
const qty = Math.max(det.minOrderSize ?? 0, inst.quantityIncrement ?? 0, det.minOrderSizeIncrement ?? 0) || 1;
const code = `af-smoke-${Date.now().toString(36)}`;
const open = { account: ORDER_ACCOUNT, orderCode: code, type: 'MARKET', instrument: symbol, quantity: qty, positionEffect: 'OPEN', side: 'BUY', tif: 'GTC' };
const placed = await call('POST', `/accounts/${acct}/orders`, { body: open });
show(`MARKET BUY ${qty} ${symbol} (HTTP ${placed.status})`, placed.data);
show('duplicate orderCode (expect 409 / code 100)', (await call('POST', `/accounts/${acct}/orders`, { body: open })));
await sleep(3_000);
const pos = ((await call('GET', `/accounts/${acct}/positions`)).data?.positions ?? []).find((p) => p.symbol === symbol);
show('position (positionCode should equal the orderId on position-based accounts)', pos);
show('order history row (commission, averagePrice)', (await call('GET', `/accounts/${acct}/orders/history`, { params: { 'with-client-id': code, limit: 5 } })).data);
if (pos) {
const px = pos.openPrice;
const tick = inst.priceIncrement || 0.01;
const far = (p) => Math.round(p / tick) * tick;
// Exactly what lib/dxtrade-class.ts sends: a closing STOP/LIMIT names
// the position, never a quantity (code 33 otherwise).
const leg = (c, type, price) => ({
account: ORDER_ACCOUNT, orderCode: c, type, instrument: symbol, positionEffect: 'CLOSE',
positionCode: pos.positionCode, side: 'SELL', tif: 'GTC', ...(type === 'LIMIT' ? { limitPrice: price } : { stopPrice: price }),
});
const group = await call('POST', `/accounts/${acct}/orders`, {
body: { orders: [leg(`${code}-tp`, 'LIMIT', far(px * 1.05)), leg(`${code}-sl`, 'STOP', far(px * 0.95))], contingencyType: 'OCO' },
});
show(`native OCO group of CLOSE legs (HTTP ${group.status})`, group.data);
if (group.status >= 400) {
// The bot's fallback: the stop on its own first, then the take-profit.
show('fallback: lone STOP CLOSE', (await call('POST', `/accounts/${acct}/orders`, { body: leg(`${code}-sl1`, 'STOP', far(px * 0.95)) })));
show('fallback: lone LIMIT CLOSE', (await call('POST', `/accounts/${acct}/orders`, { body: leg(`${code}-tp1`, 'LIMIT', far(px * 1.05)) })));
}
show('open orders now (the exit pair should be here)', (await call('GET', `/accounts/${acct}/orders`)).data);
const del = await call('DELETE', `/accounts/${acct}/orders/${encodeURIComponent(`${code}-tp`)}`);
show(`cancel without If-Match (HTTP ${del.status}; 403/412/428 means it wants one)`, del.data);
// Tradeify: every trade must be held at least 20 seconds.
console.log('holding 22 s before closing (minimum hold)…');
await sleep(22_000);
const flatten = await call('POST', `/accounts/${acct}/close`, { body: { account: ORDER_ACCOUNT, closePositions: true, cancelOrders: true } });
show(`flatten via /close (HTTP ${flatten.status})`, flatten.data);
}
await sleep(3_000);
const recent = await call('GET', `/accounts/${acct}/transfers`, { params: { 'time-from': new Date(Date.now() - 10 * 60_000).toISOString() } });
show('transactions of the last 10 minutes (commission = 0.04% × notional per side?)', recent.data);
} else {
console.log('\n(read-only: set DX_CONFIRM=yes and DX_ACCOUNT=<code> to also test a live minimum-size round trip)');
}
await call('POST', '/logout');
+3
View File
@@ -27,6 +27,9 @@ function run(cmd, args, opts = {}) {
cwd: ROOT,
encoding: 'utf8',
shell: process.platform === 'win32',
// The updater's worker is detached with no console, so without this
// every python/pip child pops a visible window on the trading desktop.
windowsHide: true,
...opts,
});
}
+10
View File
@@ -31,6 +31,16 @@ done
info() { printf ' %s\n' "$1"; }
warn() { printf ' ! %s\n' "$1" >&2; }
# Same reason as setup-linux.sh: PM2's process list lives in $HOME/.pm2 and the
# login entry in $HOME/.config/autostart. As root both go to /root, invisible to
# the account that actually logs in.
if [ "$(id -u)" -eq 0 ]; then
echo " [X] do not run this as root${SUDO_USER:+ - run it as $SUDO_USER}." >&2
echo " PM2 and the login entry are per-user; as root they land in /root" >&2
echo " and your own 'pm2 list' will be empty." >&2
exit 1
fi
info "project root: $ROOT"
# ── PM2 ─────────────────────────────────────────────────────────────────────
+44
View File
@@ -0,0 +1,44 @@
/**
* The per-device instance registry: which autofirmer processes run on this
* machine, on which ports, against which databases.
*
* scripts/instances.json is created and edited by add-instance.sh /
* remove-instance.sh. When the file is absent the device is a legacy
* single-instance install and everything behaves exactly as it always has:
* one PM2 process named `autofirmer` on port 3000 using ./autotrader.sqlite.
*
* Entry shape: { "name": "autofirmer-2", "port": 3100, "db": ".data/instance-2/autotrader.sqlite" }
* `db` is relative to the project root. Instance 1 keeps the legacy
* name/port/db so the clicker and extension (hardwired to :3000) still work.
*/
import { existsSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
export const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
export const REGISTRY_FILE = path.join(ROOT, 'scripts', 'instances.json');
const LEGACY = [{
name: 'autofirmer',
port: Number(process.env.AUTOFIRMER_PORT ?? '3000'),
db: 'autotrader.sqlite',
}];
export function loadInstances() {
if (!existsSync(REGISTRY_FILE)) return LEGACY;
let parsed;
try {
parsed = JSON.parse(readFileSync(REGISTRY_FILE, 'utf8'));
} catch (err) {
throw new Error(`scripts/instances.json is not valid JSON: ${err.message}`);
}
if (!Array.isArray(parsed) || parsed.length === 0) return LEGACY;
for (const e of parsed) {
if (typeof e?.name !== 'string' || !e.name
|| !Number.isInteger(e?.port)
|| typeof e?.db !== 'string' || !e.db) {
throw new Error(`scripts/instances.json entry is malformed: ${JSON.stringify(e)}`);
}
}
return parsed;
}
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
#
# Unregister an instance added by add-instance.sh: stops its PM2 process and
# drops it from scripts/instances.json. The database directory is left on
# disk — deleting credentials is a decision for a human with rm.
#
# ./scripts/remove-instance.sh <N>
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
REGISTRY="$ROOT/scripts/instances.json"
info() { printf ' %s\n' "$1"; }
die() { printf ' [X] %s\n' "$1" >&2; exit 1; }
[ $# -eq 1 ] || die "usage: $0 <N>"
N="$1"
case "$N" in (*[!0-9]*|'') die "N must be a number, got: $N" ;; esac
[ "$N" -ge 2 ] || die "instance 1 is the legacy install - remove it with pm2 delete autofirmer"
[ -f "$REGISTRY" ] || die "no scripts/instances.json - nothing to remove"
PM2_NAME="autofirmer-$N"
node - "$REGISTRY" "$PM2_NAME" <<'NODE'
const { readFileSync, writeFileSync } = require('node:fs');
// `node -` puts '-' in argv[1], so the arguments start at argv[2].
const [file, name] = process.argv.slice(2);
const entries = JSON.parse(readFileSync(file, 'utf8'));
if (!entries.some((e) => e.name === name)) { console.error(` [X] ${name} is not registered`); process.exit(1); }
writeFileSync(file, JSON.stringify(entries.filter((e) => e.name !== name), null, 4) + '\n');
console.log(` registry: ${name} removed`);
NODE
pm2 delete "$PM2_NAME" >/dev/null 2>&1 || info "pm2: $PM2_NAME was not running"
pm2 save >/dev/null
info "pm2: $PM2_NAME deleted and process list saved"
info "database left at .data/instance-$N/ - delete by hand if you mean it"

Some files were not shown because too many files have changed in this diff Show More