Compare commits
204
Commits
25289cb9b3
..
master
+25
@@ -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
|
||||
|
||||
@@ -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
@@ -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'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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { getSetting } from '@/lib/db';
|
||||
import { corsJson, corsPreflight } from '../cors';
|
||||
|
||||
/** The extension's 2FA lookup. It knows the tab's host; this dashboard knows
|
||||
* which person it is (the master-issued `instance_id`) and where the master
|
||||
* is, so it asks the master for this person's codes on that site and passes
|
||||
* them through.
|
||||
*
|
||||
* Never an error status: no link to the master, or a master that can't be
|
||||
* reached, is just "no codes" with a reason — the extension shows nothing. */
|
||||
export async function GET(request: Request) {
|
||||
const host = new URL(request.url).searchParams.get('host')?.trim().toLowerCase();
|
||||
if (!host) return corsJson({ codes: [], reason: 'host is required' });
|
||||
|
||||
const master = getSetting('master_dashboard_url')?.trim().replace(/\/+$/, '');
|
||||
const person = getSetting('instance_id')?.trim();
|
||||
if (!master || !person) return corsJson({ codes: [], reason: 'not linked to master' });
|
||||
|
||||
const url = `${master}/api/otp?person=${encodeURIComponent(person)}&host=${encodeURIComponent(host)}`;
|
||||
try {
|
||||
const res = await fetch(url, { cache: 'no-store', signal: AbortSignal.timeout(5000) });
|
||||
if (!res.ok) return corsJson({ codes: [], reason: `master answered ${res.status}` });
|
||||
const data = (await res.json()) as { codes?: unknown[]; serverTime?: number; scope?: string };
|
||||
// A master from before per-person lookups ignores ?person= and answers
|
||||
// with every stored code — never show those.
|
||||
if (data.scope !== 'person') return corsJson({ codes: [], reason: 'master is too old for 2FA lookups' });
|
||||
return corsJson({ codes: data.codes ?? [], serverTime: data.serverTime ?? Date.now() });
|
||||
} catch (err) {
|
||||
return corsJson({ codes: [], reason: `master unreachable: ${(err as Error).message}` });
|
||||
}
|
||||
}
|
||||
|
||||
export async function OPTIONS() {
|
||||
return corsPreflight();
|
||||
}
|
||||
@@ -1,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),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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() });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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
File diff suppressed because it is too large
Load Diff
+345
-76
@@ -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'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's{' '}
|
||||
{masterUrl ? <a className="text-blue-500 hover:underline" href={`${masterUrl.replace(/\/+$/, '')}/firms`} target="_blank" rel="noreferrer">Firms page</a> : 'Firms page'}.
|
||||
Futures “Auto” 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'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'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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
Executable
+101
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+58
@@ -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
|
||||
Executable
+50
@@ -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
|
||||
@@ -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
|
||||
Executable
+19
@@ -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"
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Executable
+61
@@ -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"
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
})();
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+249
-59
@@ -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}`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -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;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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' };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
@@ -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
@@ -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 })),
|
||||
};
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+1662
-201
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user