Compare commits
202
Commits
5538215650
...
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
|
||||
@@ -280,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
|
||||
|
||||
@@ -305,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,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', 'twocaptcha_api_key', 'twocaptcha_proxy'] as const;
|
||||
const VALID_KEYS = ['max_concurrent_accounts', 'tick_interval_seconds', 'master_dashboard_url', 'instance_name', 'trading_hours', 'twocaptcha_api_key', 'twocaptcha_proxy', 'master_backup_token', 'proxy_url', 'auto_claim', 'auto_activate'] as const;
|
||||
type SettingKey = typeof VALID_KEYS[number];
|
||||
|
||||
export async function GET() {
|
||||
@@ -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")
|
||||
|
||||
@@ -152,8 +152,17 @@ def solve(dash, step, opts, report, cancelled=None) -> None:
|
||||
}, 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
|
||||
|
||||
+236
-101
@@ -34,7 +34,8 @@ HEARTBEAT_SECONDS = 2.0
|
||||
# Bumped whenever the step vocabulary or the locate protocol changes. Reported in
|
||||
# the heartbeat so the dashboard can say "restart your runner" instead of letting
|
||||
# a stale process fail on a step type it has never heard of.
|
||||
VERSION = "0.17.0"
|
||||
# 0.20.0: page guards — run-level `guards` replace per-step signedOut/authSteps.
|
||||
VERSION = "0.20.0"
|
||||
|
||||
# Shared with the heartbeat thread: whether a run is currently executing.
|
||||
_busy = threading.Event()
|
||||
@@ -57,8 +58,14 @@ def heartbeat_loop(dash: Dashboard, opts, stop: threading.Event) -> None:
|
||||
stop.wait(HEARTBEAT_SECONDS)
|
||||
|
||||
|
||||
def run_one_step(dash: Dashboard, step: dict, opts, rng, report, run_id: int | None = None) -> None:
|
||||
"""Dispatch a single step. Raises StepError / DashboardError on failure."""
|
||||
def run_one_step(dash: Dashboard, step: dict, opts, rng, report,
|
||||
run_id: int | None = None, guards: list | None = None) -> None:
|
||||
"""Dispatch a single step. Raises StepError / DashboardError on failure,
|
||||
GuardTripped when a URL guard's condition shows up on the page.
|
||||
|
||||
`guards` is empty for recovery steps: they run while a guard's condition
|
||||
holds, so checking it again would trip on the very page they exist to fix.
|
||||
"""
|
||||
if step["action"] == "wait":
|
||||
time.sleep(float(step.get("seconds", 1)))
|
||||
report(f"waited {step.get('seconds', 1)}s")
|
||||
@@ -72,7 +79,7 @@ def run_one_step(dash: Dashboard, step: dict, opts, rng, report, run_id: int | N
|
||||
navigate_url=target)
|
||||
# Navigating to a protected page is where an expired session shows up:
|
||||
# the site answers with a redirect to its login form.
|
||||
actions.check_signed_in(landed, step.get("signedOut", "") or "")
|
||||
actions.check_guards(landed, guards)
|
||||
report(f"tab is on {landed.get('url', target)}")
|
||||
|
||||
elif step["action"] == "waitFor":
|
||||
@@ -80,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
|
||||
@@ -92,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.
|
||||
@@ -170,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,
|
||||
@@ -196,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}")
|
||||
|
||||
@@ -272,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.
|
||||
@@ -347,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:
|
||||
|
||||
@@ -58,6 +58,70 @@ going back to `<all_urls>`: it would put every tab you visit in reach, and limit
|
||||
firm needs its host in *both* lists — `host_permissions` and the `content_scripts`
|
||||
matches — or captchas on that site will have no callback to fire.
|
||||
|
||||
## 2FA codes
|
||||
|
||||
On a firm site, the extension shows the machine's person's authenticator code
|
||||
for that site in a small chip in the bottom-right corner, with a Copy button
|
||||
(minimise it to a "2FA" pill; that sticks for the tab's session).
|
||||
|
||||
```
|
||||
extension --GET /api/autobuyer/otp?host=<tab host>--> dashboard
|
||||
dashboard --GET <master>/api/otp?person=<instance_id>&host=<tab host>--> master
|
||||
```
|
||||
|
||||
The master holds the secrets and matches the host against each firm's **Site**
|
||||
link (Firms page), then returns the codes assigned to this person and that firm
|
||||
(Codes page). The chip only needs a code to be assigned — it doesn't wait for a
|
||||
code field. It stays hidden for a minute after the runner last located
|
||||
something, so automated clicks never land on it.
|
||||
|
||||
A new 2FA site needs its host in `host_permissions` (not the captcha
|
||||
`content_scripts` list), a Site link on its firm on the master, and its codes
|
||||
assigned on the master's Codes page.
|
||||
|
||||
## Vest session relay
|
||||
|
||||
Vest signs people in with Google (through Privy) in the browser and renews the
|
||||
session with a device key the browser will not export, so the dashboard cannot
|
||||
log in to Vest by itself. The extension relays the browser's session instead:
|
||||
|
||||
```
|
||||
vest-relay.js (Vest tab, MAIN world) keeps the access token the Vest app last renewed
|
||||
background.js relayVestSession lifts it every 10s, posts it when it changes (and every minute)
|
||||
dashboard POST /api/vest/session holds it in memory for the firm's Vest client,
|
||||
and binds an unset Vest firm to the login's wallet
|
||||
```
|
||||
|
||||
- The hook only reads the Vest app's own `/v3/auth/refresh` and `/register`
|
||||
answers. It renews nothing and sends nothing; the session stays the app's,
|
||||
so the bot never logs the tab out.
|
||||
- It takes the login's token only. The app renews each trading account's own
|
||||
token through the same `/v3/auth/refresh` (with that account's refresh token
|
||||
in the body), and one of those sees a single account. Before 0.17.2 the hook
|
||||
relayed those too, and the dashboard dropped every other account; the
|
||||
dashboard now refuses such a token and falls back to the login's.
|
||||
- The token lives ~15 minutes and the app renews it by itself while the tab is
|
||||
open. The tab is marked not auto-discardable, since a discarded tab stops
|
||||
renewing.
|
||||
- Several Vest tabs each renew on their own, and every renewal moves the login
|
||||
on to a new session (localStorage `vest-auth-session`). A token from before
|
||||
still reads, but Vest refuses to mint an account's token with it (401, code
|
||||
1002), so the bot's orders and history reads fail. Since 0.18.0 the hook
|
||||
records each token's session id, and the relay sends the token Vest's
|
||||
current session holds, whichever tab has it, not the first tab's. A tab
|
||||
hooked by an older build is matched by its token's expiry instead; one that
|
||||
renewed before any hook was in it shows in the popup as "none holds the
|
||||
current session" until it renews again or is reloaded.
|
||||
- A sign-in's answer (`/register`) carries the login's own refresh token, and
|
||||
is taken too since 0.18.0; before, the first token after a sign-in waited
|
||||
for the tab's first renewal.
|
||||
- When the dashboard has a Vest firm (its credential is the login's wallet)
|
||||
and no Vest tab is open, the extension opens one in the background. The app
|
||||
signs back in from its 30-day session. After that the person signs in again
|
||||
in that tab.
|
||||
- This is the one place the extension reads a tab you are not looking at. It
|
||||
only reads `next.vestmarkets.com` tabs, and only that one value.
|
||||
|
||||
## Notes
|
||||
|
||||
- The extension never captures the dashboard's own pages — otherwise it would
|
||||
|
||||
+1044
-14
File diff suppressed because it is too large
Load Diff
+15
-2
@@ -1,12 +1,19 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "AutoFirmer Capture",
|
||||
"version": "0.11.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/*"
|
||||
],
|
||||
@@ -23,6 +30,12 @@
|
||||
"run_at": "document_start",
|
||||
"all_frames": true,
|
||||
"world": "MAIN"
|
||||
},
|
||||
{
|
||||
"matches": ["https://next.vestmarkets.com/*"],
|
||||
"js": ["vest-relay.js"],
|
||||
"run_at": "document_start",
|
||||
"world": "MAIN"
|
||||
}
|
||||
],
|
||||
"action": {
|
||||
|
||||
@@ -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 };
|
||||
+2252
-428
File diff suppressed because it is too large
Load Diff
+203
-56
@@ -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
|
||||
@@ -66,7 +78,11 @@ export type AutomationStep =
|
||||
// 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;
|
||||
@@ -94,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;
|
||||
@@ -102,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;
|
||||
@@ -111,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',
|
||||
@@ -162,18 +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: 'solveCaptcha', timeoutSeconds: 180, label: 'Solve the challenge' },
|
||||
{ action: 'click', selector: 'div.summary_section > button:last-child', label: 'Open Add Account' },
|
||||
{ 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' },
|
||||
]},
|
||||
|
||||
],
|
||||
},
|
||||
@@ -199,12 +268,12 @@ export const FIRMS: Firm[] = [
|
||||
// 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. Trim the pair once you know whether the site
|
||||
// spells it as a class or an id.
|
||||
// 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, #dashboard_body',
|
||||
containerSelector: '.dashboard_body',
|
||||
label: 'Load every failed account',
|
||||
},
|
||||
|
||||
@@ -227,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: [
|
||||
{
|
||||
@@ -249,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' },
|
||||
]},
|
||||
],
|
||||
},
|
||||
@@ -303,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 {
|
||||
@@ -312,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') {
|
||||
@@ -336,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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -383,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;
|
||||
@@ -405,9 +551,10 @@ 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': {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+457
-22
@@ -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 ────────────────────────────────────────────────────
|
||||
|
||||
// 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);
|
||||
}
|
||||
return [...roots];
|
||||
}
|
||||
|
||||
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
|
||||
// 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) { accessToken = token; break; }
|
||||
if (!token) continue;
|
||||
let authenticated = true;
|
||||
try { authenticated = c.health().authenticated; } catch { /* no health: trust the token */ }
|
||||
if (authenticated) { accessToken = token; break; }
|
||||
}
|
||||
if (!accessToken) {
|
||||
console.log('[contract-resolver] No authenticated client yet — skipping resolve');
|
||||
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;
|
||||
|
||||
const symbols = getInstruments().filter((i) => i.enabled).map((i) => i.symbol);
|
||||
// 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 {
|
||||
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.exec('ALTER TABLE account_configs ADD COLUMN max_loss REAL NOT NULL DEFAULT 0');
|
||||
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,6 +343,10 @@ 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
|
||||
@@ -192,12 +355,32 @@ seedSetting.run('twocaptcha_api_key', '');
|
||||
// Blank falls back to the proxy hardcoded in app/api/autobuyer/captcha; `none`
|
||||
// solves without one.
|
||||
seedSetting.run('twocaptcha_proxy', '');
|
||||
// Bearer for PULLING an encrypted settings+credentials backup back from the
|
||||
// master (its INSTANCE_BACKUP_TOKEN) — pushing needs no token and happens
|
||||
// automatically. Blank = restore unavailable from this machine's UI.
|
||||
seedSetting.run('master_backup_token', '');
|
||||
// Routes broker traffic (TopstepX, Tradovate, Yahoo quotes) through a proxy:
|
||||
// http(s):// or socks5(h)://, user:pass ok. The master link stays direct on
|
||||
// purpose. Blank = direct. Takes effect on restart.
|
||||
seedSetting.run('proxy_url', '');
|
||||
// Mirrors the in-memory scheduler state so a restart can resume it.
|
||||
seedSetting.run('scheduler_running', '0');
|
||||
seedSetting.run('scheduler_action', 'Buy');
|
||||
seedSetting.run('scheduler_symbol', 'NQ');
|
||||
seedSetting.run('scheduler_stop_after_all', '0');
|
||||
|
||||
/** Wraps several writes in one transaction (used by the backup restore, so a
|
||||
* half-applied backup cannot survive a crash). */
|
||||
export function runInTransaction<T>(fn: () => T): T {
|
||||
return db.transaction(fn)();
|
||||
}
|
||||
|
||||
/** The whole table, for the backup pushed to the master. */
|
||||
export function listSettings(): Record<string, string> {
|
||||
const rows = db.prepare('SELECT key, value FROM settings').all() as { key: string; value: string }[];
|
||||
return Object.fromEntries(rows.map((r) => [r.key, r.value]));
|
||||
}
|
||||
|
||||
export function getSetting(key: string): string | null {
|
||||
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined;
|
||||
return row?.value ?? null;
|
||||
@@ -207,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'];
|
||||
@@ -215,12 +410,17 @@ const SYMBOLS = ['NQ','MNQ','ES','MES','YM','MYM','RTY','M2K','GC','MGC','SI','C
|
||||
// from the Instruments page, they just start off.
|
||||
const DEFAULT_ENABLED = new Set(['NQ', 'GC', 'CL']);
|
||||
|
||||
// Seed instruments table if empty
|
||||
const instrCount = (db.prepare('SELECT COUNT(*) as count FROM instruments').get() as { count: number }).count;
|
||||
if (instrCount === 0) {
|
||||
const ins = db.prepare('INSERT INTO instruments (symbol, enabled) VALUES (?, ?)');
|
||||
// Seed instruments table if empty. Check and insert share one IMMEDIATE
|
||||
// transaction: `next build` imports this module from several workers at once,
|
||||
// and on a fresh machine they all saw an empty table and raced to insert the
|
||||
// same symbols (UNIQUE constraint failed: instruments.symbol). OR IGNORE is
|
||||
// the backstop if the lock is ever skipped.
|
||||
db.transaction(() => {
|
||||
const instrCount = (db.prepare('SELECT COUNT(*) as count FROM instruments').get() as { count: number }).count;
|
||||
if (instrCount > 0) return;
|
||||
const ins = db.prepare('INSERT OR IGNORE INTO instruments (symbol, enabled) VALUES (?, ?)');
|
||||
for (const s of SYMBOLS) ins.run(s, DEFAULT_ENABLED.has(s) ? 1 : 0);
|
||||
}
|
||||
}).immediate();
|
||||
|
||||
export interface InstrumentRow {
|
||||
symbol: string;
|
||||
@@ -249,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 ───────────────────────────────────────────────────
|
||||
@@ -287,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(`
|
||||
@@ -307,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 ───────────────────────────────────────────────────────
|
||||
@@ -627,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.17.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));
|
||||
}
|
||||
}
|
||||
+2004
-24
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']) };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* The version of the running build.
|
||||
*
|
||||
* Updates arrive as `git pull` (scripts/update-check.mjs), so the checkout's
|
||||
* HEAD commit *is* the version — there is no release number to bump and
|
||||
* nothing for anyone to forget. The commit time rides along because hashes
|
||||
* don't order: it is what lets the master say which of two different
|
||||
* versions is the stale one.
|
||||
*
|
||||
* Read once and cached: the update task restarts the process after every
|
||||
* pull, so HEAD cannot move under a running build — and when it briefly does
|
||||
* (pull landed, restart pending), the cached value still describes the code
|
||||
* that is actually executing.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
export interface BuildVersion {
|
||||
/** Short commit hash of HEAD, e.g. "c493796". */
|
||||
commit: string;
|
||||
/** ISO commit time of HEAD — orders versions, which hashes cannot. */
|
||||
time: string;
|
||||
}
|
||||
|
||||
// Per process, not per bundle: instrumentation.ts and the route handlers load
|
||||
// their own copies of this module, and each would otherwise read HEAD at its own
|
||||
// first call — after an update's pull, a late first call reports code that isn't
|
||||
// running yet.
|
||||
const _g = globalThis as typeof globalThis & { __buildVersion?: BuildVersion | null };
|
||||
|
||||
/** null when this isn't a git checkout or git isn't installed — both mean
|
||||
* "no version to report", and the master shows the machine as an old build. */
|
||||
export function getBuildVersion(): BuildVersion | null {
|
||||
if (_g.__buildVersion !== undefined) return _g.__buildVersion;
|
||||
try {
|
||||
const out = execFileSync('git', ['log', '-1', '--format=%h %cI'], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
}).trim();
|
||||
const [commit, time] = out.split(' ');
|
||||
_g.__buildVersion = commit && time ? { commit, time } : null;
|
||||
} catch {
|
||||
_g.__buildVersion = null;
|
||||
}
|
||||
return _g.__buildVersion;
|
||||
}
|
||||
+2466
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user