Compare commits

...
19 Commits
Author SHA1 Message Date
Brandon LiandClaude Opus 5.5 1782c16145 Sizing: the broker's largest and smallest order bind the leg's size
fireLeg clamped the order to the instrument's max and min size, but the
size the pick ranked by (and the Firms page shows) didn't: an instrument
whose largest order falls short of the ideal notional ranked on a hold it
can't reach. planLegSize now holds the notional to the margin cap and the
broker's largest order, then its smallest, so the ranking, the stats and
the trade agree.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 16:04:15 -05:00
33 changed files with 6387 additions and 745 deletions
+99 -4
View File
@@ -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,97 @@ 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 trade the micro of a
listed product (MNQ when NQ is listed).
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) 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).
---
## The master dashboard
Set **Instance Name** and **Dashboard URL** on the Settings page and this
+20
View File
@@ -0,0 +1,20 @@
import { NextRequest, NextResponse } from 'next/server';
import { fireManualLeg } from '@/lib/auto-trade';
import { errorMessage } from '@/lib/platform-client';
type Params = { params: Promise<{ id: string }> };
/** Fire one leg on this account now, through the scheduler's own path (see
* fireManualLeg). Body: { action?: 'Buy' | 'Sell' | 'Auto' } (default Auto). */
export async function POST(req: NextRequest, { params }: Params) {
const { id } = await params;
const accountId = Number(id);
if (!Number.isSafeInteger(accountId)) return NextResponse.json({ error: 'bad account id' }, { status: 400 });
const body = (await req.json().catch(() => ({}))) as { action?: unknown };
const action = body.action === 'Buy' || body.action === 'Sell' ? body.action : 'Auto';
try {
return NextResponse.json(await fireManualLeg(accountId, action));
} catch (err) {
return NextResponse.json({ error: errorMessage(err) }, { status: 409 });
}
}
+13 -11
View File
@@ -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,11 @@ 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. */
export async function PUT(req: NextRequest, { params }: Params) {
const { id } = await params;
@@ -27,12 +28,13 @@ 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'
: 'username and password are required';
return NextResponse.json({ error }, { status: 400 });
}
try {
+9
View File
@@ -1,4 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCachedFirm } from '@/lib/firm-catalog';
import { firmInstruments, isFuturesFirm } from '@/lib/instruments';
import { getClients } from '@/lib/clients';
export async function POST(
@@ -26,6 +28,13 @@ export async function POST(
const client = clients.get(firmId);
if (!client) return NextResponse.json({ error: 'Client not found for firm' }, { status: 404 });
// A futures firm trades only what its instrument list (master Firms
// page) names.
const firm = getCachedFirm(firmId);
if (firm && isFuturesFirm(firm) && !firmInstruments(firm).includes(symbol)) {
return NextResponse.json({ error: `${symbol} isn't on ${firm.name}'s instrument list` }, { status: 400 });
}
// 1. Find the active contract chosen by the shared resolver
const contract = await client.findFrontMonthContract(symbol);
if (!contract) {
+3 -1
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCachedFirm } 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,7 +23,8 @@ export async function GET(
firm: firm.name,
username: creds?.username ?? '',
hasCredentials: !!creds,
bannedSymbols: firm.bannedSymbols,
instruments: firmInstruments(firm),
instrumentsSource: instrumentsSource(firm),
accounts: firm.accountTypes,
catalogUpdatedAt: firm.updatedAt,
});
+7 -5
View File
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server';
import { getCachedCatalog } from '@/lib/firm-catalog';
import { firmInstruments, instrumentsSource } from '@/lib/instruments';
import { getCachedCatalog, hasPlatformCredentials } from '@/lib/firm-catalog';
import { getCredentials } from '@/lib/db';
import type { FirmConfig } from '@/types';
@@ -18,16 +19,15 @@ 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 +46,8 @@ export async function GET() {
minDayStop: a.minDayStop,
combineCmeHolidays: a.combineCmeHolidays,
withdrawalStages: a.withdrawalStages,
dailyLossLimit: a.dailyLossLimit ?? 0,
tradesEveryDay: a.tradesEveryDay === true,
})),
catalogUpdatedAt: firm.updatedAt,
};
+3 -3
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { getClients } from '@/lib/clients';
import { getInstruments } from '@/lib/db';
import { futuresRootsHere } from '@/lib/instruments';
import { resolveContracts, getAllCachedContracts } from '@/lib/contract-resolver';
/**
@@ -15,7 +15,7 @@ export async function GET() {
* POST /api/instruments/contracts
* Triggers a fresh resolve via Tradovate + Yahoo Finance.
* Body (optional): { symbols: string[] } — when provided, resolves only those symbols.
* Otherwise resolves all enabled instruments.
* Otherwise resolves every root this machine's futures firms list.
*/
export async function POST(req: NextRequest) {
try {
@@ -35,7 +35,7 @@ export async function POST(req: NextRequest) {
const symbols = requestedSymbols?.length
? requestedSymbols
: getInstruments().filter((i) => i.enabled).map((i) => i.symbol);
: futuresRootsHere();
const resolved = await resolveContracts(symbols, accessToken);
return NextResponse.json(resolved);
+34 -4
View File
@@ -1,6 +1,36 @@
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, simulateDraws,
} from '@/lib/instruments';
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: a tick
* of spread, one contract's daily move, the hold of a $1,000 leg, the chance.
* ?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(), firms });
}
+69 -23
View File
@@ -4,9 +4,13 @@ import { getClients } from '@/lib/clients';
import { getCachedCatalog } from '@/lib/firm-catalog';
import { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/account-type-resolver';
import { historyFresh } from '@/lib/platform-client';
import { rankNextLeg } from '@/lib/leg-estimate';
import type { AccountState } from '@/types';
import {
combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, currentTradeDateCT,
BIG_DAY_THRESHOLD, FIRST_DAY_STOP, LEG_COMPLETION_EPSILON, MIN_DAY_STOP_MULT,
obfuscationStopDollars, dllRoom as computeDllRoom, dllStopDollars, botStopsActive,
maxLossRoom, lastStand as isLastStand,
LEG_COMPLETION_EPSILON,
} from '@/lib/trading-logic';
export async function GET() {
@@ -84,6 +88,9 @@ export async function GET() {
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,24 +101,59 @@ 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 = cfg.dailyLossLimit ?? 0;
if (dll > 0) {
const dayStart = client.dayStartBalance?.(acc.id);
dllRoom = dayStart === null ? null : computeDllRoom({
dailyLossLimit: dll,
closedBalance: cash.amount,
dayStartBalance: dayStart,
realizedToday: Math.min(cash.realizedPnL, combined.realizedPnL),
});
}
// 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],
});
lastStandNext = dllRoom !== null && isLastStand({ dllRoom, dailyLossLimit: dll, maxLossRoom: floorRoom });
if (plan && (botStopsActive(cfg))) {
const obf = obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday);
const hard = dllRoom !== null && !lastStandNext
? dllStopDollars({ room: dllRoom, dailyLossLimit: dll, entryFee: 0, commissionPct: 0 })
: 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;
}
// DXtrade: the next leg sized on every surveyed instrument as
// fireLeg would, and the one a batch would pick.
if (plan && plan.remainingToday > 0) {
const best = rankNextLeg({
client, accountId: acc.id, cfg, firm: f, leg: plan.remainingToday, dllRoom,
obfuscationDollars: obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday),
cash,
})[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,
};
}
}
@@ -187,6 +229,7 @@ export async function GET() {
cycleDailyPnL: client.dailyPnL[acc.id],
rawRealizedPnL: cash.realizedPnL,
combineCmeHolidays: cfg.combineCmeHolidays,
tradesEveryDay: cfg.tradesEveryDay === true,
effective,
equityProfit: cash.amount - cfg.accountSize,
splitEnabled: cfg.splitEnabled,
@@ -202,10 +245,10 @@ export async function GET() {
// on the Tuesday after a holiday this includes Sun/Mon.
realizedPnL: combined.realizedPnL,
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 && botStopsActive(cfg)
&& acc.active && !client.positions[acc.id]
&& combined.realizedPnL <= -25,
positionDirection: client.positions[acc.id]
@@ -220,6 +263,9 @@ export async function GET() {
stageDrain: effective?.drain ?? false,
dailyTarget,
nextStop,
dllRoom,
holdEstimate,
lastStand: lastStandNext,
remainingToday,
effectiveProfitTarget,
dailyPnL,
+2 -2
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { runTrade, startScheduler } from '@/lib/auto-trade';
import { errorMessage } from '@/lib/platform-client';
import { POINT_VALUES } from '@/lib/trading-logic';
import { CME_SPECS } from '@/lib/instrument-specs';
export async function POST(req: NextRequest) {
try {
@@ -12,7 +12,7 @@ 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 });
}
+10 -5
View File
@@ -137,20 +137,25 @@ export default function FirmSettingsPage() {
</div>
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mt-10 mb-3">
Banned Symbols
Instruments
</h2>
<div className="bg-white border border-slate-200 rounded-xl p-4 shadow-sm">
{!firm || firm.bannedSymbols.length === 0 ? (
<span className="text-sm text-slate-400 italic">None</span>
{!firm || firm.instruments.length === 0 ? (
<span className="text-sm text-slate-400 italic">None — this firm won&apos;t trade</span>
) : (
<div className="flex flex-wrap gap-1.5">
{firm.bannedSymbols.map((sym) => (
<span key={sym} className="px-2 py-1 rounded-md text-xs font-mono font-semibold bg-red-100 text-red-700">
{firm.instruments.map((sym) => (
<span key={sym} className="px-2 py-1 rounded-md text-xs font-mono font-semibold bg-slate-100 text-slate-700">
{sym}
</span>
))}
</div>
)}
<p className="mt-2 text-xs text-slate-400">
{firm?.instrumentsSource === 'legacy'
? 'The master doesn\'t send instrument lists yet: this machine\'s enabled instruments, less the firm\'s banned symbols.'
: 'Edited on the master\'s Firms page.'}
</p>
</div>
</div>
+54 -18
View File
@@ -188,9 +188,19 @@ 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';
}
}
/** 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). */
function CredentialForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => void }) {
const isTopstepX = firm.platform === 'topstepx';
const [username, setUsername] = useState('');
@@ -231,7 +241,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)}
/>
@@ -375,7 +385,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 — 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 +422,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>}
@@ -468,9 +498,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' && 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 +508,11 @@ 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 === '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 +541,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}
@@ -532,7 +567,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
<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>
@@ -554,7 +589,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
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>
) : (
@@ -594,7 +629,7 @@ export default function Home() {
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');
@@ -646,12 +681,13 @@ 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(() => {});
@@ -804,8 +840,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
+99 -90
View File
@@ -3,9 +3,30 @@
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[];
firms: {
id: string;
name: string;
platform: string;
source: 'master' | 'legacy';
instruments: string[];
stats?: {
maxHoldHours: number;
sessionProgress: number | null;
instruments: {
symbol: string;
listed: boolean;
spreadDollars: number;
dailyMoveDollars: number | null;
holdHours: number | null;
disabled: boolean;
chance: number | null;
}[];
};
}[];
}
interface ResolvedContract {
@@ -33,7 +54,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');
@@ -75,7 +96,8 @@ export default function SettingsPage() {
useEffect(() => {
fetch('/api/instruments')
.then((r) => r.json())
.then(setInstruments);
.then(setOverview)
.catch(() => {});
fetchCatalog();
@@ -125,33 +147,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);
@@ -443,68 +438,82 @@ export default function SettingsPage() {
</div>
{/* ── Instruments ── */}
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-1">
Instruments
</h2>
<p className="text-xs text-slate-400 mb-3">
What each firm trades is set on the master&apos;s{' '}
{masterUrl ? <a className="text-blue-500 hover:underline" href={`${masterUrl.replace(/\/+$/, '')}/firms`} target="_blank" rel="noreferrer">Firms page</a> : 'Firms page'}.
Futures &ldquo;Auto&rdquo; draws one per batch: cheaper per move early in the session (every instrument gets a floor),
the fastest near the last entry, never one expected to hold 12h or more. Holds below are for a $1,000 leg.
</p>
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm">
{instruments.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-slate-400 italic">
Loading…
{!overview ? (
<div className="bg-white border border-slate-200 rounded-xl px-4 py-8 text-center text-sm text-slate-400 italic shadow-sm">Loading…</div>
) : overview.firms.map((firm) => (
<div key={firm.id} className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm mb-3">
<div className="flex items-center gap-2 px-4 py-2.5 bg-slate-50 border-b border-slate-200">
<span className="font-semibold text-sm text-slate-800">{firm.name}</span>
<span className="text-xs text-slate-400">{firm.platform}</span>
{firm.source === 'legacy' && (
<span className="text-xs bg-amber-50 text-amber-700 border border-amber-200 rounded px-1.5 py-0.5" title="The master doesn't send instrument lists yet: this machine's enabled instruments, less the firm's banned symbols">
from this machine (older master)
</span>
)}
{firm.instruments.length === 0 && <span className="text-xs text-amber-700">no instruments — won&apos;t trade</span>}
</div>
) : (
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b border-slate-200 bg-slate-50">
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Symbol</th>
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Active Contract</th>
<th className="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-slate-400">Enabled</th>
</tr>
</thead>
<tbody>
{instruments.map((instr) => {
const c = contracts[instr.symbol];
const wasRolled = !!c?.alternative;
return (
<tr key={instr.symbol} className="border-b border-slate-100 last:border-0">
<td className="px-4 py-2.5 font-mono font-semibold text-slate-800">
{instr.symbol}
</td>
<td className="px-4 py-2.5">
{instr.enabled && c ? (
<div className="flex items-center gap-2">
<span className={`font-mono text-sm ${wasRolled ? 'text-amber-600 font-semibold' : 'text-slate-600'}`}>
{c.name}
</span>
{wasRolled && (
<span className="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-medium">
rolled from {c.alternative}
</span>
)}
</div>
) : null}
</td>
<td className="px-4 py-2.5 text-right">
<button
onClick={() => toggle(instr.symbol, !instr.enabled)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none ${
instr.enabled ? 'bg-blue-500' : 'bg-slate-200'
}`}
>
<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>
{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 text-slate-600" title="One tick of spread on one contract — what entering costs, commissions aside">
{x ? `$${x.spreadDollars.toFixed(2)}` : '—'}
</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>
);
+877 -473
View File
File diff suppressed because it is too large Load Diff
+68 -19
View File
@@ -1,8 +1,11 @@
import { TradovateClient } from './tradovate-class';
import { TopstepXClient } from './topstepx-class';
import { DXTradeClient } from './dxtrade-class';
import type { FirmPlatform } from './master-protocol';
import { errorMessage, isClientReady, type PlatformClient } from './platform-client';
import { getInstruments } from './db';
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';
@@ -25,6 +28,7 @@ const g = global as typeof globalThis & {
__contractResolverTimer?: ReturnType<typeof setInterval>;
__contractResolveRetryTimer?: ReturnType<typeof setTimeout>;
__contractResolveRetries?: number;
__contractResolvedRoots?: Set<string>;
__clientSupervisorTimer?: ReturnType<typeof setInterval>;
__clientSupervision?: Map<string, SupervisionEntry>;
};
@@ -43,15 +47,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 () => {
@@ -73,9 +80,29 @@ 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;
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;
@@ -166,6 +193,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);
@@ -178,8 +207,10 @@ 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();
@@ -217,6 +248,18 @@ export function getClients(): Map<string, PlatformClient> {
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);
@@ -259,7 +302,8 @@ function triggerContractResolve(): void {
// Runs from timers: a throw here (the instruments read) would escape as
// an uncaught exception.
try {
const symbols = getInstruments().filter((i) => i.enabled).map((i) => i.symbol);
const symbols = resolveRootsHere();
g.__contractResolvedRoots = new Set(symbols);
console.log(`[contract-resolver] Resolving ${symbols.length} instruments...`);
resolveContracts(symbols, accessToken)
.then((results) => {
@@ -284,6 +328,7 @@ function scheduleDailyResolve(): void {
// Its own half hour of retries, not what's left of a chain it cuts off.
g.__contractResolveRetries = 0;
triggerContractResolve();
prewarmMarket();
}
}, 60_000);
}
@@ -316,11 +361,16 @@ const SUPERVISOR_INTERVAL_MS = 60_000;
const REBUILD_AFTER_UNREADY_MS = 20 * 60_000;
const REBUILD_COOLDOWN_MS = 20 * 60_000;
/** The message TopstepXClient records when a firm has an API key but no
* username. That client never even tries to log in, so a rebuild changes
* nothing — adding the username changes the credentials, and
* reconcileClients rebuilds it then. */
const TOPSTEPX_USERNAME_MISSING = /username missing/i;
/** 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. */
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;
return false;
}
interface SupervisionEntry {
/** The instance being judged. A different one (rebuilt here, or replaced
@@ -383,8 +433,8 @@ function rebuildHold(platform: string, client: PlatformClient, now: number): str
+ ` (${health.consecutiveLoginFailures} failure(s) in a row)`;
}
} catch { /* no health: judge it on the rest */ }
if (platform === 'topstepx' && Object.keys(client.positions ?? {}).length > 0) {
return 'it holds an open position (TopstepX exit brackets are tracked only inside the client)';
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;
}
@@ -429,8 +479,7 @@ function superviseClients(): void {
firms ??= getTradableFirms();
const firm = firms.find((f) => f.id === id);
if (!firm) continue; // no longer tradable: reconcileClients drops it
if (firm.platform === 'topstepx'
&& TOPSTEPX_USERNAME_MISSING.test(client.lastFetchErrors?.['login'] ?? '')) continue;
if (needsAttention(firm.platform, client)) continue;
const minutes = Math.round((now - entry.unhealthySince) / 60_000);
const hold = rebuildHold(firm.platform, client, now);
+1786
View File
File diff suppressed because it is too large Load Diff
+330
View File
@@ -0,0 +1,330 @@
/**
* 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;
}
+98 -19
View File
@@ -17,7 +17,11 @@
* the two are not linked yet.)
*/
import type { CatalogAccountType, CatalogFirm, FirmCatalogPayload } from './master-protocol';
import { isFirmPlatform } from './master-protocol';
import { sanitizeInstruments } from './instrument-specs';
import type {
CatalogAccountType, CatalogFirm, FirmCatalogPayload, FirmPlatform, SymbolMap, SymbolMapCandidate,
} from './master-protocol';
import {
getSetting,
setSetting,
@@ -99,26 +103,67 @@ export function getCatalogMeta(): {
};
}
/** Whether stored credentials are what the firm's platform logs in with — a
* TopstepX API key, or a username + password (Tradovate, DXTrade). */
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 '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 ─────────────────────────────────────────────────────────────────────
const g2 = global as typeof globalThis & { __catalogUnknownPlatformWarned?: 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 isStages(value: unknown): value is { profit: number; consistency: number; minTradingDays: number }[] {
return Array.isArray(value) && value.every((s) =>
typeof s === 'object' && s !== null
@@ -136,12 +181,27 @@ 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[] = [];
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);
return;
}
// Percent commission and every-day trading are DXtrade settings;
// futures firms keep measured per-contract fees and the CME calendar.
const dx = platform === 'dxtrade';
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) => {
@@ -163,26 +223,39 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
return {
...type,
splitEnabled: type.splitEnabled !== false,
combineCmeHolidays: type.combineCmeHolidays === true,
// 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),
// 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;
});
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) } : {}),
bannedSymbols: Array.isArray(firm.bannedSymbols) ? (firm.bannedSymbols as string[]) : [],
accountTypes,
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),
updatedAt: typeof firm.updatedAt === 'string' ? firm.updatedAt : new Date(0).toISOString(),
};
});
});
return { version: body.version, updatedAt: body.updatedAt, firms };
@@ -233,8 +306,12 @@ 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.
const hasCache = getCachedCatalog().length > 0;
if (hasCache) url.searchParams.set('since', String(version));
const source = url.origin;
if (hasCache && getSetting('catalog_source_url') === source) url.searchParams.set('since', String(version));
try {
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
@@ -242,6 +319,7 @@ export async function syncCatalog(): Promise<{ ok: boolean; changed: boolean; ve
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 };
@@ -273,6 +351,7 @@ export async function syncCatalog(): Promise<{ ok: boolean; changed: boolean; ve
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
+89
View File
@@ -0,0 +1,89 @@
/**
* Public Hyperliquid market data — the price source for DXtrade firms whose
* API user isn't allowed DXtrade's own /marketdata (Tradeify247 answers
* "No proper permission is configured", code 34). Tradeify247's instruments
* track Hyperliquid's: crypto on the main dex (BTC, ETH), indices and
* tokenized stocks on the trade[XYZ] HIP-3 dex (xyz:XYZ100, xyz:SP500…).
*
* Only the instrument pick uses it — spread for the cost score, hourly closes
* for volatility, the mid for sizing. Orders, fills, balances and every
* take-profit/stop price still come from DXtrade and its own fill prices.
*
* No key, no account: POST https://api.hyperliquid.xyz/info.
*/
import axios from 'axios';
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> {
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);
}
+267
View File
@@ -0,0 +1,267 @@
/**
* 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. There are no live futures quotes, and
* a liquid front month is one tick wide. */
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;
}
/** 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 (0 = no cap) — the rule fireLeg sizes by. */
export function futuresContracts(leg: number, maxPositionSize: number): number {
const raw = Math.max(1, Math.ceil(leg / 1000));
return maxPositionSize > 0 ? Math.min(raw, maxPositionSize) : raw;
}
/**
* 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:05 → 14:55 CT the next day; 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 + 5;
const length = 24 * 60 - open + 14 * 60 + 55; // 21h50m
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;
}
/**
* 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`), 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[] } {
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);
let pool = candidates.filter((c) => !disabled.includes(c.symbol));
if (pool.some((c) => !c.micro)) pool = pool.filter((c) => !c.micro);
if (pool.length === 0) return { chances: [], disabled };
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,
};
}
/** 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 a firm's drain and extra-day legs trade — tiny, so a micro: MNQ when
* NQ or MNQ is listed (as always), else the micro of a listed full-size
* contract, else a listed micro, else the listed root with the smallest tick
* value. null for an empty list.
*/
export function housekeepingRoot(listed: readonly string[]): string | null {
const roots = listed.filter((r) => CME_SPECS[r]);
if (roots.length === 0) return null;
if (roots.includes('NQ') || roots.includes('MNQ')) return 'MNQ';
const twin = roots.find((r) => MICRO_OF[r]);
if (twin) return MICRO_OF[twin];
const micro = roots.find((r) => CME_SPECS[r].micro);
if (micro) return micro;
return [...roots].sort((a, b) => tickValue(a) - tickValue(b))[0];
}
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 [];
const futures = platform !== 'dxtrade';
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') {
const map = firm.symbolMap ?? {};
const keys = Object.keys(map).sort((a, b) => (a === '*' ? -1 : b === '*' ? 1 : a.localeCompare(b)));
return { roots: sanitizeInstruments('dxtrade', 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' };
}
+247
View File
@@ -0,0 +1,247 @@
/**
* 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 one tick. Commissions play no part — they barely
* differ between instruments.
*/
import axios from 'axios';
import { getInstruments, getSetting } from './db';
import { getTradableFirms } from './firm-catalog';
import { getCachedContract } from './contract-resolver';
import { currentTradeDateCT } from './trading-logic';
import { errorMessage } from './platform-client';
import type { InstrumentStats } from './master-protocol';
import {
CME_SPECS, CME_ROOTS, spreadDollars, dailyMoveDollars, futuresContracts, expectedHoldHours, sessionProgress,
drawChances, pickByChance, resolveAllowlist, MAX_HOLD_HOURS, type DrawCandidate,
} from './instrument-specs';
export interface FirmLike {
id?: string;
platform: string;
instruments?: string[];
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 firm.platform !== 'dxtrade';
}
/** 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' },
headers: { 'User-Agent': 'Mozilla/5.0' },
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;
}
function progressNow(now: Date): number | null {
return sessionProgress(now, getSetting('trading_hours') ?? 'full_cme');
}
/** 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[];
chances: { symbol: string; chance: number }[];
disabled: string[];
} {
const contracts = leg ? futuresContracts(leg.leg, leg.maxPositionSize) : 1;
const candidates = roots.filter((r) => CME_SPECS[r]).map((root): DrawCandidate => {
const dailyMove = market.get(root)?.dailyMove ?? null;
return {
symbol: root,
spreadDollars: spreadDollars(root),
dailyMove,
holdHours: leg ? expectedHoldHours({ leg: leg.leg, stopDollars: leg.stopDollars, contracts, dailyMove }) : null,
micro: CME_SPECS[root].micro === true,
};
});
const progress = progressNow(now);
const { chances, disabled } = drawChances(candidates, progress);
return { progress, candidates, chances, disabled };
}
/**
* 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 hold = (s: string) => board.candidates.find((c) => c.symbol === s)?.holdHours;
const parts = board.chances.map((c) => `${c.symbol} ${Math.round(c.chance * 100)}%`).join(' · ');
const off = board.disabled.map((s) => `${s} (~${Math.round(hold(s) ?? 0)}h)`).join(', ');
console.log(`[instruments] futures: ${root ? `drew ${root}` : 'nothing fits'} — ${parts || 'none drawable'}`
+ `${off ? `; over ${MAX_HOLD_HOURS}h: ${off}` : ''}`
+ `${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 board = futuresBoard(listed, lead);
const disabled = new Set(board.disabled);
const leg = lead ? { leg: lead.leg, stopDollars: lead.stopDollars, contracts: futuresContracts(lead.leg, lead.maxPositionSize) } : null;
return {
at: new Date().toISOString(),
account: lead?.account ?? null,
leg: lead?.leg ?? null,
sessionProgress: board.progress,
maxHoldHours: MAX_HOLD_HOURS,
instruments: CME_ROOTS.map((root) => {
const dailyMove = market.get(root)?.dailyMove ?? null;
const holdHours = leg ? expectedHoldHours({ ...leg, dailyMove }) : null;
return {
symbol: root,
listed: listed.includes(root),
contract: getCachedContract(root)?.name ?? null,
spreadDollars: spreadDollars(root),
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,
};
}),
};
}
+172
View File
@@ -0,0 +1,172 @@
/**
* 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, DEFAULT_TARGET_HOLD_HOURS,
FALLBACK_COMMISSION_PER_CONTRACT,
} from './trading-logic';
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 };
}): CandidateLeg[] {
const candidates: PlatformContract[] = o.client.lastCandidates ?? [];
if (!(o.leg > 0) || candidates.length === 0) return [];
const target = o.firm.targetHoldHours || DEFAULT_TARGET_HOLD_HOURS;
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,
});
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,
);
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 = cfg.dailyLossLimit ?? 0;
let room: number | null = null;
if (dll > 0) {
const dayStart = client.dayStartBalance?.(acc.id);
room = dayStart === null ? null : computeDllRoom({
dailyLossLimit: dll, closedBalance: cash.amount, dayStartBalance: dayStart,
realizedToday: Math.min(cash.realizedPnL, combined.realizedPnL),
});
}
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, firm, leg: p.plan.remainingToday, dllRoom: p.room,
obfuscationDollars: p.obfuscation, cash: p.cash,
}),
};
}
/**
* 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 };
}
+23 -8
View File
@@ -15,10 +15,11 @@
* 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';
@@ -35,6 +36,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 +50,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 +59,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)
@@ -96,7 +99,7 @@ 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: (re)start the scheduler first,
@@ -163,8 +166,19 @@ 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,
@@ -224,6 +238,7 @@ function connect() {
return;
}
s.socket = socket;
s.url = masterUrl;
s.ready = false;
socket.addEventListener('open', () => {
+118 -4
View File
@@ -112,6 +112,68 @@ export interface FirmStats {
/** 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;
}
/** 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;
instruments: {
symbol: string;
listed: boolean;
contract: string | null;
/** One tick of spread on one contract, in dollars. */
spreadDollars: number;
/** 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 {
@@ -205,19 +267,71 @@ 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;
}
/** 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'] 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);
}
/** 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[];
/** Older masters' way to exclude roots; newer ones derive it from
* `instruments`. */
bannedSymbols: string[];
accountTypes: CatalogAccountType[];
/** 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;
}
+52 -8
View File
@@ -4,29 +4,60 @@
* 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 } 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;
}
export interface PlatformClient {
accountList: PlatformAccount[];
/** realizedPnL is today's. pendingRoll (Tradovate): the broker still shows
* the ended day's record and today's share of it can't be told apart —
* the account waits for the broker to roll it. */
accountCashBalances: { [accountId: number]: { amount: number; realizedPnL: number; pendingRoll?: boolean } };
* 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;
@@ -69,10 +100,22 @@ export interface PlatformClient {
* read as a fresh stage-1 cycle, so such an account must not trade.
*/
historyReady(accountId: number): boolean;
/**
* 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;
/** 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;
findFrontMonthContract(productName: string): Promise<PlatformContract | null>;
/** 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 }>;
sendOrder(
@@ -113,10 +156,11 @@ export interface PlatformClient {
* Timestamps are epoch ms, 0 = never. Never carries tokens or credentials.
*/
export interface ClientHealth {
platform: 'tradovate' | 'topstepx';
platform: FirmPlatform;
/** Holding a session token right now. */
authenticated: boolean;
/** Tradovate: socket readyState name; TopstepX: hub state. 'none' = no transport. */
/** Tradovate: socket readyState name; TopstepX: hub state; DXTrade: 'rest'
* or its push socket's state. 'none' = no transport. */
transport: string;
syncComplete: boolean;
lastSyncAt: number;
+92 -7
View File
@@ -18,8 +18,10 @@ import { getClients } from './clients';
import { 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 { botStopsActive, combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig, DEFAULT_TARGET_HOLD_HOURS } from './trading-logic';
import type { AccountTypeStats, FirmStats, InstrumentStats, SymbolStats } from './master-protocol';
import { accountNextLeg, accountLegShape } from './leg-estimate';
import { instrumentStatsFor, isFuturesFirm } from './instruments';
import { historyFresh } from './platform-client';
function collectFirmStats() {
@@ -288,6 +290,7 @@ function collectFirmStats() {
cycleDailyPnL: client.dailyPnL[acc.id],
rawRealizedPnL: cash.realizedPnL,
combineCmeHolidays: cfg.combineCmeHolidays,
tradesEveryDay: cfg.tradesEveryDay === true,
effective,
equityProfit: cash.amount - cfg.accountSize,
splitEnabled: cfg.splitEnabled,
@@ -349,11 +352,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 a 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) && combined.realizedPnL <= -25) {
personalDllCount++;
slice.personalDll = (slice.personalDll ?? 0) + 1;
} else if (combined.realizedPnL < 0) {
@@ -385,10 +389,91 @@ function collectFirmStats() {
// Unknown until the firm has synced, not empty.
...(client.syncComplete ? { accounts } : {}),
accountTypes,
...symbolStatsFor(f, client, rawToday),
...instrumentStatsBlock(f, client, rawToday),
};
});
}
/** 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: f.targetHoldHours || DEFAULT_TARGET_HOLD_HOURS,
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> };
+591 -30
View File
@@ -366,13 +366,22 @@ export interface ProjectedDay {
net?: number;
}
/** Next weekday after d (YYYY-MM-DD). Trade dates never fall on Sat/Sun. */
function nextTradingDate(d: string): string {
/** Next trade date after d (YYYY-MM-DD): the next weekday on the CME calendar,
* where trade dates never fall on Sat/Sun — or simply the next calendar day
* for a firm that trades every day (24/7 crypto/CFD, e.g. Tradeify247). */
export function nextTradingDate(d: string, everyDay = false): string {
const dt = new Date(`${d}T00:00:00Z`);
do { dt.setUTCDate(dt.getUTCDate() + 1); } while (dt.getUTCDay() === 0 || dt.getUTCDay() === 6);
do { dt.setUTCDate(dt.getUTCDate() + 1); } while (!everyDay && (dt.getUTCDay() === 0 || dt.getUTCDay() === 6));
return dt.toISOString().slice(0, 10);
}
/** Whether CME holiday sessions fold into the next day for this account type.
* Never for an every-day firm: it has no CME calendar to fold by. The catalog
* sync already clears the flag for those firms; this is the backstop. */
export function effectiveCombine(cfg: { combineCmeHolidays?: boolean; tradesEveryDay?: boolean }): boolean {
return cfg.combineCmeHolidays === true && cfg.tradesEveryDay !== true;
}
/**
* Simulate the account's future assuming every day goes perfectly: each
* trading day books exactly its planned target (deterministic bumps included,
@@ -399,6 +408,9 @@ export function projectPerfectPath(opts: {
* (withdraw, reset, re-target). False = it just PASSES once (an
* eval) and the projection stops. */
payoutable?: boolean;
/** The firm trades every calendar day (24/7): weekends and CME
* holidays are ordinary projected days, and nothing combines. */
tradesEveryDay?: boolean;
};
/** Current-cycle daily P&L (account.dailyPnL). */
cycleDailyPnL: { date: string; pnl: number }[];
@@ -424,6 +436,8 @@ export function projectPerfectPath(opts: {
* so today's room is exact and only tightens going forward. */
startThreshold?: number;
};
/** Defaults to the current time. */
now?: Date;
}): ProjectedDay[] {
const { accountId, cfg } = opts;
const horizon = opts.horizonDays ?? 40;
@@ -432,30 +446,33 @@ export function projectPerfectPath(opts: {
const baseCap = opts.payout?.payoutCap ?? 0;
const profitSplit = opts.payout?.profitSplit ?? 0.9;
const dd = Math.max(0, opts.payout?.drawdown ?? 0);
const everyDay = cfg.tradesEveryDay === true;
const rawToday = currentTradeDateCT();
const rawToday = currentTradeDateCT(opts.now);
// The firm's own day view: a combining firm folds holiday-session P&L
// into its clearing date — a Labor-Day trade and the Tuesday after it are
// ONE trading day, exactly as the firm counts min days. Identity view
// when combining is off.
const combine = cfg.combineCmeHolidays === true;
const combine = effectiveCombine(cfg);
const view = combineCmeSessions(combine, opts.cycleDailyPnL, rawToday, opts.realizedPnL);
// A weekend "today" (Saturday, or Sunday before the evening open) has no
// session — the platform's realizedPnL is the LAST session's stale number,
// already booked in cycleDailyPnL under its own date. Adding it as a
// today row would fabricate an extra qualifying day.
// today row would fabricate an extra qualifying day. An every-day firm
// trades weekends, so its weekend today is a live session like any other.
const todayDow = new Date(`${rawToday}T00:00:00Z`).getUTCDay();
const todayHasSession = todayDow !== 0 && todayDow !== 6;
const todayHasSession = everyDay || (todayDow !== 0 && todayDow !== 6);
// Today's clearing date when a session exists; on weekends keep the raw
// date so the projection's first day is Monday, not the day after it
// (combinedTradeDate rolls weekend dates forward).
const today = todayHasSession ? view.tradeDate : rawToday;
let sim = view.dailyPnL.filter((d) => d.date !== today);
if (todayHasSession && view.realizedPnL !== 0) sim = [...sim, { date: today, pnl: view.realizedPnL }];
/** Next date the firm settles a session on: weekends always skip, and a
* combining firm's holiday sessions clear into the next full day. */
/** Next date the firm settles a session on: weekends skip (except for an
* every-day firm), and a combining firm's holiday sessions clear into
* the next full day. */
const nextDay = (d: string): string => {
let n = nextTradingDate(d);
let n = nextTradingDate(d, everyDay);
while (combine && combinedTradeDate(n) !== n) n = nextTradingDate(n);
return n;
};
@@ -662,12 +679,18 @@ function cycleOwesNothing(
* weekend (it is the last session's stale number, already booked under its
* own date), a Monday with a weekend-dated row — the live session filed under
* a weekend date, which would count it twice — and the first minutes after
* the 5 PM roll. */
function sessionDoubtful(rawTradeDate: string, cycleDailyPnL: { date: string; pnl: number }[], now: Date): boolean {
* the 5 PM roll. An every-day firm's weekends are real sessions, so only the
* roll window applies to it. */
function sessionDoubtful(
rawTradeDate: string,
cycleDailyPnL: { date: string; pnl: number }[],
now: Date,
everyDay = false,
): boolean {
const day = new Date(`${rawTradeDate}T00:00:00Z`);
const dow = day.getUTCDay();
if (dow === 0 || dow === 6) return true;
if (dow === 1) {
if (!everyDay && (dow === 0 || dow === 6)) return true;
if (!everyDay && dow === 1) {
const sunday = fmtUTC(new Date(day.getTime() - 24 * 60 * 60 * 1000));
const saturday = fmtUTC(new Date(day.getTime() - 2 * 24 * 60 * 60 * 1000));
if (cycleDailyPnL.some((d) => d.date === saturday || d.date === sunday)) return true;
@@ -698,6 +721,8 @@ export function cycleCompletion(opts: {
cycleDailyPnL: { date: string; pnl: number }[];
rawRealizedPnL: number;
combineCmeHolidays: boolean;
/** The firm trades every calendar day — weekends are real sessions. */
tradesEveryDay?: boolean;
effective: EffectiveConfig;
/** amount − accountSize — today's P&L included. */
equityProfit: number;
@@ -706,14 +731,18 @@ export function cycleCompletion(opts: {
now?: Date;
}): CycleCompletion {
const { accountId, rawTradeDate, effective: eff, equityProfit, splitEnabled } = opts;
const view = combineCmeSessions(opts.combineCmeHolidays, opts.cycleDailyPnL, rawTradeDate, opts.rawRealizedPnL);
const everyDay = opts.tradesEveryDay === true;
const view = combineCmeSessions(
effectiveCombine({ combineCmeHolidays: opts.combineCmeHolidays, tradesEveryDay: everyDay }),
opts.cycleDailyPnL, rawTradeDate, opts.rawRealizedPnL,
);
const prior = view.dailyPnL.filter((d) => d.date !== view.tradeDate);
if (cycleOwesNothing(accountId, eff, prior, view.tradeDate, equityProfit, view.realizedPnL, splitEnabled)) return 'before';
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date())) return 'none';
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay)) return 'none';
const withToday = [...prior, { date: view.tradeDate, pnl: view.realizedPnL }];
const probe = nextTradingDate(view.tradeDate);
const probe = nextTradingDate(view.tradeDate, everyDay);
if (view.realizedPnL !== 0 && cycleOwesNothing(accountId, eff, withToday, probe, equityProfit, 0, splitEnabled)) return 'today';
return 'none';
}
@@ -764,15 +793,18 @@ export function endedCycleCompletion(opts: {
profitTarget: number; consistency: number; minTradingDays: number; minDayPnL: number;
targetSameEquity: boolean; withdrawalStages: WithdrawalStage[];
combineCmeHolidays: boolean; splitEnabled: boolean;
tradesEveryDay?: boolean;
};
/** Defaults to the current time. */
now?: Date;
}): { date: string; stage: number; drain: boolean; cycle: string } | null {
const { accountId, rawTradeDate, lastFundDate, endedCycleStart: start, fundTransactions, cfg } = opts;
const everyDay = cfg.tradesEveryDay === true;
const combine = effectiveCombine(cfg);
if (lastFundDate === null || start === null) return null;
const ageDays = (Date.parse(`${rawTradeDate}T00:00:00Z`) - Date.parse(`${lastFundDate}T00:00:00Z`)) / (24 * 60 * 60 * 1000);
if (!(ageDays <= ENDED_CYCLE_WINDOW_DAYS)) return null; // NaN included: an unparseable date
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date())) return null;
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay)) return null;
const latest = (a: string, b: string) => (b > a ? b : a);
const earlierFunds = fundTransactions.filter((f) => f.date < lastFundDate);
@@ -789,7 +821,7 @@ export function endedCycleCompletion(opts: {
// Equity the ended cycle finished on, worked back from live equity: less
// everything the current cycle booked (today's live P&L included) and
// less the fund transaction between the two.
const cur = combineCmeSessions(cfg.combineCmeHolidays, opts.cycleDailyPnL, rawTradeDate, opts.rawRealizedPnL);
const cur = combineCmeSessions(combine, opts.cycleDailyPnL, rawTradeDate, opts.rawRealizedPnL);
const curTotal = cur.dailyPnL.filter((d) => d.date !== cur.tradeDate).reduce((s, d) => s + d.pnl, 0)
+ cur.realizedPnL;
const fundAmt = fundTransactions.filter((f) => f.date === lastFundDate).reduce((s, f) => s + f.amount, 0);
@@ -800,11 +832,11 @@ export function endedCycleCompletion(opts: {
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity,
cfg.withdrawalStages, 0, earlierFunds, cfg.minDayPnL,
);
const rows = combineCmeSessions(cfg.combineCmeHolidays, endedRaw, lastFundDate, 0).dailyPnL;
const rows = combineCmeSessions(combine, endedRaw, lastFundDate, 0).dailyPnL;
// A combining firm's folded day can net to 0 — nothing traded, as the firm counts it.
const traded = rows.filter((d) => d.pnl !== 0);
if (traded.length === 0) return null;
const probe = nextTradingDate(rows.map((d) => d.date).reduce(latest));
const probe = nextTradingDate(rows.map((d) => d.date).reduce(latest), everyDay);
if (!cycleOwesNothing(accountId, eff, rows, probe, equityBefore, 0, cfg.splitEnabled)) return null;
return {
@@ -815,15 +847,6 @@ export function endedCycleCompletion(opts: {
};
}
/** Dollar-per-point value for common futures products. */
export const POINT_VALUES: { [symbol: string]: number } = {
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,
};
/**
* Compute the next trading day's profit target for an account.
*
@@ -1040,3 +1063,541 @@ export function computeDayPlan(
splitAllowed,
};
}
// ── fees ─────────────────────────────────────────────────────────────────────
/** An entry's fee when the balance update hasn't shown it yet — at least one
* extra tick on the exit. */
export const FALLBACK_COMMISSION_PER_CONTRACT = 2.5;
/** No real per-contract entry fee comes near this. Also what a daily-loss
* stop reserves per contract and side when the fee couldn't be measured —
* the $2.50 fallback is fine for a take-profit, but it would under-reserve
* a hard stop on NQ-class fees. */
export const MAX_COMMISSION_PER_CONTRACT = 10;
/** One side's commission on a percent-of-notional firm (e.g. Tradeify247's
* 0.04%), rounded UP to the cent so every consumer errs toward paying more.
* pointValue × contracts is the position's dollars per 1.0 of price, so
* price × that is its notional. */
export function percentFee(pct: number, price: number, pointValue: number, contracts: number): number {
if (!(pct > 0)) return 0;
const raw = (pct / 100) * Math.abs(price) * pointValue * contracts;
return Math.ceil(raw * 100 - 1e-9) / 100;
}
/**
* Where to put the take-profit so the leg nets at least `leg` after fees.
*
* Measured-fee firms (commissionPct 0): the exit fee is taken to equal the
* entry fee — the same formula the fire path always used, reproduced exactly.
*
* Percent-fee firms: the exit fee depends on the exit price itself, so solve
* for it. With r = pct/100 and k = pointValue × contracts, a long nets
* (X − entry)·k − entryFee − r·X·k at exit price X, which reaches `leg` at
* X = ((leg + entryFee)/k + entry) / (1 − r); a short mirrors it at
* X = (entry − (leg + entryFee)/k) / (1 + r). Rounded out to whole ticks and
* re-checked with the cent-rounded exit fee.
*/
export function takeProfitPlan(o: {
side: 'Buy' | 'Sell';
entry: number;
leg: number;
pointValue: number;
contracts: number;
tickSize: number;
commissionPct: number;
/** The entry fee: percentFee() on percent firms, the measured one otherwise. */
entryCommission: number;
}): { exitPrice: number; ticks: number; entryFee: number; exitFee: number; totalCommission: number; grossTarget: number } {
const k = o.pointValue * o.contracts;
const dir = o.side === 'Buy' ? 1 : -1;
if (!(o.commissionPct > 0)) {
const totalCommission = o.entryCommission * 2; // entry + exit round-trip
const grossTarget = o.leg + totalCommission;
const ticks = Math.ceil(grossTarget / k / o.tickSize);
return {
exitPrice: o.entry + dir * ticks * o.tickSize,
ticks,
entryFee: o.entryCommission,
exitFee: o.entryCommission,
totalCommission,
grossTarget,
};
}
const r = o.commissionPct / 100;
const need = (o.leg + o.entryCommission) / k;
const target = o.side === 'Buy' ? (need + o.entry) / (1 - r) : (o.entry - need) / (1 + r);
let ticks = Math.max(1, Math.ceil(Math.abs(target - o.entry) / o.tickSize - 1e-9));
const priceAt = (t: number) => Math.round((o.entry + dir * t * o.tickSize) * 1e10) / 1e10;
const netAt = (t: number) => t * o.tickSize * k - o.entryCommission - percentFee(o.commissionPct, priceAt(t), o.pointValue, o.contracts);
// Cent rounding on the fees can leave the closed form short — by several
// ticks when a tick is worth a fraction of a cent. Step out by the shortfall.
for (let i = 0; i < 10; i++) {
const short = o.leg - netAt(ticks);
if (short <= 1e-9) break;
ticks += Math.max(1, Math.ceil(short / (o.tickSize * k * (1 - r)) - 1e-9));
}
const exitPrice = priceAt(ticks);
const exitFee = percentFee(o.commissionPct, exitPrice, o.pointValue, o.contracts);
const totalCommission = Math.round((o.entryCommission + exitFee) * 100) / 100;
return { exitPrice, ticks, entryFee: o.entryCommission, exitFee, totalCommission, grossTarget: round2(o.leg + totalCommission) };
}
// ── stops ────────────────────────────────────────────────────────────────────
/** Obfuscation stop for the current leg, in dollars. Infinity = no stop.
* Day 1 of a cycle carries the $1,000 account stop (firstDayStop), min-day
* (small) trades a 2×-target stop (minDayStop) — and the day after a losing
* day always rides stopless, the make-back day. A hard daily loss limit is
* separate (dllStopDollars) and never rides stopless. */
export function obfuscationStopDollars(
cfg: { firstDayStop: boolean; minDayStop: boolean },
dailyPnL: { date: string; pnl: number }[],
plan: DayPlan,
tradeDate: string,
legAmount: number,
): number {
if (!cfg.firstDayStop && !cfg.minDayStop) return Infinity;
const prior = dailyPnL
.filter((d) => d.date < tradeDate && d.pnl !== 0)
.sort((a, b) => a.date.localeCompare(b.date));
if (prior.length > 0 && prior[prior.length - 1].pnl < 0) return Infinity;
let stop = Infinity;
const realizedToday = Math.round((plan.dayTarget - plan.remainingToday) * 100) / 100;
if (cfg.firstDayStop && prior.length === 0) stop = Math.max(0, FIRST_DAY_STOP + realizedToday);
if (cfg.minDayStop && plan.dayTarget <= BIG_DAY_THRESHOLD) stop = Math.min(stop, MIN_DAY_STOP_MULT * legAmount);
return stop;
}
/** Share of a hard daily loss limit the bot never spends — room for a stop
* that fills worse than its price (gaps, thin weekend books). */
export const DLL_RESERVE_FRACTION = 0.10;
/** …or this share of the position's notional, when that is more: a stop's
* slippage grows with the position, a flat reserve doesn't. */
export const DLL_SLIPPAGE_FRACTION = 0.001;
/** The part of the day's room a stop leaves unspent. */
export function dllReserve(dailyLossLimit: number, notional = 0): number {
return Math.max(DLL_RESERVE_FRACTION * dailyLossLimit, DLL_SLIPPAGE_FRACTION * Math.max(0, notional));
}
/** Smallest daily-loss stop worth trading. Under a fair walk a leg of L with a
* stop of S wins S/(S + L) of the time — about 9% for $50 against $500 — so
* anything tighter just spends the day's room on fees. */
export const DLL_MIN_STOP = 50;
/**
* How much the firm's day may still lose before a hard daily-loss breach.
* Infinity when the account type has no limit.
*
* The day's P&L is the closed balance against the platform's snapshot at the
* firm's daily reset when the client knows it (DXTrade: 22:00 UTC), else the
* broker's realized P&L for the session. Only a red day counts: a green day
* does NOT widen the room past the limit, because a position still open at
* the next reset has its whole move counted against the NEW day — room
* borrowed from today's profit would be a breach tomorrow.
*/
export function dllRoom(o: {
dailyLossLimit: number;
closedBalance: number;
/** Platform snapshot at the firm's daily reset; undefined/null = unknown. */
dayStartBalance?: number | null;
/** Fallback when there's no snapshot: the day's realized P&L (pass the
* worse of the raw and combined figures). */
realizedToday: number;
}): number {
if (!(o.dailyLossLimit > 0)) return Infinity;
const dayPnL = typeof o.dayStartBalance === 'number' ? o.closedBalance - o.dayStartBalance : o.realizedToday;
return round2(o.dailyLossLimit + Math.min(0, dayPnL));
}
/**
* The widest stop, in dollars of price movement, that keeps a loss inside
* room − reserve with both fees paid. The exit fee at the stop is at most
* entryFee + r × (stop distance) — a short's stop sits above its entry — so
* the worst loss is d(1 + r) + 2·entryFee, plus a cent of fee rounding.
* Infinity when there is no limit. Can go negative: no room at all.
*
* Overnight swap needs no reserve of its own while a day's swap is below the
* commission rate (Tradeify: 0.033% < 0.04%): a position carried past the
* reset has its entry fee on the old day's books, and the swap takes no more
* than that slot on the new day's.
*/
export function dllStopDollars(o: { room: number; dailyLossLimit: number; entryFee: number; commissionPct: number; notional?: number }): number {
if (!Number.isFinite(o.room)) return Infinity;
const r = Math.max(0, o.commissionPct) / 100;
return (o.room - dllReserve(o.dailyLossLimit, o.notional) - 2 * o.entryFee - 0.01) / (1 + r);
}
/**
* How far the account's balance is above its max-drawdown floor, or null when
* it isn't known: the broker's liquidation floor where it reports one, else
* the account type's static max loss below the starting size — whichever is
* higher.
*/
export function maxLossRoom(o: { balance: number; accountSize: number; maxLoss: number; autoLiqThreshold?: number }): number | null {
const floors: number[] = [];
if ((o.autoLiqThreshold ?? 0) > 0) floors.push(o.autoLiqThreshold!);
if (Math.abs(o.maxLoss) > 0 && o.accountSize > 0) floors.push(o.accountSize - Math.abs(o.maxLoss));
return floors.length > 0 ? round2(o.balance - Math.max(...floors)) : null;
}
/** A daily-loss stop is kept only while the max-drawdown floor lies more than
* this share of a full daily loss beyond it. */
export const LAST_STAND_FRACTION = 0.15;
/**
* Whether a leg should ride without its daily-loss stop. The stop is there to
* keep the account alive for another day; when hitting the day's loss limit
* would leave it within 15% of that loss of its max-drawdown floor — or the
* floor is nearer than the limit — there is no useful day after, and the stop
* only gives up room (the reserve, and whatever the floor allows past it)
* for nothing. The leg then runs until its take-profit or the firm's own
* breach. E.g. $3,000 of daily room with $3,294.70 to the floor: $294.70
* left, 9.8% — a last stand.
*/
export function lastStand(o: { dllRoom: number; dailyLossLimit: number; maxLossRoom: number | null }): boolean {
if (o.maxLossRoom === null || !(o.dailyLossLimit > 0) || !Number.isFinite(o.dllRoom) || !(o.dllRoom > 0)) return false;
return o.maxLossRoom - o.dllRoom <= LAST_STAND_FRACTION * o.dllRoom;
}
/** On a last stand: how far price can move against the leg before the firm's
* nearer floor breaches the account — its equity carries the entry fee, not
* the exit's. What sizing and the swap estimate take as the walk's far side. */
export function breachDistance(o: { dllRoom: number; maxLossRoom: number | null; entryFee: number }): number {
return Math.min(o.dllRoom, o.maxLossRoom ?? Infinity) - o.entryFee;
}
/** Is there enough daily-loss room left to trade at all? */
export function dllViable(room: number, dailyLossLimit: number, roundTripFeeEstimate: number): boolean {
if (!Number.isFinite(room)) return true;
return room - DLL_RESERVE_FRACTION * dailyLossLimit >= DLL_MIN_STOP + roundTripFeeEstimate;
}
/**
* The stop distance in ticks: the tighter of the obfuscation stop (rounded
* out, as it always was — it is a soft target) and the daily-loss stop
* (rounded IN — it is a hard limit). null = no stop at all (lone take-profit);
* 0 = the daily-loss room doesn't cover a single tick, so the position must
* not stay open. An obfuscation value of 0 or Infinity means no obfuscation stop.
*/
export function stopTicks(o: {
obfuscationDollars: number;
dllDollars: number;
pointValue: number;
contracts: number;
tickSize: number;
}): number | null {
const perTick = o.pointValue * o.contracts * o.tickSize;
let ticks: number | null = null;
if (Number.isFinite(o.obfuscationDollars) && o.obfuscationDollars > 0) {
ticks = Math.max(1, Math.ceil(o.obfuscationDollars / (o.pointValue * o.contracts) / o.tickSize));
}
if (Number.isFinite(o.dllDollars)) {
const dllTicks = Math.max(0, Math.floor(o.dllDollars / perTick + 1e-9));
ticks = ticks === null ? dllTicks : Math.min(ticks, dllTicks);
}
return ticks;
}
/** Whether the bot places its own stops for this account type — the
* obfuscation options or a hard daily loss limit. A red day on such an
* account is the bot's own stop (the personal DLL), not a firm lockout. */
export function botStopsActive(cfg: { firstDayStop: boolean; minDayStop: boolean; dailyLossLimit?: number }): boolean {
return cfg.firstDayStop || cfg.minDayStop || (cfg.dailyLossLimit ?? 0) > 0;
}
// ── position size ────────────────────────────────────────────────────────────
/** The hold a leg is sized for when the firm doesn't set one. */
export const DEFAULT_TARGET_HOLD_HOURS = 12;
/** Share of the broker's margin headroom a new position may use: the rest
* absorbs the price moving between sizing and the fill, and the entry fee. */
export const MARGIN_HEADROOM = 0.97;
/**
* The largest position an account may open: what its available funds can
* margin at the broker's rate for the instrument — the firm's leverage per
* symbol (5:1 crypto, 2:1 stocks at Tradeify247). 0 = no cap known.
*/
export function notionalCap(o: { marginRate?: number; availableFunds?: number }): number {
if (!((o.marginRate ?? 0) > 0) || !Number.isFinite(o.availableFunds) || !(o.availableFunds! > 0)) return 0;
return (MARGIN_HEADROOM * o.availableFunds!) / o.marginRate!;
}
/** The swap reserve never grows past this share of the take-profit distance
* — beyond it the leg is sized too small to hold at all. */
const MAX_SWAP_RESERVE_FRACTION = 0.25;
/**
* The overnight swap a winning leg is expected to pay before its take-profit
* fills, so the take-profit can carry it and a win still clears the leg.
*
* Same driftless walk as sizeNotional, in dollars: σ$ = notional·σ per √hour,
* the take-profit A away, the stop B away. Given it wins, the walk takes
* (A² + 2AB)/(3σ$²) hours on average — longer than the unconditional hold,
* since the quick endings are mostly stop-outs. The swap is charged at each
* daily reset the position is held over, about one per 24 h held, on the
* notional. A includes the reserve itself, so it is solved as a fixed point.
* A stopless leg is taken as B = A.
*
* `distance` = the take-profit distance before swap: leg + round-trip fees.
*/
export function expectedSwapToWin(o: {
distance: number;
stopDollars: number;
notional: number;
volatilityPerHour?: number;
financingPctPerDay?: number;
}): number {
const daily = Math.max(0, o.financingPctPerDay ?? 0) / 100;
const sigma = o.volatilityPerHour ?? 0;
if (!(daily > 0) || !(sigma > 0) || !(o.notional > 0) || !(o.distance > 0)) return 0;
const variance = (o.notional * sigma) ** 2;
const stopped = Number.isFinite(o.stopDollars) && o.stopDollars > 0;
const cap = o.distance * MAX_SWAP_RESERVE_FRACTION;
let swap = 0;
for (let i = 0; i < 50; i++) {
const a = o.distance + swap;
const b = stopped ? o.stopDollars : a;
const winHours = (a * a + 2 * a * b) / (3 * variance);
const next = Math.min(cap, daily * o.notional * (winHours / 24));
const done = Math.abs(next - swap) < 0.005;
swap = next;
if (done) break;
}
return Math.ceil(swap * 100) / 100;
}
/**
* How much notional a leg should carry, on platforms that price it (DXTrade).
*
* Model price as a driftless random walk with σ per √hour. With the
* take-profit A dollars away (leg + fees + expected swap) and the stop B
* dollars away, a position of notional N wins B/(A+B) of the time whatever N
* is, its expected hold is (A/N)(B/N)/σ² hours, and its expected P&L is minus
* fees and spread — proportional to N. Smaller is cheaper, bigger is faster,
* so the knob is the target hold T: N = √(A·B/(σ²·T)). A firm that charges
* overnight financing adds a floor: below √(f·A·B/(σ²·(2c + s))), a bigger
* position is both cheaper and faster.
*
* A stopless leg (no daily loss limit, no obfuscation stop) is sized as if
* its stop sat as far away as its target. Returns null without a volatility.
*/
export function sizeNotional(o: {
leg: number;
/** Stop distance in dollars at a given notional (fees move with it). */
stopDollarsAt: (notional: number) => number;
volatilityPerHour?: number;
/** 0 = DEFAULT_TARGET_HOLD_HOURS. */
targetHoldHours: number;
/** Per side, percent. */
commissionPct: number;
spreadFraction?: number;
financingPctPerDay?: number;
/** Hard cap on the position (see notionalCap); 0 = none. */
maxNotional: number;
/** The smallest order the broker takes; 0 = none. It wins over the cap,
* as the order does. */
minNotional?: number;
}): { notional: number; predictedHoldHours: number; winProbability: number | null; swapReserve: number } | null {
const sigma = o.volatilityPerHour ?? 0;
if (!(sigma > 0)) return null;
const hold = o.targetHoldHours > 0 ? o.targetHoldHours : DEFAULT_TARGET_HOLD_HOURS;
const c = Math.max(0, o.commissionPct) / 100;
const s = Math.max(0, o.spreadFraction ?? 0);
const fh = Math.max(0, o.financingPctPerDay ?? 0) / 100 / 24;
const distances = (n: number) => {
const stop = o.stopDollarsAt(n);
const stopped = Number.isFinite(stop) && stop > 0;
const swap = expectedSwapToWin({
distance: o.leg + 2 * c * n, stopDollars: stop, notional: n,
volatilityPerHour: sigma, financingPctPerDay: o.financingPctPerDay,
});
const a = o.leg + 2 * c * n + swap;
return { a, b: stopped ? stop : a, stopped, swap };
};
// Fees and swap depend on N and the daily-loss stop depends on fees: a
// few fixed-point passes settle it (the correction is second order).
let n = o.leg * 20;
for (let pass = 0; pass < 4; pass++) {
const { a, b } = distances(n);
const nTime = Math.sqrt((a * b) / (sigma * sigma * hold));
// No per-trade cost (a zero-commission firm on a zero spread): no
// floor — there is nothing for a bigger position to save.
const nCost = fh > 0 && 2 * c + s > 0 ? Math.sqrt((fh * a * b) / (sigma * sigma * (2 * c + s))) : 0;
n = Math.max(nTime, nCost);
}
if (!Number.isFinite(n) || n <= 0) return null;
const notional = Math.max(o.maxNotional > 0 ? Math.min(n, o.maxNotional) : n, o.minNotional ?? 0);
const { a, b, stopped, swap } = distances(notional);
return {
notional,
predictedHoldHours: (a / notional) * (b / notional) / (sigma * sigma),
winProbability: stopped ? b / (a + b) : null,
swapReserve: swap,
};
}
/**
* One leg's size on one instrument for one account — the rule fireLeg
* trades, the instrument pick ranks by and the dashboard shows.
*
* The walk's far side (what sizing, the win chance and the swap estimate
* use) is the stop the leg carries: the tighter of the obfuscation stop and
* the hard daily-loss stop. On a last stand there is no daily-loss stop and
* the far side is the firm's own breach. `cost` is what the leg is expected
* to spend: spread and both commissions on its notional, plus swap over its
* expected hold.
*
* The size is held to what the order can be — the margin cap and the
* broker's largest order, then its smallest — so the pick ranks each
* instrument by the hold it can actually reach. `cap` is the margin cap
* alone.
*/
export function planLegSize(o: {
leg: number;
contract: {
stepNotional?: number; volatilityPerHour?: number; spreadFraction?: number; marginRate?: number;
/** The broker's largest and smallest order, in steps. */
maxContracts?: number; minContracts?: number;
};
/** Per side, percent. */
commissionPct: number;
targetHoldHours: number;
financingPctPerDay: number;
dailyLossLimit: number;
/** Infinity = no hard daily limit. */
dllRoom: number;
maxLossRoom: number | null;
/** Infinity (or ≤ 0) = no obfuscation stop. */
obfuscationDollars: number;
availableFunds?: number;
}): {
lastStand: boolean;
cap: number;
walkAt: (notional: number) => number;
sizing: ReturnType<typeof sizeNotional>;
cost: number | null;
} {
const dllOn = Number.isFinite(o.dllRoom) && o.dailyLossLimit > 0;
const stand = dllOn && lastStand({ dllRoom: o.dllRoom, dailyLossLimit: o.dailyLossLimit, maxLossRoom: o.maxLossRoom });
const obf = Number.isFinite(o.obfuscationDollars) && o.obfuscationDollars > 0 ? o.obfuscationDollars : Infinity;
const r = Math.max(0, o.commissionPct) / 100;
const walkAt = (notional: number) => Math.min(
obf,
!dllOn ? Infinity
: stand ? breachDistance({ dllRoom: o.dllRoom, maxLossRoom: o.maxLossRoom, entryFee: r * notional })
: dllStopDollars({ room: o.dllRoom, dailyLossLimit: o.dailyLossLimit, entryFee: r * notional, commissionPct: o.commissionPct, notional }),
);
const cap = notionalCap({ marginRate: o.contract.marginRate, availableFunds: o.availableFunds });
const step = o.contract.stepNotional ?? 0;
const orderMax = (o.contract.maxContracts ?? 0) > 0 ? o.contract.maxContracts! * step : 0;
const sizing = step > 0
? sizeNotional({
leg: o.leg,
stopDollarsAt: walkAt,
volatilityPerHour: o.contract.volatilityPerHour,
targetHoldHours: o.targetHoldHours,
commissionPct: o.commissionPct,
spreadFraction: o.contract.spreadFraction,
financingPctPerDay: o.financingPctPerDay,
maxNotional: cap > 0 && orderMax > 0 ? Math.min(cap, orderMax) : cap || orderMax,
minNotional: Math.max(1, o.contract.minContracts ?? 1) * step,
})
: null;
const cost = sizing
? sizing.notional * (Math.max(0, o.contract.spreadFraction ?? 0) + 2 * r)
+ (Math.max(0, o.financingPctPerDay) / 100) * sizing.notional * (sizing.predictedHoldHours / 24)
: null;
return { lastStand: stand, cap, walkAt, sizing, cost };
}
/**
* Order candidates for a leg: those that reach the target hold (within a
* few percent) first, cheapest first; then those whose leverage cap keeps
* them short of it, quickest first. Returns indices into `options`.
*/
export function rankForTarget(options: { holdHours: number; cost: number }[], targetHoldHours: number): number[] {
const reaches = (h: number) => h <= targetHoldHours * 1.02 + 0.01;
return options
.map((opt, i) => ({ ...opt, i, reach: reaches(opt.holdHours) }))
.sort((a, b) => (a.reach === b.reach
? (a.reach ? a.cost - b.cost : a.holdHours - b.holdHours) || a.i - b.i
: a.reach ? -1 : 1))
.map((x) => x.i);
}
// ── trading hours ────────────────────────────────────────────────────────────
/** Tradeify247 (and DXtrade brokers generally) roll the trading day at 22:00
* UTC — 5 PM Central in summer, 4 PM in winter. */
export const FIRM_RESET_UTC_HOUR = 22;
/** Every-day firms stop opening new trades this long before the earlier of
* their reset and the 5 PM CT roll. */
const ROLL_GUARD_MIN = 5;
function centralClock(now: Date): { day: string; minutes: number; resetMinutes: number } {
const parts = new Intl.DateTimeFormat('en-US', {
weekday: 'short', hour: 'numeric', minute: 'numeric', hour12: false, timeZone: 'America/Chicago',
}).formatToParts(now);
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '';
const minutes = (parseInt(get('hour'), 10) % 24) * 60 + parseInt(get('minute'), 10);
// Central's offset from UTC right now (−300 in CDT, −360 in CST) places
// the firm's 22:00 UTC reset on the Central clock.
const utcMinutes = now.getUTCHours() * 60 + now.getUTCMinutes();
const offset = ((minutes - utcMinutes + 720 + 1440) % 1440) - 720;
const resetMinutes = (FIRM_RESET_UTC_HOUR * 60 + offset + 1440) % 1440;
return { day: get('weekday'), minutes, resetMinutes };
}
/**
* Whether no new trade may open right now.
*
* CME firms follow the trading_hours setting:
* "full_cme" — Sun 5:05 PM – Fri 2:55 PM Central (5 min buffer on each side)
* "equity_hours" — 8:35 AM – 2:55 PM Central, Mon–Fri (5 min buffer on each side)
* with the open pushed back by the day's 1–20 minute jitter.
*
* Every-day firms ignore trading_hours and close only around the day's roll:
* from 5 minutes before the earlier of their 22:00 UTC reset and 5 PM CT, to
* 5:05 PM CT + jitter. In CST that is 3:55 → 5:05 PM, which keeps new entries
* out of the hour where the firm's day (reset at 4 PM CT) and our trade date
* (5 PM CT) disagree.
*/
export function isNoTradeTime(now: Date, o: { everyDay: boolean; mode: string; jitterMin: number }): boolean {
const { day, minutes, resetMinutes } = centralClock(now);
const cmeOpen = 17 * 60 + 5 + o.jitterMin;
if (o.everyDay) {
const guardStart = Math.min(resetMinutes, 17 * 60) - ROLL_GUARD_MIN;
return minutes >= guardStart && minutes < cmeOpen;
}
if (o.mode === 'equity_hours') {
if (day === 'Sat' || day === 'Sun') return true;
const open = 8 * 60 + 35 + o.jitterMin;
const close = 14 * 60 + 55;
return minutes < open || minutes >= close;
}
// Full CME: Sun 5:00 PM – Fri 4:00 PM Central with 5 min buffer.
if (day === 'Sat') return true;
if (day === 'Sun') return minutes < cmeOpen;
const cmeClose = 14 * 60 + 55;
if (day === 'Fri' && minutes >= cmeClose) return true;
// Mon–Thu: block 2:55 PM – 5:05 PM Central (early stop + daily halt + buffer).
return minutes >= cmeClose && minutes < cmeOpen;
}
/** The trade date's session has closed and the 5 PM roll hasn't opened the
* next one: weekdays 2:55–5:00 PM CT on the CME calendar, the pre-roll guard
* for an every-day firm. */
export function isPastSessionClose(now: Date, everyDay: boolean): boolean {
const { day, minutes, resetMinutes } = centralClock(now);
if (everyDay) return minutes >= Math.min(resetMinutes, 17 * 60) - ROLL_GUARD_MIN && minutes < 17 * 60;
if (day === 'Sat' || day === 'Sun') return false;
return minutes >= 14 * 60 + 55 && minutes < 17 * 60;
}
+2 -1
View File
@@ -3,7 +3,8 @@
import axios from 'axios';
import type { AccountItem, AuthLoginResponse, Contract } from './tradovate-helpers';
import { computeSec, randomUUIDV4 } from './tradovate-helpers';
import { POINT_VALUES, currentTradeDateCT } from './trading-logic';
import { currentTradeDateCT } from './trading-logic';
import { POINT_VALUES } from './instrument-specs';
import { getCachedContract, resolveContracts } from './contract-resolver';
import { saveDailyPnL, loadDailyPnL, saveAccountMeta, loadAccountMeta, saveFundTransactions, loadFundTransactions, saveEndedCycleStart } from './db';
import { brokerWebSocket } from './outbound-proxy';
+2 -1
View File
@@ -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"
},
"dependencies": {
"@microsoft/signalr": "^10.0.11",
+161
View File
@@ -0,0 +1,161 @@
// 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,
} from '../lib/instrument-specs.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:05 → 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:05'), 'full_cme'), 0);
assert.ok(Math.abs(sessionProgress(ct('2026-09-28T04:00'), 'full_cme') - 655 / 1310) < 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: a micro of what is listed', () => {
assert.equal(housekeepingRoot(['NQ', 'GC', 'CL', '6E']), 'MNQ');
assert.equal(housekeepingRoot(['MNQ']), 'MNQ');
assert.equal(housekeepingRoot(['GC', 'CL']), 'MGC');
assert.equal(housekeepingRoot(['6E', 'ZN']), '6E'); // no micro: the smallest tick value
assert.equal(housekeepingRoot(['MCL', 'ZB']), 'MCL');
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' },
);
});
+714
View File
@@ -0,0 +1,714 @@
// Checks for the pure trading math in lib/trading-logic.ts.
//
// 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/trading-logic.ts at import time (it has no
// imports of its own).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
percentFee, takeProfitPlan, obfuscationStopDollars, dllRoom, dllStopDollars, dllViable, stopTicks,
botStopsActive, sizeNotional, isNoTradeTime, isPastSessionClose, nextTradingDate, effectiveCombine, DEFAULT_TARGET_HOLD_HOURS,
projectPerfectPath, cycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, notionalCap, expectedSwapToWin, MARGIN_HEADROOM,
dllReserve, DLL_SLIPPAGE_FRACTION, maxLossRoom, lastStand, breachDistance, LAST_STAND_FRACTION, planLegSize, rankForTarget,
currentTradeDateCT,
} from '../lib/trading-logic.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;
};
}
// ── parity with the pre-refactor inline code ────────────────────────────────
/** The take-profit exactly as auto-trade.ts computed it before takeProfitPlan. */
function oldTakeProfit(side, entry, leg, pv, contracts, tickSize, entryCommission) {
const totalCommission = entryCommission * 2;
const grossTarget = leg + totalCommission;
const targetPoints = grossTarget / (pv * contracts);
const ticks = Math.ceil(targetPoints / tickSize);
const exitPrice = side === 'Buy' ? entry + ticks * tickSize : entry - ticks * tickSize;
return { exitPrice, ticks, totalCommission, grossTarget };
}
test('takeProfitPlan reproduces the old formula when fees are measured', () => {
const r = rng(1);
for (let i = 0; i < 2000; i++) {
const side = r() < 0.5 ? 'Buy' : 'Sell';
const [pv, tick] = [[20, 0.25], [2, 0.25], [100, 0.1], [1000, 0.01], [50, 0.25]][Math.floor(r() * 5)];
const entry = Math.round((1000 + r() * 20000) / tick) * tick;
const leg = Math.round(5 + r() * 2500);
const contracts = 1 + Math.floor(r() * 4);
const fee = Math.round(r() * 1000) / 100;
const got = takeProfitPlan({ side, entry, leg, pointValue: pv, contracts, tickSize: tick, commissionPct: 0, entryCommission: fee });
const want = oldTakeProfit(side, entry, leg, pv, contracts, tick, fee);
assert.equal(got.exitPrice, want.exitPrice);
assert.equal(got.ticks, want.ticks);
assert.equal(got.totalCommission, want.totalCommission);
assert.equal(got.grossTarget, want.grossTarget);
}
});
/** obfuscationStopDollars exactly as it lived in auto-trade.ts. */
function oldObfuscation(cfg, dailyPnL, plan, tradeDate, legAmount) {
if (!cfg.firstDayStop && !cfg.minDayStop) return Infinity;
const prior = dailyPnL.filter((d) => d.date < tradeDate && d.pnl !== 0).sort((a, b) => a.date.localeCompare(b.date));
if (prior.length > 0 && prior[prior.length - 1].pnl < 0) return Infinity;
let stop = Infinity;
const realizedToday = Math.round((plan.dayTarget - plan.remainingToday) * 100) / 100;
if (cfg.firstDayStop && prior.length === 0) stop = Math.max(0, 1000 + realizedToday);
if (cfg.minDayStop && plan.dayTarget <= 1700) stop = Math.min(stop, 2 * legAmount);
return stop;
}
test('obfuscationStopDollars is unchanged by the move', () => {
const r = rng(2);
for (let i = 0; i < 1000; i++) {
const cfg = { firstDayStop: r() < 0.5, minDayStop: r() < 0.5 };
const days = Math.floor(r() * 4);
const dailyPnL = Array.from({ length: days }, (_, d) => ({ date: `2026-09-0${d + 1}`, pnl: Math.round((r() - 0.4) * 2000) }));
const dayTarget = Math.round(r() * 3000);
const plan = { dayTarget, remainingToday: Math.round(dayTarget * r()), path: 'normal_day', splitAllowed: true };
const leg = Math.round(100 + r() * 1500);
assert.equal(obfuscationStopDollars(cfg, dailyPnL, plan, '2026-09-08', leg), oldObfuscation(cfg, dailyPnL, plan, '2026-09-08', leg));
}
});
/** isInNoTradeWindow as it was, with the clock and settings passed in. */
function oldNoTrade(now, mode, jitter) {
const centralParts = new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: 'numeric', hour12: false, timeZone: 'America/Chicago' }).formatToParts(now);
const hour = parseInt(centralParts.find((p) => p.type === 'hour').value, 10);
const minute = parseInt(centralParts.find((p) => p.type === 'minute').value, 10);
const day = new Intl.DateTimeFormat('en-US', { weekday: 'short', timeZone: 'America/Chicago' }).format(now);
const timeMinutes = hour * 60 + minute;
if (mode === 'equity_hours') {
if (day === 'Sat' || day === 'Sun') return true;
const open = 8 * 60 + 35 + jitter;
const close = 14 * 60 + 55;
return timeMinutes < open || timeMinutes >= close;
}
if (day === 'Sat') return true;
const cmeOpen = 17 * 60 + 5 + jitter;
if (day === 'Sun') return timeMinutes < cmeOpen;
const cmeClose = 14 * 60 + 55;
if (day === 'Fri' && timeMinutes >= cmeClose) return true;
if (timeMinutes >= cmeClose && timeMinutes < cmeOpen) return true;
return false;
}
function oldAfterClose(now) {
const parts = new Intl.DateTimeFormat('en-US', { weekday: 'short', hour: 'numeric', minute: 'numeric', hour12: false, timeZone: 'America/Chicago' }).formatToParts(now);
const get = (type) => parts.find((p) => p.type === type)?.value ?? '';
if (get('weekday') === 'Sat' || get('weekday') === 'Sun') return false;
const minutes = (parseInt(get('hour'), 10) % 24) * 60 + parseInt(get('minute'), 10);
return minutes >= 14 * 60 + 55 && minutes < 17 * 60;
}
test('CME trading hours are unchanged, across both DST regimes', () => {
for (const start of [Date.UTC(2026, 8, 20), Date.UTC(2026, 0, 11)]) { // a September and a January week
for (let m = 0; m < 7 * 24 * 60; m += 7) {
const now = new Date(start + m * 60_000);
for (const mode of ['full_cme', 'equity_hours']) {
for (const jitter of [1, 13, 20]) {
assert.equal(isNoTradeTime(now, { everyDay: false, mode, jitterMin: jitter }), oldNoTrade(now, mode, jitter), `${now.toISOString()} ${mode} ${jitter}`);
}
}
assert.equal(isPastSessionClose(now, false), oldAfterClose(now), now.toISOString());
}
}
});
// ── every-day hours ──────────────────────────────────────────────────────────
const ct = (iso, offsetHours) => new Date(Date.parse(`${iso}Z`) + offsetHours * 3600_000);
const cdt = (iso) => ct(iso, 5); // Central daylight: UTC−5
const cst = (iso) => ct(iso, 6); // Central standard: UTC−6
test('every-day firms close only around the roll (CDT)', () => {
const o = { everyDay: true, mode: 'full_cme', jitterMin: 7 };
assert.equal(isNoTradeTime(cdt('2026-09-26T12:00:00'), o), false); // Saturday noon
assert.equal(isNoTradeTime(cdt('2026-09-27T09:00:00'), o), false); // Sunday morning
assert.equal(isNoTradeTime(cdt('2026-09-28T16:54:00'), o), false);
assert.equal(isNoTradeTime(cdt('2026-09-28T16:55:00'), o), true);
assert.equal(isNoTradeTime(cdt('2026-09-28T17:11:00'), o), true);
assert.equal(isNoTradeTime(cdt('2026-09-28T17:12:00'), o), false);
assert.equal(isPastSessionClose(cdt('2026-09-26T16:56:00'), true), true);
assert.equal(isPastSessionClose(cdt('2026-09-26T17:01:00'), true), false);
});
test('every-day firms close from 3:55 PM in CST, covering the 4 PM firm reset', () => {
const o = { everyDay: true, mode: 'equity_hours', jitterMin: 1 };
assert.equal(isNoTradeTime(cst('2027-01-12T15:54:00'), o), false);
assert.equal(isNoTradeTime(cst('2027-01-12T15:55:00'), o), true);
assert.equal(isNoTradeTime(cst('2027-01-12T16:30:00'), o), true);
assert.equal(isNoTradeTime(cst('2027-01-12T17:05:00'), o), true);
assert.equal(isNoTradeTime(cst('2027-01-12T17:06:00'), o), false);
assert.equal(isNoTradeTime(cst('2027-01-16T12:00:00'), o), false); // Saturday
assert.equal(isPastSessionClose(cst('2027-01-12T16:10:00'), true), true);
});
// ── fees ─────────────────────────────────────────────────────────────────────
test('percentFee rounds each side up to the cent', () => {
assert.equal(percentFee(0.04, 60000, 1, 1), 24);
assert.equal(percentFee(0.04, 100000, 0.2, 1), 8);
assert.equal(percentFee(0.04, 123.45, 0.001, 7), 0.01); // 0.00035 → a cent
assert.equal(percentFee(0, 60000, 1, 1), 0);
});
test('percent-fee take-profits net at least the leg, and one tick less does not', () => {
const r = rng(3);
for (let i = 0; i < 3000; i++) {
const side = r() < 0.5 ? 'Buy' : 'Sell';
const tick = [0.01, 0.1, 0.5, 1][Math.floor(r() * 4)];
const entry = Math.round((50 + r() * 100000) / tick) * tick;
const step = [0.001, 0.01, 1][Math.floor(r() * 3)];
const contracts = 1 + Math.floor(r() * 400);
const pct = [0.04, 0.02, 0.1][Math.floor(r() * 3)];
const leg = Math.round(5 + r() * 2000);
const entryFee = percentFee(pct, entry, step, contracts);
const tp = takeProfitPlan({ side, entry, leg, pointValue: step, contracts, tickSize: tick, commissionPct: pct, entryCommission: entryFee });
const k = step * contracts;
const dir = side === 'Buy' ? 1 : -1;
const net = (ticks) => {
const x = entry + dir * ticks * tick;
return ticks * tick * k - entryFee - percentFee(pct, x, step, contracts);
};
assert.ok(net(tp.ticks) >= leg - 1e-6, `short of the leg: ${JSON.stringify({ side, entry, tick, step, contracts, pct, leg, tp })}`);
if (tp.ticks > 1) assert.ok(net(tp.ticks - 1) < leg + 0.02, `a tick further than needed: ${JSON.stringify(tp)}`);
assert.equal(tp.totalCommission, Math.round((entryFee + tp.exitFee) * 100) / 100);
}
});
// ── daily loss limit ─────────────────────────────────────────────────────────
test('dllRoom: snapshot, fallback, green-day cap, no limit', () => {
assert.equal(dllRoom({ dailyLossLimit: 0, closedBalance: 50000, realizedToday: -900 }), Infinity);
assert.equal(dllRoom({ dailyLossLimit: 1500, closedBalance: 49600, dayStartBalance: 50000, realizedToday: 0 }), 1100);
assert.equal(dllRoom({ dailyLossLimit: 1500, closedBalance: 50600, dayStartBalance: 50000, realizedToday: 0 }), 1500);
assert.equal(dllRoom({ dailyLossLimit: 1000, closedBalance: 50000, dayStartBalance: null, realizedToday: -200 }), 800);
assert.equal(dllRoom({ dailyLossLimit: 1000, closedBalance: 50000, realizedToday: 350 }), 1000);
});
test('the worked futures example: $1,000 limit, down $200, NQ at $2.14/side', () => {
const room = dllRoom({ dailyLossLimit: 1000, closedBalance: 49800, realizedToday: -200 });
const d = dllStopDollars({ room, dailyLossLimit: 1000, entryFee: 2.14, commissionPct: 0 });
const ticks = stopTicks({ obfuscationDollars: Infinity, dllDollars: d, pointValue: 20, contracts: 1, tickSize: 0.25 });
assert.equal(ticks, 139);
const loss = ticks * 0.25 * 20 + 2 * 2.14;
assert.ok(-200 - loss > -1000 + 100 - 1e-9);
});
test('stopTicks: obfuscation rounds out, the daily-loss stop rounds in, the tighter wins', () => {
const base = { pointValue: 20, contracts: 1, tickSize: 0.25 };
assert.equal(stopTicks({ ...base, obfuscationDollars: Infinity, dllDollars: Infinity }), null);
assert.equal(stopTicks({ ...base, obfuscationDollars: 0, dllDollars: Infinity }), null);
assert.equal(stopTicks({ ...base, obfuscationDollars: 1001, dllDollars: Infinity }), 201);
assert.equal(stopTicks({ ...base, obfuscationDollars: Infinity, dllDollars: 1000 }), 200);
assert.equal(stopTicks({ ...base, obfuscationDollars: Infinity, dllDollars: 999.99 }), 199);
assert.equal(stopTicks({ ...base, obfuscationDollars: 600, dllDollars: 1000 }), 120);
assert.equal(stopTicks({ ...base, obfuscationDollars: 1600, dllDollars: 1000 }), 200);
assert.equal(stopTicks({ ...base, obfuscationDollars: Infinity, dllDollars: 3 }), 0);
assert.equal(stopTicks({ ...base, obfuscationDollars: Infinity, dllDollars: -40 }), 0);
});
test('worst case at the stop, both fees paid, never spends past room − reserve', () => {
const r = rng(4);
for (let i = 0; i < 20000; i++) {
const side = r() < 0.5 ? 'Buy' : 'Sell';
const pctFirm = r() < 0.6;
const pct = pctFirm ? [0.04, 0.1][Math.floor(r() * 2)] : 0;
const tick = pctFirm ? [0.01, 0.1, 1][Math.floor(r() * 3)] : 0.25;
const pv = pctFirm ? [0.001, 0.01, 1][Math.floor(r() * 3)] : [2, 20, 50][Math.floor(r() * 3)];
const contracts = pctFirm ? 1 + Math.floor(r() * 500) : 1 + Math.floor(r() * 3);
const entry = Math.round((100 + r() * 90000) / tick) * tick;
const dll = [300, 750, 1500, 3000][Math.floor(r() * 4)];
const room = dllRoom({ dailyLossLimit: dll, closedBalance: 50000, dayStartBalance: 50000 + Math.round((r() - 0.3) * dll), realizedToday: 0 });
const entryFee = pctFirm ? percentFee(pct, entry, pv, contracts) : Math.round(r() * 500) / 100 * contracts;
const d = dllStopDollars({ room, dailyLossLimit: dll, entryFee, commissionPct: pct });
const ticks = stopTicks({ obfuscationDollars: Infinity, dllDollars: d, pointValue: pv, contracts, tickSize: tick });
if (ticks === 0) continue; // flattened instead
const stopPrice = side === 'Buy' ? entry - ticks * tick : entry + ticks * tick;
const exitFee = pctFirm ? percentFee(pct, stopPrice, pv, contracts) : entryFee;
const loss = ticks * tick * pv * contracts + entryFee + exitFee;
assert.ok(loss <= room - DLL_RESERVE_FRACTION * dll + 1e-6, JSON.stringify({ side, pct, tick, pv, contracts, entry, dll, room, ticks, loss }));
}
});
test('dllViable and botStopsActive', () => {
assert.equal(dllViable(Infinity, 0, 0), true);
assert.equal(dllViable(1500, 1500, 20), true);
assert.equal(dllViable(150 + DLL_MIN_STOP + 20, 1500, 20), true);
assert.equal(dllViable(150 + DLL_MIN_STOP + 19.99, 1500, 20), false);
assert.equal(botStopsActive({ firstDayStop: false, minDayStop: false }), false);
assert.equal(botStopsActive({ firstDayStop: false, minDayStop: false, dailyLossLimit: 1500 }), true);
assert.equal(botStopsActive({ firstDayStop: true, minDayStop: false, dailyLossLimit: 0 }), true);
});
// ── sizing ───────────────────────────────────────────────────────────────────
const btcSigma = 0.03 / Math.sqrt(24); // 3%/day
test('volatility sizing hits the target hold, and the example table', () => {
const stop = () => 1350;
const at = (hours) => sizeNotional({ leg: 600, stopDollarsAt: stop, volatilityPerHour: btcSigma, targetHoldHours: hours, commissionPct: 0, maxNotional: 0 });
for (const [hours, notional] of [[54, 20000], [24, 30000], [6, 60000]]) {
const s = at(hours);
assert.ok(Math.abs(s.notional - notional) / notional < 0.001, `${hours}h → ${s.notional}`);
assert.ok(Math.abs(s.predictedHoldHours - hours) / hours < 0.001);
assert.ok(Math.abs(s.winProbability - 1350 / 1950) < 1e-9);
}
});
test('sizing: fees feed back, the cap binds, financing only raises N, defaults', () => {
const base = { leg: 600, volatilityPerHour: btcSigma, targetHoldHours: 6, commissionPct: 0.04, spreadFraction: 0.0002, maxNotional: 0 };
// Daily-loss stop shrinks as fees grow with N.
const stopDollarsAt = (n) => dllStopDollars({ room: 1500, dailyLossLimit: 1500, entryFee: 0.0004 * n, commissionPct: 0.04 });
const s = sizeNotional({ ...base, stopDollarsAt });
const a = 600 + 2 * 0.0004 * s.notional;
const b = stopDollarsAt(s.notional);
assert.ok(Math.abs(s.predictedHoldHours - 6) < 0.05, `hold ${s.predictedHoldHours}`);
assert.ok(Math.abs(s.winProbability - b / (a + b)) < 1e-9);
const capped = sizeNotional({ ...base, stopDollarsAt, maxNotional: 25000 });
assert.equal(capped.notional, 25000);
assert.ok(capped.predictedHoldHours > 6);
const financed = sizeNotional({ ...base, stopDollarsAt, targetHoldHours: 200, financingPctPerDay: 0.033 });
const unfinanced = sizeNotional({ ...base, stopDollarsAt, targetHoldHours: 200 });
assert.ok(financed.notional > unfinanced.notional);
// No target hold = 12 h.
const dflt = sizeNotional({ ...base, targetHoldHours: 0, stopDollarsAt });
assert.ok(Math.abs(dflt.predictedHoldHours - DEFAULT_TARGET_HOLD_HOURS) < 0.1, `hold ${dflt.predictedHoldHours}`);
// A stopless leg is sized as if the stop sat as far as the target.
const stopless = sizeNotional({ ...base, stopDollarsAt: () => Infinity });
assert.ok(Math.abs(stopless.predictedHoldHours - 6) < 0.05);
assert.equal(stopless.winProbability, null);
// No volatility, no size.
assert.equal(sizeNotional({ ...base, volatilityPerHour: undefined, stopDollarsAt }), null);
});
test('random-walk simulation agrees with the predicted hold and win rate', () => {
const r = rng(5);
const notional = 60000;
const A = 600;
const B = 1350;
const sigma = btcSigma;
const dtHours = 1 / 60; // one-minute steps
const stepDollars = notional * sigma * Math.sqrt(dtHours);
const predicted = (A / notional) * (B / notional) / (sigma * sigma);
let wins = 0;
let totalHours = 0;
let winHours = 0;
const paths = 3000;
for (let p = 0; p < paths; p++) {
let x = 0;
let steps = 0;
while (x < A && x > -B) {
x += r() < 0.5 ? stepDollars : -stepDollars;
steps++;
}
if (x >= A) { wins++; winHours += steps * dtHours; }
totalHours += steps * dtHours;
}
const meanHold = totalHours / paths;
assert.ok(Math.abs(meanHold - predicted) / predicted < 0.1, `mean hold ${meanHold} vs predicted ${predicted}`);
assert.ok(Math.abs(wins / paths - B / (A + B)) < 0.03, `win rate ${wins / paths}`);
// Winners take longer than the average leg: (A² + 2AB) / 3σ$² — what
// expectedSwapToWin charges swap over.
const predictedWin = (A * A + 2 * A * B) / (3 * (notional * sigma) ** 2);
assert.ok(Math.abs(winHours / wins - predictedWin) / predictedWin < 0.1, `winners ${winHours / wins}h vs ${predictedWin}h`);
});
test('expected swap to win: a fixed point over the winners\' hold, zero without swap, capped', () => {
const sigma = 0.022 / Math.sqrt(24); // ETH over a week, ~2.2%/day
const o = { distance: 12400, stopDollars: 2300, notional: 200000, volatilityPerHour: sigma, financingPctPerDay: 0.033 };
const swap = expectedSwapToWin(o);
const a = o.distance + swap;
const b = o.stopDollars;
const winHours = (a * a + 2 * a * b) / (3 * (o.notional * sigma) ** 2);
assert.ok(Math.abs(swap - 0.00033 * o.notional * (winHours / 24)) < 0.02, `swap ${swap} over ${winHours}h`);
assert.ok(swap > 200 && swap < 400, `swap ${swap}`);
// Quieter means a longer hold and more swap; none without a rate or σ.
assert.ok(expectedSwapToWin({ ...o, volatilityPerHour: sigma * 0.6 }) > swap);
assert.equal(expectedSwapToWin({ ...o, financingPctPerDay: 0 }), 0);
assert.equal(expectedSwapToWin({ ...o, volatilityPerHour: undefined }), 0);
// Stopless reads as B = A: a longer walk than with the stop.
assert.ok(expectedSwapToWin({ ...o, stopDollars: Infinity }) > swap);
// A leg that can barely move is capped at a quarter of its distance.
assert.equal(expectedSwapToWin({ ...o, volatilityPerHour: sigma / 50 }), 3100);
});
test('the notional cap: what the available funds can margin at the symbol\'s rate', () => {
assert.equal(notionalCap({ marginRate: 0.2, availableFunds: 100000 }), MARGIN_HEADROOM * 500000);
assert.equal(notionalCap({ marginRate: 0.5, availableFunds: 99757.39 }), MARGIN_HEADROOM * 99757.39 * 2);
assert.equal(notionalCap({ marginRate: 0.2, availableFunds: undefined }), 0);
assert.equal(notionalCap({ availableFunds: 100000 }), 0);
});
test('the daily-loss reserve grows with the position past 10% of the limit', () => {
assert.equal(dllReserve(3000), 300);
assert.equal(dllReserve(3000, 200000), 300);
assert.equal(dllReserve(3000, 485000), 485000 * DLL_SLIPPAGE_FRACTION);
const at = (notional) => dllStopDollars({ room: 3000, dailyLossLimit: 3000, entryFee: 0.0004 * notional, commissionPct: 0.04, notional });
// Below $300k the flat 10% binds; above, 0.1% of notional does.
assert.ok(Math.abs(at(200000) - (3000 - 300 - 160 - 0.01) / 1.0004) < 1e-9);
assert.ok(Math.abs(at(485000) - (3000 - 485 - 388 - 0.01) / 1.0004) < 1e-9);
// No notional given: the old flat reserve.
assert.equal(dllStopDollars({ room: 3000, dailyLossLimit: 3000, entryFee: 0, commissionPct: 0 }), 2699.99);
});
test('max-drawdown room: the static floor, the broker floor, the higher', () => {
assert.equal(maxLossRoom({ balance: 99757.39, accountSize: 100000, maxLoss: 6000 }), 5757.39);
assert.equal(maxLossRoom({ balance: 99757.39, accountSize: 100000, maxLoss: -6000 }), 5757.39);
assert.equal(maxLossRoom({ balance: 51000, accountSize: 50000, maxLoss: 0, autoLiqThreshold: 48500 }), 2500);
assert.equal(maxLossRoom({ balance: 51000, accountSize: 50000, maxLoss: 2000, autoLiqThreshold: 49000 }), 2000);
assert.equal(maxLossRoom({ balance: 99000, accountSize: 100000, maxLoss: 0 }), null);
});
test('last stand: no daily-loss stop once hitting the daily limit would leave 15% of it or less', () => {
// A full day's $3,000 of room.
const at = (maxRoom) => lastStand({ dllRoom: 3000, dailyLossLimit: 3000, maxLossRoom: maxRoom });
assert.equal(LAST_STAND_FRACTION, 0.15);
assert.equal(at(5757.39), false, 'a fresh 1-Step keeps its stop');
assert.equal(at(3000 * 1.15 + 1), false, 'just over 15% left: keep the stop');
assert.equal(at(3000 * 1.15), true, '15% left: last stand');
assert.equal(at(3294.70), true, 'one stopped day in: $294.70 left, 9.8% — last stand');
assert.equal(at(800), true, 'floor nearer than the limit: last stand');
assert.equal(at(null), false, 'no floor known');
// Measured against what's left of the day: $1,000 of room, $1,200 to the floor.
assert.equal(lastStand({ dllRoom: 1000, dailyLossLimit: 3000, maxLossRoom: 1200 }), false);
assert.equal(lastStand({ dllRoom: 1000, dailyLossLimit: 3000, maxLossRoom: 1150 }), true);
assert.equal(lastStand({ dllRoom: Infinity, dailyLossLimit: 0, maxLossRoom: 500 }), false, 'no daily limit, no daily stop to drop');
// The far side of the walk is the nearer floor, net of the entry fee.
assert.equal(breachDistance({ dllRoom: 3000, maxLossRoom: 2900, entryFee: 120 }), 2780);
assert.equal(breachDistance({ dllRoom: 2757.39, maxLossRoom: null, entryFee: 0 }), 2757.39);
});
test('planLegSize: the stop, a last stand, the margin cap and the cost, as fireLeg trades', () => {
const eth = { stepNotional: 26.86, volatilityPerHour: 0.022 / Math.sqrt(24), spreadFraction: 0.00004, marginRate: 0.2 };
const base = {
leg: 12239, contract: eth, commissionPct: 0.04, targetHoldHours: 12, financingPctPerDay: 0.033,
dailyLossLimit: 3000, dllRoom: 2757.39, obfuscationDollars: Infinity, availableFunds: 99757.39,
};
const fresh = planLegSize({ ...base, maxLossRoom: 5757.39 });
assert.equal(fresh.lastStand, false);
assert.ok(Math.abs(fresh.sizing.predictedHoldHours - 12) < 0.1, `hold ${fresh.sizing.predictedHoldHours}`);
const n = fresh.sizing.notional;
assert.ok(Math.abs(fresh.walkAt(n) - dllStopDollars({ room: 2757.39, dailyLossLimit: 3000, entryFee: 0.0004 * n, commissionPct: 0.04, notional: n })) < 1e-9);
const expectCost = n * (0.00004 + 0.0008) + 0.00033 * n * (fresh.sizing.predictedHoldHours / 24);
assert.ok(Math.abs(fresh.cost - expectCost) < 1e-6);
// Two days stopped out: a stop-out would leave ~$300 above the floor.
const stand = planLegSize({ ...base, dllRoom: 3000, maxLossRoom: 3000 });
assert.equal(stand.lastStand, true);
assert.equal(stand.walkAt(300000), 3000 - 120);
assert.ok(stand.sizing.winProbability > fresh.sizing.winProbability, 'the whole room, no reserve: a better chance');
// An obfuscation stop still counts on a last stand.
assert.equal(planLegSize({ ...base, dllRoom: 3000, maxLossRoom: 3000, obfuscationDollars: 1000 }).walkAt(300000), 1000);
// A 2:1 stock can't reach 12 h in a quiet market: the cap binds.
const stock = planLegSize({ ...base, maxLossRoom: 5757.39, contract: { ...eth, marginRate: 0.5, volatilityPerHour: 0.0076 / Math.sqrt(24) } });
assert.ok(Math.abs(stock.sizing.notional - stock.cap) < 1e-6);
assert.ok(stock.sizing.predictedHoldHours > 12);
// Futures (no stepNotional) aren't sized by notional.
assert.equal(planLegSize({ ...base, maxLossRoom: null, contract: {} }).sizing, null);
});
test('planLegSize: the broker\'s order size limits bind the sizing, so the pick sees the real hold', () => {
const eth = { stepNotional: 26.86, volatilityPerHour: 0.022 / Math.sqrt(24), spreadFraction: 0.00004, marginRate: 0.2 };
const base = {
leg: 12239, contract: eth, commissionPct: 0.04, targetHoldHours: 12, financingPctPerDay: 0.033,
dailyLossLimit: 3000, dllRoom: 2757.39, maxLossRoom: 5757.39, obfuscationDollars: Infinity, availableFunds: 99757.39,
};
const free = planLegSize(base);
// A largest order well under the ideal size: the notional stops there and
// the hold grows past the target (the margin cap stays what it was).
const capped = planLegSize({ ...base, contract: { ...eth, maxContracts: 5000 } });
assert.ok(Math.abs(capped.sizing.notional - 5000 * 26.86) < 1e-6, `notional ${capped.sizing.notional}`);
assert.ok(capped.sizing.notional < free.sizing.notional);
assert.ok(capped.sizing.predictedHoldHours > 12, `hold ${capped.sizing.predictedHoldHours}`);
assert.equal(capped.cap, free.cap);
// One that doesn't bind changes nothing.
assert.equal(planLegSize({ ...base, contract: { ...eth, maxContracts: 1e9 } }).sizing.notional, free.sizing.notional);
// A smallest order above the ideal size: the order is that big.
const floored = planLegSize({ ...base, contract: { ...eth, minContracts: 20000 } });
assert.ok(Math.abs(floored.sizing.notional - 20000 * 26.86) < 1e-6);
assert.ok(floored.sizing.predictedHoldHours < 12);
// Ranked on the capped hold, a capped instrument no longer wins on cost.
const order = rankForTarget([
{ holdHours: capped.sizing.predictedHoldHours, cost: capped.cost },
{ holdHours: free.sizing.predictedHoldHours, cost: free.cost + 50 },
], 12);
assert.deepEqual(order, [1, 0]);
});
test('rankForTarget: reach the target hold first, cheapest; else quickest', () => {
const order = rankForTarget([
{ holdHours: 12, cost: 330 }, // ETH
{ holdHours: 12.1, cost: 300 }, // BTC — within the 2% tolerance
{ holdHours: 40, cost: 250 }, // XYZ100, capped at 2:1
{ holdHours: 20, cost: 400 }, // SP500, capped
], 12);
assert.deepEqual(order, [1, 0, 3, 2]);
assert.deepEqual(rankForTarget([{ holdHours: 30, cost: 1 }, { holdHours: 25, cost: 9 }], 12), [1, 0]);
assert.deepEqual(rankForTarget([], 12), []);
});
test('sizing carries the swap reserve into the take-profit distance', () => {
const sigma = 0.0124 / Math.sqrt(24);
const stopDollarsAt = (n) => dllStopDollars({ room: 2760.15, dailyLossLimit: 3000, entryFee: 0.0004 * n, commissionPct: 0.04 });
const base = { leg: 12239, stopDollarsAt, volatilityPerHour: sigma, targetHoldHours: 12, commissionPct: 0.04, spreadFraction: 0.0001 };
const withSwap = sizeNotional({ ...base, financingPctPerDay: 0.033, maxNotional: 200000 });
const noSwap = sizeNotional({ ...base, financingPctPerDay: 0, maxNotional: 200000 });
assert.equal(noSwap.swapReserve, 0);
assert.ok(withSwap.swapReserve > 500, `swap ${withSwap.swapReserve}`);
const a = 12239 + 2 * 0.0004 * 200000 + withSwap.swapReserve;
const b = stopDollarsAt(200000);
assert.ok(Math.abs(withSwap.predictedHoldHours - (a / 200000) * (b / 200000) / (sigma * sigma)) < 1e-6);
assert.ok(withSwap.predictedHoldHours > noSwap.predictedHoldHours);
assert.ok(withSwap.winProbability < noSwap.winProbability);
});
// ── every-day calendar ───────────────────────────────────────────────────────
test('nextTradingDate and effectiveCombine', () => {
assert.equal(nextTradingDate('2026-09-25'), '2026-09-28');
assert.equal(nextTradingDate('2026-09-25', true), '2026-09-26');
assert.equal(nextTradingDate('2026-12-24', true), '2026-12-25');
assert.equal(effectiveCombine({ combineCmeHolidays: true }), true);
assert.equal(effectiveCombine({ combineCmeHolidays: true, tradesEveryDay: true }), false);
});
const projCfg = {
profitTarget: 3000, consistency: 0, minDayPnL: 0, minTradingDays: 5, accountSize: 50000,
targetSameEquity: false, withdrawalStages: [], splitEnabled: true,
};
test('every-day projections use consecutive dates; CME ones still skip weekends', () => {
const now = cdt('2026-09-26T12:00:00'); // Saturday noon → trade date Saturday
const common = { accountId: 7, cycleDailyPnL: [], fundTransactions: [], balance: 50000, realizedPnL: 0, now };
const every = projectPerfectPath({ ...common, cfg: { ...projCfg, tradesEveryDay: true } });
const cme = projectPerfectPath({ ...common, cfg: projCfg });
assert.ok(every.length > 1);
for (let i = 1; i < every.length; i++) assert.equal(every[i].date, nextTradingDate(every[i - 1].date, true));
for (const d of cme) {
const dow = new Date(`${d.date}T00:00:00Z`).getUTCDay();
assert.ok(dow !== 0 && dow !== 6, `CME projection landed on ${d.date}`);
}
// Saturday itself is booked (silently) for the every-day firm: its first
// projected day is Sunday, the CME one's is Monday.
assert.equal(every[0].date, '2026-09-27');
assert.equal(cme[0].date, '2026-09-28');
});
test('an every-day firm can finish its cycle on a Saturday', () => {
const now = cdt('2026-09-26T12:00:00');
const cycleDailyPnL = [
{ date: '2026-09-21', pnl: 700 }, { date: '2026-09-22', pnl: 700 },
{ date: '2026-09-23', pnl: 700 }, { date: '2026-09-24', pnl: 700 },
];
const opts = {
accountId: 7, rawTradeDate: '2026-09-26', cycleDailyPnL, rawRealizedPnL: 400, combineCmeHolidays: false,
effective: { profitTarget: 3000, consistency: 0, minTradingDays: 5, minDayPnL: 0, drain: false },
equityProfit: 3200, splitEnabled: true, now,
};
assert.equal(cycleCompletion({ ...opts, tradesEveryDay: true }), 'today');
assert.equal(cycleCompletion(opts), 'none');
});
// ── DXTrade helpers (lib/dxtrade-helpers.ts) ─────────────────────────────────
import {
dxAccountId, accountDisplayName, accountCodeOf, encodeContractName, decodeContractName, roundToStep,
lastUtcReset, ctTradeDayStart, firmDayOf, parseDurationMs, ciNum, signedFundValue, inTradingHours,
realizedVolPerHour, scoreCandidate, candidateRejection, MIN_VOL_CANDLES, parseTransfers, marginRateOf,
} from '../lib/dxtrade-helpers.ts';
test('transfers: a close\'s SETTLEMENT and COMMISSION share a code and both count', () => {
// As Tradeify247 answered for a flatten.
const body = { cashTransfers: [
{ account: 'default:1S100K2166398', transferCode: '2354051:1417097', transactionTime: '2026-09-26T22:33:39.615Z', cashTransactions: [
{ account: 'default:1S100K2166398', transactionCode: '2354051:1417097', type: 'COMMISSION', value: -79.95, transactionTime: '2026-09-26T22:33:39.615Z', orderCode: 'x:af-1-e' },
] },
{ account: 'default:1S100K2166398', transferCode: '2354051:1417102', transactionTime: '2026-09-26T22:33:42.197Z', cashTransactions: [
{ account: 'default:1S100K2166398', transactionCode: '2354051:1417102', type: 'SETTLEMENT', value: -5.43, transactionTime: '2026-09-26T22:33:42.197Z', orderCode: 'x:af-2-x' },
{ account: 'default:1S100K2166398', transactionCode: '2354051:1417102', type: 'COMMISSION', value: -79.95, transactionTime: '2026-09-26T22:33:42.197Z', orderCode: 'x:af-2-x' },
] },
] };
const txns = parseTransfers(body);
assert.equal(txns.length, 3);
const byKey = new Map(txns.map((t) => [t.key, t]));
assert.equal(byKey.size, 3, 'every transaction keeps its own key');
assert.equal(Math.round([...byKey.values()].reduce((s, t) => s + t.value, 0) * 100) / 100, -165.33);
// The same answer again (the next sync, another page) keys identically.
assert.deepEqual(parseTransfers(body).map((t) => t.key), txns.map((t) => t.key));
// Two of one type in one transfer stay apart too.
const twice = { cashTransfers: [{ transferCode: 't', cashTransactions: [
{ account: 'default:A', transactionCode: 'c', type: 'COMMISSION', value: -1, transactionTime: '2026-09-26T00:00:00Z' },
{ account: 'default:A', transactionCode: 'c', type: 'COMMISSION', value: -2, transactionTime: '2026-09-26T00:00:00Z' },
] }] };
assert.equal(new Set(parseTransfers(twice).map((t) => t.key)).size, 2);
});
test('margin rates: flat and tiered, the steepest tier, junk ignored', () => {
assert.equal(marginRateOf({ marginRate: { rateType: 'FLAT', tiers: [{ endVolume: 'Infinity', value: 0.2 }] } }), 0.2);
assert.equal(marginRateOf({ marginRate: { rateType: 'FLAT', tiers: [{ endVolume: 'Infinity', value: 0.5 }] } }), 0.5);
assert.equal(marginRateOf({ marginRate: { tiers: [{ endVolume: 1e6, value: 0.1 }, { endVolume: 'Infinity', value: 0.25 }] } }), 0.25);
assert.equal(marginRateOf({ marginRate: { value: 0.05 } }), 0.05);
assert.equal(marginRateOf({}), undefined);
assert.equal(marginRateOf(undefined), undefined);
assert.equal(marginRateOf({ marginRate: { tiers: [{ value: 'x' }, { value: 0 }, { value: 3 }] } }), undefined);
});
test('dxAccountId is stable, safe, far above broker ids, and host-scoped', () => {
const a = dxAccountId('dx.tradeify247.co', 'default:TFY-1');
assert.equal(a, dxAccountId('DX.Tradeify247.co', 'default:TFY-1'));
assert.ok(Number.isSafeInteger(a) && a >= 2 ** 50 && a < 2 ** 53);
assert.notEqual(a, dxAccountId('dx.other.co', 'default:TFY-1'));
const seen = new Set();
for (let i = 0; i < 20000; i++) seen.add(dxAccountId('dx.tradeify247.co', `default:${i}`));
assert.equal(seen.size, 20000);
});
test('account codes and contract names', () => {
assert.equal(accountDisplayName('default:TFY-50K-12'), 'TFY-50K-12');
assert.equal(accountDisplayName('plain'), 'plain');
assert.equal(accountCodeOf({ clearing: 'default', account: 'x1' }), 'default:x1');
assert.equal(accountCodeOf('default:x1'), 'default:x1');
assert.deepEqual(decodeContractName(encodeContractName('BTC/USD', 0.001)), { symbol: 'BTC/USD', step: 0.001 });
assert.equal(decodeContractName('NQZ5'), null);
assert.equal(roundToStep(0.1 + 0.2, 0.1), 0.3);
assert.equal(roundToStep(12 * 0.001, 0.001), 0.012);
assert.equal(roundToStep(101.234, 0.5, 'up'), 101.5);
assert.equal(roundToStep(101.234, 0.5, 'down'), 101);
});
test('the firm reset and the CT trade-date start', () => {
assert.equal(lastUtcReset(new Date('2026-09-26T21:59:00Z')).toISOString(), '2026-09-25T22:00:00.000Z');
assert.equal(lastUtcReset(new Date('2026-09-26T22:00:00Z')).toISOString(), '2026-09-26T22:00:00.000Z');
assert.equal(ctTradeDayStart(cdt('2026-09-26T18:30:00')).toISOString(), '2026-09-26T22:00:00.000Z');
assert.equal(ctTradeDayStart(cdt('2026-09-27T09:00:00')).toISOString(), '2026-09-26T22:00:00.000Z');
assert.equal(ctTradeDayStart(cst('2027-01-12T10:00:00')).toISOString(), '2027-01-11T23:00:00.000Z');
});
test('the firm\'s day: the same label as the CT trade date, rolling at its reset', () => {
// Summer: the reset is 5 PM CDT, so the two agree on both sides of it.
assert.equal(firmDayOf(new Date('2026-09-26T21:59:00Z')), '2026-09-26');
assert.equal(firmDayOf(new Date('2026-09-26T22:00:00Z')), '2026-09-27');
assert.equal(firmDayOf(new Date('2026-09-26T22:00:00Z')), currentTradeDateCT(new Date('2026-09-26T22:00:00Z')));
// Winter: a close at 4:30 PM CST is the firm's next day, still ours.
const close = cst('2027-01-12T16:30:00');
assert.equal(firmDayOf(close), '2027-01-13');
assert.equal(currentTradeDateCT(close), '2027-01-12');
assert.equal(firmDayOf(cst('2027-01-12T15:59:00')), '2027-01-12');
assert.equal(firmDayOf(cst('2027-01-12T17:30:00')), currentTradeDateCT(cst('2027-01-12T17:30:00')));
});
test('loose parsing', () => {
assert.equal(parseDurationMs('PT30M'), 30 * 60_000);
assert.equal(parseDurationMs('01:05:08'), (3600 + 300 + 8) * 1000);
assert.equal(parseDurationMs({ seconds: 900 }), 900_000);
assert.equal(parseDurationMs(undefined), 30 * 60_000);
assert.equal(ciNum({ openPL: 12.5 }, 'openPl'), 12.5);
assert.equal(ciNum({ openPL: '12' }, 'openPl'), undefined);
assert.equal(signedFundValue('WITHDRAWAL', 500), -500);
assert.equal(signedFundValue('WITHDRAWAL', -500), -500);
assert.equal(signedFundValue('DEPOSIT', 50000), 50000);
});
test('trading hours from UTC week sessions', () => {
const at = (dayOfWeek, hour, eventType) => ({ eventType, weekDay: { dayOfWeek, time: { hour, minute: 30 } } });
const weekdays = [at('MONDAY', 13, 'SESSION_OPEN'), at('MONDAY', 20, 'SESSION_CLOSE'), at('TUESDAY', 13, 'SESSION_OPEN'), at('TUESDAY', 20, 'SESSION_CLOSE')];
assert.equal(inTradingHours(weekdays, new Date('2026-09-28T15:00:00Z')), true); // Monday
assert.equal(inTradingHours(weekdays, new Date('2026-09-28T21:00:00Z')), false);
assert.equal(inTradingHours(weekdays, new Date('2026-09-27T15:00:00Z')), false); // Sunday wraps to Tuesday's close
assert.equal(inTradingHours([], new Date()), true);
assert.equal(inTradingHours([{ garbage: true }], new Date()), true);
});
test('realized volatility of hourly closes', () => {
assert.equal(realizedVolPerHour([100, 101, 102]), null);
const r = rng(6);
const sigma = 0.004;
const closes = [30000];
for (let i = 0; i < 2000; i++) {
// Box–Muller for a normal step
const z = Math.sqrt(-2 * Math.log(1 - r())) * Math.cos(2 * Math.PI * r());
closes.push(closes[closes.length - 1] * Math.exp(sigma * z));
}
const v = realizedVolPerHour(closes);
assert.ok(Math.abs(v - sigma) / sigma < 0.08, `σ ${v}`);
const flat = Array.from({ length: MIN_VOL_CANDLES + 1 }, () => 200);
assert.equal(realizedVolPerHour(flat), 0);
});
test('the selector ranks the plan example: ETH, BTC, XYZ100, AAPL', () => {
const perHour = (daily) => daily / Math.sqrt(24);
const score = (spread, daily) => scoreCandidate({
spreadFraction: spread, commissionPct: 0.04, financingPctPerDay: 0, targetHoldHours: 6, volatilityPerHour: perHour(daily),
});
const board = [
['BTC/USD', score(0.0002, 0.03)],
['XYZ100', score(0.0001, 0.012)],
['AAPL/USD (Sat)', score(0.0005, 0.004)],
['ETH/USD', score(0.0003, 0.04)],
['AAPL/USD (Tue)', score(0.0005, 0.015)],
].sort((a, b) => a[1] - b[1]).map((b) => b[0]);
assert.deepEqual(board, ['ETH/USD', 'BTC/USD', 'XYZ100', 'AAPL/USD (Tue)', 'AAPL/USD (Sat)']);
// Cost per leg = score × √(A·B/T): the example's $50 and $60.
const k = Math.sqrt((600 * 1350) / 6);
assert.ok(Math.abs(score(0.0003, 0.04) * k - 49.6) < 1);
assert.ok(Math.abs(score(0.0002, 0.03) * k - 60) < 1);
// No volatility, no score; financing over the hold adds to the cost.
assert.equal(scoreCandidate({ spreadFraction: 0.0002, commissionPct: 0.04, financingPctPerDay: 0, targetHoldHours: 6, volatilityPerHour: null }), null);
const withFinancing = scoreCandidate({ spreadFraction: 0.0002, commissionPct: 0.04, financingPctPerDay: 0.033, targetHoldHours: 12, volatilityPerHour: perHour(0.03) });
assert.ok(withFinancing > score(0.0002, 0.03));
});
test('the selector drops what cannot trade right now', () => {
const ok = {
tradingStatus: 'FULL', bid: 100, ask: 100.02, quoteAgeMs: 1000, inHours: true, candles: 49,
minContractsOk: true, currency: 'USD', accountCurrency: 'USD',
};
assert.equal(candidateRejection(ok), null);
assert.match(candidateRejection({ ...ok, tradingStatus: 'CLOSE_ONLY' }), /CLOSE_ONLY/);
assert.match(candidateRejection({ ...ok, inHours: false }), /hours/);
assert.match(candidateRejection({ ...ok, bid: undefined }), /quote/);
assert.match(candidateRejection({ ...ok, quoteAgeMs: 10 * 60_000 }), /old/);
assert.match(candidateRejection({ ...ok, ask: 101 }), /spread/);
assert.match(candidateRejection({ ...ok, candles: 10 }), /candles/);
assert.match(candidateRejection({ ...ok, currency: 'EUR' }), /EUR/);
});
// ── Hyperliquid price fallback (lib/hyperliquid-feed.ts) ─────────────────────
import { hyperliquidCoinFor, parseL2Book } from '../lib/hyperliquid-feed.ts';
test('DXtrade symbols map to Hyperliquid coins', () => {
const coins = new Set(['BTC', 'ETH', 'xyz:XYZ100', 'xyz:SP500', 'xyz:AAPL']);
assert.equal(hyperliquidCoinFor('BTC/USD', coins), 'BTC');
assert.equal(hyperliquidCoinFor('ETH/USD', coins), 'ETH');
assert.equal(hyperliquidCoinFor('XYZ100', coins), 'xyz:XYZ100');
assert.equal(hyperliquidCoinFor('SP500', coins), 'xyz:SP500');
assert.equal(hyperliquidCoinFor('AAPL/USD', coins), 'xyz:AAPL');
assert.equal(hyperliquidCoinFor('DOGE/USD', coins), null);
});
test('l2Book best bid/ask', () => {
const q = parseL2Book({ levels: [[{ px: '84310.0' }, { px: '84309' }], [{ px: '84311.0' }]], time: 1790461870878 });
assert.deepEqual(q, { bid: 84310, ask: 84311, time: 1790461870878 });
const empty = parseL2Book({ levels: [[], []] });
assert.equal(empty.bid, undefined);
assert.equal(empty.ask, undefined);
});
+227
View File
@@ -0,0 +1,227 @@
#!/usr/bin/env node
// DXtrade smoke test — run against a real login before the scheduler trades a
// DXtrade firm. Confirms the API details lib/dxtrade-class.ts assumes.
//
// DX_BASE=https://dx.tradeify247.co/dxsca-web DX_USER=... DX_PASS=... \
// DX_DOMAIN=default DX_SYMBOLS=BTC/USD,ETH/USD,XYZ100 node scripts/dxtrade-smoke.mjs
//
// Read-only unless DX_CONFIRM=yes AND DX_ACCOUNT=<account code> are set, which
// adds a minimum-size round trip on that account (a real order: it pays two
// commissions). Credentials come from the environment only; the password and
// session token are never printed.
//
// Run it while the instance's client for the firm is disconnected (remove the
// credentials there, or stop the instance): two sessions on one login can
// collide, and logins are rate-limited per IP.
const BASE = (process.env.DX_BASE ?? '').replace(/\/+$/, '');
const USER = process.env.DX_USER ?? '';
const PASS = process.env.DX_PASS ?? '';
const DOMAIN = process.env.DX_DOMAIN || 'default';
const SYMBOLS = (process.env.DX_SYMBOLS ?? 'BTC/USD').split(',').map((s) => s.trim()).filter(Boolean);
const ORDER_ACCOUNT = process.env.DX_ACCOUNT ?? '';
const CONFIRM = process.env.DX_CONFIRM === 'yes';
if (!BASE.startsWith('https://') || !USER || !PASS) {
console.error('Set DX_BASE (https://…/dxsca-web), DX_USER and DX_PASS.');
process.exit(1);
}
let token = null;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const section = (title) => console.log(`\n── ${title} ${'─'.repeat(Math.max(0, 70 - title.length))}`);
const show = (label, value) => console.log(`${label}:`, typeof value === 'string' ? value : JSON.stringify(value, null, 2)?.slice(0, 4000));
async function call(method, path, { params, body, headers } = {}) {
const url = new URL(`${BASE}${path}`);
for (const [k, v] of Object.entries(params ?? {})) url.searchParams.set(k, String(v));
const res = await fetch(url, {
method,
headers: {
Accept: 'application/json',
...(token ? { Authorization: `DXAPI ${token}` } : {}),
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
...headers,
},
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(30_000),
});
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch { data = text.slice(0, 500); }
return { status: res.status, headers: Object.fromEntries(res.headers), data };
}
async function step(label, fn) {
try {
await fn();
} catch (err) {
console.log(`${label} FAILED: ${err instanceof Error ? err.message : String(err)}`);
}
}
const codeOf = (a) => (typeof a === 'string' ? a : a && typeof a === 'object' ? `${a.clearing}:${a.account}` : String(a));
section('login');
{
const res = await call('POST', '/login', { body: { username: USER, domain: DOMAIN, password: PASS } });
if (res.status !== 200 || !res.data?.sessionToken) {
show(`login refused (HTTP ${res.status})`, res.data);
console.log('Check DX_DOMAIN (try "default", then the broker name) — and stop after a couple of tries: repeated failures lock the account.');
process.exit(1);
}
token = res.data.sessionToken;
show('session timeout field (format check)', res.data.timeout);
}
let accounts = [];
section('accounts (/users)');
for (const path of [`${USER}@${DOMAIN}`, USER]) {
const res = await call('GET', `/users/${encodeURIComponent(path)}`);
console.log(`/users/${path} → HTTP ${res.status}`);
if (res.status === 200) {
accounts = (res.data?.userDetails ?? []).flatMap((u) => u.accounts ?? []);
for (const a of accounts) {
console.log(` ${codeOf(a.account)} status=${a.accountStatus} positionBased=${a.positionBased} currency=${a.baseCurrency} registered=${a.registrationTime}`);
}
break;
}
}
const codes = accounts.map((a) => codeOf(a.account));
const ref = codes[0];
if (!ref) {
console.log('No accounts found — nothing more to check.');
process.exit(0);
}
section('metrics (key casing: openPL vs openPl)');
await step('metrics', async () => {
const res = await call('GET', '/accounts/metrics', { params: { accounts: codes.join(',') } });
show(`HTTP ${res.status}`, res.data?.metrics?.[0] ?? res.data);
});
section('positions / open orders');
await step('positions', async () => show('positions', (await call('GET', '/accounts/positions', { params: { accounts: codes.join(',') } })).data));
await step('orders', async () => show('open orders', (await call('GET', '/accounts/orders', { params: { accounts: codes.join(',') } })).data));
section('transfers — types, signs, paging, the opening DEPOSIT, FINANCING');
await step('transfers', async () => {
const from = new Date(Date.now() - 180 * 86_400_000).toISOString();
const res = await call('GET', '/accounts/transfers', { params: { accounts: codes.join(','), 'time-from': from, limit: 50 } });
const txns = (res.data?.cashTransfers ?? []).flatMap((t) => (t.cashTransactions ?? []).map((x) => ({ ...x, transferTime: t.transactionTime })));
const byType = {};
for (const t of txns) {
const e = (byType[t.type] ??= { count: 0, min: Infinity, max: -Infinity });
e.count++; e.min = Math.min(e.min, t.value); e.max = Math.max(e.max, t.value);
}
show('by type (value range → sign convention)', byType);
show('first / last transaction (ordering)', [txns[0], txns[txns.length - 1]]);
show('nextPageTransactionTime', res.data?.nextPageTransactionTime ?? '(none)');
});
section('instruments');
await step('instruments', async () => {
const res = await call('GET', '/instruments/query', { params: { symbols: SYMBOLS.join(',') } });
for (const i of res.data?.instruments ?? []) {
console.log(` ${i.symbol}: type=${i.type} priceIncrement=${i.priceIncrement} multiplier=${i.multiplier} lotSize=${i.lotSize} quantityIncrement=${i.quantityIncrement} currency=${i.currency} sessions=${(i.tradingHours ?? []).length}`);
}
show('first instrument, raw', res.data?.instruments?.[0]);
});
await step('account instrument details', async () => {
const res = await call('GET', `/accounts/${encodeURIComponent(ref)}/instruments/query`, { params: { symbols: SYMBOLS.join(',') } });
show('details', res.data?.instrumentDetails ?? res.data);
});
section('quotes and hourly candles (σ)');
await step('quotes', async () => {
const res = await call('POST', '/marketdata', { body: { account: ref, symbols: SYMBOLS, eventTypes: [{ type: 'Quote', format: 'COMPACT' }] } });
show(`Quote events (HTTP ${res.status})`, res.data?.events ?? res.data);
});
await step('candles', async () => {
const now = Date.now();
const res = await call('POST', '/marketdata', {
body: {
account: ref, symbols: SYMBOLS,
eventTypes: [{ type: 'Candle', format: 'COMPACT', candleType: '1h', fromTime: new Date(now - 49 * 3_600_000).toISOString(), toTime: new Date(now).toISOString() }],
},
});
const events = res.data?.events ?? [];
console.log(`Candle events: HTTP ${res.status}, ${events.length} event(s); first: ${JSON.stringify(events[0])}`);
for (const symbol of SYMBOLS) {
const closes = events.filter((e) => e.symbol === symbol && typeof e.close === 'number').map((e) => e.close);
const rets = closes.slice(1).map((c, i) => Math.log(c / closes[i]));
const mean = rets.reduce((s, r) => s + r, 0) / Math.max(1, rets.length);
const sd = Math.sqrt(rets.reduce((s, r) => s + (r - mean) ** 2, 0) / Math.max(1, rets.length - 1));
console.log(` ${symbol}: ${closes.length} closes, σ ≈ ${(sd * 100).toFixed(3)}%/h ≈ ${(sd * Math.sqrt(24) * 100).toFixed(2)}%/day`);
}
});
section('end-of-day metrics vs the 22:00 UTC reset');
await step('eodmetrics', async () => {
const d = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10);
const res = await call('GET', `/accounts/eodmetrics/${d}`, { params: { accounts: ref } });
show(`eodmetrics ${d} (HTTP ${res.status}) — compare balance with the instance's dayStart debug value`, res.data);
});
section('rate limits (two history calls back to back)');
await step('rate', async () => {
const a = await call('GET', '/accounts/transfers', { params: { accounts: ref, limit: 1 } });
const b = await call('GET', '/accounts/transfers', { params: { accounts: ref, limit: 1 } });
console.log(`HTTP ${a.status}, ${b.status}${b.status === 429 ? ' — history calls are limited to 1/s' : ''}`);
});
section('ping');
console.log(`POST /ping → HTTP ${(await call('POST', '/ping')).status}`);
if (CONFIRM && ORDER_ACCOUNT) {
section(`LIVE ORDERS on ${ORDER_ACCOUNT}`);
const acct = encodeURIComponent(ORDER_ACCOUNT);
const symbol = SYMBOLS[0];
const inst = (await call('GET', '/instruments/query', { params: { symbols: symbol } })).data?.instruments?.[0] ?? {};
const det = (await call('GET', `/accounts/${acct}/instruments/query`, { params: { symbols: symbol } })).data?.instrumentDetails?.[0] ?? {};
const qty = Math.max(det.minOrderSize ?? 0, inst.quantityIncrement ?? 0, det.minOrderSizeIncrement ?? 0) || 1;
const code = `af-smoke-${Date.now().toString(36)}`;
const open = { account: ORDER_ACCOUNT, orderCode: code, type: 'MARKET', instrument: symbol, quantity: qty, positionEffect: 'OPEN', side: 'BUY', tif: 'GTC' };
const placed = await call('POST', `/accounts/${acct}/orders`, { body: open });
show(`MARKET BUY ${qty} ${symbol} (HTTP ${placed.status})`, placed.data);
show('duplicate orderCode (expect 409 / code 100)', (await call('POST', `/accounts/${acct}/orders`, { body: open })));
await sleep(3_000);
const pos = ((await call('GET', `/accounts/${acct}/positions`)).data?.positions ?? []).find((p) => p.symbol === symbol);
show('position (positionCode should equal the orderId on position-based accounts)', pos);
show('order history row (commission, averagePrice)', (await call('GET', `/accounts/${acct}/orders/history`, { params: { 'with-client-id': code, limit: 5 } })).data);
if (pos) {
const px = pos.openPrice;
const tick = inst.priceIncrement || 0.01;
const far = (p) => Math.round(p / tick) * tick;
// Exactly what lib/dxtrade-class.ts sends: a closing STOP/LIMIT names
// the position, never a quantity (code 33 otherwise).
const leg = (c, type, price) => ({
account: ORDER_ACCOUNT, orderCode: c, type, instrument: symbol, positionEffect: 'CLOSE',
positionCode: pos.positionCode, side: 'SELL', tif: 'GTC', ...(type === 'LIMIT' ? { limitPrice: price } : { stopPrice: price }),
});
const group = await call('POST', `/accounts/${acct}/orders`, {
body: { orders: [leg(`${code}-tp`, 'LIMIT', far(px * 1.05)), leg(`${code}-sl`, 'STOP', far(px * 0.95))], contingencyType: 'OCO' },
});
show(`native OCO group of CLOSE legs (HTTP ${group.status})`, group.data);
if (group.status >= 400) {
// The bot's fallback: the stop on its own first, then the take-profit.
show('fallback: lone STOP CLOSE', (await call('POST', `/accounts/${acct}/orders`, { body: leg(`${code}-sl1`, 'STOP', far(px * 0.95)) })));
show('fallback: lone LIMIT CLOSE', (await call('POST', `/accounts/${acct}/orders`, { body: leg(`${code}-tp1`, 'LIMIT', far(px * 1.05)) })));
}
show('open orders now (the exit pair should be here)', (await call('GET', `/accounts/${acct}/orders`)).data);
const del = await call('DELETE', `/accounts/${acct}/orders/${encodeURIComponent(`${code}-tp`)}`);
show(`cancel without If-Match (HTTP ${del.status}; 403/412/428 means it wants one)`, del.data);
// Tradeify: every trade must be held at least 20 seconds.
console.log('holding 22 s before closing (minimum hold)…');
await sleep(22_000);
const flatten = await call('POST', `/accounts/${acct}/close`, { body: { account: ORDER_ACCOUNT, closePositions: true, cancelOrders: true } });
show(`flatten via /close (HTTP ${flatten.status})`, flatten.data);
}
await sleep(3_000);
const recent = await call('GET', `/accounts/${acct}/transfers`, { params: { 'time-from': new Date(Date.now() - 10 * 60_000).toISOString() } });
show('transactions of the last 10 minutes (commission = 0.04% × notional per side?)', recent.data);
} else {
console.log('\n(read-only: set DX_CONFIRM=yes and DX_ACCOUNT=<code> to also test a live minimum-size round trip)');
}
await call('POST', '/logout');
+49 -9
View File
@@ -1,3 +1,5 @@
import type { FirmPlatform } from './lib/master-protocol';
export interface AccountConfig {
/** Master's account type id — what a manual assignment pins to. */
id: string;
@@ -8,7 +10,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 +29,11 @@ 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 firm trades every calendar day, weekends and CME holidays included. */
tradesEveryDay?: boolean;
}
export interface FirmConfig {
@@ -35,14 +42,19 @@ 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[];
@@ -76,10 +88,38 @@ export interface AccountState {
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;
/** Current cycle daily P&L — used for target calculations. */