Compare commits
98
Commits
dd73222e00
...
master
@@ -59,6 +59,15 @@ scripts/.deps-hash
|
||||
scripts/.python-cmd
|
||||
scripts/.update.lock
|
||||
scripts/update.log
|
||||
# updater bookkeeping (scripts/update-check.mjs)
|
||||
scripts/.built-commit
|
||||
scripts/.update-failed
|
||||
scripts/update-worker.log
|
||||
# present = this checkout is never auto-updated or healed (a dev checkout)
|
||||
scripts/.no-auto-update
|
||||
# the previous build, set aside while the updater builds (Tailwind's source
|
||||
# scan also honours this, so it never reads the old bundles)
|
||||
/.next-prev/
|
||||
|
||||
# clicker virtualenv (Linux/Pi)
|
||||
clicker/.venv/
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# AutoFirmer
|
||||
|
||||
Automated futures trading dashboard for prop firm accounts via Tradovate and
|
||||
TopstepX (ProjectX Gateway).
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
@@ -73,7 +74,7 @@ On first run, the SQLite database (`autotrader.sqlite`) is created automatically
|
||||
|
||||
Firms and their account types live on the master dashboard, not here — every
|
||||
machine pulls the same catalog so they cannot drift apart. This machine only
|
||||
stores the Tradovate login for each firm.
|
||||
stores the platform login for each firm.
|
||||
|
||||
1. On the **master dashboard**, open **Firms**, add the firm, and give it one
|
||||
account type per prefix + account size combination (profit target,
|
||||
@@ -86,7 +87,10 @@ stores the Tradovate login for each firm.
|
||||
**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
|
||||
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
|
||||
@@ -94,6 +98,182 @@ stores the Tradovate login for each firm.
|
||||
|
||||
---
|
||||
|
||||
## Instruments: what each firm trades
|
||||
|
||||
Each firm's **Instruments** list on the master's Firms page is the one place
|
||||
that says what it trades — there are no per-machine toggles or banned symbols
|
||||
any more (the instance's Settings page shows the lists read-only).
|
||||
|
||||
- **Futures firms** (Tradovate, TopstepX) list CME roots — the same names on
|
||||
both platforms; each client turns a root into its own front-month contract.
|
||||
- **DXtrade firms** list their own symbols (`ETH/USD`), whatever root a batch
|
||||
drew.
|
||||
|
||||
With the scheduler on **Auto**, each futures batch draws one instrument from
|
||||
the batch firm's list, at random but weighted:
|
||||
|
||||
- **Efficiency** — one contract's typical daily move ÷ one tick of spread
|
||||
(from Yahoo's hourly candles over the past week, measured once a day).
|
||||
Commissions don't count — they barely differ between instruments.
|
||||
- **Session timing** — at the open every instrument gets a 10% floor and the
|
||||
weights follow efficiency; toward the last entry (14:55 CT) the floor fades
|
||||
and the weight moves sharply to the fastest movers.
|
||||
- **12-hour cap** — sizing is unchanged (a contract per $1,000 of leg, up to
|
||||
the type's max contracts); an instrument whose expected hold for the
|
||||
batch's leg is 12 h or more is out of the draw (e.g. 6E at a $1,000 leg,
|
||||
about a week).
|
||||
- Micros stay out of the draw; drain and extra-day legs always trade MNQ.
|
||||
|
||||
Every draw logs its odds. `GET /api/instruments` shows each firm's list with
|
||||
spread, daily move, hold and chance (`?simulate=10000` runs the real draw
|
||||
with no orders), and the master's Firms page shows the same next to each
|
||||
instrument.
|
||||
|
||||
Against a master from before the lists, an instance falls back to the old
|
||||
rules: this machine's enabled instruments less each firm's banned symbols,
|
||||
and a DXtrade firm's symbol map.
|
||||
|
||||
---
|
||||
|
||||
## DXtrade firms (Tradeify247)
|
||||
|
||||
A DXtrade firm is set up on the master's **Firms** page like any other, plus:
|
||||
|
||||
- **Server URL** (`https://dx.tradeify247.co/dxsca-web`) and **login domain**
|
||||
(usually `default`). Changing the URL re-keys the firm's accounts.
|
||||
These settings only exist for DXtrade firms; futures firms keep their measured
|
||||
per-contract fees and the CME calendar.
|
||||
|
||||
- **Commission %** of notional per side (Tradeify: `0.04`). The engine prices
|
||||
every fee off the fill with it.
|
||||
- **Trades every day** — weekends and CME holidays included. Such a firm runs
|
||||
in its own scheduler *lane*: its own trading hours (closed only around the
|
||||
daily roll), its own wait-until-flat gate and its own batches, so a
|
||||
multi-hour crypto position never holds up the futures open.
|
||||
- **Instruments** — the firm's own symbols (see above). The cheapest
|
||||
tradable one right now wins: spread plus both commissions, per unit of its
|
||||
volatility (hourly closes over the past week, measured once a day).
|
||||
- **Target hold hours** — each leg's notional is sized from that volatility so
|
||||
its expected hold is about this long (blank = 12 h). The position never
|
||||
exceeds what the account's available funds can margin at the broker's
|
||||
rate for the symbol (Tradeify: 5:1 crypto, 2:1 stocks — read from DXtrade,
|
||||
nothing to set). A batch sizes its leg on every candidate and trades the
|
||||
cheapest (spread, commissions, swap) that reaches the target hold; if the
|
||||
leverage caps keep all of them short of it, the quickest.
|
||||
- **Financing %/day** if the firm charges swap (Tradeify 1-Step: `0.033`).
|
||||
Each take-profit then also carries the swap a winning leg is expected to pay
|
||||
before it fills, so a win still clears the full leg.
|
||||
|
||||
Per account type, in the **Hard Daily Loss** column: the **daily loss limit**
|
||||
(Tradeify: 3% of size — every leg then carries a stop that keeps the firm's
|
||||
day inside it, less fees and a reserve of 10% of the limit or 0.1% of the
|
||||
position, whichever is more — 0.02% on Vest, whose stops fill closer) and the **static max drawdown** (Tradeify
|
||||
1-Step: $6,000 on 100K). When hitting the day's loss limit would leave the
|
||||
account within 15% of that loss above its max-drawdown floor, the leg is a
|
||||
*last stand*: no daily-loss stop, running to its take-profit or the firm's
|
||||
breach.
|
||||
|
||||
Before the scheduler trades a DXtrade firm, run the smoke test against a real
|
||||
login (with this instance's client for the firm disconnected):
|
||||
|
||||
```bash
|
||||
DX_BASE=https://dx.tradeify247.co/dxsca-web DX_USER=… DX_PASS=… DX_SYMBOLS=BTC/USD,ETH/USD node scripts/dxtrade-smoke.mjs
|
||||
```
|
||||
|
||||
It is read-only unless `DX_CONFIRM=yes DX_ACCOUNT=<code>` are set too, which
|
||||
adds one minimum-size live round trip.
|
||||
|
||||
`npm run check` runs the pure-logic checks (fees, stops, sizing, trading
|
||||
hours, the DXtrade selector, the futures draw and instrument lists), plus local
|
||||
HTTP/WebSocket checks against a temporary database.
|
||||
|
||||
### Outbound user agent
|
||||
|
||||
On first server load, each instance randomly selects a desktop Chrome,
|
||||
Firefox, or Edge user agent and saves it as `user_agent` in its settings.
|
||||
Settings → Network shows the selected value. It stays the same across requests,
|
||||
WebSocket reconnects, page reloads, and server restarts, and travels with backups.
|
||||
An existing saved value is reused; an absent or blank value is generated automatically.
|
||||
|
||||
All server Axios calls use `lib/http-client.ts`; import Axios from
|
||||
`@/lib/http-client` when adding requests. Its defaults and interceptor also apply
|
||||
to `axios.create()` clients, so per-request headers cannot accidentally replace
|
||||
the saved user agent. ESLint rejects direct application imports of the Axios package.
|
||||
Use `brokerWebSocket` or `signalrWebSocket` from `lib/outbound-proxy.ts` for broker
|
||||
sockets, and `directWebSocket` for control sockets. All share the same user agent;
|
||||
the broker proxy still applies only to broker traffic.
|
||||
|
||||
## Vest firms (Vest Markets)
|
||||
|
||||
Vest (next.vestmarkets.com) is a perps prop firm: index, metal and energy perps
|
||||
on CME hours, plus stocks, FX and crypto. Set it up on the master's **Firms**
|
||||
page with platform **Vest**. It shares the DXtrade engine: its own symbols
|
||||
(new firms start on `NDX-USD-PERP`, `SPX-USD-PERP`, `GC-PERP`, standing in for
|
||||
NQ, ES and GC), notional sizing from volatility, percent fees, hard daily-loss
|
||||
stops, and a scheduler lane of its own. Leave **Trades every day** off for the
|
||||
index and metal perps: they close for weekends and 5–6 PM ET.
|
||||
|
||||
**Login is relayed, not stored.** Vest signs people in with Google (through
|
||||
Privy) and renews the session with a device key the browser keeps, so this
|
||||
instance cannot log in by itself. Instead:
|
||||
|
||||
1. In Chrome on this machine (with the AutoFirmer extension, 0.17.0+), open
|
||||
next.vestmarkets.com and sign in. The person signs in once; Vest keeps the
|
||||
session for 30 days.
|
||||
2. The extension relays the tab's short-lived access token to this dashboard
|
||||
(`POST /api/vest/session`, held in memory only) every time the Vest app
|
||||
renews it. If no Vest tab is open, the extension opens one in the background.
|
||||
3. The Vest firm's credential on this dashboard is the **wallet address** of
|
||||
that login, and it binds itself: the first relayed session fills it in when
|
||||
the firm has none. A saved wallet is never replaced. If the tab is signed in
|
||||
to a different login, the firm says so; remove its credential to switch.
|
||||
With two logins relayed at once, nothing binds by itself, and the credential
|
||||
form offers each relayed wallet with one click.
|
||||
|
||||
With no live session for the wallet, the client sits unauthenticated and says
|
||||
so; it never tries to log in.
|
||||
|
||||
What the client reads from Vest (lib/vest-class.ts, mapped from the web app's
|
||||
own traffic since Vest publishes no API):
|
||||
|
||||
- Accounts: every eval and funded account on the login, named for their rules,
|
||||
e.g. `V50K-E-D3-T10-34` (50K eval, 3% daily loss, 10% target, attempt 34),
|
||||
`V50K-F-D3-34` (funded) and `V25K-I-7` (instant). Account-type prefixes on the
|
||||
master match those names.
|
||||
- Floors: Vest states each account's max-drawdown floor (the auto-liq threshold
|
||||
here) and daily-loss floor. The engine's daily room is never more than Vest
|
||||
leaves.
|
||||
- Fees and leverage: each symbol's taker fee (0.25 bp on the index and metal
|
||||
perps) and margin rate, from Vest's exchange info. Set the firm's
|
||||
**Commission %** to the same (`0.0025`) for the pre-trade checks.
|
||||
- Exits: the take-profit and stop are legs of the position on Vest's side,
|
||||
and Vest cancels the other when one triggers.
|
||||
- Trades: every position on the accounts, the bot's and anyone else's, from
|
||||
Vest's position history (`GET /v3/positions`) — size, open and close price,
|
||||
P&L, fees, funding and why it closed (stop loss, take profit, liquidation,
|
||||
or an order on the account). Read hourly and a minute after a position
|
||||
closes, into the `trades` table. Vest can't tell an API order from a click,
|
||||
so the bot marks its own opens and closes as it sends them; anything
|
||||
unmarked since marking began was someone else's. The **Trades** button on
|
||||
the Vest firm's dashboard row lists them, and the account page shows its own.
|
||||
- Claims: the bot claims a funded account's profit by itself at each stage's
|
||||
target — whether or not the scheduler is running (a claim opens nothing).
|
||||
The dashboard's **Auto-claim payouts** box turns it off (`auto_claim`). A
|
||||
claim holds every further claim on the account until Vest's transactions
|
||||
show its WITHDRAW; one whose request failed in transit holds them too, until
|
||||
the transactions show it went in, or an hour of them shows it didn't (one
|
||||
that never went out, for want of an account token, holds nothing). Each
|
||||
claim counts as its own payout in the report, even two on one Vest day, and
|
||||
the cycle a day's second claim starts is sent with its own `cycleSeq` (the
|
||||
master needs to key on it to keep that cycle apart). The account page's **Claim
|
||||
now** button claims the whole profit above the account size at once — any
|
||||
amount from $1 — when the account is flat; it asks first, since it moves
|
||||
money to the login's main account.
|
||||
|
||||
`npm run check` includes the Vest helpers (scripts/check-vest.mjs).
|
||||
|
||||
---
|
||||
|
||||
## The master dashboard
|
||||
|
||||
Set **Instance Name** and **Dashboard URL** on the Settings page and this
|
||||
@@ -355,13 +535,32 @@ nothing updates** — signs it, and publishes the CRX and update manifest. See
|
||||
|
||||
## Updating
|
||||
|
||||
Once auto-start is installed, nothing here is manual. Every 5 minutes the update
|
||||
task fetches `master`, and when it has moved it pulls, reinstalls dependencies if
|
||||
`package-lock.json` or `clicker/requirements.txt` changed, rebuilds, restarts
|
||||
AutoFirmer, and restarts the clicker if anything under `clicker/` changed.
|
||||
Once auto-start is installed, nothing here is manual. Every minute (the
|
||||
installer's default interval) the update check fetches `master`. When there is something to do, it starts a background
|
||||
worker. The worker pulls, reinstalls dependencies if `package-lock.json` or
|
||||
`clicker/requirements.txt` changed, rebuilds, restarts every instance, and
|
||||
restarts the clicker if anything under `clicker/` changed.
|
||||
|
||||
**A failed build is never deployed.** The build runs before anything restarts, so
|
||||
a broken push leaves the previous build serving and logs the failure instead.
|
||||
**Why a check and a separate worker.** PM2 runs the check as a cron *restart*,
|
||||
and a restart kills a run that is still going at the next minute boundary. An
|
||||
npm install or a build takes longer than that, so doing the work in the cron
|
||||
process itself got it killed half-way through. The check finishes in seconds,
|
||||
and the worker runs outside PM2's process tree, where the next tick can't
|
||||
reach it.
|
||||
|
||||
**Deployed means built, not pulled.** `scripts/.built-commit` records the commit
|
||||
`.next` was last built from, and the check compares `master` with that, not with
|
||||
HEAD. A pull that was never built is therefore finished on the next check, and
|
||||
so is a missing `.next/BUILD_ID`. The first time this version runs on a machine
|
||||
it judges the build already on disk: one that is complete, newer than HEAD and
|
||||
already serving is adopted, and anything else is rebuilt.
|
||||
|
||||
**A failed build is never deployed.** The build runs before anything restarts,
|
||||
and the previous build is set aside as `.next-prev` first. A failed build is
|
||||
rolled back to it, so the box is left serving the previous build and a later
|
||||
restart or reboot still has one. A failure is retried 15 minutes later, then
|
||||
hourly until it succeeds or a new commit lands. `scripts/.update-failed` records
|
||||
which step failed and when the next try is due.
|
||||
|
||||
### Checking it is alive
|
||||
|
||||
@@ -378,19 +577,27 @@ cron-restart process as `stopped` with a restart count of `0` even while it is
|
||||
firing on schedule. Neither is evidence of a problem; `.last-check` is the
|
||||
signal to trust.
|
||||
|
||||
Everything that actually happens is appended to `scripts/update.log`. To see
|
||||
what it would do without touching anything:
|
||||
While a worker is running, `.last-check` says `update in progress` with its pid.
|
||||
After a failure it says `waiting to retry` with the time of the next attempt.
|
||||
|
||||
Everything that actually happens is appended to `scripts/update.log`. The full
|
||||
npm and build output of the latest worker run is in `scripts/update-worker.log`,
|
||||
which is the first place to look when an update failed. To see what it would do
|
||||
without touching anything:
|
||||
|
||||
```powershell
|
||||
node scripts\update-check.mjs --dry-run
|
||||
```
|
||||
|
||||
To apply an update immediately rather than waiting for the next check:
|
||||
To apply an update immediately in the foreground, bypassing the retry wait:
|
||||
|
||||
```powershell
|
||||
node scripts\update-check.mjs
|
||||
node scripts\update-check.mjs --apply
|
||||
```
|
||||
|
||||
Without `--apply` it does what the schedule does: it checks, starts a worker if
|
||||
there is anything to do, and returns straight away.
|
||||
|
||||
### What reloads itself, and what does not
|
||||
|
||||
- **The extension** updates itself now. `/api/autobuyer/status` reports the
|
||||
|
||||
+168
-39
@@ -17,6 +17,8 @@ import {
|
||||
import type { FirmConfig, FirmState, AccountState, AccountConfig } from '@/types';
|
||||
import { stagePillClasses } from '@/lib/stage-colors';
|
||||
import { projectPerfectPath } from '@/lib/trading-logic';
|
||||
import { RulesDate } from '@/app/components/RulesDate';
|
||||
import { TradesTable } from '@/app/components/TradesTable';
|
||||
|
||||
/** Reassign this account to another of its firm's account types, or hand it
|
||||
* back to the auto-resolver. Mirrors the control on the dashboard. */
|
||||
@@ -81,6 +83,106 @@ function fmt(value: number) {
|
||||
return value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
/** Claim now: the account's whole profit above its size, to the firm's main
|
||||
* account (Vest's Claim Profit) — for payout accounts on a firm that takes
|
||||
* claims by API. Confirmed inline, since it moves money. */
|
||||
function ClaimNowButton({ accountId, onDone }: { accountId: number; onDone: () => void }) {
|
||||
type Claimable = { claimable: number; target: number | null; firm: string } | { claimable: null; reason: string; unsupported: boolean };
|
||||
const [info, setInfo] = useState<Claimable | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
|
||||
const check = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/accounts/${accountId}/claim`, { cache: 'no-store' });
|
||||
if (res.ok) setInfo(await res.json());
|
||||
} catch {
|
||||
/* keep the last answer */
|
||||
}
|
||||
}, [accountId]);
|
||||
|
||||
useEffect(() => {
|
||||
void check();
|
||||
const t = setInterval(() => { void check(); }, 10_000);
|
||||
return () => clearInterval(t);
|
||||
}, [check]);
|
||||
|
||||
async function claim(amount: number) {
|
||||
setBusy(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const res = await fetch(`/api/accounts/${accountId}/claim`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ amount }),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||
setMessage({
|
||||
ok: true,
|
||||
text: `Claimed $${fmt(body.claimed)}${body.received != null ? ` — $${fmt(body.received)} to your main account` : ''} · stage ${body.stage} next`,
|
||||
});
|
||||
onDone();
|
||||
} catch (err) {
|
||||
setMessage({ ok: false, text: err instanceof Error ? err.message : 'Claim failed' });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setConfirming(false);
|
||||
void check();
|
||||
}
|
||||
}
|
||||
|
||||
if (!info || (info.claimable === null && info.unsupported)) return null;
|
||||
|
||||
if (confirming && info.claimable !== null) {
|
||||
const amount = info.claimable;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-600">
|
||||
Claim ${fmt(amount)} to your {info.firm} main account? The firm pays your split of it.
|
||||
</span>
|
||||
<button
|
||||
onClick={() => { void claim(amount); }}
|
||||
disabled={busy}
|
||||
className="px-2.5 py-1 bg-emerald-600 hover:bg-emerald-700 disabled:opacity-50 text-white text-xs font-semibold rounded-md transition-colors"
|
||||
>
|
||||
{busy ? 'Claiming…' : `Claim $${fmt(amount)}`}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirming(false)}
|
||||
disabled={busy}
|
||||
className="px-2.5 py-1 bg-slate-100 hover:bg-slate-200 disabled:opacity-50 text-slate-600 text-xs font-semibold rounded-md transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{message && <span className={`text-xs ${message.ok ? 'text-emerald-600' : 'text-red-500'}`}>{message.text}</span>}
|
||||
{info.claimable !== null ? (
|
||||
<button
|
||||
onClick={() => { setMessage(null); setConfirming(true); }}
|
||||
className="px-2.5 py-1 bg-emerald-50 hover:bg-emerald-100 text-emerald-700 text-xs font-semibold rounded-md transition-colors"
|
||||
title={`Claim the whole profit above the account size now${info.target !== null ? ` (the bot claims by itself at the stage's $${fmt(info.target)} target)` : ''}`}
|
||||
>
|
||||
Claim now · ${fmt(info.claimable)}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className="px-2.5 py-1 bg-slate-100 text-slate-400 text-xs font-semibold rounded-md cursor-not-allowed"
|
||||
title={info.reason}
|
||||
>
|
||||
Claim now — {info.reason}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Wipes the account's cached P&L / fund / meta rows and refetches from
|
||||
* Tradovate. Confirmed inline because rows older than the report's 28-day
|
||||
* window cannot come back. */
|
||||
@@ -180,6 +282,7 @@ function ObjRow({ passed, label, value }: { passed: boolean; label: ReactNode; v
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function EquityTooltip({ active, payload, label }: any) {
|
||||
if (!active || !payload?.length) return null;
|
||||
// Always read from the raw data point so fill-only series don't interfere
|
||||
@@ -385,9 +488,11 @@ export default function AccountPage() {
|
||||
}, [accountId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// The first load from a timer too, like the polls: not setState
|
||||
// from the effect's own body.
|
||||
const first = setTimeout(() => { void load(); }, 0);
|
||||
const interval = setInterval(() => { void load(); }, 5000);
|
||||
return () => clearInterval(interval);
|
||||
return () => { clearTimeout(first); clearInterval(interval); };
|
||||
}, [load]);
|
||||
|
||||
if (!account) {
|
||||
@@ -409,8 +514,14 @@ export default function AccountPage() {
|
||||
const fundTransactions = account.fundTransactions ?? [];
|
||||
const fundMap = new Map(fundTransactions.map((f) => [f.date, f.amount]));
|
||||
|
||||
const hasLossLimit = cfg != null && cfg.minDayPnL !== -999;
|
||||
const daysPassed = cfg != null && account.daysTraded >= cfg.minTradingDays;
|
||||
// The firm's hard daily loss limit, and the least a day must make to count
|
||||
// as a trading day — two different numbers; neither is set on every type.
|
||||
// The limit and the min days are the current stage's, which may set its own.
|
||||
const stageLossLimit = account.effectiveDailyLossLimit ?? cfg?.dailyLossLimit ?? 0;
|
||||
const dailyLossLimit = stageLossLimit > 0 ? stageLossLimit : null;
|
||||
const minDayPnL = (cfg?.minDayPnL ?? 0) > 0 ? cfg!.minDayPnL : null;
|
||||
const minTradingDays = account.effectiveMinTradingDays ?? cfg?.minTradingDays ?? 0;
|
||||
const daysPassed = cfg != null && account.daysTraded >= minTradingDays;
|
||||
// Dead when balance hits the liquidation floor — Tradovate reports its
|
||||
// (positive) floor; TopstepX floors are reconstructed MLLs, negative or
|
||||
// +$0.01 once locked at breakeven. 0 = no floor known.
|
||||
@@ -427,7 +538,6 @@ export default function AccountPage() {
|
||||
const profitPassed = cfg != null && totalProfit >= (account.effectiveProfitTarget ?? cfg.profitTarget);
|
||||
// dailyTarget is computed server-side in the state API — single source of truth.
|
||||
const dailyTarget = account.dailyTarget ?? null;
|
||||
const lossPassed = !hasLossLimit || (cfg != null && totalProfit >= cfg.minDayPnL);
|
||||
|
||||
// Consistency target: the total profit level at which the best day no longer
|
||||
// violates the consistency ratio. Only meaningful once a positive day exists.
|
||||
@@ -448,13 +558,19 @@ export default function AccountPage() {
|
||||
}
|
||||
const sortedEvents = [...allDates].sort().map((d) => eventsByDate.get(d)!);
|
||||
|
||||
// Running equity after each event, summed here rather than inside the
|
||||
// map below: a render callback mustn't reassign an outer variable.
|
||||
const runningAfter: number[] = [];
|
||||
let running = 0;
|
||||
for (const ev of sortedEvents) {
|
||||
if (ev.fundAmt !== undefined) running += ev.fundAmt; // withdrawals reduce equity
|
||||
if (ev.pnl !== undefined) running += ev.pnl;
|
||||
runningAfter.push(running);
|
||||
}
|
||||
const equityData = [
|
||||
{ label: '', equity: 0, pnl: 0, origin: true, pos: 0, neg: 0, isWithdrawal: false },
|
||||
...sortedEvents.map((ev) => {
|
||||
if (ev.fundAmt !== undefined) running += ev.fundAmt; // withdrawals reduce equity
|
||||
if (ev.pnl !== undefined) running += ev.pnl;
|
||||
const equity = Math.round(running * 100) / 100;
|
||||
...sortedEvents.map((ev, i) => {
|
||||
const equity = Math.round(runningAfter[i] * 100) / 100;
|
||||
return {
|
||||
label: fmtDate(ev.date),
|
||||
equity,
|
||||
@@ -493,7 +609,7 @@ export default function AccountPage() {
|
||||
// books exactly its plan, cycles complete, and the configured withdrawal
|
||||
// fires the day a cycle finishes. Same planner the trade path runs, so the
|
||||
// numbers (bumps and drain amounts included) are what would actually be
|
||||
// aimed for. Shown up to TWO payouts ahead.
|
||||
// aimed for. Shown up to TWO payout days ahead.
|
||||
const rawProjection = (cfg && !isDead) ? projectPerfectPath({
|
||||
accountId: account.id,
|
||||
cfg,
|
||||
@@ -501,7 +617,15 @@ export default function AccountPage() {
|
||||
fundTransactions,
|
||||
balance: account.amount,
|
||||
realizedPnL: account.realizedPnL,
|
||||
maxWithdrawals: 2,
|
||||
// The day the firm's numbers follow (Vest's, until 20:00 ET).
|
||||
rawTradeDate: account.tradeDate,
|
||||
// The state stage counts the payouts that pick it: today's for a type
|
||||
// whose stages reset each day, else every one, each on its own.
|
||||
stageInputs: cfg.stagesSameDay === true
|
||||
? { stagesSameDay: true, sameDayWithdrawals: account.stage - 1 }
|
||||
: { payouts: account.stage - 1 },
|
||||
// Such a type pays out once per stage on each of those days.
|
||||
maxWithdrawals: cfg.stagesSameDay === true ? 2 * (1 + cfg.withdrawalStages.length) : 2,
|
||||
payout: {
|
||||
withdrawalPct: cfg.withdrawalPct,
|
||||
payoutCap: cfg.payoutCap,
|
||||
@@ -517,23 +641,21 @@ export default function AccountPage() {
|
||||
: undefined,
|
||||
},
|
||||
}) : [];
|
||||
// Cut the projection at the second payout — days beyond it are noise here.
|
||||
const secondPayoutIdx = (() => {
|
||||
let seen = 0;
|
||||
for (let i = 0; i < rawProjection.length; i++) {
|
||||
const p = rawProjection[i];
|
||||
if (p.withdrawal !== undefined || p.payoutReady) {
|
||||
seen++;
|
||||
if (seen >= 2 || p.payoutReady) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
})();
|
||||
const projection = secondPayoutIdx >= 0 ? rawProjection.slice(0, secondPayoutIdx + 1) : rawProjection;
|
||||
// Cut the projection after the second payout day — days beyond it are
|
||||
// noise here. The path's dates never go back, and a payoutReady entry is
|
||||
// always its last.
|
||||
const payoutDays = [...new Set(rawProjection.filter((p) => p.withdrawal !== undefined || p.payoutReady).map((p) => p.date))];
|
||||
const projection = payoutDays.length >= 2 ? rawProjection.filter((p) => p.date <= payoutDays[1]) : rawProjection;
|
||||
// A payout day is the cycle's FINAL trading day — it can carry both the
|
||||
// day's booked P&L and the withdrawal.
|
||||
const projPnlMap = new Map(projection.filter((p) => p.pnl !== 0).map((p) => [p.date, p.pnl]));
|
||||
const projFundMap = new Map(projection.filter((p) => p.withdrawal !== undefined).map((p) => [p.date, p.withdrawal!]));
|
||||
// day's booked P&L and the withdrawal. A same-day type's day can hold
|
||||
// several cycles: their P&L and withdrawals add up, and the day shows
|
||||
// the last stage it traded.
|
||||
const projPnlMap = new Map<string, number>();
|
||||
const projFundMap = new Map<string, number>();
|
||||
for (const p of projection) {
|
||||
if (p.pnl !== 0) projPnlMap.set(p.date, Math.round(((projPnlMap.get(p.date) ?? 0) + p.pnl) * 100) / 100);
|
||||
if (p.withdrawal !== undefined) projFundMap.set(p.date, Math.round(((projFundMap.get(p.date) ?? 0) + p.withdrawal) * 100) / 100);
|
||||
}
|
||||
const projStageMap = new Map(projection.map((p) => [p.date, p.stage]));
|
||||
/** Cumulative probability of the perfect path surviving through each projected day. */
|
||||
const projProbMap = new Map(projection.map((p) => [p.date, p.prob]));
|
||||
@@ -548,14 +670,20 @@ export default function AccountPage() {
|
||||
];
|
||||
const calendarMonths = [...new Set(allCalendarDates.map((d) => d.slice(0, 7)))].sort();
|
||||
|
||||
// Build stage map: stage increments after each withdrawal (negative fund txn)
|
||||
// Build stage map: stage increments after each withdrawal (negative fund txn).
|
||||
// A type whose stages reset each day starts every day on stage 1 and
|
||||
// trades its next stage after a payout that day, as the projected days
|
||||
// show — one fund row a day holds all of that day's payouts, so a day
|
||||
// with one reads stage 2.
|
||||
const sortedWithdrawalDates = fundTransactions
|
||||
.filter((f) => f.amount < 0)
|
||||
.map((f) => f.date)
|
||||
.sort();
|
||||
const stageMap = new Map<string, number>();
|
||||
for (const d of displayPnL) {
|
||||
const stage = sortedWithdrawalDates.filter((wd) => wd < d.date).length + 1;
|
||||
const stage = cfg?.stagesSameDay === true
|
||||
? sortedWithdrawalDates.filter((wd) => wd === d.date).length + 1
|
||||
: sortedWithdrawalDates.filter((wd) => wd < d.date).length + 1;
|
||||
stageMap.set(d.date, stage);
|
||||
}
|
||||
|
||||
@@ -576,6 +704,7 @@ export default function AccountPage() {
|
||||
onAssigned={() => { void load(); }}
|
||||
/>
|
||||
)}
|
||||
<RulesDate account={account} firm={firm} onSaved={() => { void load(); }} />
|
||||
<span className="text-slate-300">/</span>
|
||||
<h1 className="text-slate-900 font-bold text-sm">{account.name}</h1>
|
||||
{isDead && (
|
||||
@@ -583,7 +712,10 @@ export default function AccountPage() {
|
||||
☠ DEAD
|
||||
</span>
|
||||
)}
|
||||
<WipeDataButton accountId={account.id} onDone={() => { void load(); }} />
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<ClaimNowButton accountId={account.id} onDone={() => { void load(); }} />
|
||||
<WipeDataButton accountId={account.id} onDone={() => { void load(); }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isDead && (
|
||||
@@ -610,7 +742,8 @@ export default function AccountPage() {
|
||||
positive={profitPct != null && profitPct >= 0}
|
||||
/>
|
||||
<PerfRow label="Trading Days" value={account.daysTraded} />
|
||||
<PerfRow label="Daily Loss Limit" value={hasLossLimit ? `$${fmt(cfg!.minDayPnL)}` : 'None'} />
|
||||
<PerfRow label="Daily Loss Limit" value={dailyLossLimit !== null ? `$${fmt(dailyLossLimit)}` : 'None'} />
|
||||
<PerfRow label="Min Day P&L" value={minDayPnL !== null ? `$${fmt(minDayPnL)}` : 'None'} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col w-1/2 bg-white border border-slate-200 rounded-xl shadow-sm p-6">
|
||||
@@ -630,15 +763,8 @@ export default function AccountPage() {
|
||||
<ObjRow
|
||||
passed={daysPassed}
|
||||
label="Trading Days"
|
||||
value={cfg ? `${account.daysTraded} of ${cfg.minTradingDays}` : '—'}
|
||||
value={cfg ? `${account.daysTraded} of ${minTradingDays}` : '—'}
|
||||
/>
|
||||
{hasLossLimit && (
|
||||
<ObjRow
|
||||
passed={lossPassed}
|
||||
label="Daily Loss Limit"
|
||||
value={`$${fmt(cfg!.minDayPnL)}`}
|
||||
/>
|
||||
)}
|
||||
{dailyTarget != null && (
|
||||
<div className="flex items-center justify-between py-3 border-b border-slate-100 last:border-0">
|
||||
<span className="text-slate-500 text-sm">Next Trading Day Amount</span>
|
||||
@@ -790,6 +916,7 @@ export default function AccountPage() {
|
||||
stroke={`url(#${strokeGradId})`}
|
||||
strokeWidth={2.5}
|
||||
fill="none"
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
dot={(props: any) => {
|
||||
if (props.payload?.origin) return <g key={props.key} />;
|
||||
return (
|
||||
@@ -840,6 +967,8 @@ export default function AccountPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TradesTable query={`accountId=${accountId}`} title="Trades" />
|
||||
|
||||
{/* Cash History Table */}
|
||||
{(displayPnL.length > 0 || fundTransactions.length > 0) && (
|
||||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-6 mt-5">
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getClients } from '@/lib/clients';
|
||||
import { getAccountConfig } from '@/lib/account-type-resolver';
|
||||
import { wipeAccountData } from '@/lib/db';
|
||||
|
||||
/**
|
||||
@@ -20,26 +21,25 @@ export async function POST(_req: Request, { params }: { params: Promise<{ id: st
|
||||
const account = client.accountList.find((a) => a.id === accountId);
|
||||
if (!account) continue;
|
||||
|
||||
wipeAccountData(accountId);
|
||||
// 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.
|
||||
delete client.dailyPnL[accountId];
|
||||
delete client.fullDailyPnL[accountId];
|
||||
delete client.daysTraded[accountId];
|
||||
delete client.priorProfit[accountId];
|
||||
delete client.lastFundDates[accountId];
|
||||
delete client.fundTransactions[accountId];
|
||||
delete client.lastFetchErrors[account.name];
|
||||
delete client.lastFetchRaw[account.name];
|
||||
// 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] ?? null,
|
||||
fetchError: client.lastFetchErrors[account.name]
|
||||
?? (loaded ? null : 'History did not reload — the account stays out of trading until it does'),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getClients } from '@/lib/clients';
|
||||
import { getAccountConfig } from '@/lib/account-type-resolver';
|
||||
import { wipeAccountData } from '@/lib/db';
|
||||
|
||||
/**
|
||||
@@ -19,20 +20,23 @@ export async function POST() {
|
||||
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);
|
||||
delete client.dailyPnL[account.id];
|
||||
delete client.fullDailyPnL[account.id];
|
||||
delete client.daysTraded[account.id];
|
||||
delete client.priorProfit[account.id];
|
||||
delete client.lastFundDates[account.id];
|
||||
delete client.fundTransactions[account.id];
|
||||
delete client.lastFetchErrors[account.name];
|
||||
delete client.lastFetchRaw[account.name];
|
||||
wiped++;
|
||||
}
|
||||
const result = await client.fetchDaysTraded();
|
||||
failedAccounts += result.failedAccounts;
|
||||
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();
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { getClients, resetClients } from '@/lib/clients';
|
||||
import { getTradableFirms } from '@/lib/firm-catalog';
|
||||
import { getAccountConfig } from '@/lib/account-type-resolver';
|
||||
import { combineCmeSessions, computeDayPlan, resolveEffectiveConfig, currentTradeDateCT } from '@/lib/trading-logic';
|
||||
import axios from 'axios';
|
||||
import axios from '@/lib/http-client';
|
||||
|
||||
/**
|
||||
* Per-account snapshot of everything computeDayPlan sees, plus the book-balance
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCachedFirm } from '@/lib/firm-catalog';
|
||||
import { getCachedFirm, hasPlatformCredentials } from '@/lib/firm-catalog';
|
||||
import { setCredentials, clearCredentials } from '@/lib/db';
|
||||
import { reconcileClients, removeClient } from '@/lib/clients';
|
||||
import { scheduleBackupPush } from '@/lib/backup-sync';
|
||||
@@ -12,10 +12,13 @@ type Params = { params: Promise<{ id: string }> };
|
||||
* (lib/backup-sync.ts); they are still absent from the catalog and the
|
||||
* control socket.
|
||||
*
|
||||
* Tradovate firms need username + password. 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. */
|
||||
* 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;
|
||||
|
||||
@@ -27,12 +30,15 @@ export async function PUT(req: NextRequest, { params }: Params) {
|
||||
const password = body.password?.trim() ?? '';
|
||||
const apiKey = body.apiKey?.trim() ?? '';
|
||||
|
||||
if (firm.platform === 'topstepx') {
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: 'apiKey is required for a TopstepX firm' }, { status: 400 });
|
||||
}
|
||||
} else if (!username || !password) {
|
||||
return NextResponse.json({ error: 'username and password are required' }, { status: 400 });
|
||||
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 {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getClients } from '@/lib/clients';
|
||||
import axios from 'axios';
|
||||
import axios from '@/lib/http-client';
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCachedFirm } from '@/lib/firm-catalog';
|
||||
import { firmInstruments, isFuturesFirm } from '@/lib/instruments';
|
||||
import { getClients } from '@/lib/clients';
|
||||
|
||||
export async function POST(
|
||||
@@ -26,6 +28,13 @@ export async function POST(
|
||||
const client = clients.get(firmId);
|
||||
if (!client) return NextResponse.json({ error: 'Client not found for firm' }, { status: 404 });
|
||||
|
||||
// A futures firm trades only what its instrument list (master Firms
|
||||
// page) names.
|
||||
const firm = getCachedFirm(firmId);
|
||||
if (firm && isFuturesFirm(firm) && !firmInstruments(firm).includes(symbol)) {
|
||||
return NextResponse.json({ error: `${symbol} isn't on ${firm.name}'s instrument list` }, { status: 400 });
|
||||
}
|
||||
|
||||
// 1. Find the active contract chosen by the shared resolver
|
||||
const contract = await client.findFrontMonthContract(symbol);
|
||||
if (!contract) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCachedFirm } from '@/lib/firm-catalog';
|
||||
import { getCachedFirm, getKeptRulesFor } from '@/lib/firm-catalog';
|
||||
import { firmInstruments, instrumentsSource } from '@/lib/instruments';
|
||||
import { getCredentials } from '@/lib/db';
|
||||
|
||||
/** One firm from the cached catalog. Read-only: firms and their account types
|
||||
@@ -22,8 +23,13 @@ export async function GET(
|
||||
firm: firm.name,
|
||||
username: creds?.username ?? '',
|
||||
hasCredentials: !!creds,
|
||||
bannedSymbols: firm.bannedSymbols,
|
||||
accounts: firm.accountTypes,
|
||||
instruments: firmInstruments(firm),
|
||||
instrumentsSource: instrumentsSource(firm),
|
||||
// Same shape as GET /api/firms: each type with its old rule sets' windows.
|
||||
accounts: firm.accountTypes.map((a) => ({
|
||||
...a,
|
||||
kept: getKeptRulesFor(a.id).map((k) => ({ id: k.id, openedAt: k.openedAt, closedAt: k.closedAt })),
|
||||
})),
|
||||
catalogUpdatedAt: firm.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
+17
-6
@@ -1,7 +1,13 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getCachedCatalog } from '@/lib/firm-catalog';
|
||||
import { firmInstruments, instrumentsSource } from '@/lib/instruments';
|
||||
import { getCachedCatalog, getKeptRulesFor, hasPlatformCredentials } from '@/lib/firm-catalog';
|
||||
import { getCredentials } from '@/lib/db';
|
||||
import type { FirmConfig } from '@/types';
|
||||
import type { FirmConfig, KeptRulesWindow } from '@/types';
|
||||
|
||||
/** The windows of a live account type's old rule sets, by closedAt. */
|
||||
function keptWindows(accountTypeId: string): KeptRulesWindow[] {
|
||||
return getKeptRulesFor(accountTypeId).map((k) => ({ id: k.id, openedAt: k.openedAt, closedAt: k.closedAt }));
|
||||
}
|
||||
|
||||
/**
|
||||
* The firms this machine knows about — the master's catalog, annotated with
|
||||
@@ -13,21 +19,23 @@ import type { FirmConfig } from '@/types';
|
||||
*
|
||||
* The stored password is deliberately not returned. It used to be, and nothing
|
||||
* ever read it.
|
||||
*
|
||||
* Each account type carries the windows of the old rule sets it keeps (`kept`)
|
||||
* so the dashboard's rules-date control can say which rules a date lands on.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const result: FirmConfig[] = getCachedCatalog().map((firm) => {
|
||||
const creds = getCredentials(firm.id);
|
||||
const hasCredentials = firm.platform === 'topstepx'
|
||||
? !!creds?.apiKey
|
||||
: !!(creds?.username && creds?.password);
|
||||
const hasCredentials = hasPlatformCredentials(firm.platform, creds);
|
||||
return {
|
||||
id: firm.id,
|
||||
firm: firm.name,
|
||||
platform: firm.platform,
|
||||
username: creds?.username ?? '',
|
||||
hasCredentials,
|
||||
bannedSymbols: firm.bannedSymbols,
|
||||
instruments: firmInstruments(firm),
|
||||
instrumentsSource: instrumentsSource(firm),
|
||||
accounts: firm.accountTypes.map((a) => ({
|
||||
id: a.id,
|
||||
label: a.label,
|
||||
@@ -46,6 +54,9 @@ export async function GET() {
|
||||
minDayStop: a.minDayStop,
|
||||
combineCmeHolidays: a.combineCmeHolidays,
|
||||
withdrawalStages: a.withdrawalStages,
|
||||
dailyLossLimit: a.dailyLossLimit ?? 0,
|
||||
tradesEveryDay: a.tradesEveryDay === true,
|
||||
kept: keptWindows(a.id),
|
||||
})),
|
||||
catalogUpdatedAt: firm.updatedAt,
|
||||
};
|
||||
|
||||
@@ -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,8 +1,9 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getSetting, setSetting } from '@/lib/db';
|
||||
import { scheduleBackupPush } from '@/lib/backup-sync';
|
||||
import { getRequestUserAgent } from '@/lib/request-user-agent';
|
||||
|
||||
const VALID_KEYS = ['max_concurrent_accounts', 'tick_interval_seconds', 'master_dashboard_url', 'instance_name', 'trading_hours', 'twocaptcha_api_key', 'twocaptcha_proxy', 'master_backup_token', 'proxy_url'] as const;
|
||||
const VALID_KEYS = ['max_concurrent_accounts', 'tick_interval_seconds', 'master_dashboard_url', 'instance_name', 'trading_hours', 'twocaptcha_api_key', 'twocaptcha_proxy', 'master_backup_token', 'proxy_url', 'auto_claim', 'auto_activate'] as const;
|
||||
type SettingKey = typeof VALID_KEYS[number];
|
||||
|
||||
export async function GET() {
|
||||
@@ -13,6 +14,8 @@ export async function GET() {
|
||||
// Read-only on purpose (not in VALID_KEYS): the master issues this uuid,
|
||||
// and editing it by hand would detach the instance from its person.
|
||||
result.instance_id = getSetting('instance_id');
|
||||
// Generated once per instance. Callers cannot rotate an active identity.
|
||||
result.user_agent = getRequestUserAgent();
|
||||
result.backup_last_push_at = getSetting('backup_last_push_at');
|
||||
result.backup_last_error = getSetting('backup_last_error');
|
||||
return NextResponse.json(result);
|
||||
@@ -22,6 +25,15 @@ export async function PATCH(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json() as Partial<Record<SettingKey, string | number>>;
|
||||
|
||||
// Claims and activations run unless it reads exactly 'off' — say so
|
||||
// exactly, and before anything is written.
|
||||
for (const key of ['auto_claim', 'auto_activate'] as const) {
|
||||
const v = body[key];
|
||||
if (v !== undefined && v !== null && v !== 'on' && v !== 'off') {
|
||||
return NextResponse.json({ error: `${key} is 'on' or 'off'` }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of VALID_KEYS) {
|
||||
if (key in body) {
|
||||
const raw = body[key];
|
||||
|
||||
+120
-31
@@ -1,19 +1,24 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { loadDailyPnL } from '@/lib/db';
|
||||
import { listAssignments, loadDailyPnL } from '@/lib/db';
|
||||
import { getClients } from '@/lib/clients';
|
||||
import { getCachedCatalog } from '@/lib/firm-catalog';
|
||||
import { getAccountTypeById, getCachedCatalog } from '@/lib/firm-catalog';
|
||||
import { ruleDiffs } from '@/lib/kept-rules';
|
||||
import { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/account-type-resolver';
|
||||
import { historyFresh } from '@/lib/platform-client';
|
||||
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, currentTradeDateCT,
|
||||
BIG_DAY_THRESHOLD, FIRST_DAY_STOP, LEG_COMPLETION_EPSILON, MIN_DAY_STOP_MULT,
|
||||
combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, tradeDateFor,
|
||||
obfuscationStopDollars, dllStopDollars, botStopsActive, stageInputsFor, stagePayouts,
|
||||
maxLossRoom, lastStand as isLastStand, shortestNextLeg,
|
||||
LEG_COMPLETION_EPSILON,
|
||||
} from '@/lib/trading-logic';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const firms = getCachedCatalog();
|
||||
const clients = getClients();
|
||||
const tradeDate = currentTradeDateCT();
|
||||
|
||||
const state = firms.map((f) => {
|
||||
const client = clients.get(f.id);
|
||||
@@ -24,6 +29,14 @@ export async function GET() {
|
||||
if (!client || client.accountList.length === 0) {
|
||||
return { firmId: f.id, firm: f.name, connected, accounts: [] };
|
||||
}
|
||||
// The day the firm's accounts trade on (tradeDateFor), as the
|
||||
// engine plans them — Vest's turns at 20:00 ET, not 5 PM CT.
|
||||
const tradeDate = tradeDateFor(client);
|
||||
// Accounts whose rules date was set by hand — one read per firm
|
||||
// rather than one per account.
|
||||
const rulesDateByHand = new Set(listAssignments(f.id)
|
||||
.filter((row) => row.rules_date_manual === 1)
|
||||
.map((row) => row.account_id));
|
||||
const accounts = client.accountList.map((acc) => {
|
||||
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
|
||||
|
||||
@@ -50,8 +63,11 @@ export async function GET() {
|
||||
const isDead = autoLiqThreshold !== 0 && cash.amount <= autoLiqThreshold;
|
||||
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
|
||||
const priorProfit = client.priorProfit?.[acc.id] ?? 0;
|
||||
// Stage = 1 + number of withdrawals (negative fund transactions)
|
||||
const stage = 1 + allFundTxns.filter((f) => f.amount < 0).length;
|
||||
// Stage = 1 + number of payouts (negative fund transactions, or
|
||||
// the client's own list where a row holds several), or of
|
||||
// today's for a type whose stages reset each day.
|
||||
const stageInputs = stageInputsFor(client, acc.id, cfg ?? {});
|
||||
const stage = 1 + stagePayouts(allFundTxns, stageInputs);
|
||||
// Stage-resolved config: a drain stage swaps in its own min-day
|
||||
// (default $5) and no profit target.
|
||||
const effective = cfg ? resolveEffectiveConfig(
|
||||
@@ -62,7 +78,8 @@ export async function GET() {
|
||||
cfg.withdrawalStages,
|
||||
priorProfit,
|
||||
allFundTxns,
|
||||
cfg.minDayPnL
|
||||
cfg.minDayPnL,
|
||||
stageInputs,
|
||||
) : null;
|
||||
// Days traded counts only days that hit minDayPnL (when set) — that's what
|
||||
// the firm requires toward the min trading day rule.
|
||||
@@ -79,11 +96,28 @@ export async function GET() {
|
||||
const ambiguous = cfg
|
||||
? matchAccountType(acc.name, inferredAccountSize, f.accountTypes).ambiguous
|
||||
: false;
|
||||
// An account on one of its type's old rule sets: cfg already
|
||||
// carries those rules; say which set, and how it differs from
|
||||
// the type's current rules.
|
||||
const live = resolved.keptRules && resolved.accountTypeId
|
||||
? getAccountTypeById(resolved.accountTypeId)
|
||||
: undefined;
|
||||
const keptRules: AccountState['keptRules'] = resolved.keptRules && cfg && live
|
||||
? {
|
||||
id: resolved.keptRules.id,
|
||||
openedAt: resolved.keptRules.openedAt,
|
||||
closedAt: resolved.keptRules.closedAt,
|
||||
differs: ruleDiffs(cfg, live),
|
||||
}
|
||||
: null;
|
||||
let targetHit = false;
|
||||
let dailyTarget: { amount: number; path: 'first_day' | 'normal_day' | 'reduced_day' } | null = null;
|
||||
let remainingToday: number | null = null;
|
||||
let effectiveProfitTarget: number | null = null;
|
||||
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(
|
||||
@@ -94,26 +128,68 @@ export async function GET() {
|
||||
dailyTarget = plan ? { amount: plan.dayTarget, path: plan.path } : null;
|
||||
remainingToday = plan?.remainingToday ?? null;
|
||||
|
||||
// The stop the next trade would carry under the obfuscation
|
||||
// options, mirroring obfuscationStopDollars in auto-trade:
|
||||
// day 1 keeps the day's net floor at −$1,000, min days stop
|
||||
// at 2× the target, and the day after a loss rides stopless.
|
||||
if (plan && (cfg.firstDayStop || cfg.minDayStop)) {
|
||||
const prior = dailyPnL
|
||||
.filter((d) => d.date < combined.tradeDate && d.pnl !== 0)
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
const recoveryDay = prior.length > 0 && prior[prior.length - 1].pnl < 0;
|
||||
if (!recoveryDay) {
|
||||
let stop = Infinity;
|
||||
if (cfg.firstDayStop && prior.length === 0) {
|
||||
stop = Math.max(0, Math.round((FIRST_DAY_STOP + combined.realizedPnL) * 100) / 100);
|
||||
}
|
||||
if (cfg.minDayStop && plan.dayTarget <= BIG_DAY_THRESHOLD) {
|
||||
stop = Math.min(stop, MIN_DAY_STOP_MULT * plan.dayTarget);
|
||||
}
|
||||
if (Number.isFinite(stop) && stop > 0) nextStop = Math.round(stop * 100) / 100;
|
||||
// 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
|
||||
@@ -184,9 +260,11 @@ export async function GET() {
|
||||
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,
|
||||
@@ -201,11 +279,14 @@ export async function GET() {
|
||||
// The combined day's P&L, not just the current session's —
|
||||
// on the Tuesday after a holiday this includes Sun/Mon.
|
||||
realizedPnL: combined.realizedPnL,
|
||||
// The firm's raw trade date, which the account page
|
||||
// projects the payouts from.
|
||||
tradeDate,
|
||||
daysTraded,
|
||||
// A stop-enabled account whose day booked red via its own
|
||||
// stop — the personal DLL. Firm DLL lockouts show via
|
||||
// !active instead.
|
||||
personalDll: !!cfg && (cfg.firstDayStop || cfg.minDayStop)
|
||||
// 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]
|
||||
@@ -220,8 +301,13 @@ export async function GET() {
|
||||
stageDrain: effective?.drain ?? false,
|
||||
dailyTarget,
|
||||
nextStop,
|
||||
dllRoom,
|
||||
holdEstimate,
|
||||
lastStand: lastStandNext,
|
||||
remainingToday,
|
||||
effectiveProfitTarget,
|
||||
effectiveDailyLossLimit: effective?.dailyLossLimit ?? null,
|
||||
effectiveMinTradingDays: effective?.minTradingDays ?? null,
|
||||
dailyPnL,
|
||||
fullDailyPnL: combineCmeSessions(
|
||||
cfg?.combineCmeHolidays === true,
|
||||
@@ -236,6 +322,9 @@ export async function GET() {
|
||||
accountType: cfg,
|
||||
inferredAccountSize,
|
||||
accountTypeAmbiguous: ambiguous,
|
||||
rulesDate: resolved.rulesDate,
|
||||
rulesDateManual: rulesDateByHand.has(acc.id),
|
||||
keptRules,
|
||||
};
|
||||
});
|
||||
return { firmId: f.id, firm: f.name, connected, accounts };
|
||||
|
||||
+13
-8
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -137,20 +137,25 @@ export default function FirmSettingsPage() {
|
||||
</div>
|
||||
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mt-10 mb-3">
|
||||
Banned Symbols
|
||||
Instruments
|
||||
</h2>
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4 shadow-sm">
|
||||
{!firm || firm.bannedSymbols.length === 0 ? (
|
||||
<span className="text-sm text-slate-400 italic">None</span>
|
||||
{!firm || firm.instruments.length === 0 ? (
|
||||
<span className="text-sm text-slate-400 italic">None — this firm won't trade</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{firm.bannedSymbols.map((sym) => (
|
||||
<span key={sym} className="px-2 py-1 rounded-md text-xs font-mono font-semibold bg-red-100 text-red-700">
|
||||
{firm.instruments.map((sym) => (
|
||||
<span key={sym} className="px-2 py-1 rounded-md text-xs font-mono font-semibold bg-slate-100 text-slate-700">
|
||||
{sym}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-slate-400">
|
||||
{firm?.instrumentsSource === 'legacy'
|
||||
? 'The master doesn\'t send instrument lists yet: this machine\'s enabled instruments, less the firm\'s banned symbols.'
|
||||
: 'Edited on the master\'s Firms page.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
+247
-48
@@ -4,6 +4,8 @@ import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import type { FirmConfig, FirmState, AccountState } from '@/types';
|
||||
import { stagePillClasses } from '@/lib/stage-colors';
|
||||
import { RulesDate } from '@/app/components/RulesDate';
|
||||
import { TradesTable } from '@/app/components/TradesTable';
|
||||
|
||||
type SortKey = 'name' | 'balance' | 'dayPnL' | 'daysTraded' | 'target' | 'status';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
@@ -19,6 +21,11 @@ function statusRank(account: AccountState): number {
|
||||
return 3; // In Trade
|
||||
}
|
||||
|
||||
/** Account names in counting order: XXX2 before XXX10, not after it. */
|
||||
function byName(a: AccountState, b: AccountState): number {
|
||||
return a.name.localeCompare(b.name, undefined, { numeric: true });
|
||||
}
|
||||
|
||||
function fmt(value: number) {
|
||||
return value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
@@ -96,7 +103,7 @@ function maskName(name: string, privacy: boolean): string {
|
||||
function SortHeader({ label, col, sortKey, sortDir, onSort }: {
|
||||
label: string;
|
||||
col: SortKey;
|
||||
sortKey: SortKey | null;
|
||||
sortKey: SortKey;
|
||||
sortDir: SortDir;
|
||||
onSort: (col: SortKey) => void;
|
||||
}) {
|
||||
@@ -188,10 +195,97 @@ function AccountTypeCell({ account, firm, onAssigned }: {
|
||||
);
|
||||
}
|
||||
|
||||
/** What a firm's saved login is called in the dashboard's prompts. */
|
||||
function credentialNoun(platform: FirmConfig['platform']): string {
|
||||
switch (platform) {
|
||||
case 'topstepx': return 'TopstepX API key';
|
||||
case 'dxtrade': return 'DXtrade login';
|
||||
case 'tradovate': return 'Tradovate login';
|
||||
case 'vest': return 'Vest wallet address';
|
||||
}
|
||||
}
|
||||
|
||||
/** Inline credential entry, shown to the right of a firm that has none.
|
||||
* Tradovate wants username + password; TopstepX wants the API key (username
|
||||
* optional for now, though login needs it eventually — password unused). */
|
||||
* Tradovate and DXtrade want username + password; TopstepX wants the API key
|
||||
* (username optional for now, though login needs it eventually — password
|
||||
* unused); Vest the login's wallet address, whose session the extension
|
||||
* relays from a signed-in Vest tab. */
|
||||
function CredentialForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => void }) {
|
||||
if (firm.platform === 'vest') return <VestWalletForm firm={firm} onSaved={onSaved} />;
|
||||
return <LoginForm firm={firm} onSaved={onSaved} />;
|
||||
}
|
||||
|
||||
/** A Vest firm's credential: the wallet its Vest login belongs to. It binds
|
||||
* itself as soon as the extension relays a Vest tab signed in on this
|
||||
* machine (lib/vest-wallet.ts); this form is for when it can't — two logins
|
||||
* relayed at once, or none yet — and offers the relayed wallets with one
|
||||
* click. */
|
||||
function VestWalletForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => void }) {
|
||||
const [wallet, setWallet] = useState('');
|
||||
const [seen, setSeen] = useState<string[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/vest/session', { cache: 'no-store' })
|
||||
.then((r) => r.json())
|
||||
.then((d: { sessions?: { primaryAddr: string }[] }) => setSeen([...new Set((d.sessions ?? []).map((x) => x.primaryAddr))]))
|
||||
.catch(() => setSeen([]));
|
||||
}, []);
|
||||
|
||||
async function save(address: string) {
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch(`/api/firms/${firm.id}/credentials`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ apiKey: address.trim() }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? 'Failed');
|
||||
setWallet('');
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const valid = /^0x[0-9a-fA-F]{40}$/.test(wallet.trim());
|
||||
return (
|
||||
<div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
|
||||
{seen.map((address) => (
|
||||
<button
|
||||
key={address}
|
||||
onClick={() => void save(address)}
|
||||
disabled={saving}
|
||||
title="The wallet of the Vest tab signed in on this machine"
|
||||
className="px-2 py-1 bg-emerald-100 hover:bg-emerald-200 disabled:opacity-50 text-emerald-800 text-xs font-mono rounded-md"
|
||||
>
|
||||
Use {address.slice(0, 6)}…{address.slice(-4)}
|
||||
</button>
|
||||
))}
|
||||
<input
|
||||
className="border border-slate-200 rounded-md px-2 py-1 text-xs bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 w-72 font-mono"
|
||||
placeholder={seen.length > 0 ? 'or another wallet 0x…' : 'Sign in to Vest in Chrome: it binds itself (or paste 0x…)'}
|
||||
value={wallet}
|
||||
onChange={(e) => setWallet(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && valid) void save(wallet); }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => void save(wallet)}
|
||||
disabled={saving || !valid}
|
||||
className="px-2.5 py-1 bg-blue-100 hover:bg-blue-200 disabled:opacity-50 text-blue-700 text-xs font-semibold rounded-md transition-colors"
|
||||
>
|
||||
{saving ? '…' : 'Save'}
|
||||
</button>
|
||||
{error && <span className="text-xs text-red-500">{error}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => void }) {
|
||||
const isTopstepX = firm.platform === 'topstepx';
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -231,7 +325,7 @@ function CredentialForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => vo
|
||||
<div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
className={cls}
|
||||
placeholder={isTopstepX ? 'Username (optional)' : 'Tradovate username'}
|
||||
placeholder={isTopstepX ? 'Username (optional)' : firm.platform === 'dxtrade' ? 'DXtrade username' : 'Tradovate username'}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
@@ -332,12 +426,14 @@ function WipeAllButton() {
|
||||
);
|
||||
}
|
||||
|
||||
function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
|
||||
function AccountRow({ account, firm, hideDead, privacy, onAssigned, onStateChanged }: {
|
||||
account: AccountState;
|
||||
firm: FirmConfig;
|
||||
hideDead: boolean;
|
||||
privacy: boolean;
|
||||
onAssigned: () => void;
|
||||
/** Refetches /api/state — after a rules date is saved. */
|
||||
onStateChanged: () => void;
|
||||
}) {
|
||||
const cfg = account.accountType;
|
||||
const dead = isAccountDead(account);
|
||||
@@ -349,8 +445,9 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
|
||||
return (
|
||||
<tr className={`border-b border-slate-100 hover:bg-slate-50 transition-colors${!account.active || dead ? ' opacity-50' : ''}`}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2 font-medium text-slate-800 pl-4 font-mono">
|
||||
{maskName(account.name, privacy)}
|
||||
<div className="flex items-center gap-2 pl-4">
|
||||
<span className="font-medium text-slate-800 font-mono">{maskName(account.name, privacy)}</span>
|
||||
<RulesDate account={account} firm={firm} onSaved={onStateChanged} />
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-600 tabular-nums">${fmt(account.amount)}</td>
|
||||
@@ -361,7 +458,7 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
|
||||
<AccountTypeCell account={account} firm={firm} onAssigned={onAssigned} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-600 tabular-nums">
|
||||
{account.daysTraded} / {cfg?.minTradingDays ?? '—'}
|
||||
{account.daysTraded} / {account.effectiveMinTradingDays ?? cfg?.minTradingDays ?? '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-600 tabular-nums">
|
||||
<div className="flex flex-col">
|
||||
@@ -375,7 +472,27 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
|
||||
<span className="text-xs text-slate-400">
|
||||
${fmt(account.dailyTarget.amount)} today
|
||||
{account.nextStop !== null && (
|
||||
<span className="text-amber-600" title="The next trade's stop — dollars it may lose before booking the day"> · stop ${fmt(account.nextStop)}</span>
|
||||
<span className="text-amber-600" title="The next trade's stop — dollars of price movement it may lose before booking the day (fees come on top; the daily-loss stop already reserves for them)"> · stop ${fmt(account.nextStop)}</span>
|
||||
)}
|
||||
{account.lastStand && (
|
||||
<span className="text-red-600" title="Last stand: hitting the day's loss limit would leave this account within 15% of that loss above its max-drawdown floor — nothing left to trade on — so the next leg carries no daily-loss stop and runs to its take-profit or the firm's breach"> · no stop (last stand)</span>
|
||||
)}
|
||||
{typeof account.dllRoom === 'number' && (
|
||||
<span className="text-slate-500" title="Hard daily loss limit: what the firm's day may still lose before a breach. A reserve — 10% of the limit, or 0.1% of the position if larger (0.02% on Vest) — is never spent."> · DLL room ${fmt(account.dllRoom)}</span>
|
||||
)}
|
||||
{account.holdEstimate && (
|
||||
<span
|
||||
className="text-slate-500"
|
||||
title={`Next leg on ${account.holdEstimate.symbol}: $${Math.round(account.holdEstimate.notional).toLocaleString()} notional, ${account.holdEstimate.multiplier}× the account`
|
||||
+ (account.holdEstimate.leverage !== null ? ` (${account.holdEstimate.leverage}× max)` : '')
|
||||
+ ` at a measured volatility of ${(account.holdEstimate.volatilityPerDay * 100).toFixed(2)}%/day over the past week`
|
||||
+ (account.holdEstimate.winProbability !== null ? `, wins ~${Math.round(account.holdEstimate.winProbability * 100)}% of the time` : '')
|
||||
+ (account.holdEstimate.winProbabilityNoFees !== null ? ` (~${Math.round(account.holdEstimate.winProbabilityNoFees * 100)}% with no commission, spread or swap)` : '')
|
||||
+ (account.holdEstimate.swapReserve > 0 ? `; its take-profit carries ~$${fmt(account.holdEstimate.swapReserve)} of expected swap` : '')
|
||||
+ `. Expected cost ~$${fmt(account.holdEstimate.cost)} (spread, commissions, swap) — the cheapest candidate that reaches the target hold`}
|
||||
>
|
||||
{' · hold ~'}{account.holdEstimate.hours < 48 ? `${Math.round(account.holdEstimate.hours)}h` : `${(account.holdEstimate.hours / 24).toFixed(1)}d`}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
@@ -392,7 +509,7 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
|
||||
: account.positionDirection
|
||||
? <span className={`inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold ${account.positionDirection === 'long' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>{account.positionDirection === 'long' ? 'Long' : 'Short'}</span>
|
||||
: account.personalDll
|
||||
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-700" title="Booked the day red via its own stop — make-back rides next session">Personal DLL</span>
|
||||
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-700" title="Booked the day red via its own stop (obfuscation or daily-loss) — trading resumes next session">Personal DLL</span>
|
||||
: account.targetHit
|
||||
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-700">Target Hit</span>
|
||||
: <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-100 text-slate-500">Flat</span>}
|
||||
@@ -410,17 +527,19 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
|
||||
);
|
||||
}
|
||||
|
||||
function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbol, onChanged }: {
|
||||
function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbol, onChanged, onStateChanged }: {
|
||||
state: FirmState;
|
||||
firm: FirmConfig;
|
||||
hideDead: boolean;
|
||||
privacy: boolean;
|
||||
sortKey: SortKey | null;
|
||||
sortKey: SortKey;
|
||||
sortDir: SortDir;
|
||||
tradeSymbol: string;
|
||||
onChanged: () => void;
|
||||
onStateChanged: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [showTrades, setShowTrades] = useState(false);
|
||||
const [confirmCreds, setConfirmCreds] = useState(false);
|
||||
const [removingCreds, setRemovingCreds] = useState(false);
|
||||
const hasCredentials = firm.hasCredentials;
|
||||
@@ -440,20 +559,22 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
||||
}
|
||||
}
|
||||
|
||||
const sortedAccounts = sortKey === null ? state.accounts : [...state.accounts].sort((a, b) => {
|
||||
// Ties on any other column fall back to name order, so equal rows don't
|
||||
// shuffle with the broker's list between syncs.
|
||||
const sortedAccounts = [...state.accounts].sort((a, b) => {
|
||||
const dir = sortDir === 'asc' ? 1 : -1;
|
||||
switch (sortKey) {
|
||||
case 'name': return dir * a.name.localeCompare(b.name);
|
||||
case 'balance': return dir * (a.amount - b.amount);
|
||||
case 'dayPnL': return dir * (a.realizedPnL - b.realizedPnL);
|
||||
case 'daysTraded': return dir * (a.daysTraded - b.daysTraded);
|
||||
case 'name': return dir * byName(a, b);
|
||||
case 'balance': return dir * (a.amount - b.amount) || byName(a, b);
|
||||
case 'dayPnL': return dir * (a.realizedPnL - b.realizedPnL) || byName(a, b);
|
||||
case 'daysTraded': return dir * (a.daysTraded - b.daysTraded) || byName(a, b);
|
||||
case 'target': {
|
||||
const at = a.effectiveProfitTarget ?? a.accountType?.profitTarget ?? 0;
|
||||
const bt = b.effectiveProfitTarget ?? b.accountType?.profitTarget ?? 0;
|
||||
return dir * (at - bt);
|
||||
return dir * (at - bt) || byName(a, b);
|
||||
}
|
||||
case 'status': return dir * (statusRank(a) - statusRank(b));
|
||||
default: return 0;
|
||||
case 'status': return dir * (statusRank(a) - statusRank(b)) || byName(a, b);
|
||||
default: return byName(a, b);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -468,9 +589,9 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="font-bold text-sm text-slate-800">{firm.firm}</span>
|
||||
{firm.bannedSymbols?.includes(tradeSymbol) && (
|
||||
<span className="text-xs bg-red-100 text-red-700 px-1.5 py-0.5 rounded font-semibold">
|
||||
{tradeSymbol} BANNED
|
||||
{firm.platform !== 'dxtrade' && firm.platform !== 'vest' && tradeSymbol !== 'Auto' && !firm.instruments?.includes(tradeSymbol) && (
|
||||
<span className="text-xs bg-red-100 text-red-700 px-1.5 py-0.5 rounded font-semibold" title="Not on this firm's instrument list (master Firms page) — it sits this symbol out">
|
||||
{tradeSymbol} not listed
|
||||
</span>
|
||||
)}
|
||||
{firm.platform === 'topstepx' && (
|
||||
@@ -478,6 +599,16 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
||||
TopstepX
|
||||
</span>
|
||||
)}
|
||||
{firm.platform === 'dxtrade' && (
|
||||
<span className="text-xs bg-emerald-100 text-emerald-700 px-1.5 py-0.5 rounded font-semibold">
|
||||
DXtrade
|
||||
</span>
|
||||
)}
|
||||
{firm.platform === 'vest' && (
|
||||
<span className="text-xs bg-violet-100 text-violet-700 px-1.5 py-0.5 rounded font-semibold" title="Trades through the session the extension relays from a signed-in Vest tab">
|
||||
Vest
|
||||
</span>
|
||||
)}
|
||||
{firm.platform === 'topstepx' && hasCredentials && !firm.username && (
|
||||
<span
|
||||
className="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-semibold"
|
||||
@@ -506,7 +637,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
||||
<td colSpan={5} className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="text-xs text-slate-500">
|
||||
Remove the saved {firm.platform === 'topstepx' ? 'TopstepX API key' : 'Tradovate login'} for {firm.firm}? Its accounts disappear until you enter one again.
|
||||
Remove the saved {credentialNoun(firm.platform)} for {firm.firm}? Its accounts disappear until you enter one again.
|
||||
</span>
|
||||
<button
|
||||
onClick={removeCredentials}
|
||||
@@ -528,11 +659,20 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
||||
)}
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{hasCredentials && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowTrades((v) => !v); }}
|
||||
className={`px-2 py-0.5 text-xs font-semibold rounded-md transition-colors ${showTrades ? 'bg-violet-100 text-violet-700' : 'text-slate-500 hover:bg-slate-200'}`}
|
||||
title="Every trade on this firm's accounts — the bot's and anyone else's — with how each one closed"
|
||||
>
|
||||
Trades
|
||||
</button>
|
||||
)}
|
||||
{hasCredentials && !confirmCreds && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setConfirmCreds(true); }}
|
||||
className="text-slate-400 hover:text-red-500 hover:bg-red-50 p-1 rounded-md transition-colors inline-flex"
|
||||
title={firm.platform === 'topstepx' ? 'Remove saved TopstepX API key' : 'Remove saved Tradovate login'}
|
||||
title={`Remove saved ${credentialNoun(firm.platform)}`}
|
||||
>
|
||||
<KeyOffIcon />
|
||||
</button>
|
||||
@@ -550,11 +690,19 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{showTrades && hasCredentials && (
|
||||
<tr className="border-b border-slate-100">
|
||||
<td colSpan={7} className="px-4 py-3 pl-12 bg-white">
|
||||
<TradesTable query={`firmId=${encodeURIComponent(firm.id)}`} showAccount formatAccount={(n) => maskName(n, privacy)} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{open && hasCredentials && (
|
||||
state.accounts.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-5 pl-12 text-sm text-slate-400 italic">
|
||||
{state.connected ? 'No accounts at this firm' : 'Connecting to Tradovate…'}
|
||||
{state.connected ? 'No accounts at this firm' : 'Connecting…'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
@@ -566,6 +714,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
||||
hideDead={hideDead}
|
||||
privacy={privacy}
|
||||
onAssigned={onChanged}
|
||||
onStateChanged={onStateChanged}
|
||||
/>
|
||||
))
|
||||
)
|
||||
@@ -590,17 +739,20 @@ export default function Home() {
|
||||
const [catalog, setCatalog] = useState<{ lastSyncAt: string | null; lastError: string | null; masterUrl: string | null } | null>(null);
|
||||
const [hideDead, setHideDead] = useState(false);
|
||||
const [privacy, setPrivacy] = useState(false);
|
||||
const [sortKey, setSortKey] = useState<SortKey | null>(null);
|
||||
// Name order until a header is clicked, so XXX1 always sits above XXX2.
|
||||
const [sortKey, setSortKey] = useState<SortKey>('name');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc');
|
||||
|
||||
// Trade controls
|
||||
const [scheduler, setScheduler] = useState<SchedulerStatus>({ running: false, action: 'Buy', symbol: 'NQ', lastRun: null, intervalSeconds: 60, stopAfterAll: false });
|
||||
const [scheduler, setScheduler] = useState<SchedulerStatus>({ running: false, action: 'Buy', symbol: 'Auto', lastRun: null, intervalSeconds: 60, stopAfterAll: false });
|
||||
const [enabledSymbols, setEnabledSymbols] = useState<string[]>([]);
|
||||
const [tradeSymbol, setTradeSymbol] = useState('');
|
||||
const [tradeAction, setTradeAction] = useState<'Buy' | 'Sell' | 'Auto'>('Buy');
|
||||
const [tickInterval, setTickInterval] = useState('60');
|
||||
const [tradeLoading, setTradeLoading] = useState(false);
|
||||
const [stopAfterAll, setStopAfterAll] = useState(false);
|
||||
const [autoClaim, setAutoClaim] = useState(true);
|
||||
const [autoActivate, setAutoActivate] = useState(true);
|
||||
|
||||
const handleSort = (col: SortKey) => {
|
||||
if (sortKey === col) {
|
||||
@@ -638,6 +790,18 @@ export default function Home() {
|
||||
fetchConfig();
|
||||
};
|
||||
|
||||
const fetchState = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/state');
|
||||
if (res.ok) {
|
||||
const data: FirmState[] = await res.json();
|
||||
setFirms(data);
|
||||
}
|
||||
} catch {
|
||||
// server not running yet
|
||||
}
|
||||
};
|
||||
|
||||
const fetchScheduler = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/auto-trade');
|
||||
@@ -646,38 +810,28 @@ export default function Home() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// The roots this machine's futures firms list; 'Auto' draws among
|
||||
// each batch firm's list and leads the dropdown.
|
||||
fetch('/api/instruments')
|
||||
.then((r) => r.json() as Promise<{ symbol: string; enabled: boolean }[]>)
|
||||
.then((instruments) => {
|
||||
const syms = instruments.filter((i) => i.enabled).map((i) => i.symbol);
|
||||
setEnabledSymbols(syms);
|
||||
setTradeSymbol((prev) => prev || syms[0] || 'NQ');
|
||||
.then((r) => r.json() as Promise<{ roots: string[] }>)
|
||||
.then(({ roots }) => {
|
||||
setEnabledSymbols(roots ?? []);
|
||||
setTradeSymbol((prev) => prev || 'Auto');
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
fetch('/api/settings')
|
||||
.then((r) => r.json())
|
||||
.then((s: { tick_interval_seconds?: string | null }) => {
|
||||
.then((s: { tick_interval_seconds?: string | null; auto_claim?: string | null; auto_activate?: string | null }) => {
|
||||
if (s.tick_interval_seconds != null) setTickInterval(s.tick_interval_seconds);
|
||||
setAutoClaim(s.auto_claim !== 'off');
|
||||
setAutoActivate(s.auto_activate !== 'off');
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
fetchConfig();
|
||||
fetchCatalog();
|
||||
fetchScheduler();
|
||||
|
||||
const fetchState = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/state');
|
||||
if (res.ok) {
|
||||
const data: FirmState[] = await res.json();
|
||||
setFirms(data);
|
||||
}
|
||||
} catch {
|
||||
// server not running yet
|
||||
}
|
||||
};
|
||||
|
||||
fetchState();
|
||||
const stateInterval = setInterval(fetchState, 5000);
|
||||
const schedulerInterval = setInterval(fetchScheduler, 5000);
|
||||
@@ -804,8 +958,8 @@ export default function Home() {
|
||||
onChange={(e) => setTradeSymbol(e.target.value)}
|
||||
className="rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-1.5 text-sm font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{enabledSymbols.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
<option value="Auto">Auto</option>
|
||||
{enabledSymbols.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<div className="flex rounded-lg border border-slate-200 overflow-hidden text-sm font-semibold">
|
||||
<button
|
||||
@@ -858,6 +1012,50 @@ export default function Home() {
|
||||
/>
|
||||
<span className="text-xs text-slate-400 whitespace-nowrap">Stop after all eligible</span>
|
||||
</label>
|
||||
{config.some((f) => f.platform === 'vest') && (
|
||||
<label
|
||||
className="flex items-center gap-1.5 ml-2 cursor-pointer select-none"
|
||||
title="Claim a funded account's profit as soon as it reaches its stage's target (Vest) — whether or not the scheduler is running"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoClaim}
|
||||
onChange={(e) => {
|
||||
const on = e.target.checked;
|
||||
setAutoClaim(on);
|
||||
fetch('/api/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ auto_claim: on ? 'on' : 'off' }),
|
||||
}).then((r) => { if (!r.ok) setAutoClaim(!on); }).catch(() => setAutoClaim(!on));
|
||||
}}
|
||||
className="rounded border-slate-300 text-emerald-500 focus:ring-emerald-500"
|
||||
/>
|
||||
<span className="text-xs text-slate-400 whitespace-nowrap">Auto-claim payouts</span>
|
||||
</label>
|
||||
)}
|
||||
{config.some((f) => f.platform === 'vest') && (
|
||||
<label
|
||||
className="flex items-center gap-1.5 ml-2 cursor-pointer select-none"
|
||||
title="Activate a passed evaluation's funded account as soon as it passes (Vest) — whether or not the scheduler is running"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoActivate}
|
||||
onChange={(e) => {
|
||||
const on = e.target.checked;
|
||||
setAutoActivate(on);
|
||||
fetch('/api/settings', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ auto_activate: on ? 'on' : 'off' }),
|
||||
}).then((r) => { if (!r.ok) setAutoActivate(!on); }).catch(() => setAutoActivate(!on));
|
||||
}}
|
||||
className="rounded border-slate-300 text-emerald-500 focus:ring-emerald-500"
|
||||
/>
|
||||
<span className="text-xs text-slate-400 whitespace-nowrap">Auto-activate funded</span>
|
||||
</label>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{hasAnyPosition && (
|
||||
<button
|
||||
@@ -929,6 +1127,7 @@ export default function Home() {
|
||||
sortDir={sortDir}
|
||||
tradeSymbol={tradeSymbol}
|
||||
onChanged={fetchConfig}
|
||||
onStateChanged={fetchState}
|
||||
/>
|
||||
);
|
||||
})
|
||||
|
||||
+131
-89
@@ -3,9 +3,35 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Instrument {
|
||||
symbol: string;
|
||||
enabled: boolean;
|
||||
/** GET /api/instruments: each firm's list (from the master) and, for
|
||||
* futures firms, how the Auto draw sees each instrument. */
|
||||
interface InstrumentsOverview {
|
||||
roots: string[];
|
||||
maxEntrySpreadDollars?: number;
|
||||
quotes?: { source: string | null; platform: string | null; lastQuoteAt: string | null; lastError: string | null };
|
||||
firms: {
|
||||
id: string;
|
||||
name: string;
|
||||
platform: string;
|
||||
source: 'master' | 'legacy';
|
||||
instruments: string[];
|
||||
stats?: {
|
||||
maxHoldHours: number;
|
||||
quoteSource?: string | null;
|
||||
sessionProgress: number | null;
|
||||
instruments: {
|
||||
symbol: string;
|
||||
listed: boolean;
|
||||
spreadDollars: number;
|
||||
spreadLive?: boolean;
|
||||
spreadBlocked?: boolean;
|
||||
dailyMoveDollars: number | null;
|
||||
holdHours: number | null;
|
||||
disabled: boolean;
|
||||
chance: number | null;
|
||||
}[];
|
||||
};
|
||||
}[];
|
||||
}
|
||||
|
||||
interface ResolvedContract {
|
||||
@@ -21,6 +47,7 @@ interface AppSettings {
|
||||
trading_hours: string | null;
|
||||
master_backup_token: string | null;
|
||||
proxy_url: string | null;
|
||||
user_agent: string | null;
|
||||
backup_last_push_at: string | null;
|
||||
backup_last_error: string | null;
|
||||
}
|
||||
@@ -33,7 +60,7 @@ interface BackupMeta {
|
||||
}
|
||||
|
||||
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');
|
||||
@@ -51,6 +78,7 @@ export default function SettingsPage() {
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [backupToken, setBackupToken] = useState<string>('');
|
||||
const [proxyUrl, setProxyUrl] = useState<string>('');
|
||||
const [userAgent, setUserAgent] = useState<string>('');
|
||||
const [backupStatus, setBackupStatus] = useState<{ at: string | null; error: string | null }>({ at: null, error: null });
|
||||
const [restoreList, setRestoreList] = useState<BackupMeta[] | null>(null);
|
||||
const [restoreBusy, setRestoreBusy] = useState(false);
|
||||
@@ -75,7 +103,8 @@ export default function SettingsPage() {
|
||||
useEffect(() => {
|
||||
fetch('/api/instruments')
|
||||
.then((r) => r.json())
|
||||
.then(setInstruments);
|
||||
.then(setOverview)
|
||||
.catch(() => {});
|
||||
|
||||
fetchCatalog();
|
||||
|
||||
@@ -103,6 +132,7 @@ export default function SettingsPage() {
|
||||
if (s.proxy_url != null) {
|
||||
setProxyUrl(s.proxy_url);
|
||||
}
|
||||
setUserAgent(s.user_agent ?? '');
|
||||
setBackupStatus({ at: s.backup_last_push_at, error: s.backup_last_error || null });
|
||||
});
|
||||
|
||||
@@ -125,33 +155,6 @@ 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);
|
||||
@@ -440,71 +443,110 @@ export default function SettingsPage() {
|
||||
className="w-72 rounded-lg border border-slate-200 bg-slate-50 px-3 py-1.5 text-sm font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-slate-100 px-4 py-3">
|
||||
<p className="text-sm font-medium text-slate-800">User agent</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
Chrome, Firefox, or Edge is randomly selected on first load and saved.
|
||||
All server Axios requests and WebSocket connections reuse it, including after restarts.
|
||||
</p>
|
||||
<p className="mt-2 break-all rounded-lg bg-slate-50 px-3 py-2 text-xs font-mono text-slate-700">
|
||||
{userAgent || 'Loading…'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Instruments ── */}
|
||||
<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">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 bg-slate-50">
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Symbol</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Active Contract</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-slate-400">Enabled</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{instruments.map((instr) => {
|
||||
const c = contracts[instr.symbol];
|
||||
const wasRolled = !!c?.alternative;
|
||||
return (
|
||||
<tr key={instr.symbol} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-4 py-2.5 font-mono font-semibold text-slate-800">
|
||||
{instr.symbol}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{instr.enabled && c ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-mono text-sm ${wasRolled ? 'text-amber-600 font-semibold' : 'text-slate-600'}`}>
|
||||
{c.name}
|
||||
</span>
|
||||
{wasRolled && (
|
||||
<span className="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-medium">
|
||||
rolled from {c.alternative}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<button
|
||||
onClick={() => toggle(instr.symbol, !instr.enabled)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none ${
|
||||
instr.enabled ? 'bg-blue-500' : 'bg-slate-200'
|
||||
}`}
|
||||
{firm.stats ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse whitespace-nowrap">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100 text-xs uppercase tracking-wider text-slate-400">
|
||||
<th className="px-4 py-2 text-left font-semibold">Symbol</th>
|
||||
<th className="px-4 py-2 text-left font-semibold">Contract</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">Spread</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">Daily move</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">Hold</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">Chance now</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{firm.instruments.map((sym) => {
|
||||
const x = firm.stats!.instruments.find((i) => i.symbol === sym);
|
||||
const c = contracts[sym];
|
||||
return (
|
||||
<tr key={sym} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-4 py-2 font-mono font-semibold text-slate-800">{sym}</td>
|
||||
<td className="px-4 py-2 font-mono text-slate-600">
|
||||
{c?.name ?? '—'}
|
||||
{c?.alternative && <span className="ml-2 text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-sans">rolled from {c.alternative}</span>}
|
||||
</td>
|
||||
<td
|
||||
className={`px-4 py-2 text-right tabular-nums ${x?.spreadBlocked ? 'text-amber-600' : 'text-slate-600'}`}
|
||||
title="Crossing the spread on one contract — what entering costs, commissions aside. Live off the quote feed, else the typical tick"
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
|
||||
instr.enabled ? 'translate-x-4' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
{x ? `$${x.spreadDollars.toFixed(2)}` : '—'}
|
||||
{x && <span className="text-xs text-slate-400"> {x.spreadLive ? 'live' : 'typical'}</span>}
|
||||
{x?.spreadBlocked && ' · over limit'}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums text-slate-600">
|
||||
{x?.dailyMoveDollars != null ? `$${x.dailyMoveDollars.toLocaleString()}` : '—'}
|
||||
</td>
|
||||
<td className={`px-4 py-2 text-right tabular-nums ${x?.disabled ? 'text-amber-600' : 'text-slate-600'}`}>
|
||||
{x?.holdHours != null ? (x.holdHours < 48 ? `${x.holdHours.toFixed(1)}h` : `${(x.holdHours / 24).toFixed(1)}d`) : '—'}
|
||||
{x?.disabled && ' · disabled'}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums font-semibold text-slate-700">
|
||||
{x?.chance != null ? `${Math.round(x.chance * 100)}%` : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5 px-4 py-3">
|
||||
{firm.instruments.map((sym) => (
|
||||
<span key={sym} className="px-2 py-1 rounded-md text-xs font-mono font-semibold bg-slate-100 text-slate-700">{sym}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
*
|
||||
* The locate queue is served whenever clicks are waiting. The AutoBuyer is
|
||||
* ready at all times; a run happens because someone asked for one.
|
||||
*
|
||||
* On a firm site, the person's 2FA code for it shows in a corner chip.
|
||||
*
|
||||
* A signed-in Vest tab's session is relayed to the dashboard, whose Vest client
|
||||
* trades with it (relayVestSession).
|
||||
*/
|
||||
|
||||
const DEFAULTS = {
|
||||
@@ -900,6 +905,7 @@ async function serveLocateRequest(cfg) {
|
||||
let error = null;
|
||||
try {
|
||||
let { tab, opened } = await resolveLocateTab(cfg, request);
|
||||
await hideOtpChip(tab.id);
|
||||
|
||||
// A navigate step points the tab at a specific page first. Skip it when we
|
||||
// are already there — reloading would throw away page state for nothing,
|
||||
@@ -1176,6 +1182,435 @@ async function tick() {
|
||||
} catch (err) {
|
||||
await setState({ connected: true, error: err.message });
|
||||
}
|
||||
|
||||
try {
|
||||
await maybeShowOtp(cfg);
|
||||
} catch (err) {
|
||||
await setState({ connected: true, error: `2FA chip: ${err.message}` });
|
||||
}
|
||||
|
||||
try {
|
||||
await relayVestSession(cfg);
|
||||
} catch (err) {
|
||||
await setState({ connected: true, vest: `Vest relay: ${err.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Vest: relay the signed-in tab's session to the dashboard ────────────────
|
||||
//
|
||||
// Vest signs people in through Google in the browser and renews the session
|
||||
// with a device key the browser won't hand out, so the dashboard cannot log in
|
||||
// by itself. vest-relay.js, in the Vest tab, keeps the latest access token the
|
||||
// Vest app renewed for itself (~15 minutes each); this lifts it and posts it to
|
||||
// the dashboard whenever it changes. Nothing here renews or signs anything: the
|
||||
// session stays the app's.
|
||||
//
|
||||
// With several Vest tabs open, each renews on its own, and each renewal moves
|
||||
// the login on to a new session: the other tabs' tokens still read, but Vest
|
||||
// refuses to mint an account's token with them (401, code 1002) — the bot's
|
||||
// history reads and orders fail until the tab it relays renews again. So the
|
||||
// token relayed is the one Vest's current session holds (pickVestSession),
|
||||
// whichever tab it is in, not the first tab's.
|
||||
|
||||
const VEST_PATTERN = 'https://next.vestmarkets.com/*';
|
||||
const VEST_URL = 'https://next.vestmarkets.com/portfolio';
|
||||
const VEST_EVERY_MS = 10_000;
|
||||
/** The session is posted again this often even unchanged: the dashboard
|
||||
* keeps it in memory only, so a restart there needs it back. */
|
||||
const VEST_REPOST_MS = 60_000;
|
||||
/** A Vest tab opened for the relay isn't opened again for this long. */
|
||||
const VEST_REOPEN_MS = 10 * 60_000;
|
||||
|
||||
let vestLastCheck = 0;
|
||||
let vestLastRelayed = '';
|
||||
let vestLastPostAt = 0;
|
||||
let vestOpenedAt = 0;
|
||||
|
||||
/** Runs in the Vest tab's MAIN world: the session vest-relay.js holds, while
|
||||
* it has life left, and the current session the Vest app keeps for all its
|
||||
* tabs. Self-contained. Version 2's token whenever it holds one: it sees
|
||||
* every login renewal version 1 does once it's in, so a version-1 token it
|
||||
* lacks is an account's (the 0.17.0/0.17.1 hooks kept those too). */
|
||||
function pageVestSession() {
|
||||
const live = (s) => (s && typeof s.accessToken === 'string' && s.expiresAt > Date.now() + 30000 ? s : null);
|
||||
const session = live(window.__afVestLogin) ?? live(window.__afVestSession);
|
||||
let current = null;
|
||||
try {
|
||||
const raw = JSON.parse(localStorage.getItem('vest-auth-session') || 'null');
|
||||
if (raw && typeof raw === 'object') {
|
||||
current = {
|
||||
refreshTokenId: typeof raw.refreshTokenId === 'string' && raw.refreshTokenId ? raw.refreshTokenId : null,
|
||||
accessTokenExpiresAt: Number.isFinite(raw.accessTokenExpiresAt) ? raw.accessTokenExpiresAt : null,
|
||||
};
|
||||
}
|
||||
} catch { /* no session kept */ }
|
||||
return { hooked: window.__afVestHook === 2, session, current };
|
||||
}
|
||||
|
||||
/** Whether a tab's token is the one the Vest app's current session holds:
|
||||
* the same session id — the app's own check before it mints — else, for a
|
||||
* token caught by a version-1 hook (no id), the same expiry, which the app
|
||||
* writes into the session with the token. */
|
||||
function isCurrentVestSession(session, current) {
|
||||
if (!session || !current) return false;
|
||||
if (session.refreshTokenId && current.refreshTokenId) return session.refreshTokenId === current.refreshTokenId;
|
||||
return current.accessTokenExpiresAt !== null && session.expiresAt === current.accessTokenExpiresAt;
|
||||
}
|
||||
|
||||
/** Out of every Vest tab's pageVestSession, the session to relay: the
|
||||
* current one, else — no tab holds it (one renewed before its hook was in)
|
||||
* — the latest to expire, which still reads. `current` is null when no tab
|
||||
* shows the app's session record to judge by. */
|
||||
function pickVestSession(pages) {
|
||||
const live = pages.filter((p) => p && p.session);
|
||||
if (live.length === 0) return null;
|
||||
const current = live.find((p) => isCurrentVestSession(p.session, p.current));
|
||||
if (current) return { session: current.session, current: true };
|
||||
const newest = live.reduce((x, y) => (y.session.expiresAt > x.session.expiresAt ? y : x));
|
||||
return { session: newest.session, current: pages.some((p) => p && p.current) ? false : null };
|
||||
}
|
||||
|
||||
/** The wallets the dashboard's Vest firms trade; [] from an older dashboard. */
|
||||
async function fetchVestWanted(cfg) {
|
||||
try {
|
||||
const res = await fetch(`${cfg.apiBase}/api/vest/session`, { cache: 'no-store' });
|
||||
if (!res.ok) return [];
|
||||
const body = await res.json();
|
||||
return Array.isArray(body.wanted) ? body.wanted : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function relayVestSession(cfg) {
|
||||
const now = Date.now();
|
||||
if (now - vestLastCheck < VEST_EVERY_MS) return;
|
||||
vestLastCheck = now;
|
||||
|
||||
const tabs = (await chrome.tabs.query({ url: VEST_PATTERN })).filter((t) => /^https:/.test(t.url || ''));
|
||||
if (tabs.length === 0) {
|
||||
// This machine trades Vest: open the app in the background. It signs
|
||||
// itself back in from its 30-day session, and the hook catches that.
|
||||
const wanted = await fetchVestWanted(cfg);
|
||||
if (wanted.length === 0) return;
|
||||
if (now - vestOpenedAt > VEST_REOPEN_MS) {
|
||||
vestOpenedAt = now;
|
||||
await chrome.tabs.create({ url: VEST_URL, active: false });
|
||||
await setState({ vest: 'Opened a Vest tab to relay its session' });
|
||||
} else {
|
||||
await setState({ vest: 'No Vest tab open' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Every tab: the current session may be in any of them.
|
||||
const pages = [];
|
||||
for (const tab of tabs) {
|
||||
// A discarded tab stops renewing the session; keep Chrome from it.
|
||||
if (tab.autoDiscardable !== false) chrome.tabs.update(tab.id, { autoDiscardable: false }).catch(() => {});
|
||||
const [injection] = await chrome.scripting.executeScript({
|
||||
target: { tabId: tab.id }, world: 'MAIN', func: pageVestSession,
|
||||
}).catch(() => [null]);
|
||||
const out = injection?.result;
|
||||
if (out && !out.hooked) {
|
||||
// A tab open from before this build: hook it now, and its app's
|
||||
// next renewal is caught.
|
||||
await chrome.scripting.executeScript({ target: { tabId: tab.id }, world: 'MAIN', files: ['vest-relay.js'] }).catch(() => {});
|
||||
}
|
||||
if (out) pages.push(out);
|
||||
}
|
||||
const picked = pickVestSession(pages);
|
||||
if (!picked) {
|
||||
await setState({ vest: 'Vest tab open — waiting for its session (sign in there if it shows Log In)' });
|
||||
return;
|
||||
}
|
||||
const found = picked.session;
|
||||
|
||||
const fingerprint = `${found.expiresAt}:${found.accessToken.length}:${found.accessToken.slice(-16)}`;
|
||||
if (fingerprint === vestLastRelayed && now - vestLastPostAt < VEST_REPOST_MS) return;
|
||||
const res = await fetch(`${cfg.apiBase}/api/vest/session`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
accessToken: found.accessToken, expiresAt: found.expiresAt, primaryAddr: found.primaryAddr, userId: found.userId,
|
||||
}),
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (res.status === 404) throw new Error('this dashboard is too old for the Vest relay');
|
||||
if (!res.ok) throw new Error(`dashboard refused the session: ${body.reason || body.error || `HTTP ${res.status}`}`);
|
||||
vestLastRelayed = fingerprint;
|
||||
vestLastPostAt = now;
|
||||
const short = `${found.primaryAddr.slice(0, 6)}…${found.primaryAddr.slice(-4)}`;
|
||||
const mins = Math.max(0, Math.round((found.expiresAt - Date.now()) / 60000));
|
||||
const note = Array.isArray(body.bound) && body.bound.length > 0 ? ` · bound to ${body.bound.join(', ')}`
|
||||
: body.used ? '' : ' (no Vest firm here uses this wallet)';
|
||||
// A tab that renewed before its hook was in holds the current session
|
||||
// unseen: until it renews again, orders and history reads can fail —
|
||||
// reloading it hooks it.
|
||||
const tabsNote = picked.current === false
|
||||
? (tabs.length === 1 ? " · its token isn't Vest's current session — reload the Vest tab"
|
||||
: ` · ${tabs.length} Vest tabs and none holds the current session — reload them, or keep one open`)
|
||||
: tabs.length > 1 ? ` · ${tabs.length} Vest tabs${picked.current ? ', relaying the current one' : ''}` : '';
|
||||
await setState({ vest: `Relayed ${short}${note} · good ${mins}m${tabsNote}` });
|
||||
}
|
||||
|
||||
// ── 2FA: the person's authenticator code for the firm site you are on ───────
|
||||
//
|
||||
// The master keeps the authenticator secrets, each assigned to a firm (whose
|
||||
// site link it matches against) and a person. The dashboard knows which
|
||||
// person this machine is, so the extension only sends the tab's host and gets
|
||||
// back that person's live codes for the site — shown in a small chip in the
|
||||
// corner with a Copy button.
|
||||
|
||||
/** Refetch at least this often, so a newly assigned code turns up. */
|
||||
const OTP_REFRESH_MS = 30_000;
|
||||
/** No chip for this long after the runner last located something: its clicks
|
||||
* land on screen coordinates, and a chip there would take them. */
|
||||
const OTP_QUIET_AFTER_LOCATE_MS = 60_000;
|
||||
|
||||
let otpCache = null; // { host, codes, fetchedAt }
|
||||
let lastLocateAt = 0;
|
||||
const chipTabs = new Set(); // tabs we have drawn a chip in
|
||||
|
||||
/** This person's codes for a host, with expiresAt moved onto this clock. An
|
||||
* unreachable dashboard returns nothing and retries next tick; any answer —
|
||||
* including an old dashboard's 404 — is cached until a code expires. */
|
||||
async function fetchOtpCodes(cfg, host) {
|
||||
const now = Date.now();
|
||||
if (otpCache && otpCache.host === host && now - otpCache.fetchedAt < OTP_REFRESH_MS
|
||||
&& otpCache.codes.every((c) => c.expiresAt > now)) {
|
||||
return otpCache.codes;
|
||||
}
|
||||
|
||||
let codes = [];
|
||||
try {
|
||||
const res = await fetch(`${cfg.apiBase}/api/autobuyer/otp?host=${encodeURIComponent(host)}`, { cache: 'no-store' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// The master's clock made the codes; a countdown on ours needs the gap.
|
||||
const drift = (Number(data.serverTime) || now) - now;
|
||||
codes = (Array.isArray(data.codes) ? data.codes : [])
|
||||
.filter((c) => c && typeof c.code === 'string')
|
||||
.map((c) => ({
|
||||
label: String(c.label || ''),
|
||||
firmName: c.firmName ? String(c.firmName) : '',
|
||||
code: c.code,
|
||||
period: Number(c.period) || 30,
|
||||
expiresAt: Number(c.expiresAt) - drift,
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
return otpCache && otpCache.host === host ? otpCache.codes.filter((c) => c.expiresAt > now) : [];
|
||||
}
|
||||
otpCache = { host, codes, fetchedAt: now };
|
||||
return codes;
|
||||
}
|
||||
|
||||
async function maybeShowOtp(cfg) {
|
||||
const tab = await pickTab();
|
||||
if (!tab) return;
|
||||
if (Date.now() - lastLocateAt < OTP_QUIET_AFTER_LOCATE_MS) return;
|
||||
|
||||
let host;
|
||||
try { host = new URL(tab.url).hostname; } catch { return; }
|
||||
|
||||
const codes = await fetchOtpCodes(cfg, host);
|
||||
// An empty list is still drawn once where a chip was, which removes it.
|
||||
if (!codes.length && !chipTabs.has(tab.id)) return;
|
||||
await chrome.scripting.executeScript({ target: { tabId: tab.id }, func: pageOtpChip, args: [codes] });
|
||||
if (codes.length) chipTabs.add(tab.id); else chipTabs.delete(tab.id);
|
||||
}
|
||||
|
||||
/** Take the chip down before the runner measures or clicks anything there. */
|
||||
async function hideOtpChip(tabId) {
|
||||
lastLocateAt = Date.now();
|
||||
if (!chipTabs.has(tabId)) return;
|
||||
chipTabs.delete(tabId);
|
||||
try {
|
||||
await chrome.scripting.executeScript({ target: { tabId }, func: pageOtpChip, args: [[]] });
|
||||
} catch {
|
||||
// tab gone or navigated away — nothing left to hide
|
||||
}
|
||||
}
|
||||
|
||||
chrome.tabs.onRemoved.addListener((tabId) => chipTabs.delete(tabId));
|
||||
|
||||
/** Draws, updates or (with no codes) removes the 2FA chip. Runs in the
|
||||
* extension's isolated world and must be self-contained. The chip lives in a
|
||||
* shadow root so the site's CSS can't reach it, and counts down on its own
|
||||
* between the worker's updates. */
|
||||
function pageOtpChip(codes) {
|
||||
const ID = 'autofirmer-otp';
|
||||
const COLLAPSED_KEY = 'autofirmer-otp-collapsed';
|
||||
let host = document.getElementById(ID);
|
||||
|
||||
if (!codes || !codes.length) {
|
||||
if (host) host.remove();
|
||||
return;
|
||||
}
|
||||
// A chip drawn by an older build has no updater; start over.
|
||||
if (host && typeof host.__afOtp !== 'function') {
|
||||
host.remove();
|
||||
host = null;
|
||||
}
|
||||
if (host) {
|
||||
host.__afOtp(codes);
|
||||
return;
|
||||
}
|
||||
|
||||
host = document.createElement('div');
|
||||
host.id = ID;
|
||||
host.style.cssText = 'all: initial; position: fixed; right: 16px; bottom: 16px; z-index: 2147483647;';
|
||||
const root = host.attachShadow({ mode: 'open' });
|
||||
root.innerHTML = `
|
||||
<style>
|
||||
:host { all: initial; }
|
||||
* { box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
||||
.chip { width: 260px; background: #fff; color: #0f172a; border: 1px solid #e2e8f0; border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, .18); overflow: hidden; }
|
||||
.head { display: flex; align-items: center; justify-content: space-between; padding: 8px 10px 6px 12px;
|
||||
font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: #64748b; }
|
||||
.icon { border: 0; background: none; color: #94a3b8; font-size: 16px; line-height: 1; cursor: pointer; padding: 2px 4px; border-radius: 4px; }
|
||||
.icon:hover { color: #334155; background: #f1f5f9; }
|
||||
.row { padding: 6px 12px 10px; border-top: 1px solid #f1f5f9; }
|
||||
.row:first-child { border-top: 0; }
|
||||
.label { font-size: 12px; font-weight: 600; color: #334155; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.line { display: flex; align-items: center; gap: 8px; margin-top: 2px; }
|
||||
.code { font-size: 24px; font-weight: 700; letter-spacing: .12em; font-variant-numeric: tabular-nums; }
|
||||
.secs { font-size: 11px; font-weight: 600; color: #94a3b8; font-variant-numeric: tabular-nums; margin-left: auto; }
|
||||
.soon { color: #dc2626; }
|
||||
.copy { border: 1px solid #e2e8f0; background: #fff; color: #475569; font-size: 12px; font-weight: 600;
|
||||
padding: 5px 10px; border-radius: 8px; cursor: pointer; }
|
||||
.copy:hover { background: #f1f5f9; }
|
||||
.copy.done { border-color: #86efac; color: #15803d; background: #f0fdf4; }
|
||||
.bar { height: 3px; background: #f1f5f9; border-radius: 2px; margin-top: 6px; overflow: hidden; }
|
||||
.fill { height: 100%; background: #22c55e; }
|
||||
.fill.soon { background: #ef4444; }
|
||||
.pill { border: 1px solid #e2e8f0; background: #fff; color: #0f172a; font-size: 12px; font-weight: 700;
|
||||
letter-spacing: .06em; padding: 7px 12px; border-radius: 999px; cursor: pointer;
|
||||
box-shadow: 0 6px 18px rgba(15, 23, 42, .16); }
|
||||
.pill:hover { background: #f8fafc; }
|
||||
</style>
|
||||
<div class="chip">
|
||||
<div class="head"><span>2FA code</span><button class="icon" title="Minimise">–</button></div>
|
||||
<div class="rows"></div>
|
||||
</div>
|
||||
<button class="pill" title="Show the 2FA code">2FA</button>`;
|
||||
|
||||
const chip = root.querySelector('.chip');
|
||||
const pill = root.querySelector('.pill');
|
||||
const rowsEl = root.querySelector('.rows');
|
||||
let rows = [];
|
||||
let signature = '';
|
||||
|
||||
const collapsed = () => { try { return sessionStorage.getItem(COLLAPSED_KEY) === '1'; } catch { return false; } };
|
||||
const setCollapsed = (on) => {
|
||||
try { sessionStorage.setItem(COLLAPSED_KEY, on ? '1' : '0'); } catch { /* storage blocked */ }
|
||||
chip.style.display = on ? 'none' : '';
|
||||
pill.style.display = on ? '' : 'none';
|
||||
};
|
||||
root.querySelector('.icon').addEventListener('click', () => setCollapsed(true));
|
||||
pill.addEventListener('click', () => setCollapsed(false));
|
||||
setCollapsed(collapsed());
|
||||
|
||||
function flash(button, text, done) {
|
||||
button.textContent = text;
|
||||
button.classList.toggle('done', done);
|
||||
setTimeout(() => { button.textContent = 'Copy'; button.classList.remove('done'); }, 1500);
|
||||
}
|
||||
|
||||
// execCommand first: it is synchronous inside the click, needs no
|
||||
// clipboard permission, and never stalls on a prompt the way the async
|
||||
// Clipboard API can. Focus goes back to whatever had it (the code field).
|
||||
function copy(text, button) {
|
||||
const had = document.activeElement;
|
||||
const area = document.createElement('textarea');
|
||||
area.value = text;
|
||||
area.setAttribute('readonly', '');
|
||||
area.style.cssText = 'position: fixed; top: 0; left: 0; opacity: 0;';
|
||||
root.appendChild(area);
|
||||
area.select();
|
||||
let ok = false;
|
||||
try { ok = document.execCommand('copy'); } catch { /* fall through */ }
|
||||
area.remove();
|
||||
if (had && had !== document.body && typeof had.focus === 'function') had.focus();
|
||||
if (ok) return flash(button, 'Copied', true);
|
||||
if (!navigator.clipboard) return flash(button, 'Copy failed', false);
|
||||
navigator.clipboard.writeText(text).then(
|
||||
() => flash(button, 'Copied', true),
|
||||
() => flash(button, 'Copy failed', false),
|
||||
);
|
||||
}
|
||||
|
||||
function build(list) {
|
||||
rowsEl.textContent = '';
|
||||
rows = list.map((c) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row';
|
||||
const label = document.createElement('div');
|
||||
label.className = 'label';
|
||||
label.textContent = [c.firmName, c.label].filter(Boolean).join(' · ');
|
||||
label.title = label.textContent;
|
||||
const line = document.createElement('div');
|
||||
line.className = 'line';
|
||||
const code = document.createElement('span');
|
||||
code.className = 'code';
|
||||
const secs = document.createElement('span');
|
||||
secs.className = 'secs';
|
||||
const button = document.createElement('button');
|
||||
button.className = 'copy';
|
||||
button.textContent = 'Copy';
|
||||
// Keep focus where it is (the site's code field), so ⌘V follows.
|
||||
button.addEventListener('mousedown', (e) => e.preventDefault());
|
||||
button.addEventListener('click', () => copy(c.code, button));
|
||||
line.append(code, secs, button);
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'bar';
|
||||
const fill = document.createElement('div');
|
||||
fill.className = 'fill';
|
||||
bar.appendChild(fill);
|
||||
row.append(label, line, bar);
|
||||
rowsEl.appendChild(row);
|
||||
return { c, code, secs, fill, button };
|
||||
});
|
||||
}
|
||||
|
||||
function paint() {
|
||||
const now = Date.now();
|
||||
for (const r of rows) {
|
||||
const msLeft = r.c.expiresAt - now;
|
||||
const left = Math.max(0, Math.ceil(msLeft / 1000));
|
||||
const soon = left <= 5;
|
||||
// Expired: blank it until the worker brings the next one (seconds).
|
||||
const half = Math.ceil(r.c.code.length / 2);
|
||||
r.code.textContent = left > 0 ? `${r.c.code.slice(0, half)} ${r.c.code.slice(half)}` : '… …';
|
||||
r.code.classList.toggle('soon', soon && left > 0);
|
||||
r.secs.textContent = left > 0 ? `${left}s` : '';
|
||||
r.secs.classList.toggle('soon', soon);
|
||||
r.fill.style.width = `${Math.max(0, Math.min(1, msLeft / (r.c.period * 1000))) * 100}%`;
|
||||
r.fill.classList.toggle('soon', soon);
|
||||
r.button.disabled = left === 0;
|
||||
}
|
||||
}
|
||||
|
||||
host.__afOtp = (list) => {
|
||||
const next = list.map((c) => `${c.firmName}|${c.label}|${c.code}`).join('\n');
|
||||
if (next !== signature) {
|
||||
signature = next;
|
||||
build(list);
|
||||
} else {
|
||||
rows.forEach((r, i) => { r.c = list[i]; });
|
||||
}
|
||||
paint();
|
||||
};
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (!host.isConnected) { clearInterval(timer); return; }
|
||||
paint();
|
||||
}, 250);
|
||||
|
||||
(document.body || document.documentElement).appendChild(host);
|
||||
host.__afOtp(codes);
|
||||
}
|
||||
|
||||
// ── Keeping ourselves current ───────────────────────────────────────────────
|
||||
|
||||
+10
-2
@@ -1,17 +1,19 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "AutoFirmer Capture",
|
||||
"version": "0.15.0",
|
||||
"version": "0.18.0",
|
||||
|
||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxdbOMRR+58a9dHlbIKtlALtS3E4eltBGqfTnUc0lzJuWj5ZA4+5YFSdUnxv5N1ogxwpJUGkUV3MnKJuZnTekCmsNwjCy41a20Xtc7jQLHrIBZkesB6cB+d0l9Vo8M6EC3ZMD8Iv6MdAalU3GZh1Hn9vm24NjJ+xUT1qNIVHEcNUIb9evYobR/XHv+JAp6bOFnaLM+3wpyYKe4efMhsz0F86u9thRNhQnraTqUWfnKNpsHPK2zivaYHrziEEWX7Y3EsMHkf2FRhkUe7IDoxeJM49DBToTo9sAJUkcgLeGCGZLo7H3mcxyn/zERYR+rD3k5xdeBtjoZHXFx2+FanLb9wIDAQAB",
|
||||
"update_url": "https://ext.juicerroom.com/updates.xml",
|
||||
|
||||
"description": "Scrapes the HTML of the firm tab you are viewing and posts it to the AutoFirmer dashboard.",
|
||||
"description": "Scrapes the HTML of the firm tab you are viewing and posts it to the AutoFirmer dashboard, shows the 2FA code for the firm site you are on, and relays a signed-in Vest tab's session to the dashboard.",
|
||||
"permissions": ["scripting", "tabs", "storage", "alarms"],
|
||||
"host_permissions": [
|
||||
"https://*.tradeify.co/*",
|
||||
"https://*.lucidtrading.com/*",
|
||||
"https://*.topstep.com/*",
|
||||
"https://*.apextraderfunding.com/*",
|
||||
"https://*.vestmarkets.com/*",
|
||||
"http://localhost/*",
|
||||
"http://127.0.0.1/*"
|
||||
],
|
||||
@@ -28,6 +30,12 @@
|
||||
"run_at": "document_start",
|
||||
"all_frames": true,
|
||||
"world": "MAIN"
|
||||
},
|
||||
{
|
||||
"matches": ["https://next.vestmarkets.com/*"],
|
||||
"js": ["vest-relay.js"],
|
||||
"run_at": "document_start",
|
||||
"world": "MAIN"
|
||||
}
|
||||
],
|
||||
"action": {
|
||||
|
||||
@@ -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>` : '');
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
+165
-19
@@ -9,31 +9,58 @@
|
||||
*
|
||||
* An Unassigned account does not trade: every caller already guards on a
|
||||
* missing config, which is the behaviour we want.
|
||||
*
|
||||
* Which of its type's rule sets an account trades on is the other half (see
|
||||
* lib/kept-rules.ts): each assignment row carries a rules date, and an
|
||||
* account dated inside one of the type's kept rule sets trades that set's
|
||||
* rules — getAccountConfig applies it, so every trading read sees it.
|
||||
*/
|
||||
|
||||
import type { CatalogAccountType } from './master-protocol';
|
||||
import type { CatalogAccountType, KeptAccountRules } from './master-protocol';
|
||||
import type { PlatformClient } from './platform-client';
|
||||
import { firstFundDate } from './platform-client';
|
||||
import {
|
||||
getAssignment,
|
||||
setAssignment,
|
||||
setAutoAssignment,
|
||||
clearAssignment,
|
||||
unpinAssignment,
|
||||
lowerAutoRulesDate,
|
||||
stampMissingRulesDate,
|
||||
listAssignments,
|
||||
loadAccountMeta,
|
||||
saveAccountMeta,
|
||||
} from './db';
|
||||
import { getAccountTypeById, getCachedFirm } from './firm-catalog';
|
||||
import { getAccountTypeById, getCachedFirm, getCatalogUpdatedAt, getKeptRulesFor, getMasterOffsetMs } from './firm-catalog';
|
||||
import {
|
||||
identityStillMatches, lowersRulesDate, openingEvidence, overlayKept, pickKeptRules, stampRulesDate,
|
||||
type EffectiveAccountType, type KeptRulesWindow,
|
||||
} from './kept-rules';
|
||||
|
||||
const INFERRED_SIZE_KEY = 'inferred_account_size';
|
||||
|
||||
/** Accounts this process has run the evidence step on with their fund
|
||||
* history loaded (see ensureRulesEvidence). On `global` like the clients,
|
||||
* so the trade path and the resolver share it whichever bundle loads them. */
|
||||
const ge = global as typeof globalThis & { __rulesEvidenceApplied?: Set<number> };
|
||||
function evidenceApplied(): Set<number> {
|
||||
return (ge.__rulesEvidenceApplied ??= new Set());
|
||||
}
|
||||
|
||||
export type AssignmentSource = 'manual' | 'auto' | 'unassigned';
|
||||
|
||||
export interface ResolvedAssignment {
|
||||
/** Always the live account type's id — a kept rule set's never is one. */
|
||||
accountTypeId: string | null;
|
||||
accountType: CatalogAccountType | null;
|
||||
/** The rules the account trades on: its live type, or the live type with
|
||||
* a kept rule set's rules on top (then keptRules says which). */
|
||||
accountType: EffectiveAccountType | null;
|
||||
source: AssignmentSource;
|
||||
/** True when more than one account type matched and the tie-break decided. */
|
||||
ambiguous: boolean;
|
||||
/** The account's rules date (ISO); null = none recorded, current rules. */
|
||||
rulesDate: string | null;
|
||||
/** The kept rule set the account trades on; null = its type's current rules. */
|
||||
keptRules: KeptRulesWindow | null;
|
||||
}
|
||||
|
||||
const UNASSIGNED: ResolvedAssignment = {
|
||||
@@ -41,8 +68,23 @@ const UNASSIGNED: ResolvedAssignment = {
|
||||
accountType: null,
|
||||
source: 'unassigned',
|
||||
ambiguous: false,
|
||||
rulesDate: null,
|
||||
keptRules: null,
|
||||
};
|
||||
|
||||
/** One overlay per kept set and live type, so a tick's many lookups share one
|
||||
* object the way they share the live type. Both come from the same catalog
|
||||
* index, so a sync replaces them together. */
|
||||
const overlays = new WeakMap<KeptAccountRules, { live: CatalogAccountType; effective: EffectiveAccountType }>();
|
||||
|
||||
function overlaid(live: CatalogAccountType, kept: KeptAccountRules): EffectiveAccountType {
|
||||
const hit = overlays.get(kept);
|
||||
if (hit && hit.live === live) return hit.effective;
|
||||
const effective = overlayKept(live, kept);
|
||||
overlays.set(kept, { live, effective });
|
||||
return effective;
|
||||
}
|
||||
|
||||
/** The earliest positive fund transaction — the account's opening balance.
|
||||
* Returns null while the reports fetch hasn't produced one yet, which is the
|
||||
* "wait until it is findable" case rather than an error. */
|
||||
@@ -93,13 +135,20 @@ export function matchAccountType(
|
||||
}
|
||||
|
||||
/** THE account→config lookup. Reads the assignment table; never writes.
|
||||
* Replaces the longest-prefix helper that used to be copied into five files. */
|
||||
* Replaces the longest-prefix helper that used to be copied into five files.
|
||||
* Every trading read of an account's rules comes through here, so this is
|
||||
* where an account dated inside a kept rule set gets that set's rules —
|
||||
* manual pins and auto assignments alike. One row read, the rest from the
|
||||
* in-memory catalog: it runs per account per tick. */
|
||||
export function getAccountConfig(accountId: number): ResolvedAssignment {
|
||||
const row = getAssignment(accountId);
|
||||
if (!row?.account_type_id) return UNASSIGNED;
|
||||
if (!row) return UNASSIGNED;
|
||||
// `?? null`: a row read before the rules-date migration ran has no column.
|
||||
const rulesDate = row.rules_date ?? null;
|
||||
if (!row.account_type_id) return { ...UNASSIGNED, rulesDate };
|
||||
|
||||
const accountType = getAccountTypeById(row.account_type_id);
|
||||
if (!accountType) {
|
||||
const live = getAccountTypeById(row.account_type_id);
|
||||
if (!live) {
|
||||
// The account type was deleted on the master. A manual pin is kept so
|
||||
// that a master flap which recreates it restores the user's intent, but
|
||||
// it reports as unassigned until then, so the account stops trading.
|
||||
@@ -108,14 +157,20 @@ export function getAccountConfig(accountId: number): ResolvedAssignment {
|
||||
accountType: null,
|
||||
source: 'unassigned',
|
||||
ambiguous: false,
|
||||
rulesDate,
|
||||
keptRules: null,
|
||||
};
|
||||
}
|
||||
|
||||
const kept = pickKeptRules(getKeptRulesFor(live.id), rulesDate);
|
||||
const accountType: EffectiveAccountType = kept ? overlaid(live, kept) : live;
|
||||
return {
|
||||
accountTypeId: accountType.id,
|
||||
accountTypeId: live.id,
|
||||
accountType,
|
||||
source: row.manual === 1 ? 'manual' : 'auto',
|
||||
ambiguous: false,
|
||||
rulesDate,
|
||||
keptRules: accountType.keptRules ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -130,28 +185,106 @@ export function getInferredSize(accountId: number, client: PlatformClient): numb
|
||||
return inferAccountSize(client.fundTransactions?.[accountId] ?? []);
|
||||
}
|
||||
|
||||
/** Re-resolves every account on one firm's client. Skips manual overrides.
|
||||
* Called after each Tradovate sync and after the catalog changes. */
|
||||
/** The rules date a row first written now gets — on the master's clock when
|
||||
* this one runs behind, and never before the catalog's last change. */
|
||||
function freshRulesDate(): string {
|
||||
return stampRulesDate(Date.now(), getMasterOffsetMs(), getCatalogUpdatedAt());
|
||||
}
|
||||
|
||||
/** The broker's evidence of when the account opened (the report's
|
||||
* firstFundDate, read as lib/kept-rules.ts openingEvidence says); undefined
|
||||
* until its fund history has loaded, null when that history doesn't say. */
|
||||
function brokerEvidence(client: PlatformClient, accountId: number): string | null | undefined {
|
||||
const funds = client.fundTransactions?.[accountId];
|
||||
if (!funds) return undefined;
|
||||
return openingEvidence(firstFundDate(funds, undefined), client.brokerAccountMeta?.(accountId)?.registrationTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* The evidence step for one account, for the trade path: once its fund
|
||||
* history has loaded, lower an auto rules date to the broker's evidence
|
||||
* before anything reads the account's rules. The resolver does the same on
|
||||
* every pass, but only after a sync — and history lands between syncs, when
|
||||
* an account turns tradable (historyReady needs the fund rows loaded, on
|
||||
* every platform) and could trade on its first-seen date for up to a sync.
|
||||
* Once per account per process; a no-op until the history has loaded and the
|
||||
* account has an assignment row.
|
||||
*/
|
||||
export function ensureRulesEvidence(client: PlatformClient, accountId: number): void {
|
||||
if (evidenceApplied().has(accountId)) return;
|
||||
const evidence = brokerEvidence(client, accountId);
|
||||
if (evidence === undefined) return;
|
||||
const row = getAssignment(accountId);
|
||||
if (!row) return;
|
||||
if (evidence && lowersRulesDate(row, evidence)) lowerAutoRulesDate(accountId, evidence);
|
||||
evidenceApplied().add(accountId);
|
||||
}
|
||||
|
||||
/** Re-resolves every account on one firm's client. Skips manual overrides,
|
||||
* and auto ones on a kept rule set (see below). Lowers rules dates to the
|
||||
* broker's evidence on the way. Called after each sync and after the
|
||||
* catalog changes. */
|
||||
export function resolveFirmAssignments(
|
||||
firmId: string,
|
||||
client: PlatformClient,
|
||||
): { assigned: number; unassigned: number; manual: number } {
|
||||
const firm = getCachedFirm(firmId);
|
||||
const types = firm?.accountTypes ?? [];
|
||||
const stamp = freshRulesDate();
|
||||
|
||||
let assigned = 0;
|
||||
let unassigned = 0;
|
||||
let manual = 0;
|
||||
|
||||
for (const account of client.accountList) {
|
||||
if (getAssignment(account.id)?.manual === 1) {
|
||||
let row = getAssignment(account.id);
|
||||
// A row an older build wrote without a rules date (between a build's
|
||||
// migration and its restart, or after a rollback): dated now, as the
|
||||
// migration dates rows, before the evidence below can lower it.
|
||||
if (row && !row.rules_date) {
|
||||
stampMissingRulesDate(account.id, stamp);
|
||||
row = { ...row, rules_date: stamp };
|
||||
}
|
||||
|
||||
// Evidence next, for every account, pinned or not: once its fund
|
||||
// history has loaded, the broker shows when it opened (the report's
|
||||
// firstFundDate), and an auto rules date later than that comes down
|
||||
// to it. Ahead of the kept-set check below, which reads the date.
|
||||
const loadedFunds = client.fundTransactions?.[account.id];
|
||||
const evidence = brokerEvidence(client, account.id) ?? null;
|
||||
if (row && evidence && lowersRulesDate(row, evidence)) {
|
||||
lowerAutoRulesDate(account.id, evidence);
|
||||
row = { ...row, rules_date: evidence };
|
||||
}
|
||||
if (row && loadedFunds) evidenceApplied().add(account.id);
|
||||
|
||||
if (row?.manual === 1) {
|
||||
manual++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const fundTxns = client.fundTransactions?.[account.id] ?? [];
|
||||
const fundTxns = loadedFunds ?? [];
|
||||
const cached = loadAccountMeta(account.id, INFERRED_SIZE_KEY);
|
||||
|
||||
// Sticky: an account on one of its type's kept rule sets stays bound
|
||||
// to that type, so a tie-break flip to another matching type can never
|
||||
// move it off its old rules. Only while it still matches the type's
|
||||
// prefix and size, though: a kept set takes both from the live type,
|
||||
// so after a size edit it would trade on a size it doesn't have.
|
||||
// Unstuck, it re-matches as it did before kept rules existed —
|
||||
// usually to Unassigned, which stops it trading.
|
||||
if (row?.account_type_id) {
|
||||
const live = getAccountTypeById(row.account_type_id);
|
||||
if (
|
||||
live && live.firmId === firmId
|
||||
&& identityStillMatches(account.name, cached, live)
|
||||
&& pickKeptRules(getKeptRulesFor(live.id), row.rules_date ?? null)
|
||||
) {
|
||||
assigned++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let matched: { type: CatalogAccountType | null; ambiguous: boolean } = { type: null, ambiguous: false };
|
||||
let matchedSize: number | null = null;
|
||||
|
||||
@@ -194,24 +327,37 @@ export function resolveFirmAssignments(
|
||||
if (matchedSize !== null && cached === null) {
|
||||
saveAccountMeta(account.id, INFERRED_SIZE_KEY, String(matchedSize));
|
||||
}
|
||||
setAutoAssignment(account.id, firmId, matched.type.id);
|
||||
setAutoAssignment(account.id, firmId, matched.type.id, stamp);
|
||||
assigned++;
|
||||
} else {
|
||||
setAutoAssignment(account.id, firmId, null);
|
||||
setAutoAssignment(account.id, firmId, null, stamp);
|
||||
unassigned++;
|
||||
}
|
||||
// A row this pass created is dated "first seen now"; the evidence may
|
||||
// already date it earlier.
|
||||
if (!row && evidence) lowerAutoRulesDate(account.id, evidence);
|
||||
if (!row && loadedFunds) evidenceApplied().add(account.id);
|
||||
}
|
||||
|
||||
return { assigned, unassigned, manual };
|
||||
}
|
||||
|
||||
export function setManualAssignment(accountId: number, firmId: string, accountTypeId: string | null): void {
|
||||
setAssignment(accountId, firmId, accountTypeId, true);
|
||||
/** insertRulesDate dates the row when the pin creates it (default: now, as
|
||||
* stampRulesDate gives it); re-pinning keeps the account's rules date. */
|
||||
export function setManualAssignment(
|
||||
accountId: number,
|
||||
firmId: string,
|
||||
accountTypeId: string | null,
|
||||
insertRulesDate: string = freshRulesDate(),
|
||||
): void {
|
||||
setAssignment(accountId, firmId, accountTypeId, true, insertRulesDate);
|
||||
}
|
||||
|
||||
/** Drops the override so the auto-resolver takes the account back over. */
|
||||
/** Drops the override so the auto-resolver takes the account back over. The
|
||||
* row and its rules date stay: unpinning must not make an old account look
|
||||
* new. */
|
||||
export function clearManualAssignment(accountId: number): boolean {
|
||||
return clearAssignment(accountId);
|
||||
return unpinAssignment(accountId);
|
||||
}
|
||||
|
||||
export { listAssignments };
|
||||
|
||||
+2110
-484
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,7 @@ import {
|
||||
getSetting,
|
||||
setCredentials,
|
||||
setInstrumentEnabled,
|
||||
setAssignment,
|
||||
restoreAssignment,
|
||||
runInTransaction,
|
||||
} from './db';
|
||||
import type { BackupPayload } from './backup-sync';
|
||||
@@ -116,8 +116,10 @@ export function applyBackup(payload: BackupPayload, opts: { keepMasterUrl?: bool
|
||||
setInstrumentEnabled(i.symbol, i.enabled);
|
||||
counts.instruments++;
|
||||
}
|
||||
// Rules dates travel with them: a restored machine must not date
|
||||
// its accounts afresh, or old ones would leave their kept rules.
|
||||
for (const a of payload.assignments ?? []) {
|
||||
setAssignment(a.account_id, a.firm_id, a.account_type_id ?? null, a.manual === 1);
|
||||
restoreAssignment(a);
|
||||
counts.assignments++;
|
||||
}
|
||||
});
|
||||
|
||||
+4
-2
@@ -26,7 +26,7 @@ import {
|
||||
getInstruments,
|
||||
listAllAssignments,
|
||||
type FirmCredentialRow,
|
||||
type AssignmentRow,
|
||||
type BackupAssignmentRow,
|
||||
} from './db';
|
||||
|
||||
export interface BackupPayload {
|
||||
@@ -35,7 +35,9 @@ export interface BackupPayload {
|
||||
settings: Record<string, string>;
|
||||
credentials: FirmCredentialRow[];
|
||||
instruments: { symbol: string; enabled: boolean }[];
|
||||
assignments: AssignmentRow[];
|
||||
/** Whole rows (SELECT *), rules dates included — absent from a backup
|
||||
* an older build pushed. */
|
||||
assignments: BackupAssignmentRow[];
|
||||
}
|
||||
|
||||
const BACKUP_PATH = '/api/instances/backup';
|
||||
|
||||
+359
-26
@@ -1,23 +1,41 @@
|
||||
import { TradovateClient } from './tradovate-class';
|
||||
import { TopstepXClient } from './topstepx-class';
|
||||
import type { PlatformClient } from './platform-client';
|
||||
import { getInstruments } from './db';
|
||||
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 { applyBrokerProxy } from './outbound-proxy';
|
||||
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.
|
||||
// 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<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<string, PlatformClient> {
|
||||
@@ -34,15 +52,18 @@ function credsMap(): Map<string, string> {
|
||||
return g.__tradovateClientCreds;
|
||||
}
|
||||
|
||||
function credsSignature(firm: { platform: string; username: string; password: string; apiKey: string }): string {
|
||||
return `${firm.platform} ${firm.username} ${firm.password} ${firm.apiKey}`;
|
||||
/** 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: 'tradovate' | 'topstepx',
|
||||
creds: { username: string; password: string; apiKey: string },
|
||||
platform: FirmPlatform,
|
||||
creds: { username: string; password: string; apiKey: string; serverUrl?: string; loginDomain?: string },
|
||||
): PlatformClient {
|
||||
const map = ensureMap();
|
||||
const onSyncComplete = async () => {
|
||||
@@ -64,9 +85,34 @@ export function initClient(
|
||||
}
|
||||
};
|
||||
|
||||
const client: PlatformClient = platform === 'topstepx'
|
||||
? new TopstepXClient(creds.username, creds.apiKey, onSyncComplete)
|
||||
: new TradovateClient(creds.username, creds.password, onSyncComplete);
|
||||
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;
|
||||
@@ -74,7 +120,14 @@ export function initClient(
|
||||
|
||||
export function removeClient(id: string): void {
|
||||
const client = ensureMap().get(id);
|
||||
client?.disconnect();
|
||||
// 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);
|
||||
}
|
||||
@@ -127,12 +180,25 @@ export function reconcileClients(): void {
|
||||
|
||||
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 {
|
||||
// Before anything dials out: put the operator's proxy (if any)
|
||||
// under the broker clients. Master traffic stays direct by design.
|
||||
applyBrokerProxy();
|
||||
// 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);
|
||||
|
||||
// Pull the catalog before anything else — without it there are no
|
||||
// firms to start clients for. Subsequent changes re-reconcile and
|
||||
@@ -140,6 +206,8 @@ export function getClients(): Map<string, PlatformClient> {
|
||||
// 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);
|
||||
@@ -152,15 +220,27 @@ export function getClients(): Map<string, PlatformClient> {
|
||||
reconcileClients();
|
||||
console.log(`[clients] Initialized ${map.size} platform client(s)`);
|
||||
|
||||
// Auto-resolve contracts after clients have time to authenticate
|
||||
// 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();
|
||||
@@ -180,30 +260,80 @@ export function getClients(): Map<string, PlatformClient> {
|
||||
|
||||
// ── 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 (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 Tradovate 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);
|
||||
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));
|
||||
// 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: ${errorMessage(err)}`));
|
||||
} catch (err) {
|
||||
console.error(`[contract-resolver] Resolve failed: ${errorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleDailyResolve(): void {
|
||||
@@ -215,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,6 +1,8 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdirSync } from 'fs';
|
||||
import path from 'path';
|
||||
import { sortableIso, stampRulesDate } from './kept-rules';
|
||||
import type { ClaimedCycle } from './trading-logic';
|
||||
|
||||
// AUTOFIRMER_DB lets several instances share one checkout, each with its own
|
||||
// database. It is read once, at module load — schema creation and seeding are
|
||||
@@ -78,6 +80,62 @@ try {
|
||||
// Column already exists
|
||||
}
|
||||
|
||||
// Migration: each account's rules date — which of its account type's rule
|
||||
// sets it trades on (lib/kept-rules.ts): an account dated before a firm's
|
||||
// rule change keeps the rules from before it. Written when this machine first
|
||||
// records the account (or from a restored backup), lowered to the broker's
|
||||
// evidence of when the account opened, or set by hand (rules_date_manual,
|
||||
// which evidence never moves). Every writer stores a toISOString() UTC
|
||||
// string, so string order is time order.
|
||||
//
|
||||
// The run that adds the column dates every existing row at the migration
|
||||
// time, never NULL: no account changes rules merely because its machine
|
||||
// updated. The resolver then lowers those auto dates to the broker's
|
||||
// evidence, so an account the evidence shows opened before a rule change
|
||||
// goes back to the old rules. Column and backfill commit together, so
|
||||
// `next build` workers racing the migration can't leave rows undated.
|
||||
|
||||
/** The date an undated row gets: now, but never before the catalog's last
|
||||
* change — lib/kept-rules.ts stampRulesDate's floor (every build keeps
|
||||
* settings.catalog_updated_at current), so a machine whose clock runs
|
||||
* behind can't backdate an account first seen after a rule change into the
|
||||
* rule set that change closed. Call inside the writing transaction. */
|
||||
function undatedRowsDate(): string {
|
||||
let catalogUpdatedAt: string | null = null;
|
||||
try {
|
||||
const row = db.prepare("SELECT value FROM settings WHERE key = 'catalog_updated_at'").get() as { value?: unknown } | undefined;
|
||||
if (typeof row?.value === 'string') catalogUpdatedAt = row.value;
|
||||
} catch {
|
||||
// No settings table yet: a fresh database, with no rows to date.
|
||||
}
|
||||
return stampRulesDate(Date.now(), null, catalogUpdatedAt);
|
||||
}
|
||||
|
||||
try {
|
||||
db.transaction(() => {
|
||||
db.exec('ALTER TABLE account_assignments ADD COLUMN rules_date TEXT');
|
||||
db.prepare('UPDATE account_assignments SET rules_date = ? WHERE rules_date IS NULL').run(undatedRowsDate());
|
||||
}).immediate();
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
try {
|
||||
db.exec('ALTER TABLE account_assignments ADD COLUMN rules_date_manual INTEGER NOT NULL DEFAULT 0');
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
// Rows an older build wrote after that run have no date: it keeps serving
|
||||
// between a build's migration and the restart, and comes back in a rollback,
|
||||
// and its INSERT has no rules_date. Dated the same way at every start, so no
|
||||
// row stays undated (current rules forever, with no date to set by hand).
|
||||
try {
|
||||
db.transaction(() => {
|
||||
db.prepare('UPDATE account_assignments SET rules_date = ? WHERE rules_date IS NULL').run(undatedRowsDate());
|
||||
}).immediate();
|
||||
} catch {
|
||||
// No rules_date column — the migration above failed; nothing to date.
|
||||
}
|
||||
|
||||
// ── Firm credentials ─────────────────────────────────────────────────────────
|
||||
|
||||
export interface FirmCredentialRow {
|
||||
@@ -133,8 +191,18 @@ export interface AssignmentRow {
|
||||
account_type_id: string | null;
|
||||
manual: number; // 0 | 1
|
||||
resolved_at: string;
|
||||
/** ISO (toISOString). Which of the type's rule sets the account trades
|
||||
* on; null only on a row an older build wrote after the migration —
|
||||
* the current rules until evidence dates it. */
|
||||
rules_date: string | null;
|
||||
rules_date_manual: number; // 0 | 1 — set by hand; evidence leaves it alone
|
||||
}
|
||||
|
||||
/** An assignment as a backup carries it: one from a build before rules
|
||||
* dates has neither column. */
|
||||
export type BackupAssignmentRow = Omit<AssignmentRow, 'rules_date' | 'rules_date_manual'>
|
||||
& Partial<Pick<AssignmentRow, 'rules_date' | 'rules_date_manual'>>;
|
||||
|
||||
export function getAssignment(accountId: number): AssignmentRow | undefined {
|
||||
return db.prepare('SELECT * FROM account_assignments WHERE account_id = ?').get(accountId) as AssignmentRow | undefined;
|
||||
}
|
||||
@@ -148,33 +216,116 @@ export function listAllAssignments(): AssignmentRow[] {
|
||||
return db.prepare('SELECT * FROM account_assignments').all() as AssignmentRow[];
|
||||
}
|
||||
|
||||
/** insertRulesDate dates a row this call creates (default now — callers pass
|
||||
* lib/kept-rules.ts stampRulesDate). An existing row keeps its rules date
|
||||
* and how it was set: re-resolving or re-pinning an account never makes it
|
||||
* look new. Only a row with no date at all (an older build's) takes
|
||||
* insertRulesDate. */
|
||||
export function setAssignment(
|
||||
accountId: number,
|
||||
firmId: string,
|
||||
accountTypeId: string | null,
|
||||
manual: boolean,
|
||||
insertRulesDate?: string,
|
||||
): void {
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(`
|
||||
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at, rules_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
firm_id = excluded.firm_id,
|
||||
account_type_id = excluded.account_type_id,
|
||||
manual = excluded.manual,
|
||||
resolved_at = excluded.resolved_at
|
||||
`).run(accountId, firmId, accountTypeId, manual ? 1 : 0, new Date().toISOString());
|
||||
resolved_at = excluded.resolved_at,
|
||||
rules_date = COALESCE(account_assignments.rules_date, excluded.rules_date)
|
||||
`).run(accountId, firmId, accountTypeId, manual ? 1 : 0, now, insertRulesDate ?? now);
|
||||
}
|
||||
|
||||
/** Writes an auto-resolved assignment, leaving any manual override alone.
|
||||
* The `manual = 0` guard is what makes a user's pick stick across syncs. */
|
||||
export function setAutoAssignment(accountId: number, firmId: string, accountTypeId: string | null): void {
|
||||
export function setAutoAssignment(
|
||||
accountId: number,
|
||||
firmId: string,
|
||||
accountTypeId: string | null,
|
||||
insertRulesDate?: string,
|
||||
): void {
|
||||
const existing = getAssignment(accountId);
|
||||
if (existing?.manual === 1) return;
|
||||
setAssignment(accountId, firmId, accountTypeId, false);
|
||||
setAssignment(accountId, firmId, accountTypeId, false, insertRulesDate);
|
||||
}
|
||||
|
||||
export function clearAssignment(accountId: number): boolean {
|
||||
return db.prepare('DELETE FROM account_assignments WHERE account_id = ?').run(accountId).changes > 0;
|
||||
/** Drops a manual pin but keeps the row — and with it the rules date, so
|
||||
* unpinning can't make an old account look new. The auto-resolver takes the
|
||||
* account back on its next pass. False when there is no row. */
|
||||
export function unpinAssignment(accountId: number): boolean {
|
||||
return db.prepare('UPDATE account_assignments SET manual = 0, account_type_id = NULL, resolved_at = ? WHERE account_id = ?')
|
||||
.run(new Date().toISOString(), accountId).changes > 0;
|
||||
}
|
||||
|
||||
/** Sets an account's rules date (an ISO toISOString() string); manual marks
|
||||
* it set by hand, which broker evidence never moves. False when the account
|
||||
* has no row. */
|
||||
export function setRulesDate(accountId: number, iso: string, manual: boolean): boolean {
|
||||
return db.prepare('UPDATE account_assignments SET rules_date = ?, rules_date_manual = ? WHERE account_id = ?')
|
||||
.run(iso, manual ? 1 : 0, accountId).changes > 0;
|
||||
}
|
||||
|
||||
/** Dates a row that has none (one an older build wrote) at `iso`; a dated
|
||||
* row is left alone. */
|
||||
export function stampMissingRulesDate(accountId: number, iso: string): void {
|
||||
db.prepare('UPDATE account_assignments SET rules_date = ? WHERE account_id = ? AND rules_date IS NULL').run(iso, accountId);
|
||||
}
|
||||
|
||||
/** Moves an auto rules date earlier to `iso` (the broker's evidence of when
|
||||
* the account opened, lib/kept-rules.ts openingEvidence); never later and
|
||||
* never a date set by hand. Compared as strings, which is time order only
|
||||
* because every rules date is a plain four-digit-year UTC toISOString()
|
||||
* string (lib/kept-rules.ts sortableIso) — keep it that way. */
|
||||
export function lowerAutoRulesDate(accountId: number, iso: string): void {
|
||||
db.prepare(`
|
||||
UPDATE account_assignments SET rules_date = ?
|
||||
WHERE account_id = ? AND rules_date_manual = 0 AND (rules_date IS NULL OR rules_date > ?)
|
||||
`).run(iso, accountId, iso);
|
||||
}
|
||||
|
||||
/**
|
||||
* One backed-up assignment onto this machine, every column (both restore
|
||||
* paths: lib/backup-restore.ts, and scripts/seed-settings.js by hand). The
|
||||
* backup's rules date and its manual flag win when the backup has one; a
|
||||
* backup from an older build has none, so an existing row keeps its own and
|
||||
* a new row is dated now, at the restore.
|
||||
*/
|
||||
export function restoreAssignment(row: BackupAssignmentRow): void {
|
||||
const now = new Date().toISOString();
|
||||
const resolvedAt = typeof row.resolved_at === 'string' ? row.resolved_at : now;
|
||||
// Only a date that sorts as a string counts as the backup's (anything
|
||||
// else is restored like an older build's undated row).
|
||||
const rulesDate = typeof row.rules_date === 'string' ? sortableIso(Date.parse(row.rules_date)) : null;
|
||||
if (rulesDate) {
|
||||
db.prepare(`
|
||||
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at, rules_date, rules_date_manual)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
firm_id = excluded.firm_id,
|
||||
account_type_id = excluded.account_type_id,
|
||||
manual = excluded.manual,
|
||||
resolved_at = excluded.resolved_at,
|
||||
rules_date = excluded.rules_date,
|
||||
rules_date_manual = excluded.rules_date_manual
|
||||
`).run(row.account_id, row.firm_id, row.account_type_id ?? null, row.manual === 1 ? 1 : 0, resolvedAt,
|
||||
rulesDate, row.rules_date_manual === 1 ? 1 : 0);
|
||||
return;
|
||||
}
|
||||
db.prepare(`
|
||||
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at, rules_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
firm_id = excluded.firm_id,
|
||||
account_type_id = excluded.account_type_id,
|
||||
manual = excluded.manual,
|
||||
resolved_at = excluded.resolved_at,
|
||||
rules_date = COALESCE(account_assignments.rules_date, excluded.rules_date)
|
||||
`).run(row.account_id, row.firm_id, row.account_type_id ?? null, row.manual === 1 ? 1 : 0, resolvedAt, now);
|
||||
}
|
||||
|
||||
// ── Settings ─────────────────────────────────────────────────────────────────
|
||||
@@ -239,6 +390,18 @@ export function setSetting(key: string, value: string): void {
|
||||
db.prepare(`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(key, value);
|
||||
}
|
||||
|
||||
/** Seed a generated setting once, including across simultaneous server workers.
|
||||
* Acquire the write lock before reading so they cannot choose different values. */
|
||||
export function getOrCreateSetting(key: string, create: () => string): string {
|
||||
return db.transaction(() => {
|
||||
const saved = getSetting(key);
|
||||
if (saved?.trim()) return saved;
|
||||
const value = create();
|
||||
setSetting(key, value);
|
||||
return value;
|
||||
}).immediate();
|
||||
}
|
||||
|
||||
// ── Instruments ──────────────────────────────────────────────────────────────
|
||||
|
||||
const SYMBOLS = ['NQ','MNQ','ES','MES','YM','MYM','RTY','M2K','GC','MGC','SI','CL','MCL','NG','ZB','ZN','ZF','6E','6J','6B'];
|
||||
@@ -247,12 +410,17 @@ const SYMBOLS = ['NQ','MNQ','ES','MES','YM','MYM','RTY','M2K','GC','MGC','SI','C
|
||||
// from the Instruments page, they just start off.
|
||||
const DEFAULT_ENABLED = new Set(['NQ', 'GC', 'CL']);
|
||||
|
||||
// Seed instruments table if empty
|
||||
const instrCount = (db.prepare('SELECT COUNT(*) as count FROM instruments').get() as { count: number }).count;
|
||||
if (instrCount === 0) {
|
||||
const ins = db.prepare('INSERT INTO instruments (symbol, enabled) VALUES (?, ?)');
|
||||
// Seed instruments table if empty. Check and insert share one IMMEDIATE
|
||||
// transaction: `next build` imports this module from several workers at once,
|
||||
// and on a fresh machine they all saw an empty table and raced to insert the
|
||||
// same symbols (UNIQUE constraint failed: instruments.symbol). OR IGNORE is
|
||||
// the backstop if the lock is ever skipped.
|
||||
db.transaction(() => {
|
||||
const instrCount = (db.prepare('SELECT COUNT(*) as count FROM instruments').get() as { count: number }).count;
|
||||
if (instrCount > 0) return;
|
||||
const ins = db.prepare('INSERT OR IGNORE INTO instruments (symbol, enabled) VALUES (?, ?)');
|
||||
for (const s of SYMBOLS) ins.run(s, DEFAULT_ENABLED.has(s) ? 1 : 0);
|
||||
}
|
||||
}).immediate();
|
||||
|
||||
export interface InstrumentRow {
|
||||
symbol: string;
|
||||
@@ -328,6 +496,22 @@ export function loadFundTransactions(accountId: number): { date: string; amount:
|
||||
return db.prepare('SELECT date, amount FROM fund_transactions WHERE account_id = ? ORDER BY date').all(accountId) as { date: string; amount: number }[];
|
||||
}
|
||||
|
||||
/** Deletes one day's fund transaction and sets account meta keys back in the
|
||||
* same transaction (null deletes the key), so no reader sees the row gone
|
||||
* with the cycle it started still in place. TopstepX's reconcile uses it to
|
||||
* take back a day whose bookings netted to fee noise. */
|
||||
export function dropFundTransaction(accountId: number, date: string, meta: { [key: string]: string | null }): void {
|
||||
const del = db.prepare('DELETE FROM account_meta WHERE account_id = ? AND key = ?');
|
||||
const txn = db.transaction(() => {
|
||||
db.prepare('DELETE FROM fund_transactions WHERE account_id = ? AND date = ?').run(accountId, date);
|
||||
for (const [key, value] of Object.entries(meta)) {
|
||||
if (value === null) del.run(accountId, key);
|
||||
else saveAccountMeta(accountId, key, value);
|
||||
}
|
||||
});
|
||||
txn();
|
||||
}
|
||||
|
||||
// ── Account Meta ─────────────────────────────────────────────────────────────
|
||||
|
||||
db.exec(`
|
||||
@@ -373,19 +557,291 @@ export function loadEndedCycleStart(accountId: number): { fundDate: string; incl
|
||||
}
|
||||
}
|
||||
|
||||
/** The completed cycles this instance's own claims ended (trading-logic
|
||||
* claimedCycle), oldest first — saved by the claim (auto-trade afterClaim),
|
||||
* since it can end one before any report sees it complete; the reporter
|
||||
* names each for the catch-up's week. Gone with a wipe, as the claims
|
||||
* themselves are. */
|
||||
export function saveClaimedCycles(accountId: number, list: ClaimedCycle[]): void {
|
||||
saveAccountMeta(accountId, 'claimed_cycles', JSON.stringify(list));
|
||||
}
|
||||
|
||||
export function loadClaimedCycles(accountId: number): ClaimedCycle[] {
|
||||
const raw = loadAccountMeta(accountId, 'claimed_cycles');
|
||||
if (raw === null) return [];
|
||||
try {
|
||||
const list = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(list)) return [];
|
||||
return list.filter((c): c is ClaimedCycle => typeof c?.date === 'string' && typeof c.cycle === 'string'
|
||||
&& Number.isInteger(c.stage) && typeof c.drain === 'boolean' && Number.isInteger(c.cycleSeq))
|
||||
.map(({ date, stage, drain, cycle, cycleSeq, at }) => ({ date, stage, drain, cycle, cycleSeq, ...(Number.isFinite(at) ? { at } : {}) }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes every cached row for an account — daily P&L, fund transactions and
|
||||
* meta — so the next report fetch rebuilds it from scratch. Rows older than
|
||||
* the report's 28-day window are gone for good; that is the point (stale
|
||||
* 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);
|
||||
db.prepare('DELETE FROM account_meta WHERE account_id = ?').run(accountId);
|
||||
// History goes; what guards the rebuild stays — the balance baseline
|
||||
// and roll base a Cash History vouched for, the wipe's own hold and
|
||||
// pre-wipe fund signature — and the resolver's inferred size, which
|
||||
// nothing in the rebuilt history can infer again.
|
||||
db.prepare(`DELETE FROM account_meta WHERE account_id = ? AND key NOT IN (${WIPE_KEEPS.map(() => '?').join(', ')})`)
|
||||
.run(accountId, ...WIPE_KEEPS);
|
||||
});
|
||||
txn();
|
||||
}
|
||||
|
||||
// ── Trades ───────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Every position a platform's history shows — the bot's and anyone else's
|
||||
// (Vest: GET /v3/positions). The platform can't tell an API order from a
|
||||
// click, so opened_by / closed_by record what this instance did itself:
|
||||
// 'bot' when its own order opened or closed the position, NULL otherwise —
|
||||
// or 'outside', set by hand for a trade from before marking began whose
|
||||
// origin the bot's own log settles.
|
||||
// A wipe leaves these alone: they're refetched, but the marks aren't.
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS trades (
|
||||
position_id TEXT PRIMARY KEY,
|
||||
account_id INTEGER NOT NULL,
|
||||
account_name TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
side TEXT NOT NULL,
|
||||
quantity REAL NOT NULL,
|
||||
leverage REAL,
|
||||
open_price REAL,
|
||||
close_price REAL,
|
||||
pnl REAL,
|
||||
fee REAL,
|
||||
funding REAL,
|
||||
opened_at INTEGER NOT NULL,
|
||||
closed_at INTEGER,
|
||||
close_reason TEXT,
|
||||
opened_by TEXT,
|
||||
closed_by TEXT,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS trades_account_opened ON trades (account_id, opened_at);
|
||||
`);
|
||||
|
||||
/** settings key: when this instance began marking its own trades (epoch
|
||||
* ms). A position opened before it and never marked is of unknown origin;
|
||||
* one opened after it was opened outside the bot. */
|
||||
export const TRADES_SINCE_SETTING = 'trades_tracked_since';
|
||||
|
||||
export interface TradeRow {
|
||||
positionId: string;
|
||||
accountId: number;
|
||||
accountName: string;
|
||||
symbol: string;
|
||||
side: 'long' | 'short';
|
||||
quantity: number;
|
||||
leverage: number | null;
|
||||
openPrice: number | null;
|
||||
closePrice: number | null;
|
||||
pnl: number | null;
|
||||
fee: number | null;
|
||||
funding: number | null;
|
||||
openedAt: number;
|
||||
closedAt: number | null;
|
||||
closeReason: string | null;
|
||||
openedBy: string | null;
|
||||
closedBy: string | null;
|
||||
}
|
||||
|
||||
/** The platform's view of positions: inserts new ones, refreshes known ones.
|
||||
* A size it no longer reports (0) keeps the one already stored, a field it
|
||||
* leaves out keeps its value, and the bot's marks are never touched. */
|
||||
export function saveTrades(rows: Omit<TradeRow, 'openedBy' | 'closedBy'>[]): void {
|
||||
const up = db.prepare(`
|
||||
INSERT INTO trades (position_id, account_id, account_name, symbol, side, quantity, leverage, open_price, close_price,
|
||||
pnl, fee, funding, opened_at, closed_at, close_reason, updated_at)
|
||||
VALUES (@positionId, @accountId, @accountName, @symbol, @side, @quantity, @leverage, @openPrice, @closePrice,
|
||||
@pnl, @fee, @funding, @openedAt, @closedAt, @closeReason, @updatedAt)
|
||||
ON CONFLICT(position_id) DO UPDATE SET
|
||||
account_name = excluded.account_name,
|
||||
symbol = excluded.symbol,
|
||||
side = excluded.side,
|
||||
quantity = CASE WHEN excluded.quantity > 0 THEN excluded.quantity ELSE trades.quantity END,
|
||||
leverage = COALESCE(excluded.leverage, trades.leverage),
|
||||
open_price = COALESCE(excluded.open_price, trades.open_price),
|
||||
close_price = CASE WHEN COALESCE(excluded.closed_at, trades.closed_at) IS NULL THEN NULL
|
||||
ELSE COALESCE(excluded.close_price, trades.close_price) END,
|
||||
pnl = CASE WHEN COALESCE(excluded.closed_at, trades.closed_at) IS NULL THEN NULL
|
||||
ELSE COALESCE(excluded.pnl, trades.pnl) END,
|
||||
fee = COALESCE(excluded.fee, trades.fee),
|
||||
funding = COALESCE(excluded.funding, trades.funding),
|
||||
opened_at = excluded.opened_at,
|
||||
closed_at = COALESCE(excluded.closed_at, trades.closed_at),
|
||||
close_reason = COALESCE(excluded.close_reason, trades.close_reason),
|
||||
updated_at = excluded.updated_at
|
||||
`);
|
||||
const now = Date.now();
|
||||
const txn = db.transaction(() => {
|
||||
for (const r of rows) up.run({ ...r, updatedAt: now });
|
||||
});
|
||||
txn();
|
||||
}
|
||||
|
||||
/** This instance's own order opened (or closed) the position — stored at
|
||||
* once, before the platform's history shows it. */
|
||||
export function markTrade(
|
||||
by: 'opened' | 'closed',
|
||||
t: { positionId: string; accountId: number; accountName: string; symbol: string; side: 'long' | 'short'; quantity: number; openPrice: number | null; openedAt: number },
|
||||
): void {
|
||||
const col = by === 'opened' ? 'opened_by' : 'closed_by';
|
||||
db.prepare(`
|
||||
INSERT INTO trades (position_id, account_id, account_name, symbol, side, quantity, open_price, opened_at, ${col}, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'bot', ?)
|
||||
ON CONFLICT(position_id) DO UPDATE SET ${col} = 'bot', updated_at = excluded.updated_at
|
||||
`).run(t.positionId, t.accountId, t.accountName, t.symbol, t.side, t.quantity, t.openPrice, t.openedAt, Date.now());
|
||||
}
|
||||
|
||||
/** Which of these positions this instance's own order opened (markTrade). */
|
||||
export function botOpenedPositions(positionIds: string[]): Set<string> {
|
||||
if (positionIds.length === 0) return new Set();
|
||||
const rows = db.prepare(`SELECT position_id AS positionId FROM trades
|
||||
WHERE opened_by = 'bot' AND position_id IN (${positionIds.map(() => '?').join(', ')})`)
|
||||
.all(...positionIds) as { positionId: string }[];
|
||||
return new Set(rows.map((r) => r.positionId));
|
||||
}
|
||||
|
||||
/** Vest accounts with a trade the table still shows open (Vest's position
|
||||
* ids are bare numbers; the fill-built trades below carry a prefix). */
|
||||
export function openTradeAccounts(): { accountId: number; accountName: string }[] {
|
||||
return db.prepare(`SELECT account_id AS accountId, MAX(account_name) AS accountName FROM trades
|
||||
WHERE closed_at IS NULL AND position_id NOT LIKE '%:%' GROUP BY account_id`)
|
||||
.all() as { accountId: number; accountName: string }[];
|
||||
}
|
||||
|
||||
// ── Own orders, and trades built from fills ──────────────────────────────────
|
||||
//
|
||||
// TopstepX and Tradovate report fills, not positions, and neither says who
|
||||
// placed an order. So every order this instance places is noted here as it
|
||||
// goes in, and a trade built from fills (lib/trade-recorder.ts) is the bot's
|
||||
// when its opening fill's order is one of these. Its kind tells how the bot
|
||||
// closed one: its stop, its take-profit limit, or a market order. Those
|
||||
// trades carry a platform prefix on their id ("tsx:…", "tdv:…") and their
|
||||
// marks are worked out afresh on each pass: 'bot', 'outside', or 'unknown'
|
||||
// for a fill from before this table began.
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS own_orders (
|
||||
account_id INTEGER NOT NULL,
|
||||
order_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
placed_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (account_id, order_id)
|
||||
);
|
||||
`);
|
||||
|
||||
/** settings key: when this instance began noting its own futures orders. */
|
||||
const OWN_ORDERS_SINCE_SETTING = 'own_orders_since';
|
||||
const ownOrdersSinceMs = Number(getOrCreateSetting(OWN_ORDERS_SINCE_SETTING, () => String(Date.now())));
|
||||
|
||||
export function ownOrdersSince(): number {
|
||||
return ownOrdersSinceMs;
|
||||
}
|
||||
|
||||
export type OwnOrderKind = 'market' | 'limit' | 'stop';
|
||||
|
||||
export function recordOwnOrder(accountId: number, orderId: string | number, kind: OwnOrderKind): void {
|
||||
db.prepare('INSERT OR IGNORE INTO own_orders (account_id, order_id, kind, placed_at) VALUES (?, ?, ?, ?)')
|
||||
.run(accountId, String(orderId), kind, Date.now());
|
||||
}
|
||||
|
||||
export function loadOwnOrders(accountId: number): Map<string, OwnOrderKind> {
|
||||
const rows = db.prepare('SELECT order_id AS orderId, kind FROM own_orders WHERE account_id = ?').all(accountId) as { orderId: string; kind: OwnOrderKind }[];
|
||||
return new Map(rows.map((r) => [r.orderId, r.kind]));
|
||||
}
|
||||
|
||||
export type FillTradeRow = Omit<TradeRow, 'leverage' | 'funding'>;
|
||||
|
||||
/** One account's stored trades off one platform — for one contract, when
|
||||
* `symbol` is given. */
|
||||
export function loadFillTrades(accountId: number, prefix: string, symbol?: string): FillTradeRow[] {
|
||||
return db.prepare(`
|
||||
SELECT position_id AS positionId, account_id AS accountId, account_name AS accountName, symbol, side, quantity,
|
||||
open_price AS openPrice, close_price AS closePrice, pnl, fee, opened_at AS openedAt, closed_at AS closedAt,
|
||||
close_reason AS closeReason, opened_by AS openedBy, closed_by AS closedBy
|
||||
FROM trades WHERE account_id = ? AND position_id LIKE ? ${symbol === undefined ? '' : 'AND symbol = ?'}
|
||||
`).all(accountId, `${prefix}:%`, ...(symbol === undefined ? [] : [symbol])) as FillTradeRow[];
|
||||
}
|
||||
|
||||
/** Trades rebuilt from fills: every field as worked out this pass, except a
|
||||
* null openedBy (a carried trade's) keeps the stored one. */
|
||||
export function saveFillTrades(rows: FillTradeRow[]): void {
|
||||
const up = db.prepare(`
|
||||
INSERT INTO trades (position_id, account_id, account_name, symbol, side, quantity, open_price, close_price,
|
||||
pnl, fee, opened_at, closed_at, close_reason, opened_by, closed_by, updated_at)
|
||||
VALUES (@positionId, @accountId, @accountName, @symbol, @side, @quantity, @openPrice, @closePrice,
|
||||
@pnl, @fee, @openedAt, @closedAt, @closeReason, @openedBy, @closedBy, @updatedAt)
|
||||
ON CONFLICT(position_id) DO UPDATE SET
|
||||
account_name = excluded.account_name,
|
||||
side = excluded.side,
|
||||
quantity = excluded.quantity,
|
||||
open_price = COALESCE(excluded.open_price, trades.open_price),
|
||||
close_price = excluded.close_price,
|
||||
pnl = excluded.pnl,
|
||||
fee = COALESCE(excluded.fee, trades.fee),
|
||||
opened_at = excluded.opened_at,
|
||||
closed_at = excluded.closed_at,
|
||||
close_reason = excluded.close_reason,
|
||||
opened_by = COALESCE(excluded.opened_by, trades.opened_by),
|
||||
closed_by = excluded.closed_by,
|
||||
updated_at = excluded.updated_at
|
||||
`);
|
||||
const now = Date.now();
|
||||
db.transaction(() => {
|
||||
for (const r of rows) up.run({ ...r, updatedAt: now });
|
||||
})();
|
||||
}
|
||||
|
||||
/** Trades a pass found never existed (built off a history that moved
|
||||
* under it). */
|
||||
export function deleteTrades(positionIds: string[]): void {
|
||||
const del = db.prepare('DELETE FROM trades WHERE position_id = ?');
|
||||
db.transaction(() => {
|
||||
for (const id of positionIds) del.run(id);
|
||||
})();
|
||||
}
|
||||
|
||||
/** Open trades whose close the history no longer reaches: closed, how and
|
||||
* when unknown. */
|
||||
export function closeTradesUnseen(positionIds: string[]): void {
|
||||
const up = db.prepare(`UPDATE trades SET close_reason = 'unknown', updated_at = ? WHERE position_id = ? AND closed_at IS NULL AND close_reason IS NULL`);
|
||||
const now = Date.now();
|
||||
db.transaction(() => {
|
||||
for (const id of positionIds) up.run(now, id);
|
||||
})();
|
||||
}
|
||||
|
||||
/** Newest first (by close, else open); every account when accountIds is
|
||||
* undefined. */
|
||||
export function loadTrades(o: { accountIds?: number[]; limit: number }): TradeRow[] {
|
||||
if (o.accountIds && o.accountIds.length === 0) return [];
|
||||
const where = o.accountIds ? `WHERE account_id IN (${o.accountIds.map(() => '?').join(', ')})` : '';
|
||||
return db.prepare(`
|
||||
SELECT position_id AS positionId, account_id AS accountId, account_name AS accountName, symbol, side, quantity, leverage,
|
||||
open_price AS openPrice, close_price AS closePrice, pnl, fee, funding, opened_at AS openedAt, closed_at AS closedAt,
|
||||
close_reason AS closeReason, opened_by AS openedBy, closed_by AS closedBy
|
||||
FROM trades ${where}
|
||||
ORDER BY COALESCE(closed_at, opened_at) DESC
|
||||
LIMIT ?
|
||||
`).all(...(o.accountIds ?? []), o.limit) as TradeRow[];
|
||||
}
|
||||
|
||||
// ── AutoBuyer captures ───────────────────────────────────────────────────────
|
||||
|
||||
db.exec(`
|
||||
|
||||
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();
|
||||
}
|
||||
+272
-55
@@ -17,7 +17,14 @@
|
||||
* the two are not linked yet.)
|
||||
*/
|
||||
|
||||
import type { CatalogAccountType, CatalogFirm, FirmCatalogPayload } from './master-protocol';
|
||||
import { isFirmPlatform, isNotionalPlatform } from './master-protocol';
|
||||
import { isVestWalletAddress } from './vest-helpers';
|
||||
import { sanitizeInstruments, sanitizeInstrumentLimits, DEFAULT_MAX_ENTRY_SPREAD } from './instrument-specs';
|
||||
import { normalizeAccountType } from './kept-rules';
|
||||
import type {
|
||||
CatalogAccountType, CatalogFirm, CatalogTradingSettings, FirmCatalogPayload, FirmPlatform, KeptAccountRules, SymbolMap,
|
||||
SymbolMapCandidate,
|
||||
} from './master-protocol';
|
||||
import {
|
||||
getSetting,
|
||||
setSetting,
|
||||
@@ -35,24 +42,53 @@ const FETCH_TIMEOUT_MS = 10_000;
|
||||
const COLD_RETRY_MIN_MS = 10_000;
|
||||
const COLD_RETRY_MAX_MS = 60_000;
|
||||
|
||||
/** The version of validatePayload below, stamped on the payload it caches.
|
||||
* Bump it whenever the parser starts keeping a field an older one dropped:
|
||||
* a cache from before then lacks the field however often it is re-read, and
|
||||
* `?since=` would answer "unchanged" until the master next changes, so the
|
||||
* first sync on a newer parser fetches the whole catalog.
|
||||
* 2: kept rule sets (keptAccountRules) and the firm's maxConcurrentAccounts.
|
||||
* Stamped only on a payload from a master that sends keptAccountRules: one
|
||||
* from an older master can't vouch for kept sets it never sent.
|
||||
* 3: Vest firms, which a build from before the platform skipped. A platform
|
||||
* learned later needs no bump: the cache names the platforms it skipped
|
||||
* (skippedPlatforms), and a build that knows one fetches in full. */
|
||||
export const CATALOG_PARSER_VERSION = 3;
|
||||
|
||||
/** A Date header further off than this is somebody else's clock, not the master's. */
|
||||
const MAX_MASTER_OFFSET_MS = 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
interface CatalogCache {
|
||||
payload: FirmCatalogPayload | null;
|
||||
/** Index over every firm's account types, for the id lookup. */
|
||||
typesById: Map<string, CatalogAccountType>;
|
||||
/** Each live account type's kept rule sets, by closedAt (oldest window
|
||||
* first). Kept ids are deliberately not in typesById: nothing can be
|
||||
* pinned to one. */
|
||||
keptByLiveId: Map<string, KeptAccountRules[]>;
|
||||
}
|
||||
|
||||
const g = global as typeof globalThis & {
|
||||
__firmCatalog?: CatalogCache;
|
||||
__firmCatalogTimer?: ReturnType<typeof setTimeout>;
|
||||
__firmCatalogColdDelay?: number;
|
||||
/** The master's clock minus this one, from the last sync's Date header. */
|
||||
__masterOffsetMs?: number;
|
||||
};
|
||||
|
||||
function indexPayload(payload: FirmCatalogPayload | null): CatalogCache {
|
||||
const typesById = new Map<string, CatalogAccountType>();
|
||||
const keptByLiveId = new Map<string, KeptAccountRules[]>();
|
||||
for (const firm of payload?.firms ?? []) {
|
||||
for (const type of firm.accountTypes) typesById.set(type.id, type);
|
||||
for (const kept of firm.keptAccountRules ?? []) {
|
||||
const list = keptByLiveId.get(kept.keptFrom);
|
||||
if (list) list.push(kept);
|
||||
else keptByLiveId.set(kept.keptFrom, [kept]);
|
||||
}
|
||||
}
|
||||
return { payload, typesById };
|
||||
for (const list of keptByLiveId.values()) list.sort((a, b) => Date.parse(a.closedAt) - Date.parse(b.closedAt));
|
||||
return { payload, typesById, keptByLiveId };
|
||||
}
|
||||
|
||||
function cache(): CatalogCache {
|
||||
@@ -62,7 +98,18 @@ function cache(): CatalogCache {
|
||||
const raw = getSetting('catalog_payload');
|
||||
if (raw) {
|
||||
try {
|
||||
payload = validatePayload(JSON.parse(raw));
|
||||
const stored = JSON.parse(raw);
|
||||
payload = validatePayload(stored);
|
||||
// Whatever parsed the blob when it was stored, not this parser: a
|
||||
// field an older build dropped on the way in can't come back from
|
||||
// re-reading the blob.
|
||||
const parser = (stored as { parser?: unknown }).parser;
|
||||
if (typeof parser === 'number') payload.parser = parser;
|
||||
else delete payload.parser;
|
||||
// Likewise the platforms it skipped: their firms aren't in the blob.
|
||||
const skipped = (stored as { skippedPlatforms?: unknown }).skippedPlatforms;
|
||||
if (Array.isArray(skipped)) payload.skippedPlatforms = skipped.filter((p): p is string => typeof p === 'string');
|
||||
else delete payload.skippedPlatforms;
|
||||
} catch {
|
||||
console.error('[catalog] cached payload is unreadable — ignoring it');
|
||||
}
|
||||
@@ -83,6 +130,37 @@ export function getAccountTypeById(typeId: string): CatalogAccountType | undefin
|
||||
return cache().typesById.get(typeId);
|
||||
}
|
||||
|
||||
/** The kept rule sets of one live account type, oldest window first ([] when
|
||||
* it keeps none). lib/account-type-resolver.ts picks an account's by its
|
||||
* rules date. */
|
||||
export function getKeptRulesFor(liveTypeId: string): KeptAccountRules[] {
|
||||
return cache().keptByLiveId.get(liveTypeId) ?? [];
|
||||
}
|
||||
|
||||
/** The kept rule set ids loaded for a firm, for the report — null while the
|
||||
* cached catalog came from a parser older than CATALOG_PARSER_VERSION, which
|
||||
* can't be trusted to hold them (the master then reads this machine as not
|
||||
* applying kept rules yet). */
|
||||
export function getKeptRuleIdsForFirm(firmId: string): string[] | null {
|
||||
const payload = cache().payload;
|
||||
if (!payload || (payload.parser ?? 0) < CATALOG_PARSER_VERSION) return null;
|
||||
return (payload.firms.find((f) => f.id === firmId)?.keptAccountRules ?? []).map((k) => k.id);
|
||||
}
|
||||
|
||||
/** The master's clock minus this machine's, as of the last good sync (null
|
||||
* before one this process). Dates new accounts on the master's clock when
|
||||
* this one runs behind (lib/kept-rules.ts stampRulesDate). */
|
||||
export function getMasterOffsetMs(): number | null {
|
||||
return g.__masterOffsetMs ?? null;
|
||||
}
|
||||
|
||||
/** When the cached catalog last changed on the master (its payload's
|
||||
* updatedAt); null with no cache. No account first seen after a change can
|
||||
* be dated before it. */
|
||||
export function getCatalogUpdatedAt(): string | null {
|
||||
return cache().payload?.updatedAt ?? null;
|
||||
}
|
||||
|
||||
export function getCatalogMeta(): {
|
||||
version: number;
|
||||
updatedAt: string | null;
|
||||
@@ -99,32 +177,107 @@ export function getCatalogMeta(): {
|
||||
};
|
||||
}
|
||||
|
||||
/** 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 — a Tradovate username + password, or a
|
||||
* TopstepX API key. Everything that starts a platform client goes through
|
||||
* here. */
|
||||
* 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) continue;
|
||||
if (firm.platform === 'topstepx') {
|
||||
if (creds.apiKey) result.push({ ...firm, ...creds });
|
||||
} else {
|
||||
if (creds.username && creds.password) result.push({ ...firm, ...creds });
|
||||
}
|
||||
if (creds && hasPlatformCredentials(firm.platform, creds)) result.push({ ...firm, ...creds });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Sync ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function isStages(value: unknown): value is { profit: number; consistency: number; minTradingDays: number }[] {
|
||||
return Array.isArray(value) && value.every((s) =>
|
||||
typeof s === 'object' && s !== null
|
||||
&& typeof (s as Record<string, unknown>).profit === 'number'
|
||||
&& typeof (s as Record<string, unknown>).consistency === 'number'
|
||||
&& typeof (s as Record<string, unknown>).minTradingDays === 'number');
|
||||
const g2 = global as typeof globalThis & {
|
||||
__catalogUnknownPlatformWarned?: Set<string>;
|
||||
__catalogKeptSkipWarned?: Set<string>;
|
||||
};
|
||||
|
||||
/** Once per firm per process — the catalog is re-validated every 60s. */
|
||||
function warnUnknownPlatform(id: string, name: string, platform: unknown): void {
|
||||
const warned = (g2.__catalogUnknownPlatformWarned ??= new Set());
|
||||
const key = `${id}|${String(platform)}`;
|
||||
if (warned.has(key)) return;
|
||||
warned.add(key);
|
||||
console.warn(`[catalog] skipping "${name}": platform ${JSON.stringify(platform)} is unknown to this build — update this instance`);
|
||||
}
|
||||
|
||||
function nonNegative(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0;
|
||||
}
|
||||
|
||||
/** Drops anything malformed rather than failing the sync: a bad symbol-map
|
||||
* entry only means that candidate isn't traded. */
|
||||
function sanitizeSymbolMap(raw: unknown): SymbolMap {
|
||||
const map: SymbolMap = {};
|
||||
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return map;
|
||||
for (const [root, list] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (!Array.isArray(list)) continue;
|
||||
const candidates = list.flatMap((c): SymbolMapCandidate[] => {
|
||||
if (typeof c !== 'object' || c === null) return [];
|
||||
const cand = c as Record<string, unknown>;
|
||||
if (typeof cand.symbol !== 'string' || !cand.symbol || cand.symbol.includes('|')) return [];
|
||||
return [{ symbol: cand.symbol }];
|
||||
});
|
||||
if (candidates.length > 0) map[root.toUpperCase()] = candidates;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function isDateString(value: unknown): value is string {
|
||||
return typeof value === 'string' && Number.isFinite(Date.parse(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* A firm's kept rule sets, each as strict as an account type: a malformed
|
||||
* entry throws and the whole sync is rejected. One that names no live type
|
||||
* of this firm is skipped instead (warned once) — no account can map to it.
|
||||
*/
|
||||
function parseKeptRules(
|
||||
firm: { id: string; name: string },
|
||||
raw: unknown[],
|
||||
accountTypes: CatalogAccountType[],
|
||||
tradesEveryDay: boolean,
|
||||
): KeptAccountRules[] {
|
||||
const liveIds = new Set(accountTypes.map((t) => t.id));
|
||||
const kept: KeptAccountRules[] = [];
|
||||
raw.forEach((k: unknown, j: number) => {
|
||||
const where = `kept rules ${j} of ${firm.name}`;
|
||||
const type = normalizeAccountType(k, { tradesEveryDay, where });
|
||||
const entry = k as Record<string, unknown>;
|
||||
if (typeof entry.keptFrom !== 'string') throw new Error(`${where} is missing keptFrom`);
|
||||
if (entry.openedAt !== null && !isDateString(entry.openedAt)) throw new Error(`${where} has bad openedAt`);
|
||||
if (!isDateString(entry.closedAt)) throw new Error(`${where} has bad closedAt`);
|
||||
if (!liveIds.has(entry.keptFrom) || type.firmId !== firm.id) {
|
||||
const warned = (g2.__catalogKeptSkipWarned ??= new Set());
|
||||
if (!warned.has(type.id)) {
|
||||
warned.add(type.id);
|
||||
console.warn(`[catalog] skipping kept rules ${type.id} of "${firm.name}": it names no account type of this firm (keptFrom ${entry.keptFrom}, firmId ${type.firmId})`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
kept.push({ ...type, keptFrom: entry.keptFrom, openedAt: entry.openedAt, closedAt: entry.closedAt });
|
||||
});
|
||||
return kept;
|
||||
}
|
||||
|
||||
/** Rejects anything that isn't a complete catalog. A half-parsed payload would
|
||||
@@ -136,56 +289,91 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
|
||||
if (typeof body.updatedAt !== 'string') throw new Error('missing updatedAt');
|
||||
if (!Array.isArray(body.firms)) throw new Error('missing firms');
|
||||
|
||||
const firms: CatalogFirm[] = body.firms.map((f: unknown, i: number) => {
|
||||
const firms: CatalogFirm[] = [];
|
||||
// Stamped on the payload, so a build that learns one refetches its firms.
|
||||
const skippedPlatforms = new Set<string>();
|
||||
body.firms.forEach((f: unknown, i: number) => {
|
||||
if (typeof f !== 'object' || f === null) throw new Error(`firm ${i} is not an object`);
|
||||
const firm = f as Record<string, unknown>;
|
||||
if (typeof firm.id !== 'string' || typeof firm.name !== 'string') {
|
||||
throw new Error(`firm ${i} is missing id or name`);
|
||||
}
|
||||
// An old master sends no platform: that is Tradovate. A platform this
|
||||
// build doesn't know is NOT — coercing it would start the wrong client
|
||||
// with another platform's credentials. Skip the firm (before its
|
||||
// account types can fail the whole sync); it comes back once this
|
||||
// build knows the platform.
|
||||
const platform = firm.platform === undefined ? 'tradovate' : firm.platform;
|
||||
if (!isFirmPlatform(platform)) {
|
||||
warnUnknownPlatform(firm.id, firm.name, platform);
|
||||
skippedPlatforms.add(String(platform));
|
||||
return;
|
||||
}
|
||||
// Percent commission and every-day trading are settings of the
|
||||
// notional platforms (DXtrade, Vest); futures firms keep measured
|
||||
// per-contract fees and the CME calendar.
|
||||
const dx = isNotionalPlatform(platform);
|
||||
const tradesEveryDay = dx && firm.tradesEveryDay === true;
|
||||
if (!Array.isArray(firm.accountTypes)) throw new Error(`firm ${firm.name} is missing accountTypes`);
|
||||
|
||||
const accountTypes: CatalogAccountType[] = firm.accountTypes.map((t: unknown, j: number) => {
|
||||
if (typeof t !== 'object' || t === null) throw new Error(`account type ${j} is not an object`);
|
||||
const type = t as Record<string, unknown>;
|
||||
for (const key of ['id', 'firmId', 'label', 'prefix', 'updatedAt']) {
|
||||
if (typeof type[key] !== 'string') throw new Error(`account type ${j} is missing ${key}`);
|
||||
}
|
||||
for (const key of [
|
||||
'accountSize', 'profitTarget', 'consistency', 'minDayPnL',
|
||||
'minTradingDays', 'maxLoss', 'maxPositionSize',
|
||||
]) {
|
||||
if (typeof type[key] !== 'number') throw new Error(`account type ${j} is missing ${key}`);
|
||||
}
|
||||
if (!isStages(type.withdrawalStages)) throw new Error(`account type ${j} has bad withdrawalStages`);
|
||||
// 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,
|
||||
combineCmeHolidays: type.combineCmeHolidays === true,
|
||||
// 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,
|
||||
} as unknown as CatalogAccountType;
|
||||
});
|
||||
const accountTypes: CatalogAccountType[] = firm.accountTypes.map((t: unknown, j: number) =>
|
||||
normalizeAccountType(t, { tradesEveryDay, where: `account type ${j}` }));
|
||||
// Only when the master sent the list: absent = a master from before
|
||||
// kept rules, [] = every account on the current rules.
|
||||
const keptAccountRules = Array.isArray(firm.keptAccountRules)
|
||||
? parseKeptRules({ id: firm.id, name: firm.name }, firm.keptAccountRules, accountTypes, tradesEveryDay)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
firms.push({
|
||||
id: firm.id,
|
||||
name: firm.name,
|
||||
// Lenient on purpose: a master from before the platform field (or
|
||||
// with a platform this build doesn't know) must not fail the whole
|
||||
// sync, and 'tradovate' is what every firm was until then.
|
||||
platform: firm.platform === 'topstepx' ? 'topstepx' : 'tradovate',
|
||||
platform,
|
||||
// The master's list when it sends one; absent from an older
|
||||
// master, whose bannedSymbols / symbolMap then apply.
|
||||
...(Array.isArray(firm.instruments) ? { instruments: sanitizeInstruments(platform, firm.instruments) } : {}),
|
||||
...(!dx ? { instrumentLimits: sanitizeInstrumentLimits(firm.instrumentLimits) } : {}),
|
||||
bannedSymbols: Array.isArray(firm.bannedSymbols) ? (firm.bannedSymbols as string[]) : [],
|
||||
accountTypes,
|
||||
...(keptAccountRules ? { keptAccountRules } : {}),
|
||||
serverUrl: typeof firm.serverUrl === 'string' ? firm.serverUrl : '',
|
||||
loginDomain: typeof firm.loginDomain === 'string' ? firm.loginDomain : '',
|
||||
commissionPct: dx ? nonNegative(firm.commissionPct) : 0,
|
||||
tradesEveryDay,
|
||||
symbolMap: sanitizeSymbolMap(firm.symbolMap),
|
||||
targetHoldHours: nonNegative(firm.targetHoldHours),
|
||||
financingPctPerDay: nonNegative(firm.financingPctPerDay),
|
||||
// Lenient: absent or malformed = no firm cap.
|
||||
maxConcurrentAccounts: Math.floor(nonNegative(firm.maxConcurrentAccounts)),
|
||||
updatedAt: typeof firm.updatedAt === 'string' ? firm.updatedAt : new Date(0).toISOString(),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
return { version: body.version, updatedAt: body.updatedAt, firms };
|
||||
const trading = parseTradingSettings(body.trading);
|
||||
// A kept-aware master sends keptAccountRules on every firm (even []). A
|
||||
// payload without it is a master from before kept rules: cached without
|
||||
// a parser version, so the cache isn't trusted to hold kept sets — every
|
||||
// sync fetches in full (no `since`), and the first one after a kept-aware
|
||||
// master is back brings them, though its catalog version never moved.
|
||||
const keptAware = body.firms.every((f: unknown) =>
|
||||
typeof f === 'object' && f !== null && Array.isArray((f as Record<string, unknown>).keptAccountRules));
|
||||
return {
|
||||
version: body.version, updatedAt: body.updatedAt, firms, ...(trading ? { trading } : {}),
|
||||
...(keptAware ? { parser: CATALOG_PARSER_VERSION } : {}),
|
||||
...(skippedPlatforms.size > 0 ? { skippedPlatforms: [...skippedPlatforms] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The master's trading settings; undefined when it sent none (or nothing
|
||||
* usable), so the defaults apply. */
|
||||
function parseTradingSettings(raw: unknown): CatalogTradingSettings | undefined {
|
||||
if (typeof raw !== 'object' || raw === null) return undefined;
|
||||
const max = (raw as Record<string, unknown>).maxEntrySpreadDollars;
|
||||
return typeof max === 'number' && Number.isFinite(max) && max >= 0 ? { maxEntrySpreadDollars: max } : undefined;
|
||||
}
|
||||
|
||||
/** What the master's Settings page says, or the defaults from before it. */
|
||||
export function getTradingSettings(): CatalogTradingSettings {
|
||||
return cache().payload?.trading ?? { maxEntrySpreadDollars: DEFAULT_MAX_ENTRY_SPREAD };
|
||||
}
|
||||
|
||||
function catalogUrl(): URL | null {
|
||||
@@ -205,6 +393,17 @@ function recordError(message: string): void {
|
||||
setSetting('catalog_last_sync_at', new Date().toISOString());
|
||||
}
|
||||
|
||||
/** The master's clock off a response's Date header (whole seconds, read on
|
||||
* arrival — close enough to date accounts by). An unreadable header, or one
|
||||
* a year off, leaves the last offset standing. */
|
||||
function recordMasterClock(header: string | null): void {
|
||||
const at = header ? Date.parse(header) : NaN;
|
||||
if (!Number.isFinite(at)) return;
|
||||
const offset = at - Date.now();
|
||||
if (Math.abs(offset) > MAX_MASTER_OFFSET_MS) return;
|
||||
g.__masterOffsetMs = offset;
|
||||
}
|
||||
|
||||
/** If the master's database was rebuilt, its firms come back with fresh ids and
|
||||
* every credential row orphans. Match the survivors up by name (once) rather
|
||||
* than making someone retype every password. */
|
||||
@@ -233,15 +432,26 @@ export async function syncCatalog(): Promise<{ ok: boolean; changed: boolean; ve
|
||||
// Only the URL is required — deliberately not instance_name, unlike the
|
||||
// reporter. A freshly imaged machine has to be able to pull its catalog
|
||||
// before anyone has got round to naming it.
|
||||
// `since` only against the master the cache came from: two masters'
|
||||
// version numbers can collide, and an 'unchanged' from the wrong one
|
||||
// would keep the other's catalog. Nor for a cache an older parser wrote:
|
||||
// it lacks what that parser dropped, and 'unchanged' would keep it so —
|
||||
// fields, or firms on a platform it didn't know and this build does.
|
||||
const hasCache = getCachedCatalog().length > 0;
|
||||
if (hasCache) url.searchParams.set('since', String(version));
|
||||
const source = url.origin;
|
||||
const cached = cache().payload;
|
||||
const cacheCurrent = (cached?.parser ?? 0) >= CATALOG_PARSER_VERSION
|
||||
&& !(cached?.skippedPlatforms ?? []).some((p) => isFirmPlatform(p));
|
||||
if (hasCache && cacheCurrent && getSetting('catalog_source_url') === source) url.searchParams.set('since', String(version));
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
||||
if (!res.ok) throw new Error(`master returned ${res.status}`);
|
||||
recordMasterClock(res.headers.get('date'));
|
||||
const body = await res.json();
|
||||
|
||||
if (body && typeof body === 'object' && (body as Record<string, unknown>).unchanged === true) {
|
||||
setSetting('catalog_source_url', source);
|
||||
setSetting('catalog_last_sync_at', new Date().toISOString());
|
||||
setSetting('catalog_last_error', '');
|
||||
return { ok: true, changed: false, version };
|
||||
@@ -260,19 +470,26 @@ export async function syncCatalog(): Promise<{ ok: boolean; changed: boolean; ve
|
||||
}
|
||||
|
||||
// Same reasoning one level down: a firm losing every account type
|
||||
// unassigns all of its accounts. Keep the previous types and flag it.
|
||||
// unassigns all of its accounts. Keep the previous types and flag it,
|
||||
// with the kept rule sets that went with them (whose keptFrom named
|
||||
// types this payload lacks, so none of its own survived parsing). An
|
||||
// empty kept list on its own is never suspicious: it is every account
|
||||
// on the current rules.
|
||||
const previous = new Map(getCachedCatalog().map((f) => [f.id, f]));
|
||||
const suspicious: string[] = [];
|
||||
for (const firm of payload.firms) {
|
||||
const before = previous.get(firm.id);
|
||||
if (firm.accountTypes.length === 0 && before && before.accountTypes.length > 0) {
|
||||
firm.accountTypes = before.accountTypes;
|
||||
if (before.keptAccountRules) firm.keptAccountRules = before.keptAccountRules;
|
||||
else delete firm.keptAccountRules;
|
||||
suspicious.push(firm.name);
|
||||
}
|
||||
}
|
||||
|
||||
setSetting('catalog_payload', JSON.stringify(payload));
|
||||
setSetting('catalog_version', String(payload.version));
|
||||
setSetting('catalog_source_url', source);
|
||||
setSetting('catalog_updated_at', payload.updatedAt);
|
||||
setSetting('catalog_last_sync_at', new Date().toISOString());
|
||||
setSetting('catalog_last_error', suspicious.length > 0
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+41
-14
@@ -15,13 +15,17 @@
|
||||
* master you control, and prefer https so it cannot be redirected in transit.
|
||||
*/
|
||||
|
||||
import { getSetting, setSetting, getInstruments, getRunnerHeartbeat, RUNNER_TIMEOUT_MS } from './db';
|
||||
import { getSetting, setSetting, getRunnerHeartbeat, RUNNER_TIMEOUT_MS } from './db';
|
||||
import { runTrade, startScheduler, stopScheduler, getSchedulerStatus } from './auto-trade';
|
||||
import { FIRMS, automationKey } from './automations';
|
||||
import { POINT_VALUES } from './trading-logic';
|
||||
import { CME_ROOTS, CME_SPECS } from './instrument-specs';
|
||||
import { futuresRootsHere } from './instruments';
|
||||
import { queueRun } from './queue-run';
|
||||
import { pushBackup } from './backup-sync';
|
||||
import { getBuildVersion } from './version';
|
||||
import { updaterStatus } from './updater-status';
|
||||
import { runUpdateNow } from './updater-watchdog';
|
||||
import { directWebSocket } from './outbound-proxy';
|
||||
import type {
|
||||
Command,
|
||||
InstanceCapabilities,
|
||||
@@ -35,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;
|
||||
@@ -47,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;
|
||||
}
|
||||
@@ -56,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)
|
||||
@@ -83,6 +89,7 @@ export function collectCapabilities(): InstanceCapabilities {
|
||||
}))
|
||||
),
|
||||
runnerOnline,
|
||||
updateNow: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -96,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() };
|
||||
}
|
||||
|
||||
@@ -123,6 +134,9 @@ async function execute(command: Command): Promise<unknown> {
|
||||
case 'backup-now':
|
||||
return pushBackup(true);
|
||||
|
||||
case 'update-now':
|
||||
return runUpdateNow();
|
||||
|
||||
default: {
|
||||
// A newer master may know commands this build does not. Say so
|
||||
// rather than failing silently.
|
||||
@@ -159,14 +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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -214,12 +240,13 @@ function connect() {
|
||||
|
||||
let socket: WebSocket;
|
||||
try {
|
||||
socket = new WebSocket(url);
|
||||
socket = directWebSocket(url);
|
||||
} catch (err: any) {
|
||||
scheduleReconnect(err?.message ?? 'could not open socket');
|
||||
return;
|
||||
}
|
||||
s.socket = socket;
|
||||
s.url = masterUrl;
|
||||
s.ready = false;
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
|
||||
+323
-15
@@ -36,11 +36,19 @@ export interface AccountTypeStats {
|
||||
* cycle ready to withdraw, 'drain' = a drain stage's days booked;
|
||||
* cycle = the date of the fund transaction that started the cycle ('' for
|
||||
* the account's first cycle) — firm + name + cycle identify one
|
||||
* completed cycle. The list also carries caught-up entries: for up to 7
|
||||
* completed cycle, but where one day started several (Vest: claim,
|
||||
* trade again, claim again — a fund row holds the day's payouts):
|
||||
* cycleSeq = which of that day's cycles, 1 for the second and so on,
|
||||
* sent only when above 0 (trading-logic completionPlace). A master that
|
||||
* keys on it tells them apart; one that doesn't folds them into the
|
||||
* day's first. The list also carries caught-up entries: for up to 7
|
||||
* days after a fund transaction starts a new cycle, it includes the cycle
|
||||
* that just ended if that cycle had met the rule. Caught-up entries are
|
||||
* not counted in `completed`. Optional: older instances omit it. */
|
||||
completedAccounts?: { name: string; date: string; stage: number; kind: 'pass' | 'payout' | 'drain'; cycle: string }[];
|
||||
* that just ended if that cycle had met the rule — and, for 7 days after
|
||||
* it, each cycle the instance's own payout claim ended complete, dated
|
||||
* the claim's day and listed last, newest first (a claim can end one
|
||||
* before any report sees it). Caught-up and claimed entries are not
|
||||
* counted in `completed`. Optional: older instances omit it. */
|
||||
completedAccounts?: { name: string; date: string; stage: number; kind: 'pass' | 'payout' | 'drain'; cycle: string; cycleSeq?: number }[];
|
||||
/** Live accounts by trading days completed toward the current target:
|
||||
* {daysTraded: accountCount}. Feeds the master's payout calendar, so
|
||||
* accounts that are dead, DLL-locked, or in the red are left out — the
|
||||
@@ -81,6 +89,10 @@ export interface AccountTypeStats {
|
||||
stage: number; drain?: boolean;
|
||||
}[];
|
||||
}[];
|
||||
/** Live accounts of this slice on each kept rule set — the slice itself
|
||||
* stays keyed by the live account type, whose id is the only one ever
|
||||
* reported as an accountTypeId. Optional. */
|
||||
keptRules?: { id: string; accounts: number }[];
|
||||
}
|
||||
|
||||
export interface FirmStats {
|
||||
@@ -102,11 +114,146 @@ export interface FirmStats {
|
||||
* 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. */
|
||||
accounts?: { name: string; size: number; payoutable: boolean | null }[];
|
||||
* 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 {
|
||||
@@ -116,9 +263,54 @@ export interface SchedulerState {
|
||||
lastRun: string | null;
|
||||
intervalSeconds: number;
|
||||
stopAfterAll?: boolean;
|
||||
/** Today's random session-open delay in minutes (1–20), drawn once per
|
||||
* Central-time day. Optional so an older master simply ignores it. */
|
||||
/** 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
|
||||
@@ -140,6 +332,8 @@ export interface InstanceCapabilities {
|
||||
symbols: string[];
|
||||
automations: AutomationSummary[];
|
||||
runnerOnline: boolean;
|
||||
/** Answers the 'update-now' command; the master offers it only then. */
|
||||
updateNow: boolean;
|
||||
}
|
||||
|
||||
// ── Firm catalog ─────────────────────────────────────────────────────────────
|
||||
@@ -185,7 +379,11 @@ export interface CatalogAccountType {
|
||||
* minTradingDays qualifying days of ~minDayPnL (default $5, randomized
|
||||
* $5–$15, traded as tiny MNQ legs), then wait for the next withdrawal.
|
||||
* withdrawalPct/payoutCap override the type's payout defaults per stage. */
|
||||
withdrawalStages: { profit: number; consistency: number; minTradingDays: number; drain?: boolean; minDayPnL?: number; withdrawalPct?: number; payoutCap?: number }[];
|
||||
withdrawalStages: { profit: number; consistency: number; minTradingDays: number; drain?: boolean; minDayPnL?: number; withdrawalPct?: number; payoutCap?: number; dailyLossLimit?: number }[];
|
||||
/** True = the stage is chosen by the payouts made since the firm's last
|
||||
* daily reset, and every new day starts back at the base stage (Vest:
|
||||
* claim, then trade again the same day). Absent from older masters. */
|
||||
stagesSameDay?: boolean;
|
||||
/** Payout-projection settings, owned by the master's payout calendar but
|
||||
* consumed here to compute each account's future payout chain. Optional:
|
||||
* an older master doesn't send them. */
|
||||
@@ -200,26 +398,133 @@ export interface CatalogAccountType {
|
||||
payoutCap?: number;
|
||||
/** Share of the gross the person actually receives (default 0.9). */
|
||||
profitSplit?: number;
|
||||
/** Hard daily loss limit in dollars (0 = none). Every leg then carries a
|
||||
* stop that keeps the firm's day from losing more than this, less a 10%
|
||||
* reserve and fees. Optional: an older master doesn't send it. */
|
||||
dailyLossLimit?: number;
|
||||
/** Copied from the firm by the catalog sync, so every cfg-based path sees
|
||||
* it: the firm trades every calendar day (24/7). */
|
||||
tradesEveryDay?: boolean;
|
||||
/** ISO. Ties between two matching account types are broken by this. */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Old rules an account type keeps for its accounts whose rules date is
|
||||
* inside [openedAt, closedAt) — set when the owner changes the type's rules
|
||||
* for new accounts only. Built as: live type X's fields, overwritten by the
|
||||
* snapshot's RULE fields, plus these. Never an account type itself: nothing
|
||||
* is pinned or reported by its id as an accountTypeId (lib/kept-rules.ts). */
|
||||
export interface KeptAccountRules extends CatalogAccountType {
|
||||
/** This kept set's own uuid — never a live account type id. */
|
||||
id: string;
|
||||
/** The live account type id (X). */
|
||||
keptFrom: string;
|
||||
/** ISO; inclusive lower bound on the rules date; null = none. */
|
||||
openedAt: string | null;
|
||||
/** ISO; exclusive upper bound. */
|
||||
closedAt: string;
|
||||
}
|
||||
|
||||
/** Where a firm's accounts trade. A build only knows the platforms listed
|
||||
* here; the catalog sync skips a firm on any other rather than guess. */
|
||||
export const FIRM_PLATFORMS = ['tradovate', 'topstepx', 'dxtrade', 'vest'] as const;
|
||||
export type FirmPlatform = (typeof FIRM_PLATFORMS)[number];
|
||||
|
||||
export function isFirmPlatform(value: unknown): value is FirmPlatform {
|
||||
return typeof value === 'string' && (FIRM_PLATFORMS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/** Platforms whose firms trade their own symbols by notional (DXtrade's
|
||||
* CFDs, Vest's perps) rather than CME contracts: the firm's instrument list
|
||||
* is platform symbols, fees are a percent of notional, and each firm is a
|
||||
* scheduler lane of its own. */
|
||||
export function isNotionalPlatform(platform: string | undefined): boolean {
|
||||
return platform === 'dxtrade' || platform === 'vest';
|
||||
}
|
||||
|
||||
/** One symbol a DXTrade firm may trade for a CME root. Position size comes
|
||||
* from volatility and the firm's target hold, and the commission is the
|
||||
* firm's (an older master's notionalPerContract or per-symbol commissionPct
|
||||
* is ignored). */
|
||||
export interface SymbolMapCandidate {
|
||||
/** The platform's symbol, e.g. "BTC/USD". */
|
||||
symbol: string;
|
||||
}
|
||||
|
||||
/** CME root the batch drew (NQ, MNQ, GC…) or '*' for any root not listed →
|
||||
* the candidates to choose from, cheapest per unit of volatility wins. */
|
||||
export type SymbolMap = Record<string, SymbolMapCandidate[]>;
|
||||
|
||||
export interface CatalogFirm {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Which platform the firm's accounts trade on. Only 'tradovate' firms get
|
||||
* a client here; anything else is synced but sits idle until its trading
|
||||
* logic exists. Older masters omit the field — treated as 'tradovate'. */
|
||||
platform: 'tradovate' | 'topstepx';
|
||||
/** Which platform the firm's accounts trade on. Older masters omit the
|
||||
* field — treated as 'tradovate'; a value this build doesn't know makes
|
||||
* the sync skip the firm. */
|
||||
platform: FirmPlatform;
|
||||
/** What the firm trades, in order: CME roots for futures firms, the firm's
|
||||
* own symbols for DXtrade (see lib/instruments.ts). Absent from a master
|
||||
* from before the lists — then bannedSymbols and symbolMap apply. */
|
||||
instruments?: string[];
|
||||
/** Futures firms: the most contracts the firm allows on an instrument,
|
||||
* by root, for every account type (absent = no limit beyond the type's
|
||||
* max contracts). */
|
||||
instrumentLimits?: Record<string, number>;
|
||||
/** The firm's website ('' = none; absent from older masters). The master
|
||||
* matches the extension's tab against it to find a person's 2FA code. */
|
||||
siteUrl?: string;
|
||||
/** The most of the firm's accounts this machine trades at once, on top of
|
||||
* its own max_concurrent_accounts (0 = no firm cap; absent from older
|
||||
* masters). */
|
||||
maxConcurrentAccounts?: number;
|
||||
/** Older masters' way to exclude roots; newer ones derive it from
|
||||
* `instruments`. */
|
||||
bannedSymbols: string[];
|
||||
accountTypes: CatalogAccountType[];
|
||||
/** Old rule sets the firm's account types keep for older accounts.
|
||||
* Optional; old builds drop it. */
|
||||
keptAccountRules?: KeptAccountRules[];
|
||||
/** DXTrade REST base, e.g. https://dx.tradeify247.co/dxsca-web ('' otherwise). */
|
||||
serverUrl?: string;
|
||||
/** DXTrade login domain ('' = 'default'). */
|
||||
loginDomain?: string;
|
||||
/** Commission as % of notional per side (e.g. 0.04). 0 = the platform's
|
||||
* per-contract fees, measured off each fill. */
|
||||
commissionPct?: number;
|
||||
/** Trades every calendar day, weekends and CME holidays included. */
|
||||
tradesEveryDay?: boolean;
|
||||
/** DXTrade, older masters: which symbols each CME root may trade as.
|
||||
* Newer ones derive it from `instruments`. */
|
||||
symbolMap?: SymbolMap;
|
||||
/** DXTrade sizing: aim each leg's expected hold at this many hours
|
||||
* (0 = DEFAULT_TARGET_HOLD_HOURS, 12). */
|
||||
targetHoldHours?: number;
|
||||
/** Overnight financing the firm charges, % of notional per day (0 = none). */
|
||||
financingPctPerDay?: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Settings the master applies to every instance (its Settings page). */
|
||||
export interface CatalogTradingSettings {
|
||||
/** The most an entry may pay to cross the spread, per contract, in
|
||||
* dollars (0 = no limit). */
|
||||
maxEntrySpreadDollars: number;
|
||||
}
|
||||
|
||||
export interface FirmCatalogPayload {
|
||||
version: number;
|
||||
updatedAt: string;
|
||||
firms: CatalogFirm[];
|
||||
/** Absent from a master from before its Settings page. */
|
||||
trading?: CatalogTradingSettings;
|
||||
/** Instance-local, never sent by the master: the catalog parser version
|
||||
* (lib/firm-catalog.ts CATALOG_PARSER_VERSION) that produced a cached
|
||||
* payload. An older parser dropped fields it didn't know, so a cache
|
||||
* without it, or below the current one, isn't trusted to hold them. */
|
||||
parser?: number;
|
||||
/** Instance-local, like parser: the platforms of the firms that parser
|
||||
* skipped as unknown to it. A build that knows one fetches in full. */
|
||||
skippedPlatforms?: string[];
|
||||
}
|
||||
|
||||
export interface FirmCatalogUnchanged {
|
||||
@@ -234,7 +539,10 @@ export type Command =
|
||||
| { kind: 'autobuyer-run'; automationId: string; inputs?: Record<string, number> }
|
||||
| { kind: 'report-now' }
|
||||
/** Push a fresh settings/credentials backup right now. */
|
||||
| { kind: 'backup-now' };
|
||||
| { kind: 'backup-now' }
|
||||
/** Heal this machine's auto-updater if it stopped, then check for an
|
||||
* update at once, skipping a failed update's retry wait. */
|
||||
| { kind: 'update-now' };
|
||||
|
||||
export type MasterMessage =
|
||||
/** personId is the person's stable uuid; the instance stores it and
|
||||
@@ -245,5 +553,5 @@ export type MasterMessage =
|
||||
|
||||
export type InstanceMessage =
|
||||
| { type: 'hello'; instance: string; id?: string; capabilities: InstanceCapabilities; version?: BuildVersion }
|
||||
| { type: 'report'; firms: FirmStats[]; scheduler: SchedulerState; 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 };
|
||||
|
||||
+108
-43
@@ -12,16 +12,29 @@
|
||||
* 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 axios from 'axios';
|
||||
import { ProxyAgent, type Dispatcher } from 'undici';
|
||||
import type { AxiosDefaults } from 'axios';
|
||||
import { ProxyAgent, WebSocket as UndiciWebSocket, type Dispatcher } from 'undici';
|
||||
import NodeWebSocket from 'ws';
|
||||
import { socksDispatcher } from 'fetch-socks';
|
||||
import { HttpProxyAgent } from 'http-proxy-agent';
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent';
|
||||
import { SocksProxyAgent } from 'socks-proxy-agent';
|
||||
import type { Agent } from 'node:http';
|
||||
import { getSetting } from './db';
|
||||
import { getRequestUserAgent } from './request-user-agent';
|
||||
|
||||
const SCHEMES = ['http:', 'https:', 'socks5:', 'socks5h:'] as const;
|
||||
|
||||
@@ -30,7 +43,6 @@ interface ProxyState {
|
||||
dispatcher: Dispatcher | null;
|
||||
httpAgent: Agent | null;
|
||||
httpsAgent: Agent | null;
|
||||
applied: boolean;
|
||||
}
|
||||
|
||||
// HMR-safe, and the "read once per process" contract lives here.
|
||||
@@ -39,7 +51,7 @@ 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, applied: false };
|
||||
const s: ProxyState = { url: null, dispatcher: null, httpAgent: null, httpsAgent: null };
|
||||
g.__outboundProxy = s;
|
||||
|
||||
const raw = getSetting('proxy_url')?.trim();
|
||||
@@ -49,7 +61,8 @@ function state(): ProxyState {
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
console.error(`[proxy] proxy_url is not a URL — trading DIRECT: ${raw}`);
|
||||
// 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])) {
|
||||
@@ -57,22 +70,42 @@ function state(): ProxyState {
|
||||
return s;
|
||||
}
|
||||
|
||||
const socks = url.protocol.startsWith('socks');
|
||||
if (socks) {
|
||||
s.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.httpAgent = agent;
|
||||
s.httpsAgent = agent;
|
||||
} else {
|
||||
s.dispatcher = new ProxyAgent(url.href);
|
||||
s.httpAgent = new HttpProxyAgent(url);
|
||||
s.httpsAgent = new HttpsProxyAgent(url);
|
||||
// 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)`);
|
||||
@@ -87,44 +120,76 @@ export function getProxyUrl(): string | null {
|
||||
/** Puts the proxy under every axios call. All axios usage in this app is
|
||||
* broker/quote traffic (TopstepX, Tradovate REST, contract resolver), so the
|
||||
* process-wide default is the scoped choice, not a global one. Idempotent;
|
||||
* called first in the getClients() bootstrap. */
|
||||
export function applyBrokerProxy(): void {
|
||||
* 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.applied || !s.url) return;
|
||||
s.applied = true;
|
||||
axios.defaults.proxy = false; // agents own the routing; ignore env HTTP(S)_PROXY handling
|
||||
axios.defaults.httpAgent = s.httpAgent!;
|
||||
axios.defaults.httpsAgent = s.httpsAgent!;
|
||||
if (!s.url || defaults.httpsAgent === s.httpsAgent) return;
|
||||
defaults.proxy = false; // agents own the routing; ignore env HTTP(S)_PROXY handling
|
||||
defaults.httpAgent = s.httpAgent!;
|
||||
defaults.httpsAgent = s.httpsAgent!;
|
||||
}
|
||||
|
||||
/** A websocket to a BROKER endpoint: the built-in (undici) WebSocket, dialled
|
||||
* through the proxy when one is set. The master link keeps using plain
|
||||
* `new WebSocket(...)` — do not use this for it. */
|
||||
/** 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();
|
||||
if (!dispatcher) return new WebSocket(wsUrl);
|
||||
// The runtime accepts { dispatcher } fine; only the DOM lib type doesn't.
|
||||
return new (WebSocket as unknown as new (u: string, o: object) => WebSocket)(wsUrl, { dispatcher });
|
||||
return new UndiciWebSocket(wsUrl, {
|
||||
...(dispatcher ? { dispatcher } : {}),
|
||||
headers: { 'User-Agent': getRequestUserAgent() },
|
||||
}) as unknown as WebSocket;
|
||||
}
|
||||
|
||||
/** A `ws`-package constructor with the proxy agent baked in, for SignalR's
|
||||
* `withUrl({ WebSocket })` option. undefined = no proxy, let SignalR use its
|
||||
* own require('ws'). */
|
||||
/** SignalR always uses this constructor, even without a proxy, because its
|
||||
* own transport supplies a library User-Agent that we must replace. */
|
||||
export function signalrWebSocket(): unknown {
|
||||
const s = state();
|
||||
if (!s.httpsAgent) return undefined;
|
||||
const agent = s.httpsAgent;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const WS = require('ws') as new (address: string, protocols?: unknown, options?: object) => unknown;
|
||||
|
||||
return class ProxiedWebSocket extends (WS as new (...args: never[]) => object) {
|
||||
return class ConfiguredWebSocket extends NodeWebSocket {
|
||||
constructor(address: string, protocols?: unknown, options?: Record<string, unknown>) {
|
||||
// ws also allows (address, options) — normalize before merging.
|
||||
if (protocols && typeof protocols === 'object' && !Array.isArray(protocols)) {
|
||||
options = protocols as Record<string, unknown>;
|
||||
protocols = undefined;
|
||||
}
|
||||
super(...[address, protocols, { ...(options ?? {}), agent }] as never[]);
|
||||
const headers = { ...(options?.headers as Record<string, string> | undefined) };
|
||||
for (const name of Object.keys(headers)) {
|
||||
if (name.toLowerCase() === 'user-agent') delete headers[name];
|
||||
}
|
||||
headers['User-Agent'] = getRequestUserAgent();
|
||||
const agent = address.startsWith('ws:') ? s.httpAgent : s.httpsAgent;
|
||||
super(address, protocols, { ...(options ?? {}), ...(agent ? { agent } : {}), headers });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+487
-6
@@ -4,26 +4,72 @@
|
||||
* 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` (e.g.
|
||||
* "CON.F.US.ENQ.Z25") on TopstepX — everything that compares them only ever
|
||||
* compares within one client, so the union is safe.
|
||||
* 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[];
|
||||
accountCashBalances: { [accountId: number]: { amount: number; realizedPnL: number } };
|
||||
/** 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;
|
||||
@@ -40,25 +86,130 @@ export interface PlatformClient {
|
||||
fundTransactions: { [accountId: number]: { date: string; amount: number }[] };
|
||||
priorProfit: { [accountId: number]: number };
|
||||
autoLiqThresholds: { [accountId: number]: number };
|
||||
/** Share of a position's notional a stop may fill past its price — the
|
||||
* part of the daily-loss reserve that grows with size (dllReserve).
|
||||
* Only where this platform's stops are measured to fill closer than
|
||||
* DLL_SLIPPAGE_FRACTION, the default. */
|
||||
stopSlippageFraction?: number;
|
||||
|
||||
fetchDaysComplete: boolean;
|
||||
syncComplete: boolean;
|
||||
/** 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 }>;
|
||||
findFrontMonthContract(productName: string): Promise<PlatformContract | null>;
|
||||
/**
|
||||
* 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
|
||||
price?: number,
|
||||
intent?: OrderIntent,
|
||||
): Promise<Record<string, unknown>>;
|
||||
|
||||
placeOrderNoWait(
|
||||
@@ -81,6 +232,336 @@ export interface PlatformClient {
|
||||
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
+269
-39
@@ -11,16 +11,24 @@
|
||||
* POST keeps those working unchanged.
|
||||
*/
|
||||
|
||||
import { getSetting, loadEndedCycleStart } from './db';
|
||||
import { getSetting, loadClaimedCycles, loadEndedCycleStart } from './db';
|
||||
import { pushReport } from './master-link';
|
||||
import { getBuildVersion } from './version';
|
||||
import { tradeMixFor } from './trade-mix';
|
||||
import { updaterStatus } from './updater-status';
|
||||
import { getClients } from './clients';
|
||||
import { getTradableFirms } from './firm-catalog';
|
||||
import { getAccountTypeById, getKeptRuleIdsForFirm, getTradableFirms } from './firm-catalog';
|
||||
import { getAccountConfig } from './account-type-resolver';
|
||||
import { getSchedulerStatus } from './auto-trade';
|
||||
import { combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig } from './trading-logic';
|
||||
import type { AccountTypeStats, FirmStats } from './master-protocol';
|
||||
import { historyFresh } from './platform-client';
|
||||
import {
|
||||
botStopsActive, combineCmeSessions, computeDayPlan, completionPlace, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON,
|
||||
projectPerfectPath, resolveEffectiveConfig, stageInputsFor, tradeDateFor, withinEndedCycleWindow,
|
||||
} from './trading-logic';
|
||||
import type { AccountTypeStats, FirmStats, InstrumentStats, SymbolStats } from './master-protocol';
|
||||
import { accountNextLeg, accountLegShape } from './leg-estimate';
|
||||
import { sizingHoldHours } from './person-size';
|
||||
import { instrumentStatsFor, isFuturesFirm } from './instruments';
|
||||
import { accountStatusFields, brokerFacts, emptyFirmStats, firmStatusFields, historyFresh, keptRuleIdsField, vestAppNumbersField } from './platform-client';
|
||||
|
||||
function collectFirmStats() {
|
||||
// Only firms this machine has credentials for. A firm it cannot log into
|
||||
@@ -28,9 +36,6 @@ function collectFirmStats() {
|
||||
// the master's table with rows for firms it does not run.
|
||||
const firms = getTradableFirms();
|
||||
const clients = getClients();
|
||||
// One raw trade date for the whole report: the combined views below roll
|
||||
// it forward, the completion check needs it as is.
|
||||
const rawToday = currentTradeDateCT();
|
||||
// projectPerfectPath's `loops` test: a type whose completed cycle keeps
|
||||
// going pays out (a funded account), one that stops just passes.
|
||||
const loops = (cfg: { payoutable?: boolean; withdrawalStages: unknown[] }): boolean =>
|
||||
@@ -40,16 +45,15 @@ function collectFirmStats() {
|
||||
|
||||
return firms.map((f) => {
|
||||
const client = clients.get(f.id);
|
||||
if (!client || client.accountList.length === 0) {
|
||||
return {
|
||||
firm: f.name, totalAccounts: 0, accountsTraded: 0, inTrade: 0,
|
||||
targetHit: 0, partial: 0, loss: 0, personalDll: 0, completed: 0,
|
||||
// Empty only once synced — before that, a firm whose accounts
|
||||
// haven't arrived would read as every saved account erased.
|
||||
...(client?.syncComplete ? { accounts: [] } : {}),
|
||||
accountTypes: [],
|
||||
};
|
||||
}
|
||||
// Absent (null) while this machine's catalog can't vouch for them.
|
||||
const keptRuleIds = getKeptRuleIdsForFirm(f.id);
|
||||
// accounts: [] only once synced (see emptyFirmStats).
|
||||
if (!client || client.accountList.length === 0) return { ...emptyFirmStats(f, client, keptRuleIds), ...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;
|
||||
@@ -67,16 +71,19 @@ function collectFirmStats() {
|
||||
// Per account type, so the master can say what the accounts still to
|
||||
// trade actually are. Keyed by account type id; unassigned accounts
|
||||
// share the null bucket — they inflate "left to trade" indefinitely,
|
||||
// which is precisely the thing worth being able to see.
|
||||
// which is precisely the thing worth being able to see. Always the
|
||||
// live type's id, label and size: accounts on a kept rule set count
|
||||
// in their live type's slice (and in its keptRules).
|
||||
const byType = new Map<string | null, AccountTypeStats>();
|
||||
const bucket = (cfg: { id: string; label: string; accountSize: number } | null): AccountTypeStats => {
|
||||
const key = cfg?.id ?? null;
|
||||
let entry = byType.get(key);
|
||||
if (!entry) {
|
||||
const live = key ? getAccountTypeById(key) ?? cfg : null;
|
||||
entry = {
|
||||
accountTypeId: key,
|
||||
label: cfg?.label ?? 'Unassigned',
|
||||
accountSize: cfg?.accountSize ?? 0,
|
||||
label: live?.label ?? 'Unassigned',
|
||||
accountSize: live?.accountSize ?? 0,
|
||||
totalAccounts: 0,
|
||||
accountsTraded: 0,
|
||||
inTrade: 0,
|
||||
@@ -88,12 +95,53 @@ function collectFirmStats() {
|
||||
}
|
||||
return entry;
|
||||
};
|
||||
// The completed cycles this instance's own claims ended, by slice
|
||||
// (listed after the accounts — see below).
|
||||
const claimedBySlice = new Map<AccountTypeStats, NonNullable<AccountTypeStats['completedAccounts']>>();
|
||||
/** When each claimed entry's claim was made, to list the newest first. */
|
||||
const claimedAt = new Map<object, number>();
|
||||
|
||||
for (const acc of client.accountList) {
|
||||
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
|
||||
const reportedCash = client.accountCashBalances[acc.id];
|
||||
const cash = reportedCash ?? { amount: 0, realizedPnL: 0 };
|
||||
const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0;
|
||||
const cfg = getAccountConfig(acc.id).accountType;
|
||||
accounts.push({ name: acc.name, size: cfg?.accountSize ?? 0, payoutable: cfg ? loops(cfg) : null });
|
||||
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;
|
||||
@@ -102,6 +150,12 @@ function collectFirmStats() {
|
||||
|
||||
totalAccounts++;
|
||||
slice.totalAccounts++;
|
||||
if (resolved.keptRules) {
|
||||
const keptId = resolved.keptRules.id;
|
||||
const kept = (slice.keptRules ??= []).find((k) => k.id === keptId);
|
||||
if (kept) kept.accounts++;
|
||||
else slice.keptRules.push({ id: keptId, accounts: 1 });
|
||||
}
|
||||
|
||||
// Same combined-day view as the trade path: for a combining firm,
|
||||
// "today" spans the holiday stretch. Computed for every live
|
||||
@@ -117,11 +171,11 @@ function collectFirmStats() {
|
||||
// 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 fundTxns = client.fundTransactions?.[acc.id] ?? [];
|
||||
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
|
||||
@@ -175,6 +229,8 @@ function collectFirmStats() {
|
||||
fundTransactions: fundTxns,
|
||||
balance: cash.amount,
|
||||
realizedPnL: cash.realizedPnL,
|
||||
rawTradeDate: rawToday,
|
||||
stageInputs: stageInputsFor(client, acc.id, cfg),
|
||||
payout: {
|
||||
withdrawalPct: cfg.withdrawalPct,
|
||||
payoutCap: cfg.payoutCap,
|
||||
@@ -214,7 +270,7 @@ function collectFirmStats() {
|
||||
maxDay: qualifying.length > 0 ? Math.max(...qualifying.map((d) => d.pnl)) : 0,
|
||||
sum,
|
||||
high: highWater,
|
||||
withdrawals: fundTxns.filter((f) => f.amount < 0).length,
|
||||
withdrawals: withdrawalsTaken,
|
||||
target: effective.profitTarget,
|
||||
consistency: effective.consistency,
|
||||
minDays: effective.minTradingDays,
|
||||
@@ -227,7 +283,7 @@ function collectFirmStats() {
|
||||
// restart the missing rows and withdrawals would read as a fresh
|
||||
// stage-1 cycle.
|
||||
const historyLoaded = client.dailyPnL[acc.id] !== undefined
|
||||
&& client.fundTransactions?.[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
|
||||
@@ -238,6 +294,7 @@ function collectFirmStats() {
|
||||
const ended = endedCycleCompletion({
|
||||
accountId: acc.id,
|
||||
rawTradeDate: rawToday,
|
||||
client,
|
||||
fullDailyPnL: client.fullDailyPnL[acc.id] ?? [],
|
||||
cycleDailyPnL: client.dailyPnL[acc.id],
|
||||
fundTransactions: fundTxns,
|
||||
@@ -248,16 +305,47 @@ function collectFirmStats() {
|
||||
cfg,
|
||||
});
|
||||
if (ended) {
|
||||
// Counted one per payout, as the final day counts this
|
||||
// cycle, so the two name it alike.
|
||||
const place = completionPlace({
|
||||
payoutDays: payoutDaysTaken, fundTransactions: fundTxns, cycle: ended.cycle,
|
||||
end: client.lastFundDates?.[acc.id] ?? undefined,
|
||||
});
|
||||
(slice.completedAccounts ??= []).push({
|
||||
name: acc.name,
|
||||
date: ended.date,
|
||||
stage: ended.stage,
|
||||
stage: place.stage,
|
||||
kind: completionKind(cfg, ended.drain),
|
||||
cycle: ended.cycle,
|
||||
...(place.cycleSeq > 0 ? { cycleSeq: place.cycleSeq } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// The completed cycles this instance's own claims ended
|
||||
// (afterClaim), named for the catch-up's week: a claim can end
|
||||
// one before any report saw it complete, where the catch-up
|
||||
// can't find it again (a Vest day's second claim, or one right
|
||||
// after the reset — trading-logic claimedCycle). Named only, as
|
||||
// the catch-up's: no counter moves.
|
||||
if (cfg) {
|
||||
for (const c of loadClaimedCycles(acc.id).reverse()) {
|
||||
if (!withinEndedCycleWindow(c.date, rawToday)) continue;
|
||||
let list = claimedBySlice.get(slice);
|
||||
if (!list) claimedBySlice.set(slice, list = []);
|
||||
const entry = {
|
||||
name: acc.name,
|
||||
date: c.date,
|
||||
stage: c.stage,
|
||||
kind: completionKind(cfg, c.drain),
|
||||
cycle: c.cycle,
|
||||
...(c.cycleSeq > 0 ? { cycleSeq: c.cycleSeq } : {}),
|
||||
};
|
||||
claimedAt.set(entry, c.at ?? 0);
|
||||
list.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
if (client.positions[acc.id]) {
|
||||
inTrade++;
|
||||
accountsTraded++;
|
||||
@@ -274,9 +362,11 @@ function collectFirmStats() {
|
||||
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,
|
||||
@@ -292,14 +382,18 @@ function collectFirmStats() {
|
||||
dayEntry.targetHit++;
|
||||
dayEntry.completed = (dayEntry.completed ?? 0) + 1;
|
||||
}
|
||||
// The fund transaction that started this cycle, and its
|
||||
// place among that day's payouts — the catch-up names the
|
||||
// same cycle by the same key.
|
||||
const cycle = client.lastFundDates?.[acc.id] ?? '';
|
||||
const place = completionPlace({ payoutDays: payoutDaysTaken, fundTransactions: fundTxns, cycle });
|
||||
(slice.completedAccounts ??= []).push({
|
||||
name: acc.name,
|
||||
date: combined.tradeDate,
|
||||
stage: 1 + fundTxns.filter((f) => f.amount < 0).length,
|
||||
stage: place.stage,
|
||||
kind: completionKind(cfg, effective.drain),
|
||||
// The fund transaction that started this cycle — the
|
||||
// catch-up names the same cycle by the same key.
|
||||
cycle: client.lastFundDates?.[acc.id] ?? '',
|
||||
cycle,
|
||||
...(place.cycleSeq > 0 ? { cycleSeq: place.cycleSeq } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -338,11 +432,12 @@ function collectFirmStats() {
|
||||
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 a stop-enabled account 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 ((cfg.firstDayStop || cfg.minDayStop) && combined.realizedPnL <= -25) {
|
||||
// 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) {
|
||||
@@ -360,6 +455,23 @@ function collectFirmStats() {
|
||||
}
|
||||
}
|
||||
|
||||
// Claimed cycles go last in each slice's list, newest first: the
|
||||
// master reads only so many entries a slice (twice its accounts), so
|
||||
// what it has long had drops off before a final day, a catch-up or a
|
||||
// fresh claim. One those already name goes once.
|
||||
for (const [slice, claimed] of claimedBySlice) {
|
||||
const list = slice.completedAccounts ?? [];
|
||||
const key = (e: (typeof list)[number]) => `${e.name}\n${e.cycle}\n${e.cycleSeq ?? 0}`;
|
||||
const named = new Set(list.map(key));
|
||||
// Newest first across accounts: a day's ties go by the claim's time.
|
||||
for (const e of claimed.sort((a, b) => b.date.localeCompare(a.date) || (claimedAt.get(b) ?? 0) - (claimedAt.get(a) ?? 0))) {
|
||||
if (named.has(key(e))) continue;
|
||||
named.add(key(e));
|
||||
list.push(e);
|
||||
}
|
||||
if (list.length > 0) slice.completedAccounts = list;
|
||||
}
|
||||
|
||||
// Biggest shortfall first — the account types with the most left to do
|
||||
// are the ones worth reading, and Unassigned sinks to the bottom only
|
||||
// when it is genuinely small.
|
||||
@@ -374,12 +486,122 @@ function collectFirmStats() {
|
||||
// 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
|
||||
@@ -396,7 +618,14 @@ export async function sendReport(): Promise<void> {
|
||||
const firms = collectFirmStats();
|
||||
const scheduler = getSchedulerStatus();
|
||||
|
||||
if (pushReport(firms, scheduler)) return;
|
||||
const overSocketLimit = JSON.stringify(firms).length > SOCKET_REPORT_MAX_CHARS;
|
||||
if (overSocketLimit !== (_g.__reportOverSocketLimit ?? false)) {
|
||||
_g.__reportOverSocketLimit = overSocketLimit;
|
||||
console.warn(overSocketLimit
|
||||
? `[reporter] report over ${SOCKET_REPORT_MAX_CHARS} chars — sending it by POST, not the socket`
|
||||
: '[reporter] report fits the socket again');
|
||||
}
|
||||
if (!overSocketLimit && pushReport(firms, scheduler)) return;
|
||||
|
||||
await fetch(new URL('/api/report', url), {
|
||||
method: 'POST',
|
||||
@@ -409,6 +638,7 @@ export async function sendReport(): Promise<void> {
|
||||
firms,
|
||||
scheduler,
|
||||
version: getBuildVersion() ?? undefined,
|
||||
updater: updaterStatus(),
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+1133
-211
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));
|
||||
}
|
||||
}
|
||||
+1194
-83
File diff suppressed because it is too large
Load Diff
+1732
-336
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']) };
|
||||
}
|
||||
+9
-5
@@ -22,12 +22,16 @@ export interface BuildVersion {
|
||||
time: string;
|
||||
}
|
||||
|
||||
let cached: BuildVersion | null | undefined;
|
||||
// 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 (cached !== undefined) return cached;
|
||||
if (_g.__buildVersion !== undefined) return _g.__buildVersion;
|
||||
try {
|
||||
const out = execFileSync('git', ['log', '-1', '--format=%h %cI'], {
|
||||
cwd: process.cwd(),
|
||||
@@ -35,9 +39,9 @@ export function getBuildVersion(): BuildVersion | null {
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
}).trim();
|
||||
const [commit, time] = out.split(' ');
|
||||
cached = commit && time ? { commit, time } : null;
|
||||
_g.__buildVersion = commit && time ? { commit, time } : null;
|
||||
} catch {
|
||||
cached = null;
|
||||
_g.__buildVersion = null;
|
||||
}
|
||||
return cached;
|
||||
return _g.__buildVersion;
|
||||
}
|
||||
|
||||
+2466
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,844 @@
|
||||
/**
|
||||
* Pure helpers for the Vest client (lib/vest-class.ts) — parsing the gateway's
|
||||
* answers and errors, naming accounts, Vest's trading day, profit-claim
|
||||
* bookkeeping, market hours, volatility.
|
||||
*
|
||||
* Only Node built-ins are imported, so scripts/check-vest.mjs loads this file
|
||||
* directly (node --experimental-strip-types).
|
||||
*/
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
/** A Vest login's primary wallet: 0x + 40 hex. The instance's saved Vest
|
||||
* "credential" is this address — it says whose relayed session to trade. */
|
||||
export function isVestWalletAddress(value: unknown): value is string {
|
||||
return typeof value === 'string' && /^0x[0-9a-fA-F]{40}$/.test(value.trim());
|
||||
}
|
||||
|
||||
/** Stable numeric id for a Vest account uuid, in the same range as DXtrade's
|
||||
* (dxAccountId) but keyed apart, so every numeric account table keeps
|
||||
* working. */
|
||||
export function vestAccountId(uuid: string): number {
|
||||
const digest = createHash('sha256').update(`vest|${uuid.toLowerCase()}`).digest();
|
||||
return 2 ** 50 + digest.readUIntBE(0, 6) * 16 + (digest[6] >> 4);
|
||||
}
|
||||
|
||||
/** A decimal string or number from the gateway → a number (undefined when
|
||||
* missing or not a number). */
|
||||
export function num(raw: unknown): number | undefined {
|
||||
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : undefined;
|
||||
if (typeof raw !== 'string' || raw.trim() === '') return undefined;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
/** Epoch ms from the gateway's epoch ms, epoch seconds or ISO strings; NaN
|
||||
* when unreadable. */
|
||||
export function toMs(raw: unknown): number {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return raw < 1e11 ? raw * 1000 : raw;
|
||||
if (typeof raw === 'string' && raw.trim() !== '') {
|
||||
const n = Number(raw);
|
||||
if (Number.isFinite(n)) return n < 1e11 ? n * 1000 : n;
|
||||
const t = Date.parse(raw);
|
||||
return Number.isFinite(t) ? t : NaN;
|
||||
}
|
||||
return NaN;
|
||||
}
|
||||
|
||||
/** `value` on a decimal grid, as the gateway wants it: a plain string with at
|
||||
* most `decimals` places, trailing zeros trimmed (never exponent notation). */
|
||||
export function formatDecimal(value: number, decimals: number): string {
|
||||
const d = Math.max(0, Math.min(18, Math.floor(decimals)));
|
||||
const fixed = value.toFixed(d);
|
||||
return d > 0 ? fixed.replace(/\.?0+$/, '') || '0' : fixed;
|
||||
}
|
||||
|
||||
// ── gateway errors ──────────────────────────────────────────────────────────
|
||||
|
||||
/** The gateway answered with an error status. */
|
||||
export class VestApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string | undefined;
|
||||
constructor(message: string, status: number, code: string | undefined) {
|
||||
super(message);
|
||||
this.name = 'VestApiError';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/** The request never went out: the account token it needed couldn't be had. */
|
||||
export class VestNotSentError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'VestNotSentError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a failed request proves nothing it asked for happened: it never
|
||||
* went out, or the gateway refused it (a 4xx, but not a timeout). A
|
||||
* transport failure or a 5xx may have gone in. */
|
||||
export function nothingWentIn(err: unknown): boolean {
|
||||
return err instanceof VestNotSentError
|
||||
|| (err instanceof VestApiError && err.status >= 400 && err.status < 500 && err.status !== 408);
|
||||
}
|
||||
|
||||
/** The account token a request needs, from `mint`. Any failure to mint one —
|
||||
* a timeout, a 5xx, an answer without a token — is the request not sent,
|
||||
* never an outcome that could have gone in. */
|
||||
export async function tokenForRequest(path: string, mint: () => Promise<string>): Promise<string> {
|
||||
try {
|
||||
return await mint();
|
||||
} catch (err) {
|
||||
throw new VestNotSentError(`${path} not sent — no account token: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Vest's trading day ──────────────────────────────────────────────────────
|
||||
|
||||
const ET_PARTS = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'America/New_York', hourCycle: 'h23',
|
||||
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit',
|
||||
});
|
||||
|
||||
/** Vest's daily reset hour, America/New_York (its docs: 20:00 ET). */
|
||||
export const VEST_RESET_HOUR_ET = 20;
|
||||
|
||||
/**
|
||||
* The trading day a moment belongs to, YYYY-MM-DD. Vest's day runs 20:00 ET
|
||||
* to 20:00 ET and is named for the date it ends on, like the CME session
|
||||
* that opens the evening before — so 21:00 ET on the 26th is the 27th's day.
|
||||
*/
|
||||
export function vestDayOf(ms: number): string {
|
||||
const parts = Object.fromEntries(ET_PARTS.formatToParts(new Date(ms)).map((p) => [p.type, p.value]));
|
||||
const date = `${parts.year}-${parts.month}-${parts.day}`;
|
||||
if (Number(parts.hour) < VEST_RESET_HOUR_ET) return date;
|
||||
const next = new Date(`${date}T12:00:00Z`);
|
||||
next.setUTCDate(next.getUTCDate() + 1);
|
||||
return next.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** The last 20:00 ET at or before `now`, epoch ms. 20:00 ET is 00:00 UTC
|
||||
* in summer and 01:00 UTC in winter; whichever of those hours, on this or
|
||||
* an earlier UTC day, reads as 20:00 in New York is the boundary. */
|
||||
export function lastVestResetMs(now: number): number {
|
||||
const day = Date.UTC(new Date(now).getUTCFullYear(), new Date(now).getUTCMonth(), new Date(now).getUTCDate());
|
||||
for (let back = 0; back <= 2; back++) {
|
||||
for (const hour of [1, 0]) {
|
||||
const t = day - back * 86_400_000 + hour * 3_600_000;
|
||||
if (t > now) continue;
|
||||
const parts = Object.fromEntries(ET_PARTS.formatToParts(new Date(t)).map((p) => [p.type, p.value]));
|
||||
if (Number(parts.hour) === VEST_RESET_HOUR_ET) return t;
|
||||
}
|
||||
}
|
||||
return NaN;
|
||||
}
|
||||
|
||||
/** The next 20:00 ET after `now`, epoch ms (a day can be 23 or 25 hours). */
|
||||
export function nextVestResetMs(now: number): number {
|
||||
for (let h = 1; h <= 26; h++) {
|
||||
const t = lastVestResetMs(now + h * 3_600_000);
|
||||
if (t > now) return t;
|
||||
}
|
||||
return NaN;
|
||||
}
|
||||
|
||||
/** Vest's stopSlippageFraction: 0.02% of the position, about 6 NDX points.
|
||||
* Measured 2026-10-27 on 9 bot stop-outs (Me, $74k–$132k): fills 0.9 points
|
||||
* past the trigger on average, 2.25 at worst — the 0.1% default held back
|
||||
* about 30. The flat 10% of the limit still applies under it. */
|
||||
export const VEST_STOP_SLIPPAGE_FRACTION = 0.0002;
|
||||
/** Crypto's (asset "crypto" in exchangeInfo: BTC, ETH, SOL, HYPE): unmeasured,
|
||||
* so the engine's default share (trading-logic's DLL_SLIPPAGE_FRACTION).
|
||||
* Vest's BTC book trades ~$10M an hour on a weekend against NDX's ~$600M, and
|
||||
* a batch's stops fire together: a square-root impact estimate scaled to the
|
||||
* NDX measurement puts the worst fill near 7bp. To come down once crypto
|
||||
* stop-outs are measured, as NDX's was. */
|
||||
export const VEST_CRYPTO_STOP_SLIPPAGE_FRACTION = 0.001;
|
||||
|
||||
/** The stop-slippage share for a symbol of this exchangeInfo asset class. */
|
||||
export function vestStopSlippageFraction(asset: string | undefined): number {
|
||||
return asset === 'crypto' ? VEST_CRYPTO_STOP_SLIPPAGE_FRACTION : VEST_STOP_SLIPPAGE_FRACTION;
|
||||
}
|
||||
|
||||
/**
|
||||
* What to do with an open position before the daily reset so the reset can't
|
||||
* breach it. At 20:00 ET Vest captures the realized balance (collateral in,
|
||||
* unrealized P&L out) and sets the day's floor to balance × (1 − rate); the
|
||||
* max-drawdown floor stays. A position whose loss allowance is wider than
|
||||
* that next-day room would breach the moment the reset lands, so its stop
|
||||
* comes in to where equity stays above the coming floor plus `reserve`, with
|
||||
* the exit fee paid:
|
||||
* 'none' — its stop is already at least that tight;
|
||||
* 'stop' — move (or place) the stop at `price`, on the tick grid, rounded
|
||||
* toward the position's safe side;
|
||||
* 'close' — the mark is already past that price (with no room left by
|
||||
* the account's other positions, it sits on the profit side),
|
||||
* or no room and no mark to tell.
|
||||
*/
|
||||
export function resetSafeStop(o: {
|
||||
side: 'long' | 'short';
|
||||
quantity: number;
|
||||
openPrice: number;
|
||||
mark: number;
|
||||
/** Realized balance, collateral included — what the reset captures —
|
||||
* less what the account's other positions may lose at their stops. */
|
||||
balance: number;
|
||||
/** The realized balance the floor is set from; default `balance`. */
|
||||
floorBalance?: number;
|
||||
dailyLossPct: number;
|
||||
maxDrawdownFloor: number | undefined;
|
||||
reserve: number;
|
||||
/** Taker fee as a fraction of notional. */
|
||||
exitFeeRate: number;
|
||||
currentStop: number | null;
|
||||
tick: number;
|
||||
}): { action: 'none' } | { action: 'stop'; price: number } | { action: 'close' } {
|
||||
const floor = Math.max((o.floorBalance ?? o.balance) * (1 - o.dailyLossPct), o.maxDrawdownFloor ?? -Infinity);
|
||||
const exitFee = o.exitFeeRate * o.quantity * (Number.isFinite(o.mark) && o.mark > 0 ? o.mark : o.openPrice);
|
||||
const allowed = o.balance - floor - o.reserve - exitFee;
|
||||
if (!(o.quantity > 0)) return { action: 'close' };
|
||||
// No room to lose (the account's other positions took it): the stop must
|
||||
// lock in at least the shortfall — on the profit side of the entry,
|
||||
// which a position already that far up can still carry.
|
||||
if (!(allowed > 0) && !Number.isFinite(o.mark)) return { action: 'close' };
|
||||
const long = o.side === 'long';
|
||||
const raw = long ? o.openPrice - allowed / o.quantity : o.openPrice + allowed / o.quantity;
|
||||
const price = o.tick > 0
|
||||
? (long ? Math.ceil(raw / o.tick - 1e-9) * o.tick : Math.floor(raw / o.tick + 1e-9) * o.tick)
|
||||
: raw;
|
||||
const rounded = Number(price.toFixed(10));
|
||||
if (o.currentStop !== null && (long ? o.currentStop >= rounded : o.currentStop <= rounded)) return { action: 'none' };
|
||||
if (Number.isFinite(o.mark) && (long ? o.mark <= rounded : o.mark >= rounded)) return { action: 'close' };
|
||||
return { action: 'stop', price: rounded };
|
||||
}
|
||||
|
||||
// ── accounts ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface VestCapitalAccount {
|
||||
id: string;
|
||||
planId: string;
|
||||
planName: string;
|
||||
/** 'eval' | 'instant_funded' | … as the gateway names the product. */
|
||||
productType: string;
|
||||
/** 1-based stage; stages[stage-1] is the current one. */
|
||||
stage: number;
|
||||
/** Evaluation steps the plan has (a 1-Step's is 1, a 2-Step's 2). */
|
||||
stepsTotal: number;
|
||||
/** The evaluation step this account is (1-based; a funded one's is
|
||||
* stepsTotal + 1, as the Vest app counts it when Vest leaves it out). */
|
||||
step: number;
|
||||
/** Vest's account_type: 2 an evaluation, 3 a funded account. */
|
||||
accountType: number;
|
||||
/** Vest blocked the account (blocked_at set): nothing it offers applies. */
|
||||
blocked: boolean;
|
||||
/** The current stage: 'eval' or 'funded'. */
|
||||
kind: string;
|
||||
status: number;
|
||||
/** Vest's attempt_index, from 0; -1 when Vest leaves it out. */
|
||||
attemptIndex: number;
|
||||
initialCapital: number;
|
||||
/** The current stage's profit target in dollars (0 = none). */
|
||||
profitTarget: number;
|
||||
targetEquity: number | undefined;
|
||||
maxDrawdownLimit: number | undefined;
|
||||
maxDailyLossPct: number;
|
||||
dailyResetBalance: number | undefined;
|
||||
dailyLossFloor: number | undefined;
|
||||
dailyResetAt: number;
|
||||
activatedAt: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/** One entry of GET /v3/capital/accounts/active; null when it isn't one. */
|
||||
export function parseCapitalAccount(raw: unknown): VestCapitalAccount | null {
|
||||
const a = raw as Record<string, unknown>;
|
||||
if (!a || typeof a.id !== 'string') return null;
|
||||
const stages = Array.isArray(a.stages) ? (a.stages as Record<string, unknown>[]) : [];
|
||||
const stage = num(a.stage) ?? 1;
|
||||
const current = stages.find((s) => num(s.ordinal) === stage) ?? stages[stage - 1] ?? {};
|
||||
// The Vest app's own fallbacks, for an answer that leaves these out.
|
||||
const stepsTotal = num(a.steps_total) ?? (a.plan_product_type === 'eval' ? 1 : 0);
|
||||
return {
|
||||
id: a.id,
|
||||
planId: typeof a.plan_id === 'string' ? a.plan_id : '',
|
||||
planName: typeof a.plan_name === 'string' ? a.plan_name : '',
|
||||
productType: typeof a.plan_product_type === 'string' ? a.plan_product_type : '',
|
||||
stage,
|
||||
stepsTotal,
|
||||
step: num(a.step) ?? (stage === VEST_STAGE_FUNDED ? stepsTotal + 1 : 1),
|
||||
accountType: num(a.account_type) ?? 0,
|
||||
blocked: Boolean(a.blocked_at),
|
||||
kind: typeof current.kind === 'string' ? current.kind : '',
|
||||
status: num(a.status) ?? -1,
|
||||
attemptIndex: num(a.attempt_index) ?? -1,
|
||||
initialCapital: num(a.initial_capital) ?? 0,
|
||||
profitTarget: num(current.profit_target) ?? 0,
|
||||
targetEquity: num(a.target_equity),
|
||||
maxDrawdownLimit: num(a.max_drawdown_limit),
|
||||
maxDailyLossPct: num(a.max_daily_loss_pct) ?? 0,
|
||||
dailyResetBalance: num(a.daily_reset_balance),
|
||||
dailyLossFloor: num(a.daily_loss_floor),
|
||||
dailyResetAt: toMs(a.daily_reset_at),
|
||||
activatedAt: toMs(a.activated_at),
|
||||
createdAt: toMs(a.created_at),
|
||||
};
|
||||
}
|
||||
|
||||
/** Vest's capital account statuses, as the Vest app names them (its bundle,
|
||||
* 2026-10-29). Only an ACTIVE account trades. A passed evaluation sits at
|
||||
* GOAL_PASSED until its funded account is activated, and is CLAIMED after. */
|
||||
export const VEST_STATUS = {
|
||||
CREATED: 1,
|
||||
ACTIVE: 2,
|
||||
GOAL_PASSED: 3,
|
||||
FAILED_MAX_DRAWDOWN: 4,
|
||||
FAILED_MAX_DAILY_LOSS: 5,
|
||||
CLAIMED: 6,
|
||||
EXPIRED: 7,
|
||||
} as const;
|
||||
export const VEST_STATUS_ACTIVE = VEST_STATUS.ACTIVE;
|
||||
|
||||
/** Vest's stage field: an evaluation's, then the funded account's. */
|
||||
const VEST_STAGE_EVAL = 1;
|
||||
const VEST_STAGE_FUNDED = 2;
|
||||
/** Vest's account_type of an evaluation. */
|
||||
const VEST_ACCOUNT_TYPE_EVAL = 2;
|
||||
|
||||
/** A status as the log says it: Vest's own name, with its number. */
|
||||
export function vestStatusName(status: number): string {
|
||||
const name = Object.entries(VEST_STATUS).find(([, v]) => v === status)?.[0];
|
||||
return `${name ? name.toLowerCase().replace(/_/g, ' ') : 'unknown'} (status ${status})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A passed evaluation whose funded account Vest activates on request (POST
|
||||
* /v3/capital/activate-funded) — the Vest app's own test for its "Go to
|
||||
* funded account" button: an evaluation at GOAL_PASSED, not blocked, on its
|
||||
* plan's last step. An earlier step of a 2-Step passes on to the next step
|
||||
* by itself.
|
||||
*/
|
||||
export function awaitingActivation(a: VestCapitalAccount): boolean {
|
||||
const evaluation = a.accountType === VEST_ACCOUNT_TYPE_EVAL || a.stage === VEST_STAGE_EVAL;
|
||||
return evaluation && a.status === VEST_STATUS.GOAL_PASSED && !a.blocked && a.stepsTotal > 0 && a.step === a.stepsTotal;
|
||||
}
|
||||
|
||||
const pct = (fraction: number) => Math.round(fraction * 1000) / 10;
|
||||
|
||||
/**
|
||||
* The number the Vest app shows for the account: attempt_index + 1, padded
|
||||
* below 10 ("Account 01"). An account without an attempt index (the app's
|
||||
* "Legacy Account") gets 0.
|
||||
*/
|
||||
export function vestAccountNumber(a: VestCapitalAccount): string {
|
||||
if (!(a.attemptIndex >= 0)) return '0';
|
||||
return `${a.attemptIndex < 9 ? '0' : ''}${a.attemptIndex + 1}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the dashboard calls the account and what account-type prefixes match
|
||||
* against. Vest itself only says "Account 34", so the name carries the rules
|
||||
* that tell its types apart, ending in that same number:
|
||||
* V50K-E-D3-T10-34 evaluation, $50k, 3% daily loss, 10% target, Account 34
|
||||
* V50K-F-D3-34 the funded stage of an evaluation account
|
||||
* V25K-I-07 instant funded (no daily loss limit, no target)
|
||||
* D0 = no daily loss limit. The target is the current stage's (a 2-Step's
|
||||
* second stage reads T5). Older builds ended the name in attempt_index, one
|
||||
* lower; the master renames what it saved under those numbers when a report
|
||||
* marks the firm vestAppNumbers.
|
||||
*/
|
||||
export function vestAccountName(a: VestCapitalAccount): string {
|
||||
const size = a.initialCapital >= 1000 ? `${+(a.initialCapital / 1000).toFixed(1)}K` : `${Math.round(a.initialCapital)}`;
|
||||
const dll = `D${pct(a.maxDailyLossPct)}`;
|
||||
const n = vestAccountNumber(a);
|
||||
if (/instant/i.test(a.productType)) return `V${size}-I-${n}`;
|
||||
if (a.kind === 'funded') return `V${size}-F-${dll}-${n}`;
|
||||
const target = a.initialCapital > 0 && a.profitTarget > 0 ? `-T${pct(a.profitTarget / a.initialCapital)}` : '';
|
||||
return `V${size}-E-${dll}${target}-${n}`;
|
||||
}
|
||||
|
||||
// ── positions and orders ────────────────────────────────────────────────────
|
||||
|
||||
export interface VestLeg {
|
||||
id: string;
|
||||
triggerPrice: number;
|
||||
executionType: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface VestPosition {
|
||||
positionId: string;
|
||||
accountId: string;
|
||||
symbol: string;
|
||||
side: 'long' | 'short';
|
||||
quantity: number;
|
||||
openPrice: number;
|
||||
/** Margin the position holds out of the free balance. */
|
||||
collateral: number;
|
||||
leverage: number;
|
||||
openedAt: number;
|
||||
stopLosses: VestLeg[];
|
||||
takeProfits: VestLeg[];
|
||||
}
|
||||
|
||||
function parseLegs(raw: unknown): VestLeg[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.flatMap((l) => {
|
||||
const leg = l as Record<string, unknown>;
|
||||
const trigger = num(leg?.triggerPrice);
|
||||
if (typeof leg?.id !== 'string' || trigger === undefined) return [];
|
||||
return [{ id: leg.id, triggerPrice: trigger, executionType: String(leg.executionType ?? ''), status: String(leg.status ?? '') }];
|
||||
});
|
||||
}
|
||||
|
||||
/** One entry of GET /v3/positions/opened; null when it isn't an open one. */
|
||||
export function parsePosition(raw: unknown): VestPosition | null {
|
||||
const p = raw as Record<string, unknown>;
|
||||
const accountId = p?.accountId ?? p?.account_id;
|
||||
if (!p || typeof p.positionId !== 'string' || typeof accountId !== 'string' || typeof p.symbol !== 'string') return null;
|
||||
const quantity = Math.abs(num(p.quantity) ?? 0);
|
||||
if (!(quantity > 0)) return null;
|
||||
return {
|
||||
positionId: p.positionId,
|
||||
accountId,
|
||||
symbol: p.symbol,
|
||||
side: String(p.side).toLowerCase() === 'short' ? 'short' : 'long',
|
||||
quantity,
|
||||
openPrice: num(p.openPrice) ?? NaN,
|
||||
collateral: num(p.collateral) ?? 0,
|
||||
leverage: num(p.leverage) ?? 0,
|
||||
openedAt: toMs(p.openDate ?? p.createdAt),
|
||||
stopLosses: parseLegs(p.stopLosses),
|
||||
takeProfits: parseLegs(p.takeProfits),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One position from GET /v3/positions — Vest's position history, the app's
|
||||
* History tab: closed positions with how they ended (an open one has no
|
||||
* close yet). pnl is Vest's realized P&L before fees and funding.
|
||||
*/
|
||||
export interface VestTrade {
|
||||
positionId: string;
|
||||
symbol: string;
|
||||
side: 'long' | 'short';
|
||||
/** 0 when Vest doesn't say (a store keeps the size it already knows). */
|
||||
quantity: number;
|
||||
leverage: number | null;
|
||||
openPrice: number | null;
|
||||
closePrice: number | null;
|
||||
pnl: number | null;
|
||||
fee: number | null;
|
||||
funding: number | null;
|
||||
openedAt: number;
|
||||
closedAt: number | null;
|
||||
/** user_requested, stop_loss, take_profit, liquidation, auto_deleverage,
|
||||
* temporary_leverage_limit, corporate_action. */
|
||||
closeReason: string | null;
|
||||
}
|
||||
|
||||
export function parseHistoryPosition(raw: unknown): VestTrade | null {
|
||||
const p = raw as Record<string, unknown>;
|
||||
const id = p?.positionId ?? p?.position_id;
|
||||
if (!p || typeof id !== 'string' || typeof p.symbol !== 'string') return null;
|
||||
const openedAt = toMs(p.openDate ?? p.open_date ?? p.createdAt ?? p.created_at);
|
||||
if (!Number.isFinite(openedAt)) return null;
|
||||
const closedMs = toMs(p.closeDate ?? p.close_date);
|
||||
const closedAt = Number.isFinite(closedMs) && closedMs > 0 ? closedMs : null;
|
||||
const openPrice = num(p.openPrice ?? p.open_price);
|
||||
// An open position comes with a placeholder close price and a $0 P&L
|
||||
// (seen: 290.25 on an NDX short at 29,538.96): only a close has them.
|
||||
const closePrice = closedAt === null ? undefined : num(p.closePrice ?? p.close_price);
|
||||
const reason = p.closeReason ?? p.close_reason;
|
||||
return {
|
||||
positionId: id,
|
||||
symbol: p.symbol,
|
||||
side: String(p.side).toLowerCase() === 'short' ? 'short' : 'long',
|
||||
quantity: Math.abs(num(p.quantity) ?? 0),
|
||||
leverage: num(p.leverage) ?? null,
|
||||
openPrice: openPrice !== undefined && openPrice > 0 ? openPrice : null,
|
||||
closePrice: closePrice !== undefined && closePrice > 0 ? closePrice : null,
|
||||
pnl: closedAt === null ? null : num(p.pnl) ?? null,
|
||||
fee: num(p.fee) ?? null,
|
||||
funding: num(p.funding) ?? null,
|
||||
openedAt,
|
||||
closedAt,
|
||||
closeReason: typeof reason === 'string' && reason !== '' ? reason : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Every string value in a JWT's payload, lower-case ([] when it isn't
|
||||
* one). Used only to compare against account ids — never logged. */
|
||||
export function jwtClaimStrings(token: string): string[] {
|
||||
const part = token.split('.')[1];
|
||||
if (!part) return [];
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = JSON.parse(Buffer.from(part.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const out: string[] = [];
|
||||
const walk = (v: unknown, depth: number): void => {
|
||||
if (typeof v === 'string') out.push(v.toLowerCase());
|
||||
else if (v && typeof v === 'object' && depth < 4) for (const x of Object.values(v)) walk(x, depth + 1);
|
||||
};
|
||||
walk(payload, 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a token the extension relayed is one Vest account's own token, not
|
||||
* the login's. The Vest app renews both through POST /v3/auth/refresh — the
|
||||
* login's every ~15 minutes, an account's while someone trades in it — and
|
||||
* an account's token sees only that account: /v3/capital/accounts/active
|
||||
* lists it alone, and /v3/accounts has no primary (wallet) account. The
|
||||
* login sees every account and its primary one.
|
||||
*/
|
||||
export function accountScopedToken(o: { capitalIds: string[]; hasPrimary: boolean; claims: string[] }): boolean {
|
||||
if (o.capitalIds.length > 1) return false;
|
||||
if (!o.hasPrimary) return true;
|
||||
return o.capitalIds.some((id) => o.claims.includes(id.toLowerCase()));
|
||||
}
|
||||
|
||||
/** Exit legs still waiting to trigger. */
|
||||
export function awaitingLegs(p: VestPosition): VestLeg[] {
|
||||
return [...p.stopLosses, ...p.takeProfits].filter((l) => l.status === 'awaiting');
|
||||
}
|
||||
|
||||
/**
|
||||
* The positions an open of unknown outcome (a timeout, a reset, a 5xx after
|
||||
* the send) may have made, as a read shows them — the bot's only when there
|
||||
* is exactly one. The one the open's answer named, when it named one. Else
|
||||
* on its account, in its symbol and side, with no exit legs (none went on
|
||||
* it), opened between `from` and `until` — the send and the error, widened
|
||||
* for the gateway's clock: a market open is IOC, so a later one (an outside
|
||||
* copy-trader's, which opens the same symbol on every account at once) isn't
|
||||
* it. More than one of those can't be told apart.
|
||||
*/
|
||||
export function unknownOpenFill(
|
||||
sent: { accountId: string; symbol: string; side: 'long' | 'short'; from: number; until: number; positionId?: string },
|
||||
positions: VestPosition[],
|
||||
): VestPosition[] {
|
||||
const mine = positions.filter((p) => p.accountId === sent.accountId);
|
||||
if (sent.positionId) return mine.filter((p) => p.positionId === sent.positionId);
|
||||
return mine.filter((p) => p.symbol === sent.symbol && p.side === sent.side
|
||||
&& p.stopLosses.length === 0 && p.takeProfits.length === 0
|
||||
&& p.openedAt >= sent.from && p.openedAt <= sent.until);
|
||||
}
|
||||
|
||||
export interface VestOpenOrder {
|
||||
orderId: string;
|
||||
accountId: string;
|
||||
positionId: string | undefined;
|
||||
symbol: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/** One entry of GET /v3/positions/opened-orders (snake or camel case). */
|
||||
export function parseOpenOrder(raw: unknown): VestOpenOrder | null {
|
||||
const o = raw as Record<string, unknown>;
|
||||
const orderId = o?.order_id ?? o?.orderId ?? o?.id;
|
||||
const accountId = o?.account_id ?? o?.accountId;
|
||||
if (typeof orderId !== 'string' || typeof accountId !== 'string') return null;
|
||||
const positionId = o.position_id ?? o.positionId;
|
||||
return {
|
||||
orderId,
|
||||
accountId,
|
||||
positionId: typeof positionId === 'string' ? positionId : undefined,
|
||||
symbol: String(o.symbol ?? ''),
|
||||
createdAt: toMs(o.created_at ?? o.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
/** Unrealized P&L of a position at `mark`. */
|
||||
export function unrealizedPnl(p: Pick<VestPosition, 'side' | 'quantity' | 'openPrice'>, mark: number): number {
|
||||
if (!Number.isFinite(mark) || !Number.isFinite(p.openPrice)) return 0;
|
||||
return (p.side === 'long' ? 1 : -1) * p.quantity * (mark - p.openPrice);
|
||||
}
|
||||
|
||||
// ── transactions ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The gateway's transaction types (its app's own enum, 2026-10-26). Trading
|
||||
* P&L is fees, realized P&L, funding and the adjustments that come with them;
|
||||
* funds are money moved in or out; the rest only moves margin between the
|
||||
* free balance and positions, and leaves the account's total alone.
|
||||
*/
|
||||
export const VEST_TXN = {
|
||||
DEPOSIT: 1, WITHDRAW: 2, OPEN_POSITION: 3, CLOSE_POSITION: 4, CORRECTION: 5, FILL_MARGIN: 6, MARGIN_RELEASE: 7,
|
||||
FEE: 8, REALIZED_PNL: 9, FUNDING: 10, LEVERAGE_RESERVE: 11, LEVERAGE_RELEASE: 12, ADD_COLLATERAL: 13,
|
||||
REMOVE_COLLATERAL: 14, ORDER_MARGIN_RESERVE: 15, ORDER_MARGIN_RELEASE: 16, LIQUIDATION_DEFICIT_COVERAGE: 17,
|
||||
CORPORATE_ACTION_CASH_ADJUSTMENT: 18, FUNDED_PROFIT_CREDIT: 19, AFFILIATE_PAYOUT_CREDIT: 20, COMPENSATION: 22,
|
||||
BALANCE_MIGRATION: 23,
|
||||
} as const;
|
||||
|
||||
const TRADING_TXNS = new Set<number>([
|
||||
VEST_TXN.CORRECTION, VEST_TXN.FEE, VEST_TXN.REALIZED_PNL, VEST_TXN.FUNDING,
|
||||
VEST_TXN.LIQUIDATION_DEFICIT_COVERAGE, VEST_TXN.CORPORATE_ACTION_CASH_ADJUSTMENT,
|
||||
]);
|
||||
const FUND_TXNS = new Set<number>([
|
||||
VEST_TXN.DEPOSIT, VEST_TXN.WITHDRAW, VEST_TXN.FUNDED_PROFIT_CREDIT, VEST_TXN.AFFILIATE_PAYOUT_CREDIT,
|
||||
VEST_TXN.COMPENSATION, VEST_TXN.BALANCE_MIGRATION,
|
||||
]);
|
||||
|
||||
export type VestTxnKind = 'trade' | 'fund' | 'margin';
|
||||
|
||||
export function txnKind(type: number): VestTxnKind {
|
||||
if (TRADING_TXNS.has(type)) return 'trade';
|
||||
if (FUND_TXNS.has(type)) return 'fund';
|
||||
return 'margin';
|
||||
}
|
||||
|
||||
export interface VestTxn {
|
||||
/** action_id, else type|time|amount — what a page overlap dedupes on. */
|
||||
key: string;
|
||||
type: number;
|
||||
kind: VestTxnKind;
|
||||
/** Signed: what it did to the account's balance. */
|
||||
amount: number;
|
||||
time: number;
|
||||
}
|
||||
|
||||
/** One entry of GET /v3/transactions; null when unreadable. A withdrawal is
|
||||
* always negative and a deposit positive, whatever sign the gateway used. */
|
||||
export function parseTxn(raw: unknown): VestTxn | null {
|
||||
const t = raw as Record<string, unknown>;
|
||||
const type = num(t?.transaction_type ?? t?.transactionType);
|
||||
const amount = num(t?.amount);
|
||||
const time = toMs(t?.created_at ?? t?.createdAt);
|
||||
if (type === undefined || amount === undefined || !Number.isFinite(time)) return null;
|
||||
const signed = type === VEST_TXN.WITHDRAW ? -Math.abs(amount) : type === VEST_TXN.DEPOSIT ? Math.abs(amount) : amount;
|
||||
const action = t.action_id ?? t.actionId;
|
||||
return {
|
||||
key: typeof action === 'string' || typeof action === 'number' ? `${action}|${type}` : `${type}|${time}|${amount}`,
|
||||
type,
|
||||
kind: txnKind(type),
|
||||
amount: signed,
|
||||
time,
|
||||
};
|
||||
}
|
||||
|
||||
/** Daily trading P&L and fund rows by Vest day, from the account's
|
||||
* transactions. */
|
||||
export function bookTxns(txns: VestTxn[]): { daily: Map<string, { pnl: number; fees: number }>; funds: Map<string, number> } {
|
||||
const daily = new Map<string, { pnl: number; fees: number }>();
|
||||
const funds = new Map<string, number>();
|
||||
for (const t of txns) {
|
||||
const day = vestDayOf(t.time);
|
||||
if (t.kind === 'fund') {
|
||||
funds.set(day, (funds.get(day) ?? 0) + t.amount);
|
||||
} else if (t.kind === 'trade') {
|
||||
const e = daily.get(day) ?? { pnl: 0, fees: 0 };
|
||||
e.pnl += t.amount;
|
||||
if (t.type === VEST_TXN.FEE) e.fees += Math.abs(t.amount);
|
||||
daily.set(day, e);
|
||||
}
|
||||
}
|
||||
return { daily, funds };
|
||||
}
|
||||
|
||||
// ── profit claims ───────────────────────────────────────────────────────────
|
||||
|
||||
/** A claim shows in Vest's transactions as a WITHDRAW of the same amount
|
||||
* within this long of it. */
|
||||
export const CLAIM_MATCH_MS = 30 * 60_000;
|
||||
/** A claim of unknown outcome (the request failed in transit) that no
|
||||
* WITHDRAW matches in a history read this long after it never happened. */
|
||||
export const UNKNOWN_CLAIM_EXPIRES_MS = 60 * 60_000;
|
||||
|
||||
/** A claim this client sent: the amount, when (at = once it was answered;
|
||||
* sentAt = before it went out, absent on claims saved before it was kept),
|
||||
* and the balance it left. unknown = the request failed in transit, so it
|
||||
* may or may not have gone in — it holds further claims, but nothing is
|
||||
* booked on it. */
|
||||
export interface OwnClaim { at: number; sentAt?: number; amount: number; base: number; unknown?: true }
|
||||
|
||||
/** A WITHDRAW transaction: when, and its (negative) amount. */
|
||||
export interface VestWithdrawal { at: number; amount: number }
|
||||
|
||||
/** The WITHDRAW that shows the claim — its amount, within CLAIM_MATCH_MS,
|
||||
* the nearest in time — or undefined while Vest's transactions don't. */
|
||||
export function claimWithdrawal(withdrawals: VestWithdrawal[], c: OwnClaim): VestWithdrawal | undefined {
|
||||
let best: VestWithdrawal | undefined;
|
||||
for (const w of withdrawals) {
|
||||
if (Math.abs(Math.abs(w.amount) - c.amount) >= 0.01 || Math.abs(w.at - c.at) >= CLAIM_MATCH_MS) continue;
|
||||
if (!best || Math.abs(w.at - c.at) < Math.abs(best.at - c.at)) best = w;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** The claims Vest's transactions, as read at `readAt`, don't show: those
|
||||
* still pending, and those that lapse — of unknown outcome and still not
|
||||
* shown an hour on, so never made. One Vest accepted never lapses. */
|
||||
export function settleClaims(claims: OwnClaim[], withdrawals: VestWithdrawal[], readAt: number): { pending: OwnClaim[]; lapsed: OwnClaim[] } {
|
||||
const pending: OwnClaim[] = [];
|
||||
const lapsed: OwnClaim[] = [];
|
||||
for (const c of claims) {
|
||||
if (claimWithdrawal(withdrawals, c)) continue;
|
||||
if (c.unknown && readAt - c.at > UNKNOWN_CLAIM_EXPIRES_MS) lapsed.push(c);
|
||||
else pending.push(c);
|
||||
}
|
||||
return { pending, lapsed };
|
||||
}
|
||||
|
||||
/**
|
||||
* The account's fund rows, one per Vest day: Vest's own (bookTxns' funds),
|
||||
* plus each claim this client made that the transactions don't show yet, on
|
||||
* the day it was booked. And the stored days to take back: a day a claim
|
||||
* was booked on that Vest's transactions leave empty, its WITHDRAW being
|
||||
* dated another day (the request spanned 20:00 ET, or the clocks differ) —
|
||||
* kept, the payout would count twice.
|
||||
*/
|
||||
export function claimFundRows(o: {
|
||||
funds: Map<string, number>; claims: OwnClaim[]; withdrawals: VestWithdrawal[]; stored: { date: string }[];
|
||||
}): { rows: { date: string; amount: number }[]; dropped: string[] } {
|
||||
const funds = new Map(o.funds);
|
||||
const made = o.claims.filter((c) => !c.unknown);
|
||||
for (const c of made) {
|
||||
if (claimWithdrawal(o.withdrawals, c)) continue;
|
||||
const day = vestDayOf(c.at);
|
||||
funds.set(day, (funds.get(day) ?? 0) - c.amount);
|
||||
}
|
||||
const rows = [...funds.entries()]
|
||||
.map(([date, amount]) => ({ date, amount: Math.round(amount * 100) / 100 }))
|
||||
.filter((f) => f.amount !== 0)
|
||||
.sort((x, y) => x.date.localeCompare(y.date));
|
||||
const claimDays = new Set(made.map((c) => vestDayOf(c.at)));
|
||||
const dropped = o.stored.map((f) => f.date).filter((d) => claimDays.has(d) && !rows.some((r) => r.date === d));
|
||||
return { rows, dropped };
|
||||
}
|
||||
|
||||
/**
|
||||
* What a history read makes of this client's claims (VestClient.bookHistory):
|
||||
* Vest's WITHDRAWs; the claims that lapse and those still outstanding
|
||||
* (settleClaims); the fund rows and the stored days to take back
|
||||
* (claimFundRows); and the balance the transactions add up to — `opening`
|
||||
* (the capital of an account Vest funded without a deposit row) included,
|
||||
* the claims they don't show yet taken out — that the books check holds the
|
||||
* account to. A lapsed claim is out of it: counted, one that never went in
|
||||
* would hold the account for good.
|
||||
*/
|
||||
export function readClaims(o: {
|
||||
txns: VestTxn[]; funds: Map<string, number>; claims: OwnClaim[]; stored: { date: string }[]; readAt: number; opening: number;
|
||||
}): {
|
||||
withdrawals: VestWithdrawal[]; lapsed: OwnClaim[]; outstanding: OwnClaim[];
|
||||
rows: { date: string; amount: number }[]; dropped: string[]; expected: number;
|
||||
} {
|
||||
const withdrawals = o.txns.filter((t) => t.type === VEST_TXN.WITHDRAW).map((t) => ({ at: t.time, amount: t.amount }));
|
||||
const { pending, lapsed } = settleClaims(o.claims, withdrawals, o.readAt);
|
||||
const { rows, dropped } = claimFundRows({ funds: o.funds, claims: o.claims.filter((c) => !lapsed.includes(c)), withdrawals, stored: o.stored });
|
||||
const unbooked = pending.reduce((s, c) => s + c.amount, 0);
|
||||
const expected = o.txns.reduce((s, t) => s + (t.kind === 'margin' ? 0 : t.amount), 0) + o.opening - unbooked;
|
||||
return { withdrawals, lapsed, outstanding: pending, rows, dropped, expected };
|
||||
}
|
||||
|
||||
/** Each payout taken from the account, by its Vest day, one entry per
|
||||
* payout: every WITHDRAW Vest's transactions show (claims made by hand
|
||||
* among them), and each claim of this client's they don't show yet, on the
|
||||
* day it was booked — a fund row can't say how many, it holds a whole Vest
|
||||
* day's. A day whose fund rows net negative with neither still counts once,
|
||||
* as it did before. Sorted. */
|
||||
export function payoutDaysOf(withdrawals: VestWithdrawal[], claims: OwnClaim[], fundRows: { date: string; amount: number }[]): string[] {
|
||||
const days = [
|
||||
...withdrawals.map((w) => vestDayOf(w.at)),
|
||||
...claims.filter((c) => !c.unknown && !claimWithdrawal(withdrawals, c)).map((c) => vestDayOf(c.at)),
|
||||
];
|
||||
for (const f of fundRows) if (f.amount < 0 && !days.includes(f.date)) days.push(f.date);
|
||||
return days.sort();
|
||||
}
|
||||
|
||||
/** Payouts since `since` (the last 20:00 ET): this client's claims — each at
|
||||
* its WITHDRAW's time once Vest shows it, Vest's day being the one it
|
||||
* counts on, and until then at when it was sent — or the WITHDRAWs Vest
|
||||
* shows, whichever counts more (a claim made by hand shows only there).
|
||||
* Sent, not answered: Vest dates it no earlier (clocks in sync), so a
|
||||
* claim answered just after 20:00 ET can't put the fresh day on a payout
|
||||
* stage before Vest says it's that day's; one Vest dates after the turn
|
||||
* reads as the day before until then, the cautious way round. */
|
||||
export function payoutsSince(withdrawals: VestWithdrawal[], claims: OwnClaim[], since: number): number {
|
||||
const mine = claims.filter((c) => !c.unknown && (claimWithdrawal(withdrawals, c)?.at ?? c.sentAt ?? c.at) >= since).length;
|
||||
const seen = withdrawals.filter((w) => w.at >= since).length;
|
||||
return Math.max(mine, seen);
|
||||
}
|
||||
|
||||
// ── market data ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Fewest hourly returns a volatility is measured from. */
|
||||
export const MIN_HOURLY_RETURNS = 24;
|
||||
|
||||
/**
|
||||
* σ of log returns per √hour from hourly klines ([openTime, o, h, l, close, …],
|
||||
* any order). Only returns between consecutive hours count: an index perp
|
||||
* closes for weekends and the daily maintenance hour, and a return across
|
||||
* such a gap is a gap, not an hour's move. null when too few are left.
|
||||
*/
|
||||
export function hourlyVolatility(klines: unknown[]): number | null {
|
||||
const rows = klines.flatMap((k) => {
|
||||
if (!Array.isArray(k)) return [];
|
||||
const t = num(k[0]);
|
||||
const close = num(k[4]);
|
||||
return t !== undefined && close !== undefined && close > 0 ? [{ t: toMs(t), close }] : [];
|
||||
}).sort((a, b) => a.t - b.t);
|
||||
const returns: number[] = [];
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
if (Math.abs(rows[i].t - rows[i - 1].t - 3_600_000) > 60_000) continue;
|
||||
returns.push(Math.log(rows[i].close / rows[i - 1].close));
|
||||
}
|
||||
if (returns.length < MIN_HOURLY_RETURNS) return null;
|
||||
const mean = returns.reduce((s, r) => s + r, 0) / returns.length;
|
||||
const variance = returns.reduce((s, r) => s + (r - mean) ** 2, 0) / (returns.length - 1);
|
||||
return Math.sqrt(variance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the market-hours periods (GET /v4/market-hours/{assetId}) have the
|
||||
* symbol open for trading at `now`, and for how long yet. A symbol with no
|
||||
* periods at all (crypto: always open) reads as open with no end. A closure
|
||||
* (weekly, the daily maintenance hour) counts as closed even where the
|
||||
* gateway still says OPEN: no leg should start into one.
|
||||
*/
|
||||
export function marketOpen(periods: unknown, now: number): { open: boolean; closesAt: number | null } {
|
||||
if (!Array.isArray(periods) || periods.length === 0) return { open: true, closesAt: null };
|
||||
const list = hoursPeriods(periods);
|
||||
const i = list.findIndex((p) => p.from <= now && now < p.to);
|
||||
if (i < 0 || !list[i].open) return { open: false, closesAt: null };
|
||||
// Consecutive open periods (overnight → pre-market → regular …) are one stretch.
|
||||
let end = list[i].to;
|
||||
for (let j = i + 1; j < list.length && list[j].open && list[j].from <= end + 1; j++) end = list[j].to;
|
||||
return { open: true, closesAt: end };
|
||||
}
|
||||
|
||||
/** The market-hours periods, read and in order; open = trading, with no
|
||||
* closure (see marketOpen). */
|
||||
function hoursPeriods(periods: unknown[]): { from: number; to: number; open: boolean }[] {
|
||||
return periods.flatMap((p) => {
|
||||
const r = p as {
|
||||
fromMs?: unknown; toMs?: unknown;
|
||||
state?: { tradingStatus?: unknown; closure?: unknown; price?: { allow?: unknown } };
|
||||
};
|
||||
const from = num(r?.fromMs);
|
||||
const to = num(r?.toMs);
|
||||
if (from === undefined || to === undefined) return [];
|
||||
const closure = r.state?.closure;
|
||||
const open = r.state?.tradingStatus === 'OPEN' && r.state?.price?.allow !== false
|
||||
&& (closure === undefined || closure === 'unspecified');
|
||||
return [{ from, to, open }];
|
||||
}).sort((a, b) => a.from - b.from);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the closure that starts at `closesAt` (marketOpen's) keeps the
|
||||
* market shut across one of Vest's 8 PM ET resets — a weekend, a holiday —
|
||||
* or for as far as the periods go. A position held into it can't be exited
|
||||
* or fitted to that reset until the market reopens, and meets the reopen's
|
||||
* gap with its stop; the daily maintenance hour reopens before the reset.
|
||||
* A close the periods don't show (they just end there) isn't one.
|
||||
*/
|
||||
export function longClosure(periods: unknown, closesAt: number): boolean {
|
||||
if (!Array.isArray(periods)) return false;
|
||||
const list = hoursPeriods(periods);
|
||||
if (!list.some((p) => p.to > closesAt)) return false;
|
||||
const reopensAt = list.find((p) => p.open && p.from >= closesAt)?.from;
|
||||
return reopensAt === undefined || nextVestResetMs(closesAt) < reopensAt;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Vest sessions relayed by the extension.
|
||||
*
|
||||
* Vest logs a person in through Google/Privy in the browser, and refreshes
|
||||
* the session with a device key the browser will not export, so this
|
||||
* machine cannot log in by itself. Instead the AutoFirmer extension watches
|
||||
* the signed-in Vest tab: whenever the Vest app renews its own ~15-minute
|
||||
* access token, the extension posts it here (POST /api/vest/session). The
|
||||
* Vest client (lib/vest-class.ts) trades with the latest one for its wallet
|
||||
* and mints per-account tokens from it.
|
||||
*
|
||||
* Kept in memory only — never in the DB, the logs, a report or a backup. A
|
||||
* restart forgets them; the Vest tab's next renewal (or the extension's
|
||||
* next tick) relays a fresh one within minutes.
|
||||
*/
|
||||
|
||||
export interface VestSession {
|
||||
accessToken: string;
|
||||
/** Epoch ms the access token stops working. */
|
||||
expiresAt: number;
|
||||
/** The wallet the Vest login belongs to, lower-case. */
|
||||
primaryAddr: string;
|
||||
userId: string;
|
||||
receivedAt: number;
|
||||
}
|
||||
|
||||
/** What the dashboard and the extension may see of a session: never the token. */
|
||||
export interface VestSessionInfo {
|
||||
primaryAddr: string;
|
||||
userId: string;
|
||||
expiresAt: number;
|
||||
receivedAt: number;
|
||||
}
|
||||
|
||||
const g = globalThis as typeof globalThis & {
|
||||
__vestSessions?: Map<string, VestSession>;
|
||||
__vestPrevious?: Map<string, VestSession>;
|
||||
__vestRefused?: Map<string, number>;
|
||||
};
|
||||
|
||||
function store(): Map<string, VestSession> {
|
||||
return (g.__vestSessions ??= new Map());
|
||||
}
|
||||
|
||||
/** The session each wallet's current one replaced — what a refused token
|
||||
* falls back to. */
|
||||
function previous(): Map<string, VestSession> {
|
||||
return (g.__vestPrevious ??= new Map());
|
||||
}
|
||||
|
||||
/** Tokens the client found to be one account's, not the login's (by their
|
||||
* tail — enough to tell them apart, never the whole token), with when. */
|
||||
function refused(): Map<string, number> {
|
||||
return (g.__vestRefused ??= new Map());
|
||||
}
|
||||
|
||||
const REFUSED_KEEP = 50;
|
||||
|
||||
/** Shortest life a relayed token must have left to be taken. */
|
||||
const MIN_REMAINING_MS = 30_000;
|
||||
/** Longest a relayed token may claim to live — a Vest access token is
|
||||
* ~15 minutes; anything far beyond that is not one. */
|
||||
const MAX_REMAINING_MS = 6 * 60 * 60_000;
|
||||
|
||||
/** Take a relayed session. Returns why it was refused, or null. A token for
|
||||
* a wallet only ever replaces one that expires sooner. */
|
||||
export function putVestSession(input: { accessToken: unknown; expiresAt: unknown; primaryAddr: unknown; userId: unknown }, now = Date.now()): string | null {
|
||||
const token = typeof input.accessToken === 'string' ? input.accessToken.trim() : '';
|
||||
const expiresAt = Number(input.expiresAt);
|
||||
const addr = typeof input.primaryAddr === 'string' ? input.primaryAddr.trim().toLowerCase() : '';
|
||||
if (!/^[\w-]+\.[\w-]+\.[\w-]+$/.test(token) || token.length > 8192) return 'accessToken is not a JWT';
|
||||
if (!/^0x[0-9a-f]{40}$/.test(addr)) return 'primaryAddr is not a wallet address';
|
||||
if (!Number.isFinite(expiresAt) || expiresAt - now < MIN_REMAINING_MS) return 'token already expired';
|
||||
if (expiresAt - now > MAX_REMAINING_MS) return 'expiresAt is implausibly far away';
|
||||
if (refused().has(token.slice(-32))) return "that is one Vest account's token, not the login's — reload the Vest tab, or update the extension";
|
||||
const prev = store().get(addr);
|
||||
if (prev && prev.expiresAt > expiresAt && prev.accessToken !== token) return null; // keep the fresher one
|
||||
if (prev && prev.accessToken !== token) previous().set(addr, prev);
|
||||
store().set(addr, {
|
||||
accessToken: token,
|
||||
expiresAt,
|
||||
primaryAddr: addr,
|
||||
userId: typeof input.userId === 'string' ? input.userId : '',
|
||||
receivedAt: now,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The live session for a wallet, or null when none has been relayed or it
|
||||
* is about to expire. */
|
||||
export function vestSessionFor(primaryAddr: string, now = Date.now(), minRemainingMs = MIN_REMAINING_MS): VestSession | null {
|
||||
const s = store().get(primaryAddr.trim().toLowerCase());
|
||||
return s && s.expiresAt - now >= minRemainingMs ? s : null;
|
||||
}
|
||||
|
||||
/** The client found the wallet's token to be one account's own (see
|
||||
* accountScopedToken): drop it, and take it no more if it's relayed again —
|
||||
* the login's own token, when the Vest app next renews it, takes its place. */
|
||||
export function refuseVestSession(primaryAddr: string, token: string, now = Date.now()): void {
|
||||
const key = primaryAddr.trim().toLowerCase();
|
||||
refused().set(token.slice(-32), now);
|
||||
while (refused().size > REFUSED_KEEP) refused().delete(refused().keys().next().value as string);
|
||||
if (store().get(key)?.accessToken !== token) return;
|
||||
store().delete(key);
|
||||
// Back to the token it replaced, if that one is still good.
|
||||
const before = previous().get(key);
|
||||
previous().delete(key);
|
||||
if (before && !refused().has(before.accessToken.slice(-32)) && before.expiresAt - now >= MIN_REMAINING_MS) store().set(key, before);
|
||||
}
|
||||
|
||||
/** Drop a wallet's session — the gateway refused it. */
|
||||
export function dropVestSession(primaryAddr: string, token: string): void {
|
||||
const key = primaryAddr.trim().toLowerCase();
|
||||
if (store().get(key)?.accessToken === token) store().delete(key);
|
||||
}
|
||||
|
||||
/** Every relayed session, newest first, without tokens. */
|
||||
export function listVestSessions(): VestSessionInfo[] {
|
||||
return [...store().values()]
|
||||
.sort((a, b) => b.receivedAt - a.receivedAt)
|
||||
.map(({ primaryAddr, userId, expiresAt, receivedAt }) => ({ primaryAddr, userId, expiresAt, receivedAt }));
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Binds the Vest login the extension relays to this machine's Vest firm, so
|
||||
* nobody has to copy a wallet address into the dashboard.
|
||||
*
|
||||
* A Vest firm's only credential here is the wallet of the login whose session
|
||||
* it trades (lib/vest-session.ts). One person per machine, and Vest allows one
|
||||
* profile per person, so the Vest tab signed in on this machine is the one to
|
||||
* trade. It is bound automatically when:
|
||||
* - the firm has no credential yet. A saved one is never replaced: someone
|
||||
* chose it, and a different login in the tab is a mistake to flag (the
|
||||
* client says so), not one to follow;
|
||||
* - exactly one wallet has a live relayed session. Two logins in two tabs
|
||||
* can't be told apart, so the dashboard's one-click form decides.
|
||||
*/
|
||||
|
||||
import { getCachedCatalog } from './firm-catalog';
|
||||
import { getCredentials, setCredentials } from './db';
|
||||
import { listVestSessions } from './vest-session';
|
||||
import { reconcileClients } from './clients';
|
||||
import { scheduleBackupPush } from './backup-sync';
|
||||
|
||||
/** Bind the relayed wallet to every Vest firm here that has no credential.
|
||||
* Returns the names of the firms it bound. */
|
||||
export function autoBindVestWallet(now = Date.now()): string[] {
|
||||
const live = [...new Set(listVestSessions().filter((s) => s.expiresAt > now).map((s) => s.primaryAddr))];
|
||||
if (live.length !== 1) return [];
|
||||
const wallet = live[0];
|
||||
const bound: string[] = [];
|
||||
for (const firm of getCachedCatalog()) {
|
||||
if (firm.platform !== 'vest') continue;
|
||||
const creds = getCredentials(firm.id);
|
||||
if (creds && (creds.apiKey || creds.username || creds.password)) continue;
|
||||
setCredentials(firm.id, firm.name, '', '', wallet);
|
||||
bound.push(firm.name);
|
||||
console.log(`[vest] ${firm.name}: bound to wallet ${wallet}, the Vest login signed in on this machine`);
|
||||
}
|
||||
if (bound.length > 0) {
|
||||
reconcileClients();
|
||||
scheduleBackupPush();
|
||||
}
|
||||
return bound;
|
||||
}
|
||||
+2
-1
@@ -6,7 +6,8 @@
|
||||
"dev": "next dev --webpack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs scripts/check-instruments.mjs scripts/check-account-status.mjs scripts/check-kept-rules.mjs scripts/check-request-user-agent.mjs scripts/check-vest.mjs scripts/check-vest-client.mjs scripts/check-trades.mjs scripts/check-catalog.mjs scripts/check-extension-vest.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@microsoft/signalr": "^10.0.11",
|
||||
|
||||
@@ -68,7 +68,8 @@ command -v pm2 >/dev/null 2>&1 || die "PM2 not installed - run install-autostart
|
||||
# ── registry: create with the legacy entry, refuse duplicates, append ───────
|
||||
node - "$REGISTRY" "$PM2_NAME" "$PORT" "$DB_REL" <<'NODE'
|
||||
const { existsSync, readFileSync, writeFileSync } = require('node:fs');
|
||||
const [, file, name, port, db] = process.argv;
|
||||
// `node -` puts '-' in argv[1], so the arguments start at argv[2].
|
||||
const [file, name, port, db] = process.argv.slice(2);
|
||||
const entries = existsSync(file)
|
||||
? JSON.parse(readFileSync(file, 'utf8'))
|
||||
: [{ name: 'autofirmer', port: 3000, db: 'autotrader.sqlite' }];
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
// Checks for the account status the reporter sends the master's ledger
|
||||
// (lib/platform-client.ts accountStatus and the helpers around it).
|
||||
//
|
||||
// npm run check
|
||||
//
|
||||
// .mjs on purpose: Next's tsconfig includes every .ts file, and a .ts test
|
||||
// importing another .ts by extension would trip the build's type check. Node
|
||||
// strips the types from lib/platform-client.ts at import time (its only
|
||||
// imports are `import type`).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
accountStatus, accountStatusFields, brokerFacts, emptyFirmStats, firmStatusFields, firstFundDate, keptRuleIdsField, legacyDead,
|
||||
REPORT_STATUS_VERSION, SYNC_STALE_MS,
|
||||
} from '../lib/platform-client.ts';
|
||||
|
||||
const FLOORS = { negative: -2000, zero: 0, positive: 48_000 };
|
||||
const OFFSETS = { above: 1500, at: 0, below: -1500 };
|
||||
|
||||
/** Every case of {cash present/missing, floor <0/0/>0, cash above/at/below the
|
||||
* floor, active, history ready}. */
|
||||
function* matrix() {
|
||||
for (const [floorName, floor] of Object.entries(FLOORS)) {
|
||||
for (const [offsetName, offset] of Object.entries(OFFSETS)) {
|
||||
for (const missing of [false, true]) {
|
||||
for (const active of [true, false]) {
|
||||
for (const historyReady of [true, false]) {
|
||||
const cash = missing ? undefined : floor + offset;
|
||||
yield { label: `${floorName} floor, cash ${missing ? 'missing' : offsetName}, active ${active}, history ${historyReady}`, cash, floor, active, historyReady };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('legacyDead is the test reporter.ts:114 used before', () => {
|
||||
for (const c of matrix()) {
|
||||
const cashOrZero = c.cash ?? 0;
|
||||
// Copied from reporter.ts at 8747dd6: autoLiqThreshold !== 0 && cash.amount <= autoLiqThreshold
|
||||
assert.equal(legacyDead(cashOrZero, c.floor), c.floor !== 0 && cashOrZero <= c.floor, c.label);
|
||||
}
|
||||
});
|
||||
|
||||
test('dead is exactly the legacy test whenever there is a balance', () => {
|
||||
let cases = 0;
|
||||
for (const c of matrix()) {
|
||||
if (c.cash === undefined) continue;
|
||||
cases++;
|
||||
const { status } = accountStatus(c);
|
||||
assert.equal(status === 'dead', legacyDead(c.cash, c.floor), c.label);
|
||||
}
|
||||
assert.equal(cases, 36);
|
||||
// A balance of exactly 0 is a balance, not a missing one.
|
||||
assert.deepEqual(accountStatus({ cash: 0, floor: 48_000, active: true, historyReady: true }), { status: 'dead' });
|
||||
assert.deepEqual(accountStatus({ cash: 0, floor: 0, active: true, historyReady: true }), { status: 'live' });
|
||||
});
|
||||
|
||||
test('a missing balance is unknown, never dead and never 0', () => {
|
||||
for (const c of matrix()) {
|
||||
if (c.cash !== undefined) continue;
|
||||
assert.deepEqual(accountStatus(c), { status: 'unknown', reason: 'cash_missing' }, c.label);
|
||||
}
|
||||
assert.deepEqual(accountStatus({ cash: null, floor: 48_000, active: true, historyReady: true }), { status: 'unknown', reason: 'cash_missing' });
|
||||
});
|
||||
|
||||
test('the legacy invariant: the old dead count = dead + cash-missing with a positive floor', () => {
|
||||
for (const c of matrix()) {
|
||||
const { status, reason } = accountStatus(c);
|
||||
const oldDead = legacyDead(c.cash ?? 0, c.floor);
|
||||
const newDead = status === 'dead' || (status === 'unknown' && reason === 'cash_missing' && c.floor > 0);
|
||||
assert.equal(newDead, oldDead, c.label);
|
||||
}
|
||||
});
|
||||
|
||||
test('priority: dead, then history pending, then locked, then live', () => {
|
||||
const base = { cash: 50_000, floor: 48_000, active: true, historyReady: true };
|
||||
assert.deepEqual(accountStatus(base), { status: 'live' });
|
||||
assert.deepEqual(accountStatus({ ...base, active: false }), { status: 'locked', reason: 'inactive' });
|
||||
assert.deepEqual(accountStatus({ ...base, active: false, inactiveReason: 'liquidate_only' }), { status: 'locked', reason: 'liquidate_only' });
|
||||
assert.deepEqual(accountStatus({ ...base, active: false, historyReady: false }), { status: 'history_pending' });
|
||||
assert.deepEqual(accountStatus({ ...base, cash: 47_000, active: false, historyReady: false }), { status: 'dead' });
|
||||
assert.deepEqual(accountStatus({ ...base, cash: 47_000, deadReason: 'terminal:TERMINATED' }), { status: 'dead', reason: 'terminal:TERMINATED' });
|
||||
// A floor of 0 is no floor: never dead, whatever the balance.
|
||||
assert.equal(accountStatus({ ...base, cash: -10, floor: 0 }).status, 'live');
|
||||
});
|
||||
|
||||
test('broker facts per platform', () => {
|
||||
assert.deepEqual(brokerFacts('tradovate', { active: true, closed: false, archived: false, restricted: false }), { inactiveReason: 'liquidate_only' });
|
||||
assert.deepEqual(brokerFacts('tradovate', { active: false, closed: true, archived: true, restricted: false }),
|
||||
{ inactiveReason: 'liquidate_only', brokerStatus: 'closed,archived' });
|
||||
assert.deepEqual(brokerFacts('topstepx', { active: true }), { inactiveReason: 'cannot_trade' });
|
||||
assert.deepEqual(brokerFacts('topstepx', { active: false }), { inactiveReason: 'cannot_trade', brokerStatus: 'canTrade:false' });
|
||||
const registered = Date.parse('2026-10-03T03:00:00Z');
|
||||
assert.deepEqual(brokerFacts('dxtrade', { active: true }, { status: 'FULL_TRADING', registrationTime: registered }),
|
||||
{ inactiveReason: 'status:FULL_TRADING', deadReason: 'terminal:FULL_TRADING', registeredAt: registered });
|
||||
assert.deepEqual(brokerFacts('dxtrade', { active: false }, { status: 'CLOSE_ONLY', registrationTime: NaN }),
|
||||
{ inactiveReason: 'close_only', deadReason: 'terminal:CLOSE_ONLY', brokerStatus: 'CLOSE_ONLY' });
|
||||
assert.deepEqual(brokerFacts('dxtrade', { active: false }, undefined), { inactiveReason: 'inactive' });
|
||||
});
|
||||
|
||||
test('a DXtrade account the firm terminated reads dead with the terminal status', () => {
|
||||
// dxtrade-class.ts sets the floor to the balance for DEAD_STATUSES.
|
||||
const facts = brokerFacts('dxtrade', { active: false }, { status: 'TERMINATED', registrationTime: NaN });
|
||||
const fields = accountStatusFields({ cash: 94_210.5, floor: 94_210.5, active: false, historyReady: true, accountTypeId: null, fundTransactions: [], ...facts });
|
||||
assert.equal(fields.status, 'dead');
|
||||
assert.equal(fields.reason, 'terminal:TERMINATED');
|
||||
assert.equal(fields.brokerStatus, 'TERMINATED');
|
||||
// A CLOSE_ONLY account is inactive (floor 0 there), not dead.
|
||||
const closeOnly = accountStatusFields({ cash: 94_210.5, floor: 0, active: false, historyReady: true, accountTypeId: 't', fundTransactions: [],
|
||||
...brokerFacts('dxtrade', { active: false }, { status: 'CLOSE_ONLY', registrationTime: NaN }) });
|
||||
assert.deepEqual([closeOnly.status, closeOnly.reason], ['locked', 'close_only']);
|
||||
});
|
||||
|
||||
test('per-account fields: defaults and nulls are left out', () => {
|
||||
const fields = accountStatusFields({ cash: undefined, floor: 0, active: true, historyReady: false, accountTypeId: null, fundTransactions: undefined, inactiveReason: 'liquidate_only' });
|
||||
assert.deepEqual(fields, { status: 'unknown', reason: 'cash_missing' });
|
||||
|
||||
const live = accountStatusFields({
|
||||
cash: 50_123.456, floor: 47_623.451, active: true, historyReady: true, accountTypeId: 'cc9d7145-f85c-4b61-9e90-b7091b6f7dcc',
|
||||
fundTransactions: [{ date: '2026-09-30', amount: -1000 }, { date: '2026-09-25', amount: 50_000 }, { date: '2026-10-02', amount: 25 }],
|
||||
inactiveReason: 'liquidate_only',
|
||||
});
|
||||
assert.deepEqual(live, {
|
||||
status: 'live', balance: 50_123.46, floor: 47_623.45, accountTypeId: 'cc9d7145-f85c-4b61-9e90-b7091b6f7dcc',
|
||||
firstFundDate: '2026-09-25', positiveFunds: 2,
|
||||
});
|
||||
|
||||
// Fund history loaded with no positive rows: a count of 0, no date.
|
||||
const none = accountStatusFields({ cash: 10, floor: 0, active: true, historyReady: true, accountTypeId: 't', fundTransactions: [] });
|
||||
assert.deepEqual([none.positiveFunds, 'firstFundDate' in none], [0, false]);
|
||||
});
|
||||
|
||||
test('DXtrade: the registration date (Chicago) stands in for the first fund date', () => {
|
||||
const registeredAt = Date.parse('2026-10-03T03:00:00Z'); // Oct 2, 22:00 CDT
|
||||
const input = { cash: 100_000, floor: 0, active: true, historyReady: true, accountTypeId: 't', registeredAt };
|
||||
assert.equal(accountStatusFields({ ...input, fundTransactions: [] }).firstFundDate, '2026-10-02');
|
||||
// A real fund row wins; and nothing until the fund history has loaded.
|
||||
assert.equal(accountStatusFields({ ...input, fundTransactions: [{ date: '2026-10-05', amount: 100_000 }] }).firstFundDate, '2026-10-05');
|
||||
assert.equal('firstFundDate' in accountStatusFields({ ...input, fundTransactions: undefined }), false);
|
||||
// A time no Date can hold is no date — and must not throw.
|
||||
for (const bad of [1.7e18, -1e16, NaN]) {
|
||||
assert.equal('registeredAt' in brokerFacts('dxtrade', { active: true }, { status: 'FULL_TRADING', registrationTime: bad }), false);
|
||||
assert.equal('firstFundDate' in accountStatusFields({ ...input, registeredAt: bad, fundTransactions: [] }), false);
|
||||
}
|
||||
assert.deepEqual(firmStatusFields('f', true, 1e17), { statusVersion: 1, firmId: 'f' });
|
||||
});
|
||||
|
||||
test('firstFundDate: the helper the report and the rules-date evidence share', () => {
|
||||
const registeredAt = Date.parse('2026-10-03T03:00:00Z'); // Oct 2, 22:00 CDT
|
||||
assert.equal(firstFundDate(undefined, registeredAt), undefined);
|
||||
assert.equal(firstFundDate([], registeredAt), '2026-10-02');
|
||||
assert.equal(firstFundDate([], undefined), undefined);
|
||||
assert.equal(firstFundDate([{ date: '2026-10-05T00:00:00', amount: 100_000 }, { date: '2026-10-04', amount: -50 }], registeredAt), '2026-10-05');
|
||||
assert.equal(firstFundDate([{ date: '2026-10-09', amount: 25 }, { date: '2026-10-05', amount: 100_000 }], undefined), '2026-10-05');
|
||||
// Always what accountStatusFields reports.
|
||||
for (const funds of [undefined, [], [{ date: '2026-09-25', amount: 50_000 }], [{ date: '2026-09-30', amount: -1 }]]) {
|
||||
for (const reg of [undefined, registeredAt, NaN]) {
|
||||
const fields = accountStatusFields({ cash: 1, floor: 0, active: true, historyReady: true, accountTypeId: 't', fundTransactions: funds, registeredAt: reg });
|
||||
assert.equal(fields.firstFundDate, firstFundDate(funds, reg));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('firm-level fields: statusVersion and firmId always, syncedAt once synced', () => {
|
||||
const at = Date.parse('2026-10-06T14:00:00Z');
|
||||
assert.deepEqual(firmStatusFields('f-1', true, at), { statusVersion: 1, firmId: 'f-1', syncedAt: '2026-10-06T14:00:00.000Z' });
|
||||
assert.deepEqual(firmStatusFields('f-1', true, 0), { statusVersion: 1, firmId: 'f-1' });
|
||||
assert.deepEqual(firmStatusFields('f-1', false, at), { statusVersion: 1, firmId: 'f-1' });
|
||||
assert.equal(REPORT_STATUS_VERSION, 1);
|
||||
assert.equal(SYNC_STALE_MS, 5 * 60 * 1000);
|
||||
});
|
||||
|
||||
test('keptRuleIds: present (even empty) when the catalog vouches for them, absent otherwise', () => {
|
||||
assert.deepEqual(keptRuleIdsField(null), {});
|
||||
assert.deepEqual(keptRuleIdsField([]), { keptRuleIds: [] });
|
||||
assert.deepEqual(keptRuleIdsField(['k1', 'k2']), { keptRuleIds: ['k1', 'k2'] });
|
||||
});
|
||||
|
||||
test('the no-accounts branch keeps every existing key and value', () => {
|
||||
const firm = { id: '71016d02-752f-4078-9e24-e74c9d75d1c8', name: 'Tradeify' };
|
||||
// What reporter.ts sent before (its :46-56 branch), for each client state.
|
||||
const legacy = (client) => ({
|
||||
firm: firm.name, totalAccounts: 0, accountsTraded: 0, inTrade: 0,
|
||||
targetHit: 0, partial: 0, loss: 0, personalDll: 0, completed: 0,
|
||||
...(client?.syncComplete ? { accounts: [] } : {}),
|
||||
accountTypes: [],
|
||||
});
|
||||
const at = Date.parse('2026-10-06T14:00:00Z');
|
||||
const states = [undefined, { syncComplete: false, lastSyncAt: 0 }, { syncComplete: false, lastSyncAt: at }, { syncComplete: true, lastSyncAt: 0 }, { syncComplete: true, lastSyncAt: at }];
|
||||
// No kept-rule ids (an older cache: null, or not passed), none, some.
|
||||
const keptCases = [undefined, null, [], ['3f0c2b7e-0000-4000-8000-000000000001']];
|
||||
for (const client of states) {
|
||||
for (const kept of keptCases) {
|
||||
const got = kept === undefined ? emptyFirmStats(firm, client) : emptyFirmStats(firm, client, kept);
|
||||
const old = legacy(client);
|
||||
const label = `${JSON.stringify(client)} kept ${JSON.stringify(kept)}`;
|
||||
for (const [key, value] of Object.entries(old)) assert.deepEqual(got[key], value, `${label}: ${key}`);
|
||||
const added = Object.keys(got).filter((k) => !(k in old)).sort();
|
||||
const synced = client?.syncComplete === true;
|
||||
assert.equal(got.statusVersion, 1);
|
||||
assert.equal(got.firmId, firm.id);
|
||||
assert.equal('accounts' in got, synced);
|
||||
assert.equal('syncedAt' in got, synced && client.lastSyncAt > 0);
|
||||
assert.deepEqual(got.keptRuleIds, Array.isArray(kept) ? kept : undefined, label);
|
||||
assert.deepEqual(added, ['firmId', 'statusVersion', ...(got.syncedAt ? ['syncedAt'] : []), ...(Array.isArray(kept) ? ['keptRuleIds'] : [])].sort(), label);
|
||||
// The old keys stay first and in their order: only new keys follow.
|
||||
assert.deepEqual(Object.keys(got).slice(0, Object.keys(old).length), Object.keys(old));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Size: 4 firms × 500 accounts against the master socket's 1 MB frame ──────
|
||||
|
||||
const TYPE_ID = 'f5ad1527-8bd7-4ca0-9e35-bcadf78e52e9';
|
||||
const pad = (n, w) => String(n).padStart(w, '0');
|
||||
|
||||
/** The entry reporter.ts sent before, with a realistic 14-character name. */
|
||||
function legacyEntry(i) {
|
||||
return { name: `TDFY${pad(i, 10)}`, size: 150_000, payoutable: false, withdrawals: 2 };
|
||||
}
|
||||
|
||||
/** Every new field populated, worst realistic case per platform. */
|
||||
function fullFields(platform, i) {
|
||||
const balance = 149_823.45 - (i % 97);
|
||||
if (platform === 'tradovate') {
|
||||
return accountStatusFields({ cash: balance, floor: 147_500.12, active: false, historyReady: true, accountTypeId: TYPE_ID,
|
||||
fundTransactions: [{ date: '2026-09-25', amount: 150_000 }, { date: '2026-10-01', amount: 25 }],
|
||||
...brokerFacts('tradovate', { active: false, closed: true, archived: true, restricted: true }) });
|
||||
}
|
||||
if (platform === 'dxtrade') {
|
||||
return accountStatusFields({ cash: balance, floor: balance, active: false, historyReady: true, accountTypeId: TYPE_ID,
|
||||
fundTransactions: [{ date: '2026-09-25', amount: 150_000 }],
|
||||
...brokerFacts('dxtrade', { active: false }, { status: 'TERMINATED', registrationTime: Date.parse('2026-09-25T15:00:00Z') }) });
|
||||
}
|
||||
return accountStatusFields({ cash: balance, floor: 147_500.12, active: false, historyReady: true, accountTypeId: TYPE_ID,
|
||||
fundTransactions: [{ date: '2026-09-25', amount: 150_000 }],
|
||||
...brokerFacts('topstepx', { active: false }) });
|
||||
}
|
||||
|
||||
/** A typical live account: no reason, no broker status. */
|
||||
function typicalFields(i) {
|
||||
return accountStatusFields({ cash: 50_123.45 + (i % 89), floor: 47_623.45, active: true, historyReady: true, accountTypeId: TYPE_ID,
|
||||
fundTransactions: [{ date: '2026-09-25', amount: 50_000 }], ...brokerFacts('tradovate', { active: true, closed: false, archived: false, restricted: false }) });
|
||||
}
|
||||
|
||||
const added = (fields, i) => JSON.stringify({ ...legacyEntry(i), ...fields }).length - JSON.stringify(legacyEntry(i)).length;
|
||||
const PLATFORMS = ['tradovate', 'tradovate', 'topstepx', 'dxtrade'];
|
||||
|
||||
const ALL_FIELDS = ['status', 'reason', 'balance', 'floor', 'accountTypeId', 'firstFundDate', 'positiveFunds', 'brokerStatus'];
|
||||
|
||||
test('size: the new fields add < 230 bytes per fully populated account, < 180 per typical live one', () => {
|
||||
let worst = 0;
|
||||
for (const platform of new Set(PLATFORMS)) {
|
||||
assert.deepEqual(Object.keys(fullFields(platform, 0)).sort(), [...ALL_FIELDS].sort(), `${platform} entry is fully populated`);
|
||||
assert.equal(fullFields(platform, 0).accountTypeId.length, 36);
|
||||
for (let i = 0; i < 500; i++) worst = Math.max(worst, added(fullFields(platform, i), i));
|
||||
}
|
||||
assert.deepEqual(Object.keys(typicalFields(0)).sort(), ['accountTypeId', 'balance', 'firstFundDate', 'floor', 'positiveFunds', 'status']);
|
||||
let typical = 0;
|
||||
for (let i = 0; i < 500; i++) typical = Math.max(typical, added(typicalFields(i), i));
|
||||
console.log(` # new fields per account: ${worst} B fully populated (worst platform), ${typical} B typical live`);
|
||||
assert.ok(worst < 230, `fully populated adds ${worst} B`);
|
||||
assert.ok(typical < 180, `typical live adds ${typical} B`);
|
||||
});
|
||||
|
||||
test('size: 2000 fully populated accounts < 600 KB, the whole report body < 900 KB', () => {
|
||||
const firms = PLATFORMS.map((platform, f) => ({
|
||||
firm: `Firm ${f}`, totalAccounts: 500, accountsTraded: 500, inTrade: 0,
|
||||
targetHit: 0, partial: 0, loss: 500, personalDll: 0, completed: 0,
|
||||
accounts: Array.from({ length: 500 }, (_, i) => ({ ...legacyEntry(f * 500 + i), ...fullFields(platform, i) })),
|
||||
accountTypes: [{ accountTypeId: TYPE_ID, label: '150K Eval', accountSize: 150_000, totalAccounts: 500, accountsTraded: 500, inTrade: 0, targetHit: 0, partial: 0, loss: 500 }],
|
||||
...firmStatusFields(`00000000-0000-4000-8000-00000000000${f}`, true, Date.parse('2026-10-06T14:00:00Z')),
|
||||
}));
|
||||
const accountsBytes = JSON.stringify(firms.flatMap((f) => f.accounts)).length;
|
||||
const body = JSON.stringify({
|
||||
instance: 'Someone', id: '00000000-0000-4000-8000-000000000000', firms,
|
||||
scheduler: { running: true, lastTicks: [] }, version: '8747dd6', updater: null,
|
||||
}).length;
|
||||
console.log(` # 2000 accounts: ${accountsBytes} B; report body: ${body} B`);
|
||||
assert.ok(accountsBytes < 600_000, `accounts serialize to ${accountsBytes} B`);
|
||||
assert.ok(body < 900_000, `report body is ${body} B`);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
// Checks for the firm catalog sync in lib/firm-catalog.ts, against a stand-in
|
||||
// master on a local port and a throwaway database — never the live DB or the
|
||||
// real master.
|
||||
//
|
||||
// npm run check
|
||||
import { after, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { createServer } from 'node:http';
|
||||
import { once } from 'node:events';
|
||||
|
||||
// Resolve the extensionless local imports used by Next, without a build.
|
||||
function resolveTypes(specifier, context, nextResolve) {
|
||||
try {
|
||||
return nextResolve(specifier, context);
|
||||
} catch (err) {
|
||||
if (err.code !== 'ERR_MODULE_NOT_FOUND' || !specifier.startsWith('.')) throw err;
|
||||
return nextResolve(`${specifier}.ts`, context);
|
||||
}
|
||||
}
|
||||
registerHooks({ resolve: resolveTypes });
|
||||
|
||||
const directory = mkdtempSync(join(tmpdir(), 'autofirmer-catalog-'));
|
||||
process.env.AUTOFIRMER_DB = join(directory, 'test.sqlite');
|
||||
after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
|
||||
const { getSetting, setSetting } = await import('../lib/db.ts');
|
||||
const { syncCatalog, getCachedCatalog, getKeptRuleIdsForFirm, CATALOG_PARSER_VERSION } = await import('../lib/firm-catalog.ts');
|
||||
|
||||
const firm = (id, name, platform) => ({
|
||||
id, name, platform, keptAccountRules: [], updatedAt: '2026-10-26T19:00:00.000Z',
|
||||
accountTypes: [{
|
||||
id: `${id}-50k`, firmId: id, label: '50k', prefix: `${id.toUpperCase()}-`, updatedAt: '2026-10-26T19:00:00.000Z',
|
||||
accountSize: 50000, profitTarget: 3000, consistency: 0, minDayPnL: 0, minTradingDays: 0, maxLoss: 3000,
|
||||
maxPositionSize: 0, withdrawalStages: [],
|
||||
}],
|
||||
});
|
||||
const apex = firm('apex', 'Apex', 'tradovate');
|
||||
const vest = firm('vest', 'VestMarkets', 'vest');
|
||||
|
||||
// The master's catalog, answering ?since= as app/api/firms/catalog does, and
|
||||
// the `since` each sync sent (null = a full fetch).
|
||||
let served = { version: 41, updatedAt: '2026-10-26T19:20:00.000Z', firms: [apex, vest] };
|
||||
const sinceSent = [];
|
||||
const server = createServer((req, res) => {
|
||||
const since = new URL(req.url, 'http://master').searchParams.get('since');
|
||||
sinceSent.push(since);
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(since !== null && Number(since) === served.version
|
||||
? { version: served.version, updatedAt: served.updatedAt, unchanged: true }
|
||||
: served));
|
||||
});
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
after(() => server.close());
|
||||
const master = `http://127.0.0.1:${server.address().port}`;
|
||||
|
||||
/** This machine's cache as some build stored it, read afresh as after a restart. */
|
||||
function storedCache(payload) {
|
||||
setSetting('master_dashboard_url', master);
|
||||
setSetting('catalog_source_url', master);
|
||||
setSetting('catalog_version', String(payload.version));
|
||||
setSetting('catalog_payload', JSON.stringify(payload));
|
||||
globalThis.__firmCatalog = undefined;
|
||||
sinceSent.length = 0;
|
||||
}
|
||||
|
||||
const names = () => getCachedCatalog().map((f) => f.name);
|
||||
|
||||
test('a cache a pre-Vest build stored is fetched in full by a build that knows Vest', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
// Parser 2 skipped the Vest firm and said nothing of it; the master's
|
||||
// catalog hasn't changed since, so ?since= would answer "unchanged".
|
||||
storedCache({ version: 41, updatedAt: served.updatedAt, firms: [apex], parser: 2 });
|
||||
assert.equal((await syncCatalog()).ok, true);
|
||||
assert.deepEqual(sinceSent, [null]);
|
||||
assert.deepEqual(names(), ['Apex', 'VestMarkets']);
|
||||
// Current from then on: the next sync asks ?since= again.
|
||||
await syncCatalog();
|
||||
assert.deepEqual(sinceSent, [null, '41']);
|
||||
});
|
||||
|
||||
test('a skipped platform is named in the cache, and a build that knows it fetches in full', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
// The master adds a firm on a platform this build doesn't know.
|
||||
served = { version: 42, updatedAt: '2026-10-27T15:00:00.000Z', firms: [apex, vest, firm('px', 'PX Firm', 'projectx')] };
|
||||
storedCache({ version: 41, updatedAt: '2026-10-26T19:20:00.000Z', firms: [apex, vest], parser: CATALOG_PARSER_VERSION });
|
||||
await syncCatalog();
|
||||
assert.deepEqual(names(), ['Apex', 'VestMarkets']);
|
||||
assert.deepEqual(JSON.parse(getSetting('catalog_payload')).skippedPlatforms, ['projectx']);
|
||||
// While this build still doesn't know it, syncs stay incremental, and the
|
||||
// cache still vouches for the kept rule sets of the firms it holds.
|
||||
globalThis.__firmCatalog = undefined;
|
||||
sinceSent.length = 0;
|
||||
await syncCatalog();
|
||||
assert.deepEqual(sinceSent, ['42']);
|
||||
assert.deepEqual(getKeptRuleIdsForFirm('apex'), []);
|
||||
// A build that knows the platform it skipped (Vest here) fetches in full.
|
||||
storedCache({ version: 42, updatedAt: served.updatedAt, firms: [apex], parser: CATALOG_PARSER_VERSION, skippedPlatforms: ['vest'] });
|
||||
await syncCatalog();
|
||||
assert.deepEqual(sinceSent, [null]);
|
||||
assert.ok(names().includes('VestMarkets'));
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
// Checks for the extension's Vest relay: the hook in the Vest tab
|
||||
// (extension/vest-relay.js) and the choice of which tab's token the background
|
||||
// relays (extension/background.js pageVestSession / pickVestSession).
|
||||
//
|
||||
// npm run check
|
||||
//
|
||||
// The hook runs in a sandbox standing in for the Vest page — its own XHR and
|
||||
// fetch, answered here; the background's functions are lifted out of its
|
||||
// source, since it is a service worker and not a module. Nothing reaches Vest
|
||||
// or Chrome. The answers are shaped like the Vest app's own (index bundle,
|
||||
// 2026-10-29): a renewal's carries refreshTokenId, a sign-in's the login's
|
||||
// refreshToken too, an account's renewal a refreshToken in its request.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const read = (path) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8');
|
||||
const HOOK = read('extension/vest-relay.js');
|
||||
const BACKGROUND = read('extension/background.js');
|
||||
|
||||
const GATEWAY = 'https://api-gateway.hz.vestmarkets.com';
|
||||
const WALLET = '0x7ce10771819f80130e204ddb5ca1522e48a9b451';
|
||||
const answer = (extra) => JSON.stringify({ accessToken: 'eyJ.login.sig', accessExpiresAtMs: 1_800_000_000_000, primaryAddr: WALLET, userId: 'u', ...extra });
|
||||
|
||||
/** A Vest page with the hook run in it (after `before`, a hook already
|
||||
* there): `xhr(url, body, status, text)` and `fetch(...)` play one of the
|
||||
* app's requests through it. */
|
||||
function page(before = {}) {
|
||||
class FakeXHR {
|
||||
constructor() { this.listeners = []; this.responseType = ''; }
|
||||
open(method, url) { this.url = url; }
|
||||
send() {}
|
||||
addEventListener(type, fn) { if (type === 'loadend') this.listeners.push(fn); }
|
||||
}
|
||||
const answers = [];
|
||||
const sandbox = {
|
||||
XMLHttpRequest: FakeXHR, URL,
|
||||
fetch: async () => answers.shift(),
|
||||
...before,
|
||||
};
|
||||
sandbox.window = sandbox;
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(HOOK, sandbox);
|
||||
return {
|
||||
sandbox,
|
||||
run: () => vm.runInContext(HOOK, sandbox),
|
||||
xhr(url, body, status, text) {
|
||||
const x = new sandbox.XMLHttpRequest();
|
||||
x.open('POST', url);
|
||||
x.send(body);
|
||||
x.status = status;
|
||||
x.responseText = text;
|
||||
for (const fn of x.listeners) fn();
|
||||
},
|
||||
async fetch(url, body, status, text) {
|
||||
answers.push({ status, clone: () => ({ text: async () => text }) });
|
||||
await sandbox.window.fetch(url, { method: 'POST', body });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("the hook keeps the login's renewed token with its session id, and an account's never", async () => {
|
||||
const p = page();
|
||||
assert.equal(p.sandbox.__afVestHook, 2);
|
||||
p.xhr(`${GATEWAY}/v3/auth/refresh`, JSON.stringify({ requestId: 'r1', rememberMe: true }), 200, answer({ refreshTokenId: 'rt-1' }));
|
||||
assert.equal(p.sandbox.__afVestLogin.accessToken, 'eyJ.login.sig');
|
||||
assert.equal(p.sandbox.__afVestLogin.refreshTokenId, 'rt-1');
|
||||
assert.equal(p.sandbox.__afVestLogin.primaryAddr, WALLET);
|
||||
|
||||
// An account's renewal, by its request, then by its answer alone.
|
||||
p.xhr(`${GATEWAY}/v3/auth/refresh`, JSON.stringify({ refreshToken: 'acct-rt', requestId: 'r2' }), 200, answer({ accessToken: 'eyJ.acct.sig', refreshTokenId: 'rt-a' }));
|
||||
await p.fetch(`${GATEWAY}/v3/auth/refresh`, undefined, 200, answer({ accessToken: 'eyJ.acct2.sig', refreshToken: 'acct-rt', refreshTokenId: 'rt-b' }));
|
||||
assert.equal(p.sandbox.__afVestLogin.accessToken, 'eyJ.login.sig');
|
||||
|
||||
// The login's next renewal, through fetch; a refused one leaves it be.
|
||||
await p.fetch(`${GATEWAY}/v3/auth/refresh`, JSON.stringify({ requestId: 'r3' }), 200, answer({ accessToken: 'eyJ.login2.sig', refreshTokenId: 'rt-2' }));
|
||||
p.xhr(`${GATEWAY}/v3/auth/refresh`, JSON.stringify({ requestId: 'r4' }), 401, answer({ accessToken: 'eyJ.no.sig', refreshTokenId: 'rt-x' }));
|
||||
assert.equal(p.sandbox.__afVestLogin.accessToken, 'eyJ.login2.sig');
|
||||
assert.equal(p.sandbox.__afVestLogin.refreshTokenId, 'rt-2');
|
||||
});
|
||||
|
||||
test("a sign-in's token is taken, though its answer carries the login's refresh token", () => {
|
||||
const p = page();
|
||||
p.xhr(`${GATEWAY}/v3/auth/register`, JSON.stringify({ primaryAddr: WALLET }), 200, answer({ accessToken: 'eyJ.signin.sig', refreshToken: 'login-rt', refreshTokenId: 'rt-s' }));
|
||||
assert.equal(p.sandbox.__afVestLogin.accessToken, 'eyJ.signin.sig');
|
||||
assert.equal(p.sandbox.__afVestLogin.refreshTokenId, 'rt-s');
|
||||
});
|
||||
|
||||
test('the hook installs once, and beside a version-1 hook', () => {
|
||||
const p = page({ __afVestHook: true, __afVestSession: { accessToken: 'eyJ.old.sig', expiresAt: 1 } });
|
||||
assert.equal(p.sandbox.__afVestHook, 2);
|
||||
const send = p.sandbox.XMLHttpRequest.prototype.send;
|
||||
p.run();
|
||||
assert.equal(p.sandbox.XMLHttpRequest.prototype.send, send);
|
||||
});
|
||||
|
||||
// The background's own functions, out of its source.
|
||||
function lift(name) {
|
||||
const m = BACKGROUND.match(new RegExp(`^function ${name}\\([\\s\\S]*?^}`, 'm'));
|
||||
assert.ok(m, `${name} not found in background.js`);
|
||||
return m[0];
|
||||
}
|
||||
const { isCurrentVestSession, pickVestSession } = new Function(`${lift('isCurrentVestSession')}\n${lift('pickVestSession')}\nreturn { isCurrentVestSession, pickVestSession };`)();
|
||||
const pageVestSession = (window, stored) => new Function('window', 'localStorage', `${lift('pageVestSession')}\nreturn pageVestSession();`)(
|
||||
window, { getItem: (key) => (key === 'vest-auth-session' ? stored : null) },
|
||||
);
|
||||
|
||||
const later = (min) => Date.now() + min * 60_000;
|
||||
const session = (token, expiresAt, refreshTokenId) => ({ accessToken: token, expiresAt, primaryAddr: WALLET, userId: 'u', ...(refreshTokenId !== undefined ? { refreshTokenId } : {}) });
|
||||
const record = (refreshTokenId, accessTokenExpiresAt) => JSON.stringify({ refreshTokenId, accessTokenExpiresAt, refreshTokenExpiresAt: later(43200), rememberMe: true });
|
||||
|
||||
test("a tab reports its live token (version 2's, else version 1's) and the app's current session", () => {
|
||||
const expiresAt = later(12);
|
||||
let out = pageVestSession({ __afVestHook: 2, __afVestLogin: session('eyJ.v2.sig', expiresAt, 'rt-2'), __afVestSession: session('eyJ.v1.sig', expiresAt) }, record('rt-2', expiresAt));
|
||||
assert.equal(out.hooked, true);
|
||||
assert.equal(out.session.accessToken, 'eyJ.v2.sig');
|
||||
assert.deepEqual(out.current, { refreshTokenId: 'rt-2', accessTokenExpiresAt: expiresAt });
|
||||
|
||||
// Hooked by version 1 only: its token, and the tab asks for version 2.
|
||||
out = pageVestSession({ __afVestHook: true, __afVestSession: session('eyJ.v1.sig', later(9)) }, record('rt-3', later(14)));
|
||||
assert.equal(out.hooked, false);
|
||||
assert.equal(out.session.accessToken, 'eyJ.v1.sig');
|
||||
|
||||
// A 0.17.0 hook kept an account's renewal too: version 2's login token
|
||||
// wins over it, though it expires sooner.
|
||||
out = pageVestSession({ __afVestHook: 2, __afVestLogin: session('eyJ.v2.sig', later(5), 'rt-1'), __afVestSession: session('eyJ.acct.sig', later(13)) }, null);
|
||||
assert.equal(out.session.accessToken, 'eyJ.v2.sig');
|
||||
assert.equal(out.current, null);
|
||||
|
||||
// Nearly expired, or a record that doesn't read.
|
||||
out = pageVestSession({ __afVestHook: 2, __afVestLogin: session('eyJ.v2.sig', Date.now() + 20_000, 'rt-1') }, '{nope');
|
||||
assert.equal(out.session, null);
|
||||
assert.equal(out.current, null);
|
||||
});
|
||||
|
||||
test("the token relayed is the one the current session holds, whichever tab has it — not the first tab's", () => {
|
||||
// Tab 1 renewed first; tab 2 renewed after it, so the session is tab 2's.
|
||||
const tab1 = { hooked: true, session: session('eyJ.tab1.sig', later(6), 'rt-1'), current: { refreshTokenId: 'rt-2', accessTokenExpiresAt: later(13) } };
|
||||
const tab2 = { hooked: true, session: session('eyJ.tab2.sig', later(13), 'rt-2'), current: { refreshTokenId: 'rt-2', accessTokenExpiresAt: later(13) } };
|
||||
assert.deepEqual(pickVestSession([tab1, tab2]), { session: tab2.session, current: true });
|
||||
assert.equal(isCurrentVestSession(tab1.session, tab1.current), false);
|
||||
|
||||
// Tab 1 renews again: now its token is the current one.
|
||||
const rt3 = { refreshTokenId: 'rt-3', accessTokenExpiresAt: later(15) };
|
||||
const tab1b = { ...tab1, session: session('eyJ.tab1b.sig', later(15), 'rt-3'), current: rt3 };
|
||||
assert.equal(pickVestSession([{ ...tab2, current: rt3 }, tab1b]).session.accessToken, 'eyJ.tab1b.sig');
|
||||
|
||||
// Version-1 hooks keep no id: matched by the expiry the app keeps with it.
|
||||
const v1a = { hooked: false, session: session('eyJ.v1a.sig', later(4)), current: { refreshTokenId: 'rt-9', accessTokenExpiresAt: later(11) } };
|
||||
const v1b = { hooked: false, session: session('eyJ.v1b.sig', v1a.current.accessTokenExpiresAt), current: v1a.current };
|
||||
assert.deepEqual(pickVestSession([v1a, v1b]), { session: v1b.session, current: true });
|
||||
});
|
||||
|
||||
test('with no tab holding the current session the newest is relayed, and said; with no record, unjudged', () => {
|
||||
const current = { refreshTokenId: 'rt-5', accessTokenExpiresAt: later(14) };
|
||||
const a = { hooked: true, session: session('eyJ.a.sig', later(3), 'rt-3'), current };
|
||||
const b = { hooked: true, session: session('eyJ.b.sig', later(8), 'rt-4'), current };
|
||||
assert.deepEqual(pickVestSession([a, b]), { session: b.session, current: false });
|
||||
assert.deepEqual(pickVestSession([{ ...a, current: null }, { ...b, current: null }]), { session: b.session, current: null });
|
||||
// A tab with no token yet still shows the record.
|
||||
assert.equal(pickVestSession([{ hooked: true, session: null, current }, { ...b, current: null }]).current, false);
|
||||
assert.equal(pickVestSession([{ hooked: true, session: null, current }]), null);
|
||||
assert.equal(pickVestSession([]), null);
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
// Checks for the futures instrument specs and the Auto draw
|
||||
// (lib/instrument-specs.ts).
|
||||
//
|
||||
// npm run check
|
||||
//
|
||||
// lib/instrument-specs.ts has no imports, so Node strips its types and loads
|
||||
// it directly; .mjs keeps this file out of Next's type check.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
CME_SPECS, CME_ROOTS, POINT_VALUES, MICRO_OF, tickValue, spreadDollars, dailyMoveDollars, futuresContracts,
|
||||
expectedHoldHours, sessionProgress, drawChances, pickByChance, housekeepingRoot, sanitizeInstruments,
|
||||
resolveAllowlist, MAX_HOLD_HOURS, DRAW_FLOOR, DEFAULT_MAX_ENTRY_SPREAD, spreadTooWide, sanitizeInstrumentLimits,
|
||||
} from '../lib/instrument-specs.ts';
|
||||
import { mergeTopstepxQuote, parseTradovateFrame, bookSpread } from '../lib/quote-helpers.ts';
|
||||
import { TOPSTEP_COMMISSION_PER_SIDE, topstepxProductCode, topstepxRoot, topstepCommission } from '../lib/topstep-fees.ts';
|
||||
|
||||
// Deterministic PRNG so a failure reproduces.
|
||||
function rng(seed) {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s + 0x6d2b79f5) >>> 0;
|
||||
let t = s;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
test('specs: the old point-value table exactly, and the tick values', () => {
|
||||
// The table lib/trading-logic.ts carried until the specs replaced it.
|
||||
const old = {
|
||||
NQ: 20, MNQ: 2, ES: 50, MES: 5, YM: 5, MYM: 0.5, RTY: 50, M2K: 5, GC: 100, MGC: 10, SI: 5000, CL: 1000,
|
||||
MCL: 100, NG: 10000, ZB: 1000, ZN: 1000, ZF: 1000, '6E': 125000, '6J': 12500000, '6B': 62500,
|
||||
};
|
||||
assert.deepEqual({ ...POINT_VALUES }, old);
|
||||
assert.equal(CME_ROOTS.length, 20);
|
||||
const ticks = { NQ: 5, MNQ: 0.5, ES: 12.5, GC: 10, MGC: 1, CL: 10, SI: 25, NG: 10, ZB: 31.25, ZN: 15.625, ZF: 7.8125, '6E': 6.25, '6J': 6.25, '6B': 6.25, YM: 5, RTY: 5 };
|
||||
for (const [root, value] of Object.entries(ticks)) assert.ok(Math.abs(tickValue(root) - value) < 1e-9, `${root} ${tickValue(root)}`);
|
||||
assert.equal(spreadDollars('NQ'), 5);
|
||||
for (const [full, micro] of Object.entries(MICRO_OF)) {
|
||||
assert.ok(CME_SPECS[micro].micro && !CME_SPECS[full].micro);
|
||||
assert.equal(CME_SPECS[full].pointValue / CME_SPECS[micro].pointValue, 10);
|
||||
}
|
||||
});
|
||||
|
||||
test('daily move from hourly closes; contracts; expected hold', () => {
|
||||
// A constant 0.2% hourly swing: σ = 0.002, × √23 × price × pv.
|
||||
const closes = Array.from({ length: 60 }, (_, i) => (i % 2 ? 20000 * 1.002 : 20000));
|
||||
const move = dailyMoveDollars(closes, 20);
|
||||
const r = Math.log(1.002);
|
||||
const sd = Math.sqrt((r * r * 59) / 58); // mean ≈ 0 over alternating ±r
|
||||
assert.ok(Math.abs(move - sd * Math.sqrt(23) * closes[59] * 20) / move < 0.01, `move ${move}`);
|
||||
assert.equal(dailyMoveDollars(closes.slice(0, 10), 20), null);
|
||||
assert.equal(futuresContracts(1000, 0), 1);
|
||||
assert.equal(futuresContracts(2400, 0), 3);
|
||||
assert.equal(futuresContracts(2400, 2), 2);
|
||||
// $1,000 leg, stopless, 1 contract: (1000/5227)² days ≈ 0.88 h on NQ; ≈ 8 days on 6E.
|
||||
assert.ok(Math.abs(expectedHoldHours({ leg: 1000, stopDollars: Infinity, contracts: 1, dailyMove: 5227 }) - 0.878) < 0.01);
|
||||
assert.ok(expectedHoldHours({ leg: 1000, stopDollars: Infinity, contracts: 1, dailyMove: 351 }) > 190);
|
||||
// More contracts, bigger move, shorter hold; a stop shortens it too.
|
||||
assert.ok(Math.abs(expectedHoldHours({ leg: 2000, stopDollars: Infinity, contracts: 2, dailyMove: 5227 }) - 0.878) < 0.01);
|
||||
assert.ok(expectedHoldHours({ leg: 1000, stopDollars: 500, contracts: 1, dailyMove: 5227 }) < 0.5);
|
||||
assert.equal(expectedHoldHours({ leg: 1000, stopDollars: Infinity, contracts: 1, dailyMove: null }), null);
|
||||
});
|
||||
|
||||
test('session progress: full CME 17:45 → 14:55 CT, equity hours 8:35 → 14:55', () => {
|
||||
// 2026-09-28 is a Monday; CDT is UTC−5.
|
||||
const ct = (iso) => new Date(`${iso}-05:00`);
|
||||
assert.equal(sessionProgress(ct('2026-09-27T17:45'), 'full_cme'), 0);
|
||||
assert.equal(sessionProgress(ct('2026-09-27T17:30'), 'full_cme'), null); // Sunday before the open
|
||||
assert.ok(Math.abs(sessionProgress(ct('2026-09-28T04:00'), 'full_cme') - 615 / 1270) < 1e-9);
|
||||
assert.equal(sessionProgress(ct('2026-09-28T14:55'), 'full_cme'), 1);
|
||||
assert.equal(sessionProgress(ct('2026-09-28T15:30'), 'full_cme'), null); // maintenance
|
||||
assert.equal(sessionProgress(ct('2026-09-26T12:00'), 'full_cme'), null); // Saturday
|
||||
assert.equal(sessionProgress(ct('2026-09-27T12:00'), 'full_cme'), null); // Sunday before the open
|
||||
assert.equal(sessionProgress(ct('2026-10-02T15:10'), 'full_cme'), null); // Friday after the close
|
||||
assert.equal(sessionProgress(ct('2026-09-28T08:35'), 'equity_hours'), 0);
|
||||
assert.equal(sessionProgress(ct('2026-09-28T14:55'), 'equity_hours'), 1);
|
||||
assert.equal(sessionProgress(ct('2026-09-28T07:00'), 'equity_hours'), null);
|
||||
});
|
||||
|
||||
// Measured 2026-09-26 from Yahoo hourly closes.
|
||||
const MEASURED = [
|
||||
{ symbol: 'NQ', spreadDollars: 5, dailyMove: 5227, holdHours: null },
|
||||
{ symbol: 'GC', spreadDollars: 10, dailyMove: 4843, holdHours: null },
|
||||
{ symbol: 'CL', spreadDollars: 10, dailyMove: 3550, holdHours: null },
|
||||
{ symbol: '6E', spreadDollars: 6.25, dailyMove: 351, holdHours: null },
|
||||
];
|
||||
const pct = (chances) => Object.fromEntries(chances.map((c) => [c.symbol, Math.round(c.chance * 1000) / 10]));
|
||||
|
||||
test('the draw reproduces the plan\'s table: a floor early, the fastest late', () => {
|
||||
const rows = [
|
||||
[0, { NQ: 42.3, GC: 25.0, CL: 21.0, '6E': 11.7 }],
|
||||
[0.5, { NQ: 49.0, GC: 27.5, CL: 16.0, '6E': 7.5 }],
|
||||
[0.9, { NQ: 59.9, GC: 32.5, CL: 5.7, '6E': 1.9 }],
|
||||
[1, { NQ: 63.0, GC: 34.2, CL: 2.9, '6E': 0.0 }],
|
||||
];
|
||||
for (const [p, expected] of rows) {
|
||||
const { chances } = drawChances(MEASURED, p);
|
||||
const got = pct(chances);
|
||||
for (const [sym, v] of Object.entries(expected)) assert.ok(Math.abs(got[sym] - v) <= 0.15, `p=${p} ${sym} ${got[sym]} vs ${v}`);
|
||||
assert.ok(Math.abs(chances.reduce((s, c) => s + c.chance, 0) - 1) < 1e-9);
|
||||
}
|
||||
// At the open every instrument has at least the floor.
|
||||
for (const c of drawChances(MEASURED, 0).chances) assert.ok(c.chance >= DRAW_FLOOR - 1e-12);
|
||||
});
|
||||
|
||||
test('the 12-hour cap disables slow instruments; nothing fitting means no draw', () => {
|
||||
const withHolds = MEASURED.map((c) => ({ ...c, holdHours: c.symbol === '6E' ? 195 : 1 }));
|
||||
const { chances, disabled } = drawChances(withHolds, 0);
|
||||
assert.deepEqual(disabled, ['6E']);
|
||||
assert.deepEqual(chances.map((c) => c.symbol), ['NQ', 'GC', 'CL']);
|
||||
const got = pct(chances);
|
||||
assert.ok(Math.abs(got.NQ - 48.9) < 0.2 && Math.abs(got.GC - 28.0) < 0.2 && Math.abs(got.CL - 23.2) < 0.2, JSON.stringify(got));
|
||||
assert.equal(drawChances([{ ...MEASURED[0], holdHours: MAX_HOLD_HOURS }], 0).chances.length, 0);
|
||||
assert.equal(drawChances([{ ...MEASURED[0], holdHours: MAX_HOLD_HOURS - 0.01 }], 0).chances.length, 1);
|
||||
});
|
||||
|
||||
test('fallbacks: median weight when unmeasured, even when nothing is; micros only as a last resort', () => {
|
||||
const one = drawChances([...MEASURED.slice(0, 3), { symbol: 'ES', spreadDollars: 12.5, dailyMove: null, holdHours: null }], 0);
|
||||
const es = one.chances.find((c) => c.symbol === 'ES').chance;
|
||||
const sorted = one.chances.filter((c) => c.symbol !== 'ES').map((c) => c.chance).sort();
|
||||
assert.ok(es >= sorted[0] && es <= sorted[2], `ES ${es}`);
|
||||
const none = drawChances(MEASURED.map((c) => ({ ...c, dailyMove: null })), 0.7);
|
||||
for (const c of none.chances) assert.ok(Math.abs(c.chance - 0.25) < 1e-9);
|
||||
const micros = [{ symbol: 'MNQ', spreadDollars: 0.5, dailyMove: 522, holdHours: 5, micro: true }];
|
||||
assert.deepEqual(drawChances([...micros, MEASURED[0]], 0).chances.map((c) => c.symbol), ['NQ']);
|
||||
assert.deepEqual(drawChances(micros, 0).chances.map((c) => c.symbol), ['MNQ']);
|
||||
});
|
||||
|
||||
test('10k seeded draws land on the chances', () => {
|
||||
const { chances } = drawChances(MEASURED, 0.5);
|
||||
const r = rng(7);
|
||||
const counts = {};
|
||||
for (let i = 0; i < 10000; i++) {
|
||||
const s = pickByChance(chances, r());
|
||||
counts[s] = (counts[s] ?? 0) + 1;
|
||||
}
|
||||
for (const c of chances) assert.ok(Math.abs(counts[c.symbol] / 10000 - c.chance) < 0.02, `${c.symbol} ${counts[c.symbol]} vs ${c.chance}`);
|
||||
assert.equal(pickByChance([], 0.5), null);
|
||||
});
|
||||
|
||||
test('drain and extra-day legs: always MNQ', () => {
|
||||
// Always MNQ, whatever the firm lists; nothing for a firm that lists nothing.
|
||||
assert.equal(housekeepingRoot(['NQ', 'GC', 'CL', '6E']), 'MNQ');
|
||||
assert.equal(housekeepingRoot(['MNQ']), 'MNQ');
|
||||
assert.equal(housekeepingRoot(['GC', 'CL']), 'MNQ');
|
||||
assert.equal(housekeepingRoot(['6E', 'ZN']), 'MNQ');
|
||||
assert.equal(housekeepingRoot(['MCL', 'ZB']), 'MNQ');
|
||||
assert.equal(housekeepingRoot([]), null);
|
||||
});
|
||||
|
||||
test('reading lists: sanitize per family; master list first, the old rules otherwise', () => {
|
||||
assert.deepEqual(sanitizeInstruments('tradovate', ['nq', 'GC', 'XX', 'NQ', 5]), ['NQ', 'GC']);
|
||||
assert.deepEqual(sanitizeInstruments('dxtrade', ['ETH/USD', 'BAD|X', 'ETH/USD', 'btc/usd']), ['ETH/USD', 'btc/usd']);
|
||||
const local = ['CL', 'GC', 'NQ', '6E'];
|
||||
assert.deepEqual(resolveAllowlist({ platform: 'tradovate', instruments: ['NQ', 'ES'], bannedSymbols: ['NQ'] }, local), { roots: ['NQ', 'ES'], source: 'master' });
|
||||
assert.deepEqual(resolveAllowlist({ platform: 'tradovate', bannedSymbols: ['GC'] }, local), { roots: ['CL', 'NQ', '6E'], source: 'legacy' });
|
||||
assert.deepEqual(resolveAllowlist({ platform: 'topstepx', instruments: [] }, local), { roots: [], source: 'master' });
|
||||
assert.deepEqual(
|
||||
resolveAllowlist({ platform: 'dxtrade', symbolMap: { NQ: [{ symbol: 'XYZ100/USD' }], '*': [{ symbol: 'ETH/USD' }, { symbol: 'XYZ100/USD' }] } }, local),
|
||||
{ roots: ['ETH/USD', 'XYZ100/USD'], source: 'legacy' },
|
||||
);
|
||||
});
|
||||
|
||||
// ── live quotes and the entry spread limit ──────────────────────────────────
|
||||
|
||||
test('TopstepX quotes: a side the hub leaves out keeps its last price', () => {
|
||||
let book = mergeTopstepxQuote(undefined, { symbol: 'F.US.ENQ', bestBid: 24000.25, bestAsk: 24000.5, lastPrice: 24000.5 });
|
||||
assert.deepEqual(book, { bid: 24000.25, ask: 24000.5 });
|
||||
book = mergeTopstepxQuote(book, { bestAsk: 24001 });
|
||||
assert.deepEqual(book, { bid: 24000.25, ask: 24001 });
|
||||
// Wrapped in { data }, as the user hub's events are; junk prices ignored.
|
||||
assert.deepEqual(mergeTopstepxQuote(book, { data: { bestBid: 24000.75, bestAsk: 0 } }), { bid: 24000.75, ask: 24001 });
|
||||
assert.deepEqual(mergeTopstepxQuote(undefined, null), {});
|
||||
});
|
||||
|
||||
test('Tradovate market-data frames: replies and quotes', () => {
|
||||
const frame = 'a' + JSON.stringify([
|
||||
{ s: 200, i: 1 },
|
||||
{ s: 404, i: 3, d: 'Symbol not found' },
|
||||
{ e: 'md', d: { quotes: [
|
||||
{ contractId: 3267315, timestamp: '2026-09-27T22:10:00Z', entries: { Bid: { price: 24000.25, size: 3 }, Offer: { price: 24000.5, size: 5 }, Trade: { price: 24000.5 } } },
|
||||
{ contractId: 3273057, entries: { Offer: { price: 71.24, size: 2 } } },
|
||||
{ entries: { Bid: { price: 1 } } },
|
||||
] } },
|
||||
]);
|
||||
const parsed = parseTradovateFrame(frame);
|
||||
assert.deepEqual(parsed.responses, [{ id: 1, status: 200, data: undefined }, { id: 3, status: 404, data: 'Symbol not found' }]);
|
||||
assert.deepEqual(parsed.quotes, [
|
||||
{ contractId: 3267315, bid: 24000.25, ask: 24000.5 },
|
||||
{ contractId: 3273057, ask: 71.24 },
|
||||
]);
|
||||
for (const other of ['o', 'h', 'c[1000,"bye"]', 'a[not json', 42]) assert.deepEqual(parseTradovateFrame(other), { responses: [], quotes: [] });
|
||||
});
|
||||
|
||||
test('the spread off the book, in dollars and ticks', () => {
|
||||
const nq = CME_SPECS.NQ;
|
||||
assert.deepEqual(bookSpread({ bid: 24000.25, ask: 24000.5 }, nq.pointValue, nq.tickSize), { dollars: 5, ticks: 1 });
|
||||
assert.deepEqual(bookSpread({ bid: 24000, ask: 24001 }, nq.pointValue, nq.tickSize), { dollars: 20, ticks: 4 });
|
||||
const gc = CME_SPECS.GC;
|
||||
assert.deepEqual(bookSpread({ bid: 3650.1, ask: 3650.3 }, gc.pointValue, gc.tickSize), { dollars: 20, ticks: 2 });
|
||||
// A locked book is zero wide; a crossed or one-sided one isn't a spread.
|
||||
assert.deepEqual(bookSpread({ bid: 71.2, ask: 71.2 }, 1000, 0.01), { dollars: 0, ticks: 0 });
|
||||
assert.equal(bookSpread({ bid: 71.3, ask: 71.2 }, 1000, 0.01), null);
|
||||
assert.equal(bookSpread({ bid: 71.2 }, 1000, 0.01), null);
|
||||
assert.equal(bookSpread(undefined, 1000, 0.01), null);
|
||||
});
|
||||
|
||||
test('the entry limit: $15 by default; one tick of the listed roots fits under it', () => {
|
||||
assert.equal(DEFAULT_MAX_ENTRY_SPREAD, 15);
|
||||
for (const root of ['NQ', 'GC', 'CL', 'ES', 'MNQ']) assert.equal(spreadTooWide(spreadDollars(root), 15), false, root);
|
||||
assert.equal(spreadTooWide(15, 15), false);
|
||||
assert.equal(spreadTooWide(15.01, 15), true);
|
||||
assert.equal(spreadTooWide(20, 15), true);
|
||||
// 0 = no limit; a NaN spread never blocks.
|
||||
assert.equal(spreadTooWide(500, 0), false);
|
||||
assert.equal(spreadTooWide(NaN, 15), false);
|
||||
// One tick of these is over $15 by itself.
|
||||
for (const root of ['SI', 'ZB', 'ZN']) assert.equal(spreadTooWide(spreadDollars(root), 15), true, root);
|
||||
});
|
||||
|
||||
test('the draw leaves out what is over the spread limit', () => {
|
||||
const cands = [
|
||||
{ symbol: 'NQ', spreadDollars: 20, dailyMove: 5164, holdHours: 0.9, wide: true },
|
||||
{ symbol: 'GC', spreadDollars: 10, dailyMove: 4899, holdHours: 1.0 },
|
||||
{ symbol: 'CL', spreadDollars: 10, dailyMove: 3554, holdHours: 1.9 },
|
||||
];
|
||||
const { chances, wide, disabled } = drawChances(cands, 0);
|
||||
assert.deepEqual(wide, ['NQ']);
|
||||
assert.deepEqual(disabled, []);
|
||||
assert.deepEqual(chances.map((c) => c.symbol), ['GC', 'CL']);
|
||||
assert.ok(Math.abs(chances.reduce((s, c) => s + c.chance, 0) - 1) < 1e-9);
|
||||
// All of them wide: nothing to draw.
|
||||
assert.deepEqual(drawChances(cands.map((c) => ({ ...c, wide: true })), 0.5).chances, []);
|
||||
});
|
||||
|
||||
test('a firm\'s per-instrument limit caps the contracts, with the account type\'s max', () => {
|
||||
// One per $1,000 of leg ...
|
||||
assert.equal(futuresContracts(4200, 0), 5);
|
||||
// ... capped by the account type's max, the firm's limit, whichever is lower.
|
||||
assert.equal(futuresContracts(4200, 0, 3), 3);
|
||||
assert.equal(futuresContracts(4200, 4, 2), 2);
|
||||
assert.equal(futuresContracts(4200, 2, 4), 2);
|
||||
assert.equal(futuresContracts(4200, 0, 0), 5);
|
||||
// Never under one contract.
|
||||
assert.equal(futuresContracts(300, 0, 5), 1);
|
||||
// A capped leg holds longer: 5 contracts on NQ vs a cap of 2.
|
||||
const free = expectedHoldHours({ leg: 5000, stopDollars: Infinity, contracts: futuresContracts(5000, 0), dailyMove: 5227 });
|
||||
const capped = expectedHoldHours({ leg: 5000, stopDollars: Infinity, contracts: futuresContracts(5000, 0, 2), dailyMove: 5227 });
|
||||
assert.ok(Math.abs(capped / free - 6.25) < 1e-9, `${capped} vs ${free}`);
|
||||
});
|
||||
|
||||
test('instrument limits off the catalog: CME roots, whole numbers 1-1000', () => {
|
||||
assert.deepEqual(sanitizeInstrumentLimits({ NQ: 15, CL: 5, GC: 0, ES: 2.5, ZZ: 3, SI: 2000, YM: '4' }), { NQ: 15, CL: 5 });
|
||||
assert.deepEqual(sanitizeInstrumentLimits(null), {});
|
||||
assert.deepEqual(sanitizeInstrumentLimits(['NQ']), {});
|
||||
});
|
||||
|
||||
test('Topstep commission: every root, a micro at half the full-size rate', () => {
|
||||
assert.deepEqual(Object.keys(TOPSTEP_COMMISSION_PER_SIDE).sort(), [...CME_ROOTS].sort());
|
||||
for (const root of CME_ROOTS) assert.equal(TOPSTEP_COMMISSION_PER_SIDE[root], CME_SPECS[root].micro ? 0.25 : 0.5, root);
|
||||
});
|
||||
|
||||
test('Topstep commission: TopstepX contract ids to roots', () => {
|
||||
// Codes seen on real fills.
|
||||
assert.equal(topstepxRoot('CON.F.US.ENQ.Z26'), 'NQ');
|
||||
assert.equal(topstepxRoot('CON.F.US.MNQ.Z26'), 'MNQ');
|
||||
assert.equal(topstepxRoot('CON.F.US.GCE.Z26'), 'GC');
|
||||
assert.equal(topstepxRoot('CON.F.US.CLE.X26'), 'CL');
|
||||
assert.equal(topstepxProductCode('CON.F.US.ENQ.Z26'), 'ENQ');
|
||||
// Not a contract id, or a product with no entry.
|
||||
assert.equal(topstepxRoot('NQZ6'), null);
|
||||
assert.equal(topstepxRoot(undefined), null);
|
||||
assert.equal(topstepxRoot('CON.F.US.MBT.Z26'), null);
|
||||
// A contract the client resolved names its own code; a stray key doesn't.
|
||||
assert.equal(topstepxRoot('CON.F.US.MBT.Z26', new Map([['MBT', 'MES']])), 'MES');
|
||||
assert.equal(topstepxRoot('CON.F.US.ENQ.Z26', new Map([['ENQ', '*']])), 'NQ');
|
||||
});
|
||||
|
||||
test('Topstep commission: reported fees plus commission make the published round turn', () => {
|
||||
// TopstepX reports exchange + NFA a side; Topstep's page totals a round turn.
|
||||
const roundTurn = (root, reportedPerSide, contracts) =>
|
||||
Math.round((2 * reportedPerSide * contracts + 2 * topstepCommission(root, contracts)) * 100) / 100;
|
||||
assert.equal(roundTurn('NQ', 1.39, 1), 3.78);
|
||||
assert.equal(roundTurn('MNQ', 0.36, 1), 1.22);
|
||||
assert.equal(roundTurn('GC', 1.66, 1), 4.32);
|
||||
assert.equal(roundTurn('CL', 1.51, 1), 4.02);
|
||||
assert.equal(roundTurn('NQ', 1.39, 3), 11.34);
|
||||
// A sell fill's size is still a cost; an unknown product pays the full-size rate.
|
||||
assert.equal(topstepCommission('NQ', -3), 1.5);
|
||||
assert.equal(topstepCommission(null, 2), 1);
|
||||
// 2026-10-18, an XFA $27.50 under its books: 26 full-size round turns and 3 MNQ.
|
||||
const missing = 26 * 2 * topstepCommission('NQ', 1) + 3 * 2 * topstepCommission('MNQ', 1);
|
||||
assert.equal(missing, 27.5);
|
||||
});
|
||||
@@ -0,0 +1,517 @@
|
||||
// Checks for kept rules — which of an account type's rule sets an account
|
||||
// trades on (lib/kept-rules.ts).
|
||||
//
|
||||
// npm run check
|
||||
//
|
||||
// lib/kept-rules.ts has only `import type`s, so Node strips its types and
|
||||
// loads it directly; .mjs keeps this file out of Next's type check.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
RULE_FIELDS, IDENTITY_FIELDS, PROJECTION_FIELDS, normalizeAccountType, pickKeptRules, overlayKept, ruleDiffs,
|
||||
stampRulesDate, evidenceRulesDate, lowersRulesDate, sortableIso, chicagoDay, chicagoDayStart, evidenceDay,
|
||||
openingEvidence, identityStillMatches, pickedDayRulesDate, unchangedDaySaves,
|
||||
} from '../lib/kept-rules.ts';
|
||||
|
||||
const LIVE = {
|
||||
id: 'live-x', firmId: 'firm-1', label: '50K Eval', prefix: 'TDFY', accountSize: 50_000,
|
||||
profitTarget: 2500, consistency: 0.4, minDayPnL: 150, minTradingDays: 3, maxLoss: 2000, maxPositionSize: 5,
|
||||
targetSameEquity: false, splitEnabled: true, minLegs: 2, firstDayStop: true, minDayStop: false, combineCmeHolidays: false,
|
||||
withdrawalStages: [{ profit: 2000, consistency: 0.4, minTradingDays: 5, drain: false, minDayPnL: 0 }],
|
||||
payoutable: true, trailingDrawdown: 2000, expectedPayout: 1000, withdrawalPct: 0.5, payoutCap: 3000, profitSplit: 0.9,
|
||||
dailyLossLimit: 1000, stagesSameDay: false, tradesEveryDay: false, updatedAt: '2026-10-18T14:05:00.000Z',
|
||||
};
|
||||
|
||||
/** `obj` without `keys`. */
|
||||
const omit = (obj, keys) => Object.fromEntries(Object.entries(obj).filter(([k]) => !keys.includes(k)));
|
||||
|
||||
/** A kept set as the master builds it: the live type, the old rules on top. */
|
||||
function keptOf(rules, window, id = 'kept-1') {
|
||||
return {
|
||||
...LIVE, ...rules, id, keptFrom: LIVE.id, createdAt: '2026-01-01T00:00:00.000Z',
|
||||
openedAt: window.openedAt, closedAt: window.closedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// ── field classes ───────────────────────────────────────────────────────────
|
||||
|
||||
test('field classes: the spec\'s lists, identical to the master\'s, and disjoint', () => {
|
||||
assert.deepEqual([...RULE_FIELDS], [
|
||||
'profitTarget', 'consistency', 'minDayPnL', 'minTradingDays', 'maxLoss', 'maxPositionSize', 'targetSameEquity',
|
||||
'splitEnabled', 'minLegs', 'firstDayStop', 'minDayStop', 'combineCmeHolidays', 'withdrawalStages', 'payoutable',
|
||||
'trailingDrawdown', 'dailyLossLimit', 'stagesSameDay',
|
||||
]);
|
||||
assert.deepEqual([...IDENTITY_FIELDS], ['prefix', 'accountSize']);
|
||||
assert.deepEqual([...PROJECTION_FIELDS], ['expectedPayout', 'withdrawalPct', 'payoutCap', 'profitSplit']);
|
||||
const all = [...RULE_FIELDS, ...IDENTITY_FIELDS, ...PROJECTION_FIELDS, 'label'];
|
||||
assert.equal(new Set(all).size, all.length);
|
||||
// Every field of a catalog account type is in exactly one class, or is
|
||||
// bookkeeping the live type always owns.
|
||||
const bookkeeping = ['id', 'firmId', 'updatedAt', 'tradesEveryDay'];
|
||||
assert.deepEqual(Object.keys(LIVE).filter((k) => !all.includes(k) && !bookkeeping.includes(k)), []);
|
||||
});
|
||||
|
||||
// ── normalizeAccountType: exactly the old validatePayload callback ──────────
|
||||
|
||||
/** lib/firm-catalog.ts validatePayload's per-type callback before the move
|
||||
* (git show HEAD:lib/firm-catalog.ts), verbatim but for the types. */
|
||||
function legacyNormalize(t, j, tradesEveryDay) {
|
||||
const nonNegative = (value) => (typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0);
|
||||
const isStages = (value) => Array.isArray(value) && value.every((s) =>
|
||||
typeof s === 'object' && s !== null
|
||||
&& typeof s.profit === 'number'
|
||||
&& typeof s.consistency === 'number'
|
||||
&& typeof s.minTradingDays === 'number');
|
||||
if (typeof t !== 'object' || t === null) throw new Error(`account type ${j} is not an object`);
|
||||
const type = t;
|
||||
for (const key of ['id', 'firmId', 'label', 'prefix', 'updatedAt']) {
|
||||
if (typeof type[key] !== 'string') throw new Error(`account type ${j} is missing ${key}`);
|
||||
}
|
||||
for (const key of [
|
||||
'accountSize', 'profitTarget', 'consistency', 'minDayPnL',
|
||||
'minTradingDays', 'maxLoss', 'maxPositionSize',
|
||||
]) {
|
||||
if (typeof type[key] !== 'number') throw new Error(`account type ${j} is missing ${key}`);
|
||||
}
|
||||
if (!isStages(type.withdrawalStages)) throw new Error(`account type ${j} has bad withdrawalStages`);
|
||||
return {
|
||||
...type,
|
||||
splitEnabled: type.splitEnabled !== false,
|
||||
combineCmeHolidays: type.combineCmeHolidays === true && !tradesEveryDay,
|
||||
minLegs: typeof type.minLegs === 'number' ? type.minLegs : 0,
|
||||
firstDayStop: type.firstDayStop === true,
|
||||
minDayStop: type.minDayStop === true,
|
||||
dailyLossLimit: nonNegative(type.dailyLossLimit),
|
||||
stagesSameDay: type.stagesSameDay === true,
|
||||
tradesEveryDay,
|
||||
};
|
||||
}
|
||||
|
||||
function outcome(fn) {
|
||||
try {
|
||||
return { ok: JSON.stringify(fn()) };
|
||||
} catch (err) {
|
||||
return { error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
test('normalizeAccountType is the old per-type validation, byte for byte (values, key order, errors)', () => {
|
||||
const wire = omit(LIVE, ['tradesEveryDay']);
|
||||
const variants = [
|
||||
wire,
|
||||
// An old master: none of the lenient fields.
|
||||
omit(wire, ['splitEnabled', 'minLegs', 'firstDayStop', 'minDayStop', 'combineCmeHolidays', 'dailyLossLimit']),
|
||||
{ ...wire, splitEnabled: false, combineCmeHolidays: true, minLegs: '3', firstDayStop: 'yes', dailyLossLimit: -5 },
|
||||
{ ...wire, dailyLossLimit: NaN, minDayStop: true, extra: { nested: [1, 2] } },
|
||||
{ ...wire, withdrawalStages: [] },
|
||||
// Malformed: each must throw the same message.
|
||||
null, 'type', 42,
|
||||
{ ...wire, id: 7 }, { ...wire, label: undefined }, { ...wire, updatedAt: null },
|
||||
{ ...wire, accountSize: '50000' }, { ...wire, maxPositionSize: undefined },
|
||||
{ ...wire, withdrawalStages: [{ profit: 1, consistency: 0.5 }] }, { ...wire, withdrawalStages: 'none' },
|
||||
{ ...wire, withdrawalStages: [null] },
|
||||
];
|
||||
let errors = 0;
|
||||
for (const tradesEveryDay of [false, true]) {
|
||||
variants.forEach((v, j) => {
|
||||
const legacy = outcome(() => legacyNormalize(v, j, tradesEveryDay));
|
||||
const now = outcome(() => normalizeAccountType(v, { tradesEveryDay, where: `account type ${j}` }));
|
||||
assert.deepEqual(now, legacy, `variant ${j}, every day ${tradesEveryDay}`);
|
||||
if (legacy.error) errors++;
|
||||
});
|
||||
}
|
||||
assert.equal(errors, 2 * 11);
|
||||
// The kept-set wording only changes the name in the message.
|
||||
assert.throws(() => normalizeAccountType({ ...wire, prefix: 1 }, { tradesEveryDay: false, where: 'kept rules 0 of Tradeify' }),
|
||||
{ message: 'kept rules 0 of Tradeify is missing prefix' });
|
||||
});
|
||||
|
||||
// ── pickKeptRules ───────────────────────────────────────────────────────────
|
||||
|
||||
const G1 = { id: 'g1', openedAt: null, closedAt: '2026-10-02T15:00:00.000Z' };
|
||||
const G2 = { id: 'g2', openedAt: '2026-10-02T15:00:00.000Z', closedAt: '2026-10-18T14:05:00.000Z' };
|
||||
const G3 = { id: 'g3', openedAt: '2026-10-18T14:05:00.000Z', closedAt: '2026-10-18T19:30:00.000Z' };
|
||||
const GENERATIONS = [G1, G2, G3];
|
||||
|
||||
test('pickKeptRules: the window holding the date, [openedAt, closedAt)', () => {
|
||||
const pick = (d, sets = GENERATIONS) => pickKeptRules(sets, d)?.id ?? null;
|
||||
// No lower bound on the first generation.
|
||||
assert.equal(pick('2020-01-01T00:00:00.000Z'), 'g1');
|
||||
assert.equal(pick('2026-10-02T14:59:59.999Z'), 'g1');
|
||||
// A date exactly at a cutoff is on the newer side: closedAt excluded,
|
||||
// openedAt included.
|
||||
assert.equal(pick('2026-10-02T15:00:00.000Z'), 'g2');
|
||||
assert.equal(pick('2026-10-18T14:04:59.999Z'), 'g2');
|
||||
assert.equal(pick('2026-10-18T14:05:00.000Z'), 'g3');
|
||||
assert.equal(pick('2026-10-18T19:29:59.999Z'), 'g3');
|
||||
// From the last cutoff on: the current rules.
|
||||
assert.equal(pick('2026-10-18T19:30:00.000Z'), null);
|
||||
assert.equal(pick('2027-01-01T00:00:00.000Z'), null);
|
||||
// Compared as times, not strings: other ISO spellings of the same instant.
|
||||
assert.equal(pick('2026-10-02T10:00:00-05:00'), 'g2');
|
||||
assert.equal(pick('2026-10-02T09:59:59.999-05:00'), 'g1');
|
||||
// Order of the list doesn't matter for disjoint windows.
|
||||
assert.equal(pick('2026-10-10T00:00:00.000Z', [G3, G1, G2]), 'g2');
|
||||
// Returns the set itself (the generic carries through).
|
||||
assert.equal(pickKeptRules(GENERATIONS, '2026-10-10T00:00:00.000Z'), G2);
|
||||
});
|
||||
|
||||
test('pickKeptRules: no date, no sets, or an unreadable date — the current rules', () => {
|
||||
assert.equal(pickKeptRules(GENERATIONS, null), null);
|
||||
assert.equal(pickKeptRules(GENERATIONS, ''), null);
|
||||
assert.equal(pickKeptRules(GENERATIONS, 'not a date'), null);
|
||||
assert.equal(pickKeptRules(undefined, '2026-10-10T00:00:00.000Z'), null);
|
||||
assert.equal(pickKeptRules([], '2026-10-10T00:00:00.000Z'), null);
|
||||
// An unreadable bound never matches (the catalog parse rejects them anyway).
|
||||
assert.equal(pickKeptRules([{ id: 'bad', openedAt: null, closedAt: 'soon' }], '2020-01-01T00:00:00.000Z'), null);
|
||||
assert.equal(pickKeptRules([{ id: 'bad', openedAt: 'once', closedAt: '2030-01-01T00:00:00.000Z' }], '2026-01-01T00:00:00.000Z'), null);
|
||||
// An empty window (openedAt == closedAt) holds nothing.
|
||||
assert.equal(pickKeptRules([{ id: 'empty', openedAt: G2.closedAt, closedAt: G2.closedAt }], G2.closedAt), null);
|
||||
});
|
||||
|
||||
// ── overlayKept ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('overlayKept: the kept rules on top; identity, label, projections and bookkeeping stay live', () => {
|
||||
const oldRules = {
|
||||
profitTarget: 3000, consistency: 0.5, minDayPnL: 200, minTradingDays: 5, maxLoss: 2500, maxPositionSize: 3,
|
||||
targetSameEquity: true, splitEnabled: false, minLegs: 0, firstDayStop: false, minDayStop: true, combineCmeHolidays: true,
|
||||
withdrawalStages: [], payoutable: false, trailingDrawdown: 2500, dailyLossLimit: 0, stagesSameDay: false,
|
||||
};
|
||||
// A master that sent other identity/projection values on the kept set:
|
||||
// they must not leak through.
|
||||
const kept = keptOf({ ...oldRules, label: 'OLD', prefix: 'OLD', accountSize: 1, expectedPayout: 1, withdrawalPct: 1,
|
||||
payoutCap: 1, profitSplit: 1, firmId: 'other', updatedAt: '1999-01-01T00:00:00.000Z', tradesEveryDay: true }, G2);
|
||||
const liveBefore = structuredClone(LIVE);
|
||||
const keptBefore = structuredClone(kept);
|
||||
const eff = overlayKept(LIVE, kept);
|
||||
|
||||
for (const field of RULE_FIELDS) assert.deepEqual(eff[field], oldRules[field], field);
|
||||
for (const field of ['id', 'firmId', 'label', 'prefix', 'accountSize', 'updatedAt', 'tradesEveryDay', ...PROJECTION_FIELDS]) {
|
||||
assert.deepEqual(eff[field], LIVE[field], field);
|
||||
}
|
||||
assert.deepEqual(eff.keptRules, { id: 'kept-1', openedAt: G2.openedAt, closedAt: G2.closedAt });
|
||||
// Nothing of the kept set's own bookkeeping comes along.
|
||||
for (const key of ['keptFrom', 'openedAt', 'closedAt', 'createdAt']) assert.equal(key in eff, false, key);
|
||||
assert.deepEqual(Object.keys(eff).sort(), [...Object.keys(LIVE), 'keptRules'].sort());
|
||||
// Inputs untouched.
|
||||
assert.deepEqual(LIVE, liveBefore);
|
||||
assert.deepEqual(kept, keptBefore);
|
||||
});
|
||||
|
||||
test('overlayKept: a rule field the kept set lacks keeps the live value', () => {
|
||||
const kept = keptOf({ consistency: 0.5 }, G1);
|
||||
delete kept.payoutable;
|
||||
kept.trailingDrawdown = undefined;
|
||||
const eff = overlayKept(LIVE, kept);
|
||||
assert.equal(eff.consistency, 0.5);
|
||||
assert.equal(eff.payoutable, LIVE.payoutable);
|
||||
assert.equal(eff.trailingDrawdown, LIVE.trailingDrawdown);
|
||||
});
|
||||
|
||||
// ── ruleDiffs ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('ruleDiffs: the rule fields that differ, raw values both sides', () => {
|
||||
assert.deepEqual(ruleDiffs(LIVE, LIVE), []);
|
||||
const eff = overlayKept(LIVE, keptOf({ consistency: 0.5, profitTarget: 3000 }, G1));
|
||||
assert.deepEqual(ruleDiffs(eff, LIVE), [
|
||||
{ field: 'profitTarget', kept: 3000, now: 2500 },
|
||||
{ field: 'consistency', kept: 0.5, now: 0.4 },
|
||||
]);
|
||||
// Non-rule fields never count.
|
||||
assert.deepEqual(ruleDiffs({ ...LIVE, label: 'x', prefix: 'y', profitSplit: 0.1 }, LIVE), []);
|
||||
const stages = [{ profit: 3000, consistency: 0.4, minTradingDays: 5, drain: false, minDayPnL: 0 }];
|
||||
assert.deepEqual(ruleDiffs({ ...LIVE, withdrawalStages: stages }, LIVE), [
|
||||
{ field: 'withdrawalStages', kept: stages, now: LIVE.withdrawalStages },
|
||||
]);
|
||||
});
|
||||
|
||||
test('ruleDiffs: stored JSON from before a field existed is not a change', () => {
|
||||
// A stage without drain / minDayPnL is what parseAccountTypeInput fills
|
||||
// with drain false, minDayPnL 0 (5 for a drain stage); key order is free.
|
||||
const bare = { ...LIVE, withdrawalStages: [{ minTradingDays: 5, consistency: 0.4, profit: 2000 }] };
|
||||
assert.deepEqual(ruleDiffs(bare, LIVE), []);
|
||||
const drainFilled = { ...LIVE, withdrawalStages: [{ profit: 0, consistency: 0, minTradingDays: 5, drain: true, minDayPnL: 5 }] };
|
||||
const drainBare = { ...LIVE, withdrawalStages: [{ profit: 0, consistency: 0, minTradingDays: 5, drain: true }] };
|
||||
assert.deepEqual(ruleDiffs(drainBare, drainFilled), []);
|
||||
// A per-stage payout override is a rule.
|
||||
assert.equal(ruleDiffs({ ...LIVE, withdrawalStages: [{ ...LIVE.withdrawalStages[0], payoutCap: 1500 }] }, LIVE).length, 1);
|
||||
// An absent optional flag or amount is its default.
|
||||
const noOptionals = omit(LIVE, ['payoutable', 'trailingDrawdown']);
|
||||
assert.deepEqual(ruleDiffs(noOptionals, { ...LIVE, payoutable: false, trailingDrawdown: 0 }), []);
|
||||
assert.deepEqual(ruleDiffs({ ...LIVE, splitEnabled: undefined }, LIVE), []);
|
||||
assert.deepEqual(ruleDiffs({ ...LIVE, splitEnabled: undefined }, { ...LIVE, splitEnabled: false }).map((d) => d.field), ['splitEnabled']);
|
||||
});
|
||||
|
||||
// ── rules dates ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('stampRulesDate: the latest of this clock, the master\'s and the catalog\'s last change', () => {
|
||||
const now = Date.parse('2026-10-18T14:00:00.000Z');
|
||||
assert.equal(stampRulesDate(now, null, null), '2026-10-18T14:00:00.000Z');
|
||||
// This clock 5 minutes behind the master's.
|
||||
assert.equal(stampRulesDate(now, 5 * 60_000, null), '2026-10-18T14:05:00.000Z');
|
||||
// This clock ahead: its own time stands.
|
||||
assert.equal(stampRulesDate(now, -5 * 60_000, null), '2026-10-18T14:00:00.000Z');
|
||||
// The catalog changed after both: no new account dates before it.
|
||||
assert.equal(stampRulesDate(now, 60_000, '2026-10-18T14:07:30.000Z'), '2026-10-18T14:07:30.000Z');
|
||||
assert.equal(stampRulesDate(now, null, '2026-10-01T00:00:00.000Z'), '2026-10-18T14:00:00.000Z');
|
||||
// Unreadable inputs are ignored.
|
||||
assert.equal(stampRulesDate(now, NaN, 'never'), '2026-10-18T14:00:00.000Z');
|
||||
assert.equal(stampRulesDate(now, null, ''), '2026-10-18T14:00:00.000Z');
|
||||
});
|
||||
|
||||
test('stampRulesDate: a machine whose clock is behind never dates a new account inside the set just closed', () => {
|
||||
// The owner saves "new accounts only" at 14:05 master time; this machine's
|
||||
// clock reads 14:01 when it first sees a brand-new account at 14:05.
|
||||
const cutoff = '2026-10-18T14:05:00.000Z';
|
||||
const kept = [{ id: 'g', openedAt: null, closedAt: cutoff }];
|
||||
const local = Date.parse('2026-10-18T14:01:00.000Z');
|
||||
// Unclamped, the account would land in the old rules.
|
||||
assert.equal(pickKeptRules(kept, new Date(local).toISOString())?.id, 'g');
|
||||
// With the master's offset, or just the catalog's updatedAt, it doesn't.
|
||||
assert.equal(pickKeptRules(kept, stampRulesDate(local, 4 * 60_000, null)), null);
|
||||
assert.equal(pickKeptRules(kept, stampRulesDate(local, null, cutoff)), null);
|
||||
// A machine whose master offset isn't known yet still clamps to the catalog.
|
||||
assert.equal(pickKeptRules(kept, stampRulesDate(local, null, '2026-10-18T14:05:00.250Z')), null);
|
||||
});
|
||||
|
||||
test('evidenceRulesDate: the end of the first fund day in Chicago — CDT and CST', () => {
|
||||
// CDT (UTC−5): 23:59:59.999 CDT is 04:59:59.999Z the next day.
|
||||
assert.equal(evidenceRulesDate('2026-10-18'), '2026-10-19T04:59:59.999Z');
|
||||
assert.equal(evidenceRulesDate('2026-07-04'), '2026-07-05T04:59:59.999Z');
|
||||
// CST (UTC−6): 05:59:59.999Z the next day.
|
||||
assert.equal(evidenceRulesDate('2026-01-15'), '2026-01-16T05:59:59.999Z');
|
||||
assert.equal(evidenceRulesDate('2026-12-31'), '2027-01-01T05:59:59.999Z');
|
||||
assert.equal(evidenceRulesDate('2028-02-29'), '2028-03-01T05:59:59.999Z');
|
||||
for (const bad of [null, undefined, '', '2026-10-18T00:00:00Z', '18/10/2026', '2026-1-8', '2026-02-31', '2026-13-01', 20261018]) {
|
||||
assert.equal(evidenceRulesDate(bad), null, String(bad));
|
||||
}
|
||||
});
|
||||
|
||||
test('evidenceRulesDate: DST boundary days end on the right side of the change', () => {
|
||||
// 2026: CST→CDT at 2 AM on Sunday Mar 8, CDT→CST at 2 AM on Sunday Nov 1.
|
||||
assert.equal(evidenceRulesDate('2026-03-07'), '2026-03-08T05:59:59.999Z'); // last CST day
|
||||
assert.equal(evidenceRulesDate('2026-03-08'), '2026-03-09T04:59:59.999Z'); // springs forward: ends in CDT
|
||||
assert.equal(evidenceRulesDate('2026-03-09'), '2026-03-10T04:59:59.999Z');
|
||||
assert.equal(evidenceRulesDate('2026-10-31'), '2026-11-01T04:59:59.999Z'); // last CDT day
|
||||
assert.equal(evidenceRulesDate('2026-11-01'), '2026-11-02T05:59:59.999Z'); // falls back: ends in CST
|
||||
assert.equal(evidenceRulesDate('2026-11-02'), '2026-11-03T05:59:59.999Z');
|
||||
// The day before each boundary and the boundary day are 23 h / 25 h long
|
||||
// in Chicago, measured end to end.
|
||||
const len = (a, b) => (Date.parse(evidenceRulesDate(b)) - Date.parse(evidenceRulesDate(a))) / 3_600_000;
|
||||
assert.equal(len('2026-03-07', '2026-03-08'), 23);
|
||||
assert.equal(len('2026-10-31', '2026-11-01'), 25);
|
||||
assert.equal(len('2026-06-01', '2026-06-02'), 24);
|
||||
});
|
||||
|
||||
test('evidenceRulesDate: every day of 2026–2027 ends 1 ms before the next one starts, in Chicago', () => {
|
||||
for (let ms = Date.UTC(2026, 0, 1); ms < Date.UTC(2028, 0, 1); ms += 86_400_000) {
|
||||
const day = new Date(ms).toISOString().slice(0, 10);
|
||||
const next = new Date(ms + 86_400_000).toISOString().slice(0, 10);
|
||||
const end = evidenceRulesDate(day);
|
||||
assert.equal(Date.parse(chicagoDayStart(next)) - Date.parse(end), 1, day);
|
||||
// Both read back as their own Chicago day, and the end as evidence.
|
||||
assert.equal(chicagoDay(end), day);
|
||||
assert.equal(chicagoDay(chicagoDayStart(next)), next);
|
||||
assert.equal(evidenceDay(end), day);
|
||||
// Under either offset the end is 04:59:59.999Z or 05:59:59.999Z the next UTC day.
|
||||
assert.match(end, new RegExp(`^${next}T0[45]:59:59\\.999Z$`));
|
||||
}
|
||||
});
|
||||
|
||||
test('evidenceRulesDate: an account opened on the day of a change — even that evening — counts as after it', () => {
|
||||
// A change at 14:05 CDT on Oct 18: an account first funded that day is
|
||||
// after it; one funded the day before keeps the old rules.
|
||||
const afternoon = [{ id: 'g', openedAt: null, closedAt: '2026-10-18T19:05:00.000Z' }];
|
||||
assert.equal(pickKeptRules(afternoon, evidenceRulesDate('2026-10-18')), null);
|
||||
assert.equal(pickKeptRules(afternoon, evidenceRulesDate('2026-10-17'))?.id, 'g');
|
||||
// A change at 8 PM CDT (01:00Z the next UTC day): a UTC end of day
|
||||
// (Oct 18 23:59:59.999Z = 6:59 PM CDT) would have put an account that
|
||||
// opened that evening before it.
|
||||
const evening = [{ id: 'g', openedAt: null, closedAt: '2026-10-19T01:00:00.000Z' }];
|
||||
assert.equal(pickKeptRules(evening, evidenceRulesDate('2026-10-18')), null);
|
||||
assert.equal(pickKeptRules(evening, evidenceRulesDate('2026-10-17'))?.id, 'g');
|
||||
// Same under CST: a change at 11:30 PM CST Jan 15 (05:30Z Jan 16).
|
||||
const winter = [{ id: 'g', openedAt: null, closedAt: '2026-01-16T05:30:00.000Z' }];
|
||||
assert.equal(pickKeptRules(winter, evidenceRulesDate('2026-01-15')), null);
|
||||
assert.equal(pickKeptRules(winter, evidenceRulesDate('2026-01-14'))?.id, 'g');
|
||||
});
|
||||
|
||||
test('chicagoDayStart / chicagoDay / evidenceDay', () => {
|
||||
assert.equal(chicagoDayStart('2026-10-18'), '2026-10-18T05:00:00.000Z');
|
||||
assert.equal(chicagoDayStart('2026-01-15'), '2026-01-15T06:00:00.000Z');
|
||||
assert.equal(chicagoDayStart('2026-03-08'), '2026-03-08T06:00:00.000Z'); // midnight is still CST
|
||||
assert.equal(chicagoDayStart('2026-11-01'), '2026-11-01T05:00:00.000Z'); // midnight is still CDT
|
||||
assert.equal(chicagoDayStart('2026-02-31'), null);
|
||||
assert.equal(chicagoDayStart('1999-12-31'), null); // before 2000: not a rules date
|
||||
assert.equal(chicagoDay('2026-10-19T04:59:59.999Z'), '2026-10-18');
|
||||
assert.equal(chicagoDay('2026-10-19T05:00:00.000Z'), '2026-10-19');
|
||||
assert.equal(chicagoDay('nope'), null);
|
||||
// Only the exact end of a Chicago day reads as evidence.
|
||||
assert.equal(evidenceDay('2026-10-19T04:59:59.999Z'), '2026-10-18');
|
||||
assert.equal(evidenceDay('2026-10-19T04:59:59.998Z'), null);
|
||||
assert.equal(evidenceDay('2026-10-18T23:59:59.999Z'), null); // the old UTC shape
|
||||
assert.equal(evidenceDay('2026-10-18T19:05:00.000Z'), null);
|
||||
});
|
||||
|
||||
test('sortableIso: plain four-digit-year ISO from 2000 on, else null', () => {
|
||||
assert.equal(sortableIso(Date.parse('2026-10-18T14:05:00Z')), '2026-10-18T14:05:00.000Z');
|
||||
assert.equal(sortableIso(Date.UTC(2000, 0, 1)), '2000-01-01T00:00:00.000Z');
|
||||
assert.equal(sortableIso(Date.UTC(2000, 0, 1) - 1), null);
|
||||
assert.equal(sortableIso(0), null);
|
||||
assert.equal(sortableIso(Date.UTC(9999, 11, 31, 23, 59, 59, 999)), '9999-12-31T23:59:59.999Z');
|
||||
assert.equal(sortableIso(Date.parse('+010000-01-01T00:00:00Z')), null); // would print as +010000-…
|
||||
assert.equal(sortableIso(8.64e15 + 1), null);
|
||||
assert.equal(sortableIso(NaN), null);
|
||||
assert.equal(sortableIso(Infinity), null);
|
||||
});
|
||||
|
||||
test('openingEvidence: a fund day ends in Chicago; the registration fallback is the instant itself', () => {
|
||||
// A positive fund row's day: the end of it in Chicago, whatever the registration.
|
||||
assert.equal(openingEvidence('2026-10-02', Date.parse('2026-09-01T00:00:00Z')), '2026-10-03T04:59:59.999Z');
|
||||
// Positive rows present but unreadable: no evidence (never the registration).
|
||||
assert.equal(openingEvidence('2026-02-31', Date.parse('2026-09-01T00:00:00Z')), null);
|
||||
// No positive rows: the registration instant, exactly.
|
||||
assert.equal(openingEvidence(undefined, Date.parse('2026-10-19T02:00:00Z')), '2026-10-19T02:00:00.000Z');
|
||||
// Unreadable or implausible registrations: none.
|
||||
for (const bad of [undefined, null, NaN, 0, 5, -1, Infinity, Date.parse('1999-12-31T23:00:00Z'), 9e15]) {
|
||||
assert.equal(openingEvidence(undefined, bad), null, String(bad));
|
||||
}
|
||||
});
|
||||
|
||||
test('openingEvidence: a DXtrade account registered after an evening cutoff is never lowered before it', () => {
|
||||
// "New accounts only" saved at 8 PM CDT Oct 18 (01:00Z Oct 19); a DX
|
||||
// account registered at 9 PM CDT (02:00Z) with no DEPOSIT row.
|
||||
const kept = [{ id: 'g', openedAt: null, closedAt: '2026-10-19T01:00:00.000Z' }];
|
||||
const stamped = '2026-10-19T02:00:05.000Z';
|
||||
const evidence = openingEvidence(undefined, Date.parse('2026-10-19T02:00:00Z'));
|
||||
const lowered = lowersRulesDate({ rules_date: stamped, rules_date_manual: 0 }, evidence) ? evidence : stamped;
|
||||
assert.equal(pickKeptRules(kept, lowered), null);
|
||||
// And at 9 PM CDT Oct 20 against an 8 PM cutoff that day.
|
||||
const kept2 = [{ id: 'g', openedAt: null, closedAt: '2026-10-21T01:00:00.000Z' }];
|
||||
assert.equal(pickKeptRules(kept2, openingEvidence(undefined, Date.parse('2026-10-21T02:00:00Z'))), null);
|
||||
// One registered before the cutoff, first seen after it, goes back to the old rules.
|
||||
const before = openingEvidence(undefined, Date.parse('2026-10-19T00:30:00Z'));
|
||||
assert.equal(lowersRulesDate({ rules_date: stamped, rules_date_manual: 0 }, before), true);
|
||||
assert.equal(pickKeptRules(kept, before)?.id, 'g');
|
||||
});
|
||||
|
||||
test('identityStillMatches: prefix and the cached size against the live type', () => {
|
||||
const live = { prefix: 'TDFY', accountSize: 50_000 };
|
||||
assert.equal(identityStillMatches('TDFY1234', '50000', live), true);
|
||||
assert.equal(identityStillMatches('TDFY1234', '50000.004', live), true);
|
||||
// A size edit on the type (50K → 100K, or → $0-start): no longer its size.
|
||||
assert.equal(identityStillMatches('TDFY1234', '50000', { ...live, accountSize: 100_000 }), false);
|
||||
assert.equal(identityStillMatches('TDFY1234', '50000', { ...live, accountSize: 0 }), false);
|
||||
// A prefix edit.
|
||||
assert.equal(identityStillMatches('TDFY1234', '50000', { ...live, prefix: 'TDFX' }), false);
|
||||
// A $0-start type caches 0 when it matches.
|
||||
assert.equal(identityStillMatches('XFA77', '0', { prefix: 'XFA', accountSize: 0 }), true);
|
||||
assert.equal(identityStillMatches('XFA77', '150000', { prefix: 'XFA', accountSize: 0 }), false);
|
||||
// Nothing cached, or junk: not provably the same account.
|
||||
assert.equal(identityStillMatches('TDFY1234', null, live), false);
|
||||
assert.equal(identityStillMatches('TDFY1234', 'abc', live), false);
|
||||
assert.equal(identityStillMatches('TDFY1234', '', { ...live, accountSize: 0 }), false);
|
||||
});
|
||||
|
||||
test('pickedDayRulesDate: today is now; another day its start, never before Chicago\'s', () => {
|
||||
const nowMs = Date.parse('2026-10-18T19:30:00.000Z');
|
||||
const at = (localMidnightMs, today = '2026-10-18') => ({ today, nowMs, localMidnightMs });
|
||||
// Today: now, whatever the zone.
|
||||
assert.equal(pickedDayRulesDate('2026-10-18', at(Date.parse('2026-10-18T05:00:00Z'))), '2026-10-18T19:30:00.000Z');
|
||||
// Chicago browser: its own midnight.
|
||||
assert.equal(pickedDayRulesDate('2026-10-15', at(Date.parse('2026-10-15T05:00:00Z'))), '2026-10-15T05:00:00.000Z');
|
||||
// Pacific browser: local midnight is later than Chicago's — stands.
|
||||
assert.equal(pickedDayRulesDate('2026-10-15', at(Date.parse('2026-10-15T07:00:00Z'))), '2026-10-15T07:00:00.000Z');
|
||||
// London (BST), Eastern and Karachi browsers: raised to Chicago's midnight.
|
||||
for (const local of ['2026-10-14T23:00:00Z', '2026-10-15T04:00:00Z', '2026-10-14T19:00:00Z']) {
|
||||
assert.equal(pickedDayRulesDate('2026-10-15', at(Date.parse(local))), '2026-10-15T05:00:00.000Z', local);
|
||||
}
|
||||
assert.equal(pickedDayRulesDate('2026-02-31', at(0)), null);
|
||||
assert.equal(pickedDayRulesDate('2026-10-15', at(NaN)), null);
|
||||
});
|
||||
|
||||
test('pickedDayRulesDate: a picked day never lands inside the evidence of the day before', () => {
|
||||
// A browser at UTC+1 picks Oct 15 for a set closing at Chicago midnight
|
||||
// of Oct 15: an account first funded Oct 14 stays on the old rules, the
|
||||
// hand-dated one on Oct 15 does not.
|
||||
const kept = [{ id: 'g', openedAt: null, closedAt: chicagoDayStart('2026-10-15') }];
|
||||
const picked = pickedDayRulesDate('2026-10-15', {
|
||||
today: '2026-10-18', nowMs: Date.parse('2026-10-18T12:00:00Z'), localMidnightMs: Date.parse('2026-10-14T23:00:00Z'),
|
||||
});
|
||||
assert.equal(pickKeptRules(kept, evidenceRulesDate('2026-10-14'))?.id, 'g');
|
||||
assert.equal(pickKeptRules(kept, picked), null);
|
||||
for (let ms = Date.UTC(2026, 0, 2); ms < Date.UTC(2027, 0, 1); ms += 86_400_000) {
|
||||
const day = new Date(ms).toISOString().slice(0, 10);
|
||||
const prev = new Date(ms - 86_400_000).toISOString().slice(0, 10);
|
||||
for (const offsetH of [-10, -8, -6, -5, -4, 0, 1, 5, 9, 14]) {
|
||||
const localMidnightMs = Date.parse(`${day}T00:00:00Z`) - offsetH * 3_600_000;
|
||||
const stored = pickedDayRulesDate(day, { today: '2030-01-01', nowMs: 0, localMidnightMs });
|
||||
assert.ok(stored > evidenceRulesDate(prev), `${day} at UTC${offsetH}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('unchangedDaySaves: an untouched day saves only when it moves the account to other rules', () => {
|
||||
// A change at 2:05 PM CDT today (19:05Z) with the default cutoff "now".
|
||||
const kept = [{ id: 'g', openedAt: null, closedAt: '2026-10-18T19:05:00.000Z' }];
|
||||
const at = { today: '2026-10-18', nowMs: Date.parse('2026-10-18T19:30:00Z'), localMidnightMs: Date.parse('2026-10-18T05:00:00Z') };
|
||||
// Dated this morning (old rules): ✓ on today stores now → current rules.
|
||||
const now = pickedDayRulesDate('2026-10-18', at);
|
||||
assert.equal(unchangedDaySaves(kept, '2026-10-18T15:00:00.000Z', now), true);
|
||||
assert.equal(pickKeptRules(kept, now), null);
|
||||
// Already after the change: nothing to save.
|
||||
assert.equal(unchangedDaySaves(kept, '2026-10-18T19:10:00.000Z', now), false);
|
||||
// A past day's start lands where the date already is: nothing to save.
|
||||
const oct2 = pickedDayRulesDate('2026-10-02', at);
|
||||
assert.equal(unchangedDaySaves(kept, evidenceRulesDate('2026-10-02'), oct2), false);
|
||||
// Windows not known (still loading, no firm): never.
|
||||
assert.equal(unchangedDaySaves(null, '2026-10-18T15:00:00.000Z', now), false);
|
||||
// The editor opened on Oct 18 (current rules) and a poll since lowered the
|
||||
// date to Oct 2: an untouched ✓ compares against the opened date, so it
|
||||
// doesn't pin Oct 18 by hand over the evidence.
|
||||
const sets = [{ id: 'g2', openedAt: null, closedAt: '2026-10-10T00:00:00.000Z' }];
|
||||
const oct18 = pickedDayRulesDate('2026-10-18', { ...at, today: '2026-10-19' });
|
||||
assert.equal(unchangedDaySaves(sets, '2026-10-18T15:00:00.000Z', oct18), false);
|
||||
});
|
||||
|
||||
test('lowersRulesDate: only earlier, only auto dates — the test lowerAutoRulesDate runs in SQL', () => {
|
||||
const evidence = '2026-10-01T23:59:59.999Z';
|
||||
assert.equal(lowersRulesDate({ rules_date: '2026-10-18T14:00:00.000Z', rules_date_manual: 0 }, evidence), true);
|
||||
assert.equal(lowersRulesDate({ rules_date: null, rules_date_manual: 0 }, evidence), true);
|
||||
assert.equal(lowersRulesDate({ rules_date: evidence, rules_date_manual: 0 }, evidence), false);
|
||||
assert.equal(lowersRulesDate({ rules_date: '2026-09-01T00:00:00.000Z', rules_date_manual: 0 }, evidence), false);
|
||||
// Set by hand: never moved.
|
||||
assert.equal(lowersRulesDate({ rules_date: '2026-10-18T14:00:00.000Z', rules_date_manual: 1 }, evidence), false);
|
||||
assert.equal(lowersRulesDate({ rules_date: null, rules_date_manual: 1 }, evidence), false);
|
||||
// String order is time order for the two formats that are ever stored.
|
||||
assert.equal('2026-10-01T23:59:59.999Z' < '2026-10-02T00:00:00.000Z', true);
|
||||
assert.equal(evidenceRulesDate('2026-10-02') > new Date(Date.parse('2026-10-02T23:59:59.998Z')).toISOString(), true);
|
||||
});
|
||||
|
||||
// ── the whole selection, as getAccountConfig runs it ────────────────────────
|
||||
|
||||
test('selection: generations, boundaries and clamps end to end', () => {
|
||||
const sets = [
|
||||
keptOf({ profitTarget: 3000, consistency: 0.5 }, G1, 'g1'),
|
||||
keptOf({ profitTarget: 2800, consistency: 0.45 }, G2, 'g2'),
|
||||
];
|
||||
const rulesFor = (rulesDate) => {
|
||||
const kept = pickKeptRules(sets, rulesDate);
|
||||
return kept ? overlayKept(LIVE, kept) : LIVE;
|
||||
};
|
||||
const cases = [
|
||||
[null, 2500, undefined],
|
||||
['2026-09-01T00:00:00.000Z', 3000, 'g1'],
|
||||
[G1.closedAt, 2800, 'g2'],
|
||||
[G2.closedAt, 2500, undefined],
|
||||
[evidenceRulesDate('2026-10-02'), 2800, 'g2'],
|
||||
[evidenceRulesDate('2026-10-01'), 3000, 'g1'],
|
||||
];
|
||||
for (const [date, target, keptId] of cases) {
|
||||
const eff = rulesFor(date);
|
||||
assert.equal(eff.profitTarget, target, String(date));
|
||||
assert.equal(eff.keptRules?.id, keptId, String(date));
|
||||
// Always reported as the live type.
|
||||
assert.equal(eff.id, LIVE.id);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
// Real local HTTP requests and WebSocket handshakes. Never use the live DB
|
||||
// or contact a broker. The existing check suite uses Node's type stripping.
|
||||
import { after, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createServer } from 'node:http';
|
||||
import { connect } from 'node:net';
|
||||
import { once } from 'node:events';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { HubConnectionBuilder, HttpTransportType, LogLevel } from '@microsoft/signalr';
|
||||
|
||||
// Resolve the extensionless local imports used by Next, without a build.
|
||||
function resolveTypes(specifier, context, nextResolve) {
|
||||
try {
|
||||
return nextResolve(specifier, context);
|
||||
} catch (err) {
|
||||
if (err.code !== 'ERR_MODULE_NOT_FOUND' || !specifier.startsWith('.')) throw err;
|
||||
return nextResolve(`${specifier}.ts`, context);
|
||||
}
|
||||
}
|
||||
registerHooks({ resolve: resolveTypes });
|
||||
|
||||
const directory = mkdtempSync(join(tmpdir(), 'autofirmer-user-agent-'));
|
||||
process.env.AUTOFIRMER_DB = join(directory, 'test.sqlite');
|
||||
delete process.env.NEXT_PHASE;
|
||||
after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
|
||||
const { getSetting, setSetting } = await import('../lib/db.ts');
|
||||
// Do not let inherited environment proxy settings route local test traffic.
|
||||
process.env.NO_PROXY = '*';
|
||||
setSetting('proxy_url', '');
|
||||
const { getRequestUserAgent } = await import('../lib/request-user-agent.ts');
|
||||
const { default: axios } = await import('../lib/http-client.ts');
|
||||
const { brokerWebSocket, directWebSocket, signalrWebSocket } = await import('../lib/outbound-proxy.ts');
|
||||
const userAgent = getRequestUserAgent();
|
||||
|
||||
async function listen(server) {
|
||||
server.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
return `http://127.0.0.1:${server.address().port}`;
|
||||
}
|
||||
|
||||
async function socketHeaders(makeSocket, server, url, path = '/') {
|
||||
const accepted = once(server, 'connection');
|
||||
const client = makeSocket(url.replace('http:', 'ws:') + path);
|
||||
const opened = new Promise((resolve, reject) => {
|
||||
client.addEventListener('open', resolve, { once: true });
|
||||
client.addEventListener('error', reject, { once: true });
|
||||
});
|
||||
const [peer, request] = await accepted;
|
||||
try {
|
||||
await opened;
|
||||
return request.headers;
|
||||
} finally {
|
||||
client.close();
|
||||
peer.terminate();
|
||||
}
|
||||
}
|
||||
|
||||
test('first load saves a browser identity and another process reuses it', () => {
|
||||
assert.match(userAgent, /^Mozilla\/5\.0 .*(Chrome\/|Firefox\/)/);
|
||||
assert.equal(getSetting('user_agent'), userAgent);
|
||||
const value = execFileSync(process.execPath, [
|
||||
'--experimental-strip-types', '--disable-warning=MODULE_TYPELESS_PACKAGE_JSON',
|
||||
'--input-type=module', '-e',
|
||||
`import { registerHooks } from 'node:module';
|
||||
registerHooks({ resolve: ${resolveTypes.toString()} });
|
||||
const { getRequestUserAgent } = await import(${JSON.stringify(new URL('../lib/request-user-agent.ts', import.meta.url).href)});
|
||||
process.stdout.write(getRequestUserAgent());`,
|
||||
], { env: process.env, encoding: 'utf8' });
|
||||
assert.equal(value, userAgent);
|
||||
});
|
||||
|
||||
test('Axios calls, custom headers, and nested create clients use the saved identity', async () => {
|
||||
const server = createServer((req, res) => {
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(req.headers));
|
||||
});
|
||||
const url = await listen(server);
|
||||
try {
|
||||
const child = axios.create({ headers: { 'user-agent': 'old child identity' } });
|
||||
const grandchild = child.create({ headers: { 'User-Agent': false } });
|
||||
for (const client of [axios, child, grandchild]) {
|
||||
const result = await client.post(url, { hello: 'world' }, {
|
||||
headers: { 'uSeR-aGeNt': false, Authorization: 'Bearer local-test', 'X-Test': 'kept' },
|
||||
});
|
||||
assert.equal(result.data['user-agent'], userAgent);
|
||||
assert.equal(result.data.authorization, 'Bearer local-test');
|
||||
assert.equal(result.data['x-test'], 'kept');
|
||||
assert.equal(client.defaults.timeout, 60_000);
|
||||
}
|
||||
assert.equal((await axios(url)).data['user-agent'], userAgent);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test('direct, broker and SignalR handshakes preserve the saved identity and auth', async () => {
|
||||
const server = createServer();
|
||||
const wss = new WebSocketServer({ server });
|
||||
const url = await listen(server);
|
||||
try {
|
||||
for (const makeSocket of [directWebSocket, brokerWebSocket]) {
|
||||
assert.equal((await socketHeaders(makeSocket, wss, url))['user-agent'], userAgent);
|
||||
}
|
||||
const WS = signalrWebSocket();
|
||||
for (const overload of [false, true]) {
|
||||
const headers = await socketHeaders((address) => {
|
||||
const options = { headers: { 'user-agent': 'SignalR default', Cookie: 'session=local', Authorization: 'Bearer local-test' } };
|
||||
return overload ? new WS(address, options) : new WS(address, undefined, options);
|
||||
}, wss, url);
|
||||
assert.equal(headers['user-agent'], userAgent);
|
||||
assert.equal(headers.cookie, 'session=local');
|
||||
assert.equal(headers.authorization, 'Bearer local-test');
|
||||
}
|
||||
|
||||
// Exercise SignalR itself, including its generated UA/auth headers.
|
||||
wss.on('connection', (socket) => {
|
||||
socket.on('message', () => socket.send('{}\u001e'));
|
||||
});
|
||||
const hub = new HubConnectionBuilder().withUrl(url, {
|
||||
skipNegotiation: true,
|
||||
transport: HttpTransportType.WebSockets,
|
||||
WebSocket: WS,
|
||||
accessTokenFactory: () => 'signalr-test',
|
||||
}).configureLogging(LogLevel.None).build();
|
||||
const accepted = once(wss, 'connection');
|
||||
await hub.start();
|
||||
try {
|
||||
const [, request] = await accepted;
|
||||
assert.equal(request.headers['user-agent'], userAgent);
|
||||
assert.equal(request.headers.authorization, 'Bearer signalr-test');
|
||||
} finally {
|
||||
await hub.stop();
|
||||
}
|
||||
} finally {
|
||||
for (const socket of wss.clients) socket.terminate();
|
||||
await new Promise((resolve) => wss.close(resolve));
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test('HTTP proxy keeps the identity on broker sockets while control sockets stay direct', async () => {
|
||||
const server = createServer();
|
||||
const wss = new WebSocketServer({ server });
|
||||
const url = await listen(server);
|
||||
let tunnels = 0;
|
||||
const connections = new Set();
|
||||
const proxy = createServer((req, res) => {
|
||||
// ws with HttpProxyAgent uses an absolute-form upgrade; handled below.
|
||||
res.writeHead(500).end();
|
||||
});
|
||||
function tunnel(req, socket, head, isConnect) {
|
||||
tunnels++;
|
||||
const target = new URL(isConnect ? `http://${req.url}` : req.url);
|
||||
const upstream = connect(Number(target.port), target.hostname, () => {
|
||||
if (isConnect) socket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
||||
else {
|
||||
upstream.write(`${req.method} ${target.pathname} HTTP/1.1\r\n`);
|
||||
for (let i = 0; i < req.rawHeaders.length; i += 2) upstream.write(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}\r\n`);
|
||||
upstream.write('\r\n');
|
||||
}
|
||||
if (head.length) upstream.write(head);
|
||||
socket.pipe(upstream).pipe(socket);
|
||||
});
|
||||
connections.add(socket);
|
||||
connections.add(upstream);
|
||||
socket.on('error', () => upstream.destroy());
|
||||
upstream.on('error', () => socket.destroy());
|
||||
}
|
||||
proxy.on('connect', (req, socket, head) => tunnel(req, socket, head, true));
|
||||
proxy.on('upgrade', (req, socket, head) => tunnel(req, socket, head, false));
|
||||
const proxyUrl = await listen(proxy);
|
||||
setSetting('proxy_url', proxyUrl);
|
||||
delete globalThis.__outboundProxy;
|
||||
try {
|
||||
for (const makeSocket of [brokerWebSocket, (address) => new (signalrWebSocket())(address)]) {
|
||||
const before = tunnels;
|
||||
assert.equal((await socketHeaders(makeSocket, wss, url))['user-agent'], userAgent);
|
||||
assert.equal(tunnels, before + 1);
|
||||
}
|
||||
const before = tunnels;
|
||||
assert.equal((await socketHeaders(directWebSocket, wss, url))['user-agent'], userAgent);
|
||||
assert.equal(tunnels, before);
|
||||
} finally {
|
||||
await globalThis.__outboundProxy.dispatcher.destroy();
|
||||
globalThis.__outboundProxy.httpAgent.destroy();
|
||||
globalThis.__outboundProxy.httpsAgent.destroy();
|
||||
delete globalThis.__outboundProxy;
|
||||
setSetting('proxy_url', '');
|
||||
for (const socket of connections) socket.destroy();
|
||||
for (const socket of wss.clients) socket.terminate();
|
||||
await new Promise((resolve) => wss.close(resolve));
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
await new Promise((resolve) => proxy.close(resolve));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
// Checks for building trades out of fills (lib/fill-trades.ts — TopstepX and
|
||||
// Tradovate) and out of DXtrade's order history (lib/dxtrade-helpers.ts).
|
||||
//
|
||||
// npm run check
|
||||
//
|
||||
// The files import only Node built-ins, so Node strips their types and loads
|
||||
// them directly; .mjs keeps this file out of Next's type check.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fillsToTrades, startingNet, contractRoot, byFillTime } from '../lib/fill-trades.ts';
|
||||
import { topstepxSymbol } from '../lib/topstep-fees.ts';
|
||||
import { parseHistoryOrder, dxTrades } from '../lib/dxtrade-helpers.ts';
|
||||
|
||||
let n = 0;
|
||||
const fill = (side, qty, price, extra = {}) => {
|
||||
n += 1;
|
||||
return { id: String(1000 + n), orderId: String(5000 + n), at: 1_000 + n * 1_000, side, qty, price, fee: 1, pnl: null, ...extra };
|
||||
};
|
||||
|
||||
test('a round trip, with the platform’s P&L', () => {
|
||||
const a = fill('buy', 2, 100);
|
||||
const b = fill('sell', 2, 101, { pnl: 40 });
|
||||
const [t, ...rest] = fillsToTrades([b, a], { startNet: 0, pointValue: null });
|
||||
assert.equal(rest.length, 0);
|
||||
assert.deepEqual(t, {
|
||||
id: a.id, side: 'long', quantity: 2, openPrice: 100, closePrice: 101, pnl: 40, fee: 2,
|
||||
openedAt: a.at, closedAt: b.at, openOrderId: a.orderId, closeOrderId: b.orderId, carried: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('P&L from the prices when the platform gives none; adds and partial closes stay one trade', () => {
|
||||
const fills = [fill('buy', 1, 100), fill('buy', 1, 102), fill('sell', 1, 103), fill('sell', 1, 104)];
|
||||
const [t, ...rest] = fillsToTrades(fills, { startNet: 0, pointValue: 20 });
|
||||
assert.equal(rest.length, 0);
|
||||
assert.equal(t.quantity, 2);
|
||||
assert.equal(t.openPrice, 101);
|
||||
assert.equal(t.closePrice, 103.5);
|
||||
assert.equal(t.pnl, 100);
|
||||
assert.equal(t.fee, 4);
|
||||
assert.equal(t.closeOrderId, fills[3].orderId);
|
||||
// No point value and no platform P&L: unknown, not zero.
|
||||
assert.equal(fillsToTrades(fills, { startNet: 0, pointValue: null })[0].pnl, null);
|
||||
});
|
||||
|
||||
test('a short', () => {
|
||||
const fills = [fill('sell', 3, 50.5), fill('buy', 3, 50)];
|
||||
const [t] = fillsToTrades(fills, { startNet: 0, pointValue: 1000 });
|
||||
assert.equal(t.side, 'short');
|
||||
assert.equal(t.pnl, 1500);
|
||||
});
|
||||
|
||||
test('a fill through zero closes one trade and opens the next', () => {
|
||||
const a = fill('buy', 1, 100);
|
||||
const b = fill('sell', 3, 105, { fee: 3, pnl: 100 });
|
||||
const [first, second, ...rest] = fillsToTrades([a, b], { startNet: 0, pointValue: 20 });
|
||||
assert.equal(rest.length, 0);
|
||||
assert.equal(first.side, 'long');
|
||||
assert.equal(first.pnl, 100);
|
||||
assert.equal(first.fee, 2); // its own buy, and a third of the reversing fill
|
||||
assert.equal(first.closeOrderId, b.orderId);
|
||||
assert.equal(second.id, b.id);
|
||||
assert.equal(second.side, 'short');
|
||||
assert.equal(second.quantity, 2);
|
||||
assert.equal(second.openPrice, 105);
|
||||
assert.equal(second.fee, 2);
|
||||
assert.equal(second.closedAt, null);
|
||||
assert.equal(second.pnl, null);
|
||||
assert.equal(second.closePrice, null);
|
||||
});
|
||||
|
||||
test('fills that start inside a position: passed over, or carried on', () => {
|
||||
const close = fill('sell', 1, 110, { pnl: 200 });
|
||||
const a = fill('buy', 1, 100);
|
||||
const b = fill('sell', 1, 101, { pnl: 20 });
|
||||
// Nothing stored: the leading close belongs to nothing on record.
|
||||
const plain = fillsToTrades([close, a, b], { startNet: 1, pointValue: 20 });
|
||||
assert.equal(plain.length, 1);
|
||||
assert.equal(plain[0].id, a.id);
|
||||
// The stored open trade carries on and closes.
|
||||
const carry = { id: 'tsx:42', side: 'long', quantity: 1, openPrice: 100, fee: 0.5, openedAt: 10 };
|
||||
const [carried, next] = fillsToTrades([close, a, b], { startNet: 1, pointValue: 20, carry });
|
||||
assert.equal(carried.id, 'tsx:42');
|
||||
assert.equal(carried.carried, true);
|
||||
assert.equal(carried.openedAt, 10);
|
||||
assert.equal(carried.openOrderId, null);
|
||||
assert.equal(carried.pnl, 200);
|
||||
assert.equal(carried.fee, 1.5);
|
||||
assert.equal(carried.closedAt, close.at);
|
||||
assert.equal(next.id, a.id);
|
||||
// A carry on the other side isn't this position.
|
||||
assert.equal(fillsToTrades([close], { startNet: 1, pointValue: 20, carry: { ...carry, side: 'short' } }).length, 0);
|
||||
});
|
||||
|
||||
test('the position before the fills', () => {
|
||||
const buy = fill('buy', 1, 1);
|
||||
const sell = fill('sell', 1, 1);
|
||||
assert.equal(startingNet([buy, sell], 0), 0);
|
||||
assert.equal(startingNet([buy], 1), 0);
|
||||
assert.equal(startingNet([sell], 0), 1);
|
||||
assert.equal(startingNet([], -2), -2);
|
||||
});
|
||||
|
||||
test('fills sort by time, then numerically by id', () => {
|
||||
const a = { id: '9', at: 5 };
|
||||
const b = { id: '10', at: 5 };
|
||||
const c = { id: '1', at: 6 };
|
||||
assert.deepEqual([c, b, a].sort(byFillTime).map((f) => f.id), ['9', '10', '1']);
|
||||
});
|
||||
|
||||
test('contract roots and TopstepX names', () => {
|
||||
assert.equal(contractRoot('MNQZ6'), 'MNQ');
|
||||
assert.equal(contractRoot('ZNZ6'), 'ZN');
|
||||
assert.equal(contractRoot('NGZ6'), 'NG');
|
||||
assert.equal(contractRoot('6EZ6'), '6E');
|
||||
assert.equal(contractRoot('M2KH27'), 'M2K');
|
||||
assert.equal(contractRoot('CL'), 'CL');
|
||||
assert.equal(topstepxSymbol('CON.F.US.ENQ.Z26'), 'NQZ6');
|
||||
assert.equal(topstepxSymbol('CON.F.US.MNQ.H27'), 'MNQH7');
|
||||
assert.equal(topstepxSymbol('CON.F.US.XYZ.M26'), 'XYZM6');
|
||||
assert.equal(topstepxSymbol('something'), 'something');
|
||||
});
|
||||
|
||||
// DXtrade: a bot round trip on BTC/USD, as /orders/history and /transfers
|
||||
// answer (position-based account: the opening order's id is the position).
|
||||
const openOrder = {
|
||||
account: 'default:T247-1', orderId: 555, orderCode: 'af-mh2-abc-e', type: 'MARKET', instrument: 'BTC/USD',
|
||||
status: 'COMPLETED', finalStatus: true,
|
||||
legs: [{ instrument: 'BTC/USD', positionEffect: 'OPEN', side: 'BUY', quantity: 0.5, filledQuantity: 0.5, averagePrice: 60000 }],
|
||||
issueTime: '2026-10-26T01:00:00.000Z', transactionTime: '2026-10-26T01:00:01.000Z',
|
||||
executions: [{ transactionTime: '2026-10-26T01:00:00.500Z', lastQuantity: 0.5, averagePrice: 60000 }],
|
||||
};
|
||||
const stopOrder = {
|
||||
account: 'default:T247-1', orderId: 556, orderCode: 'af-mh3-def-sl', type: 'STOP', instrument: 'BTC/USD',
|
||||
status: 'COMPLETED', finalStatus: true,
|
||||
legs: [{ instrument: 'BTC/USD', positionEffect: 'CLOSE', positionCode: '555', side: 'SELL', filledQuantity: 0.5, averagePrice: 59000 }],
|
||||
issueTime: '2026-10-26T01:00:02.000Z', transactionTime: '2026-10-26T02:00:00.000Z', executions: [],
|
||||
};
|
||||
const txn = (type, value, orderCode) => ({ key: `${orderCode}|${type}`, account: 'default:T247-1', type, value, time: 0, orderCode });
|
||||
|
||||
test('DXtrade order history rows', () => {
|
||||
const o = parseHistoryOrder(openOrder);
|
||||
assert.equal(o.orderCode, 'af-mh2-abc-e');
|
||||
assert.equal(o.effect, 'OPEN');
|
||||
assert.equal(o.side, 'BUY');
|
||||
assert.equal(o.positionCode, undefined);
|
||||
assert.equal(o.qty, 0.5);
|
||||
assert.equal(o.price, 60000);
|
||||
assert.equal(o.at, Date.parse('2026-10-26T01:00:00.500Z'));
|
||||
const s = parseHistoryOrder(stopOrder);
|
||||
assert.equal(s.positionCode, '555');
|
||||
assert.equal(s.at, Date.parse('2026-10-26T02:00:00.000Z'));
|
||||
// Never filled: not a trade's.
|
||||
assert.equal(parseHistoryOrder({ ...openOrder, legs: [{ ...openOrder.legs[0], filledQuantity: 0 }] }), null);
|
||||
assert.equal(parseHistoryOrder({}), null);
|
||||
});
|
||||
|
||||
test('DXtrade positions from their orders and cash', () => {
|
||||
const orders = [parseHistoryOrder(stopOrder), parseHistoryOrder(openOrder)];
|
||||
const txns = [txn('SETTLEMENT', -500, 'af-mh3-def-sl'), txn('COMMISSION', -12, 'af-mh2-abc-e'), txn('COMMISSION', -11.8, 'af-mh3-def-sl')];
|
||||
const [t, ...rest] = dxTrades(orders, txns, new Map(), new Map());
|
||||
assert.equal(rest.length, 0);
|
||||
assert.equal(t.positionCode, '555');
|
||||
assert.equal(t.side, 'long');
|
||||
assert.equal(t.quantity, 0.5);
|
||||
assert.equal(t.openPrice, 60000);
|
||||
assert.equal(t.closePrice, 59000);
|
||||
assert.equal(t.pnl, -500);
|
||||
assert.equal(t.fee, 23.8);
|
||||
assert.equal(t.openCode, 'af-mh2-abc-e');
|
||||
assert.equal(t.closeCode, 'af-mh3-def-sl');
|
||||
assert.equal(t.closeType, 'STOP');
|
||||
|
||||
// Still held: open, whatever the history says.
|
||||
const held = new Map([['555', { symbol: 'BTC/USD', side: 'long', quantity: 0.5, openPrice: 60000, openedAt: 1 }]]);
|
||||
const [open] = dxTrades([parseHistoryOrder(openOrder)], [], held, new Map());
|
||||
assert.equal(open.closedAt, null);
|
||||
assert.equal(open.pnl, null);
|
||||
|
||||
// Opened before the history: completed from what's stored, or left out.
|
||||
const [late] = dxTrades([parseHistoryOrder(stopOrder)], txns, new Map(), held);
|
||||
assert.equal(late.openedAt, 1);
|
||||
assert.equal(late.openCode, null);
|
||||
assert.equal(late.pnl, -500);
|
||||
assert.equal(dxTrades([parseHistoryOrder(stopOrder)], txns, new Map(), new Map()).length, 0);
|
||||
|
||||
// A position open with no orders in the window still shows.
|
||||
const [quiet] = dxTrades([], [], held, new Map());
|
||||
assert.equal(quiet.positionCode, '555');
|
||||
assert.equal(quiet.openedAt, 1);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,813 @@
|
||||
// Checks for the Vest client's own wiring (lib/vest-class.ts), past what its
|
||||
// pure helpers show: the market-close guard closing a leg whose open came back
|
||||
// unknown, and the candidate board keeping a symbol the close gate holds back;
|
||||
// a sync older than the bot's own fill, an open a restart cut off, an outside
|
||||
// trade beside an open of unknown outcome, and the client's day at 8 PM ET;
|
||||
// the completed cycles a Vest day's claims end, named at each claim; a
|
||||
// refused account-token mint, a new account's history read again within the
|
||||
// minute, and exits that wait out a lapse in the session.
|
||||
//
|
||||
// npm run check
|
||||
//
|
||||
// A scratch database, a fake clock, and every gateway call answered here —
|
||||
// nothing reaches Vest.
|
||||
import { after, test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { registerHooks, stripTypeScriptTypes } from 'node:module';
|
||||
|
||||
// Resolve the extensionless local imports used by Next, without a build.
|
||||
function resolveTypes(specifier, context, nextResolve) {
|
||||
try {
|
||||
return nextResolve(specifier, context);
|
||||
} catch (err) {
|
||||
if (err.code !== 'ERR_MODULE_NOT_FOUND' || !specifier.startsWith('.')) throw err;
|
||||
return nextResolve(`${specifier}.ts`, context);
|
||||
}
|
||||
}
|
||||
// The client's classes use parameter properties, which plain type
|
||||
// stripping refuses: transform them instead.
|
||||
function loadTypes(url, context, nextLoad) {
|
||||
if (!url.startsWith('file:') || !url.endsWith('.ts')) return nextLoad(url, context);
|
||||
const source = stripTypeScriptTypes(readFileSync(fileURLToPath(url), 'utf8'), { mode: 'transform' });
|
||||
return { format: 'module', source, shortCircuit: true };
|
||||
}
|
||||
registerHooks({ resolve: resolveTypes, load: loadTypes });
|
||||
|
||||
const directory = mkdtempSync(join(tmpdir(), 'autofirmer-vest-client-'));
|
||||
process.env.AUTOFIRMER_DB = join(directory, 'test.sqlite');
|
||||
delete process.env.NEXT_PHASE;
|
||||
after(() => rmSync(directory, { recursive: true, force: true }));
|
||||
|
||||
// No request leaves this process: axios refuses every one, and each client's
|
||||
// gateway is a function in this file — from construction on, since the
|
||||
// constructor starts a sync.
|
||||
const { default: axios } = await import('../lib/http-client.ts');
|
||||
axios.defaults.adapter = (config) => Promise.reject(new Error(`no network in this test: ${config.method} ${config.url}`));
|
||||
const { VestClient } = await import('../lib/vest-class.ts');
|
||||
// The client's own transport, for the checks of what it does with an answer.
|
||||
const realRequest = VestClient.prototype.request;
|
||||
let gateway = null;
|
||||
VestClient.prototype.request = async function (bucket, method, path, opts = {}) {
|
||||
// Calls on an account's token reach the gateway here without one, so its
|
||||
// mint is answered here too.
|
||||
if (opts.mint) return { data: { accessToken: `eyJ.${opts.body.accountId}.sig`, accessExpiresAtMs: Date.now() + 600_000 } };
|
||||
if (!gateway) throw new Error(`no gateway in this test: ${method} ${path}`);
|
||||
return gateway(method, path, opts);
|
||||
};
|
||||
const { putVestSession, vestSessionFor, dropVestSession } = await import('../lib/vest-session.ts');
|
||||
const {
|
||||
botOpenedPositions, loadAccountMeta, saveAccountMeta, saveFundTransactions, wipeAccountData, loadClaimedCycles, saveClaimedCycles,
|
||||
} = await import('../lib/db.ts');
|
||||
const { vestDayOf, vestAccountId, VestApiError, VEST_STATUS_ACTIVE, resetSafeStop } = await import('../lib/vest-helpers.ts');
|
||||
const { encodeContractName } = await import('../lib/dxtrade-helpers.ts');
|
||||
const { tradeDateFor, stageInputsFor, resolveEffectiveConfig, claimedCycle, endedCycleCompletion } = await import('../lib/trading-logic.ts');
|
||||
const { isClientReady, isOrderNotPlaced } = await import('../lib/platform-client.ts');
|
||||
|
||||
const iso = (s) => Date.parse(s);
|
||||
const WALLET = '0x7ce10771819f80130e204ddb5ca1522e48a9b451';
|
||||
|
||||
// The clock the client reads (Date.now), set to an instant and running on.
|
||||
const realNow = Date.now;
|
||||
after(() => { Date.now = realNow; });
|
||||
let tokens = 0;
|
||||
function clockAt(when) {
|
||||
const start = realNow();
|
||||
const t0 = iso(when);
|
||||
Date.now = () => t0 + (realNow() - start);
|
||||
tokens += 1;
|
||||
assert.equal(putVestSession({ accessToken: `eyJ.test${tokens}.sig`, expiresAt: Date.now() + 3_600_000, primaryAddr: WALLET, userId: 'u' }), null);
|
||||
}
|
||||
|
||||
// The week of NDX hours from check-vest.mjs: maintenance at 21:00 UTC
|
||||
// (17:00 ET) Thursday, the weekly close at 21:00 UTC Friday.
|
||||
const open = (from, to) => ({ fromMs: iso(from), toMs: iso(to), state: { session: 'overnight', closure: 'unspecified', tradingStatus: 'OPEN', price: { allow: true } } });
|
||||
const shut = (from, to, closure) => ({ fromMs: iso(from), toMs: iso(to), state: { session: 'unspecified', closure, tradingStatus: closure === 'weekly' ? 'CLOSE' : 'OPEN', price: { allow: closure !== 'weekly' } } });
|
||||
const week = [
|
||||
open('2026-10-28T22:00:00Z', '2026-10-29T21:00:00Z'),
|
||||
shut('2026-10-29T21:00:00Z', '2026-10-29T22:00:00Z', 'maintenance'),
|
||||
open('2026-10-29T22:00:00Z', '2026-10-30T21:00:00Z'),
|
||||
shut('2026-10-30T21:00:00Z', '2026-11-01T23:00:00Z', 'weekly'),
|
||||
open('2026-11-01T23:00:00Z', '2026-11-02T22:00:00Z'),
|
||||
];
|
||||
const spec = (symbol, assetId, asset = 'stock') => ({
|
||||
symbol, assetId, sizeDecimals: 3, priceDecimals: 2, tick: 0.01, takerFee: 0.0001, marginRate: 0.05, tradingStatus: 'TRADING', capitalTradingStatus: 'TRADING', asset,
|
||||
});
|
||||
const specs = new Map([['NDX-USD', spec('NDX-USD', 7)], ['BTC-PERP', spec('BTC-PERP', 9, 'crypto')]]);
|
||||
|
||||
/** A client with no sync running, its gateway answered by `answer`, holding
|
||||
* acc-1 and acc-2 under `ids` (the ids a sync gives them: A and B). */
|
||||
function testClient(answer, ids = [1, 2]) {
|
||||
gateway = null;
|
||||
const client = new VestClient({ firmId: 'vest-test', firmName: 'Vest test', wallet: WALLET }, async () => {});
|
||||
clearInterval(client.syncInterval);
|
||||
clearInterval(client.watchdogInterval);
|
||||
gateway = answer;
|
||||
client.specs = specs;
|
||||
client.symbolSpecs = async () => specs;
|
||||
client.marketHours = async (assetId) => (assetId === 7 ? week : []);
|
||||
client.accountList = ids.map((id, i) => ({ id, name: `V50K-F-D3-${i + 1}`, active: true }));
|
||||
ids.forEach((id, i) => {
|
||||
client.uuidById.set(id, `acc-${i + 1}`);
|
||||
client.capital.set(id, { status: VEST_STATUS_ACTIVE });
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
const A = vestAccountId('acc-1');
|
||||
const B = vestAccountId('acc-2');
|
||||
const NDX = encodeContractName('NDX-USD', 0.001);
|
||||
const rawPosition = (positionId, accountId, openDate, extra = {}) => ({
|
||||
positionId, accountId, symbol: 'NDX-USD', side: 'LONG', quantity: '0.500', openPrice: '20000', collateral: '500', leverage: '20', openDate,
|
||||
stopLosses: [], takeProfits: [], ...extra,
|
||||
});
|
||||
|
||||
/** What a sync reads (acc-1 and acc-2 funded, $50k each, and the login's
|
||||
* main account; `positions` open), and the history reads after it. */
|
||||
function syncReads(method, path, positions) {
|
||||
if (method !== 'GET') return null;
|
||||
if (path === '/v3/capital/accounts/active') {
|
||||
return { data: { accounts: ['acc-1', 'acc-2'].map((id, i) => ({
|
||||
id, status: VEST_STATUS_ACTIVE, attempt_index: i + 1, initial_capital: '50000', max_daily_loss_pct: '0.03', stage: 1, stages: [{ ordinal: 1, kind: 'funded' }],
|
||||
})) } };
|
||||
}
|
||||
if (path === '/v3/accounts') {
|
||||
return { data: { accounts: [
|
||||
{ account_id: 'acc-1', amount: '50000', account_type: 2 }, { account_id: 'acc-2', amount: '50000', account_type: 2 }, { account_id: 'main', amount: '0', account_type: 1 },
|
||||
] } };
|
||||
}
|
||||
if (path === '/v3/positions/opened') return { data: { positions } };
|
||||
if (path === '/v3/positions/opened-orders') return { data: { orders: [] } };
|
||||
if (path === '/v3/ticker/latest') return { data: { tickers: [] } };
|
||||
if (path === '/v3/positions') return { data: { positions: [] } };
|
||||
return null;
|
||||
}
|
||||
|
||||
/** testClient on the ids a sync gives, once the constructor's own sync
|
||||
* (which reaches no gateway) is over, with its history taken as read — so
|
||||
* each requestSync here is one good sync, and starts nothing else. */
|
||||
async function syncingClient(answer) {
|
||||
const client = testClient(answer, [A, B]);
|
||||
await client.syncRunning;
|
||||
client.historyLoaded = true;
|
||||
client.historyLoadedIds = new Set([A, B]);
|
||||
client.lastDaysFetchStartedAt = Date.now();
|
||||
client.lastDaysFetchSucceededAt = Infinity;
|
||||
return client;
|
||||
}
|
||||
|
||||
const unanswered = (method, path) => { throw new Error(`no gateway in this test: ${method} ${path}`); };
|
||||
|
||||
test('the market-close guard closes a bot leg whose open came back unknown, not a hand trade', async () => {
|
||||
let positions = [];
|
||||
const closes = [];
|
||||
const client = testClient((method, path, opts) => {
|
||||
if (method === 'GET' && path === '/v3/positions/opened') return { data: { positions } };
|
||||
if (method === 'POST' && path === '/v3/positions/open') throw new Error('socket hang up');
|
||||
if (method === 'POST' && path === '/v3/positions/close') { closes.push({ account: opts.account, ...opts.body }); return { data: {} }; }
|
||||
throw new Error(`no gateway in this test: ${method} ${path}`);
|
||||
});
|
||||
try {
|
||||
// Friday 8:30 AM ET: the entry's send fails in transit, but it filled.
|
||||
clockAt('2026-10-30T12:30:00Z');
|
||||
await assert.rejects(client.sendOrder(1, encodeContractName('NDX-USD', 0.001), 500, 'Buy', 'Market'), /outcome unknown/);
|
||||
const raw = (positionId, accountId, openDate) => ({
|
||||
positionId, accountId, symbol: 'NDX-USD', side: 'LONG', quantity: '0.500', openPrice: '20000', collateral: '500', leverage: '20', openDate,
|
||||
stopLosses: [], takeProfits: [],
|
||||
});
|
||||
positions = [raw('9001', 'acc-1', iso('2026-10-30T12:30:01Z')), raw('9002', 'acc-2', iso('2026-10-30T11:00:00Z'))];
|
||||
client.positionsDetail.set(1, []);
|
||||
client.positionsDetail.set(2, [{ positionId: '9002', accountId: 'acc-2', symbol: 'NDX-USD', side: 'long', quantity: 0.5 }]);
|
||||
client.syncComplete = true;
|
||||
|
||||
// 4:56 PM ET: four minutes to the weekly close.
|
||||
clockAt('2026-10-30T20:56:00Z');
|
||||
await client.guardClose();
|
||||
assert.deepEqual(closes, [{ account: 'acc-1', positionId: '9001', symbol: 'NDX-USD', orderType: 'market' }]);
|
||||
assert.deepEqual([...botOpenedPositions(['9001', '9002'])], ['9001']);
|
||||
// A close just sent isn't sent again while a read may not show it.
|
||||
await client.guardClose();
|
||||
assert.equal(closes.length, 1);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('a symbol the close gate holds back stays on the board, and isn\'t picked; the maintenance hour is ridden', async () => {
|
||||
const client = testClient((method, path) => {
|
||||
throw new Error(`no gateway in this test: ${method} ${path}`);
|
||||
});
|
||||
client.refreshMarks = async () => {};
|
||||
client.volatility = async (symbols) => new Map(symbols.map((s) => [s, { vol: 0.004, candles: 168 }]));
|
||||
const marked = () => { for (const s of ['NDX-USD', 'BTC-PERP']) client.marks.set(s, { mark: s === 'NDX-USD' ? 20000 : 70000, at: Date.now() }); };
|
||||
try {
|
||||
// Thursday 2 PM ET: NDX's maintenance hour is 3 h off, and reopens
|
||||
// before the reset — no gate.
|
||||
clockAt('2026-10-29T18:00:00Z');
|
||||
marked();
|
||||
await client.pickCandidate('Auto', ['NDX-USD', 'BTC-PERP'], false);
|
||||
const thursday = client.lastCandidates.find((c) => c.id === 'NDX-USD');
|
||||
assert.equal(thursday.closesAt, undefined);
|
||||
|
||||
// Friday 2 PM ET: the weekly close is 3 h off; crypto never closes.
|
||||
clockAt('2026-10-30T18:00:00Z');
|
||||
marked();
|
||||
const pick = await client.pickCandidate('Auto', ['NDX-USD', 'BTC-PERP'], false);
|
||||
assert.equal(pick.id, 'BTC-PERP');
|
||||
assert.equal(pick.closesAt, undefined);
|
||||
assert.deepEqual(client.lastCandidates.map((c) => c.id).sort(), ['BTC-PERP', 'NDX-USD']);
|
||||
const ndx = client.lastCandidates.find((c) => c.id === 'NDX-USD');
|
||||
assert.equal(ndx.closesAt, iso('2026-10-30T21:00:00Z'));
|
||||
// Crypto's stops keep the engine's slippage share; NDX's is measured.
|
||||
assert.equal(pick.stopSlippageFraction, 0.001);
|
||||
assert.equal(ndx.stopSlippageFraction, 0.0002);
|
||||
// The survey keeps it up for the dashboard, though nothing is picked.
|
||||
assert.equal(await client.pickCandidate('survey', ['NDX-USD'], false), null);
|
||||
assert.deepEqual(client.lastCandidates.map((c) => c.id), ['NDX-USD']);
|
||||
// And the client's day is Vest's.
|
||||
assert.equal(client.tradeDate(iso('2026-10-27T22:30:00Z')), vestDayOf(iso('2026-10-27T22:30:00Z')));
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test("a sync whose positions read predates the bot's own fill leaves the fill's record alone", async () => {
|
||||
let positions = [];
|
||||
let holdRead = false;
|
||||
let releaseRead = null;
|
||||
clockAt('2026-10-28T14:00:00Z');
|
||||
const client = await syncingClient((method, path) => {
|
||||
if (method === 'GET' && path === '/v3/positions/opened' && holdRead) {
|
||||
// Read now, flat — answered once released.
|
||||
holdRead = false;
|
||||
const served = positions;
|
||||
return new Promise((resolve) => { releaseRead = () => resolve({ data: { positions: served } }); });
|
||||
}
|
||||
const read = syncReads(method, path, positions);
|
||||
if (read) return read;
|
||||
if (method === 'POST' && path === '/v3/positions/open') {
|
||||
positions = [rawPosition('9101', 'acc-1', Date.now() + 300)];
|
||||
return { data: { orderId: 'o-9101', positionId: '9101' } };
|
||||
}
|
||||
return unanswered(method, path);
|
||||
});
|
||||
try {
|
||||
await client.requestSync();
|
||||
// A sync reads acc-1 flat; the entry fills while it is still out.
|
||||
holdRead = true;
|
||||
const sync = client.requestSync();
|
||||
await client.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open');
|
||||
// Filled: the note kept against a restart goes.
|
||||
assert.equal(loadAccountMeta(A, 'vest_pending_open'), 'null');
|
||||
releaseRead();
|
||||
await sync;
|
||||
assert.deepEqual(client.positionsDetail.get(A).map((p) => p.positionId), ['9101']);
|
||||
assert.equal(client.positions[A].netPos, 0.5);
|
||||
// A read lagging the fill: the leg's flatten is refused as a position
|
||||
// still out there (FLATTEN FAILED), not taken as nothing to flatten.
|
||||
positions = [];
|
||||
await assert.rejects(client.sendOrder(A, NDX, 500, 'Sell', 'Market', undefined, 'close'), /last saw a long one open/);
|
||||
// A sync that went out after the fill writes what it reads.
|
||||
await client.requestSync();
|
||||
assert.deepEqual(client.positionsDetail.get(A), []);
|
||||
assert.equal(client.positions[A], undefined);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test("an open a restart cut off is marked the bot's once a read after the restart shows it", async () => {
|
||||
let positions = [];
|
||||
let posted;
|
||||
const postedAt = new Promise((resolve) => { posted = resolve; });
|
||||
clockAt('2026-10-28T15:00:00Z');
|
||||
const before = await syncingClient((method, path) => {
|
||||
const read = syncReads(method, path, positions);
|
||||
if (read) return read;
|
||||
// The app stops with the open out.
|
||||
if (method === 'POST' && path === '/v3/positions/open') { posted(Date.now()); return new Promise(() => {}); }
|
||||
return unanswered(method, path);
|
||||
});
|
||||
void before.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open').catch(() => {});
|
||||
const sentAt = await postedAt;
|
||||
before.disconnect();
|
||||
|
||||
// Vest filled it, and an outside long opened on acc-2 with it. acc-2 has
|
||||
// an older note, of a short that never showed.
|
||||
positions = [rawPosition('9201', 'acc-1', sentAt + 400), rawPosition('9202', 'acc-2', sentAt + 400)];
|
||||
saveAccountMeta(B, 'vest_pending_open', JSON.stringify({ accountId: 'acc-2', symbol: 'NDX-USD', side: 'short', sentAt: sentAt - 10 * 60_000 }));
|
||||
clockAt('2026-10-28T15:02:00Z');
|
||||
let refuse = false;
|
||||
const after = await syncingClient((method, path) => {
|
||||
const read = syncReads(method, path, positions);
|
||||
if (read) return read;
|
||||
if (method === 'POST' && path === '/v3/positions/open' && refuse) throw new VestApiError('/v3/positions/open: refused (HTTP 400)', 400, 'REFUSED');
|
||||
return unanswered(method, path);
|
||||
});
|
||||
try {
|
||||
await after.requestSync();
|
||||
assert.deepEqual([...botOpenedPositions(['9201', '9202'])], ['9201']);
|
||||
assert.equal(loadAccountMeta(A, 'vest_pending_open'), 'null');
|
||||
assert.equal(loadAccountMeta(B, 'vest_pending_open'), 'null');
|
||||
// An open Vest refuses leaves no note behind.
|
||||
positions = [];
|
||||
refuse = true;
|
||||
await assert.rejects(after.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open'), /refused/);
|
||||
assert.equal(loadAccountMeta(A, 'vest_pending_open'), 'null');
|
||||
} finally {
|
||||
after.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test("an open of unknown outcome isn't matched to an outside trade after its error, nor to one of several", async () => {
|
||||
let positions = [];
|
||||
let open = null;
|
||||
let onRead = () => {};
|
||||
clockAt('2026-10-28T16:00:00Z');
|
||||
const client = await syncingClient((method, path) => {
|
||||
if (method === 'GET' && path === '/v3/positions/opened') onRead();
|
||||
const read = syncReads(method, path, positions);
|
||||
if (read) return read;
|
||||
if (method === 'POST' && path === '/v3/positions/open') return open();
|
||||
return unanswered(method, path);
|
||||
});
|
||||
try {
|
||||
await client.requestSync();
|
||||
// The entry's send fails in transit, and it never went in. Half a
|
||||
// minute on, an outside copy-trader opens NDX long on every account.
|
||||
open = () => { throw new Error('socket hang up'); };
|
||||
await assert.rejects(client.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open'), /outcome unknown/);
|
||||
const errorAt = Date.now();
|
||||
positions = [rawPosition('9301', 'acc-1', errorAt + 30_000), rawPosition('9302', 'acc-2', errorAt + 30_000)];
|
||||
clockAt('2026-10-28T16:01:00Z');
|
||||
await client.requestSync();
|
||||
assert.deepEqual([...botOpenedPositions(['9301', '9302'])], []);
|
||||
assert.equal(loadAccountMeta(A, 'vest_pending_open'), 'null');
|
||||
|
||||
// Another on acc-2, and two positions show from that moment: either
|
||||
// could be the bot's, so neither is marked.
|
||||
positions = [];
|
||||
clockAt('2026-10-28T16:05:00Z');
|
||||
open = () => {
|
||||
positions = [rawPosition('9303', 'acc-2', Date.now() + 100), rawPosition('9304', 'acc-2', Date.now() + 150)];
|
||||
throw new Error('socket hang up');
|
||||
};
|
||||
await assert.rejects(client.sendOrder(B, NDX, 500, 'Buy', 'Market', undefined, 'open'), /outcome unknown/);
|
||||
await client.requestSync();
|
||||
assert.deepEqual([...botOpenedPositions(['9303', '9304'])], []);
|
||||
|
||||
// An open whose answer named its position, though no read showed it
|
||||
// within the wait: that position is the bot's, even with a stop a
|
||||
// hand has put on it since.
|
||||
positions = [];
|
||||
clockAt('2026-10-28T16:10:00Z');
|
||||
let answered = false;
|
||||
open = () => { answered = true; return { data: { orderId: 'o-9305', positionId: '9305' } }; };
|
||||
// The wait's first read comes back past its deadline.
|
||||
onRead = () => { if (answered) { answered = false; clockAt('2026-10-28T16:10:45Z'); } };
|
||||
await assert.rejects(client.sendOrder(A, NDX, 500, 'Buy', 'Market', undefined, 'open'), /not seen filled/);
|
||||
const stop = { id: 'sl-9305', triggerPrice: '19900', executionType: 'market', status: 'awaiting' };
|
||||
positions = [rawPosition('9305', 'acc-1', iso('2026-10-28T16:10:00Z') + 500, { stopLosses: [stop] })];
|
||||
await client.requestSync();
|
||||
assert.deepEqual([...botOpenedPositions(['9305'])], ['9305']);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test("the client's Vest day turns with its numbers, and a sync that stalls stops the trading it holds back", async () => {
|
||||
clockAt('2026-10-27T23:58:00Z');
|
||||
const client = await syncingClient((method, path) => syncReads(method, path, []) ?? unanswered(method, path));
|
||||
// The day the engine plans the lane on, at this test's clock.
|
||||
const engineDay = () => tradeDateFor(client, new Date(Date.now()));
|
||||
try {
|
||||
// 7:58 PM ET, before any sync: the day now.
|
||||
assert.equal(engineDay(), '2026-10-27');
|
||||
await client.requestSync();
|
||||
// 8:00:20 PM ET: Vest's day has turned, but the numbers the last sync
|
||||
// worked out are still the ended day's — and so is the engine's day.
|
||||
clockAt('2026-10-28T00:00:20Z');
|
||||
assert.equal(vestDayOf(Date.now()), '2026-10-28');
|
||||
assert.equal(engineDay(), '2026-10-27');
|
||||
assert.equal(isClientReady(client), true);
|
||||
// Nothing opens on the ended day's numbers.
|
||||
const opening = await client.sendOrder(A, encodeContractName('NDX-USD', 0.001), 500, 'Buy', 'Market', undefined, 'open').catch((err) => err);
|
||||
assert.equal(isOrderNotPlaced(opening), true);
|
||||
assert.match(opening.message, /no sync has read it yet/);
|
||||
await client.requestSync();
|
||||
assert.equal(engineDay(), '2026-10-28');
|
||||
// No sync after the next reset: the day holds back only while the
|
||||
// scheduler still trades on the last sync's numbers.
|
||||
clockAt('2026-10-28T23:59:00Z');
|
||||
await client.requestSync();
|
||||
clockAt('2026-10-29T00:03:00Z');
|
||||
assert.equal(engineDay(), '2026-10-28');
|
||||
assert.equal(isClientReady(client), true);
|
||||
clockAt('2026-10-29T00:05:00Z');
|
||||
assert.equal(engineDay(), '2026-10-28');
|
||||
assert.equal(isClientReady(client), false);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// A timeout: an ask let through twice leaves the first one waiting forever.
|
||||
test("a passed evaluation doesn't hold the sync, and its funded account is asked for once", { timeout: 10_000 }, async () => {
|
||||
clockAt('2026-10-29T15:31:00Z');
|
||||
// Vest's status for each account (acc-2 passed), and the balance Vest's
|
||||
// active list leaves out — a passed one's.
|
||||
const status = { 'acc-1': 2, 'acc-2': 3 };
|
||||
let missingBalance = 'acc-2';
|
||||
let activation = null;
|
||||
const sent = [];
|
||||
const client = await syncingClient((method, path, opts) => {
|
||||
if (method === 'GET' && path === '/v3/capital/accounts/active') {
|
||||
return { data: { accounts: Object.keys(status).map((id, i) => ({
|
||||
id, status: status[id], account_type: 2, stage: 1, step: 1, steps_total: 1, plan_product_type: 'eval',
|
||||
attempt_index: i + 80, initial_capital: '50000', max_daily_loss_pct: '0.03',
|
||||
stages: [{ ordinal: 1, kind: 'eval', profit_target: '5000' }, { ordinal: 2, kind: 'funded', profit_target: '' }],
|
||||
})) } };
|
||||
}
|
||||
if (method === 'GET' && path === '/v3/accounts') {
|
||||
return { data: { accounts: [
|
||||
{ account_id: 'acc-1', amount: '50000', account_type: 2 }, { account_id: 'acc-2', amount: '55100', account_type: 2 }, { account_id: 'main', amount: '0', account_type: 1 },
|
||||
].filter((b) => b.account_id !== missingBalance) } };
|
||||
}
|
||||
if (method === 'POST' && path === '/v3/capital/activate-funded') {
|
||||
sent.push({ account: opts.account, body: opts.body });
|
||||
return activation();
|
||||
}
|
||||
return syncReads(method, path, []) ?? unanswered(method, path);
|
||||
});
|
||||
const funded = { id: 'acc-2-funded', status: 2, account_type: 3, stage: 2, attempt_index: 81, initial_capital: '50000', max_daily_loss_pct: '0.03', stages: [{ ordinal: 1, kind: 'eval' }, { ordinal: 2, kind: 'funded' }] };
|
||||
// The sync an ask starts behind it, run to its end.
|
||||
const settled = async () => {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await client.syncRunning;
|
||||
};
|
||||
try {
|
||||
await client.requestSync();
|
||||
assert.ok(client.lastSyncAt > 0, "the passed account's missing balance didn't make the sync short of good");
|
||||
assert.equal(client.accountList.find((a) => a.id === B).active, false);
|
||||
assert.deepEqual(client.activatable(), [B]);
|
||||
await assert.rejects(client.activateFunded(A), /only a passed evaluation/);
|
||||
|
||||
// While the ask is out, a second caller is refused, and a sync reads
|
||||
// a list from before Vest's answer: it isn't asked again on it.
|
||||
let answer;
|
||||
activation = () => new Promise((resolve) => { answer = resolve; });
|
||||
const asking = client.activateFunded(B);
|
||||
await assert.rejects(client.activateFunded(B), /hasn't been read back/);
|
||||
clockAt('2026-10-29T15:31:30Z');
|
||||
await client.requestSync();
|
||||
assert.deepEqual(client.activatable(), []);
|
||||
answer({ data: { account: funded } });
|
||||
assert.deepEqual(await asking, { funded: 'V50K-F-D3-82' }); // attempt_index 81 = Account 82
|
||||
assert.deepEqual(sent, [{ account: 'acc-2', body: { eval_account_id: 'acc-2' } }]);
|
||||
|
||||
// Answered: never asked again, while Vest's list is slow to move it
|
||||
// or once it's claimed.
|
||||
await settled();
|
||||
clockAt('2026-10-29T15:40:00Z');
|
||||
await client.requestSync();
|
||||
assert.deepEqual(client.activatable(), []);
|
||||
status['acc-2'] = 6;
|
||||
await client.requestSync();
|
||||
assert.deepEqual(client.activatable(), []);
|
||||
assert.equal(sent.length, 1);
|
||||
|
||||
// acc-1 passes too. A timeout may have gone in: held until a list
|
||||
// read a minute after it. A refusal went nowhere: free at once.
|
||||
status['acc-1'] = 3;
|
||||
missingBalance = 'acc-1';
|
||||
await client.requestSync();
|
||||
assert.deepEqual(client.activatable(), [A]);
|
||||
activation = () => { throw new Error('/v3/capital/activate-funded timed out after 15s'); };
|
||||
await assert.rejects(client.activateFunded(A), /timed out/);
|
||||
await settled();
|
||||
assert.deepEqual(client.activatable(), []);
|
||||
clockAt('2026-10-29T15:40:30Z');
|
||||
await client.requestSync();
|
||||
assert.deepEqual(client.activatable(), []);
|
||||
clockAt('2026-10-29T15:41:10Z');
|
||||
await client.requestSync();
|
||||
assert.deepEqual(client.activatable(), [A]);
|
||||
activation = () => { throw new VestApiError('/v3/capital/activate-funded: funded limit reached (HTTP 400)', 400); };
|
||||
await assert.rejects(client.activateFunded(A), /funded limit/);
|
||||
assert.deepEqual(client.activatable(), [A]);
|
||||
assert.equal(sent.length, 3);
|
||||
|
||||
// A live account's missing balance still holds the sync.
|
||||
status['acc-1'] = 2;
|
||||
const good = client.lastSyncAt;
|
||||
clockAt('2026-10-29T15:45:00Z');
|
||||
await client.requestSync();
|
||||
assert.equal(client.lastSyncAt, good);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('two payouts on one Vest day share its fund row, and still count two toward the stage', async () => {
|
||||
clockAt('2026-10-27T23:00:00Z');
|
||||
const client = await syncingClient(unanswered);
|
||||
try {
|
||||
// $1,500 then $3,000 taken on Vest day 10-27: Vest's two WITHDRAWs, one row.
|
||||
client.withdrawals.set(A, [{ at: iso('2026-10-27T15:00:00Z'), amount: -1500 }, { at: iso('2026-10-27T19:00:00Z'), amount: -3000 }]);
|
||||
client.fundTransactions[A] = [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-27', amount: -4500 }];
|
||||
assert.deepEqual(client.payoutDays(A), ['2026-10-27', '2026-10-27']);
|
||||
// A type whose stages don't reset each day trades stage 3's $2,000, not stage 2's again.
|
||||
const stages = [{ profit: 3000, consistency: 0, minTradingDays: 0 }, { profit: 2000, consistency: 0, minTradingDays: 0 }];
|
||||
const inputs = stageInputsFor(client, A, { stagesSameDay: false });
|
||||
assert.equal(inputs.payouts, 2);
|
||||
assert.equal(resolveEffectiveConfig(1500, 0, 0, false, stages, 0, client.fundTransactions[A], 0, inputs).profitTarget, 2000);
|
||||
// Before its history is read: one per negative fund row.
|
||||
client.historyLoadedIds.delete(A);
|
||||
assert.equal(stageInputsFor(client, A, {}).payouts, undefined);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('each claim of a Vest day names the completed cycle it ends, the second and the one after the reset too', async () => {
|
||||
// acc-1, funded $50k, Vest's own numbers: its balance, and its reset balance since 20:00 ET.
|
||||
let balance = 51500;
|
||||
let reset = { at: '2026-10-27T00:00:00Z', balance: 50000 };
|
||||
clockAt('2026-10-27T19:00:00Z'); // 3 PM ET, Vest day 10-27
|
||||
wipeAccountData(A);
|
||||
const client = await syncingClient((method, path, opts) => {
|
||||
const read = syncReads(method, path, []);
|
||||
if (read && path === '/v3/capital/accounts/active') {
|
||||
read.data.accounts[0] = { ...read.data.accounts[0], daily_reset_at: reset.at, daily_reset_balance: String(reset.balance) };
|
||||
}
|
||||
if (read && path === '/v3/accounts') read.data.accounts[0] = { ...read.data.accounts[0], amount: String(balance) };
|
||||
if (read) return read;
|
||||
if (method === 'POST' && path === '/v3/capital/withdraw' && opts.body.account_id === 'acc-1') {
|
||||
balance = Math.round((balance - Number(opts.body.amount)) * 100) / 100;
|
||||
return { data: { trader_amount: opts.body.amount } };
|
||||
}
|
||||
return unanswered(method, path);
|
||||
});
|
||||
// The history read a claim starts isn't this test's: what it books is set here.
|
||||
client.fetchDaysTraded = async () => ({ failedAccounts: 0 });
|
||||
const cfg = {
|
||||
profitTarget: 1500, consistency: 0, minDayPnL: 0, minTradingDays: 0, accountSize: 50000, targetSameEquity: false,
|
||||
splitEnabled: true, payoutable: true, tradesEveryDay: true, combineCmeHolidays: false, stagesSameDay: true, dailyLossLimit: 1500,
|
||||
withdrawalStages: [{ profit: 3000, consistency: 0, minTradingDays: 0, dailyLossLimit: 3000 }],
|
||||
};
|
||||
/** The day's trading so far, as the history read books it, and Vest's WITHDRAWs for the claims made. */
|
||||
const history = (dayPnl) => {
|
||||
client.fullDailyPnL[A] = [{ date: '2026-10-27', pnl: dayPnl }];
|
||||
client.applyCycleSlice(A);
|
||||
client.withdrawals.set(A, client.claimsOf(A).map((c) => ({ at: c.at, amount: -c.amount })));
|
||||
};
|
||||
/** The cycle a claim now would name, stamped with the claim's time. */
|
||||
const named = () => {
|
||||
const now = Date.now();
|
||||
const c = claimedCycle(client, A, cfg, new Date(now));
|
||||
if (c) { assert.equal(c.at, now); delete c.at; }
|
||||
return c;
|
||||
};
|
||||
try {
|
||||
saveFundTransactions(A, 'V50K-F-D3-1', [{ date: '2026-10-20', amount: 50000 }]);
|
||||
saveAccountMeta(A, 'last_fund_date', '2026-10-20');
|
||||
client.fundTransactions[A] = [{ date: '2026-10-20', amount: 50000 }];
|
||||
client.lastFundDates[A] = '2026-10-20';
|
||||
history(1500);
|
||||
await client.requestSync();
|
||||
|
||||
// Stage 1's $1,500, claimed: the account's first cycle.
|
||||
assert.deepEqual(named(), { date: '2026-10-27', stage: 1, drain: false, cycle: '2026-10-20', cycleSeq: 0 });
|
||||
await client.claimProfit(A, 1500);
|
||||
await client.syncRunning; // the sync the claim starts
|
||||
|
||||
// 6 PM ET: stage 2's $3,000 made, and claimed the same Vest day — its
|
||||
// payout books into 10-27's row, and the latest fund date stays.
|
||||
clockAt('2026-10-27T22:00:00Z');
|
||||
balance = 53000;
|
||||
history(4500);
|
||||
await client.requestSync();
|
||||
assert.deepEqual(named(), { date: '2026-10-27', stage: 2, drain: false, cycle: '2026-10-27', cycleSeq: 0 });
|
||||
await client.claimProfit(A, 3000);
|
||||
await client.syncRunning;
|
||||
assert.equal(client.lastFundDates[A], '2026-10-27');
|
||||
assert.deepEqual(client.fundTransactions[A].at(-1), { date: '2026-10-27', amount: -4500 });
|
||||
|
||||
// 6:30 PM ET: the day's third cycle, $1,500 of stage 2's $3,000 — not complete.
|
||||
clockAt('2026-10-27T22:30:00Z');
|
||||
balance = 51500;
|
||||
history(6000);
|
||||
await client.requestSync();
|
||||
assert.equal(named(), null);
|
||||
|
||||
// 8:00:30 PM ET: the reset puts it back on stage 1's $1,500, met —
|
||||
// claimed at the first tick.
|
||||
clockAt('2026-10-28T00:00:30Z');
|
||||
reset = { at: '2026-10-28T00:00:00Z', balance: 51500 };
|
||||
await client.requestSync();
|
||||
assert.equal(tradeDateFor(client, new Date(Date.now())), '2026-10-28');
|
||||
assert.deepEqual(named(), { date: '2026-10-28', stage: 3, drain: false, cycle: '2026-10-27', cycleSeq: 1 });
|
||||
await client.claimProfit(A, 1500);
|
||||
await client.syncRunning;
|
||||
assert.equal(client.lastFundDates[A], '2026-10-28');
|
||||
|
||||
// The catch-up names none of it: the cycle's P&L sits in 10-27's row,
|
||||
// which the cycle before owns.
|
||||
clockAt('2026-10-28T00:10:00Z');
|
||||
await client.requestSync();
|
||||
assert.equal(endedCycleCompletion({
|
||||
accountId: A, rawTradeDate: tradeDateFor(client, new Date(Date.now())), client,
|
||||
fullDailyPnL: client.fullDailyPnL[A], cycleDailyPnL: client.dailyPnL[A], fundTransactions: client.fundTransactions[A],
|
||||
lastFundDate: client.lastFundDates[A], endedCycleStart: JSON.parse(loadAccountMeta(A, 'ended_cycle_start')),
|
||||
rawRealizedPnL: client.accountCashBalances[A].realizedPnL, equityProfit: client.accountCashBalances[A].amount - 50000, cfg,
|
||||
now: new Date(Date.now()),
|
||||
}), null);
|
||||
|
||||
// Kept as the claims leave them; an entry that doesn't read is left out.
|
||||
const kept = [{ date: '2026-10-27', stage: 2, drain: false, cycle: '2026-10-27', cycleSeq: 0, at: iso('2026-10-27T22:00:00Z') }];
|
||||
saveClaimedCycles(A, [...kept, { date: '2026-10-28', stage: '3', drain: false, cycle: '2026-10-27', cycleSeq: 1 }]);
|
||||
assert.deepEqual(loadClaimedCycles(A), kept);
|
||||
saveAccountMeta(A, 'claimed_cycles', '{');
|
||||
assert.deepEqual(loadClaimedCycles(A), []);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test("a refused account-token mint fails only that account: the login's session stays, and the next account's reads go out", async () => {
|
||||
clockAt('2026-10-29T05:41:00Z');
|
||||
const client = testClient(unanswered, [A, B]);
|
||||
await client.syncRunning;
|
||||
// The client's own transport, on a gateway that refuses acc-1's mint as
|
||||
// Vest did all night on 2026-10-28 (401, code 1002).
|
||||
client.request = realRequest;
|
||||
const adapter = axios.defaults.adapter;
|
||||
const sent = [];
|
||||
axios.defaults.adapter = async (config) => {
|
||||
const path = config.url.replace(/^https:\/\/[^/]+/, '');
|
||||
const body = typeof config.data === 'string' ? JSON.parse(config.data) : undefined;
|
||||
sent.push(`${config.method.toUpperCase()} ${path}${body?.accountId ? ` ${body.accountId}` : ''}`);
|
||||
const reply = (status, data) => ({ status, statusText: '', headers: {}, config, data });
|
||||
if (path === '/v3/auth/account-token') {
|
||||
return body.accountId === 'acc-1'
|
||||
? reply(401, { detail: { code: 1002, msg: 'Unauthorized' } })
|
||||
: reply(200, { accessToken: `eyJ.${body.accountId}.sig`, accessExpiresAtMs: Date.now() + 600_000 });
|
||||
}
|
||||
if (path === '/v3/transactions') return reply(200, { transactions: [] });
|
||||
if (path === '/v3/positions') return reply(200, { positions: [] });
|
||||
return reply(404, {});
|
||||
};
|
||||
try {
|
||||
const { failedAccounts } = await client.fetchDaysTraded([A, B]);
|
||||
assert.equal(failedAccounts, 1);
|
||||
assert.notEqual(vestSessionFor(WALLET), null);
|
||||
assert.equal(client.historyLoadedIds.has(A), false);
|
||||
assert.equal(client.historyLoadedIds.has(B), true);
|
||||
assert.match(client.lastFetchErrors['V50K-F-D3-1'], /code 1002/);
|
||||
assert.ok(sent.includes('POST /v3/auth/account-token acc-2'));
|
||||
assert.ok(sent.includes('GET /v3/transactions'));
|
||||
} finally {
|
||||
axios.defaults.adapter = adapter;
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('an active account with no history is read again within a minute, while a whole pass waits out its retry', async () => {
|
||||
clockAt('2026-10-29T05:41:00Z');
|
||||
const C = vestAccountId('acc-3');
|
||||
const client = testClient(unanswered, [A, B, C]);
|
||||
await client.syncRunning;
|
||||
// acc-3 isn't active: it waits for a whole pass.
|
||||
client.capital.set(C, { status: 3 });
|
||||
client.accountList[2].active = false;
|
||||
client.historyLoaded = true;
|
||||
client.historyLoadedIds = new Set([A]);
|
||||
client.lastDaysFetchStartedAt = Date.now() - 120_000;
|
||||
// A whole pass failed: its retry is four minutes off.
|
||||
client.nextDaysFetchRetryAt = Date.now() + 4 * 60_000;
|
||||
const reads = [];
|
||||
client.fetchDaysTraded = async (ids) => {
|
||||
reads.push(ids);
|
||||
return { failedAccounts: ids ? ids.length : 0 };
|
||||
};
|
||||
const settled = () => new Promise((resolve) => setImmediate(resolve));
|
||||
try {
|
||||
client.maybeFetchDaysTraded();
|
||||
await settled();
|
||||
assert.deepEqual(reads, [[B]]);
|
||||
// It failed again: not before the minute is up, and the whole pass's
|
||||
// retry stays where it was.
|
||||
client.maybeFetchDaysTraded();
|
||||
await settled();
|
||||
assert.deepEqual(reads, [[B]]);
|
||||
clockAt('2026-10-29T05:42:01Z');
|
||||
client.maybeFetchDaysTraded();
|
||||
await settled();
|
||||
assert.deepEqual(reads, [[B], [B]]);
|
||||
clockAt('2026-10-29T05:45:01Z');
|
||||
client.maybeFetchDaysTraded();
|
||||
await settled();
|
||||
assert.deepEqual(reads, [[B], [B], undefined]);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test("a pass reads the accounts with no history first and the inactive last, and an inactive one's failure isn't the pass's", async () => {
|
||||
clockAt('2026-10-29T05:41:00Z');
|
||||
const C = vestAccountId('acc-3');
|
||||
const client = testClient(unanswered, [A, B, C]);
|
||||
await client.syncRunning;
|
||||
client.capital.set(C, { status: 3 });
|
||||
client.accountList[2].active = false;
|
||||
client.historyLoadedIds = new Set([A]);
|
||||
const order = [];
|
||||
client.fetchTxns = async (uuid) => {
|
||||
order.push(uuid);
|
||||
if (uuid === 'acc-3') throw new Error('/v3/transactions not sent — no account token: Unauthorized (HTTP 401, code 1002)');
|
||||
return [];
|
||||
};
|
||||
client.fetchTrades = async () => {};
|
||||
try {
|
||||
const { failedAccounts } = await client.fetchDaysTraded([A, B, C]);
|
||||
assert.deepEqual(order, ['acc-2', 'acc-1', 'acc-3']);
|
||||
assert.equal(failedAccounts, 0);
|
||||
assert.match(client.lastFetchErrors['V50K-F-D3-3'], /code 1002/);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('exits wait for the login session to be relayed again; an open goes now or not at all', async () => {
|
||||
clockAt('2026-10-29T06:00:00Z');
|
||||
const legs = [];
|
||||
const client = testClient((method, path, opts) => {
|
||||
if (method === 'GET' && path === '/v3/positions/opened') return { data: { positions: [rawPosition('9101', 'acc-1', iso('2026-10-29T05:59:00Z'))] } };
|
||||
if (method === 'POST' && (path === '/v3/positions/stop-loss' || path === '/v3/positions/take-profit')) {
|
||||
legs.push(`${path.slice('/v3/positions/'.length)} @ ${opts.body.triggerPrice}`);
|
||||
return { data: {} };
|
||||
}
|
||||
throw new Error(`no gateway in this test: ${method} ${path}`);
|
||||
}, [A, B]);
|
||||
try {
|
||||
// The session lapses (Vest refused it) just after an entry filled.
|
||||
dropVestSession(WALLET, vestSessionFor(WALLET).accessToken);
|
||||
assert.equal(vestSessionFor(WALLET), null);
|
||||
const opening = await client.sendOrder(B, NDX, 500, 'Buy', 'Market', undefined, 'open').catch((err) => err);
|
||||
assert.equal(isOrderNotPlaced(opening), true);
|
||||
assert.match(opening.message, /no Vest session/);
|
||||
|
||||
const exits = client.placeOcoExit(A, NDX, 500, 'Sell', 20100, 19900);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_200));
|
||||
assert.deepEqual(legs, []);
|
||||
// The extension relays it again: the exits go, the stop first.
|
||||
tokens += 1;
|
||||
assert.equal(putVestSession({ accessToken: `eyJ.test${tokens}.sig`, expiresAt: Date.now() + 3_600_000, primaryAddr: WALLET, userId: 'u' }), null);
|
||||
await exits;
|
||||
assert.deepEqual(legs, ['stop-loss @ 19900', 'take-profit @ 20100']);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test("the reset guard holds each position's own slippage reserve: crypto's wider than NDX's", async () => {
|
||||
// 7:56 PM ET: four minutes to Vest's reset. acc-1 holds BTC, acc-2 NDX,
|
||||
// neither with a stop.
|
||||
clockAt('2026-10-29T23:56:00Z');
|
||||
const stops = [];
|
||||
const positions = [
|
||||
rawPosition('9201', 'acc-1', iso('2026-10-29T22:00:00Z'), { symbol: 'BTC-PERP', quantity: '5', openPrice: '70000' }),
|
||||
rawPosition('9202', 'acc-2', iso('2026-10-29T22:00:00Z'), { quantity: '15', openPrice: '20000' }),
|
||||
];
|
||||
const client = testClient((method, path, opts) => {
|
||||
if (method === 'GET' && path === '/v3/positions/opened') return { data: { positions } };
|
||||
if (method === 'POST' && path === '/v3/positions/stop-loss') { stops.push({ account: opts.account, ...opts.body }); return { data: {} }; }
|
||||
throw new Error(`no gateway in this test: ${method} ${path}`);
|
||||
});
|
||||
client.refreshMarks = async () => {};
|
||||
client.marks.set('BTC-PERP', { mark: 70000, at: Date.now() });
|
||||
client.marks.set('NDX-USD', { mark: 20000, at: Date.now() });
|
||||
for (const id of [1, 2]) {
|
||||
client.capital.set(id, { status: VEST_STATUS_ACTIVE, maxDailyLossPct: 0.03, maxDrawdownLimit: 47000 });
|
||||
client.accountCashBalances[id] = { amount: 50000, realizedPnL: 0 };
|
||||
}
|
||||
client.syncComplete = true;
|
||||
const fitted = (quantity, openPrice, reserve) => resetSafeStop({
|
||||
side: 'long', quantity, openPrice, mark: openPrice, balance: 50000, floorBalance: 50000, dailyLossPct: 0.03,
|
||||
maxDrawdownFloor: 47000, reserve, exitFeeRate: 0.0001, currentStop: null, tick: 0.01,
|
||||
});
|
||||
try {
|
||||
await client.guardReset();
|
||||
const by = Object.fromEntries(stops.map((s) => [s.account, Number(s.triggerPrice)]));
|
||||
// BTC: 0.1% of $350k = $350 held back, over the flat $150.
|
||||
assert.equal(by['acc-1'], fitted(5, 70000, 350).price);
|
||||
assert.notEqual(by['acc-1'], fitted(5, 70000, 150).price);
|
||||
// NDX: 0.02% of $300k is $60, so the flat $150 stands.
|
||||
assert.equal(by['acc-2'], fitted(15, 20000, 150).price);
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,672 @@
|
||||
// Checks for the Vest client's pure helpers (lib/vest-helpers.ts), the
|
||||
// relayed-session store (lib/vest-session.ts), and the trade date the engine
|
||||
// plans Vest accounts on (lib/trading-logic.ts).
|
||||
//
|
||||
// npm run check
|
||||
//
|
||||
// These files import only Node built-ins, so Node strips their types and loads
|
||||
// them directly; .mjs keeps this file out of Next's type check. The samples are
|
||||
// shaped like the gateway's real answers (captured 2026-10-26).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
isVestWalletAddress, vestAccountId, num, toMs, formatDecimal, vestDayOf, lastVestResetMs, parseCapitalAccount,
|
||||
vestAccountName, vestAccountNumber, parsePosition, awaitingLegs, parseOpenOrder, unrealizedPnl, parseTxn, bookTxns, txnKind, VEST_TXN,
|
||||
hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS, nextVestResetMs, resetSafeStop, parseHistoryPosition, jwtClaimStrings,
|
||||
accountScopedToken, VestApiError, VestNotSentError, nothingWentIn, tokenForRequest, claimWithdrawal, settleClaims,
|
||||
claimFundRows, readClaims, payoutDaysOf, payoutsSince, longClosure, unknownOpenFill, awaitingActivation, vestStatusName,
|
||||
VEST_STATUS, vestStopSlippageFraction, VEST_STOP_SLIPPAGE_FRACTION, VEST_CRYPTO_STOP_SLIPPAGE_FRACTION,
|
||||
} from '../lib/vest-helpers.ts';
|
||||
import { putVestSession, vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from '../lib/vest-session.ts';
|
||||
import { computeDayPlan, currentTradeDateCT, tradeDateFor, cycleCompletion } from '../lib/trading-logic.ts';
|
||||
|
||||
const iso = (s) => Date.parse(s);
|
||||
|
||||
test('wallet addresses and account ids', () => {
|
||||
assert.equal(isVestWalletAddress('0x7ce10771819f80130e204ddb5ca1522e48a9b451'), true);
|
||||
assert.equal(isVestWalletAddress('0x7CE10771819F80130E204DDB5CA1522E48A9B451'), true);
|
||||
assert.equal(isVestWalletAddress('0x7ce1'), false);
|
||||
assert.equal(isVestWalletAddress(''), false);
|
||||
const id = vestAccountId('843f60e2-e013-5a0e-8b6c-face84bc5509');
|
||||
assert.equal(id, vestAccountId('843F60E2-E013-5A0E-8B6C-FACE84BC5509'));
|
||||
assert.notEqual(id, vestAccountId('102566b2-515a-51c5-9ad7-4a7eae0a6a2a'));
|
||||
assert.ok(id >= 2 ** 50 && Number.isSafeInteger(id));
|
||||
});
|
||||
|
||||
test('numbers, times and decimals as the gateway writes them', () => {
|
||||
assert.equal(num('49968.511115'), 49968.511115);
|
||||
assert.equal(num(''), undefined);
|
||||
assert.equal(num(null), undefined);
|
||||
assert.equal(toMs(1793035089), 1793035089000);
|
||||
assert.equal(toMs(1793035089277), 1793035089277);
|
||||
assert.equal(toMs('2026-10-26T18:41:02.886Z'), iso('2026-10-26T18:41:02.886Z'));
|
||||
assert.ok(Number.isNaN(toMs(undefined)));
|
||||
assert.equal(formatDecimal(29554.5, 2), '29554.5');
|
||||
assert.equal(formatDecimal(50, 2), '50');
|
||||
assert.equal(formatDecimal(3 * 0.0001, 4), '0.0003');
|
||||
assert.equal(formatDecimal(42.65449999, 4), '42.6545');
|
||||
assert.equal(formatDecimal(1e-7, 4), '0');
|
||||
});
|
||||
|
||||
test("Vest's day turns at 20:00 New York, summer and winter", () => {
|
||||
// EDT (UTC−4): 20:00 ET is 00:00 UTC.
|
||||
assert.equal(vestDayOf(iso('2026-10-26T23:59:00Z')), '2026-10-26');
|
||||
assert.equal(vestDayOf(iso('2026-10-27T00:00:00Z')), '2026-10-27');
|
||||
assert.equal(vestDayOf(iso('2026-10-26T22:05:00Z')), '2026-10-26'); // the bots' 6:05 PM ET open
|
||||
// EST (UTC−5): 20:00 ET is 01:00 UTC.
|
||||
assert.equal(vestDayOf(iso('2026-12-01T00:30:00Z')), '2026-11-30');
|
||||
assert.equal(vestDayOf(iso('2026-12-01T01:00:00Z')), '2026-12-01');
|
||||
// Month and year ends.
|
||||
assert.equal(vestDayOf(iso('2026-12-31T23:00:00Z')), '2026-12-31');
|
||||
assert.equal(vestDayOf(iso('2027-01-01T01:30:00Z')), '2027-01-01');
|
||||
|
||||
assert.equal(lastVestResetMs(iso('2026-10-26T18:00:00Z')), iso('2026-10-26T00:00:00Z'));
|
||||
assert.equal(lastVestResetMs(iso('2026-10-27T00:00:00Z')), iso('2026-10-27T00:00:00Z'));
|
||||
assert.equal(lastVestResetMs(iso('2026-12-01T00:30:00Z')), iso('2026-11-30T01:00:00Z'));
|
||||
assert.equal(lastVestResetMs(iso('2026-12-01T01:00:01Z')), iso('2026-12-01T01:00:00Z'));
|
||||
// Across the November switch back (Nov 1 2026, 2 AM ET).
|
||||
assert.equal(lastVestResetMs(iso('2026-11-01T12:00:00Z')), iso('2026-11-01T00:00:00Z'));
|
||||
assert.equal(lastVestResetMs(iso('2026-11-02T12:00:00Z')), iso('2026-11-02T01:00:00Z'));
|
||||
});
|
||||
|
||||
const evalAccount = {
|
||||
payment_id: 'tailoredpay:vest-markets:production:12727557177',
|
||||
id: 'e7f69717-bd18-5cb7-9466-673cb283d0b2',
|
||||
plan_id: '86bb03f9-7422-513c-8d5c-c546cb8d221d',
|
||||
account_type: 2, stage: 1, status: 2,
|
||||
initial_capital: '50000.00000000', max_drawdown_pct: '0.060000', max_leverage: '5.0000', max_profit_split_pct: '0.8000',
|
||||
target_equity: '55000.00000000', max_drawdown_limit: '47000.00000000', max_daily_loss_pct: '0.030000',
|
||||
daily_reset_balance: '50000.00000000000000', daily_reset_equity: '50000.00000000000000',
|
||||
daily_reset_base_limit: '1500.000000000000000', daily_loss_floor: '48500.00000000000000', daily_reset_at: 1793035043842,
|
||||
origin: 'eval_purchase', plan_name: 'Vest Capital Evaluation Fee | 10% Goal | 3% Daily Loss | 80/20 | Emerald',
|
||||
plan_product_type: 'eval', step: 1, steps_total: 1, root_account_id: 'e7f69717-bd18-5cb7-9466-673cb283d0b2',
|
||||
stages: [
|
||||
{ ordinal: 1, kind: 'eval', initial_capital: '50000.00000000', profit_target: '5000.00000000', max_drawdown_pct: '0.060000' },
|
||||
{ ordinal: 2, kind: 'funded', initial_capital: '50000.00000000', profit_target: '', max_drawdown_pct: '0.060000' },
|
||||
],
|
||||
attempt_index: 34, activated_at: 1793035043834, created_at: 1793035043756, updated_at: 1793035043843,
|
||||
};
|
||||
|
||||
test('capital accounts parse, and are named for their rules', () => {
|
||||
const a = parseCapitalAccount(evalAccount);
|
||||
assert.equal(a.id, evalAccount.id);
|
||||
assert.equal(a.kind, 'eval');
|
||||
assert.equal(a.status, 2);
|
||||
assert.equal(a.initialCapital, 50000);
|
||||
assert.equal(a.profitTarget, 5000);
|
||||
assert.equal(a.maxDrawdownLimit, 47000);
|
||||
assert.equal(a.dailyLossFloor, 48500);
|
||||
assert.equal(a.maxDailyLossPct, 0.03);
|
||||
assert.equal(a.dailyResetAt, 1793035043842);
|
||||
// attempt_index 34 is what the Vest app shows as Account 35.
|
||||
assert.equal(vestAccountName(a), 'V50K-E-D3-T10-35');
|
||||
|
||||
assert.equal(vestAccountName(parseCapitalAccount({ ...evalAccount, stage: 2 })), 'V50K-F-D3-35');
|
||||
assert.equal(vestAccountName(parseCapitalAccount({ ...evalAccount, max_daily_loss_pct: '0' })), 'V50K-E-D0-T10-35');
|
||||
assert.equal(vestAccountName(parseCapitalAccount({
|
||||
...evalAccount, initial_capital: '5000.00000000', attempt_index: 12, stage: 2, steps_total: 2,
|
||||
stages: [
|
||||
{ ordinal: 1, kind: 'eval', profit_target: '500.00000000' },
|
||||
{ ordinal: 2, kind: 'eval', profit_target: '250.00000000' },
|
||||
{ ordinal: 3, kind: 'funded', profit_target: '' },
|
||||
],
|
||||
})), 'V5K-E-D3-T5-13');
|
||||
assert.equal(vestAccountName(parseCapitalAccount({
|
||||
...evalAccount, plan_product_type: 'instant_funded', initial_capital: '25000.00000000', max_daily_loss_pct: '0', attempt_index: 7,
|
||||
stages: [{ ordinal: 1, kind: 'funded', profit_target: '' }],
|
||||
})), 'V25K-I-08');
|
||||
assert.equal(vestAccountName(parseCapitalAccount({
|
||||
...evalAccount, plan_product_type: 'instant_funded', initial_capital: '500.00000000', attempt_index: 1,
|
||||
stages: [{ ordinal: 1, kind: 'funded', profit_target: '' }],
|
||||
})), 'V500-I-02');
|
||||
assert.equal(parseCapitalAccount({ nope: true }), null);
|
||||
});
|
||||
|
||||
test('an account is numbered as the Vest app numbers it', () => {
|
||||
// The app: `Account ${attempt_index < 9 ? '0' : ''}${attempt_index + 1}`,
|
||||
// and "Legacy Account" without an attempt index.
|
||||
const numbered = (attempt) => vestAccountNumber(parseCapitalAccount({ ...evalAccount, attempt_index: attempt }));
|
||||
assert.equal(numbered(0), '01');
|
||||
assert.equal(numbered(8), '09');
|
||||
assert.equal(numbered(9), '10');
|
||||
assert.equal(numbered(72), '73');
|
||||
assert.equal(numbered(-1), '0');
|
||||
const { attempt_index: _, ...legacy } = evalAccount;
|
||||
assert.equal(vestAccountNumber(parseCapitalAccount(legacy)), '0');
|
||||
assert.equal(vestAccountName(parseCapitalAccount({ ...evalAccount, attempt_index: 72 })), 'V50K-E-D3-T10-73');
|
||||
});
|
||||
|
||||
test('a passed evaluation waits on its funded account, as the Vest app decides', () => {
|
||||
const passed = { ...evalAccount, status: VEST_STATUS.GOAL_PASSED };
|
||||
const a = parseCapitalAccount(passed);
|
||||
assert.equal(a.step, 1);
|
||||
assert.equal(a.stepsTotal, 1);
|
||||
assert.equal(a.accountType, 2);
|
||||
assert.equal(a.blocked, false);
|
||||
assert.equal(awaitingActivation(a), true);
|
||||
|
||||
// Still trading, already claimed, failed or expired: nothing to activate.
|
||||
for (const status of [1, 2, 4, 5, 6, 7]) assert.equal(awaitingActivation(parseCapitalAccount({ ...evalAccount, status })), false, `status ${status}`);
|
||||
// Blocked by Vest.
|
||||
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, blocked_at: 1793287000000 })), false);
|
||||
// A funded account that reached its goal isn't an evaluation — on the
|
||||
// last step's number too, so only the evaluation test can say so.
|
||||
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, account_type: 3, stage: 2, step: 1 })), false);
|
||||
// Either of Vest's marks makes it one.
|
||||
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, account_type: 3 })), true);
|
||||
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, stage: 2, step: 1 })), true);
|
||||
// A 2-Step's first step passes on to its second by itself; the last one waits.
|
||||
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, step: 1, steps_total: 2 })), false);
|
||||
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, step: 2, steps_total: 2 })), true);
|
||||
|
||||
// Without step or steps_total, the Vest app's own fallbacks.
|
||||
const { step, steps_total, ...bare } = passed;
|
||||
assert.equal(awaitingActivation(parseCapitalAccount(bare)), true);
|
||||
assert.equal(parseCapitalAccount({ ...bare, stage: 2 }).step, 2);
|
||||
assert.equal(parseCapitalAccount({ ...bare, plan_product_type: 'instant_funded' }).stepsTotal, 0);
|
||||
assert.equal(awaitingActivation(parseCapitalAccount({ ...bare, plan_product_type: 'instant_funded' })), false);
|
||||
|
||||
assert.equal(vestStatusName(3), 'goal passed (status 3)');
|
||||
assert.equal(vestStatusName(6), 'claimed (status 6)');
|
||||
assert.equal(vestStatusName(9), 'unknown (status 9)');
|
||||
});
|
||||
|
||||
const openPosition = {
|
||||
positionId: 'afe96d7e-f5ac-4b8a-aae8-f5b63b74e508', accountId: '843f60e2-e013-5a0e-8b6c-face84bc5509', accountType: 2,
|
||||
symbol: 'NDX-USD-PERP', side: 'short', quantity: '42.6545', openPrice: '29529.250000000', closePrice: '290.250000000',
|
||||
openDate: 1793035089277, pnl: '0', fee: '31.488885', funding: '-5.423271000000000000', collateral: '24944.027426', leverage: '50',
|
||||
createdAt: 1793035089244, updatedAt: 1793037621104,
|
||||
stopLosses: [{ id: 'e22311d2-fbd9-4ab8-b65f-c2921f58fefc', triggerPrice: '29554.500000000', sizeMode: 'full_position', executionType: 'market', status: 'awaiting' }],
|
||||
takeProfits: [],
|
||||
};
|
||||
|
||||
test('an open of unknown outcome is the position on its account, symbol and side opened around it', () => {
|
||||
const pos = (positionId, accountId, side, openedAt, symbol = 'NDX-USD', stopLosses = []) => ({
|
||||
positionId, accountId, symbol, side, quantity: 0.5, openPrice: 20000, collateral: 500, leverage: 20, openedAt, stopLosses, takeProfits: [],
|
||||
});
|
||||
// Sent at 12:30:00, its error at 12:30:15: the window is a few seconds of clock either side.
|
||||
const sent = { accountId: 'acc-1', symbol: 'NDX-USD', side: 'long', from: iso('2026-10-30T12:29:55Z'), until: iso('2026-10-30T12:30:20Z') };
|
||||
const filled = pos('9001', 'acc-1', 'long', iso('2026-10-30T12:30:01Z'));
|
||||
assert.deepEqual(unknownOpenFill(sent, [pos('9002', 'acc-2', 'long', iso('2026-10-30T12:30:01Z')), filled]), [filled]);
|
||||
// Another side or symbol, a position from before it, or one opened after
|
||||
// the window (a hand trade, or an outside copy-trader, half a minute on): not it.
|
||||
assert.deepEqual(unknownOpenFill(sent, [pos('9003', 'acc-1', 'short', iso('2026-10-30T12:30:01Z'))]), []);
|
||||
assert.deepEqual(unknownOpenFill(sent, [pos('9004', 'acc-1', 'long', iso('2026-10-30T12:30:01Z'), 'SPX-USD')]), []);
|
||||
assert.deepEqual(unknownOpenFill(sent, [pos('9005', 'acc-1', 'long', iso('2026-10-30T11:00:00Z'))]), []);
|
||||
assert.deepEqual(unknownOpenFill(sent, [pos('9006', 'acc-1', 'long', iso('2026-10-30T12:30:45Z'))]), []);
|
||||
// No open time: can't be told from a hand trade.
|
||||
assert.deepEqual(unknownOpenFill(sent, [pos('9007', 'acc-1', 'long', NaN)]), []);
|
||||
// One with exit legs isn't the bot's open — none went on it.
|
||||
const leg = { id: 'sl-1', triggerPrice: 19900, executionType: 'market', status: 'awaiting' };
|
||||
assert.deepEqual(unknownOpenFill(sent, [pos('9008', 'acc-1', 'long', iso('2026-10-30T12:30:01Z'), 'NDX-USD', [leg])]), []);
|
||||
// Two inside the window: both come back, for the caller to adopt neither.
|
||||
const copy = pos('9009', 'acc-1', 'long', iso('2026-10-30T12:30:02Z'));
|
||||
assert.deepEqual(unknownOpenFill(sent, [filled, copy]), [filled, copy]);
|
||||
// The position id the open's answer named settles it, legs and window aside.
|
||||
const named = pos('9010', 'acc-1', 'long', iso('2026-10-30T12:31:00Z'), 'NDX-USD', [leg]);
|
||||
assert.deepEqual(unknownOpenFill({ ...sent, positionId: '9010' }, [filled, copy, named]), [named]);
|
||||
assert.deepEqual(unknownOpenFill({ ...sent, positionId: '9010' }, [filled, copy]), []);
|
||||
assert.deepEqual(unknownOpenFill({ ...sent, positionId: '9010' }, [{ ...named, accountId: 'acc-2' }]), []);
|
||||
});
|
||||
|
||||
test('positions, their legs and unrealized P&L', () => {
|
||||
const p = parsePosition(openPosition);
|
||||
assert.equal(p.side, 'short');
|
||||
assert.equal(p.quantity, 42.6545);
|
||||
assert.equal(p.collateral, 24944.027426);
|
||||
assert.equal(p.openedAt, 1793035089277);
|
||||
assert.equal(awaitingLegs(p).length, 1);
|
||||
assert.equal(awaitingLegs({ ...p, stopLosses: [{ ...p.stopLosses[0], status: 'triggered' }] }).length, 0);
|
||||
// Short 42.6545 from 29529.25, marked at 29477.50: +51.75 points each.
|
||||
assert.ok(Math.abs(unrealizedPnl(p, 29477.5) - 42.6545 * 51.75) < 1e-6);
|
||||
assert.equal(unrealizedPnl(p, NaN), 0);
|
||||
assert.equal(parsePosition({ ...openPosition, quantity: '0' }), null);
|
||||
assert.equal(parseOpenOrder({ order_id: 'o1', account_id: 'a1', position_id: 'p1', symbol: 'GC-PERP', created_at: 1 }).positionId, 'p1');
|
||||
assert.equal(parseOpenOrder({ symbol: 'x' }), null);
|
||||
});
|
||||
|
||||
test('transactions book into daily P&L and fund rows by Vest day', () => {
|
||||
assert.equal(txnKind(VEST_TXN.FEE), 'trade');
|
||||
assert.equal(txnKind(VEST_TXN.REALIZED_PNL), 'trade');
|
||||
assert.equal(txnKind(VEST_TXN.FUNDING), 'trade');
|
||||
assert.equal(txnKind(VEST_TXN.WITHDRAW), 'fund');
|
||||
assert.equal(txnKind(VEST_TXN.FILL_MARGIN), 'margin');
|
||||
assert.equal(txnKind(VEST_TXN.ORDER_MARGIN_RELEASE), 'margin');
|
||||
|
||||
const w = parseTxn({ account_type: 3, action_id: 'a9', amount: '1200', created_at: iso('2026-10-26T15:00:00Z'), transaction_type: 2 });
|
||||
assert.equal(w.amount, -1200);
|
||||
assert.equal(w.kind, 'fund');
|
||||
assert.equal(parseTxn({ amount: 'x', transaction_type: 9, created_at: 1 }), null);
|
||||
|
||||
const rows = [
|
||||
{ action_id: 'd', amount: '50000', created_at: iso('2026-10-20T14:00:00Z'), transaction_type: 1 },
|
||||
{ action_id: 'o', amount: '-24944.02', created_at: iso('2026-10-26T17:18:00Z'), transaction_type: 6 },
|
||||
{ action_id: 'f1', amount: '-31.49', created_at: iso('2026-10-26T17:18:00Z'), transaction_type: 8 },
|
||||
// After 20:00 ET on the 26th: the 27th's day.
|
||||
{ action_id: 'r1', amount: '812.5', created_at: iso('2026-10-27T00:30:00Z'), transaction_type: 9 },
|
||||
{ action_id: 'f2', amount: '-31.2', created_at: iso('2026-10-27T00:30:00Z'), transaction_type: 8 },
|
||||
{ action_id: 'u1', amount: '-5.42', created_at: iso('2026-10-27T01:00:00Z'), transaction_type: 10 },
|
||||
].map(parseTxn);
|
||||
const { daily, funds } = bookTxns(rows);
|
||||
assert.deepEqual([...funds.entries()], [['2026-10-20', 50000]]);
|
||||
assert.ok(Math.abs(daily.get('2026-10-26').pnl - -31.49) < 1e-9);
|
||||
assert.ok(Math.abs(daily.get('2026-10-27').pnl - (812.5 - 31.2 - 5.42)) < 1e-9);
|
||||
assert.ok(Math.abs(daily.get('2026-10-27').fees - 31.2) < 1e-9);
|
||||
});
|
||||
|
||||
const withdrawalsOf = (txns) => txns.filter((t) => t.type === VEST_TXN.WITHDRAW).map((t) => ({ at: t.time, amount: t.amount }));
|
||||
const deposit = { action_id: 'd', amount: '50000', created_at: iso('2026-10-20T14:00:00Z'), transaction_type: 1 };
|
||||
|
||||
test('a claim of unknown outcome that never shows lapses an hour on; one Vest accepted never does', () => {
|
||||
const at = iso('2026-10-27T15:00:00Z');
|
||||
const lost = { at, amount: 2250, base: 50000, unknown: true };
|
||||
// Read just after the request failed: it may yet show, so it holds.
|
||||
assert.deepEqual(settleClaims([lost], [], at + 60_000), { pending: [lost], lapsed: [] });
|
||||
// Still not shown by a read an hour on: it never went in.
|
||||
assert.deepEqual(settleClaims([lost], [], at + 61 * 60_000), { pending: [], lapsed: [lost] });
|
||||
// It went in after all: a WITHDRAW of its amount near it settles it.
|
||||
assert.deepEqual(settleClaims([lost], [{ at: at + 3_000, amount: -2250 }], at + 61 * 60_000), { pending: [], lapsed: [] });
|
||||
assert.equal(claimWithdrawal([{ at: at + 3_000, amount: -2249 }], lost), undefined, 'another amount');
|
||||
assert.equal(claimWithdrawal([{ at: at + 31 * 60_000, amount: -2250 }], lost), undefined, 'too far off');
|
||||
assert.deepEqual(claimWithdrawal([{ at: at + 20 * 60_000, amount: -2250 }, { at: at - 2_000, amount: -2250 }], lost), { at: at - 2_000, amount: -2250 });
|
||||
// A claim Vest accepted holds for as long as it doesn't show.
|
||||
const accepted = { at, amount: 2250, base: 50000 };
|
||||
assert.deepEqual(settleClaims([accepted], [], at + 48 * 3_600_000), { pending: [accepted], lapsed: [] });
|
||||
});
|
||||
|
||||
test('the books stop counting a claim that never went in once it lapses, so it holds the account no longer', () => {
|
||||
const at = iso('2026-10-27T15:00:00Z');
|
||||
const lost = { at, sentAt: at - 20_000, amount: 2250, base: 50150, unknown: true };
|
||||
const txns = [deposit, { action_id: 'r1', amount: '2400', created_at: at - 3_600_000, transaction_type: 9 }].map(parseTxn);
|
||||
const read = (claims, readAt) => readClaims({ txns, funds: bookTxns(txns).funds, claims, stored: [], readAt, opening: 0 });
|
||||
// It never went in: the balance is what the transactions add up to.
|
||||
const cash = 52400;
|
||||
// Half an hour on it may yet show, so the books still take it out (and hold).
|
||||
const early = read([lost], at + 30 * 60_000);
|
||||
assert.deepEqual([early.lapsed, early.outstanding], [[], [lost]]);
|
||||
assert.equal(cash - early.expected, 2250);
|
||||
// A read an hour on lapses it, and the books agree again.
|
||||
const late = read([lost], at + 61 * 60_000);
|
||||
assert.deepEqual([late.lapsed, late.outstanding], [[lost], []]);
|
||||
assert.equal(late.expected, cash);
|
||||
assert.deepEqual(late.rows, [{ date: '2026-10-20', amount: 50000 }]);
|
||||
// One Vest accepted that doesn't show yet: out of the balance and on its
|
||||
// day's row, however long it takes.
|
||||
const accepted = { at, sentAt: at - 20_000, amount: 2250, base: 50150 };
|
||||
const waiting = read([accepted], at + 48 * 3_600_000);
|
||||
assert.deepEqual([waiting.lapsed, waiting.outstanding], [[], [accepted]]);
|
||||
assert.equal(waiting.expected, cash - 2250);
|
||||
assert.deepEqual(waiting.rows, [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-27', amount: -2250 }]);
|
||||
// Once it shows, the WITHDRAW itself is in the sum.
|
||||
const shown = [...txns, parseTxn({ action_id: 'w1', amount: '2250', created_at: at + 2_000, transaction_type: 2 })];
|
||||
const settled = readClaims({ txns: shown, funds: bookTxns(shown).funds, claims: [accepted], stored: [], readAt: at + 3_600_000, opening: 0 });
|
||||
assert.deepEqual(settled.withdrawals, [{ at: at + 2_000, amount: -2250 }]);
|
||||
assert.deepEqual(settled.outstanding, []);
|
||||
assert.equal(settled.expected, cash - 2250);
|
||||
// An account Vest funded without a deposit row: its capital counts in.
|
||||
assert.equal(readClaims({ txns: txns.slice(1), funds: new Map(), claims: [], stored: [], readAt: at, opening: 50000 }).expected, cash);
|
||||
});
|
||||
|
||||
test('an account token that can\'t be minted means the order or claim was never sent', async () => {
|
||||
const path = '/v3/capital/withdraw';
|
||||
assert.equal(await tokenForRequest(path, async () => 'tok'), 'tok');
|
||||
const failures = [
|
||||
new Error('/v3/auth/account-token timed out after 20s'),
|
||||
new VestApiError('/v3/auth/account-token: HTTP 502 (HTTP 502)', 502, undefined),
|
||||
new Error('/v3/auth/account-token answered without a token'),
|
||||
];
|
||||
for (const failure of failures) {
|
||||
// As the request's own answer each could have gone in...
|
||||
assert.equal(nothingWentIn(failure), false);
|
||||
// ...but from the mint, the request never went out.
|
||||
await assert.rejects(tokenForRequest(path, async () => { throw failure; }), (err) =>
|
||||
err instanceof VestNotSentError && nothingWentIn(err) && err.message.startsWith(`${path} not sent`));
|
||||
}
|
||||
// The request's own answer: only a refusal proves nothing went in.
|
||||
assert.equal(nothingWentIn(new VestApiError('x (HTTP 400, code 1001)', 400, '1001')), true);
|
||||
assert.equal(nothingWentIn(new VestApiError('x: no Vest session', 401, 'NO_SESSION')), true);
|
||||
assert.equal(nothingWentIn(new VestApiError('x (HTTP 408)', 408, undefined)), false);
|
||||
assert.equal(nothingWentIn(new VestApiError('x (HTTP 503)', 503, undefined)), false);
|
||||
assert.equal(nothingWentIn(new Error('x: socket hang up')), false);
|
||||
});
|
||||
|
||||
test('a claim Vest dates on the other side of 20:00 ET keeps no fund row of its own', () => {
|
||||
// Sent at 19:59:59.8 ET on the 26th and answered after 20:00: Vest dates
|
||||
// its WITHDRAW on the 26th's day, the client booked it on the 27th's.
|
||||
const claim = { at: iso('2026-10-27T00:00:00.300Z'), sentAt: iso('2026-10-26T23:59:59.800Z'), amount: 2250, base: 50000 };
|
||||
const txns = [deposit, { action_id: 'w1', amount: '2250', created_at: iso('2026-10-26T23:59:59.900Z'), transaction_type: 2 }].map(parseTxn);
|
||||
const stored = [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-27', amount: -2250 }];
|
||||
// Before Vest shows it, it stands where it was booked.
|
||||
assert.deepEqual(claimFundRows({ funds: bookTxns(txns.slice(0, 1)).funds, claims: [claim], withdrawals: [], stored }), {
|
||||
rows: [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-27', amount: -2250 }], dropped: [],
|
||||
});
|
||||
// Once it shows, it's on Vest's day, and the 27th's row goes.
|
||||
assert.deepEqual(claimFundRows({ funds: bookTxns(txns).funds, claims: [claim], withdrawals: withdrawalsOf(txns), stored }), {
|
||||
rows: [{ date: '2026-10-20', amount: 50000 }, { date: '2026-10-26', amount: -2250 }], dropped: ['2026-10-27'],
|
||||
});
|
||||
// The 27th starts with no payout on it, before Vest shows the claim too:
|
||||
// it went out on the 26th, and Vest can't date it earlier.
|
||||
const reset = lastVestResetMs(iso('2026-10-27T15:00:00Z'));
|
||||
assert.equal(payoutsSince(withdrawalsOf(txns), [claim], reset), 0);
|
||||
assert.equal(payoutsSince([], [claim], reset), 0);
|
||||
// Dated after the turn instead, it's the 27th's once Vest shows it.
|
||||
const after = [deposit, { action_id: 'w3', amount: '2250', created_at: iso('2026-10-27T00:00:00.100Z'), transaction_type: 2 }].map(parseTxn);
|
||||
assert.equal(payoutsSince(withdrawalsOf(after), [claim], reset), 1);
|
||||
// One saved before the send time was kept counts from its answer.
|
||||
assert.equal(payoutsSince([], [{ at: claim.at, amount: claim.amount, base: claim.base }], reset), 1);
|
||||
|
||||
// Dated the same day: its row stays. A day Vest has other fund rows on
|
||||
// is Vest's, with a claim it doesn't show yet added.
|
||||
const sameDay = { at: iso('2026-10-26T18:00:00Z'), amount: 1000, base: 49000 };
|
||||
const shown = [deposit, { action_id: 'w2', amount: '1000', created_at: sameDay.at + 2_000, transaction_type: 2 }].map(parseTxn);
|
||||
assert.deepEqual(claimFundRows({ funds: bookTxns(shown).funds, claims: [sameDay], withdrawals: withdrawalsOf(shown), stored: [{ date: '2026-10-26' }] }).dropped, []);
|
||||
const credit = parseTxn({ action_id: 'c1', amount: '40', created_at: sameDay.at - 3_600_000, transaction_type: 22 });
|
||||
assert.deepEqual(claimFundRows({ funds: bookTxns([credit]).funds, claims: [sameDay], withdrawals: [], stored: [] }).rows, [{ date: '2026-10-26', amount: -960 }]);
|
||||
// One of unknown outcome was never booked, so it's no day's.
|
||||
assert.deepEqual(claimFundRows({ funds: new Map(), claims: [{ ...claim, unknown: true }], withdrawals: [], stored }), { rows: [], dropped: [] });
|
||||
});
|
||||
|
||||
test('two claims on one Vest day share a fund row but count as two payouts', () => {
|
||||
const first = { at: iso('2026-10-27T14:00:00Z'), amount: 2250, base: 50000 };
|
||||
const second = { at: iso('2026-10-27T18:30:00Z'), amount: 1500, base: 50000 };
|
||||
const txns = [
|
||||
deposit,
|
||||
{ action_id: 'w1', amount: '2250', created_at: first.at + 2_000, transaction_type: 2 },
|
||||
{ action_id: 'w2', amount: '1500', created_at: second.at + 2_000, transaction_type: 2 },
|
||||
].map(parseTxn);
|
||||
const withdrawals = withdrawalsOf(txns);
|
||||
const { rows } = claimFundRows({ funds: bookTxns(txns).funds, claims: [first, second], withdrawals, stored: [] });
|
||||
assert.deepEqual(rows.filter((f) => f.amount < 0), [{ date: '2026-10-27', amount: -3750 }]);
|
||||
assert.deepEqual(payoutDaysOf(withdrawals, [first, second], rows), ['2026-10-27', '2026-10-27']);
|
||||
// The second not shown yet: still two, on the day it was booked.
|
||||
assert.deepEqual(payoutDaysOf(withdrawals.slice(0, 1), [first, second], rows), ['2026-10-27', '2026-10-27']);
|
||||
// Made by hand, they show only as WITHDRAWs.
|
||||
assert.equal(payoutDaysOf(withdrawals, [], rows).length, 2);
|
||||
// Of unknown outcome, one counts only once it shows.
|
||||
assert.deepEqual(payoutDaysOf([], [{ ...first, unknown: true }], []), []);
|
||||
assert.equal(payoutDaysOf(withdrawals.slice(0, 1), [{ ...first, unknown: true }], rows).length, 1);
|
||||
// A day that nets negative with no WITHDRAW still counts, as it did.
|
||||
assert.deepEqual(payoutDaysOf([], [], [{ date: '2026-10-21', amount: -75 }, { date: '2026-10-22', amount: 20 }]), ['2026-10-21']);
|
||||
assert.deepEqual(payoutDaysOf(withdrawals, [], [...rows, { date: '2026-10-21', amount: -75 }]), ['2026-10-21', '2026-10-27', '2026-10-27']);
|
||||
assert.equal(payoutsSince(withdrawals, [first, second], lastVestResetMs(second.at)), 2);
|
||||
});
|
||||
|
||||
test('hourly volatility leaves out the gaps', () => {
|
||||
const start = iso('2026-10-19T00:00:00Z');
|
||||
const kl = [];
|
||||
let price = 29000;
|
||||
for (let h = 0; h < 60; h++) {
|
||||
price *= h % 2 === 0 ? 1.001 : 1 / 1.001;
|
||||
kl.push([start + h * 3_600_000, '0', '0', '0', String(price), '1']);
|
||||
}
|
||||
const base = hourlyVolatility(kl);
|
||||
assert.ok(base > 0.0009 && base < 0.0011, `σ ${base}`);
|
||||
// A weekend gap with a 3% jump: not an hour's move, so σ doesn't change.
|
||||
const gapped = [...kl, [start + 120 * 3_600_000, '0', '0', '0', String(price * 1.03), '1']];
|
||||
assert.ok(Math.abs(hourlyVolatility(gapped) - base) < 1e-12);
|
||||
// Any order in, the same σ out.
|
||||
assert.equal(hourlyVolatility([...kl].reverse()), base);
|
||||
assert.equal(hourlyVolatility(kl.slice(0, MIN_HOURLY_RETURNS)), null);
|
||||
});
|
||||
|
||||
test('market hours: open stretches, closures and crypto', () => {
|
||||
const periods = [
|
||||
{ fromMs: 0, toMs: 100, state: { session: 'unspecified', closure: 'weekly', tradingStatus: 'CLOSE', price: { allow: false } } },
|
||||
{ fromMs: 100, toMs: 200, state: { session: 'overnight', closure: 'unspecified', tradingStatus: 'OPEN', price: { allow: true } } },
|
||||
{ fromMs: 200, toMs: 300, state: { session: 'regular', closure: 'unspecified', tradingStatus: 'OPEN', price: { allow: true } } },
|
||||
{ fromMs: 300, toMs: 400, state: { session: 'unspecified', closure: 'maintenance', tradingStatus: 'OPEN', price: { allow: true } } },
|
||||
{ fromMs: 400, toMs: 500, state: { session: 'overnight', closure: 'unspecified', tradingStatus: 'OPEN', price: { allow: true } } },
|
||||
];
|
||||
assert.deepEqual(marketOpen(periods, 50), { open: false, closesAt: null });
|
||||
assert.deepEqual(marketOpen(periods, 150), { open: true, closesAt: 300 });
|
||||
assert.deepEqual(marketOpen(periods, 350), { open: false, closesAt: null });
|
||||
assert.deepEqual(marketOpen(periods, 450), { open: true, closesAt: 500 });
|
||||
assert.deepEqual(marketOpen(periods, 600), { open: false, closesAt: null });
|
||||
assert.deepEqual(marketOpen([], 600), { open: true, closesAt: null });
|
||||
});
|
||||
|
||||
test('market hours: a closure past an 8 PM ET reset is a long one, the maintenance hour is not', () => {
|
||||
const open = (from, to) => ({ fromMs: iso(from), toMs: iso(to), state: { session: 'overnight', closure: 'unspecified', tradingStatus: 'OPEN', price: { allow: true } } });
|
||||
const shut = (from, to, closure) => ({ fromMs: iso(from), toMs: iso(to), state: { session: 'unspecified', closure, tradingStatus: closure === 'weekly' ? 'CLOSE' : 'OPEN', price: { allow: closure !== 'weekly' } } });
|
||||
const week = [
|
||||
open('2026-10-28T22:00:00Z', '2026-10-29T21:00:00Z'),
|
||||
shut('2026-10-29T21:00:00Z', '2026-10-29T22:00:00Z', 'maintenance'),
|
||||
open('2026-10-29T22:00:00Z', '2026-10-30T21:00:00Z'),
|
||||
shut('2026-10-30T21:00:00Z', '2026-11-01T23:00:00Z', 'weekly'),
|
||||
open('2026-11-01T23:00:00Z', '2026-11-02T22:00:00Z'),
|
||||
shut('2026-11-02T22:00:00Z', '2026-11-02T23:00:00Z', 'maintenance'),
|
||||
open('2026-11-02T23:00:00Z', '2026-11-03T22:00:00Z'),
|
||||
];
|
||||
// The weekend: shut through Friday's and Saturday's 8 PM ET resets.
|
||||
const friday = marketOpen(week, iso('2026-10-30T20:57:00Z'));
|
||||
assert.deepEqual(friday, { open: true, closesAt: iso('2026-10-30T21:00:00Z') });
|
||||
assert.equal(longClosure(week, friday.closesAt), true);
|
||||
// The maintenance hour reopens before the evening's reset, in summer and winter.
|
||||
const thursday = marketOpen(week, iso('2026-10-29T20:57:00Z'));
|
||||
assert.equal(thursday.closesAt, iso('2026-10-29T21:00:00Z'));
|
||||
assert.equal(longClosure(week, thursday.closesAt), false);
|
||||
assert.equal(longClosure(week, iso('2026-11-02T22:00:00Z')), false);
|
||||
// A gap in the periods is shut too (a holiday), and this one outlasts the reset.
|
||||
const early = [open('2026-11-25T14:00:00Z', '2026-11-25T18:00:00Z'), open('2026-11-26T23:00:00Z', '2026-11-27T18:00:00Z')];
|
||||
assert.equal(longClosure(early, iso('2026-11-25T18:00:00Z')), true);
|
||||
// Shut as far as the periods go: long. Periods that just end: no close known.
|
||||
assert.equal(longClosure(week.slice(0, 4), iso('2026-10-30T21:00:00Z')), true);
|
||||
assert.equal(longClosure(week.slice(0, 3), iso('2026-10-30T21:00:00Z')), false);
|
||||
assert.equal(longClosure(undefined, iso('2026-10-30T21:00:00Z')), false);
|
||||
});
|
||||
|
||||
test("a Vest account plans on Vest's day: the 5 PM CT roll doesn't count today's P&L twice", () => {
|
||||
const vest = { tradeDate: vestDayOf };
|
||||
// 3:30 and 5:30 PM CT (4:30 / 6:30 PM ET): the Central date rolls at
|
||||
// 5 PM CT, Vest's day only at 8 PM ET.
|
||||
const before = new Date('2026-10-27T20:30:00Z');
|
||||
const after = new Date('2026-10-27T22:30:00Z');
|
||||
assert.equal(currentTradeDateCT(after), '2026-10-28');
|
||||
assert.equal(tradeDateFor(vest, after), '2026-10-27');
|
||||
assert.equal(tradeDateFor(vest, new Date('2026-10-28T00:00:00Z')), '2026-10-28');
|
||||
// Every other platform keeps the Central date.
|
||||
assert.equal(tradeDateFor(undefined, after), '2026-10-28');
|
||||
assert.equal(tradeDateFor({}, after), '2026-10-28');
|
||||
// $3,000 to go, 3 min days of $150, $800 made today: Vest's row for its
|
||||
// day, and the realized P&L since its reset.
|
||||
const rows = [{ date: '2026-10-27', pnl: 800 }];
|
||||
const plan = (date) => computeDayPlan(7, date, 3000, 0, rows, 150, 3, 800, 800);
|
||||
assert.equal(plan(currentTradeDateCT(before)).remainingToday, 1900);
|
||||
assert.deepEqual(plan(tradeDateFor(vest, after)), plan(currentTradeDateCT(before)));
|
||||
// On the Central date the $800 is a prior min day and today's P&L both.
|
||||
assert.equal(plan(currentTradeDateCT(after)).remainingToday, 2050);
|
||||
});
|
||||
|
||||
test("a Vest account's completion is judged on Vest's day, and waits out Vest's own roll", () => {
|
||||
const vest = { tradeDate: vestDayOf };
|
||||
// $3,000 in 2 min days: day one made $3,100 and is flat. At 5:30 PM CT
|
||||
// its row is booked and its P&L is still today's realized P&L.
|
||||
const eff = { profitTarget: 3000, consistency: 0, minTradingDays: 2, minDayPnL: 0, drain: false, dailyLossLimit: 0 };
|
||||
const opts = {
|
||||
accountId: 7, cycleDailyPnL: [{ date: '2026-10-27', pnl: 3100 }], rawRealizedPnL: 3100, combineCmeHolidays: false,
|
||||
tradesEveryDay: true, effective: eff, equityProfit: 3100, splitEnabled: true, client: vest,
|
||||
};
|
||||
const evening = new Date('2026-10-27T22:30:00Z');
|
||||
// The Central date counts the one day twice and reports a pass.
|
||||
assert.equal(cycleCompletion({ ...opts, client: undefined, rawTradeDate: currentTradeDateCT(evening), now: evening }), 'today');
|
||||
assert.equal(cycleCompletion({ ...opts, rawTradeDate: tradeDateFor(vest, evening), now: evening }), 'none');
|
||||
// Two minutes past Vest's 8 PM ET roll the last sync's realized P&L can
|
||||
// still be the ended day's: no 'today' until the settle window passes.
|
||||
const justRolled = new Date('2026-10-28T00:02:00Z');
|
||||
assert.equal(tradeDateFor(vest, justRolled), '2026-10-28');
|
||||
assert.equal(cycleCompletion({ ...opts, rawTradeDate: '2026-10-28', now: justRolled }), 'none');
|
||||
// A real second day, once settled, finishes it.
|
||||
const settled = new Date('2026-10-28T00:10:00Z');
|
||||
assert.equal(cycleCompletion({ ...opts, rawTradeDate: '2026-10-28', rawRealizedPnL: 50, equityProfit: 3150, now: settled }), 'today');
|
||||
});
|
||||
|
||||
test('relayed sessions: taken, kept fresher, dropped, never listed with tokens', () => {
|
||||
const now = iso('2026-10-26T18:40:00Z');
|
||||
const jwt = (n) => `eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIke${n}In0.sig${n}`;
|
||||
const wallet = '0x7CE10771819F80130E204DDB5CA1522E48A9B451';
|
||||
assert.equal(putVestSession({ accessToken: 'nope', expiresAt: now + 900_000, primaryAddr: wallet, userId: 'u' }, now), 'accessToken is not a JWT');
|
||||
assert.equal(putVestSession({ accessToken: jwt(1), expiresAt: now + 10_000, primaryAddr: wallet, userId: 'u' }, now), 'token already expired');
|
||||
assert.equal(putVestSession({ accessToken: jwt(1), expiresAt: now + 900_000, primaryAddr: '0x12', userId: 'u' }, now), 'primaryAddr is not a wallet address');
|
||||
assert.equal(putVestSession({ accessToken: jwt(1), expiresAt: now + 900_000, primaryAddr: wallet, userId: 'u' }, now), null);
|
||||
// An older token relayed late doesn't replace the fresher one.
|
||||
assert.equal(putVestSession({ accessToken: jwt(0), expiresAt: now + 300_000, primaryAddr: wallet, userId: 'u' }, now), null);
|
||||
assert.equal(vestSessionFor(wallet.toLowerCase(), now).accessToken, jwt(1));
|
||||
assert.equal(vestSessionFor(wallet, now + 890_000), null); // under 30s left
|
||||
const listed = listVestSessions();
|
||||
assert.equal(listed.length, 1);
|
||||
assert.equal(listed[0].primaryAddr, wallet.toLowerCase());
|
||||
assert.equal('accessToken' in listed[0], false);
|
||||
dropVestSession(wallet, jwt(0)); // not the held one: kept
|
||||
assert.ok(vestSessionFor(wallet, now));
|
||||
dropVestSession(wallet, jwt(1));
|
||||
assert.equal(vestSessionFor(wallet, now), null);
|
||||
});
|
||||
|
||||
test('the next 8 PM ET reset, across both time changes', () => {
|
||||
assert.equal(nextVestResetMs(iso('2026-10-26T18:00:00Z')), iso('2026-10-27T00:00:00Z'));
|
||||
assert.equal(nextVestResetMs(iso('2026-10-27T00:00:00Z')), iso('2026-10-28T00:00:00Z'));
|
||||
assert.equal(nextVestResetMs(iso('2026-10-31T23:59:00Z')), iso('2026-11-01T00:00:00Z'));
|
||||
// Nov 1 2026, the clocks go back: that evening's reset is 01:00 UTC.
|
||||
assert.equal(nextVestResetMs(iso('2026-11-01T12:00:00Z')), iso('2026-11-02T01:00:00Z'));
|
||||
assert.equal(nextVestResetMs(iso('2026-12-01T00:30:00Z')), iso('2026-12-01T01:00:00Z'));
|
||||
});
|
||||
|
||||
test('resetSafeStop: fit an open position into the room the reset will leave', () => {
|
||||
// A stage-2 leg after a claim: balance $50,000, 3% → the reset floor is
|
||||
// $48,500 (above the $47k drawdown floor). $150 reserve, a $3 fee: $1,347 to lose.
|
||||
const base = {
|
||||
side: 'long', quantity: 4, openPrice: 29500, mark: 29480, balance: 50000, dailyLossPct: 0.03,
|
||||
maxDrawdownFloor: 47000, reserve: 150, exitFeeRate: 0.000025, tick: 0.25,
|
||||
};
|
||||
// No stop at all (a last stand): one comes in at open − 1347/4, rounded up to the tick.
|
||||
const fee = 0.000025 * 4 * 29480;
|
||||
const allowed = 50000 - 48500 - 150 - fee;
|
||||
const want = Math.ceil((29500 - allowed / 4) / 0.25) * 0.25;
|
||||
assert.deepEqual(resetSafeStop({ ...base, currentStop: null }), { action: 'stop', price: want });
|
||||
// A wider stop is pulled in; one already tighter is left alone.
|
||||
assert.deepEqual(resetSafeStop({ ...base, currentStop: 29000 }), { action: 'stop', price: want });
|
||||
assert.deepEqual(resetSafeStop({ ...base, currentStop: 29300 }), { action: 'none' });
|
||||
// The mark already past where the stop must go: close.
|
||||
assert.deepEqual(resetSafeStop({ ...base, mark: 29100, currentStop: null }), { action: 'close' });
|
||||
// Short: mirrored, rounded down.
|
||||
const short = resetSafeStop({ ...base, side: 'short', mark: 29510, currentStop: null });
|
||||
assert.equal(short.action, 'stop');
|
||||
assert.equal(short.price, Math.floor((29500 + (50000 - 48500 - 150 - 0.000025 * 4 * 29510) / 4) / 0.25) * 0.25);
|
||||
// No room past the reset at all: close.
|
||||
assert.deepEqual(resetSafeStop({ ...base, balance: 48600, maxDrawdownFloor: 48600 }), { action: 'close' });
|
||||
// The drawdown floor binds when it's above the daily one.
|
||||
const dd = resetSafeStop({ ...base, maxDrawdownFloor: 49000, currentStop: null });
|
||||
assert.equal(dd.action, 'stop');
|
||||
assert.ok(dd.price > want);
|
||||
// A second position on the account gets what the first leaves: $500
|
||||
// already committed at the first one's stop, from the same $48,500 floor.
|
||||
const second = resetSafeStop({ ...base, balance: 49500, floorBalance: 50000, currentStop: null });
|
||||
assert.equal(second.action, 'stop');
|
||||
assert.equal(second.price, Math.ceil((29500 - (allowed - 500) / 4) / 0.25) * 0.25);
|
||||
assert.ok(second.price > want);
|
||||
// ...and nothing when the first takes it all.
|
||||
assert.deepEqual(resetSafeStop({ ...base, balance: 48600, floorBalance: 50000, currentStop: null }), { action: 'close' });
|
||||
// With no room left, a position already up keeps going on a stop that
|
||||
// locks in the shortfall: one already locking in more is left alone...
|
||||
const up = { ...base, mark: 29560, balance: 48600, floorBalance: 50000 };
|
||||
assert.deepEqual(resetSafeStop({ ...up, currentStop: 29520 }), { action: 'none' });
|
||||
// ...one with none gets it, on the profit side, still under the mark...
|
||||
const shortfall = 48600 - 48500 - 150 - 0.000025 * 4 * 29560;
|
||||
assert.deepEqual(resetSafeStop({ ...up, currentStop: null }), { action: 'stop', price: Math.ceil((29500 - shortfall / 4) / 0.25) * 0.25 });
|
||||
assert.ok(resetSafeStop({ ...up, currentStop: null }).price > 29500);
|
||||
// ...and with no mark to tell, it closes.
|
||||
assert.deepEqual(resetSafeStop({ ...up, mark: NaN, currentStop: null }), { action: 'close' });
|
||||
});
|
||||
|
||||
test('parseHistoryPosition: a closed position from GET /v3/positions', () => {
|
||||
// Shaped like the app's History tab reads it (camelCase, decimal strings).
|
||||
const t = parseHistoryPosition({
|
||||
positionId: 'bdda9024-63a9-4abc-a5a8-f83df8ba030a', accountId: '843f60e2-e013-5a0e-8b6c-face84bc5509', symbol: 'NDX-USD-PERP',
|
||||
side: 'short', quantity: '4.2712', leverage: '50', openPrice: '29529.75', closePrice: '29556.50',
|
||||
pnl: '-114.25', fee: '6.31', funding: '-0.12', collateral: '2522.54', takeProfits: [], stopLosses: [],
|
||||
openDate: 1793065590123, closeDate: 1793066346456, createdAt: 1793065590123, updatedAt: 1793066346456, closeReason: 'user_requested',
|
||||
});
|
||||
assert.deepEqual(t, {
|
||||
positionId: 'bdda9024-63a9-4abc-a5a8-f83df8ba030a', symbol: 'NDX-USD-PERP', side: 'short', quantity: 4.2712, leverage: 50,
|
||||
openPrice: 29529.75, closePrice: 29556.5, pnl: -114.25, fee: 6.31, funding: -0.12,
|
||||
openedAt: 1793065590123, closedAt: 1793066346456, closeReason: 'user_requested',
|
||||
});
|
||||
});
|
||||
|
||||
test('parseHistoryPosition: open, snake_case, seconds, and junk', () => {
|
||||
const open = parseHistoryPosition({
|
||||
position_id: 'p1', symbol: 'GC-PERP', side: 'LONG', quantity: '0', open_price: '4208.9', close_price: '',
|
||||
pnl: '', open_date: 1793065590, close_date: null, close_reason: '',
|
||||
});
|
||||
assert.equal(open.side, 'long');
|
||||
assert.equal(open.quantity, 0, 'a size Vest leaves out reads 0 — the store keeps the one it knows');
|
||||
assert.equal(open.openPrice, 4208.9);
|
||||
assert.equal(open.closePrice, null);
|
||||
assert.equal(open.pnl, null);
|
||||
assert.equal(open.openedAt, 1793065590000);
|
||||
assert.equal(open.closedAt, null);
|
||||
assert.equal(open.closeReason, null);
|
||||
const held = parseHistoryPosition({
|
||||
positionId: 'p3', symbol: 'NDX-USD-PERP', side: 'short', quantity: '85.7363', openPrice: '29538.96', closePrice: '290.25',
|
||||
pnl: '0.0', fee: '63.314059', funding: '0.0', openDate: 1793065422000, closeDate: null,
|
||||
});
|
||||
assert.equal(held.closePrice, null, "an open position's placeholder close price is dropped");
|
||||
assert.equal(held.pnl, null, 'and its $0 P&L');
|
||||
assert.equal(held.fee, 63.314059, 'its entry fee is real');
|
||||
assert.equal(parseHistoryPosition({ symbol: 'GC-PERP', openDate: 1 }), null, 'no id');
|
||||
assert.equal(parseHistoryPosition({ positionId: 'p2', symbol: 'GC-PERP' }), null, 'no open time');
|
||||
assert.equal(parseHistoryPosition(null), null);
|
||||
});
|
||||
|
||||
test('an account\'s own token is told from the login\'s', () => {
|
||||
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
|
||||
const acct = '275e765a-2d3d-5de3-8386-71aaa847c0e8';
|
||||
const accountToken = `${b64({ alg: 'ES256' })}.${b64({ sub: '3cab1794-47bc-523b-91e6-64a9ad26dcbf', acc: { id: acct.toUpperCase() } })}.sig`;
|
||||
assert.ok(jwtClaimStrings(accountToken).includes(acct));
|
||||
assert.deepEqual(jwtClaimStrings('not.a-jwt.at-all'), []);
|
||||
assert.deepEqual(jwtClaimStrings('nodots'), []);
|
||||
// The login sees every account and its primary one.
|
||||
assert.equal(accountScopedToken({ capitalIds: [acct, 'b', 'c'], hasPrimary: true, claims: [] }), false);
|
||||
// One account and no primary: an account's token, whatever its claims.
|
||||
assert.equal(accountScopedToken({ capitalIds: [acct], hasPrimary: false, claims: [] }), true);
|
||||
// One account with a primary: only when the token names that account.
|
||||
assert.equal(accountScopedToken({ capitalIds: [acct], hasPrimary: true, claims: jwtClaimStrings(accountToken) }), true);
|
||||
assert.equal(accountScopedToken({ capitalIds: [acct], hasPrimary: true, claims: ['3cab1794-47bc-523b-91e6-64a9ad26dcbf'] }), false);
|
||||
// A login with no capital accounts yet.
|
||||
assert.equal(accountScopedToken({ capitalIds: [], hasPrimary: true, claims: [] }), false);
|
||||
});
|
||||
|
||||
test('a refused account token falls back to the login\'s and stays refused', () => {
|
||||
const now = iso('2026-10-27T01:00:00Z');
|
||||
const wallet = '0x00000000000000000000000000000000000000aa';
|
||||
const login = 'eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJsb2dpbiJ9.loginsignature-loginsignature-loginsig';
|
||||
const account = 'eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJhY2N0In0.accountsignature-accountsignature-acct';
|
||||
assert.equal(putVestSession({ accessToken: login, expiresAt: now + 600_000, primaryAddr: wallet, userId: 'u' }, now), null);
|
||||
// The account's token lives longer, so it replaces the login's...
|
||||
assert.equal(putVestSession({ accessToken: account, expiresAt: now + 900_000, primaryAddr: wallet, userId: 'u' }, now), null);
|
||||
assert.equal(vestSessionFor(wallet, now).accessToken, account);
|
||||
// ...until the client finds it sees one account: back to the login's.
|
||||
refuseVestSession(wallet, account, now);
|
||||
assert.equal(vestSessionFor(wallet, now).accessToken, login);
|
||||
// Relayed again (the old extension posts it every minute): refused.
|
||||
assert.match(putVestSession({ accessToken: account, expiresAt: now + 900_000, primaryAddr: wallet, userId: 'u' }, now), /one Vest account's token/);
|
||||
assert.equal(vestSessionFor(wallet, now).accessToken, login);
|
||||
// The login's next renewal is taken as usual.
|
||||
const renewed = 'eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJsb2dpbiJ9.renewedsignature-renewedsignature-rene';
|
||||
assert.equal(putVestSession({ accessToken: renewed, expiresAt: now + 1_200_000, primaryAddr: wallet, userId: 'u' }, now), null);
|
||||
assert.equal(vestSessionFor(wallet, now).accessToken, renewed);
|
||||
// Refusing a token that isn't held changes nothing held.
|
||||
refuseVestSession(wallet, 'x.y.zzz', now);
|
||||
assert.equal(vestSessionFor(wallet, now).accessToken, renewed);
|
||||
// No good token to fall back to: none at all.
|
||||
const wallet2 = '0x00000000000000000000000000000000000000bb';
|
||||
assert.equal(putVestSession({ accessToken: account.replace('acct', 'accu'), expiresAt: now + 900_000, primaryAddr: wallet2, userId: 'u' }, now), null);
|
||||
refuseVestSession(wallet2, account.replace('acct', 'accu'), now);
|
||||
assert.equal(vestSessionFor(wallet2, now), null);
|
||||
});
|
||||
|
||||
test("a crypto stop's slippage share is the engine default; everything else keeps NDX's measured one", () => {
|
||||
assert.equal(VEST_CRYPTO_STOP_SLIPPAGE_FRACTION, 0.001);
|
||||
assert.equal(vestStopSlippageFraction('crypto'), VEST_CRYPTO_STOP_SLIPPAGE_FRACTION);
|
||||
for (const asset of ['stock', 'forex', '', undefined]) assert.equal(vestStopSlippageFraction(asset), VEST_STOP_SLIPPAGE_FRACTION);
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env node
|
||||
// DXtrade smoke test — run against a real login before the scheduler trades a
|
||||
// DXtrade firm. Confirms the API details lib/dxtrade-class.ts assumes.
|
||||
//
|
||||
// DX_BASE=https://dx.tradeify247.co/dxsca-web DX_USER=... DX_PASS=... \
|
||||
// DX_DOMAIN=default DX_SYMBOLS=BTC/USD,ETH/USD,XYZ100 node scripts/dxtrade-smoke.mjs
|
||||
//
|
||||
// Read-only unless DX_CONFIRM=yes AND DX_ACCOUNT=<account code> are set, which
|
||||
// adds a minimum-size round trip on that account (a real order: it pays two
|
||||
// commissions). Credentials come from the environment only; the password and
|
||||
// session token are never printed.
|
||||
//
|
||||
// Run it while the instance's client for the firm is disconnected (remove the
|
||||
// credentials there, or stop the instance): two sessions on one login can
|
||||
// collide, and logins are rate-limited per IP.
|
||||
|
||||
const BASE = (process.env.DX_BASE ?? '').replace(/\/+$/, '');
|
||||
const USER = process.env.DX_USER ?? '';
|
||||
const PASS = process.env.DX_PASS ?? '';
|
||||
const DOMAIN = process.env.DX_DOMAIN || 'default';
|
||||
const SYMBOLS = (process.env.DX_SYMBOLS ?? 'BTC/USD').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const ORDER_ACCOUNT = process.env.DX_ACCOUNT ?? '';
|
||||
const CONFIRM = process.env.DX_CONFIRM === 'yes';
|
||||
|
||||
if (!BASE.startsWith('https://') || !USER || !PASS) {
|
||||
console.error('Set DX_BASE (https://…/dxsca-web), DX_USER and DX_PASS.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let token = null;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const section = (title) => console.log(`\n── ${title} ${'─'.repeat(Math.max(0, 70 - title.length))}`);
|
||||
const show = (label, value) => console.log(`${label}:`, typeof value === 'string' ? value : JSON.stringify(value, null, 2)?.slice(0, 4000));
|
||||
|
||||
async function call(method, path, { params, body, headers } = {}) {
|
||||
const url = new URL(`${BASE}${path}`);
|
||||
for (const [k, v] of Object.entries(params ?? {})) url.searchParams.set(k, String(v));
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(token ? { Authorization: `DXAPI ${token}` } : {}),
|
||||
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
||||
...headers,
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
const text = await res.text();
|
||||
let data = null;
|
||||
try { data = text ? JSON.parse(text) : null; } catch { data = text.slice(0, 500); }
|
||||
return { status: res.status, headers: Object.fromEntries(res.headers), data };
|
||||
}
|
||||
|
||||
async function step(label, fn) {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
console.log(`${label} FAILED: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const codeOf = (a) => (typeof a === 'string' ? a : a && typeof a === 'object' ? `${a.clearing}:${a.account}` : String(a));
|
||||
|
||||
section('login');
|
||||
{
|
||||
const res = await call('POST', '/login', { body: { username: USER, domain: DOMAIN, password: PASS } });
|
||||
if (res.status !== 200 || !res.data?.sessionToken) {
|
||||
show(`login refused (HTTP ${res.status})`, res.data);
|
||||
console.log('Check DX_DOMAIN (try "default", then the broker name) — and stop after a couple of tries: repeated failures lock the account.');
|
||||
process.exit(1);
|
||||
}
|
||||
token = res.data.sessionToken;
|
||||
show('session timeout field (format check)', res.data.timeout);
|
||||
}
|
||||
|
||||
let accounts = [];
|
||||
section('accounts (/users)');
|
||||
for (const path of [`${USER}@${DOMAIN}`, USER]) {
|
||||
const res = await call('GET', `/users/${encodeURIComponent(path)}`);
|
||||
console.log(`/users/${path} → HTTP ${res.status}`);
|
||||
if (res.status === 200) {
|
||||
accounts = (res.data?.userDetails ?? []).flatMap((u) => u.accounts ?? []);
|
||||
for (const a of accounts) {
|
||||
console.log(` ${codeOf(a.account)} status=${a.accountStatus} positionBased=${a.positionBased} currency=${a.baseCurrency} registered=${a.registrationTime}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
const codes = accounts.map((a) => codeOf(a.account));
|
||||
const ref = codes[0];
|
||||
if (!ref) {
|
||||
console.log('No accounts found — nothing more to check.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
section('metrics (key casing: openPL vs openPl)');
|
||||
await step('metrics', async () => {
|
||||
const res = await call('GET', '/accounts/metrics', { params: { accounts: codes.join(',') } });
|
||||
show(`HTTP ${res.status}`, res.data?.metrics?.[0] ?? res.data);
|
||||
});
|
||||
|
||||
section('positions / open orders');
|
||||
await step('positions', async () => show('positions', (await call('GET', '/accounts/positions', { params: { accounts: codes.join(',') } })).data));
|
||||
await step('orders', async () => show('open orders', (await call('GET', '/accounts/orders', { params: { accounts: codes.join(',') } })).data));
|
||||
|
||||
section('transfers — types, signs, paging, the opening DEPOSIT, FINANCING');
|
||||
await step('transfers', async () => {
|
||||
const from = new Date(Date.now() - 180 * 86_400_000).toISOString();
|
||||
const res = await call('GET', '/accounts/transfers', { params: { accounts: codes.join(','), 'time-from': from, limit: 50 } });
|
||||
const txns = (res.data?.cashTransfers ?? []).flatMap((t) => (t.cashTransactions ?? []).map((x) => ({ ...x, transferTime: t.transactionTime })));
|
||||
const byType = {};
|
||||
for (const t of txns) {
|
||||
const e = (byType[t.type] ??= { count: 0, min: Infinity, max: -Infinity });
|
||||
e.count++; e.min = Math.min(e.min, t.value); e.max = Math.max(e.max, t.value);
|
||||
}
|
||||
show('by type (value range → sign convention)', byType);
|
||||
show('first / last transaction (ordering)', [txns[0], txns[txns.length - 1]]);
|
||||
show('nextPageTransactionTime', res.data?.nextPageTransactionTime ?? '(none)');
|
||||
});
|
||||
|
||||
section('instruments');
|
||||
await step('instruments', async () => {
|
||||
const res = await call('GET', '/instruments/query', { params: { symbols: SYMBOLS.join(',') } });
|
||||
for (const i of res.data?.instruments ?? []) {
|
||||
console.log(` ${i.symbol}: type=${i.type} priceIncrement=${i.priceIncrement} multiplier=${i.multiplier} lotSize=${i.lotSize} quantityIncrement=${i.quantityIncrement} currency=${i.currency} sessions=${(i.tradingHours ?? []).length}`);
|
||||
}
|
||||
show('first instrument, raw', res.data?.instruments?.[0]);
|
||||
});
|
||||
await step('account instrument details', async () => {
|
||||
const res = await call('GET', `/accounts/${encodeURIComponent(ref)}/instruments/query`, { params: { symbols: SYMBOLS.join(',') } });
|
||||
show('details', res.data?.instrumentDetails ?? res.data);
|
||||
});
|
||||
|
||||
section('quotes and hourly candles (σ)');
|
||||
await step('quotes', async () => {
|
||||
const res = await call('POST', '/marketdata', { body: { account: ref, symbols: SYMBOLS, eventTypes: [{ type: 'Quote', format: 'COMPACT' }] } });
|
||||
show(`Quote events (HTTP ${res.status})`, res.data?.events ?? res.data);
|
||||
});
|
||||
await step('candles', async () => {
|
||||
const now = Date.now();
|
||||
const res = await call('POST', '/marketdata', {
|
||||
body: {
|
||||
account: ref, symbols: SYMBOLS,
|
||||
eventTypes: [{ type: 'Candle', format: 'COMPACT', candleType: '1h', fromTime: new Date(now - 49 * 3_600_000).toISOString(), toTime: new Date(now).toISOString() }],
|
||||
},
|
||||
});
|
||||
const events = res.data?.events ?? [];
|
||||
console.log(`Candle events: HTTP ${res.status}, ${events.length} event(s); first: ${JSON.stringify(events[0])}`);
|
||||
for (const symbol of SYMBOLS) {
|
||||
const closes = events.filter((e) => e.symbol === symbol && typeof e.close === 'number').map((e) => e.close);
|
||||
const rets = closes.slice(1).map((c, i) => Math.log(c / closes[i]));
|
||||
const mean = rets.reduce((s, r) => s + r, 0) / Math.max(1, rets.length);
|
||||
const sd = Math.sqrt(rets.reduce((s, r) => s + (r - mean) ** 2, 0) / Math.max(1, rets.length - 1));
|
||||
console.log(` ${symbol}: ${closes.length} closes, σ ≈ ${(sd * 100).toFixed(3)}%/h ≈ ${(sd * Math.sqrt(24) * 100).toFixed(2)}%/day`);
|
||||
}
|
||||
});
|
||||
|
||||
section('end-of-day metrics vs the 22:00 UTC reset');
|
||||
await step('eodmetrics', async () => {
|
||||
const d = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
|
||||
const res = await call('GET', `/accounts/eodmetrics/${d}`, { params: { accounts: ref } });
|
||||
show(`eodmetrics ${d} (HTTP ${res.status}) — compare balance with the instance's dayStart debug value`, res.data);
|
||||
});
|
||||
|
||||
section('rate limits (two history calls back to back)');
|
||||
await step('rate', async () => {
|
||||
const a = await call('GET', '/accounts/transfers', { params: { accounts: ref, limit: 1 } });
|
||||
const b = await call('GET', '/accounts/transfers', { params: { accounts: ref, limit: 1 } });
|
||||
console.log(`HTTP ${a.status}, ${b.status}${b.status === 429 ? ' — history calls are limited to 1/s' : ''}`);
|
||||
});
|
||||
|
||||
section('ping');
|
||||
console.log(`POST /ping → HTTP ${(await call('POST', '/ping')).status}`);
|
||||
|
||||
if (CONFIRM && ORDER_ACCOUNT) {
|
||||
section(`LIVE ORDERS on ${ORDER_ACCOUNT}`);
|
||||
const acct = encodeURIComponent(ORDER_ACCOUNT);
|
||||
const symbol = SYMBOLS[0];
|
||||
const inst = (await call('GET', '/instruments/query', { params: { symbols: symbol } })).data?.instruments?.[0] ?? {};
|
||||
const det = (await call('GET', `/accounts/${acct}/instruments/query`, { params: { symbols: symbol } })).data?.instrumentDetails?.[0] ?? {};
|
||||
const qty = Math.max(det.minOrderSize ?? 0, inst.quantityIncrement ?? 0, det.minOrderSizeIncrement ?? 0) || 1;
|
||||
const code = `af-smoke-${Date.now().toString(36)}`;
|
||||
const open = { account: ORDER_ACCOUNT, orderCode: code, type: 'MARKET', instrument: symbol, quantity: qty, positionEffect: 'OPEN', side: 'BUY', tif: 'GTC' };
|
||||
const placed = await call('POST', `/accounts/${acct}/orders`, { body: open });
|
||||
show(`MARKET BUY ${qty} ${symbol} (HTTP ${placed.status})`, placed.data);
|
||||
show('duplicate orderCode (expect 409 / code 100)', (await call('POST', `/accounts/${acct}/orders`, { body: open })));
|
||||
await sleep(3_000);
|
||||
const pos = ((await call('GET', `/accounts/${acct}/positions`)).data?.positions ?? []).find((p) => p.symbol === symbol);
|
||||
show('position (positionCode should equal the orderId on position-based accounts)', pos);
|
||||
show('order history row (commission, averagePrice)', (await call('GET', `/accounts/${acct}/orders/history`, { params: { 'with-client-id': code, limit: 5 } })).data);
|
||||
if (pos) {
|
||||
const px = pos.openPrice;
|
||||
const tick = inst.priceIncrement || 0.01;
|
||||
const far = (p) => Math.round(p / tick) * tick;
|
||||
// Exactly what lib/dxtrade-class.ts sends: a closing STOP/LIMIT names
|
||||
// the position, never a quantity (code 33 otherwise).
|
||||
const leg = (c, type, price) => ({
|
||||
account: ORDER_ACCOUNT, orderCode: c, type, instrument: symbol, positionEffect: 'CLOSE',
|
||||
positionCode: pos.positionCode, side: 'SELL', tif: 'GTC', ...(type === 'LIMIT' ? { limitPrice: price } : { stopPrice: price }),
|
||||
});
|
||||
const group = await call('POST', `/accounts/${acct}/orders`, {
|
||||
body: { orders: [leg(`${code}-tp`, 'LIMIT', far(px * 1.05)), leg(`${code}-sl`, 'STOP', far(px * 0.95))], contingencyType: 'OCO' },
|
||||
});
|
||||
show(`native OCO group of CLOSE legs (HTTP ${group.status})`, group.data);
|
||||
if (group.status >= 400) {
|
||||
// The bot's fallback: the stop on its own first, then the take-profit.
|
||||
show('fallback: lone STOP CLOSE', (await call('POST', `/accounts/${acct}/orders`, { body: leg(`${code}-sl1`, 'STOP', far(px * 0.95)) })));
|
||||
show('fallback: lone LIMIT CLOSE', (await call('POST', `/accounts/${acct}/orders`, { body: leg(`${code}-tp1`, 'LIMIT', far(px * 1.05)) })));
|
||||
}
|
||||
show('open orders now (the exit pair should be here)', (await call('GET', `/accounts/${acct}/orders`)).data);
|
||||
const del = await call('DELETE', `/accounts/${acct}/orders/${encodeURIComponent(`${code}-tp`)}`);
|
||||
show(`cancel without If-Match (HTTP ${del.status}; 403/412/428 means it wants one)`, del.data);
|
||||
// Tradeify: every trade must be held at least 20 seconds.
|
||||
console.log('holding 22 s before closing (minimum hold)…');
|
||||
await sleep(22_000);
|
||||
const flatten = await call('POST', `/accounts/${acct}/close`, { body: { account: ORDER_ACCOUNT, closePositions: true, cancelOrders: true } });
|
||||
show(`flatten via /close (HTTP ${flatten.status})`, flatten.data);
|
||||
}
|
||||
await sleep(3_000);
|
||||
const recent = await call('GET', `/accounts/${acct}/transfers`, { params: { 'time-from': new Date(Date.now() - 10 * 60_000).toISOString() } });
|
||||
show('transactions of the last 10 minutes (commission = 0.04% × notional per side?)', recent.data);
|
||||
} else {
|
||||
console.log('\n(read-only: set DX_CONFIRM=yes and DX_ACCOUNT=<code> to also test a live minimum-size round trip)');
|
||||
}
|
||||
|
||||
await call('POST', '/logout');
|
||||
@@ -27,6 +27,9 @@ function run(cmd, args, opts = {}) {
|
||||
cwd: ROOT,
|
||||
encoding: 'utf8',
|
||||
shell: process.platform === 'win32',
|
||||
// The updater's worker is detached with no console, so without this
|
||||
// every python/pip child pops a visible window on the trading desktop.
|
||||
windowsHide: true,
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ PM2_NAME="autofirmer-$N"
|
||||
|
||||
node - "$REGISTRY" "$PM2_NAME" <<'NODE'
|
||||
const { readFileSync, writeFileSync } = require('node:fs');
|
||||
const [, file, name] = process.argv;
|
||||
// `node -` puts '-' in argv[1], so the arguments start at argv[2].
|
||||
const [file, name] = process.argv.slice(2);
|
||||
const entries = JSON.parse(readFileSync(file, 'utf8'));
|
||||
if (!entries.some((e) => e.name === name)) { console.error(` [X] ${name} is not registered`); process.exit(1); }
|
||||
writeFileSync(file, JSON.stringify(entries.filter((e) => e.name !== name), null, 4) + '\n');
|
||||
|
||||
+65
-10
@@ -67,11 +67,13 @@ db.exec(`
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS account_assignments (
|
||||
account_id INTEGER PRIMARY KEY,
|
||||
firm_id TEXT NOT NULL,
|
||||
account_type_id TEXT,
|
||||
manual INTEGER NOT NULL DEFAULT 0,
|
||||
resolved_at TEXT NOT NULL
|
||||
account_id INTEGER PRIMARY KEY,
|
||||
firm_id TEXT NOT NULL,
|
||||
account_type_id TEXT,
|
||||
manual INTEGER NOT NULL DEFAULT 0,
|
||||
resolved_at TEXT NOT NULL,
|
||||
rules_date TEXT,
|
||||
rules_date_manual INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
`);
|
||||
try {
|
||||
@@ -79,6 +81,36 @@ try {
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
// The rules-date migration, as lib/db.ts runs it — including its backfill:
|
||||
// adding the column here means lib/db.ts's own ALTER never runs on this file,
|
||||
// and rows the restore doesn't overwrite must not stay undated. Dated as
|
||||
// lib/db.ts undatedRowsDate dates them: now, never before the catalog's last
|
||||
// change (a clock running behind must not date a row into an old rule set).
|
||||
function undatedRowsDate() {
|
||||
const row = db.prepare("SELECT value FROM settings WHERE key = 'catalog_updated_at'").get();
|
||||
const floor = row && typeof row.value === 'string' ? Date.parse(row.value) : NaN;
|
||||
return new Date(Math.max(Date.now(), Number.isFinite(floor) ? floor : 0)).toISOString();
|
||||
}
|
||||
// lib/kept-rules.ts sortableIso: the only rules dates stored are plain
|
||||
// four-digit-year ISO strings from 2000 on, so string order is time order.
|
||||
function sortableIso(ms) {
|
||||
if (!Number.isFinite(ms) || ms < Date.UTC(2000, 0, 1) || Math.abs(ms) > 8.64e15) return null;
|
||||
const iso = new Date(ms).toISOString();
|
||||
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(iso) ? iso : null;
|
||||
}
|
||||
try {
|
||||
db.transaction(() => {
|
||||
db.exec('ALTER TABLE account_assignments ADD COLUMN rules_date TEXT');
|
||||
db.prepare('UPDATE account_assignments SET rules_date = ? WHERE rules_date IS NULL').run(undatedRowsDate());
|
||||
}).immediate();
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
try {
|
||||
db.exec('ALTER TABLE account_assignments ADD COLUMN rules_date_manual INTEGER NOT NULL DEFAULT 0');
|
||||
} catch {
|
||||
// Column already exists
|
||||
}
|
||||
|
||||
const upsert = db.prepare(
|
||||
'INSERT INTO settings (key, value) VALUES (?, ?) ' +
|
||||
@@ -134,14 +166,29 @@ async function restore() {
|
||||
'INSERT INTO instruments (symbol, enabled) VALUES (?, ?) ' +
|
||||
'ON CONFLICT(symbol) DO UPDATE SET enabled = excluded.enabled'
|
||||
);
|
||||
const setAssign = db.prepare(`
|
||||
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
// lib/db.ts restoreAssignment, by hand: the backup's rules date (and how
|
||||
// it was set) wins; a backup from an older build has none, so an existing
|
||||
// row keeps its own and a new one is dated at the restore.
|
||||
const setAssignDated = db.prepare(`
|
||||
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at, rules_date, rules_date_manual)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
firm_id = excluded.firm_id,
|
||||
account_type_id = excluded.account_type_id,
|
||||
manual = excluded.manual,
|
||||
resolved_at = excluded.resolved_at
|
||||
resolved_at = excluded.resolved_at,
|
||||
rules_date = excluded.rules_date,
|
||||
rules_date_manual = excluded.rules_date_manual
|
||||
`);
|
||||
const setAssignUndated = db.prepare(`
|
||||
INSERT INTO account_assignments (account_id, firm_id, account_type_id, manual, resolved_at, rules_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(account_id) DO UPDATE SET
|
||||
firm_id = excluded.firm_id,
|
||||
account_type_id = excluded.account_type_id,
|
||||
manual = excluded.manual,
|
||||
resolved_at = excluded.resolved_at,
|
||||
rules_date = COALESCE(account_assignments.rules_date, excluded.rules_date)
|
||||
`);
|
||||
|
||||
db.transaction(() => {
|
||||
@@ -162,7 +209,15 @@ async function restore() {
|
||||
setInstr.run(i.symbol, i.enabled ? 1 : 0);
|
||||
}
|
||||
for (const a of payload.assignments ?? []) {
|
||||
setAssign.run(a.account_id, a.firm_id, a.account_type_id ?? null, a.manual ? 1 : 0, a.resolved_at ?? new Date().toISOString());
|
||||
const now = new Date().toISOString();
|
||||
const resolvedAt = a.resolved_at ?? now;
|
||||
const rulesDate = typeof a.rules_date === 'string' ? sortableIso(Date.parse(a.rules_date)) : null;
|
||||
if (rulesDate) {
|
||||
setAssignDated.run(a.account_id, a.firm_id, a.account_type_id ?? null, a.manual ? 1 : 0, resolvedAt,
|
||||
rulesDate, a.rules_date_manual === 1 ? 1 : 0);
|
||||
} else {
|
||||
setAssignUndated.run(a.account_id, a.firm_id, a.account_type_id ?? null, a.manual ? 1 : 0, resolvedAt, now);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
+28
-5
@@ -4,10 +4,12 @@
|
||||
*
|
||||
* Run by the "at log on" scheduled task. Safe to run by hand at any time.
|
||||
*
|
||||
* The warm-up is not cosmetic. getClients() in lib/clients.ts is lazily
|
||||
* bootstrapped — Tradovate clients, the contract resolver, the reporter and the
|
||||
* persisted-schedule resume all start inside it — so until something makes an
|
||||
* HTTP request the process sits idle and none of that happens.
|
||||
* The warm-up used to be what started trading: getClients() in lib/clients.ts
|
||||
* was only bootstrapped by the first HTTP request. Builds with
|
||||
* instrumentation.ts boot it when the server starts, so the warm-up is now a
|
||||
* readiness check — it waits for each instance to answer, loads the dashboard
|
||||
* state once, and logs the scheduler and /api/health status. (On an older
|
||||
* build it still does the bootstrapping, so it stays.)
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { ensurePythonDeps } from './ensure-python-deps.mjs';
|
||||
@@ -58,7 +60,7 @@ for (const instance of loadInstances()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
log(`warming up ${instance.name} (this is what triggers the client bootstrap)`);
|
||||
log(`warming up ${instance.name}`);
|
||||
try {
|
||||
await fetch(`${base}/api/state`, { signal: AbortSignal.timeout(60_000) });
|
||||
} catch (err) {
|
||||
@@ -71,6 +73,27 @@ for (const instance of loadInstances()) {
|
||||
? `${instance.name}: scheduler resumed — ${status.action} ${status.symbol}`
|
||||
: `${instance.name}: scheduler is stopped`);
|
||||
} catch { /* non-critical */ }
|
||||
|
||||
// One line of client health. Right after boot a client or two may still
|
||||
// be logging in, so this reports; it doesn't fail the run. Builds from
|
||||
// before /api/health answer 404 — nothing to say then.
|
||||
try {
|
||||
const res = await fetch(`${base}/api/health`, { signal: AbortSignal.timeout(10_000) });
|
||||
if (res.status !== 404) {
|
||||
const health = await res.json();
|
||||
if (!res.ok) {
|
||||
log(`${instance.name}: /api/health failed — ${health.error ?? `HTTP ${res.status}`}`);
|
||||
} else {
|
||||
// Firms without a client yet aren't failing to sync — same
|
||||
// rule as the route's own `ok` (and the updater's line).
|
||||
const clients = (Array.isArray(health.clients) ? health.clients : [])
|
||||
.filter((c) => c?.hasClient !== false);
|
||||
const waiting = clients.filter((c) => !c.ready).map((c) => c.firm);
|
||||
log(`${instance.name}: ${clients.length - waiting.length}/${clients.length} firm(s) ready`
|
||||
+ (waiting.length > 0 ? ` — not ready yet: ${waiting.join(', ')}` : ''));
|
||||
}
|
||||
}
|
||||
} catch { /* non-critical */ }
|
||||
}
|
||||
|
||||
if (failed) process.exit(1);
|
||||
|
||||
+1032
-85
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,11 @@
|
||||
import type { FirmPlatform } from './lib/master-protocol';
|
||||
import type { KeptRulesWindow } from './lib/kept-rules';
|
||||
|
||||
/** When one of an account type's old rule sets applies: accounts whose rules
|
||||
* date is inside [openedAt, closedAt) keep those rules (openedAt null = no
|
||||
* lower bound). `id` is the kept set's own, never an account type id. */
|
||||
export type { KeptRulesWindow };
|
||||
|
||||
export interface AccountConfig {
|
||||
/** Master's account type id — what a manual assignment pins to. */
|
||||
id: string;
|
||||
@@ -8,7 +16,7 @@ export interface AccountConfig {
|
||||
minDayPnL: number;
|
||||
minTradingDays: number;
|
||||
accountSize: number;
|
||||
maxLoss: number; // 0 = no limit; positive = account is "Dead" when loss exceeds this
|
||||
maxLoss: number; // static max drawdown below the starting size (0 = none); with a hard daily loss, decides last stands
|
||||
maxPositionSize: number; // 0 = no limit; positive = max contracts per trade
|
||||
targetSameEquity: boolean; // if true, reduce profitTarget by priorProfit after withdrawal
|
||||
splitEnabled: boolean; // false = big days trade as one unsplit leg instead of random ≥$500 pieces
|
||||
@@ -27,6 +35,22 @@ export interface AccountConfig {
|
||||
payoutCap?: number;
|
||||
profitSplit?: number;
|
||||
trailingDrawdown?: number;
|
||||
/** Hard daily loss limit in dollars (0/absent = none): every leg carries a
|
||||
* stop that keeps the firm's day inside it, less a reserve and fees. */
|
||||
dailyLossLimit?: number;
|
||||
/** The stage is picked by the payouts since the firm's last daily reset,
|
||||
* and every new day starts back at the base stage (Vest). */
|
||||
stagesSameDay?: boolean;
|
||||
/** The firm trades every calendar day, weekends and CME holidays included. */
|
||||
tradesEveryDay?: boolean;
|
||||
}
|
||||
|
||||
/** An account type as the firm listing (/api/firms) carries it: the current
|
||||
* rules, plus the windows of the old rule sets it keeps, so the dashboard can
|
||||
* preview which rules a rules date lands on. */
|
||||
export interface FirmAccountType extends AccountConfig {
|
||||
/** Sorted by closedAt; empty when every account is on the current rules. */
|
||||
kept: KeptRulesWindow[];
|
||||
}
|
||||
|
||||
export interface FirmConfig {
|
||||
@@ -35,17 +59,22 @@ export interface FirmConfig {
|
||||
firm: string;
|
||||
/** Which platform the firm's accounts trade on — decides both which client
|
||||
* drives it and which credential fields the dashboard asks for. */
|
||||
platform: 'tradovate' | 'topstepx';
|
||||
platform: FirmPlatform;
|
||||
/** Blank when this machine has no credentials for the firm. */
|
||||
username: string;
|
||||
/** True once the platform's credentials are stored locally — Tradovate
|
||||
* username + password, or a TopstepX API key. Firms without them are
|
||||
* shown greyed out with an inline credential form. */
|
||||
/** True once the platform's credentials are stored locally — a username +
|
||||
* password (Tradovate, DXTrade) or a TopstepX API key. Firms without them
|
||||
* are shown greyed out with an inline credential form. */
|
||||
hasCredentials: boolean;
|
||||
bannedSymbols: string[];
|
||||
/** What the firm trades (lib/instruments.ts): CME roots for futures
|
||||
* firms, its own symbols for DXtrade. */
|
||||
instruments: string[];
|
||||
/** 'master' = the catalog's list; 'legacy' = a master from before the
|
||||
* lists, read the old way (this machine's enabled set less bans). */
|
||||
instrumentsSource: 'master' | 'legacy';
|
||||
/** The firm's account types, straight from the catalog. Doubles as the
|
||||
* option list for a manual account-type override. */
|
||||
accounts: AccountConfig[];
|
||||
accounts: FirmAccountType[];
|
||||
catalogUpdatedAt: string;
|
||||
}
|
||||
|
||||
@@ -55,6 +84,9 @@ export interface AccountState {
|
||||
active: boolean;
|
||||
amount: number;
|
||||
realizedPnL: number;
|
||||
/** The firm's raw trade date (tradeDateFor): Vest's turns at 20:00 ET,
|
||||
* every other firm's at 5 PM CT. */
|
||||
tradeDate: string;
|
||||
daysTraded: number;
|
||||
positionDirection: 'long' | 'short' | null;
|
||||
/** Balance floor from Tradovate's auto-liquidation profile (0 = not set) */
|
||||
@@ -70,18 +102,52 @@ export interface AccountState {
|
||||
* consistency and minimum days all met (cycleCompletion 'today'). Only
|
||||
* the final day; the firm may lock or remove the account afterwards. */
|
||||
completed: boolean;
|
||||
/** Current stage = 1 + number of withdrawals (negative fund transactions). Stage 1 = fresh account. */
|
||||
/** Current stage = 1 + number of withdrawals (negative fund transactions,
|
||||
* or the platform's own list where a row holds several; today's for a
|
||||
* type whose stages reset each day). Stage 1 = fresh account. */
|
||||
stage: number;
|
||||
/** True when the current stage is a drain stage (spam-withdraw mode). */
|
||||
stageDrain: boolean;
|
||||
/** Next trading day's target, computed server-side. null when account is dead or config is missing. */
|
||||
dailyTarget: { amount: number; path: 'first_day' | 'normal_day' | 'reduced_day' } | null;
|
||||
/** Dollars the next trade may lose before its obfuscation stop fires.
|
||||
* null = the next trade rides stopless (options off, big non-first day,
|
||||
* or a make-back day after a loss). */
|
||||
/** Dollars of price movement the next trade may lose before its stop
|
||||
* fires — the tighter of the obfuscation stop and the hard daily-loss
|
||||
* stop, before fees. null = the next trade rides stopless (options off,
|
||||
* big non-first day, or a make-back day after a loss, with no daily
|
||||
* loss limit). */
|
||||
nextStop: number | null;
|
||||
/** What the firm's day may still lose before a hard daily-loss breach.
|
||||
* null = no daily loss limit (or its day-start balance isn't known yet). */
|
||||
dllRoom: number | null;
|
||||
/** DXtrade: how the next leg would be sized and how long it is expected to
|
||||
* hold, from today's measured volatility of the last-picked instrument.
|
||||
* null on other platforms, or before the day's first pick. */
|
||||
holdEstimate: {
|
||||
hours: number;
|
||||
symbol: string;
|
||||
notional: number;
|
||||
/** Notional ÷ account size. */
|
||||
multiplier: number;
|
||||
volatilityPerDay: number;
|
||||
winProbability: number | null;
|
||||
/** The same leg with no commission, spread or swap. */
|
||||
winProbabilityNoFees: number | null;
|
||||
/** Expected swap a winning leg pays, added to its take-profit. */
|
||||
swapReserve: number;
|
||||
/** The broker's leverage on the symbol (5 = 5:1); null when unstated. */
|
||||
leverage: number | null;
|
||||
/** Expected spread, commissions and swap for the leg. */
|
||||
cost: number;
|
||||
} | null;
|
||||
/** The next leg rides without a daily-loss stop: a stop-out would leave
|
||||
* the account too close to its max-drawdown floor to trade on. */
|
||||
lastStand: boolean;
|
||||
/** Actual profit target to hit — max(stage profit target, consistency realTarget = maxDay/consistency). */
|
||||
effectiveProfitTarget: number | null;
|
||||
/** The current stage's hard daily loss limit (0 = none) and minimum
|
||||
* trading days — a withdrawal stage may set its own. null = unassigned. */
|
||||
effectiveDailyLossLimit: number | null;
|
||||
effectiveMinTradingDays: number | null;
|
||||
/** Current cycle daily P&L — used for target calculations. */
|
||||
dailyPnL: { date: string; pnl: number }[];
|
||||
/** Full P&L history across all cycles — used for calendar and equity curve display. */
|
||||
@@ -98,6 +164,20 @@ export interface AccountState {
|
||||
inferredAccountSize: number | null;
|
||||
/** True when more than one account type matched and updatedAt decided it. */
|
||||
accountTypeAmbiguous: boolean;
|
||||
/** ISO. Decides which of the type's rule sets the account trades on: one
|
||||
* dated before a firm's rule change keeps the old rules. Defaults to when
|
||||
* the account started (broker's first deposit / registration), else when
|
||||
* this machine first saw it. null = no assignment row yet. */
|
||||
rulesDate: string | null;
|
||||
/** True when the rules date was set by hand — broker evidence never moves it. */
|
||||
rulesDateManual: boolean;
|
||||
/** The old rule set the account trades on; null = the type's current
|
||||
* rules. accountType above already carries these rules. */
|
||||
keptRules: (KeptRulesWindow & {
|
||||
/** Rule fields where the kept set differs from the type's current
|
||||
* rules — raw values; the dashboard formats them. */
|
||||
differs: { field: string; kept: unknown; now: unknown }[];
|
||||
}) | null;
|
||||
}
|
||||
|
||||
export interface FirmState {
|
||||
@@ -108,3 +188,30 @@ export interface FirmState {
|
||||
connected: boolean;
|
||||
accounts: AccountState[];
|
||||
}
|
||||
|
||||
/** One recorded position (GET /api/trades) — Vest's position history plus
|
||||
* what this instance did itself. */
|
||||
export interface TradeView {
|
||||
positionId: string;
|
||||
accountId: number;
|
||||
accountName: string;
|
||||
symbol: string;
|
||||
side: 'long' | 'short';
|
||||
quantity: number;
|
||||
openPrice: number | null;
|
||||
closePrice: number | null;
|
||||
/** Realized P&L before fees and funding. */
|
||||
pnl: number | null;
|
||||
fee: number | null;
|
||||
funding: number | null;
|
||||
/** pnl − fee + funding; null while open (or unknown). */
|
||||
net: number | null;
|
||||
openedAt: number;
|
||||
closedAt: number | null;
|
||||
/** null: opened before this instance began marking its own trades. */
|
||||
openedBy: 'bot' | 'outside' | null;
|
||||
/** null while open; else 'bot', 'outside' (closed by hand or another
|
||||
* tool), or the platform's own reason (stop_loss, take_profit,
|
||||
* liquidation, …; user_requested from before marking began). */
|
||||
closedBy: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user