Compare commits

...
204 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
Brandon Li dece6999f0 Quick resolve account types 2026-09-03 13:13:32 -05:00
Brandon Li 9d4e7a36a7 Duplicate automations 2026-09-03 13:13:03 -05:00
Brandon Li 45f71cbecf add repeating to the tradeify dashboard 2026-09-03 13:04:43 -05:00
Brandon Li 722b70df07 Fixed tradeify buying 2026-09-03 12:23:01 -05:00
Brandon LiandClaude Opus 4.8 7d677dfa89 Click the terms checkboxes directly, not the required row
.validate-required is on every required checkout field, so the old selector's
first match was usually a text input, and on the terms row the click landed in
the empty half of a full-width <p> beside the box. Target the checkbox input in
a required row and gate on :not(:checked) so the two clicks tick box one then
box two instead of toggling the same box twice; the second is skipIfNotFound in
case a checkout has only one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 21:39:15 -05:00
Brandon Li 2d0c87b900 Added frame in wait for 2026-09-02 14:06:26 -05:00
Brandon Li 4373f09cca Fix id 2026-09-02 14:02:16 -05:00
Brandon Li 68cd6760da Fix selector to be button instead of input 2026-09-02 13:59:45 -05:00
Brandon Li 2eeb97e245 Wait for coupon button on lucid 2026-09-02 13:56:41 -05:00
Brandon LiandClaude Opus 4.8 b44416e8eb Default autostart update-check interval to 1 minute
The no-change path is a single `git fetch`, so polling master every minute
costs nothing and lines up with the VPS's own 60s deploy timer instead of
lagging it by up to five.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 13:38:59 -05:00
Brandon LiandClaude Opus 4.8 8aeb9cea81 Fix frame locate tripping the signed-out guard
A frame-targeted step resolved the element inside the checkout iframe and
reported that iframe's URL as the result `url`. The signed-out guard keys off
`url` to decide whether the tab is on the login page, and Lucid's checkout
iframe sits on the apex host (lucidtrading.com) that its signedOutPattern
matches — so a signed-in checkout looked signed out, ran the login step, and
failed with no login button present.

Report the TOP tab's URL as `url` (from pageFrameOffset), keeping the child
frame's URL as `frameUrl`. Extension 0.14.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 13:34:54 -05:00
Brandon LiandClaude Opus 4.8 4b7f30de41 Bump RUNNER_EXPECTED_VERSION to 0.19.0
The frame-aware step vocabulary only the 0.19.0 runner understands, so the
server must require it too. Missed in the previous commit, which left the
dashboard flagging the restarted 0.19.0 runner as out of date.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 13:25:46 -05:00
Brandon LiandClaude Opus 4.8 66c99c5499 Add frame-aware locate for cross-origin iframe steps
Selectors were only resolved against the top document, so steps targeting
the Lucid WooCommerce checkout (rendered in a lucidtrading.com iframe under
the dash.lucidtrading.com app) never matched — e.g. input#coupon_code_field.

Add an optional `frame` on click/type/waitFor naming the <iframe> element.
The extension measures the iframe in the top frame (pageFrameOffset), finds
the element across child frames (pageLocate `relative` mode), and composes
the two into desktop coordinates; the Python side is unchanged since the
result keeps the same shape. Without `frame`, every path behaves as before.

Point the Lucid checkout steps at the frame and harden the coupon selectors
(the exact field id is still unconfirmed against the live iframe).

Extension 0.13.0, runner 0.19.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 13:22:10 -05:00
Brandon Li 016f121f40 Fix 2026-09-02 13:07:23 -05:00
Brandon Li f8de384bc8 fixex selector for coupon codel ucid 2026-09-02 13:04:24 -05:00
Brandon Li 172e67f931 Fixed id 2026-09-02 13:00:30 -05:00
Brandon Li 61ba3fd268 Fixed selector again 2026-09-02 12:56:31 -05:00
Brandon Li 7f8144436b Modify coupon code selectors for lucid 2026-09-02 12:49:36 -05:00
Brandon Li b336d0edd8 Push new lucid automation 2026-09-02 12:27:58 -05:00
Brandon LiandClaude Fable 5 a088381749 Store the person uuid the master issues, so renames keep our identity
The welcome now carries a personId; it lands in the instance_id setting
and rides on every hello and HTTP report from then on. The master keys
the person by that uuid, so changing instance_name moves our history to
the new name instead of starting a stranger. Read-only in Settings on
purpose — editing it by hand would detach this machine from its person.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 12:07:25 -05:00
Brandon LiandClaude Fable 5 de9cbffd83 Randomize daily targets: split big days into legs, bump min-days
Big days (> $1700) now trade as multiple separate legs of random size
(each >= $800, $5-rounded), resumable across pauses since the remaining
target is derived from realized PnL against a day-anchored target.
Min-day targets get a deterministic random $0-25 bump seeded per
(account, trade date) so the dashboard, reporter, and orders agree.
Strict-consistency accounts randomize only their first trade ever.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 11:32:48 -05:00
Brandon LiandClaude Fable 5 8350601cf0 Skip non-Tradovate firms until their trading logic exists
The master's catalog now labels each firm with a platform (TopstepX being
the first non-Tradovate one). Sync accepts the field, defaulting anything
missing or unrecognized to tradovate so an older master still validates,
and getTradableFirms only hands Tradovate firms to the client.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 11:24:24 -05:00
Brandon LiandClaude Opus 5 3ce14d4928 Sink firms with no login to the bottom, and name the account type plainly
A firm with no stored Tradovate login can do nothing until someone enters
one, so it no longer interleaves by name with the firms that are actually
running. Sorted at render rather than in /api/firms, which keeps serving the
catalog in the master's own order. Name order is preserved within each group.

The account-type picker showed "Auto — Flex 50k Funded", which put the
mechanism before the answer in the one place the answer is what you are
reading. It shows the type's name now, or Unassigned when nothing matched.
That first entry still clears a manual pin; the dot beside the select is what
says a pin is in effect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 23:04:48 -05:00
Brandon LiandClaude Opus 5 8bdc85080c Take firms from the master instead of defining them per machine
This box no longer decides what firms exist. It caches the master's catalog
and stores one thing of its own: the Tradovate login for each firm. Creating
firms locally is gone — app/add, POST /api/firms, the account-config routes
and the banned-symbol route with it.

The cache is the point, not an optimisation. The scheduler needs every
account's target and size on each 60s tick, so fetching them per tick would
stop trading with positions open the first time the master rebooted. It is
one JSON blob in settings, replaced only by a complete valid response, and a
response with zero firms is refused outright: a misconfigured FIRMS_DB serves
{"firms":[]} as a healthy 200, and mirroring that would idle the whole fleet
at once.

Accounts bind to an account type by prefix AND size, the size taken from the
opening funding transaction. That does not arrive with the first sync, so an
account sits Unassigned and the resolver tries again next sync rather than
guessing. A type picked by hand is never overwritten by an auto pass. This
replaces the longest-prefix helper that had been copy-pasted into five files,
which could not tell two types with the same prefix apart at all.

firm_credentials and account_assignments deliberately have no foreign key to
the cached catalog. foreign_keys is ON, so a cascade would erase the Tradovate
passwords the moment a firm was removed on the master — routine, not
exceptional. They survive it, and are re-keyed by name if the master's database
is ever rebuilt with fresh ids.

The legacy firms/account_configs/firm_banned_symbols tables are left on disk,
unreferenced. firms is the only table that ever held plaintext credentials and
dropping it would make a rollback unrecoverable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 22:17:23 -05:00
Brandon LiandClaude Opus 5 8b2e78d766 Set Chrome as default and pin it, as far as Windows allows
Neither can be done for the running profile: UserChoice is hash-validated and
guarded by UCPD.sys since Feb 2024, and taskbar pinning lost its shell verb
years ago - both to stop installers helping themselves. What is supported is
policy, which applies when a profile is created, so this writes the
DefaultAssociationsConfiguration and taskbar layout XMLs for the next user and
does the two things that help the current one: a desktop shortcut, and opening
the Settings page where the browser choice actually has to be made.

Folded into the enrolment script's existing elevation so the install still
raises a single UAC prompt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 18:17:42 -05:00
Brandon LiandClaude Opus 5 458d667ca6 Linux needs no enrolment - it never did
The managed-device requirement is documented for Windows and macOS only, and a
Pi proved it: aarch64 Chromium fetched updates.xml with installedby=policy and
installed the CRX from a plain /etc policy file, with no enrolment, no Google
account and no Chrome. So setup-linux.sh writes the policy directly for
whichever browsers are present, and enrolment is a Windows concern again.

That also retires the ARM special case. Chromium could not be enrolled, which I
read as Chromium not being able to install this at all; the two are unrelated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 17:31:18 -05:00
Brandon LiandClaude Opus 5 d14bcdeed3 A Pi gets the unpacked path, not a dead end
Chrome is x86_64 only on Linux and Chromium cannot be enrolled, so on ARM the
enrolment step can never succeed. Removing load-unpacked from the installer
left those machines being told to run a script that would refuse - setup now
detects the architecture, explains why, and the summary points at the unpacked
install, which on that hardware is not a fallback but the only thing that
works. It still self-updates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 17:18:14 -05:00
Brandon LiandClaude Opus 5 61d6305ded Download Chrome with curl.exe, then BITS, then Invoke-WebRequest
Invoke-WebRequest pushes every buffer through the pipeline and is the slowest
of the three by a wide margin; curl.exe ships in System32 on every supported
Windows and resumes a partial file with -C -, which matters on a 166 MB
download that gets interrupted. Also checks the finished file against
Content-Length, because a truncated MSI fails in msiexec with an error that
says nothing about why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 17:16:45 -05:00
Brandon LiandClaude Opus 5 f52efd73c6 Correct the Chrome download sizes
The Windows enterprise MSI is 166 MB, not 120.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 17:11:57 -05:00
Brandon LiandClaude Opus 5 7e82a294fa Turn off the progress bar while downloading Chrome
Windows PowerShell re-renders progress on every buffer, so a 120 MB download
spends most of its time drawing a bar rather than transferring - minutes for
what should be seconds, and indistinguishable from a stall while you watch the
byte count crawl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 17:10:39 -05:00
Brandon LiandClaude Opus 5 389c714caf Bake in the enrolment token, and install Chrome when missing
Setup asked for a token that is the same on every machine - a token is reusable
and enrols any number of browsers - so it is a default in both scripts now and
step 7 is a plain yes/no. It is a modest secret: it lets someone enrol their own
browser into the org and nothing more, and rotating it is a console click and a
one-line change.

Both scripts also install Chrome if it is absent, from Google's enterprise MSI
and the official .deb, since enrolling a browser that is not there writes a
token nothing reads. Chromium is refused rather than substituted: no cloud
management, so it could never be enrolled - and on ARM, where Chrome is not
published at all, the Linux script says so instead of installing something that
cannot do the job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 16:18:20 -05:00
Brandon LiandClaude Opus 5 35dd15d584 Enrol Chrome from the installers, and drop load-unpacked
Both setup scripts ended by telling you to load the folder unpacked, which is
the one install that cannot update itself - and the policy install they moved
to next does nothing on an unmanaged machine. Enrolment is the step that
actually matters: with it, the extension arrives from the Admin console already
force-installed, and there is nothing else to configure on the box.

So step 7 now asks for an enrolment token instead. The Windows script elevates
itself for it - the token is machine-scoped, and that is the only UAC prompt in
the whole install.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 16:14:14 -05:00
Brandon LiandClaude Opus 5 95c073d4c2 Publish the CRX from a timer, and refuse an unbumped version
Mirrors the autotrader stack's auto-deploy: fetch every minute, act when master
moves, lock against overlap, rewrite .last-check every run so a silent failure
is visible. Only extension/ and deploy/ changes publish - repacking for a docs
edit would republish an identical CRX every minute.

The guard is the part that earns its place. Chrome ignores a CRX that is not
strictly newer, so shipping extension changes without bumping manifest.json is
a no-op that looks exactly like success until someone notices the fix never
arrived. That now fails loudly instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 16:10:21 -05:00
Brandon LiandClaude Opus 5 56114b727c dsregcmd cannot see Chrome Enterprise Core
The managed check asked Windows, but Enterprise Core is browser management by
Google: Windows reports a plain standalone machine however enrolled Chrome is.
The guard therefore blocked the exact path it recommends. It now asks Chrome
first - the dmtoken it writes on a successful enrolment, and the enrolment
token that requests one, in both registry views - and only then falls back to
domain and Entra join state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 15:55:27 -05:00
Brandon LiandClaude Opus 5 a103d7b6f1 Script the Chrome Enterprise Core enrolment
Enrolling is what lifts the off-store restriction without joining a domain, and
it is one machine-scoped token in both places Chrome looks: HKLM on Windows, a
file under /etc/opt/chrome/policies/enrollment on Linux. Both scripts take
--remove, and both say plainly that removing the token locally does not release
the seat in the Admin console.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 15:44:16 -05:00
Brandon LiandClaude Opus 5 ce6fa6c0e1 The managed-machine rule is Chromium's, not Edge's
Chrome blanks an off-store ExtensionInstallForcelist entry on an unmanaged
machine exactly as Edge does - the qualifying states are AD domain join, Entra
join, or Chrome Enterprise Core enrolment, and this box is none of them. The
guard now covers every browser and refuses up front, with the three ways
forward, instead of skipping Edge alone and letting Chrome fail the same way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 13:30:04 -05:00
Brandon LiandClaude Opus 5 e42e729189 Skip Edge on unmanaged devices instead of writing a dead policy
Unmanaged Edge installs extensions from the Microsoft Store and nowhere else:
it reads ExtensionInstallForcelist, blanks the value to [BLOCKED]<id>, and
reports an invalid extension id forever after. Writing that key buys nothing
and costs a permanent policy error plus a "Managed by your organization"
badge, so the script now checks dsregcmd and skips Edge with the two ways
round it. -Force writes it anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 13:25:39 -05:00
Brandon LiandClaude Opus 5 8f00d8c547 Find Edge and Chrome where they actually install
Test-BrowserInstalled probed App Paths under HKLM:\SOFTWARE only. Edge and
Chrome ship 32-bit installers, so on a 64-bit box their App Paths entries live
under WOW6432Node and a 64-bit PowerShell never saw them — the browser was
skipped, silently, and -Browser All configured nothing for it.

Checks the install directories first now, with App Paths (both registry views)
as a backstop, and prints which browsers it is configuring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 13:17:46 -05:00
Brandon LiandClaude Opus 5 a55ba1ccfc Let the TLS bootstrap past a stale local resolver
The droplet kept a cached answer for the new hostname long after the record
was live everywhere else, and the DNS check is a courtesy — Let's Encrypt
resolves the name itself. SKIP_DNS_CHECK=1 skips it, matching the autotrader
stack's script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 13:14:42 -05:00
Brandon LiandClaude Opus 5 b820168afa Configure Edge and Brave too, not just Chrome
They are Chromium: same CRX, same update manifest, same policy — only the
registry vendor key differs. The script now defaults to every one of the three
it finds installed, which is what the Linux counterpart already did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 13:08:29 -05:00
Brandon LiandClaude Opus 5 b9da419ebc Install the extension from the update server during setup
Both installers ended by telling you to load the folder unpacked, which is the
one install that can never update itself. They now offer the policy install as
a last step instead, and only fall back to those instructions when it is
declined or fails.

Windows writes to the user's own policy hive rather than HKLM, so the step
needs no elevation and no separate admin shell; -Scope Machine is still there
for every-account installs. Linux gets the counterpart script — a managed
policy file under /etc, which is root-only because Chrome reads policy from
nowhere else on that platform.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 13:06:29 -05:00
Brandon LiandClaude Opus 5 035f93d220 Ship the extension as a signed CRX from our own update server
The dashboard-hosting stack from the previous commit is gone; it was answering
a question nobody asked. What was actually needed is Chrome's real update
channel, which an unpacked extension does not have and cannot be given.

So the extension is now signed into a CRX with an update manifest beside it,
served from the droplet, and installed on the trading machines by policy
(ExtensionInstallForcelist). From there Chrome installs it, updates it on its
own schedule, and puts it back if it goes missing. The manifest gained the
`key` that fixes the extension id, so an unpacked development load keeps the
same identity as the published build.

The self-update check now branches on how it was installed: policy installs ask
Chrome to check the update server now rather than waiting out its five-hour
cycle, and unpacked loads keep the reload-from-disk path. chrome.management
.getSelf() tells the two apart and needs no permission to do it.

The signing key lives at /opt/autofirmer-updates/key.pem on the droplet and
nowhere else. It is the extension's identity: a different key is a different
extension, and no browser would take it as an update.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 12:50:01 -05:00
Brandon LiandClaude Opus 5 ece6aab9cf Plain-JS Next config so the image boots without devDependencies
next start stops to install TypeScript when the config is a .ts file, which in
the container meant a yarn install into node_modules on every boot — slow
enough that the health check failed before the server answered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 12:38:52 -05:00
Brandon LiandClaude Opus 5 d1e8668440 Let the extension update itself, and host the dashboard
Chrome has no update channel for an unpacked extension, so every pull left one
manual step: click reload on its card. The dashboard now publishes the version
its own checkout ships at /api/autobuyer/status, and the extension reloads
itself when it is behind — chrome.runtime.reload() re-reads the folder from
disk, which the update check has already pulled into. It only ever runs code
that is on disk, so a reload that changes nothing is reported and waited out
rather than repeated; a dashboard that is behind is ignored.

Also adds the deploy stack for running the dashboard on the droplet, next to
the autotrader one: it joins that nginx's network and renders a server block
into its conf.d rather than standing up a second ingress. AUTOFIRMER_DB moves
the SQLite file onto a volume, since the working directory in a container is
replaced on every deploy.

And waitFor gained `hidden`: an absent gate matched on DOM presence only, so a
modal that closes by going display:none could never satisfy it. The extension
grew a visibility probe for this — locate cannot answer it, because absent and
hidden are both errors there and a wait gate has to tell them apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 12:30:59 -05:00
Brandon LiandClaude Opus 5 1f4459985a Extension to 0.11.2
background.js changed with the widget-targeting fix, so the version has to
move with it: the AutoBuyer page now shows the version each capture was sent
by, and an unchanged number is how you tell a reload did not take.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 01:47:00 -05:00
Brandon LiandClaude Opus 5 dfa706c7b7 Target the captcha widget the open modal is actually showing
Second reset still failed: token solved, callback reported as fired, confirm
button never enabled. The button enabling is pure client-side state — the
site's callback storing the token — so if it stays dark the LIVE callback
never ran, whatever was reported. Picking the newest client was still a guess.

There is a precise link. reCAPTCHA numbers its response fields by widget id:
the first widget gets #g-recaptcha-response, the second
#g-recaptcha-response-1. The field the open modal is showing therefore names
the client holding its callback. Use that, fall back to newest-first when
there is no id to go on.

The run log now prints which field carried the token and which widgets were on
the page, so the next failure is diagnosable from the log instead of by
hypothesis: `filled 1 response field(s) ['g-recaptcha-response-1'], fired
grecaptcha callback (widget 1)` / `page had widgets ['0','1'], live one looked
like 1`.

Also surfaces the extension version on the AutoBuyer page. The extension has
always stamped it into every capture and nothing ever read it, so the README's
claim that a version mismatch shows there was no way to check. Chrome runs the
old service worker until an unpacked extension is reloaded by hand, which is
exactly the state that makes a deployed fix look like a broken one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 01:46:31 -05:00
Brandon LiandClaude Opus 5 2d53639c82 Hand the token to the live captcha widget, not a dead one
First live run reset one account, then failed on the second: the confirm
button never enabled and the step reported it covered by a bare `div`.

reCAPTCHA adds a client to ___grecaptcha_cfg.clients per rendered widget and
removes none, so the second modal leaves two — and the callback walk fired the
first it found, the one belonging to the modal that already closed. The token
goes to a widget nobody is listening to, the live modal never hears it was
solved, and its button stays dark. Iteration 1 worked only because there was
one client to find. Now the newest wins: hook widgets are walked in reverse,
clients sorted by descending key, and the count of candidates is reported so
`2 widgets on the page, used the newest` says plainly when a page is stacking
stale ones.

Also from that run:

- coveredBy printed `div` and nothing else, which diagnoses nothing. It only
  ever reported tag and id, and modal backdrops and captcha overlays carry
  neither. It now names classes too.
- A covered target gets 8s to clear before the step fails. Most things over a
  button are on their way out — a backdrop mid-fade, an overlay closing. This
  does not weaken the check: still covered at the deadline still fails, and
  nothing is clicked while anything is on top of it.
- The reset list's scroll container is confirmed as a class, so the
  class-or-id hedge is trimmed. The run reported it as
  `dashboard_body MuiBox-root mui-0`.

Extension to 0.11.1 so the dashboard can confirm the reload took. Runner
VERSION deliberately unchanged — the step vocabulary is the same, and a bump
would raise the mismatch banner for a change that does not need it. The
runner does still need restarting to pick up the covered-retry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 00:56:47 -05:00
Brandon LiandClaude Opus 5 5538215650 Load every failed account before the Tradeify reset loop
The account list loads a screenful at a time, so a reset count larger than the
first page ran out of rows to click.

Counts the reset buttons rather than the rows, so the run log reports the
number that answers the question — are there enough accounts to reset `count`
times. A stretch of non-resettable rows does not stop it early: `stable` only
advances when the scroller was ALREADY at the bottom and nothing new matched,
and every appended batch pushes scrollHeight past scrollTop + clientHeight.
Simulated at 100 rows with the resettables in the middle, at the bottom, and
at the top only — all four layouts walk the whole list.

The container is named rather than guessed. The list scrolls inside
dashboard_body, not the document, and setting scrollTop on the wrong element
scrolls nothing at all — which fails as a low match count rather than an
error. Spelled as a selector list because the live markup is unconfirmed:
querySelector takes either form, and matching neither fails loudly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 00:41:30 -05:00
Brandon LiandClaude Opus 5 159389be35 Solve captchas through the 2captcha API, not its extension
The two Tradeify automations clicked the 2captcha *extension's* solve button
and waited for it to go green. That needed a second extension installed and
signed in, put a real mouse click on a button whose markup is 2captcha's to
change, and gave a run no way to tell "still solving" from "not installed".

Replaced with one `solveCaptcha` step. No mouse: the extension reads the
widget's sitekey out of the page, the dashboard buys a token from 2captcha,
and the extension puts it in the response field and fires the page's own
callback.

captcha-hook.js is the part that earns its keep. A widget rendered
programmatically puts nothing in the DOM — no sitekey, and no callback. Most
pages only enable submit from that callback, so filling the hidden field alone
leaves the page looking unsolved. The hook wraps turnstile/grecaptcha/hcaptcha
`render` at document_start to hold on to what the page passed.

Solves go through 2captcha's residential proxy. Its API rejects host names in
proxyAddress, so na.proxy.2captcha.com is resolved to an IP first (cached);
the gateway was confirmed to work addressed that way.

Covers reCAPTCHA v2 (incl. invisible and Enterprise), hCaptcha and Turnstile.
NOT reCAPTCHA v3: a v3 page carries an anchor iframe with k=, so it is
detected as v2 and would be solved with the wrong task type. Everything else
unsupported fails loudly instead.

The API key and proxy credentials are hardcoded in the captcha route on
request. Unlike the sqlite database, that file is in the repo, so they travel
with it. TWOCAPTCHA_API_KEY / TWOCAPTCHA_PROXY and the settings rows still
override; neither has a field on the Settings page.

Runner VERSION and RUNNER_EXPECTED_VERSION both to 0.17.0 — they are a pair,
and a mismatch shows as a red "restart your runner" banner that blames the
wrong process.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 00:18:00 -05:00
137 changed files with 35917 additions and 6499 deletions
+25
View File
@@ -59,7 +59,32 @@ 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/
scripts/.last-check
# The deploy template is meant to be read, unlike a real .env
!deploy/.env.example
# publish bookkeeping
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
+352 -24
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).
---
@@ -70,12 +72,205 @@ The dashboard runs on port **3000**: `http://localhost:3000`.
On first run, the SQLite database (`autotrader.sqlite`) is created automatically in the project root. No migrations need to be run manually.
Open the app in your browser and add your firms through the UI:
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 platform login for each firm.
1. Click **+ Add Firm** on the main page
2. Enter the firm name, Tradovate username, and password
3. Go to the firm's **Settings** page to configure account types (prefix, profit target, consistency %, etc.)
4. Return to the main page — accounts will populate once the firm connects to Tradovate
1. On the **master dashboard**, open **Firms**, add the firm, and give it one
account type per prefix + account size combination (profit target,
consistency %, min trading days, and so on)
2. Set **Dashboard URL** on this machine's **Settings** page so it can reach the
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**. 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
Type** dropdown to pin one by hand; a manual pick is never overwritten
---
## 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).
---
@@ -86,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:
| | |
@@ -126,14 +328,48 @@ Automations are declared in `lib/automations.ts` — one entry per firm, with th
steps inside. Adding a button means editing that file; the page and the runner
pick it up from the server.
### 1. Load the extension
### 1. Install the extension
`chrome://extensions` → enable **Developer mode** → **Load unpacked** →
select the `extension` folder.
The setup scripts do this as their last step: give them the Chrome Enterprise
Core enrolment token from the Admin console (Devices → Chrome → Managed
browsers) and they enrol this machine's Chrome. The extension then arrives from
the console already set to force install, and Chrome keeps it updated. Run on
its own at any time:
Chrome does **not** reload an unpacked extension when its files change. After
pulling updates, click the reload icon on its card — the dashboard shows the
version it sees, and a mismatch means the reload did not take.
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\enroll-chrome-management.ps1
```
```bash
sudo scripts/install-extension-policy.sh
```
The enrolment token is built into both scripts — a token is reusable across any
number of machines — so there is nothing to type. Pass one explicitly to use a
different org. Chrome itself is installed first if the machine hasn't got it,
from Google's own enterprise MSI on Windows and the official .deb on Linux;
Chromium is not accepted as a substitute, because it has no cloud-management
support and can never be enrolled.
**A Raspberry Pi needs none of it.** Chrome is not published for ARM, but
Chromium is, and on Linux Chromium honours the same forcelist policy — the CRX
is pure JavaScript, so the architecture is irrelevant. `setup-linux.sh` writes
the policy and Chromium installs the extension from the update server on its
next start. Loading `extension/` unpacked still works and still self-reloads,
but it is no longer the only option there.
**Enrolment is a Windows problem only.** Windows refuses to force-install an
off-store extension unless the machine is domain-joined, Entra-joined or
enrolled in Chrome Enterprise Core — it reads the policy and blanks it — and
macOS wants MDM. Linux has no such requirement: a policy file under `/etc` is
enough, which is why a Raspberry Pi running Chromium installs this with no
Google account involved at all. On Linux, run `install-extension-policy.sh` and
skip enrolment entirely. See [deploy/README.md](deploy/README.md). Either way the
extension notices when
the dashboard is running newer code than it is: a policy install asks Chrome to
fetch the new CRX, an unpacked one reloads itself from disk. Clicking the reload
icon on its card still works and is the thing to try when something looks
stuck.
Adding a new firm also means adding its host to `host_permissions` in
`extension/manifest.json` and reloading. Without it the extension cannot read
@@ -166,6 +402,45 @@ the automation buttons when it is not there. A running Python process does not
reload when the source changes, so restart it after pulling updates; the
dashboard warns when its version is behind.
### 4. Captchas
A run that hits a captcha solves it through the **2captcha API**, not by clicking
2captcha's own browser extension. The API key is hardcoded in
`app/api/autobuyer/captcha/route.ts` — to bill a different account, set
`TWOCAPTCHA_API_KEY`, or PATCH `twocaptcha_api_key` to `/api/settings`. Either way
the key stays on the dashboard: the runner asks this machine to solve and never
handles it.
A `solveCaptcha` step then does three things, with no mouse involved: the
extension reads the widget's sitekey out of the page, the dashboard buys a token
from 2captcha, and the extension puts that token into the page's response field
and fires the page's own callback. reCAPTCHA v2 (including Enterprise), hCaptcha
and Turnstile are detected automatically.
If the 2captcha *extension* is still installed, disable it or turn off its
auto-solve. Two solvers racing on one widget is a good way to burn balance and
end up with a token the page has already replaced.
Solving costs money per captcha, and a failed solve is still billed. `timeoutSeconds`
on the step caps how long a run waits; 2captcha usually answers in 10–30s.
**The solver proxy.** Solves go through 2captcha's residential proxy, so the
challenge is answered from a residential address rather than a datacentre one.
The credentials are hardcoded in `app/api/autobuyer/captcha/route.ts`; override
them without touching code via `TWOCAPTCHA_PROXY`, or by PATCHing
`twocaptcha_proxy` to `/api/settings`. Either accepts `none` to solve proxyless.
Neither has a field on the Settings page — both are hardcoded, so there is
nothing to fill in. The run log names the address that
solved each captcha — the first thing to check when a token comes back and the
site rejects it anyway.
Two things that proxy does *not* do. It is not the browser's proxy: the token is
solved from the proxy's address but the form is submitted from this machine's,
and nothing makes those match. And the pool is not geo-pinned — despite the
`na.` hostname, test requests exited from Russia and Japan. For reCAPTCHA v2 and
Turnstile that mismatch is usually tolerated; if a site starts rejecting tokens
that 2captcha reports as solved, set the proxy to `none` and compare.
### Running it
Press an automation's button. Progress appears per step, and **Stop** halts a run
@@ -241,15 +516,51 @@ New-NetFirewallRule -DisplayName "AutoFirmer" -Direction Inbound -Protocol TCP -
---
## Publishing extension updates
The extension is signed and served from the droplet, which is what lets Chrome
update it on its own. Everything else — the dashboard, the clicker — stays on
the trading machine.
```bash
# on the droplet, in /opt/autofirmer
git pull && ./deploy/deploy.sh
```
That packs `extension/` at the version in its `manifest.json` — **bump it or
nothing updates** — signs it, and publishes the CRX and update manifest. See
[deploy/README.md](deploy/README.md).
---
## 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
@@ -266,24 +577,41 @@ 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
```
### The two things that still do not reload themselves
Without `--apply` it does what the schedule does: it checks, starts a worker if
there is anything to do, and returns straight away.
- **The extension** — click reload on its card in `chrome://extensions`.
### What reloads itself, and what does not
- **The extension** updates itself now. `/api/autobuyer/status` reports the
version the dashboard's own checkout ships, and when the running build is
older the extension acts on it: a policy install asks Chrome to fetch the new
CRX from the update server, an unpacked one calls `chrome.runtime.reload()`
and comes back on the files the update check pulled. The unpacked path can
only run code already on disk, so if it reloads before the pull lands it says
so in the popup and waits rather than restarting in a loop.
- **The scheduler is fine now.** It persists to the settings table and resumes
after a restart, so an update no longer silently stops automated trading.
- **The runner** does not. It is a long-lived Python process; the update check
restarts it when anything under `clicker/` changes, and the dashboard warns
when the version it reports is behind what the server expects.
The dashboard reports the version it sees from the extension and the runner, and
warns when either is behind. Most AutoBuyer bugs that look mysterious are the
+426 -43
View File
@@ -1,6 +1,6 @@
'use client';
import { useEffect, useState, type ReactNode } from 'react';
import { useCallback, useEffect, useState, type ReactNode } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import {
@@ -16,17 +16,236 @@ 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';
function getAccountConfig(name: string, firm: FirmConfig): AccountConfig | undefined {
return [...firm.accounts]
.sort((a, b) => b.prefix.length - a.prefix.length)
.find((a) => name.startsWith(a.prefix));
/** 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. */
function AccountTypePicker({ account, firm, onAssigned }: {
account: AccountState;
firm: FirmConfig;
onAssigned: () => void;
}) {
const [saving, setSaving] = useState(false);
async function assign(accountTypeId: string) {
setSaving(true);
try {
if (accountTypeId === '') {
await fetch(`/api/accounts/${account.id}/account-type`, { method: 'DELETE' });
} else {
await fetch(`/api/accounts/${account.id}/account-type`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accountTypeId }),
});
}
onAssigned();
} finally {
setSaving(false);
}
}
return (
<span className="flex items-center gap-1.5">
<select
value={account.assignmentSource === 'manual' ? (account.accountTypeId ?? '') : ''}
disabled={saving}
onChange={(e) => assign(e.target.value)}
className="border border-slate-200 rounded-md px-1.5 py-1 text-xs bg-white text-slate-700 focus:outline-none focus:ring-1 focus:ring-blue-400"
>
<option value="">
{account.accountTypeLabel ?? 'Unassigned'}
</option>
{firm.accounts.map((type) => (
<option key={type.id} value={type.id}>
{type.label} · {type.prefix} · {type.accountSize >= 1000 ? `${type.accountSize / 1000}k` : type.accountSize}
</option>
))}
</select>
{account.assignmentSource === 'manual' && (
<span className="w-1.5 h-1.5 rounded-full bg-blue-400" title="Pinned by hand" />
)}
{account.accountTypeAmbiguous && (
<span className="text-amber-500 text-xs font-bold" title="Several account types matched — using the most recently updated one">*</span>
)}
{!account.accountTypeLabel && (
<span className="inline-block px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-100 text-amber-700">
Unassigned
</span>
)}
</span>
);
}
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)}`;
@@ -63,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
@@ -87,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
@@ -134,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}
@@ -147,6 +391,12 @@ 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'
: 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'
}`}
>
@@ -157,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>
)}
@@ -174,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>
);
})}
@@ -189,9 +462,9 @@ export default function AccountPage() {
const [account, setAccount] = useState<AccountState | null>(null);
const [cfg, setCfg] = useState<AccountConfig | null>(null);
const [firmName, setFirmName] = useState('');
const [firm, setFirm] = useState<FirmConfig | null>(null);
useEffect(() => {
async function load() {
const load = useCallback(async () => {
const [stateRes, firmsRes] = await Promise.all([
fetch('/api/state'),
fetch('/api/firms'),
@@ -202,20 +475,26 @@ export default function AccountPage() {
for (const firmState of states) {
const acc = firmState.accounts.find((a) => a.id === accountId);
if (acc) {
const firmCfg = firms.find((f) => f.firm === firmState.firm);
// Join on the master's firm id — the name changes the
// moment someone renames a firm there.
const firmCfg = firms.find((f) => f.id === firmState.firmId);
setAccount(acc);
setFirmName(firmState.firm);
if (firmCfg) setCfg(getAccountConfig(acc.name, firmCfg) ?? null);
setFirm(firmCfg ?? null);
setCfg(acc.accountType);
break;
}
}
}
load();
const interval = setInterval(load, 5000);
return () => clearInterval(interval);
}, [accountId]);
useEffect(() => {
// 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 () => { clearTimeout(first); clearInterval(interval); };
}, [load]);
if (!account) {
return (
<div className="min-h-screen bg-slate-50 p-8">
@@ -235,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.
@@ -251,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.
@@ -272,13 +558,19 @@ export default function AccountPage() {
}
const sortedEvents = [...allDates].sort().map((d) => eventsByDate.get(d)!);
// Running equity after each event, summed here rather than inside the
// map below: a render callback mustn't reassign an outer variable.
const runningAfter: number[] = [];
let running = 0;
const equityData = [
{ label: '', equity: 0, pnl: 0, origin: true, pos: 0, neg: 0, isWithdrawal: false },
...sortedEvents.map((ev) => {
for (const ev of sortedEvents) {
if (ev.fundAmt !== undefined) running += ev.fundAmt; // withdrawals reduce equity
if (ev.pnl !== undefined) running += ev.pnl;
const equity = Math.round(running * 100) / 100;
runningAfter.push(running);
}
const equityData = [
{ label: '', equity: 0, pnl: 0, origin: true, pos: 0, neg: 0, isWithdrawal: false },
...sortedEvents.map((ev, i) => {
const equity = Math.round(runningAfter[i] * 100) / 100;
return {
label: fmtDate(ev.date),
equity,
@@ -313,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);
}
@@ -339,6 +697,14 @@ export default function AccountPage() {
</Link>
<span className="text-slate-300">/</span>
<span className="text-slate-400 text-sm">{firmName}</span>
{firm && (
<AccountTypePicker
account={account}
firm={firm}
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 && (
@@ -346,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 && (
@@ -354,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>
@@ -372,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">
@@ -383,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>
}
@@ -392,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>
@@ -552,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 (
@@ -576,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">
-83
View File
@@ -1,83 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function AddPage() {
const router = useRouter();
const [firmName, setFirmName] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
const inputCls = 'w-full border border-slate-200 rounded-lg px-3 py-2 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-400';
const labelCls = 'block text-xs font-semibold uppercase tracking-wider text-slate-500 mb-1';
const handleSave = async () => {
if (!firmName.trim() || !username.trim() || !password.trim()) return;
setSaving(true);
setError('');
try {
const res = await fetch('/api/firms', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: firmName.trim(), username: username.trim(), password: password.trim() }),
});
if (!res.ok) {
const data = await res.json() as { error?: string };
setError(data.error ?? 'Failed to save firm');
return;
}
router.push('/');
} catch {
setError('Could not connect to server');
} finally {
setSaving(false);
}
};
return (
<div className="min-h-screen bg-slate-50 p-8">
<div className="max-w-7xl mx-auto">
<div className="flex items-center gap-3 mb-6">
<button onClick={() => router.push('/')} className="text-slate-400 hover:text-slate-600 text-sm transition-colors">
← Back
</button>
<h1 className="text-2xl font-bold text-slate-900">Add New Firm</h1>
</div>
<div className="bg-white border border-slate-200 rounded-xl p-6 shadow-sm max-w-md space-y-4">
{error && (
<div className="text-sm text-red-600 bg-red-50 border border-red-100 rounded-lg px-3 py-2">
{error}
</div>
)}
<div>
<label className={labelCls}>Firm Name</label>
<input value={firmName} onChange={(e) => setFirmName(e.target.value)} className={inputCls} placeholder="e.g. MyFirm" />
</div>
<div>
<label className={labelCls}>Username</label>
<input value={username} onChange={(e) => setUsername(e.target.value)} className={inputCls} placeholder="Username" />
</div>
<div>
<label className={labelCls}>Password</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} className={inputCls} placeholder="Password" />
</div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={() => router.push('/')} className="px-4 py-2 text-sm text-slate-600 hover:text-slate-800 transition-colors">
Cancel
</button>
<button
onClick={handleSave}
disabled={saving || !firmName.trim() || !username.trim() || !password.trim()}
className="px-4 py-2 bg-green-100 hover:bg-green-200 disabled:opacity-40 disabled:cursor-not-allowed text-green-700 text-sm font-semibold rounded-lg transition-colors"
>
{saving ? 'Saving...' : 'Add Firm'}
</button>
</div>
</div>
</div>
</div>
);
}
-87
View File
@@ -1,87 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { updateAccountConfig, deleteAccountConfig } from '@/lib/db';
export async function PUT(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id: idStr } = await params;
const id = parseInt(idStr, 10);
if (isNaN(id)) {
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
}
const body = await req.json() as {
prefix?: string;
profitTarget?: number;
consistency?: number;
minDayPnL?: number;
minTradingDays?: number;
accountSize?: number;
maxLoss?: number;
maxPositionSize?: number;
targetSameEquity?: boolean;
withdrawalStages?: { profit: number; consistency: number; minTradingDays: number }[];
};
const { prefix, profitTarget, consistency, minDayPnL, minTradingDays, accountSize, maxLoss, maxPositionSize, targetSameEquity, withdrawalStages } = body;
if (
typeof prefix !== 'string' || !prefix.trim() ||
typeof profitTarget !== 'number' ||
typeof consistency !== 'number' ||
typeof minDayPnL !== 'number' ||
typeof minTradingDays !== 'number' ||
typeof accountSize !== 'number'
) {
return NextResponse.json({ error: 'Invalid body' }, { status: 400 });
}
try {
const updated = updateAccountConfig(id, {
prefix: prefix.trim(),
profitTarget,
consistency,
minDayPnL,
minTradingDays,
accountSize,
maxLoss: maxLoss ?? 0,
maxPositionSize: maxPositionSize ?? 0,
targetSameEquity: targetSameEquity ?? false,
withdrawalStages: withdrawalStages ?? [],
});
if (!updated) {
return NextResponse.json({ error: 'Account config not found' }, { status: 404 });
}
return NextResponse.json({ success: true });
} catch (err) {
console.error('[PUT /api/account-configs/:id]', err);
return NextResponse.json({ error: 'Failed to update' }, { status: 500 });
}
}
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id: idStr } = await params;
const id = parseInt(idStr, 10);
if (isNaN(id)) {
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
}
try {
const deleted = deleteAccountConfig(id);
if (!deleted) {
return NextResponse.json({ error: 'Account config not found' }, { status: 404 });
}
return NextResponse.json({ success: true });
} catch (err) {
console.error('[DELETE /api/account-configs/:id]', err);
return NextResponse.json({ error: 'Failed to delete' }, { status: 500 });
}
}
@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAccountTypeById } from '@/lib/firm-catalog';
import { getAssignment } from '@/lib/db';
import { getClients } from '@/lib/clients';
import {
clearManualAssignment,
resolveFirmAssignments,
setManualAssignment,
} from '@/lib/account-type-resolver';
type Params = { params: Promise<{ id: string }> };
function parseAccountId(raw: string): number | null {
const id = parseInt(raw, 10);
return Number.isNaN(id) ? null : id;
}
/** Pin an account to one of its firm's account types. A manual pick is never
* overwritten by the auto-resolver — that is the point of it. */
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 { accountTypeId } = await req.json() as { accountTypeId?: string | null };
// The firm comes from the existing assignment, or from whichever client is
// holding this account — an account the machine has never seen has nothing
// to pin to.
let firmId = getAssignment(accountId)?.firm_id ?? null;
if (!firmId) {
for (const [candidateFirmId, client] of getClients()) {
if (client.accountList.some((a) => a.id === accountId)) {
firmId = candidateFirmId;
break;
}
}
}
if (!firmId) return NextResponse.json({ error: 'Unknown account' }, { status: 404 });
if (accountTypeId) {
const type = getAccountTypeById(accountTypeId);
if (!type) return NextResponse.json({ error: 'Unknown account type' }, { status: 400 });
if (type.firmId !== firmId) {
return NextResponse.json({ error: 'That account type belongs to another firm' }, { status: 400 });
}
}
setManualAssignment(accountId, firmId, accountTypeId ?? null);
return NextResponse.json({ ok: true });
}
/** Drop the override and hand the account back to the auto-resolver. */
export async function DELETE(_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 firmId = getAssignment(accountId)?.firm_id ?? null;
clearManualAssignment(accountId);
if (firmId) {
const client = getClients().get(firmId);
if (client) resolveFirmAssignments(firmId, client);
}
return NextResponse.json({ ok: true });
}
+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 });
}
+273
View File
@@ -0,0 +1,273 @@
import { lookup as dnsLookup } from 'node:dns/promises';
import { NextRequest } from 'next/server';
import { getSetting } from '@/lib/db';
import { corsJson, corsPreflight } from '../cors';
/** A thin proxy over api.2captcha.com, so the runner never handles the key.
*
* The runner is a desktop process that may not even be on this machine; keeping
* the key here means it lives in exactly one place — the settings table — and
* a captcha solve is one more dashboard call like every other step.
*/
const API_BASE = 'https://api.2captcha.com';
const UPSTREAM_TIMEOUT_MS = 20_000;
/** 2captcha's residential proxy, so the solve comes from a residential address
* rather than a datacentre one the site may already score badly.
*
* Hardcoded on request. Note what that means: unlike the sqlite database, this
* file IS in the repo, so these credentials travel with it. Override without
* editing code via TWOCAPTCHA_PROXY, or by PATCHing `twocaptcha_proxy` to
* /api/settings; either accepts `none` to go back to solving proxyless.
*
* Caveat worth knowing before trusting it: the token is scored partly on the
* address that solved it, while the form is submitted from THIS machine's
* address. The two only match if the browser goes out through the same proxy,
* which it does not. For reCAPTCHA v2 and Turnstile that is usually tolerated;
* for anything scoring the IP harder, a mismatched proxy can be worse than
* none. */
const DEFAULT_PROXY = 'http://u3229993f561005c2-zone-custom:u3229993f561005c2@na.proxy.2captcha.com:2334';
interface Proxy {
type: string;
host: string;
port: number;
login: string;
password: string;
}
/** Settings first, then the environment, then the hardcoded default. `none`
* anywhere in that chain means solve without a proxy. */
function proxyConfig(): Proxy | null {
const raw = (getSetting('twocaptcha_proxy') || process.env.TWOCAPTCHA_PROXY || DEFAULT_PROXY).trim();
if (!raw || /^(none|off|proxyless)$/i.test(raw)) return null;
try {
const url = new URL(/^\w+:\/\//.test(raw) ? raw : `http://${raw}`);
const port = Number(url.port);
if (!url.hostname || !port) return null;
return {
type: url.protocol.replace(':', '').toLowerCase(),
host: url.hostname,
port,
login: decodeURIComponent(url.username),
password: decodeURIComponent(url.password),
};
} catch {
return null;
}
}
/** 2captcha rejects host names in `proxyAddress` — it wants a literal IP — so
* `na.proxy.2captcha.com` has to be resolved here. Cached: the gateway address
* is stable, and a DNS lookup per solve would be a needless round trip. */
const IS_IP = /^(\d{1,3}\.){3}\d{1,3}$|:/;
const dnsCache = new Map<string, { ip: string; at: number }>();
const DNS_TTL_MS = 5 * 60_000;
async function proxyAddress(host: string): Promise<string> {
if (IS_IP.test(host)) return host;
const hit = dnsCache.get(host);
if (hit && Date.now() - hit.at < DNS_TTL_MS) return hit.ip;
const { address } = await dnsLookup(host);
dnsCache.set(host, { ip: address, at: Date.now() });
return address;
}
type Provider = 'recaptcha' | 'hcaptcha' | 'turnstile';
/** The slice of 2captcha's answer we act on. Both endpoints share the shape. */
interface UpstreamResult {
errorId?: number;
errorCode?: string;
errorDescription?: string;
taskId?: number | string;
status?: string;
solution?: { token?: string; gRecaptchaResponse?: string };
cost?: string;
}
function message(err: unknown, fallback: string): string {
return err instanceof Error ? err.message : fallback;
}
/** The 2captcha account these solves are billed to.
*
* Hardcoded on request, with the same caveat as the proxy below: this file is in
* the repo, so the key travels with it. Anyone who ends up with a copy can spend
* the balance. To bill another account, set TWOCAPTCHA_API_KEY, or PATCH
* `twocaptcha_api_key` to /api/settings — the settings row still wins, it just
* has no field on the Settings page any more. */
const DEFAULT_API_KEY = 'bde31ec7a337243e7331fbb882111a62';
/** Settings first, then the environment, then the hardcoded default. */
function apiKey(): string {
return (getSetting('twocaptcha_api_key') || process.env.TWOCAPTCHA_API_KEY || DEFAULT_API_KEY).trim();
}
/** Build the task 2captcha expects for this widget.
*
* Each type comes in two spellings: `…TaskProxyless` solves from 2captcha's own
* address, `…Task` solves through the proxy named in `proxy`. Same fields
* otherwise, so the only difference is the suffix and the five proxy keys.
*/
function buildTask(
body: Record<string, unknown>,
proxy: (Proxy & { address: string }) | null,
): { task?: Record<string, unknown>; error?: string } {
const provider = String(body.provider || '') as Provider;
const websiteKey = String(body.sitekey || '').trim();
const websiteURL = String(body.pageurl || '').trim();
if (!websiteKey) return { error: '`sitekey` is required' };
if (!/^https?:\/\//.test(websiteURL)) return { error: '`pageurl` must be an http(s) URL' };
const suffix = proxy ? '' : 'Proxyless';
const common: Record<string, unknown> = {
websiteURL,
websiteKey,
...(proxy ? {
proxyType: proxy.type,
proxyAddress: proxy.address,
proxyPort: proxy.port,
proxyLogin: proxy.login,
proxyPassword: proxy.password,
} : {}),
// The browser that will submit the form. Sent only alongside a proxy:
// a solve routed through a residential address is more convincing when
// it also claims the same browser, and 2captcha ignores it otherwise.
...(proxy && body.userAgent ? { userAgent: String(body.userAgent) } : {}),
};
if (provider === 'turnstile') {
return {
task: {
type: `TurnstileTask${suffix}`,
...common,
// Only present when the page rendered the widget programmatically
// with them. Sending empty ones makes 2captcha reject the task.
...(body.action ? { action: String(body.action) } : {}),
...(body.data ? { data: String(body.data) } : {}),
...(body.pagedata ? { pagedata: String(body.pagedata) } : {}),
},
};
}
if (provider === 'hcaptcha') {
return {
task: {
type: `HCaptchaTask${suffix}`,
...common,
isInvisible: !!body.invisible,
},
};
}
if (provider === 'recaptcha') {
return {
task: {
type: body.enterprise ? `RecaptchaV2EnterpriseTask${suffix}` : `RecaptchaV2Task${suffix}`,
...common,
isInvisible: !!body.invisible,
},
};
}
return { error: `unknown captcha provider ${provider || '(none)'}` };
}
async function callUpstream(path: string, payload: unknown): Promise<UpstreamResult> {
const res = await fetch(`${API_BASE}/${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
});
if (!res.ok) throw new Error(`2captcha ${path} -> HTTP ${res.status}`);
return res.json();
}
/** Queue a solve. Returns the 2captcha task id to poll with GET. */
export async function POST(req: NextRequest) {
const clientKey = apiKey();
if (!clientKey) {
return corsJson(
{ error: 'No 2captcha API key — set TWOCAPTCHA_API_KEY, or `twocaptcha_api_key` via /api/settings' },
{ status: 400 },
);
}
let body: Record<string, unknown>;
try {
body = await req.json();
} catch {
return corsJson({ error: 'Invalid JSON body' }, { status: 400 });
}
const proxy = proxyConfig();
let resolved: (Proxy & { address: string }) | null = null;
if (proxy) {
try {
resolved = { ...proxy, address: await proxyAddress(proxy.host) };
} catch (err) {
return corsJson(
{ error: `cannot resolve proxy host ${proxy.host}: ${message(err, 'DNS lookup failed')}` },
{ status: 502 },
);
}
}
const { task, error } = buildTask(body, resolved);
if (!task) return corsJson({ error }, { status: 400 });
try {
const out = await callUpstream('createTask', { clientKey, task, softId: 0 });
if (out.errorId) {
// A rejected task is 2captcha's verdict, not a broken pipeline — a bad
// key or an empty balance both land here, and the runner reports them
// as the step failure they are.
return corsJson({ error: `${out.errorCode}: ${out.errorDescription}` }, { status: 400 });
}
return corsJson({
taskId: out.taskId,
type: task.type,
// Echoed so the run log says which address solved it — the first
// thing to check when a token comes back and the site rejects it.
via: resolved ? `${proxy!.host}:${proxy!.port} (${resolved.address})` : 'no proxy',
});
} catch (err) {
return corsJson({ error: message(err, 'createTask failed') }, { status: 502 });
}
}
/** Poll one task. `processing` means keep waiting; `failed` means this attempt
* is dead and a fresh task is the only way forward. */
export async function GET(req: NextRequest) {
const clientKey = apiKey();
if (!clientKey) return corsJson({ error: 'No 2captcha API key configured' }, { status: 400 });
const taskId = req.nextUrl.searchParams.get('taskId');
if (!taskId) return corsJson({ error: '`taskId` is required' }, { status: 400 });
try {
const out = await callUpstream('getTaskResult', { clientKey, taskId: Number(taskId) || taskId });
if (out.errorId) {
return corsJson({ status: 'failed', error: `${out.errorCode}: ${out.errorDescription}` });
}
if (out.status !== 'ready') return corsJson({ status: 'processing' });
// Turnstile and hCaptcha answer with `token`, reCAPTCHA with
// `gRecaptchaResponse`. Same string, two names.
const token = out.solution?.token || out.solution?.gRecaptchaResponse || '';
if (!token) return corsJson({ status: 'failed', error: 'solved, but the answer carried no token' });
return corsJson({ status: 'ready', token, cost: out.cost ?? null });
} catch (err) {
return corsJson({ error: message(err, 'getTaskResult failed') }, { status: 502 });
}
}
export async function OPTIONS() {
return corsPreflight();
}
+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),
},
});
+32 -2
View File
@@ -1,11 +1,41 @@
import { readFileSync, statSync } from 'fs';
import path from 'path';
import { countPendingLocate } from '@/lib/db';
import { corsJson, corsPreflight } from '../cors';
/** The extension version this checkout ships.
*
* Read out of the manifest rather than kept as a constant here: the manifest is
* the file Chrome actually loads, so the two cannot drift apart. Cached on
* mtime because every browser polls this every few seconds and the file only
* changes when an update lands. Null when there is no extension folder beside
* the server — the extension then leaves itself alone, which is the right
* answer for a dashboard that cannot see what it is supposed to be running. */
let manifestCache: { mtimeMs: number; version: string | null } | null = null;
function shippedExtensionVersion(): string | null {
const file = path.join(process.cwd(), 'extension', 'manifest.json');
try {
const { mtimeMs } = statSync(file);
if (!manifestCache || manifestCache.mtimeMs !== mtimeMs) {
const parsed = JSON.parse(readFileSync(file, 'utf8')) as { version?: string };
manifestCache = { mtimeMs, version: parsed.version ?? null };
}
return manifestCache.version;
} catch {
return null;
}
}
/** Polled by the Chromium extension every few seconds, and by the AutoBuyer page.
* The AutoBuyer has no on/off switch — it is ready whenever the runner is — so
* the only thing worth reporting here is whether clicks are waiting to be served. */
* the only things worth reporting here are whether clicks are waiting to be
* served, and which extension build this checkout expects to be talking to. */
export async function GET() {
return corsJson({ pendingLocate: countPendingLocate() });
return corsJson({
pendingLocate: countPendingLocate(),
extensionVersion: shippedExtensionVersion(),
});
}
export async function OPTIONS() {
+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 });
}
}
+57 -6
View File
@@ -1,12 +1,60 @@
import { NextResponse } from 'next/server';
import { getClients, resetClients } from '@/lib/clients';
import { getFirms } from '@/lib/db';
import axios from 'axios';
import { getTradableFirms } from '@/lib/firm-catalog';
import { getAccountConfig } from '@/lib/account-type-resolver';
import { combineCmeSessions, computeDayPlan, resolveEffectiveConfig, currentTradeDateCT } from '@/lib/trading-logic';
import axios from '@/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 = getFirms();
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(),
@@ -48,7 +99,7 @@ export async function GET() {
export async function POST() {
try {
const clients = getClients();
const firms = getFirms();
const firms = getTradableFirms();
const results = await Promise.all(
firms.map(async (firm) => {
+31
View File
@@ -0,0 +1,31 @@
import { NextResponse } from 'next/server';
import { getCatalogMeta, syncCatalog } from '@/lib/firm-catalog';
import { getClients, reconcileClients } from '@/lib/clients';
import { resolveFirmAssignments } from '@/lib/account-type-resolver';
import { getSetting } from '@/lib/db';
/** Sync status for the Settings page. */
export async function GET() {
return NextResponse.json({
...getCatalogMeta(),
masterUrl: getSetting('master_dashboard_url'),
});
}
/** Force a pull now, instead of waiting for the poll. */
export async function POST() {
const result = await syncCatalog();
if (result.changed) {
reconcileClients();
for (const [firmId, client] of getClients()) {
try {
resolveFirmAssignments(firmId, client);
} catch (err) {
console.error(`[resolver] re-resolve failed for ${firmId}`, err);
}
}
}
return NextResponse.json({ ...result, ...getCatalogMeta() });
}
-76
View File
@@ -1,76 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { getFirmById, createAccountConfig } from '@/lib/db';
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id: idStr } = await params;
const firmId = parseInt(idStr, 10);
if (isNaN(firmId)) {
return NextResponse.json({ error: 'Invalid firm id' }, { status: 400 });
}
if (!getFirmById(firmId)) {
return NextResponse.json({ error: 'Firm not found' }, { status: 404 });
}
const body = await req.json() as {
prefix?: string;
profitTarget?: number;
consistency?: number;
minDayPnL?: number;
minTradingDays?: number;
accountSize?: number;
maxLoss?: number;
maxPositionSize?: number;
targetSameEquity?: boolean;
withdrawalStages?: { profit: number; consistency: number; minTradingDays: number }[];
};
const { prefix, profitTarget, consistency, minDayPnL, minTradingDays, accountSize, maxLoss, maxPositionSize, targetSameEquity, withdrawalStages } = body;
if (
typeof prefix !== 'string' || !prefix.trim() ||
typeof profitTarget !== 'number' ||
typeof consistency !== 'number' ||
typeof minDayPnL !== 'number' ||
typeof minTradingDays !== 'number' ||
typeof accountSize !== 'number'
) {
return NextResponse.json({ error: 'Invalid body' }, { status: 400 });
}
try {
const row = createAccountConfig(firmId, {
prefix: prefix.trim(),
profitTarget,
consistency,
minDayPnL,
minTradingDays,
accountSize,
maxLoss: maxLoss ?? 0,
maxPositionSize: maxPositionSize ?? 0,
targetSameEquity: targetSameEquity ?? false,
withdrawalStages: withdrawalStages ?? [],
});
return NextResponse.json({
id: row.id,
prefix: row.prefix,
profitTarget: row.profit_target,
consistency: row.consistency,
minDayPnL: row.min_day_pnl,
minTradingDays: row.min_trading_days,
accountSize: row.account_size,
maxLoss: row.max_loss,
maxPositionSize: row.max_position_size,
targetSameEquity: row.target_same_equity === 1,
withdrawalStages: (() => { try { return JSON.parse(row.withdrawal_stages ?? '[]') as { profit: number; consistency: number; minTradingDays: number }[]; } catch { return []; } })(),
}, { status: 201 });
} catch (err) {
console.error('[POST /api/firms/:id/accounts]', err);
return NextResponse.json({ error: 'Failed to create account type' }, { status: 500 });
}
}
@@ -1,25 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { getBannedSymbols, setBannedSymbol } from '@/lib/db';
type Params = { params: Promise<{ id: string }> };
/** GET /api/firms/[id]/banned-symbols — list banned symbols for a firm */
export async function GET(_req: NextRequest, { params }: Params) {
const { id: idStr } = await params;
const firmId = parseInt(idStr, 10);
if (isNaN(firmId)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
return NextResponse.json(getBannedSymbols(firmId));
}
/** PATCH /api/firms/[id]/banned-symbols — { symbol, banned } to ban or unban */
export async function PATCH(req: NextRequest, { params }: Params) {
const { id: idStr } = await params;
const firmId = parseInt(idStr, 10);
if (isNaN(firmId)) return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
const { symbol, banned } = await req.json() as { symbol: string; banned: boolean };
if (!symbol) return NextResponse.json({ error: 'symbol required' }, { status: 400 });
setBannedSymbol(firmId, symbol, banned);
return NextResponse.json({ firmId, symbol, banned });
}
+62
View File
@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server';
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 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; apiKey?: string };
const username = body.username?.trim() ?? '';
const password = body.password?.trim() ?? '';
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, 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);
return NextResponse.json({ error: 'Failed to save credentials' }, { status: 500 });
}
}
export async function DELETE(_req: NextRequest, { params }: Params) {
const { id } = await params;
const cleared = clearCredentials(id);
removeClient(id);
scheduleBackupPush();
return NextResponse.json({ ok: cleared });
}
+2 -3
View File
@@ -1,14 +1,13 @@
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,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const firmId = parseInt(id, 10);
const { id: firmId } = await params;
const clients = getClients();
const client = clients.get(firmId) as any;
if (!client) return NextResponse.json({ error: 'Client not found' }, { status: 404 });
+10 -3
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(
@@ -6,9 +8,7 @@ export async function POST(
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const firmId = parseInt(id, 10);
if (isNaN(firmId)) return NextResponse.json({ error: 'Invalid firm ID' }, { status: 400 });
const { id: firmId } = await params;
const body = await req.json() as {
accountId: number;
@@ -28,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) {
+18 -47
View File
@@ -1,64 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
import { getFirmById, deleteFirm } from '@/lib/db';
import { removeClient } from '@/lib/clients';
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
* are owned by the master dashboard, and there is no local DELETE — a firm
* goes away here when it goes away there. */
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id: idStr } = await params;
const id = parseInt(idStr, 10);
const { id } = await params;
if (isNaN(id)) {
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
}
const firm = getFirmById(id);
const firm = getCachedFirm(id);
if (!firm) {
return NextResponse.json({ error: 'Firm not found' }, { status: 404 });
}
const creds = getCredentials(id);
return NextResponse.json({
id: firm.id,
firm: firm.name,
username: firm.username,
password: firm.password,
accounts: firm.accounts.map((a) => ({
id: a.id,
prefix: a.prefix,
profitTarget: a.profit_target,
consistency: a.consistency,
minDayPnL: a.min_day_pnl,
minTradingDays: a.min_trading_days,
accountSize: a.account_size,
maxLoss: a.max_loss,
maxPositionSize: a.max_position_size,
targetSameEquity: a.target_same_equity === 1,
withdrawalStages: (() => { try { return JSON.parse(a.withdrawal_stages ?? '[]') as { profit: number; consistency: number; minTradingDays: number }[]; } catch { return []; } })(),
username: creds?.username ?? '',
hasCredentials: !!creds,
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,
});
}
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id: idStr } = await params;
const id = parseInt(idStr, 10);
if (isNaN(id)) {
return NextResponse.json({ error: 'Invalid id' }, { status: 400 });
}
try {
const deleted = deleteFirm(id);
if (!deleted) {
return NextResponse.json({ error: 'Firm not found' }, { status: 404 });
}
removeClient(id);
return NextResponse.json({ success: true });
} catch (err) {
console.error('[DELETE /api/firms/:id]', err);
return NextResponse.json({ error: 'Failed to delete firm' }, { status: 500 });
}
}
+57 -45
View File
@@ -1,57 +1,69 @@
import { NextRequest, NextResponse } from 'next/server';
import { getFirms, createFirm, getBannedSymbols } from '@/lib/db';
import { initClient } from '@/lib/clients';
import { NextResponse } from 'next/server';
import { firmInstruments, instrumentsSource } from '@/lib/instruments';
import { getCachedCatalog, getKeptRulesFor, hasPlatformCredentials } from '@/lib/firm-catalog';
import { getCredentials } from '@/lib/db';
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
* whether credentials for each are stored here.
*
* Firms are created and edited on the master dashboard; there is no POST. The
* only writable thing per firm is its credentials, at
* PUT /api/firms/[id]/credentials.
*
* 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 firms = getFirms();
const result = firms.map((f) => ({
id: f.id,
firm: f.name,
username: f.username,
password: f.password,
bannedSymbols: getBannedSymbols(f.id),
accounts: f.accounts.map((a) => ({
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,
instruments: firmInstruments(firm),
instrumentsSource: instrumentsSource(firm),
accounts: firm.accountTypes.map((a) => ({
id: a.id,
label: a.label,
prefix: a.prefix,
profitTarget: a.profit_target,
profitTarget: a.profitTarget,
consistency: a.consistency,
minDayPnL: a.min_day_pnl,
minTradingDays: a.min_trading_days,
accountSize: a.account_size,
maxLoss: a.max_loss,
minDayPnL: a.minDayPnL,
minTradingDays: a.minTradingDays,
accountSize: a.accountSize,
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,
};
});
return NextResponse.json(result);
} catch (err) {
console.error('[GET /api/firms]', err);
return NextResponse.json({ error: 'Failed to fetch firms' }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
const body = await req.json() as { name?: string; username?: string; password?: string };
const { name, username, password } = body;
if (!name?.trim() || !username?.trim() || !password?.trim()) {
return NextResponse.json({ error: 'name, username, and password are required' }, { status: 400 });
}
try {
const firm = createFirm(name.trim(), username.trim(), password.trim());
initClient(firm.id, firm.username, firm.password, firm.name);
return NextResponse.json({
id: firm.id,
firm: firm.name,
username: firm.username,
password: firm.password,
accounts: [],
}, { status: 201 });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('UNIQUE')) {
return NextResponse.json({ error: 'A firm with that name already exists' }, { status: 409 });
}
console.error('[POST /api/firms]', err);
return NextResponse.json({ error: 'Failed to create firm' }, { status: 500 });
}
}
+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 });
}
+23 -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'] 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() {
@@ -9,6 +11,13 @@ export async function GET() {
for (const key of VALID_KEYS) {
result[key] = getSetting(key);
}
// 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);
}
@@ -16,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];
@@ -24,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 });
+277 -48
View File
@@ -1,80 +1,274 @@
import { NextResponse } from 'next/server';
import { getFirms, loadDailyPnL } from '@/lib/db';
import { listAssignments, loadDailyPnL } from '@/lib/db';
import { getClients } from '@/lib/clients';
import { computeDailyTarget, resolveEffectiveConfig } from '@/lib/trading-logic';
import type { AccountConfigRow } from '@/lib/db';
function getAccountConfig(name: string, accounts: AccountConfigRow[]): AccountConfigRow | undefined {
return [...accounts]
.sort((a, b) => b.prefix.length - a.prefix.length)
.find((a) => name.startsWith(a.prefix));
}
import { getAccountTypeById, getCachedCatalog } from '@/lib/firm-catalog';
import { ruleDiffs } from '@/lib/kept-rules';
import { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/account-type-resolver';
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 = getFirms();
const firms = getCachedCatalog();
const clients = getClients();
const state = firms.map((f) => {
const client = clients.get(f.id);
// connected == the Tradovate sync has come back, NOT "has accounts".
// A firm with no accounts is a real, settled state and must not be
// reported the same way as one that is still authenticating.
const connected = !!client && client.syncComplete;
if (!client || client.accountList.length === 0) {
return { firm: f.name, connected: false, accounts: [] };
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);
// Compute daily target and targetHit in one place — the single source of truth.
const cfg = getAccountConfig(acc.name, f.accounts);
// 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.min_day_pnl > 0
? dailyPnL.filter((d) => d.pnl >= cfg.min_day_pnl).length
: (client.daysTraded[acc.id] ?? 0);
// One lookup against the assignment table — no prefix matching
// here any more. An unassigned account gets no targets, which
// is also what stops it being traded.
const resolved = getAccountConfig(acc.id);
const cfg = resolved.accountType;
const inferredAccountSize = getInferredSize(acc.id, client);
// 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 displayFundTxns = cfg
? allFundTxns.filter((f) => f.amount !== cfg.account_size)
const fundingAmount = cfg?.accountSize ?? inferredAccountSize;
const displayFundTxns = fundingAmount !== null
? allFundTxns.filter((f) => f.amount !== fundingAmount)
: allFundTxns;
// Surfaced so the dashboard can flag an account whose type was
// picked out of several equally valid matches.
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 withdrawalStages: { profit: number; consistency: number; minTradingDays: number }[] = (() => { try { return JSON.parse(cfg.withdrawal_stages ?? '[]'); } catch { return []; } })();
const effective = resolveEffectiveConfig(
cfg.profit_target,
cfg.consistency,
cfg.min_trading_days,
cfg.target_same_equity === 1,
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, combined.tradeDate, effective.profitTarget, effective.consistency,
dailyPnL, effective.minDayPnL, effective.minTradingDays, equityProfit, combined.realizedPnL,
cfg.splitEnabled, effective.drain
);
const equityProfit = cash.amount - cfg.account_size;
const target = computeDailyTarget(effective.profitTarget, effective.consistency, dailyPnL, cfg.min_day_pnl, effective.minTradingDays, equityProfit);
dailyTarget = target;
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.min_day_pnl === 0 ? dailyPnL : dailyPnL.filter((d) => d.pnl >= cfg.min_day_pnl);
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);
// Condition 1: profit target already exceeded (target=0), still need days → any activity counts
// Condition 2: target > 0 → must have made at least the computed daily target
if (target) {
targetHit =
(target.amount === 0 && Math.abs(cash.realizedPnL) > 0 && client.daysTraded[acc.id] <= effective.minTradingDays) ||
(cash.realizedPnL >= target.amount);
// ── 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(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 {
@@ -82,23 +276,58 @@ export async function GET() {
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,
assignmentSource: resolved.source,
accountType: cfg,
inferredAccountSize,
accountTypeAmbiguous: ambiguous,
rulesDate: resolved.rulesDate,
rulesDateManual: rulesDateByHand.has(acc.id),
keptRules,
};
});
return { firm: f.name, connected: true, accounts };
return { firmId: f.id, firm: f.name, connected, accounts };
});
return NextResponse.json(state);
+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();
}
+9
View File
@@ -23,6 +23,10 @@ interface ViewportMetrics {
screenY: number;
chromeHeight: number;
devicePixelRatio: number;
/** Stamped by the extension. Chrome keeps running the old service worker
* until an unpacked extension is reloaded by hand, so this is the only way
* to tell from here whether a reload actually took. */
extVersion: string;
}
interface Capture {
@@ -452,6 +456,11 @@ export default function AutoBuyer() {
<span className="text-sm font-medium text-slate-800">{capture.title || '(untitled)'}</span>
<span className="text-xs font-mono text-slate-400 break-all">{capture.url}</span>
<span className="ml-auto flex items-center gap-3 text-xs text-slate-400">
{capture.viewport?.extVersion && (
<span title="Extension version that sent this capture">
ext v{capture.viewport.extVersion}
</span>
)}
<span>{(capture.html.length / 1024).toFixed(1)} KB</span>
<span className={stale ? 'text-amber-600' : ''}>
{new Date(capture.capturedAt).toLocaleTimeString()}
+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>
);
}
+80 -564
View File
@@ -1,226 +1,50 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import { useRouter, useParams } from 'next/navigation';
import type { FirmConfig } from '@/types';
const SIZE_PRESETS = [5_000, 10_000, 25_000, 50_000, 75_000, 100_000, 150_000];
interface AccountConfig {
id: number;
prefix: string;
profitTarget: number;
consistency: number;
minDayPnL: number;
minTradingDays: number;
accountSize: number;
maxPositionSize: number;
targetSameEquity: boolean;
withdrawalStages: { profit: number; consistency: number; minTradingDays: number }[];
}
interface FirmConfig {
id: number;
firm: string;
accounts: AccountConfig[];
}
interface RowState extends AccountConfig {
dirty: boolean;
saving: boolean;
error: string;
useCustomSize: boolean;
confirmDelete: boolean;
}
function initRow(acc: AccountConfig): RowState {
return {
...acc,
dirty: false,
saving: false,
error: '',
useCustomSize: !SIZE_PRESETS.includes(acc.accountSize),
confirmDelete: false,
};
}
function newRow(): RowState {
return {
id: -(Date.now()),
prefix: '',
profitTarget: 3000,
consistency: 0.5,
minDayPnL: -999,
minTradingDays: 5,
accountSize: 50_000,
maxPositionSize: 0,
targetSameEquity: true,
withdrawalStages: [],
dirty: true,
saving: false,
error: '',
useCustomSize: false,
confirmDelete: false,
};
}
/**
* Read-only view of one firm's account types and banned symbols.
*
* Both are owned by the master dashboard now. This page renders from the local
* catalog cache rather than linking straight out to the master, because the
* moment you most want to know what an account type says is the moment the
* master is unreachable.
*/
function fmtSize(n: number) {
return n >= 1000 ? `${n / 1000}k` : String(n);
}
function TrashIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14H6L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4h6v2" />
</svg>
);
function fmtMoney(n: number) {
return `$${n.toLocaleString(undefined, { maximumFractionDigits: 2 })}`;
}
const inputCls =
'border border-slate-200 rounded-md px-2 py-1.5 text-sm text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-400 bg-white w-full';
export default function FirmSettingsPage() {
const router = useRouter();
const params = useParams();
const id = params.id as string;
const [firmName, setFirmName] = useState('');
const [rows, setRows] = useState<RowState[]>([]);
const [firm, setFirm] = useState<FirmConfig | null>(null);
const [masterUrl, setMasterUrl] = useState<string | null>(null);
const [loadError, setLoadError] = useState('');
// Instruments (globally enabled only) and per-firm bans
const [instruments, setInstruments] = useState<string[]>([]);
const [bannedSymbols, setBannedSymbols] = useState<Set<string>>(new Set());
useEffect(() => {
fetch(`/api/firms/${id}`)
.then((r) => {
if (!r.ok) throw new Error('Not found');
return r.json() as Promise<FirmConfig>;
})
.then((firm) => {
setFirmName(firm.firm);
setRows(firm.accounts.map(initRow));
})
.then(setFirm)
.catch(() => setLoadError('Failed to load firm'));
// Load globally-enabled instruments
fetch('/api/instruments')
fetch('/api/firm-catalog')
.then((r) => r.json())
.then((data: { symbol: string; enabled: boolean }[]) =>
setInstruments(data.filter((i) => i.enabled).map((i) => i.symbol))
);
// Load this firm's banned symbols
fetch(`/api/firms/${id}/banned-symbols`)
.then((r) => r.json())
.then((syms: string[]) => setBannedSymbols(new Set(syms)));
.then((meta: { masterUrl?: string | null }) => setMasterUrl(meta.masterUrl ?? null))
.catch(() => {});
}, [id]);
async function toggleBan(symbol: string) {
const nowBanned = !bannedSymbols.has(symbol);
setBannedSymbols((prev) => {
const next = new Set(prev);
if (nowBanned) next.add(symbol); else next.delete(symbol);
return next;
});
await fetch(`/api/firms/${id}/banned-symbols`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ symbol, banned: nowBanned }),
});
}
const updateRow = (rowId: number, patch: Partial<RowState>) => {
setRows((prev) =>
prev.map((r) => (r.id === rowId ? { ...r, ...patch, dirty: true } : r))
);
};
const saveRow = async (row: RowState) => {
setRows((prev) =>
prev.map((r) => (r.id === row.id ? { ...r, saving: true, error: '' } : r))
);
try {
const body = {
prefix: row.prefix,
profitTarget: row.profitTarget,
consistency: row.consistency,
minDayPnL: row.minDayPnL,
minTradingDays: row.minTradingDays,
accountSize: row.accountSize,
maxPositionSize: row.maxPositionSize,
targetSameEquity: row.targetSameEquity,
withdrawalStages: row.withdrawalStages,
};
if (row.id < 0) {
const res = await fetch(`/api/firms/${id}/accounts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error('Failed');
const created = await res.json() as AccountConfig;
setRows((prev) =>
prev.map((r) =>
r.id === row.id ? { ...initRow(created), dirty: false } : r
)
);
} else {
const res = await fetch(`/api/account-configs/${row.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error('Failed');
setRows((prev) =>
prev.map((r) =>
r.id === row.id ? { ...r, saving: false, dirty: false, error: '' } : r
)
);
}
} catch {
setRows((prev) =>
prev.map((r) =>
r.id === row.id ? { ...r, saving: false, error: 'Save failed' } : r
)
);
}
};
const confirmDeleteRow = (rowId: number) => {
setRows((prev) =>
prev.map((r) => (r.id === rowId ? { ...r, confirmDelete: true } : r))
);
};
const cancelDeleteRow = (rowId: number) => {
setRows((prev) =>
prev.map((r) => (r.id === rowId ? { ...r, confirmDelete: false } : r))
);
};
const deleteRow = async (row: RowState) => {
if (row.id < 0) {
setRows((prev) => prev.filter((r) => r.id !== row.id));
return;
}
try {
const res = await fetch(`/api/account-configs/${row.id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed');
setRows((prev) => prev.filter((r) => r.id !== row.id));
} catch {
setRows((prev) =>
prev.map((r) =>
r.id === row.id ? { ...r, confirmDelete: false, error: 'Delete failed' } : r
)
);
}
};
if (loadError) {
return (
<div className="min-h-screen bg-slate-50 p-8">
@@ -231,11 +55,12 @@ export default function FirmSettingsPage() {
);
}
const firmsPageUrl = masterUrl ? new URL('/firms', masterUrl).toString() : null;
return (
<div className="min-h-screen bg-slate-50 p-8">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<button
onClick={() => router.push('/')}
@@ -243,403 +68,94 @@ export default function FirmSettingsPage() {
>
← Back
</button>
<h1 className="text-2xl font-bold text-slate-900">{firmName || '…'}</h1>
<h1 className="text-2xl font-bold text-slate-900">{firm?.firm ?? '…'}</h1>
<span className="text-slate-300 text-2xl font-light">/</span>
<span className="text-2xl font-bold text-slate-400">Settings</span>
<span className="text-2xl font-bold text-slate-400">Account Types</span>
</div>
<div className="mb-4 rounded-lg border border-slate-200 bg-white px-4 py-3 text-sm text-slate-600 shadow-sm">
Account types and banned symbols are managed on the master dashboard and pulled from it — this is a
read-only copy of the last successful sync.
{firmsPageUrl && (
<>
{' '}
<a
href={firmsPageUrl}
target="_blank"
rel="noreferrer"
className="text-blue-600 hover:text-blue-700 font-medium"
>
Edit on master →
</a>
</>
)}
</div>
{/* Account Types */}
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
Account Types
</h2>
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm">
<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">Prefix</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Account Size</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Profit Target</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Consistency</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Min Day P&L</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Min Trading Days</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Max Contracts</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">After First W/D</th>
<th className="px-3 py-3 w-32" />
{['Label', 'Prefix', 'Account Size', 'Profit Target', 'Consistency', 'Min Day P&L', 'Min Trading Days', 'Max Contracts', 'After First W/D'].map((h) => (
<th key={h} className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">{h}</th>
))}
</tr>
</thead>
<tbody>
{rows.length === 0 && firmName && (
{firm && firm.accounts.length === 0 && (
<tr>
<td colSpan={9} className="px-4 py-6 text-center text-sm text-slate-400 italic">
No account types yet
No account types — add them on the master dashboard
</td>
</tr>
)}
{rows.map((row) => (
<React.Fragment key={row.id}>
<tr className="border-b border-slate-100 last:border-0">
{/* Prefix */}
<td className="px-4 py-2.5">
<input
className={`${inputCls} font-mono w-32`}
value={row.prefix}
placeholder="e.g. MYACCT"
onChange={(e) => updateRow(row.id, { prefix: e.target.value })}
/>
{firm?.accounts.map((acc) => (
<tr key={acc.id} className="border-b border-slate-100 last:border-0">
<td className="px-4 py-2.5 font-medium text-slate-800">{acc.label}</td>
<td className="px-4 py-2.5 font-mono text-slate-700">{acc.prefix}</td>
<td className="px-4 py-2.5 text-slate-600 tabular-nums">{fmtSize(acc.accountSize)}</td>
<td className="px-4 py-2.5 text-slate-600 tabular-nums">{fmtMoney(acc.profitTarget)}</td>
<td className="px-4 py-2.5 text-slate-600 tabular-nums">{Math.round(acc.consistency * 100)}%</td>
<td className="px-4 py-2.5 text-slate-600 tabular-nums">
{acc.minDayPnL <= -999 ? '—' : fmtMoney(acc.minDayPnL)}
</td>
{/* Account Size */}
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<select
className={`${inputCls} w-24`}
value={row.useCustomSize ? 'custom' : row.accountSize}
onChange={(e) => {
if (e.target.value === 'custom') {
updateRow(row.id, { useCustomSize: true });
} else {
updateRow(row.id, {
accountSize: Number(e.target.value),
useCustomSize: false,
});
}
}}
>
{SIZE_PRESETS.map((s) => (
<option key={s} value={s}>{fmtSize(s)}</option>
<td className="px-4 py-2.5 text-slate-600 tabular-nums">{acc.minTradingDays}</td>
<td className="px-4 py-2.5 text-slate-600 tabular-nums">
{acc.maxPositionSize === 0 ? 'No limit' : acc.maxPositionSize}
</td>
<td className="px-4 py-2.5 text-slate-600">
{acc.targetSameEquity
? 'Same equity'
: acc.withdrawalStages.length === 0
? 'Base values'
: `${acc.withdrawalStages.length} stage${acc.withdrawalStages.length === 1 ? '' : 's'}`}
</td>
</tr>
))}
<option value="custom">Custom</option>
</select>
{row.useCustomSize && (
<input
type="number"
className={`${inputCls} w-28`}
value={row.accountSize || ''}
min={0}
step={1000}
placeholder="e.g. 35000"
onChange={(e) =>
updateRow(row.id, { accountSize: Number(e.target.value) })
}
/>
)}
</div>
</td>
{/* Profit Target */}
<td className="px-4 py-2.5">
<div className="relative flex items-center">
<span className="absolute left-2.5 text-slate-400 text-sm pointer-events-none">$</span>
<input
type="number"
className={`${inputCls} pl-5 w-28`}
value={row.profitTarget}
min={0}
step={100}
onChange={(e) =>
updateRow(row.id, { profitTarget: Number(e.target.value) })
}
/>
</div>
</td>
{/* Consistency */}
<td className="px-4 py-2.5">
<div className="relative flex items-center">
<input
type="number"
className={`${inputCls} pr-6 w-24`}
value={Math.round(row.consistency * 100)}
min={0}
max={100}
step={1}
onChange={(e) =>
updateRow(row.id, { consistency: Number(e.target.value) / 100 })
}
/>
<span className="absolute right-2.5 text-slate-400 text-sm pointer-events-none">%</span>
</div>
</td>
{/* Min Day P&L */}
<td className="px-4 py-2.5">
<div className="relative flex items-center">
<span className="absolute left-2.5 text-slate-400 text-sm pointer-events-none">$</span>
<input
type="number"
className={`${inputCls} pl-5 w-28`}
value={row.minDayPnL <= -999 ? '' : row.minDayPnL}
placeholder="None"
step={100}
onChange={(e) =>
updateRow(row.id, {
minDayPnL: e.target.value === '' ? -999 : Number(e.target.value),
})
}
/>
</div>
</td>
{/* Min Trading Days */}
<td className="px-4 py-2.5">
<input
type="number"
className={`${inputCls} w-20`}
value={row.minTradingDays}
min={0}
step={1}
onChange={(e) =>
updateRow(row.id, { minTradingDays: Number(e.target.value) })
}
/>
</td>
{/* Max Contracts */}
<td className="px-4 py-2.5">
<input
type="number"
className={`${inputCls} w-20`}
value={row.maxPositionSize === 0 ? '' : row.maxPositionSize}
placeholder="No limit"
min={0}
step={1}
onChange={(e) =>
updateRow(row.id, {
maxPositionSize: e.target.value === '' ? 0 : Number(e.target.value),
})
}
/>
</td>
{/* After Withdrawal */}
<td className="px-4 py-2.5">
<label className="flex items-center gap-1.5 cursor-pointer select-none">
<input
type="checkbox"
checked={row.targetSameEquity}
onChange={(e) =>
updateRow(row.id, { targetSameEquity: e.target.checked })
}
className="rounded border-slate-300 text-blue-500 focus:ring-blue-400"
/>
<span className="text-xs text-slate-600">Same equity</span>
</label>
</td>
{/* Actions */}
<td className="px-3 py-2.5">
<div className="flex items-center justify-end gap-1.5">
{row.error && (
<span className="text-xs text-red-500 font-medium mr-1">{row.error}</span>
)}
{row.confirmDelete ? (
<>
<span className="text-xs text-slate-500 mr-0.5">Delete?</span>
<button
onClick={() => deleteRow(row)}
className="px-2 py-1 bg-red-100 hover:bg-red-200 text-red-700 text-xs font-semibold rounded-md transition-colors"
>
Yes
</button>
<button
onClick={() => cancelDeleteRow(row.id)}
className="px-2 py-1 bg-slate-100 hover:bg-slate-200 text-slate-600 text-xs font-semibold rounded-md transition-colors"
>
No
</button>
</>
) : (
<>
<button
onClick={() => saveRow(row)}
disabled={row.saving || !row.dirty}
className={`px-3 py-1 bg-blue-100 hover:bg-blue-200 disabled:opacity-50 text-blue-700 text-xs font-semibold rounded-md transition-colors ${!row.dirty ? 'invisible' : ''}`}
>
{row.saving ? '…' : 'Save'}
</button>
<button
onClick={() => confirmDeleteRow(row.id)}
className="p-1 text-slate-300 hover:text-red-400 hover:bg-red-50 rounded-md transition-colors"
title="Delete account type"
>
<TrashIcon />
</button>
</>
)}
</div>
</td>
</tr>
{/* Stage sub-rows — shown when targetSameEquity is OFF */}
{!row.targetSameEquity && (
<>
{row.withdrawalStages.map((stage, idx) => {
const isLast = idx === row.withdrawalStages.length - 1;
const showPlus = isLast;
return (
<tr key={`${row.id}-stage-${idx}`} className="bg-slate-50/60 border-b border-slate-100 last:border-0">
<td colSpan={9} className="pl-10 pr-4 py-1.5">
<div className="flex items-center gap-3">
<span className="text-xs text-slate-400 w-16 shrink-0">
Stage {idx + 2}{showPlus ? '+' : ''}
</span>
<div className="relative flex items-center">
<span className="absolute left-2 text-slate-400 text-xs pointer-events-none">$</span>
<input
type="number"
className="border border-slate-200 rounded px-1.5 py-1 pl-4 text-xs text-slate-800 focus:outline-none focus:ring-1 focus:ring-blue-400 bg-white w-24"
value={stage.profit}
min={0}
step={100}
placeholder="Profit"
onChange={(e) => {
const next = row.withdrawalStages.map((s, i) =>
i === idx ? { ...s, profit: Number(e.target.value) } : s
);
updateRow(row.id, { withdrawalStages: next });
}}
/>
</div>
{row.consistency > 0 && (
<div className="relative flex items-center">
<input
type="number"
className="border border-slate-200 rounded px-1.5 py-1 pr-5 text-xs text-slate-800 focus:outline-none focus:ring-1 focus:ring-blue-400 bg-white w-20"
value={Math.round(stage.consistency * 100)}
min={0}
max={100}
step={1}
placeholder="Cons%"
onChange={(e) => {
const next = row.withdrawalStages.map((s, i) =>
i === idx ? { ...s, consistency: Number(e.target.value) / 100 } : s
);
updateRow(row.id, { withdrawalStages: next });
}}
/>
<span className="absolute right-1.5 text-slate-400 text-xs pointer-events-none">%</span>
</div>
)}
<div className="flex items-center gap-1">
<input
type="number"
className="border border-slate-200 rounded px-1.5 py-1 text-xs text-slate-800 focus:outline-none focus:ring-1 focus:ring-blue-400 bg-white w-12"
value={stage.minTradingDays}
min={0}
step={1}
placeholder="Days"
onChange={(e) => {
const next = row.withdrawalStages.map((s, i) =>
i === idx ? { ...s, minTradingDays: Number(e.target.value) } : s
);
updateRow(row.id, { withdrawalStages: next });
}}
/>
<span className="text-slate-400 text-[10px] shrink-0">days</span>
</div>
<button
onClick={() => {
const next = row.withdrawalStages.filter((_, i) => i !== idx);
updateRow(row.id, { withdrawalStages: next });
}}
className="text-slate-300 hover:text-red-400 text-sm leading-none"
title="Remove stage"
>
×
</button>
</div>
</td>
</tr>
);
})}
<tr className="bg-slate-50/60 border-b border-dashed border-slate-200">
<td colSpan={9} className="pl-10 pr-4 py-1">
<button
onClick={() =>
updateRow(row.id, {
withdrawalStages: [
...row.withdrawalStages,
{ profit: row.profitTarget, consistency: row.consistency, minTradingDays: row.minTradingDays },
],
})
}
className="text-xs text-blue-400 hover:text-blue-600"
>
+ Add stage
</button>
</td>
</tr>
</>
)}
</React.Fragment>
))}
{/* Add row */}
<tr className="border-t border-dashed border-slate-200">
<td colSpan={9} className="px-2 py-2">
<button
onClick={() => setRows((prev) => [...prev, newRow()])}
className="w-full py-1.5 text-sm text-slate-400 hover:text-slate-600 hover:bg-slate-50 rounded-lg transition-colors"
>
+ Add account type
</button>
</td>
</tr>
</tbody>
</table>
</div>
{/* ── Instruments ── */}
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mt-10 mb-3">
Instruments
</h2>
<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…</div>
<div className="bg-white border border-slate-200 rounded-xl p-4 shadow-sm">
{!firm || firm.instruments.length === 0 ? (
<span className="text-sm text-slate-400 italic">None — this firm won&apos;t trade</span>
) : (
<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-right text-xs font-semibold uppercase tracking-wider text-slate-400">Banned</th>
</tr>
</thead>
<tbody>
{instruments.map((sym) => {
const banned = bannedSymbols.has(sym);
return (
<tr key={sym} className="border-b border-slate-100 last:border-0">
<td className="px-4 py-2.5 flex items-center gap-2">
<span className="font-mono font-semibold text-slate-800">{sym}</span>
{banned && (
<span className="text-xs bg-red-100 text-red-700 px-1.5 py-0.5 rounded font-semibold">
{sym} BANNED
<div className="flex flex-wrap gap-1.5">
{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>
)}
</td>
<td className="px-4 py-2.5 text-right">
<button
onClick={() => toggleBan(sym)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none ${
banned ? 'bg-red-500' : 'bg-slate-200'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
banned ? 'translate-x-4' : 'translate-x-1'
}`}
/>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
<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>
+635 -142
View File
File diff suppressed because it is too large Load Diff
+345 -76
View File
@@ -3,9 +3,35 @@
import { useEffect, useState } from 'react';
import Link from 'next/link';
interface Instrument {
/** 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;
enabled: boolean;
listed: boolean;
spreadDollars: number;
spreadLive?: boolean;
spreadBlocked?: boolean;
dailyMoveDollars: number | null;
holdHours: number | null;
disabled: boolean;
chance: number | null;
}[];
};
}[];
}
interface ResolvedContract {
@@ -17,23 +43,70 @@ interface AppSettings {
max_concurrent_accounts: string | null;
master_dashboard_url: string | null;
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');
const [masterUrl, setMasterUrl] = useState<string>('');
const [catalog, setCatalog] = useState<{
version: number;
firmCount: number;
lastSyncAt: string | null;
lastError: string | null;
} | null>(null);
const [syncing, setSyncing] = useState(false);
const [instanceName, setInstanceName] = useState<string>('');
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')
.then((r) => r.json())
.then(setCatalog)
.catch(() => {});
};
const syncCatalogNow = async () => {
setSyncing(true);
try {
await fetch('/api/firm-catalog', { method: 'POST' });
} catch { /* the error lands in lastError */ }
fetchCatalog();
setSyncing(false);
};
useEffect(() => {
fetch('/api/instruments')
.then((r) => r.json())
.then(setInstruments);
.then(setOverview)
.catch(() => {});
fetchCatalog();
fetch('/api/settings')
.then((r) => r.json())
@@ -50,6 +123,17 @@ export default function SettingsPage() {
if (s.instance_name != null) {
setInstanceName(s.instance_name);
}
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
@@ -71,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">
@@ -186,7 +291,11 @@ export default function SettingsPage() {
<div>
<p className="text-sm font-medium text-slate-800">Instance Name</p>
<p className="text-xs text-slate-400 mt-0.5">
Identifier for this autotrader on the master dashboard
Identifier for this autotrader on the master dashboard.
Renaming is safe — the master tracks this machine by its
person ID{instanceId ? (
<> (<span className="font-mono">{instanceId.slice(0, 8)}</span>)</>
) : ' once it has connected'}, so history follows the new name.
</p>
</div>
<input
@@ -213,71 +322,231 @@ export default function SettingsPage() {
className="w-72 rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-sm font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-100">
<div>
<p className="text-sm font-medium text-slate-800">Firm catalog</p>
<p className="text-xs text-slate-400 mt-0.5">
{catalog
? `v${catalog.version} · ${catalog.firmCount} firm${catalog.firmCount === 1 ? '' : 's'} · ${
catalog.lastSyncAt
? `last synced ${new Date(catalog.lastSyncAt).toLocaleTimeString()}`
: 'never synced'
}`
: 'Loading…'}
</p>
{catalog?.lastError && (
<p className="text-xs text-red-600 mt-0.5">{catalog.lastError}</p>
)}
</div>
<button
onClick={syncCatalogNow}
disabled={syncing}
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"
>
{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">
{firm.stats ? (
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse whitespace-nowrap">
<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 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>
{instruments.map((instr) => {
const c = contracts[instr.symbol];
const wasRolled = !!c?.alternative;
{firm.instruments.map((sym) => {
const x = firm.stats!.instruments.find((i) => i.symbol === sym);
const c = contracts[sym];
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}
<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.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'
}`}
<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>
{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>
);
+76 -24
View File
@@ -6,6 +6,7 @@ progress and how they surface failures.
"""
import random
import time
import focus
@@ -14,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):
@@ -22,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
@@ -56,6 +69,19 @@ def check_signed_in(found: dict, signed_out: str) -> None:
# scaled single display.
KNOWN_SCALES = (1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0)
# How long to let something covering the target get out of the way.
#
# Most things that cover a button are on their way out: a modal backdrop
# mid-fade, a captcha overlay closing, a spinner over the button that is about
# to be enabled. One measurement cannot tell those apart from a banner that is
# there to stay, and failing on the first look turns a timing wobble into a
# stopped run halfway through a purchase.
#
# This does not weaken the check — still covered when the time is up still
# fails, and nothing is ever clicked while something is on top of it.
COVERED_SETTLE_SECONDS = 8.0
COVERED_POLL_SECONDS = 0.75
def scale_factor(found: dict, override: float | None, report=None) -> float:
"""CSS pixels and desktop pixels match on macOS and unscaled Windows, but
@@ -103,6 +129,8 @@ def perform(
url: str = "",
open_url: str = "",
signed_out: str = "",
guards: list | None = None,
frame: str = "",
text: str | None = None,
clear: bool = False,
scale: float | None = None,
@@ -127,16 +155,41 @@ def perform(
rng = rng or random.Random()
say = report or (lambda _m: None)
found = dash.locate(selector, index, url, timeout, open_url)
# `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.
frame_opts = {"frame": frame} if frame else None
found = dash.locate(selector, index, url, timeout, open_url, options=frame_opts)
# Before any check that assumes we are on the real page.
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:
say(f"{selector} is covered by {found.get('coveredBy')} — "
f"waiting up to {COVERED_SETTLE_SECONDS:g}s for it to clear")
deadline = time.time() + COVERED_SETTLE_SECONDS
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_guards(found, guards)
if not found.get("covered"):
say("it cleared")
if on_located:
on_located(found)
# Before any check that assumes we are on the real page.
check_signed_in(found, signed_out)
if found.get("covered") and not force:
raise StepError(
f"{selector} is covered by {found.get('coveredBy')} — the click would hit that instead"
f"{selector} is still covered by {found.get('coveredBy')} after "
f"{COVERED_SETTLE_SECONDS:g}s — the click would hit that instead"
)
if action == "type" and not found.get("editable", True) and not force:
@@ -185,7 +238,6 @@ def perform(
return found
# ── type ────────────────────────────────────────────────────────────────
import time
time.sleep(0.15) # let the field take focus and any JS handlers settle
if clear:
@@ -197,8 +249,8 @@ 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)
check_signed_in(after, signed_out)
after = dash.locate(selector, index, url, timeout, open_url, options=frame_opts)
check_guards(after, guards)
got = after.get("value")
if got is None:
say("element exposes no value to verify against")
+170
View File
@@ -0,0 +1,170 @@
"""Solving a page's captcha through the 2captcha API.
The old flow clicked the 2captcha browser extension's own "solve" button and
waited for it to turn green. This replaces it: read the widget's parameters out
of the page, ask 2captcha for a token, put the token back into the page.
Three round trips, all through the dashboard:
runner --locate captchaInfo--> extension reads the widget's sitekey
runner --/api/autobuyer/captcha-> dashboard asks 2captcha, polls, returns a token
runner --locate captchaToken--> extension fills the field and fires the callback
The API key lives on the dashboard, not here — see app/api/autobuyer/captcha.
"""
import time
import actions
from clicker import DashboardError
# 2captcha's own guidance: nothing is ready sooner than this, so the first poll
# is a wasted round trip if it goes out immediately.
FIRST_POLL_SECONDS = 10.0
POLL_SECONDS = 5.0
# A "task expired"/"unsolvable" verdict means this attempt is dead, not that the
# page is unsolvable — a fresh task usually goes through. Bounded so a site that
# genuinely cannot be solved fails the run instead of burning the balance.
MAX_ATTEMPTS = 3
# Timeouts on the two page ops. Reading the widget is a plain injection; posting
# the token runs the page's own callback, which can kick off a network request.
PAGE_TIMEOUT = 30.0
def _page_op(dash, step, opts, options: dict, timeout: float) -> dict:
"""Run one captcha op in the page. The selector is unused by these ops, but
the locate endpoint requires one, so `body` stands in for it."""
return dash.locate(
"body", 0,
step.get("urlPattern", "") or opts.url,
timeout,
step.get("openUrl", "") or "",
options=options,
)
def read_widget(dash, step, opts, provider: str, deadline: float, report) -> dict:
"""Wait for a captcha to exist on the page, then describe it.
Polls rather than reading once: the step that opens the modal returns as soon
as the click lands, and the widget's script still has to load and render.
"""
announced = False
last = "no captcha widget found"
while True:
try:
return _page_op(dash, step, opts, {
"op": "captchaInfo",
"provider": provider,
"scopeSelector": step.get("selector", "") or "",
}, PAGE_TIMEOUT)
except DashboardError as exc:
# Includes "no captcha widget on the page yet", which is the normal
# answer while the widget is still loading.
last = str(exc)
if time.time() >= deadline:
raise actions.StepError(f"no captcha to solve: {last}")
if not announced:
report("waiting for the captcha widget to render")
announced = True
time.sleep(2.0)
def _await_token(dash, task_id, deadline: float, report, cancelled) -> str | None:
"""Poll one 2captcha task. Returns the token, None if the task failed and a
retry is worth it, or raises when the run is out of time."""
time.sleep(min(FIRST_POLL_SECONDS, max(0.0, deadline - time.time())))
while True:
if cancelled and cancelled():
raise actions.StepError("cancelled while waiting for the captcha")
try:
row = dash._request(f"/api/autobuyer/captcha?taskId={task_id}")
except DashboardError as exc:
# A blip talking to our own dashboard should not throw away a task
# that 2captcha is already working on.
report(f"(poll failed, retrying: {exc})")
row = {"status": "processing"}
if row.get("status") == "ready":
return row["token"]
if row.get("status") == "failed":
report(f"2captcha gave up on task {task_id}: {row.get('error')}")
return None
if time.time() >= deadline:
raise actions.StepError(f"2captcha did not return a token in time (task {task_id})")
time.sleep(POLL_SECONDS)
def solve(dash, step, opts, report, cancelled=None) -> None:
"""Run one `solveCaptcha` step. Raises StepError if the page is never solved."""
deadline = time.time() + float(step.get("timeoutSeconds", 180))
provider = step.get("provider") or "auto"
info = read_widget(dash, step, opts, provider, deadline, report)
sitekey = info.get("sitekey") or ""
report(f"{info.get('provider')} widget, sitekey {sitekey[:16]}… (via {info.get('source')})")
started = time.time()
token = None
for attempt in range(1, MAX_ATTEMPTS + 1):
if time.time() >= deadline:
break
queued = dash._request("/api/autobuyer/captcha", "POST", {
"provider": info.get("provider"),
"sitekey": sitekey,
# The page's own URL, not the firm's configured one: 2captcha scores
# the token against the page it was asked for.
"pageurl": info.get("pageurl"),
"action": info.get("action") or "",
"data": info.get("data") or "",
"pagedata": info.get("pagedata") or "",
"invisible": bool(info.get("invisible")),
"enterprise": bool(info.get("enterprise")),
# The browser that will submit the form, so a proxied solve can claim
# the same one rather than 2captcha's default.
"userAgent": info.get("userAgent") or "",
})
task_id = queued.get("taskId")
report(f"2captcha task {task_id} queued ({queued.get('type')} via {queued.get('via')})"
+ (f", attempt {attempt}" if attempt > 1 else ""))
token = _await_token(dash, task_id, deadline, report, cancelled)
if token:
break
if not token:
raise actions.StepError("2captcha could not solve the captcha")
report(f"token in hand after {time.time() - started:.0f}s ({len(token)} chars)")
delivered = _page_op(dash, step, opts, {
"op": "captchaToken",
"provider": info.get("provider"),
"token": token,
}, PAGE_TIMEOUT)
called = delivered.get("called")
fields = delivered.get("fieldIds") or []
keys = delivered.get("clientKeys") or []
report(f"filled {delivered.get('filled', 0)} response field(s)"
+ (f" {fields}" if fields else "")
+ (f", fired {called}" if called else ""))
# Printed whenever the page is carrying more than one widget, because that is
# the state in which handing the token to the wrong one is possible at all —
# and it looks identical to success right up until the button stays dark.
if len(keys) > 1 or (delivered.get("candidates") or 0) > 1:
report(f"page had widgets {keys or '?'}, live one looked like "
f"{delivered.get('liveWidgetId')}")
if not called:
# The token is in the form, so a plain submit still works — but a page
# that gates its submit button on the callback will look unsolved, and
# that is worth saying out loud rather than failing three steps later.
report("no captcha callback found — if the next step fails, that is why")
+245 -101
View File
@@ -24,6 +24,7 @@ import threading
import time
import actions
import captcha
import focus
from clicker import Dashboard, DashboardError, NotFoundError, force_utf8_output
@@ -33,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.16.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()
@@ -56,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")
@@ -71,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":
@@ -79,9 +87,14 @@ def run_one_step(dash: Dashboard, step: dict, opts, rng, report, run_id: int | N
# `absent`, until it is gone. Nothing is clicked or typed. Whatever has to
# change the page — a person solving a challenge, a modal closing itself,
# a slow server — happens outside this run.
#
# `hidden` widens an absent gate from "removed from the DOM" to "no
# longer visible": a modal that closes by going display:none never stops
# matching its selector, so the plain gate can only ever time out.
selector = step["selector"]
absent = bool(step.get("absent"))
goal = "disappear" if absent else "appear"
by_sight = absent and bool(step.get("hidden"))
goal = "disappear from view" if by_sight else "disappear" if absent else "appear"
timeout_s = float(step.get("timeoutSeconds", 120))
deadline = time.time() + timeout_s
announced = False
@@ -91,11 +104,37 @@ def run_one_step(dash: Dashboard, step: dict, opts, rng, report, run_id: int | N
if run_id is not None and run_status(dash, run_id) == "cancelled":
raise actions.StepError("cancelled while waiting")
# A frame-scoped gate resolves the selector inside the named <iframe>;
# `hidden` adds the visibility probe. Either can be set independently.
loc_opts: dict = {}
if by_sight:
loc_opts["op"] = "visible"
if step.get("frame"):
loc_opts["frame"] = step["frame"]
try:
dash.locate(selector, int(step.get("index", 0)),
seen = dash.locate(selector, int(step.get("index", 0)),
step.get("urlPattern", "") or opts.url,
opts.timeout, step.get("openUrl", "") or "")
if not absent:
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
# that as "gone" would wave the gate through on a visible
# element, so insist the answer is the one we asked for.
if seen.get("op") != "visible":
raise actions.StepError(
"waitFor `hidden` needs AutoBuyer extension 0.12.0+ — "
"reload it in chrome://extensions"
)
if not seen.get("visible"):
report(f"{selector} is out of sight ({seen.get('reason') or 'hidden'})")
break
elif not absent:
report(f"{selector} appeared")
break
# Still there. Keep waiting for it to go.
@@ -119,6 +158,14 @@ def run_one_step(dash: Dashboard, step: dict, opts, rng, report, run_id: int | N
announced = True
time.sleep(2.0)
elif step["action"] == "solveCaptcha":
# No mouse: the extension reads the widget, the dashboard buys a token
# from 2captcha, and the extension hands it to the page's own callback.
captcha.solve(
dash, step, opts, report,
cancelled=(lambda: run_status(dash, run_id) == "cancelled") if run_id is not None else None,
)
elif step["action"] == "scrollToLoad":
# Lists that load progressively need walking to the bottom before the
# steps that act on their items can see everything.
@@ -161,7 +208,8 @@ 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")),
scale=opts.scale,
@@ -187,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 ""):
if guard_present(dash, guard, step, opts) is False:
return True
except DashboardError:
pass # mid-redirect the tab can be briefly un-injectable
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}")
@@ -263,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):
outcome = None
try:
run_one_step(dash, step, opts, rng, report, run_id)
break
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)
# 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
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.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)
except (actions.StepError, DashboardError) as exc:
if run_status(dash, run_id) == "cancelled":
print(f"■ run #{run_id} cancelled from the dashboard")
return
# 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.
@@ -338,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")
+18
View File
@@ -0,0 +1,18 @@
# The hostname the update server answers on. It has to match the `update_url` in
# extension/manifest.json — Chrome checks the one baked into the extension, not
# this — and needs an A record pointing at the droplet before bootstrap-tls.sh
# can get a certificate for it.
EXT_HOST=ext.example.com
# Where the signing key and the served files live. Outside the checkout on
# purpose: the private key must never be committed, and a release should survive
# `git clean`.
UPDATES_DIR=/opt/autofirmer-updates
# Where the autotrader stack lives. Its nginx is the shared ingress, its certbot
# issues the certificates, and its compose project owns the network this joins.
SHARED_STACK=/opt/autotrader/deploy
# The docker network that stack created. Only set this if you renamed the
# project — the default matches `docker network ls`.
SHARED_NETWORK=autotrader-vps_web
+180
View File
@@ -0,0 +1,180 @@
# The extension update server
Chrome has no update channel for an unpacked extension: it reads the folder off
disk once and never looks again. This is the supported alternative — the
extension is signed into a CRX, an update manifest is published next to it, and
Chrome installs and updates it from there like any other extension.
```
droplet 159.223.129.19 Windows box
key.pem ──signs──> autofirmer.crx ──HTTPS──> Chrome (policy install)
updates.xml <──polls───
```
Nothing here serves the dashboard. That still runs on the trading machine, next
to the clicker, exactly as before.
## The pieces
| | |
|---|---|
| `/opt/autofirmer-updates/key.pem` | The signing key. **The extension's identity.** |
| `/opt/autofirmer-updates/public/` | `autofirmer.crx` and `updates.xml`, served as-is |
| `deploy/pack-extension.sh` | Signs `extension/` and writes both files |
| `deploy/docker-compose.yml` | An `nginx:alpine` that serves that folder and nothing else |
| `deploy/nginx/ext.conf.template` | Rendered into the autotrader stack's `conf.d` |
| `scripts/install-extension-policy.ps1` | The Windows side: one registry entry |
Extension id: `lkjbblagjjbldlkeaaffhiopdlhniaan`, derived from the public half of
the signing key and repeated in three places that must agree — `key` in
`extension/manifest.json`, `appid` in `updates.xml`, and the forcelist entry in
the policy script. `pack-extension.sh` refuses to publish when the first two
have drifted apart, because the failure otherwise is silent: Chrome simply never
sees an update that applies to it.
**Losing `key.pem` means losing the extension.** A new key is a new id, which
means a new policy entry on every machine and a fresh install rather than an
update. It exists in one place, deliberately — it is not in the repo, and a
compromised copy would let someone else publish an extension your browsers
would trust and install.
## Ingress
This stack owns no ingress. The droplet already runs one nginx on 80/443 for
`master.juicerroom.com` and `git.juicerroom.com`, from `/opt/autotrader/deploy`;
this joins that network, and `deploy.sh` renders a server block into that
stack's `nginx/conf.d`. Two consequences:
- The rendered `ext.conf` is untracked in the autotrader repo. It survives that
stack's pulls and deploys, but not its `bootstrap-tls.sh`, which clears
`conf.d` — re-run `./deploy.sh` here if that ever happens.
- `deploy.sh` reloads the shared nginx, so a mistake in the template would be a
mistake in front of the autotrader dashboard too. It runs `nginx -t` first and
refuses to reload on a bad config, and will not install the TLS server block
until the certificate actually exists.
## First run
```bash
cp .env.example .env # set EXT_HOST
./bootstrap-tls.sh # DNS check, certificate, then deploy.sh
```
`bootstrap-tls.sh` borrows the autotrader stack's certbot container and Let's
Encrypt account, reading `ACME_EMAIL` from its `.env` rather than keeping a
second copy. The A record has to point at the droplet first.
## Publishing a release
Automatic. `autofirmer-publish.timer` fetches every minute and publishes when
master moves — the same shape as the autotrader stack's auto-deploy next door.
Install it once:
```bash
./deploy/install-autopublish.sh # as root
```
Only changes under `extension/` or `deploy/` trigger a publish; anything else is
pulled and left alone, because repacking for a README edit would republish a
byte-identical CRX every minute.
**Bump `version` in `extension/manifest.json` for every release.** Chrome ignores
a CRX that is not strictly newer than the copy it has, so republishing at the
same version is a silent no-op — the kind discovered days later, when a fix
everyone believed had shipped turns out never to have left the building.
`auto-publish.sh` refuses that case outright and logs why, rather than letting
it pass quietly.
By hand, any time:
```bash
git pull && ./deploy/deploy.sh
```
Watch it with `tail -f deploy/publish.log`, and `cat deploy/.last-check` for the
heartbeat — that file is rewritten on every run, whether or not anything came
down, which is the only way to tell "running, nothing to do" from "not running".
The browsers pick it up on Chrome's own schedule, roughly every five hours. They
also pick it up within seconds if the dashboard is running, because the
extension asks Chrome for a check as soon as the dashboard reports a newer
version than the one it is running.
## Installing it on a machine
`setup-windows.bat` and `setup-linux.sh` both offer this as their last step, so
a fresh instance needs nothing extra. To do it by hand, or on a machine that was
set up before this existed:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install-extension-policy.ps1
```
```bash
sudo scripts/install-extension-policy.sh
```
Both configure every Chromium-family browser they find, and narrowing that to
one is `-Browser Chrome`.
### The machine has to be managed
This is the constraint the whole approach turns on, and it is worth stating
plainly. On Windows, Chromium force-installs an extension from outside its own
web store **only** when the instance is joined to an Active Directory domain,
joined to Entra ID, or enrolled in Chrome Enterprise Core. Edge says the same of
the Microsoft Store. On an ordinary machine the policy is read and then blanked:
```
Value ["[BLOCKED]lkjbblagjjbldlkeaaffhiopdlhniaan;https://ext.juicerroom.com/updates.xml"]
Error Error at ExtensionInstallForcelist[0]: Invalid extension ID.
Warning This computer is not detected as enterprise managed so policy can only
automatically install extensions hosted on the Chrome Webstore.
```
The "invalid extension ID" is just `[BLOCKED]` being parsed as the id. Nothing
about the CRX, the update manifest or the hive is wrong — the machine is what
Chrome is refusing. Both scripts check for this and write nothing rather than
leaving a permanent policy error and a "Managed by your organization" badge
behind; `-Force` overrides.
So the update server is only half a solution on an unmanaged box. The other half
is one of:
- **Load `extension/` unpacked.** No update channel, but it reloads itself from
disk when the dashboard reports a newer version, and the updater has already
pulled those files. Works today, any browser, nothing to enrol.
- **Enrol Chrome in Chrome Enterprise Core** (free, and it does not require a
domain-joined machine): one `CloudManagementEnrollmentToken` makes the browser
managed, and everything here then works as designed. Generate a token in the
Admin console under Devices > Chrome > Managed browsers, then:
```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\enroll-chrome-management.ps1 -Token <token>
```
```bash
sudo scripts/enroll-chrome-management.sh <token>
```
Both need admin/root — the token is machine-scoped — and both are Chrome only;
Chromium has no cloud-management support and Edge is not covered by any of
this. Once enrolled you can either keep using `install-extension-policy`, or
force-install the extension from the Admin console instead, which reaches
every enrolled machine at once.
- **Publish to a web store.** Store-hosted extensions update themselves with no
policy at all — at the price of a listing and a review on every release.
The Windows script writes to the current user's policy hive and needs no
elevation; `-Scope Machine` covers every account on the box and does need an
elevated shell. Linux has no per-user equivalent — Chrome only reads managed
policy from `/etc` — so that one is root-only and machine-wide. Both take
`-Remove` / `--remove`.
Afterwards, remove the unpacked copy from `chrome://extensions` — it now has the
same id, and Chrome will not load both — and restart the browser.
Development still works the old way: load `extension/` unpacked on a machine
with no policy entry. It keeps the same id (that is what `key` in the manifest
is for), and there it reloads itself from disk rather than asking Chrome for an
update.
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
#
# Publish a new CRX when master moves. Run by a systemd timer.
#
# Bash rather than Node, matching the autotrader stack next door: nothing on
# this host runs outside Docker, so there is no Node to depend on.
#
# Only a change under extension/ or deploy/ is worth acting on — the dashboard
# and the clicker are pulled by each instance itself, and repacking for a README
# edit would publish a byte-identical CRX every few minutes.
#
# ./auto-publish.sh # normal
# ./auto-publish.sh --dry-run # report only
set -euo pipefail
cd "$(dirname "$0")/.."
ROOT="$(pwd)"
BRANCH="${AUTOPUBLISH_BRANCH:-master}"
LOG="$ROOT/deploy/publish.log"
LAST_CHECK="$ROOT/deploy/.last-check"
LOCK="$ROOT/deploy/.publish.lock"
PUBLISHED_XML="${UPDATES_DIR:-/opt/autofirmer-updates}/public/updates.xml"
STALE_LOCK_SECS=3600
LOG_MAX_BYTES=262144
LOG_KEEP_LINES=500
DRY_RUN=0
[ "${1:-}" = "--dry-run" ] && DRY_RUN=1
log() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" | tee -a "$LOG"; }
record() { printf '%s %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" > "$LAST_CHECK" 2>/dev/null || true; }
# Trimmed once per run, before anything is written: the no-change path writes
# nothing, so this is the only moment the file can have grown.
if [ -f "$LOG" ] && [ "$(stat -c%s "$LOG")" -gt "$LOG_MAX_BYTES" ]; then
tail -n "$LOG_KEEP_LINES" "$LOG" > "$LOG.tmp" && mv "$LOG.tmp" "$LOG"
fi
if [ "$DRY_RUN" -eq 0 ] && [ -f "$LOCK" ]; then
LOCK_AGE=$(( $(date +%s) - $(cat "$LOCK" 2>/dev/null || echo 0) ))
if [ "$LOCK_AGE" -lt "$STALE_LOCK_SECS" ]; then
exit 0 # already publishing; say nothing
fi
log "clearing a stale lock (${LOCK_AGE}s old)"
fi
[ "$DRY_RUN" -eq 0 ] && date +%s > "$LOCK"
OUTCOME="interrupted"
cleanup() { [ "$DRY_RUN" -eq 0 ] && { rm -f "$LOCK"; record "$OUTCOME"; }; }
trap cleanup EXIT
git fetch origin "$BRANCH" --quiet
LOCAL=$(git rev-parse HEAD)
REMOTE=$(git rev-parse "origin/$BRANCH")
if [ "$LOCAL" = "$REMOTE" ]; then
OUTCOME="up to date at ${LOCAL:0:8}"
exit 0 # the common path: silent
fi
log "update available: ${LOCAL:0:8} -> ${REMOTE:0:8}"
CHANGED=$(git diff --name-only "$LOCAL" "$REMOTE")
TOUCHES_RELEASE=0
echo "$CHANGED" | grep -qE '^(extension|deploy)/' && TOUCHES_RELEASE=1
if [ "$DRY_RUN" -eq 1 ]; then
log "dry run - would pull; release-relevant changes: $TOUCHES_RELEASE"
exit 0
fi
git pull --ff-only origin "$BRANCH" --quiet
if [ "$TOUCHES_RELEASE" -eq 0 ]; then
# Pulled so the checkout stays current, but nothing that ships changed.
OUTCOME="pulled ${REMOTE:0:8}, nothing to publish"
log "$OUTCOME"
exit 0
fi
# The guard worth having. Chrome ignores a CRX that is not strictly newer than
# what it already has, so republishing at the same version is a silent no-op —
# the kind you discover days later, when a fix everyone believed had shipped
# turns out never to have left the building.
VERSION=$(python3 -c 'import json;print(json.load(open("extension/manifest.json"))["version"])')
LIVE=$(sed -n 's/.*[^a-z]version="\([0-9][^"]*\)".*/\1/p' "$PUBLISHED_XML" 2>/dev/null | head -1)
if echo "$CHANGED" | grep -qE '^extension/' && [ "$VERSION" = "$LIVE" ]; then
log "REFUSED: extension/ changed but manifest.json is still $VERSION, which is"
log " already published. Chrome would ignore the new CRX. Bump the"
log " version and push again."
OUTCOME="refused - version $VERSION not bumped"
exit 1
fi
if ./deploy/deploy.sh >> "$LOG" 2>&1; then
OUTCOME="published $VERSION at ${REMOTE:0:8}"
log "$OUTCOME"
else
log "ABORTED: deploy.sh failed - the previous CRX is still being served"
OUTCOME="publish failed - not switched"
exit 1
fi
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=Publish a new AutoFirmer CRX when Gitea's master branch moves
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
WorkingDirectory=/opt/autofirmer
ExecStart=/opt/autofirmer/deploy/auto-publish.sh
# Packing pulls a node image on a cold cache; well inside this.
TimeoutStartSec=1800
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=Check Gitea for AutoFirmer extension releases every minute
[Timer]
OnBootSec=2min
OnUnitActiveSec=60s
AccuracySec=5s
Unit=autofirmer-publish.service
[Install]
WantedBy=timers.target
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# First run only: get a certificate for EXT_HOST, then hand off to deploy.sh.
#
# This uses the autotrader stack's certbot container and its Let's Encrypt
# account, so it agrees to the Subscriber Agreement the same way that stack's
# own bootstrap did (certbot --agree-tos):
# https://letsencrypt.org/repository/
set -euo pipefail
cd "$(dirname "$0")"
log() { printf '\n\033[1;34m==> %s\033[0m\n' "$1"; }
fail() { printf '\n\033[1;31mERROR: %s\033[0m\n' "$1" >&2; exit 1; }
[ -f .env ] || fail "no .env — copy .env.example to .env and fill it in"
set -a; . ./.env; set +a
: "${EXT_HOST:?set EXT_HOST in .env}"
SHARED_STACK="${SHARED_STACK:-/opt/autotrader/deploy}"
# ACME_EMAIL is not duplicated here: it belongs to the account that already
# holds the other certificates on this box.
[ -f "$SHARED_STACK/.env" ] || fail "no .env under $SHARED_STACK"
ACME_EMAIL=$(grep -E '^ACME_EMAIL=' "$SHARED_STACK/.env" | cut -d= -f2-)
[ -n "$ACME_EMAIL" ] || fail "ACME_EMAIL is not set in $SHARED_STACK/.env"
# Let's Encrypt rate-limits failures, so check DNS before spending an attempt.
#
# This box's own resolver is the one thing this cannot really trust: a record
# that used to point somewhere else stays cached here long after the world can
# see the new one. SKIP_DNS_CHECK=1 is for exactly that — Let's Encrypt resolves
# the name itself anyway, so the check is a courtesy, not a gate.
if [ "${SKIP_DNS_CHECK:-0}" != "1" ]; then
log "Checking DNS"
ip=$(curl -fsS --max-time 5 http://169.254.169.254/metadata/v1/interfaces/public/0/ipv4/address 2>/dev/null \
|| curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null) \
|| fail "could not determine this droplet's public IP"
got=$(getent ahostsv4 "$EXT_HOST" 2>/dev/null | awk 'NR==1{print $1}') || true
echo " droplet: $ip"
echo " $EXT_HOST -> ${got:-<unresolved>}"
[ "$got" = "$ip" ] || fail "$EXT_HOST does not resolve to $ip here — fix DNS, wait for it to propagate, or re-run with SKIP_DNS_CHECK=1 if you know this box's cache is stale"
fi
# The challenge is served over plain HTTP by the shared nginx, which needs a
# server block for this name before it will answer for it.
log "Rendering the HTTP-only config so the challenge can be served"
envsubst '${EXT_HOST}' < nginx/ext-acme.conf.template > "$SHARED_STACK/nginx/conf.d/ext.conf"
docker exec nginx nginx -t
docker exec nginx nginx -s reload
log "Requesting a certificate for $EXT_HOST"
docker compose --project-directory "$SHARED_STACK" -f "$SHARED_STACK/docker-compose.yml" \
run --rm --entrypoint certbot certbot \
certonly --webroot -w /var/www/certbot \
--email "$ACME_EMAIL" --agree-tos --no-eff-email \
--non-interactive -d "$EXT_HOST" \
|| fail "certbot failed — check DNS and that port 80 reaches this droplet"
log "Switching to the TLS config"
exec ./deploy.sh
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Pack the current extension, serve it, and point the shared nginx at it.
# Safe to re-run any time — this is the normal "publish a release" command.
set -euo pipefail
cd "$(dirname "$0")"
log() { printf '\n\033[1;34m==> %s\033[0m\n' "$1"; }
fail() { printf '\n\033[1;31mERROR: %s\033[0m\n' "$1" >&2; exit 1; }
[ -f .env ] || fail "no .env — copy .env.example to .env and fill it in"
set -a; . ./.env; set +a
: "${EXT_HOST:?set EXT_HOST in .env}"
SHARED_STACK="${SHARED_STACK:-/opt/autotrader/deploy}"
command -v envsubst >/dev/null || fail "envsubst missing — apt-get install -y gettext-base"
[ -d "$SHARED_STACK/nginx/conf.d" ] || fail "no nginx/conf.d under $SHARED_STACK"
./pack-extension.sh
log "Starting the file server"
docker compose up -d
# Rendered into the *other* stack's conf.d, because that is the directory its
# nginx has mounted. The file is untracked there, and survives that stack's
# pulls and deploys; only its bootstrap-tls.sh wipes conf.d, and re-running this
# script puts it back.
#
# Held back until the certificate exists: nginx refuses to load a server block
# whose ssl_certificate is missing, and a broken file in that directory would
# also abort the autotrader deploys that reload the same nginx.
if docker run --rm -v autotrader-vps_letsencrypt:/le alpine \
test -f "/le/live/$EXT_HOST/fullchain.pem" 2>/dev/null; then
template=nginx/ext.conf.template
else
log "No certificate for $EXT_HOST yet — rendering the HTTP-only config"
log "Run ./bootstrap-tls.sh once DNS points here, then this script again"
template=nginx/ext-acme.conf.template
fi
log "Rendering $template"
# Named explicitly so envsubst leaves nginx's own $host, $remote_addr and the
# rest alone.
envsubst '${EXT_HOST}' < "$template" > "$SHARED_STACK/nginx/conf.d/ext.conf"
log "Reloading the shared nginx"
docker exec nginx nginx -t
docker exec nginx nginx -s reload
log "Status"
docker compose ps
+23
View File
@@ -0,0 +1,23 @@
# A file server and nothing else: two static files, no application, no database.
# It joins the autotrader stack's network so the nginx already on 80/443 can
# proxy to it — this publishes no host port of its own.
name: autofirmer-updates
services:
updates:
image: nginx:alpine
container_name: autofirmer-updates
restart: unless-stopped
volumes:
- ${UPDATES_DIR:-/opt/autofirmer-updates}/public:/srv:ro
- ./nginx/updates-static.conf:/etc/nginx/conf.d/default.conf:ro
expose:
- "80"
networks: [web]
networks:
# Created by the autotrader stack. `external` so this compose file never tries
# to own it — bringing this down must not take the ingress with it.
web:
name: ${SHARED_NETWORK:-autotrader-vps_web}
external: true
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Install the systemd timer that publishes a new CRX when master moves.
# Run as root on the droplet: ./deploy/install-autopublish.sh
set -euo pipefail
cd "$(dirname "$0")/.."
[ "$(id -u)" -eq 0 ] || { echo "run as root" >&2; exit 1; }
install -m 644 deploy/autofirmer-publish.service /etc/systemd/system/
install -m 644 deploy/autofirmer-publish.timer /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now autofirmer-publish.timer
echo
echo " installed. next run:"
systemctl list-timers autofirmer-publish.timer --no-pager | sed -n '2p'
echo
echo " logs: tail -f /opt/autofirmer/deploy/publish.log"
echo " heartbeat: cat /opt/autofirmer/deploy/.last-check"
echo " disable: systemctl disable --now autofirmer-publish.timer"
+16
View File
@@ -0,0 +1,16 @@
# HTTP only, for the window before a certificate exists: nginx will not start
# with an ssl_certificate that is not on disk yet, so the real config cannot go
# in until certbot has run. Rendered by bootstrap-tls.sh.
server {
listen 80;
listen [::]:80;
server_name ${EXT_HOST};
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 503;
}
}
+45
View File
@@ -0,0 +1,45 @@
# The extension update server — rendered into the autotrader stack's nginx/conf.d
# by deploy.sh. Edit the template, not the rendered file.
server {
listen 80;
listen [::]:80;
server_name ${EXT_HOST};
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name ${EXT_HOST};
ssl_certificate /etc/letsencrypt/live/${EXT_HOST}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/${EXT_HOST}/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Content-Type-Options "nosniff" always;
# Two public files: the update manifest and the signed CRX. Both are meant to
# be readable by anyone — Chrome fetches them with no cookies and no way to
# authenticate, which is why there is no auth here. The CRX is signed, and
# carries no credentials; the private key stays on the droplet.
location / {
proxy_pass http://autofirmer-updates/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+25
View File
@@ -0,0 +1,25 @@
# Inside the little file-server container. Not the public-facing config — that
# is ext.conf.template, rendered into the shared nginx.
server {
listen 80;
root /srv;
# Replaces the whole MIME table, which is fine when the whole site is two
# files. Chrome is happy to take a CRX as octet-stream, but the updater logs
# are much easier to read when the type is right.
types {
application/x-chrome-extension crx;
text/xml xml;
}
default_type application/octet-stream;
# The update manifest is the thing Chrome polls to notice a release; a cached
# copy is a release that does not land. The CRX itself is named by version
# inside that manifest, so caching it would be safe — but it is one small
# file fetched once per update, and a single rule is easier to trust.
add_header Cache-Control "no-store" always;
location / {
autoindex off;
}
}
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Sign extension/ into a CRX and write the update manifest Chrome polls.
#
# The signing key is the extension's identity: the id Chrome derives from it is
# baked into every installed copy and into the Windows policy. Sign with a
# different key and you have published a different extension that no browser
# will ever accept as an update — so this refuses to mint one by accident.
set -euo pipefail
cd "$(dirname "$0")"
log() { printf '\n\033[1;34m==> %s\033[0m\n' "$1"; }
fail() { printf '\n\033[1;31mERROR: %s\033[0m\n' "$1" >&2; exit 1; }
[ -f .env ] || fail "no .env — copy .env.example to .env and fill it in"
set -a; . ./.env; set +a
: "${EXT_HOST:?set EXT_HOST in .env}"
UPDATES_DIR="${UPDATES_DIR:-/opt/autofirmer-updates}"
KEY="$UPDATES_DIR/key.pem"
PUBLIC="$UPDATES_DIR/public"
EXTENSION="$(cd .. && pwd)/extension"
[ -f "$KEY" ] || fail "no signing key at $KEY. A new one would change the extension id and orphan every install. See deploy/README.md before generating one."
mkdir -p "$PUBLIC"
VERSION=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "$EXTENSION/manifest.json")
[ -n "$VERSION" ] || fail "could not read the version out of the manifest"
# The id is the first 16 bytes of the SHA-256 of the public key, each hex digit
# shifted from 0-9a-f into a-p. Derived from the *manifest* here so it can be
# checked against what the packer writes: if the manifest's `key` and the
# signing key are not a pair, updates silently never apply, and this is the only
# moment that mismatch is visible.
manifest_id() {
python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("key",""))' "$EXTENSION/manifest.json" \
| base64 -d 2>/dev/null | openssl dgst -sha256 -binary | xxd -p -c32 | cut -c1-32 | tr '0-9a-f' 'a-p'
}
log "Packing v$VERSION"
# Node lives in a container so the droplet needs no toolchain of its own. The
# key is mounted read-only: crx3 writes a new one when it cannot find the file,
# and that is the one failure mode worth making impossible.
docker run --rm \
-v "$EXTENSION:/ext:ro" \
-v "$KEY:/key.pem:ro" \
-v "$PUBLIC:/out" \
node:24-slim sh -c "cd /tmp && npm i --silent crx3@1 >/dev/null 2>&1 && \
node_modules/.bin/crx3 -p /key.pem -o /out/autofirmer.crx -x /out/updates.xml \
--crxURL https://$EXT_HOST/autofirmer.crx --appVersion '$VERSION' -- /ext" \
|| fail "packing failed"
EXPECTED=$(manifest_id)
if [ -n "$EXPECTED" ] && ! grep -q "appid=\"$EXPECTED\"" "$PUBLIC/updates.xml"; then
fail "the manifest's \`key\` and $KEY are not a pair — the update manifest advertises a different extension than the one people have installed"
fi
log "Published"
echo " id: ${EXPECTED:-<no key in manifest>}"
echo " version: $VERSION"
echo " crx: https://$EXT_HOST/autofirmer.crx"
echo " updates: https://$EXT_HOST/updates.xml"
+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:
+75 -2
View File
@@ -32,7 +32,7 @@ captures the tab you are on.
## Scope
Capture has no switch, so scope is what keeps it honest. Two limits, both
Capture has no switch, so scope is what keeps it honest. Three limits, all
enforced by Chrome rather than by this code:
1. **`host_permissions` is deliberately narrow** — the firm sites plus localhost
@@ -43,11 +43,84 @@ enforced by Chrome rather than by this code:
last-focused window *and* a host_permissions match. A firm tab sitting in the
background is not captured, and neither is anything else. Switch away and
capture simply stops until you switch back.
3. **One content script, same hosts.** `captcha-hook.js` runs at document_start
on the firm sites only. It wraps `turnstile.render` / `grecaptcha.render` /
`hcaptcha.render` and remembers what the page passed them — the sitekey, and
above all the callback a solved token has to be handed to, which a
programmatically rendered widget puts nowhere else. It reads nothing else and
sends nothing anywhere; the remembered arguments stay in the page's own world
until a `solveCaptcha` step comes to collect them.
To automate a different broker, add its pattern to `host_permissions` in
`manifest.json` and reload the extension — that alone scopes capture to it. Avoid
going back to `<all_urls>`: it would put every tab you visit in reach, and limit
1 is the only thing standing between this and scraping your whole browser.
1 is the only thing standing between this and scraping your whole browser. A new
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
+1256 -15
View File
File diff suppressed because it is too large Load Diff
+69
View File
@@ -0,0 +1,69 @@
/* AutoFirmer — captcha render hook.
*
* Runs in the page's MAIN world at document_start, before any captcha script
* loads. It wraps `turnstile.render` / `grecaptcha.render` / `hcaptcha.render`
* and records what the page passed to them on `window.__autofirmerCaptcha`.
*
* Why bother, when the sitekey is usually a `data-sitekey` attribute: a widget
* rendered programmatically puts NOTHING in the DOM — no sitekey, and more
* importantly no callback. The callback is the whole point. Filling the hidden
* response field with a solved token does not by itself tell the page a token
* arrived; the page only reacts when its own callback runs with it. Turnstile in
* particular hands that callback straight to render() and never exposes it
* anywhere else, so a hook installed before the widget renders is the only way
* to hold on to it.
*
* Reads nothing and sends nothing. It only remembers arguments, in the page's
* own world, for background.js to use when a token comes back from 2captcha.
*/
(() => {
const registry = (window.__autofirmerCaptcha = window.__autofirmerCaptcha || { widgets: [] });
/** Wrap `render` on a captcha API object, once. */
function wrap(kind, api) {
if (!api || typeof api !== 'object' || api.__autofirmerWrapped) return api;
if (typeof api.render === 'function') {
const original = api.render.bind(api);
api.render = function (container, params) {
const widgetId = original(container, params);
// `params` holds the sitekey, the callback, and for Turnstile the
// action / cData / chlPageData that a solve has to be asked for.
registry.widgets.push({ kind, params: params || {}, widgetId });
return widgetId;
};
try {
Object.defineProperty(api, '__autofirmerWrapped', { value: true });
} catch { /* frozen API object — wrapping still took */ }
}
// reCAPTCHA Enterprise is a second render() hanging off the same object.
if (api.enterprise) wrap(kind, api.enterprise);
return api;
}
/** Wrap whatever the page eventually assigns to `window[name]`.
*
* The script that defines it loads after this one, so there is nothing to
* wrap yet — the accessor waits for the assignment instead. It also catches
* the reCAPTCHA pattern of replacing a `{ready}` placeholder later on. */
function intercept(name, kind) {
let current = wrap(kind, window[name]);
try {
Object.defineProperty(window, name, {
configurable: true,
enumerable: true,
get: () => current,
set: (value) => { current = wrap(kind, value); },
});
} catch {
// The page made the property non-configurable. Detection falls back
// to the DOM, which covers every declaratively rendered widget.
}
}
intercept('turnstile', 'turnstile');
intercept('grecaptcha', 'recaptcha');
intercept('hcaptcha', 'hcaptcha');
})();
+27 -2
View File
@@ -1,18 +1,43 @@
{
"manifest_version": 3,
"name": "AutoFirmer Capture",
"version": "0.10.0",
"description": "Scrapes the HTML of the firm tab you are viewing and posts it to the AutoFirmer dashboard.",
"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, 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/*"
],
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": [
"https://*.tradeify.co/*",
"https://*.lucidtrading.com/*"
],
"js": ["captcha-hook.js"],
"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": {
"default_popup": "popup.html",
"default_title": "AutoFirmer Capture"
+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}`);
}
}
}
+363
View File
@@ -0,0 +1,363 @@
/**
* Binds a live Tradovate account to one of its firm's account types.
*
* Matching uses the account's name prefix AND its size together, because two
* account types at one firm can share a prefix and differ only by size. The
* size comes from the account's initial funding transaction, which does not
* arrive with the first sync — until it does the account is Unassigned and the
* resolver simply tries again on the next one.
*
* 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, KeptAccountRules } from './master-protocol';
import type { PlatformClient } from './platform-client';
import { firstFundDate } from './platform-client';
import {
getAssignment,
setAssignment,
setAutoAssignment,
unpinAssignment,
lowerAutoRulesDate,
stampMissingRulesDate,
listAssignments,
loadAccountMeta,
saveAccountMeta,
} from './db';
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;
/** 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 = {
accountTypeId: null,
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. */
export function inferAccountSize(fundTxns: { date: string; amount: number }[]): number | null {
const deposits = fundTxns.filter((t) => t.amount > 0);
if (deposits.length === 0) return null;
return [...deposits].sort((a, b) => a.date.localeCompare(b.date))[0].amount;
}
function sizesToTry(fundTxns: { date: string; amount: number }[]): number[] {
// Earliest deposit first, then every other deposit: an account that was
// reset or refunded can carry more than one, and the first is not always
// the one that equals the account size.
const deposits = [...fundTxns.filter((t) => t.amount > 0)].sort((a, b) => a.date.localeCompare(b.date));
return [...new Set(deposits.map((t) => t.amount))];
}
/** Pure. Exported for the UI's candidate list and for reasoning about ties. */
export function matchAccountType(
accountName: string,
accountSize: number | null,
types: CatalogAccountType[],
): { type: CatalogAccountType | null; ambiguous: boolean } {
const byPrefix = types.filter((t) => accountName.startsWith(t.prefix));
if (byPrefix.length === 0) return { type: null, ambiguous: false };
// 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 };
// More than one match. Most recently updated wins; the rest of the ordering
// exists so the outcome is stable across restarts rather than incidental.
const sorted = [...candidates].sort((a, b) => {
const byUpdated = b.updatedAt.localeCompare(a.updatedAt);
if (byUpdated !== 0) return byUpdated;
const byPrefixLength = b.prefix.length - a.prefix.length;
if (byPrefixLength !== 0) return byPrefixLength;
return a.id.localeCompare(b.id);
});
return { type: sorted[0], ambiguous: true };
}
/** THE account→config lookup. Reads the assignment table; never writes.
* 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) 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 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.
return {
accountTypeId: row.account_type_id,
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: 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: PlatformClient): number | null {
const cached = loadAccountMeta(accountId, INFERRED_SIZE_KEY);
if (cached !== null) {
const value = Number(cached);
if (Number.isFinite(value)) return value;
}
return inferAccountSize(client.fundTransactions?.[accountId] ?? []);
}
/** The rules date a row first written now gets — on the master's clock when
* this one runs behind, and never before the catalog's last change. */
function freshRulesDate(): string {
return stampRulesDate(Date.now(), getMasterOffsetMs(), getCatalogUpdatedAt());
}
/** The broker's evidence of when the account opened (the report's
* firstFundDate, read as lib/kept-rules.ts openingEvidence says); undefined
* until its fund history has loaded, null when that history doesn't say. */
function brokerEvidence(client: PlatformClient, accountId: number): string | null | undefined {
const funds = client.fundTransactions?.[accountId];
if (!funds) return undefined;
return openingEvidence(firstFundDate(funds, undefined), client.brokerAccountMeta?.(accountId)?.registrationTime);
}
/**
* The evidence step for one account, for the trade path: once its fund
* history has loaded, lower an auto rules date to the broker's evidence
* before anything reads the account's rules. The resolver does the same on
* every pass, but only after a sync — and history lands between syncs, when
* an account turns tradable (historyReady needs the fund rows loaded, on
* every platform) and could trade on its first-seen date for up to a sync.
* Once per account per process; a no-op until the history has loaded and the
* account has an assignment row.
*/
export function ensureRulesEvidence(client: PlatformClient, accountId: number): void {
if (evidenceApplied().has(accountId)) return;
const evidence = brokerEvidence(client, accountId);
if (evidence === undefined) return;
const row = getAssignment(accountId);
if (!row) return;
if (evidence && lowersRulesDate(row, evidence)) lowerAutoRulesDate(accountId, evidence);
evidenceApplied().add(accountId);
}
/** Re-resolves every account on one firm's client. Skips manual overrides,
* and auto ones on a kept rule set (see below). Lowers rules dates to the
* broker's evidence on the way. Called after each sync and after the
* catalog changes. */
export function resolveFirmAssignments(
firmId: string,
client: PlatformClient,
): { assigned: number; unassigned: number; manual: number } {
const firm = getCachedFirm(firmId);
const types = firm?.accountTypes ?? [];
const stamp = freshRulesDate();
let assigned = 0;
let unassigned = 0;
let manual = 0;
for (const account of client.accountList) {
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 = 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;
if (cached !== null && Number.isFinite(Number(cached))) {
matchedSize = Number(cached);
matched = matchAccountType(account.name, matchedSize, types);
} else {
for (const size of sizesToTry(fundTxns)) {
const attempt = matchAccountType(account.name, size, types);
if (attempt.type) {
matched = attempt;
matchedSize = size;
break;
}
}
// Fallback while the throttled report fetch hasn't produced fund
// transactions yet: an account that has never traded still sits at
// exactly its opening balance, and balances arrive with the first
// sync. Only a cent-exact match counts — a traded account's balance
// won't equal any type size, so it falls through and waits for the
// reports as before. The matched size is persisted below, so the
// assignment survives once the balance starts moving.
if (!matched.type) {
const balance = client.accountCashBalances[account.id]?.amount;
if (typeof balance === 'number') {
const attempt = matchAccountType(account.name, balance, types);
if (attempt.type) {
matched = attempt;
matchedSize = balance;
}
}
}
}
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, stamp);
assigned++;
} else {
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 };
}
/** 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. The
* row and its rules date stay: unpinning must not make an old account look
* new. */
export function clearManualAssignment(accountId: number): boolean {
return unpinAssignment(accountId);
}
export { listAssignments };
+2251 -427
View File
File diff suppressed because it is too large Load Diff
+249 -59
View File
@@ -15,18 +15,30 @@ export type AutomationStep =
// failing the run — for things that only sometimes appear, like a cookie
// banner or a confirmation modal. Only absence is tolerated: an element that
// is present but covered or off-screen still fails.
| { action: 'click'; selector: string; index?: number; skipIfNotFound?: boolean; urlPattern?: string; label?: string }
| { action: 'type'; selector: string; text: string; index?: number; clear?: boolean; skipIfNotFound?: boolean; urlPattern?: string; label?: string }
// `frame` targets an element inside a cross-origin <iframe> (e.g. the
// WooCommerce checkout modal). Its value is a selector for the <iframe>
// element in the top document; the step's own `selector` is then resolved
// inside that frame and its coordinates offset back to the page. Without it a
// selector only ever matches the top document. Needs extension 0.14.0+ and
// runner 0.19.0+.
| { action: 'click'; selector: string; index?: number; skipIfNotFound?: boolean; urlPattern?: string; frame?: string; label?: string }
| { action: 'type'; selector: string; text: string; index?: number; clear?: boolean; skipIfNotFound?: boolean; urlPattern?: string; frame?: string; label?: string }
| { action: 'wait'; seconds: number; label?: string }
// Point the firm's tab at a page. Omit `url` to use the firm's own. Skipped
// when the tab is already there, so it doesn't reload and lose page state.
| { action: 'navigate'; url?: string; label?: string }
// Block until `selector` exists — or, with `absent`, until it is gone from the
// DOM. Nothing is clicked or typed; this is a gate, for conditions something
// outside the run has to satisfy. Note `absent` means removed, not merely
// hidden: an element still in the DOM with display:none keeps matching, so
// for those use a selector that only matches while it is visible.
| { action: 'waitFor'; selector: string; index?: number; absent?: boolean; timeoutSeconds?: number; label?: string }
// outside the run has to satisfy. Note `absent` alone means removed, not
// merely hidden: an element still in the DOM with display:none keeps
// matching, and the gate would sit there until it timed out.
//
// `hidden` (only meaningful with `absent`) widens the gate to "we cannot see
// it any more" — removed, display:none, visibility:hidden, opacity:0,
// content-visibility, or collapsed to zero size all end the wait. It says
// nothing about being scrolled off-screen or covered by an overlay: those
// are still on the page. Needs extension 0.12.0+ and runner 0.18.0+.
| { action: 'waitFor'; selector: string; index?: number; absent?: boolean; hidden?: boolean; timeoutSeconds?: number; frame?: string; label?: string }
// Scroll until the page stops adding elements matching `selector`, for lists
// that load progressively. `containerSelector` names the scrolling element
// when the automatic guess is wrong — these lists usually scroll inside a div
@@ -39,12 +51,38 @@ export type AutomationStep =
settleMs?: number;
label?: string;
}
// Solve the page's captcha through the 2captcha API.
//
// Replaces the old pair of steps that clicked the 2captcha *extension's* own
// "solve" button and waited for it to go green. That needed a second
// extension installed and signed in on the machine, put a real mouse click on
// a button whose position and markup are 2captcha's to change, and gave the
// run no way to tell "still solving" from "the extension is not there".
//
// This reads the widget's parameters out of the page, has the dashboard ask
// 2captcha for a token, and hands the token back to the page's own callback.
// No mouse involved.
//
// `provider` is normally left off: the widget is detected. Set it when a page
// carries more than one kind. `selector` likewise scopes detection to a
// container when several widgets are on the page at once.
| {
action: 'solveCaptcha';
provider?: 'turnstile' | 'recaptcha' | 'hcaptcha';
selector?: string;
timeoutSeconds?: number;
label?: string;
}
// Run `steps` several times over. `times` fixes the count here; `timesFrom`
// takes it from an input the user fills in on the dashboard. The block is
// unrolled before the runner ever sees it — see resolveSteps.
| { action: 'repeat'; times?: number; timesFrom?: string; steps: AutomationStep[]; label?: string };
/** A number the user supplies on the dashboard before starting a run. */
/**
* NOTE: the `FIRMS` array below is the AutoBuyer's list of prop-firm *websites*
* to click through. It is unrelated to the firm catalog in lib/firm-catalog.ts,
* which is the Tradovate side. Linking the two is future work.
* A number the user supplies on the dashboard before starting a run. */
export interface AutomationInput {
id: string;
label: string;
@@ -72,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;
@@ -80,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;
@@ -89,41 +184,35 @@ 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[];
}
// The AutoBuyer's list of prop-firm *websites* to click through. Unrelated to
// the firm catalog in lib/firm-catalog.ts, which is the Tradovate side — the
// names overlap but nothing joins them. Linking the two is future work.
export const FIRMS: Firm[] = [
{
id: 'tradeify',
label: 'Tradeify',
urlPattern: 'https://app-f.tradeify.co/*',
url: 'https://app-f.tradeify.co/',
signedOutPattern: '/auth/',
signedOutWaitSeconds: 300,
authSteps: [
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: [
{
id: 'buy-accounts',
@@ -140,20 +229,20 @@ export const FIRMS: Firm[] = [
// link — confirmed against a real capture, matchCount 1. The MUI
// hash classes on the same element (mui-*) are regenerated on
// every site build, so they are not safe to select on.
{ action: 'repeat', timesFrom: 'count', steps: [
{ action: 'click', selector: 'a.add_account_btn', label: 'Open Add Account' },
{ action: 'waitFor', selector: 'div.account_types:nth-child(3) > div[role="radiogroup"] > div > div:nth-child(2)', timeoutSeconds: 30, label: 'Wait for form' },
{ action: 'click', selector: 'div.account_types:nth-child(3) > div[role="radiogroup"] > div > div:nth-child(2)'},
{ action: 'click', selector: 'div.account_types:nth-child(7) span:last-child'},
{ action: 'click', selector: '.MuiBox-root:last-child div.account_types:last-child span:last-child'},
{ action: 'click', selector: 'div.summary_section div.MuiTextField-root input'},
{ action: 'type', selector: 'div.summary_section div.MuiTextField-root input', text: 'MX8'},
{ action: 'click', selector: 'div.summary_section div.MuiTextField-root + button'},
{ action: 'click', selector: 'div.captcha-solver[data-state="ready"]'},
{ action: 'waitFor', selector: "div.captcha-solver[data-state='solved']", timeoutSeconds: 120,
label: 'Wait for the challenge' },
{ action: 'click', selector: 'div.summary_section > button:last-child', label: 'Open Add Account' },
{ action: 'solveCaptcha', timeoutSeconds: 180, label: 'Solve the challenge' },
{ action: 'click', selector: 'div.summary_section > button.MuiButton-fullWidth', label: 'Buy Account' },
// TODO: the rest of the purchase flow. Confirm each selector with
// `clicker.py locate` before adding it here.
{ action: 'navigate', label: 'Open the Tradeify dashboard' },
]},
],
},
@@ -169,12 +258,29 @@ export const FIRMS: Firm[] = [
{ action: 'click', selector: 'div.MuiTabs-scroller button:nth-child(2)', label: 'Click Evaluation Tab'},
{ action: 'click', selector: '.tab-head-right .MuiSwitch-colorPrimary.Mui-checked', skipIfNotFound: true, label: 'Show failed accounts' },
// The account list loads a screenful at a time, so a reset
// count larger than the first page would run out of rows to
// click. Walk it to the bottom first. Counting the reset
// buttons rather than the rows means it stops when the thing
// the loop actually clicks stops appearing.
//
// The list scrolls inside dashboard_body, not the document —
// the automatic guess walks up from a match looking for an
// overflow ancestor, and setting scrollTop on the wrong
// element scrolls nothing at all. Named here so it cannot
// guess wrong; confirmed against a live run, which reported
// the container as `dashboard_body MuiBox-root mui-0`.
{
action: 'scrollToLoad',
selector: '.status-failed button.reset_btn',
containerSelector: '.dashboard_body',
label: 'Load every failed account',
},
{ action: 'repeat', timesFrom: 'count', steps: [
// one purchase — the steps you already have
{ action: 'click', selector: '.status-failed button.reset_btn', label: 'Reset Account' },
{ action: 'waitFor', selector: "div.captcha-solver[data-state='ready']", timeoutSeconds: 30, label: 'Wait for the solver' },
{ action: 'click', selector: 'div.captcha-solver[data-state="ready"]'},
{ action: 'waitFor', selector: "div.captcha-solver[data-state='solved']", timeoutSeconds: 120, label: 'Wait for the challenge' },
{ action: 'solveCaptcha', timeoutSeconds: 180, label: 'Solve the challenge' },
{ action: 'click', selector: 'button.cancelBtn + button.modalActionBtn' },
{ action: 'waitFor', selector: '.reset_acc_modal', absent: true, timeoutSeconds: 30, label: 'Wait for the modal to close' },
@@ -190,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/',
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.
signedOutPattern: '//lucidtrading.com/',
signedOutWaitSeconds: 300,
authSteps: [
urlContains: '//lucidtrading.com/',
resume: 'continue',
waitSeconds: 300,
recoverySteps: [
{ action: 'click', selector: 'button.lucid-login-btn', label: 'Login' },
],
},
],
automations: [
{
@@ -212,9 +324,56 @@ export const FIRMS: Firm[] = [
// dashboard, so go there first rather than assuming.
{ action: 'navigate', label: 'Open the Lucid dashboard' },
{ action: 'click', selector: 'a[data-route="/add-account"]', label: 'Open Add Account' },
{ action: 'click', selector: 'button.tab-flex', label: 'Open Flex Accounts' },
{ action: 'repeat', timesFrom: 'count', steps: [
// one purchase — the steps you already have
{ action: 'click', selector: 'a[data-route="/add-account"]', label: 'Open Add Account' },
{ action: 'click', selector: '.slide:nth-child(2) .drawdown-option:last-child', label: 'Disable DLL' },
{ action: 'click', selector: '.slide:nth-child(2) .cta-btn', label: 'Open' },
{ action: 'click', selector: '.platform-btn:first-child', label: 'CQG' },
// The checkout renders in a WooCommerce iframe on
// lucidtrading.com (parent app is dash.lucidtrading.com), so
// every step here targets the frame. The coupon field id
// varies between WooCommerce's default (#coupon_code /
// name="coupon_code") and this template's custom id; list
// both so one of them matches. Apply is WooCommerce's
// named submit button, not a positional sibling.
{ action: 'waitFor', selector: 'button#apply_coupon_button', frame: '#woocommerceModal iframe', label: 'Wait for apply coupon button to open' },
{ action: 'click', selector: 'input#coupon_code, input[name="coupon_code"], input#coupon_code_field', frame: '#woocommerceModal iframe', label: 'Focus coupon field' },
{ action: 'type', selector: 'input#coupon_code, input[name="coupon_code"], input#coupon_code_field', frame: '#woocommerceModal iframe', text: 'THEPROPFARM', label: 'Enter coupon' },
{ action: 'click', selector: 'button#apply_coupon_button', frame: '#woocommerceModal iframe', label: 'Apply coupon' },
// Target the checkbox itself, not the .validate-required
// row: that class is on every required field (name, email,
// …), so its first match is usually a text input, and even
// on the terms row the click lands in the empty right half
// of a full-width <p> rather than on the box. `:not(:checked)`
// makes the two clicks self-sequence — the first tick checks
// box one, so the second click resolves to box two — and
// re-clicking never un-ticks a box already checked.
//
// WooCommerce only stamps validate-required on its own
// required rows; the second agreement box here is theme
// markup without that class, so the second click also
// sweeps the whole checkout form for unchecked boxes —
// minus the ones that must never be ticked (different
// shipping address expands extra fields, mailing-list
// 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: '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' },
]},
],
},
@@ -266,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 {
@@ -275,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') {
@@ -286,6 +454,11 @@ function resolveOne(firm: Firm, step: AutomationStep): ResolvedStep {
// Acts on nothing, so no signed-out guard — same treatment as waitFor.
return { ...step, urlPattern: firm.urlPattern, openUrl: firm.url };
}
if (step.action === 'solveCaptcha') {
// No guard either, and deliberately: a login form is exactly where a
// captcha shows up, so auth steps have to be able to solve one.
return { ...step, urlPattern: firm.urlPattern, openUrl: firm.url };
}
if (step.action === 'repeat') {
// expand() peels these off first; reaching here means a caller bypassed it.
throw new Error('repeat steps must be expanded, not resolved directly');
@@ -294,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,
};
}
@@ -341,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;
@@ -363,10 +551,12 @@ export function describeStep(step: AutomationStep): string {
case 'wait': return `wait ${step.seconds}s`;
case 'navigate': return `open ${step.url ?? 'the firm page'}`;
case 'waitFor':
return step.absent
? `wait for ${step.selector} to disappear`
: `wait for ${step.selector}`;
if (!step.absent) return `wait for ${step.selector}`;
return step.hidden
? `wait for ${step.selector} to disappear from view`
: `wait for ${step.selector} to disappear`;
case 'scrollToLoad': return `scroll to load all ${step.selector}`;
case 'solveCaptcha': return `solve the ${step.provider ?? 'page'} captcha with 2captcha`;
case 'repeat': {
const inner = step.steps.length;
const count = step.timesFrom ? `{${step.timesFrom}}` : `${step.times ?? 1}`;
+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;
}
}
+464 -29
View File
@@ -1,37 +1,135 @@
import { TradovateClient } from './tradovate-class';
import { getFirms, getInstruments } from './db';
import { TopstepXClient } from './topstepx-class';
import { DXTradeClient } from './dxtrade-class';
import { VestClient } from './vest-class';
import type { FirmPlatform } from './master-protocol';
import { errorMessage, isClientReady, type PlatformClient } from './platform-client';
import { getTradableFirms, startCatalogSync } from './firm-catalog';
import { firmInstruments, isFuturesFirm, prewarmMarket } from './instruments';
import { housekeepingRoot } from './instrument-specs';
import { resolveFirmAssignments } from './account-type-resolver';
import { resolveContracts } from './contract-resolver';
import { startReporter, sendReport } from './reporter';
import { startBackupSync } from './backup-sync';
import { applyBrokerHttpDefaults, applyBrokerProxy } from './outbound-proxy';
import axios from './http-client';
import { startMasterLink } from './master-link';
import { resumeSchedulerIfPersisted } from './auto-trade';
import { startUpdaterWatchdog } from './updater-watchdog';
import { resumeSchedulerIfPersisted, startClaimWatcher } from './auto-trade';
import { superviseQuotes, SUPERVISE_INTERVAL_MS, type QuoteClient } from './live-quotes';
// Use global to persist the client pool across HMR reloads in dev mode
// Use global to persist the client pool across HMR reloads in dev mode.
// Keyed by the master's firm id, which is a uuid string.
//
// It is also what keeps the pool single in production: instrumentation.ts
// boots it from its own server bundle, and the route handlers can each hold a
// separate instance of this module. Registry state therefore lives here, never
// in module-level variables.
const g = global as typeof globalThis & {
__tradovateClients?: Map<number, TradovateClient>;
__tradovateClients?: Map<string, PlatformClient>;
__tradovateClientsInitialized?: boolean;
__tradovateClientCreds?: Map<string, string>;
__contractResolverTimer?: ReturnType<typeof setInterval>;
__contractResolveRetryTimer?: ReturnType<typeof setTimeout>;
__contractResolveRetries?: number;
__contractResolvedRoots?: Set<string>;
__clientSupervisorTimer?: ReturnType<typeof setInterval>;
__clientSupervision?: Map<string, SupervisionEntry>;
__quoteSupervisorTimer?: ReturnType<typeof setInterval>;
};
function ensureMap(): Map<number, TradovateClient> {
function ensureMap(): Map<string, PlatformClient> {
if (!g.__tradovateClients) {
g.__tradovateClients = new Map();
}
return g.__tradovateClients;
}
export function initClient(id: number, username: string, password: string, firmName: string): TradovateClient {
/** Tracks which credentials each live client was built with, so a password
* change can be noticed and the client rebuilt. */
function credsMap(): Map<string, string> {
if (!g.__tradovateClientCreds) g.__tradovateClientCreds = new Map();
return g.__tradovateClientCreds;
}
/** What a client was built from — a change to any of it rebuilds the client.
* DXTrade's server URL and login domain are the firm's (master), not the
* machine's, but they decide the login just as much. */
function credsSignature(firm: { platform: string; username: string; password: string; apiKey: string; serverUrl?: string; loginDomain?: string }): string {
return `${firm.platform} ${firm.username} ${firm.password} ${firm.apiKey} ${firm.serverUrl ?? ''} ${firm.loginDomain ?? ''}`;
}
export function initClient(
id: string,
firmName: string,
platform: FirmPlatform,
creds: { username: string; password: string; apiKey: string; serverUrl?: string; loginDomain?: string },
): PlatformClient {
const map = ensureMap();
const client = new TradovateClient(username, password, async () => {
console.log(`[${firmName}] sync complete — ${client.accountList.length} account(s)`);
});
const onSyncComplete = async () => {
const c = map.get(id);
if (!c) return;
console.log(`[${firmName}] sync complete — ${c.accountList.length} account(s)`);
// Every sync is a chance to bind accounts whose funding history has
// finally arrived. The first pass can already resolve untraded accounts
// from their exact cash balance; traded ones wait for the separate
// reports fetch that produces fund transactions.
try {
const counts = resolveFirmAssignments(id, c);
console.log(
`[resolver] ${firmName}: ${counts.assigned} assigned, `
+ `${counts.unassigned} unassigned, ${counts.manual} manual`,
);
} catch (err) {
console.error(`[resolver] ${firmName} failed`, err);
}
};
let client: PlatformClient;
switch (platform) {
case 'topstepx':
client = new TopstepXClient(creds.username, creds.apiKey, onSyncComplete);
break;
case 'dxtrade':
client = new DXTradeClient({
firmId: id,
firmName,
serverUrl: creds.serverUrl ?? '',
loginDomain: creds.loginDomain ?? '',
username: creds.username,
password: creds.password,
}, onSyncComplete);
break;
case 'tradovate':
client = new TradovateClient(creds.username, creds.password, onSyncComplete);
break;
case 'vest':
// The saved "API key" is the Vest login's wallet: whose session,
// relayed by the extension, this client trades.
client = new VestClient({ firmId: id, firmName, wallet: creds.apiKey }, onSyncComplete);
break;
default: {
const unknown: never = platform;
throw new Error(`no client for platform ${String(unknown)}`);
}
}
map.set(id, client);
credsMap().set(id, credsSignature({ platform, ...creds }));
return client;
}
export function removeClient(id: number): void {
export function removeClient(id: string): void {
const client = ensureMap().get(id);
// A throwing disconnect() must still take the client out of the pool:
// left in, half torn down, it would never reconnect — and the supervisor
// rebuilding it would fail the same way every time.
try {
client?.disconnect();
} catch (err) {
console.error(`[clients] disconnect failed for firm ${id}: ${errorMessage(err)}`);
}
ensureMap().delete(id);
credsMap().delete(id);
}
/** Disconnect all clients and clear the pool so they are recreated on next getClients() call. */
@@ -43,29 +141,110 @@ export function resetClients(): void {
}
}
g.__tradovateClients = undefined;
g.__tradovateClientCreds = undefined;
g.__tradovateClientsInitialized = false;
}
export function getClients(): Map<number, TradovateClient> {
/**
* Brings the client pool in line with the catalog and the stored credentials.
*
* Called at boot, whenever credentials are saved, and after the catalog
* changes — a firm can appear, disappear, or have its password corrected long
* after startup, none of which the old one-shot initialization could handle.
*/
export function reconcileClients(): void {
const map = ensureMap();
const firms = getTradableFirms();
const wanted = new Set(firms.map((f) => f.id));
// Firms removed on the master, or whose credentials were cleared.
for (const id of [...map.keys()]) {
if (!wanted.has(id)) {
console.log(`[clients] dropping client for firm ${id} — no longer tradable`);
removeClient(id);
}
}
for (const firm of firms) {
const signature = credsSignature(firm);
const existing = map.get(firm.id);
if (existing) {
if (credsMap().get(firm.id) === signature) continue;
console.log(`[clients] credentials changed for ${firm.name} — reconnecting`);
removeClient(firm.id);
}
console.log(`[clients] starting ${firm.platform} client for ${firm.name}`);
initClient(firm.id, firm.name, firm.platform, firm);
}
}
export function getClients(): Map<string, PlatformClient> {
const map = ensureMap();
// `next build` imports route modules to collect page data. Nothing there
// should call this, but a build that did must never dial the brokers or
// resume the scheduler — so hand back the empty pool and leave the flag
// unset, as if nothing had happened.
if (process.env.NEXT_PHASE === 'phase-production-build') return map;
if (!g.__tradovateClientsInitialized) {
g.__tradovateClientsInitialized = true;
// Payouts at target are claimed whether or not the scheduler runs —
// started first, so a later init step that throws can't leave it off.
startClaimWatcher();
try {
const firms = getFirms();
for (const firm of firms) {
initClient(firm.id, firm.username, firm.password, firm.name);
}
console.log(`[clients] Initialized ${firms.length} Tradovate client(s)`);
// Before anything dials out: a timeout on every broker HTTP call,
// and the operator's proxy (if any) under the broker clients.
// Master traffic stays direct by design. (http-client applies
// both when it loads, too; these are idempotent.)
applyBrokerHttpDefaults(axios.defaults);
applyBrokerProxy(axios.defaults);
// Auto-resolve contracts after clients have time to authenticate
// Pull the catalog before anything else — without it there are no
// firms to start clients for. Subsequent changes re-reconcile and
// re-resolve, so a new account type picks up accounts that were
// sitting Unassigned without waiting for a Tradovate sync.
startCatalogSync(() => {
reconcileClients();
// A newly listed root needs its front-month contract.
if (resolveRootsHere().some((r) => !g.__contractResolvedRoots?.has(r))) triggerContractResolve();
for (const [firmId, client] of ensureMap()) {
try {
resolveFirmAssignments(firmId, client);
} catch (err) {
console.error(`[resolver] re-resolve failed for ${firmId}`, err);
}
}
});
reconcileClients();
console.log(`[clients] Initialized ${map.size} platform client(s)`);
// Auto-resolve contracts after clients have time to authenticate,
// and measure every future's movement for the Auto draw.
setTimeout(() => triggerContractResolve(), 15_000);
prewarmMarket();
// Schedule daily resolve at midnight
scheduleDailyResolve();
// Rebuild any client that has been wedged for too long.
startClientSupervisor();
// Live quotes off one futures client, for the spread every entry
// pays and the Auto draw.
startQuoteSupervisor();
// Start master dashboard reporter
startReporter();
// Bring this machine's auto-updater back if its PM2 cron stops firing.
startUpdaterWatchdog();
// Push settings+credentials backups to the master, so a dead VPS
// can be replaced and this identity adopted.
startBackupSync();
// ...and the socket it prefers to report over, which is also how
// the master triggers trades and AutoBuyer runs here.
startMasterLink(() => { void sendReport(); });
@@ -81,27 +260,80 @@ export function getClients(): Map<number, TradovateClient> {
// ── Contract auto-resolve ────────────────────────────────────────────────────
function triggerContractResolve(): void {
const map = ensureMap();
// Find first client with a valid access token
let accessToken: string | null = null;
for (const [, c] of map) {
const token = (c as any).accessInfo?.accessToken;
if (token) { accessToken = token; break; }
// With no authenticated Tradovate client yet — a cold catalog, a slow login,
// broker maintenance at boot — keep checking once a minute for half an hour
// instead of giving up until midnight. Missing it leaves the contract cache
// cold: the settings page shows nothing, TopstepX loses the resolver's
// front-month names, and the first trade pays for the lookups itself.
const CONTRACT_RESOLVE_RETRY_MS = 60_000;
const CONTRACT_RESOLVE_MAX_RETRIES = 30;
/** Every root a futures firm here lists, plus each one's drain micro. */
function resolveRootsHere(): string[] {
const roots = new Set<string>();
for (const firm of getTradableFirms().filter(isFuturesFirm)) {
const listed = firmInstruments(firm);
for (const r of listed) roots.add(r);
const micro = housekeepingRoot(listed);
if (micro) roots.add(micro);
}
if (!accessToken) {
console.log('[contract-resolver] No authenticated client yet — skipping resolve');
return;
return [...roots];
}
const symbols = getInstruments().filter((i) => i.enabled).map((i) => i.symbol);
function triggerContractResolve(): void {
// A midnight run supersedes a pending retry; either way only one chain.
if (g.__contractResolveRetryTimer) clearTimeout(g.__contractResolveRetryTimer);
g.__contractResolveRetryTimer = undefined;
const map = ensureMap();
// Find first client with a valid access token (the Yahoo-driven resolver
// confirms contract ids against Tradovate; TopstepX clients resolve their
// own contracts but still reuse the resolver's front-month names).
// A token alone isn't enough: a client whose session died keeps its
// expired one, and resolving with that fails with nothing left to retry.
let accessToken: string | null = null;
for (const [, c] of map) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const token = (c as any).accessInfo?.accessToken;
if (!token) continue;
let authenticated = true;
try { authenticated = c.health().authenticated; } catch { /* no health: trust the token */ }
if (authenticated) { accessToken = token; break; }
}
if (!accessToken) {
const retries = (g.__contractResolveRetries ?? 0) + 1;
if (retries > CONTRACT_RESOLVE_MAX_RETRIES) {
console.log(
`[contract-resolver] No authenticated Tradovate client after ${CONTRACT_RESOLVE_MAX_RETRIES} retries`
+ ' — giving up until the midnight resolve',
);
g.__contractResolveRetries = 0;
return;
}
if (retries === 1) {
console.log('[contract-resolver] No authenticated Tradovate client yet — retrying every 60s');
}
g.__contractResolveRetries = retries;
g.__contractResolveRetryTimer = setTimeout(() => triggerContractResolve(), CONTRACT_RESOLVE_RETRY_MS);
return;
}
g.__contractResolveRetries = 0;
// Runs from timers: a throw here (the instruments read) would escape as
// an uncaught exception.
try {
const symbols = resolveRootsHere();
g.__contractResolvedRoots = new Set(symbols);
console.log(`[contract-resolver] Resolving ${symbols.length} instruments...`);
resolveContracts(symbols, accessToken)
.then((results) => {
const rolled = Object.entries(results).filter(([, v]) => v?.alternative);
console.log(`[contract-resolver] Done — ${rolled.length} contract(s) rolled via price match`);
})
.catch((err) => console.error('[contract-resolver] Resolve failed:', err));
.catch((err) => console.error(`[contract-resolver] Resolve failed: ${errorMessage(err)}`));
} catch (err) {
console.error(`[contract-resolver] Resolve failed: ${errorMessage(err)}`);
}
}
function scheduleDailyResolve(): void {
@@ -113,7 +345,210 @@ function scheduleDailyResolve(): void {
const now = new Date();
if (now.getHours() === 0 && now.getMinutes() === 0) {
console.log('[contract-resolver] Midnight resolve triggered');
// Its own half hour of retries, not what's left of a chain it cuts off.
g.__contractResolveRetries = 0;
triggerContractResolve();
prewarmMarket();
}
}, 60_000);
}
// ── Client supervision ───────────────────────────────────────────────────────
/**
* Rebuilds a client that has been unable to trade for a long time.
*
* The clients reconnect themselves, but only along the paths their authors
* foresaw. A re-login that fails in an unexpected way, an await that never
* settles, a socket that dies without a close event — any of those used to
* leave a client wedged for the life of the process, and because the
* scheduler waits on every firm, one wedged firm stopped them all. After a
* weekend of broker maintenance that meant a pm2 restart. A fresh client is
* exactly what the restart gave it, so the registry hands one out itself:
* same firm, same credentials, new instance.
*
* "Unready" is isClientReady() saying no — the same rule the scheduler and
* /api/health use: not synced, or no good sync in the last 5 minutes.
*
* Deliberately slow — 20 minutes unready before a rebuild, and at most one
* rebuild per firm per 20 minutes. The clients' own retries get the first go,
* and a broker that is really down, or rate-limiting logins, must not be
* hammered with new sessions. Some clients are never replaced while unready
* (see rebuildHold): one whose own login retry is pending, and a TopstepX
* one holding a position.
*/
const SUPERVISOR_INTERVAL_MS = 60_000;
const REBUILD_AFTER_UNREADY_MS = 20 * 60_000;
const REBUILD_COOLDOWN_MS = 20 * 60_000;
/** Clients that can't log in until someone acts — a rebuild changes nothing,
* and fixing the credentials (or the firm's settings) rebuilds them through
* reconcileClients anyway. TopstepX with an API key but no username never
* tries to log in; DXTrade with no server URL can't, and after refused
* credentials it stops trying so as not to lock the account; Vest waits on
* the extension to relay a signed-in tab's session. */
function needsAttention(platform: FirmPlatform, client: PlatformClient): boolean {
if (platform === 'topstepx') return /username missing/i.test(client.lastFetchErrors?.['login'] ?? '');
if (platform === 'dxtrade') return (client as DXTradeClient).needsAttention === true;
if (platform === 'vest') return (client as VestClient).needsAttention === true;
return false;
}
interface SupervisionEntry {
/** The instance being judged. A different one (rebuilt here, or replaced
* by reconcileClients after a credentials change) starts a fresh clock. */
client: PlatformClient;
/** When it was first seen unready (0 = ready at the last check). */
unhealthySince: number;
lastRebuildAt: number;
/** Last time a due rebuild was held back and said so (log rate limit). */
holdLoggedAt: number;
}
function supervisionMap(): Map<string, SupervisionEntry> {
if (!g.__clientSupervision) g.__clientSupervision = new Map();
return g.__clientSupervision;
}
/** Keep the live quote feed on a futures client, subscribed to every root
* this machine may trade (lib/live-quotes.ts). */
function superviseLiveQuotes(): void {
// Runs from a timer: nothing may escape.
try {
const map = ensureMap();
const candidates = getTradableFirms().filter(isFuturesFirm).flatMap((firm) => {
const client = map.get(firm.id);
return client ? [{ firmId: firm.id, firmName: firm.name, platform: firm.platform, client: client as unknown as QuoteClient }] : [];
});
superviseQuotes(candidates, resolveRootsHere())
.catch((err) => console.error(`[quotes] supervision failed: ${errorMessage(err)}`));
} catch (err) {
console.error(`[quotes] supervision failed: ${errorMessage(err)}`);
}
}
function startQuoteSupervisor(): void {
if (g.__quoteSupervisorTimer) clearInterval(g.__quoteSupervisorTimer);
g.__quoteSupervisorTimer = setInterval(superviseLiveQuotes, SUPERVISE_INTERVAL_MS);
// The clients log in within seconds; the first look shouldn't wait 30.
setTimeout(superviseLiveQuotes, 10_000);
}
function startClientSupervisor(): void {
// HMR-safe, and safe when resetClients() re-runs the bootstrap.
if (g.__clientSupervisorTimer) clearInterval(g.__clientSupervisorTimer);
g.__clientSupervisorTimer = setInterval(superviseClients, SUPERVISOR_INTERVAL_MS);
}
/** Why a client isn't ready, short enough for one log line. Never the token. */
function describeUnready(client: PlatformClient, now: number): string {
const parts: string[] = [];
try {
const health = client.health();
parts.push(`transport ${health.transport}`);
if (health.lastLoginError) parts.push(`login error: ${health.lastLoginError.slice(0, 160)}`);
} catch {
parts.push('health unavailable');
}
parts.push(client.lastSyncAt > 0
? `last good sync ${Math.round((now - client.lastSyncAt) / 60_000)}m ago`
: 'never synced');
return parts.join(', ');
}
/**
* Why replacing this client now would do more harm than good, or null.
*
* - Its login keeps failing and its own retry is already scheduled. Nothing
* in the instance is broken — the broker is down, refusing the
* credentials, or rate-limiting — and a fresh instance would fail the same
* way while starting its backoff over at seconds: a burst of logins after
* every rebuild instead of one every few minutes, which is how a login
* rate-limit turns into a captcha lockout.
* - TopstepX with a position open. The gateway has no OCO, so the client
* itself cancels the surviving exit leg when the other fills; a fresh
* instance knows nothing of those pairs, and the survivor would be left
* working at the broker after the position closes — an order that can open
* a position nobody asked for. It waits until the account is flat.
*/
function rebuildHold(platform: string, client: PlatformClient, now: number): string | null {
try {
const health = client.health();
if (health.consecutiveLoginFailures > 0 && health.nextReconnectAt > now) {
return `its login retry is due in ${Math.max(1, Math.round((health.nextReconnectAt - now) / 60_000))}m`
+ ` (${health.consecutiveLoginFailures} failure(s) in a row)`;
}
} catch { /* no health: judge it on the rest */ }
if ((platform === 'topstepx' || platform === 'dxtrade') && Object.keys(client.positions ?? {}).length > 0) {
return 'it holds an open position (its exit pairs are tracked only inside the client)';
}
return null;
}
function superviseClients(): void {
// Runs inside setInterval: nothing may escape, or the process logs an
// uncaught exception and the supervisor keeps firing into the same fault.
try {
const map = ensureMap();
const book = supervisionMap();
const now = Date.now();
// A firm that left the registry takes its bookkeeping with it.
for (const id of [...book.keys()]) {
if (!map.has(id)) book.delete(id);
}
let firms: ReturnType<typeof getTradableFirms> | null = null;
for (const [id, client] of [...map]) {
try {
let entry = book.get(id);
if (!entry) {
entry = { client, unhealthySince: 0, lastRebuildAt: 0, holdLoggedAt: 0 };
book.set(id, entry);
} else if (entry.client !== client) {
entry.client = client;
entry.unhealthySince = 0;
}
if (isClientReady(client, now)) {
entry.unhealthySince = 0;
continue;
}
if (!entry.unhealthySince) {
entry.unhealthySince = now;
continue;
}
if (now - entry.unhealthySince < REBUILD_AFTER_UNREADY_MS) continue;
if (now - entry.lastRebuildAt < REBUILD_COOLDOWN_MS) continue;
// Only read when a rebuild is actually due — it hits the DB.
firms ??= getTradableFirms();
const firm = firms.find((f) => f.id === id);
if (!firm) continue; // no longer tradable: reconcileClients drops it
if (needsAttention(firm.platform, client)) continue;
const minutes = Math.round((now - entry.unhealthySince) / 60_000);
const hold = rebuildHold(firm.platform, client, now);
if (hold) {
if (now - entry.holdLoggedAt >= REBUILD_COOLDOWN_MS) {
entry.holdLoggedAt = now;
console.log(`[clients] ${firm.name} unhealthy for ${minutes}m — not rebuilding while ${hold}`);
}
continue;
}
console.log(
`[clients] ${firm.name} unhealthy for ${minutes}m (${describeUnready(client, now)})`
+ ' — rebuilding client',
);
entry.lastRebuildAt = now;
removeClient(id);
entry.client = initClient(firm.id, firm.name, firm.platform, firm);
entry.unhealthySince = now;
} catch (err) {
console.error(`[clients] supervisor check failed for firm ${id}: ${errorMessage(err)}`);
}
}
} catch (err) {
console.error(`[clients] supervisor tick failed: ${errorMessage(err)}`);
}
}
+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;
}
}));
+658 -143
View File
@@ -1,172 +1,331 @@
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 firms (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
username TEXT NOT NULL,
password TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS account_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
firm_id INTEGER NOT NULL REFERENCES firms(id) ON DELETE CASCADE,
prefix TEXT NOT NULL,
profit_target REAL NOT NULL DEFAULT 3000,
consistency REAL NOT NULL DEFAULT 0.5,
min_day_pnl REAL NOT NULL DEFAULT -999,
min_trading_days INTEGER NOT NULL DEFAULT 5
);
CREATE TABLE IF NOT EXISTS instruments (
symbol TEXT PRIMARY KEY,
enabled INTEGER NOT NULL DEFAULT 1
);
`);
// Migration: add account_size column if it doesn't exist yet
// ── Machine-owned firm state ─────────────────────────────────────────────────
//
// Firms and account types themselves come from the master catalog (see
// lib/firm-catalog.ts) and are cached as one JSON blob in `settings`. These two
// tables hold the parts that are genuinely per-machine and must survive the
// catalog changing underneath them.
//
// Both reference firm ids as plain TEXT with NO foreign key on purpose. This
// file sets `foreign_keys = ON`, so an ON DELETE CASCADE here would wipe the
// Tradovate credentials the moment a firm was removed on the master — and
// mirroring master's deletions is routine, not exceptional. They must be
// orphan-tolerant: a firm that disappears and comes back finds its credentials
// and assignments still here.
db.exec(`
CREATE TABLE IF NOT EXISTS firm_credentials (
firm_id TEXT PRIMARY KEY,
firm_name TEXT NOT NULL,
username TEXT NOT NULL,
password TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS account_assignments (
account_id INTEGER PRIMARY KEY,
firm_id TEXT NOT NULL,
account_type_id TEXT,
manual INTEGER NOT NULL DEFAULT 0,
resolved_at TEXT NOT NULL
);
`);
// The legacy `firms`, `account_configs` and `firm_banned_symbols` tables are
// dead: firms now come from the master catalog. They are left on disk rather
// than dropped because `firms` is the only table that ever held plaintext
// 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 account_configs ADD COLUMN account_size REAL NOT NULL DEFAULT 50000');
db.exec("ALTER TABLE firm_credentials ADD COLUMN api_key TEXT NOT NULL DEFAULT ''");
} catch {
// Column already exists
}
// Migration: add max_loss column (0 = no max loss limit)
// 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 {
db.exec('ALTER TABLE account_configs ADD COLUMN max_loss REAL NOT NULL DEFAULT 0');
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
}
// Migration: add max_position_size column (0 = no limit)
try {
db.exec('ALTER TABLE account_configs ADD COLUMN max_position_size INTEGER NOT NULL DEFAULT 0');
db.exec('ALTER TABLE account_assignments ADD COLUMN rules_date_manual INTEGER NOT NULL DEFAULT 0');
} catch {
// Column already exists
}
// Migration: add target_same_equity flag
// 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.exec('ALTER TABLE account_configs ADD COLUMN target_same_equity INTEGER NOT NULL DEFAULT 1');
db.transaction(() => {
db.prepare('UPDATE account_assignments SET rules_date = ? WHERE rules_date IS NULL').run(undatedRowsDate());
}).immediate();
} catch {
// Column already exists
// No rules_date column — the migration above failed; nothing to date.
}
// Migration: add withdrawal_stages JSON array
try {
db.exec("ALTER TABLE account_configs ADD COLUMN withdrawal_stages TEXT NOT NULL DEFAULT '[]'");
} catch {
// Column already exists
}
// ── Firm credentials ─────────────────────────────────────────────────────────
// ── Interfaces ─────────────────────────────────────────────────────────────
export interface AccountConfigRow {
id: number;
firm_id: number;
prefix: string;
profit_target: number;
consistency: number;
min_day_pnl: number;
min_trading_days: number;
account_size: number;
max_loss: number;
max_position_size: number;
target_same_equity: number; // 0 | 1
withdrawal_stages: string; // JSON { profit: number; consistency: number; minTradingDays: number }[]
}
export interface FirmRow {
id: number;
name: string;
export interface FirmCredentialRow {
firm_id: string;
firm_name: string;
username: string;
password: string;
api_key: string;
updated_at: string;
}
export interface FirmWithAccounts extends FirmRow {
accounts: AccountConfigRow[];
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 };
}
// ── Firms ───────────────────────────────────────────────────────────────────
export function getFirms(): FirmWithAccounts[] {
const firms = db.prepare('SELECT * FROM firms ORDER BY id').all() as FirmRow[];
const getAccounts = db.prepare('SELECT * FROM account_configs WHERE firm_id = ? ORDER BY id');
return firms.map((firm) => ({
...firm,
accounts: getAccounts.all(firm.id) as AccountConfigRow[],
}));
export function listCredentials(): FirmCredentialRow[] {
return db.prepare('SELECT * FROM firm_credentials').all() as FirmCredentialRow[];
}
export function getFirmById(id: number): FirmWithAccounts | undefined {
const firm = db.prepare('SELECT * FROM firms WHERE id = ?').get(id) as FirmRow | undefined;
if (!firm) return undefined;
const accounts = db.prepare('SELECT * FROM account_configs WHERE firm_id = ? ORDER BY id').all(id) as AccountConfigRow[];
return { ...firm, accounts };
/** 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, apiKey = ''): void {
db.prepare(`
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, apiKey, new Date().toISOString());
}
export function createFirm(name: string, username: string, password: string): FirmRow {
const stmt = db.prepare('INSERT INTO firms (name, username, password) VALUES (?, ?, ?)');
const result = stmt.run(name, username, password);
return db.prepare('SELECT * FROM firms WHERE id = ?').get(result.lastInsertRowid) as FirmRow;
export function clearCredentials(firmId: string): boolean {
return db.prepare('DELETE FROM firm_credentials WHERE firm_id = ?').run(firmId).changes > 0;
}
export function deleteFirm(id: number): boolean {
const result = db.prepare('DELETE FROM firms WHERE id = ?').run(id);
return result.changes > 0;
export function rekeyCredentials(fromFirmId: string, toFirmId: string): void {
db.prepare('UPDATE firm_credentials SET firm_id = ? WHERE firm_id = ?').run(toFirmId, fromFirmId);
db.prepare('UPDATE account_assignments SET firm_id = ? WHERE firm_id = ?').run(toFirmId, fromFirmId);
}
// ── Account Configs ─────────────────────────────────────────────────────────
// ── Account type assignments ─────────────────────────────────────────────────
export function createAccountConfig(firmId: number, data: {
prefix: string;
profitTarget: number;
consistency: number;
minDayPnL: number;
minTradingDays: number;
accountSize: number;
maxLoss: number;
maxPositionSize: number;
targetSameEquity?: boolean;
withdrawalStages?: { profit: number; consistency: number; minTradingDays: number }[];
}): AccountConfigRow {
const stmt = db.prepare(
'INSERT INTO account_configs (firm_id, prefix, profit_target, consistency, min_day_pnl, min_trading_days, account_size, max_loss, max_position_size, target_same_equity, withdrawal_stages) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
const result = stmt.run(firmId, data.prefix, data.profitTarget, data.consistency, data.minDayPnL, data.minTradingDays, data.accountSize, data.maxLoss, data.maxPositionSize, data.targetSameEquity ? 1 : 0, JSON.stringify(data.withdrawalStages ?? []));
return db.prepare('SELECT * FROM account_configs WHERE id = ?').get(result.lastInsertRowid) as AccountConfigRow;
export interface AssignmentRow {
account_id: number;
firm_id: string;
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
}
export function deleteAccountConfig(id: number): boolean {
const result = db.prepare('DELETE FROM account_configs WHERE id = ?').run(id);
return result.changes > 0;
/** 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;
}
export function updateAccountConfig(id: number, data: {
prefix: string;
profitTarget: number;
consistency: number;
minDayPnL: number;
minTradingDays: number;
accountSize: number;
maxLoss: number;
maxPositionSize: number;
targetSameEquity?: boolean;
withdrawalStages?: { profit: number; consistency: number; minTradingDays: number }[];
}): boolean {
const result = db.prepare(`
UPDATE account_configs
SET prefix = ?, profit_target = ?, consistency = ?, min_day_pnl = ?, min_trading_days = ?, account_size = ?, max_loss = ?, max_position_size = ?, target_same_equity = ?, withdrawal_stages = ?
WHERE id = ?
`).run(data.prefix, data.profitTarget, data.consistency, data.minDayPnL, data.minTradingDays, data.accountSize, data.maxLoss, data.maxPositionSize, data.targetSameEquity ? 1 : 0, JSON.stringify(data.withdrawalStages ?? []), id);
return result.changes > 0;
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, 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(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,
insertRulesDate?: string,
): void {
const existing = getAssignment(accountId);
if (existing?.manual === 1) return;
setAssignment(accountId, firmId, accountTypeId, false, insertRulesDate);
}
/** 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 ─────────────────────────────────────────────────────────────────
@@ -184,13 +343,44 @@ seedSetting.run('max_concurrent_accounts', '5');
seedSetting.run('tick_interval_seconds', '60');
seedSetting.run('master_dashboard_url', 'https://master.juicerroom.com');
seedSetting.run('instance_name', '');
// The person uuid the master issues in its welcome. Stored, then presented on
// every hello/report — it is what lets a renamed instance_name stay the same
// person on the master. Never edited by hand.
seedSetting.run('instance_id', '');
seedSetting.run('trading_hours', 'full_cme');
// Solves the captchas the AutoBuyer runs into. Blank uses the key hardcoded in
// app/api/autobuyer/captcha; set this (or TWOCAPTCHA_API_KEY) to bill another
// account.
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;
@@ -200,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'];
@@ -208,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
// 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) {
const ins = db.prepare('INSERT INTO instruments (symbol, enabled) VALUES (?, ?)');
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;
@@ -242,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 ───────────────────────────────────────────────────
@@ -280,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(`
@@ -300,31 +532,314 @@ export function loadAccountMeta(accountId: number, key: string): string | null {
return row?.value ?? null;
}
// ── Firm banned symbols ───────────────────────────────────────────────────────
/** 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 firm_banned_symbols (
firm_id INTEGER NOT NULL REFERENCES firms(id) ON DELETE CASCADE,
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,
PRIMARY KEY (firm_id, symbol)
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)
);
`);
export function getBannedSymbols(firmId: number): string[] {
return (db.prepare('SELECT symbol FROM firm_banned_symbols WHERE firm_id = ? ORDER BY symbol').all(firmId) as { symbol: string }[])
.map((r) => r.symbol);
/** 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 function isSymbolBanned(firmId: number, symbol: string): boolean {
return !!db.prepare('SELECT 1 FROM firm_banned_symbols WHERE firm_id = ? AND symbol = ?').get(firmId, symbol);
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 setBannedSymbol(firmId: number, symbol: string, banned: boolean): void {
if (banned) {
db.prepare('INSERT OR IGNORE INTO firm_banned_symbols (firm_id, symbol) VALUES (?, ?)').run(firmId, symbol);
} else {
db.prepare('DELETE FROM firm_banned_symbols WHERE firm_id = ? AND symbol = ?').run(firmId, symbol);
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 ───────────────────────────────────────────────────────
@@ -620,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.16.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();
}
+546
View File
@@ -0,0 +1,546 @@
/**
* The firm catalog — firms and their account types, owned by the master
* dashboard and pulled from its public GET /api/firms/catalog.
*
* This machine holds a cache of the last complete, valid response. That cache
* is the whole point: lib/auto-trade.ts needs every account's profit target and
* size on each 60s tick, so if those numbers only ever came over the network, a
* master reboot or a flaky link would stop trading with positions open. The
* cache is written only by a successful sync and never edited locally, so it
* cannot drift the way the old per-machine account_configs did.
*
* Stored as one JSON blob in `settings` rather than mirrored into tables: the
* catalog is only ever read whole, or asked "give me firm X's account types".
*
* (Unrelated to the `FIRMS` array in lib/automations.ts, which describes
* prop-firm websites the AutoBuyer clicks through. Different notion of "firm";
* the two are not linked yet.)
*/
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,
listCredentials,
getCredentials,
rekeyCredentials,
} from './db';
const CATALOG_PATH = '/api/firms/catalog';
const POLL_INTERVAL_MS = 60_000;
const FETCH_TIMEOUT_MS = 10_000;
// Until the very first successful sync there is nothing to trade from, so back
// off quickly rather than sitting on the full poll interval.
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]);
}
}
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 {
if (g.__firmCatalog) return g.__firmCatalog;
let payload: FirmCatalogPayload | null = null;
const raw = getSetting('catalog_payload');
if (raw) {
try {
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');
}
}
g.__firmCatalog = indexPayload(payload);
return g.__firmCatalog;
}
export function getCachedCatalog(): CatalogFirm[] {
return cache().payload?.firms ?? [];
}
export function getCachedFirm(firmId: string): CatalogFirm | undefined {
return getCachedCatalog().find((f) => f.id === firmId);
}
export function getAccountTypeById(typeId: string): CatalogAccountType | undefined {
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;
lastSyncAt: string | null;
lastError: string | null;
firmCount: number;
} {
return {
version: Number(getSetting('catalog_version') ?? 0),
updatedAt: getSetting('catalog_updated_at'),
lastSyncAt: getSetting('catalog_last_sync_at'),
lastError: getSetting('catalog_last_error'),
firmCount: getCachedCatalog().length,
};
}
/** 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()) {
const creds = getCredentials(firm.id);
if (creds && hasPlatformCredentials(firm.platform, creds)) result.push({ ...firm, ...creds });
}
return result;
}
// ── Sync ─────────────────────────────────────────────────────────────────────
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
* silently unassign accounts, so it is better to keep the old cache. */
function validatePayload(raw: unknown): FirmCatalogPayload {
if (typeof raw !== 'object' || raw === null) throw new Error('payload is not an object');
const body = raw as Record<string, unknown>;
if (typeof body.version !== 'number') throw new Error('missing version');
if (typeof body.updatedAt !== 'string') throw new Error('missing updatedAt');
if (!Array.isArray(body.firms)) throw new Error('missing firms');
const firms: CatalogFirm[] = [];
// Stamped on the payload, so a build that learns one refetches its firms.
const skippedPlatforms = new Set<string>();
body.firms.forEach((f: unknown, i: number) => {
if (typeof f !== 'object' || f === null) throw new Error(`firm ${i} is not an object`);
const firm = f as Record<string, unknown>;
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) =>
normalizeAccountType(t, { tradesEveryDay, where: `account type ${j}` }));
// Only when the master sent the list: absent = a master from before
// kept rules, [] = every account on the current rules.
const keptAccountRules = Array.isArray(firm.keptAccountRules)
? parseKeptRules({ id: firm.id, name: firm.name }, firm.keptAccountRules, accountTypes, tradesEveryDay)
: undefined;
firms.push({
id: firm.id,
name: firm.name,
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(),
});
});
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 {
const base = getSetting('master_dashboard_url');
if (!base) return null;
try {
// new URL, never string concatenation: the stored value routinely has a
// trailing slash, which would produce //api/firms/catalog.
return new URL(CATALOG_PATH, base);
} catch {
return null;
}
}
function recordError(message: string): void {
setSetting('catalog_last_error', message);
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. */
function rekeyCredentialsByName(firms: CatalogFirm[]): void {
const byName = new Map(firms.map((f) => [f.name.toLowerCase(), f.id]));
const knownIds = new Set(firms.map((f) => f.id));
for (const row of listCredentials()) {
if (knownIds.has(row.firm_id)) continue;
const match = byName.get(row.firm_name.toLowerCase());
if (!match) continue;
console.warn(`[catalog] re-keying credentials for "${row.firm_name}": ${row.firm_id} -> ${match}`);
rekeyCredentials(row.firm_id, match);
}
}
export async function syncCatalog(): Promise<{ ok: boolean; changed: boolean; version: number; error?: string }> {
const version = Number(getSetting('catalog_version') ?? 0);
const url = catalogUrl();
if (!url) {
const error = 'master_dashboard_url is not set';
recordError(error);
return { ok: false, changed: false, version, error };
}
// 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;
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 };
}
const payload = validatePayload(body);
// A master pointed at the wrong FIRMS_DB, or restored from an empty
// one, serves {"firms":[]} as a perfectly valid 200. Mirroring that
// would stop trading on every machine at once, so keep what we have.
if (payload.firms.length === 0 && hasCache) {
const error = 'master returned an empty catalog — ignored';
console.error(`[catalog] ${error}`);
recordError(error);
return { ok: false, changed: false, version, error };
}
// Same reasoning one level down: a firm losing every account type
// 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
? `kept cached account types for ${suspicious.join(', ')} — master sent none`
: '');
g.__firmCatalog = indexPayload(payload);
rekeyCredentialsByName(payload.firms);
console.log(`[catalog] synced v${payload.version} — ${payload.firms.length} firm(s)`);
return { ok: true, changed: true, version: payload.version };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
console.error('[catalog] sync failed:', error);
recordError(error);
return { ok: false, changed: false, version, error };
}
}
export function startCatalogSync(onChange?: () => void): void {
stopCatalogSync();
const tick = async () => {
const result = await syncCatalog();
if (result.changed) {
try {
onChange?.();
} catch (err) {
console.error('[catalog] onChange handler failed', err);
}
}
let delay = POLL_INTERVAL_MS;
if (getCachedCatalog().length === 0) {
// Never synced: retry hard, with backoff, until something lands.
const current = g.__firmCatalogColdDelay ?? COLD_RETRY_MIN_MS;
delay = current;
g.__firmCatalogColdDelay = Math.min(current * 2, COLD_RETRY_MAX_MS);
} else {
g.__firmCatalogColdDelay = COLD_RETRY_MIN_MS;
// Jitter so a fleet restarting together doesn't poll in lockstep.
delay = POLL_INTERVAL_MS + Math.floor(Math.random() * 10_000);
}
g.__firmCatalogTimer = setTimeout(() => void tick(), delay);
};
void tick();
}
export function stopCatalogSync(): void {
if (g.__firmCatalogTimer) clearTimeout(g.__firmCatalogTimer);
g.__firmCatalogTimer = undefined;
}
+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;
}
+56 -14
View File
@@ -15,11 +15,17 @@
* master you control, and prefer https so it cannot be redirected in transit.
*/
import { getSetting, 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,19 +240,26 @@ 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', () => {
// The stored uuid, once the master has issued one — it, not the name,
// is who this instance is, so renaming instance_name keeps the same
// person (and their accounting history) on the master.
const instanceId = getSetting('instance_id');
socket.send(JSON.stringify({
type: 'hello',
instance,
id: instanceId || undefined,
capabilities: collectCapabilities(),
version: getBuildVersion() ?? undefined,
} satisfies InstanceMessage));
});
@@ -235,6 +274,9 @@ function connect() {
if (msg.type === 'welcome') {
s.ready = true;
s.attempt = 0;
if (msg.personId && msg.personId !== getSetting('instance_id')) {
setSetting('instance_id', msg.personId);
}
console.log(`[master-link] connected to ${url} as "${instance}"`);
return;
}
+509 -4
View File
@@ -7,11 +7,253 @@
* running different versions of this file at the same time.
*/
export interface AccountTypeStats {
accountTypeId: string | null;
label: string;
accountSize: number;
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 {
firm: string;
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 {
@@ -21,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 {
@@ -34,19 +332,226 @@ export interface InstanceCapabilities {
symbols: string[];
automations: AutomationSummary[];
runnerOnline: boolean;
/** Answers the 'update-now' command; the master offers it only then. */
updateNow: boolean;
}
// ── Firm catalog ─────────────────────────────────────────────────────────────
// Served by the master's public GET /api/firms/catalog and cached locally by
// lib/firm-catalog.ts. Credentials are per-machine and absent from all of it.
/** One account type as published by the master.
*
* The field names match `AccountConfig` in types.ts on purpose — that is what
* lets the trading code consume a catalog account type wherever it used to
* read a local account_configs row. */
export interface CatalogAccountType {
id: string;
firmId: string;
label: string;
prefix: string;
accountSize: number;
profitTarget: number;
consistency: number;
minDayPnL: number;
minTradingDays: number;
maxLoss: number;
maxPositionSize: number;
targetSameEquity: boolean;
/** 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. 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 {
version: number;
updatedAt: string;
unchanged: true;
}
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 =
| { type: 'welcome' }
/** personId is the person's stable uuid; the instance stores it and
* presents it on later hellos, which is what makes a rename follow the
* person. */
| { type: 'welcome'; personId?: string }
| { type: 'command'; id: string; command: Command };
export type InstanceMessage =
| { type: 'hello'; instance: 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,
};
}
+575 -32
View File
@@ -11,70 +11,597 @@
* POST keeps those working unchanged.
*/
import { getSetting } from './db';
import { getSetting, loadClaimedCycles, loadEndedCycleStart } from './db';
import { pushReport } from './master-link';
import { getFirms } from './db';
import { getBuildVersion } from './version';
import { tradeMixFor } from './trade-mix';
import { updaterStatus } from './updater-status';
import { getClients } from './clients';
import { getAccountTypeById, getKeptRuleIdsForFirm, getTradableFirms } from './firm-catalog';
import { getAccountConfig } from './account-type-resolver';
import { getSchedulerStatus } from './auto-trade';
import { computeDailyTarget } from './trading-logic';
import type { AccountConfigRow } from './db';
function getAccountConfig(name: string, accounts: AccountConfigRow[]): AccountConfigRow | undefined {
return [...accounts]
.sort((a, b) => b.prefix.length - a.prefix.length)
.find((a) => name.startsWith(a.prefix));
}
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() {
const firms = getFirms();
// Only firms this machine has credentials for. A firm it cannot log into
// has nothing to say about, and reporting it as zero accounts would fill
// 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 };
}
// 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. 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: 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 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++;
accountsTraded++;
slice.inTrade++;
slice.accountsTraded++;
continue;
}
// Check if target was hit today
const cfg = getAccountConfig(acc.name, f.accounts);
if (cfg) {
const dailyPnL = client.dailyPnL[acc.id] ?? [];
const equityProfit = cash.amount - cfg.account_size;
const target = computeDailyTarget(cfg.profit_target, cfg.consistency, dailyPnL, cfg.min_day_pnl, cfg.min_trading_days, equityProfit);
const targetHit = !target ? false :
((target.amount === 0 && Math.abs(cash.realizedPnL) > 0 && (client.daysTraded[acc.id] ?? 0) <= cfg.min_trading_days) ||
(cash.realizedPnL >= target.amount));
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;
}
}
}
}
return { firm: f.name, totalAccounts, accountsTraded, inTrade };
// 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.
const accountTypes = [...byType.values()].sort((a, b) =>
(b.totalAccounts - b.accountsTraded) - (a.totalAccounts - a.accountsTraded)
|| a.label.localeCompare(b.label));
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
@@ -91,12 +618,28 @@ 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(`${url}/api/report`, {
await fetch(new URL('/api/report', url), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ instance: instanceName, firms, scheduler }),
body: JSON.stringify({
instance: instanceName,
// Same identity the socket hello carries, so a rename resolves
// to the same person whichever transport the report takes.
id: getSetting('instance_id') || undefined,
firms,
scheduler,
version: getBuildVersion() ?? undefined,
updater: updaterStatus(),
}),
});
} catch {
// Master might be down — silently continue
+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));
}
}
+2003 -23
View File
File diff suppressed because it is too large Load Diff
+1662 -201
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']) };
}

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