Compare commits
19
Commits
49c4725e0c
...
1782c16145
@@ -1,7 +1,8 @@
|
|||||||
# AutoFirmer
|
# AutoFirmer
|
||||||
|
|
||||||
Automated futures trading dashboard for prop firm accounts via Tradovate and
|
Automated trading dashboard for prop firm accounts via Tradovate and TopstepX
|
||||||
TopstepX (ProjectX Gateway).
|
(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
|
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
|
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
|
1. On the **master dashboard**, open **Firms**, add the firm, and give it one
|
||||||
account type per prefix + account size combination (profit target,
|
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** on the master asks for an API key instead (generated in the
|
||||||
TopstepX platform under Settings → API); the username is optional to store
|
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
|
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
|
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**
|
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
|
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
|
## The master dashboard
|
||||||
|
|
||||||
Set **Instance Name** and **Dashboard URL** on the Settings page and this
|
Set **Instance Name** and **Dashboard URL** on the Settings page and this
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
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 { setCredentials, clearCredentials } from '@/lib/db';
|
||||||
import { reconcileClients, removeClient } from '@/lib/clients';
|
import { reconcileClients, removeClient } from '@/lib/clients';
|
||||||
import { scheduleBackupPush } from '@/lib/backup-sync';
|
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
|
* (lib/backup-sync.ts); they are still absent from the catalog and the
|
||||||
* control socket.
|
* control socket.
|
||||||
*
|
*
|
||||||
* Tradovate firms need username + password. TopstepX firms need the API key;
|
* Tradovate and DXTrade firms need username + password (DXTrade's server URL
|
||||||
* username is optional for now (the loginKey call still wants it, so the
|
* and login domain are set on the master, per firm). TopstepX firms need the
|
||||||
* client sits idle with a clear error until one is added) and password is
|
* API key; username is optional for now (the loginKey call still wants it,
|
||||||
* unused. */
|
* 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) {
|
export async function PUT(req: NextRequest, { params }: Params) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
@@ -27,12 +28,13 @@ export async function PUT(req: NextRequest, { params }: Params) {
|
|||||||
const password = body.password?.trim() ?? '';
|
const password = body.password?.trim() ?? '';
|
||||||
const apiKey = body.apiKey?.trim() ?? '';
|
const apiKey = body.apiKey?.trim() ?? '';
|
||||||
|
|
||||||
if (firm.platform === 'topstepx') {
|
if (!hasPlatformCredentials(firm.platform, { username, password, apiKey })) {
|
||||||
if (!apiKey) {
|
const error = firm.platform === 'topstepx'
|
||||||
return NextResponse.json({ error: 'apiKey is required for a TopstepX firm' }, { status: 400 });
|
? 'apiKey is required for a TopstepX firm'
|
||||||
}
|
: firm.platform === 'dxtrade'
|
||||||
} else if (!username || !password) {
|
? 'username and password are required for a DXtrade firm'
|
||||||
return NextResponse.json({ error: 'username and password are required' }, { status: 400 });
|
: 'username and password are required';
|
||||||
|
return NextResponse.json({ error }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getCachedFirm } from '@/lib/firm-catalog';
|
||||||
|
import { firmInstruments, isFuturesFirm } from '@/lib/instruments';
|
||||||
import { getClients } from '@/lib/clients';
|
import { getClients } from '@/lib/clients';
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
@@ -26,6 +28,13 @@ export async function POST(
|
|||||||
const client = clients.get(firmId);
|
const client = clients.get(firmId);
|
||||||
if (!client) return NextResponse.json({ error: 'Client not found for firm' }, { status: 404 });
|
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
|
// 1. Find the active contract chosen by the shared resolver
|
||||||
const contract = await client.findFrontMonthContract(symbol);
|
const contract = await client.findFrontMonthContract(symbol);
|
||||||
if (!contract) {
|
if (!contract) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getCachedFirm } from '@/lib/firm-catalog';
|
import { getCachedFirm } from '@/lib/firm-catalog';
|
||||||
|
import { firmInstruments, instrumentsSource } from '@/lib/instruments';
|
||||||
import { getCredentials } from '@/lib/db';
|
import { getCredentials } from '@/lib/db';
|
||||||
|
|
||||||
/** One firm from the cached catalog. Read-only: firms and their account types
|
/** One firm from the cached catalog. Read-only: firms and their account types
|
||||||
@@ -22,7 +23,8 @@ export async function GET(
|
|||||||
firm: firm.name,
|
firm: firm.name,
|
||||||
username: creds?.username ?? '',
|
username: creds?.username ?? '',
|
||||||
hasCredentials: !!creds,
|
hasCredentials: !!creds,
|
||||||
bannedSymbols: firm.bannedSymbols,
|
instruments: firmInstruments(firm),
|
||||||
|
instrumentsSource: instrumentsSource(firm),
|
||||||
accounts: firm.accountTypes,
|
accounts: firm.accountTypes,
|
||||||
catalogUpdatedAt: firm.updatedAt,
|
catalogUpdatedAt: firm.updatedAt,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from 'next/server';
|
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 { getCredentials } from '@/lib/db';
|
||||||
import type { FirmConfig } from '@/types';
|
import type { FirmConfig } from '@/types';
|
||||||
|
|
||||||
@@ -18,16 +19,15 @@ export async function GET() {
|
|||||||
try {
|
try {
|
||||||
const result: FirmConfig[] = getCachedCatalog().map((firm) => {
|
const result: FirmConfig[] = getCachedCatalog().map((firm) => {
|
||||||
const creds = getCredentials(firm.id);
|
const creds = getCredentials(firm.id);
|
||||||
const hasCredentials = firm.platform === 'topstepx'
|
const hasCredentials = hasPlatformCredentials(firm.platform, creds);
|
||||||
? !!creds?.apiKey
|
|
||||||
: !!(creds?.username && creds?.password);
|
|
||||||
return {
|
return {
|
||||||
id: firm.id,
|
id: firm.id,
|
||||||
firm: firm.name,
|
firm: firm.name,
|
||||||
platform: firm.platform,
|
platform: firm.platform,
|
||||||
username: creds?.username ?? '',
|
username: creds?.username ?? '',
|
||||||
hasCredentials,
|
hasCredentials,
|
||||||
bannedSymbols: firm.bannedSymbols,
|
instruments: firmInstruments(firm),
|
||||||
|
instrumentsSource: instrumentsSource(firm),
|
||||||
accounts: firm.accountTypes.map((a) => ({
|
accounts: firm.accountTypes.map((a) => ({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
label: a.label,
|
label: a.label,
|
||||||
@@ -46,6 +46,8 @@ export async function GET() {
|
|||||||
minDayStop: a.minDayStop,
|
minDayStop: a.minDayStop,
|
||||||
combineCmeHolidays: a.combineCmeHolidays,
|
combineCmeHolidays: a.combineCmeHolidays,
|
||||||
withdrawalStages: a.withdrawalStages,
|
withdrawalStages: a.withdrawalStages,
|
||||||
|
dailyLossLimit: a.dailyLossLimit ?? 0,
|
||||||
|
tradesEveryDay: a.tradesEveryDay === true,
|
||||||
})),
|
})),
|
||||||
catalogUpdatedAt: firm.updatedAt,
|
catalogUpdatedAt: firm.updatedAt,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getClients } from '@/lib/clients';
|
import { getClients } from '@/lib/clients';
|
||||||
import { getInstruments } from '@/lib/db';
|
import { futuresRootsHere } from '@/lib/instruments';
|
||||||
import { resolveContracts, getAllCachedContracts } from '@/lib/contract-resolver';
|
import { resolveContracts, getAllCachedContracts } from '@/lib/contract-resolver';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,7 +15,7 @@ export async function GET() {
|
|||||||
* POST /api/instruments/contracts
|
* POST /api/instruments/contracts
|
||||||
* Triggers a fresh resolve via Tradovate + Yahoo Finance.
|
* Triggers a fresh resolve via Tradovate + Yahoo Finance.
|
||||||
* Body (optional): { symbols: string[] } — when provided, resolves only those symbols.
|
* 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) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -35,7 +35,7 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const symbols = requestedSymbols?.length
|
const symbols = requestedSymbols?.length
|
||||||
? requestedSymbols
|
? requestedSymbols
|
||||||
: getInstruments().filter((i) => i.enabled).map((i) => i.symbol);
|
: futuresRootsHere();
|
||||||
|
|
||||||
const resolved = await resolveContracts(symbols, accessToken);
|
const resolved = await resolveContracts(symbols, accessToken);
|
||||||
return NextResponse.json(resolved);
|
return NextResponse.json(resolved);
|
||||||
|
|||||||
@@ -1,6 +1,36 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getInstruments } from '@/lib/db';
|
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() {
|
export const dynamic = 'force-dynamic';
|
||||||
return NextResponse.json(getInstruments());
|
|
||||||
|
/** 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 });
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-21
@@ -4,9 +4,13 @@ import { getClients } from '@/lib/clients';
|
|||||||
import { getCachedCatalog } from '@/lib/firm-catalog';
|
import { getCachedCatalog } from '@/lib/firm-catalog';
|
||||||
import { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/account-type-resolver';
|
import { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/account-type-resolver';
|
||||||
import { historyFresh } from '@/lib/platform-client';
|
import { historyFresh } from '@/lib/platform-client';
|
||||||
|
import { rankNextLeg } from '@/lib/leg-estimate';
|
||||||
|
import type { AccountState } from '@/types';
|
||||||
import {
|
import {
|
||||||
combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, currentTradeDateCT,
|
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';
|
} from '@/lib/trading-logic';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
@@ -84,6 +88,9 @@ export async function GET() {
|
|||||||
let remainingToday: number | null = null;
|
let remainingToday: number | null = null;
|
||||||
let effectiveProfitTarget: number | null = null;
|
let effectiveProfitTarget: number | null = null;
|
||||||
let nextStop: 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) {
|
if (cfg && effective && !isDead) {
|
||||||
const equityProfit = cash.amount - cfg.accountSize;
|
const equityProfit = cash.amount - cfg.accountSize;
|
||||||
const plan = computeDayPlan(
|
const plan = computeDayPlan(
|
||||||
@@ -94,25 +101,60 @@ export async function GET() {
|
|||||||
dailyTarget = plan ? { amount: plan.dayTarget, path: plan.path } : null;
|
dailyTarget = plan ? { amount: plan.dayTarget, path: plan.path } : null;
|
||||||
remainingToday = plan?.remainingToday ?? null;
|
remainingToday = plan?.remainingToday ?? null;
|
||||||
|
|
||||||
// The stop the next trade would carry under the obfuscation
|
// The stop the next trade would carry, from the same helpers
|
||||||
// options, mirroring obfuscationStopDollars in auto-trade:
|
// the fire path uses: the obfuscation stop (day 1 keeps the
|
||||||
// day 1 keeps the day's net floor at −$1,000, min days stop
|
// day's net floor at −$1,000, min days stop at 2× the leg,
|
||||||
// at 2× the target, and the day after a loss rides stopless.
|
// the day after a loss rides stopless) against the hard
|
||||||
if (plan && (cfg.firstDayStop || cfg.minDayStop)) {
|
// daily-loss stop, before fees. A min day trades its whole
|
||||||
const prior = dailyPnL
|
// remaining target as one leg.
|
||||||
.filter((d) => d.date < combined.tradeDate && d.pnl !== 0)
|
const dll = cfg.dailyLossLimit ?? 0;
|
||||||
.sort((a, b) => a.date.localeCompare(b.date));
|
if (dll > 0) {
|
||||||
const recoveryDay = prior.length > 0 && prior[prior.length - 1].pnl < 0;
|
const dayStart = client.dayStartBalance?.(acc.id);
|
||||||
if (!recoveryDay) {
|
dllRoom = dayStart === null ? null : computeDllRoom({
|
||||||
let stop = Infinity;
|
dailyLossLimit: dll,
|
||||||
if (cfg.firstDayStop && prior.length === 0) {
|
closedBalance: cash.amount,
|
||||||
stop = Math.max(0, Math.round((FIRST_DAY_STOP + combined.realizedPnL) * 100) / 100);
|
dayStartBalance: dayStart,
|
||||||
}
|
realizedToday: Math.min(cash.realizedPnL, combined.realizedPnL),
|
||||||
if (cfg.minDayStop && plan.dayTarget <= BIG_DAY_THRESHOLD) {
|
});
|
||||||
stop = Math.min(stop, MIN_DAY_STOP_MULT * plan.dayTarget);
|
|
||||||
}
|
}
|
||||||
|
// 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;
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Effective profit target = max(stage target, consistency realTarget).
|
// Effective profit target = max(stage target, consistency realTarget).
|
||||||
@@ -187,6 +229,7 @@ export async function GET() {
|
|||||||
cycleDailyPnL: client.dailyPnL[acc.id],
|
cycleDailyPnL: client.dailyPnL[acc.id],
|
||||||
rawRealizedPnL: cash.realizedPnL,
|
rawRealizedPnL: cash.realizedPnL,
|
||||||
combineCmeHolidays: cfg.combineCmeHolidays,
|
combineCmeHolidays: cfg.combineCmeHolidays,
|
||||||
|
tradesEveryDay: cfg.tradesEveryDay === true,
|
||||||
effective,
|
effective,
|
||||||
equityProfit: cash.amount - cfg.accountSize,
|
equityProfit: cash.amount - cfg.accountSize,
|
||||||
splitEnabled: cfg.splitEnabled,
|
splitEnabled: cfg.splitEnabled,
|
||||||
@@ -202,10 +245,10 @@ export async function GET() {
|
|||||||
// on the Tuesday after a holiday this includes Sun/Mon.
|
// on the Tuesday after a holiday this includes Sun/Mon.
|
||||||
realizedPnL: combined.realizedPnL,
|
realizedPnL: combined.realizedPnL,
|
||||||
daysTraded,
|
daysTraded,
|
||||||
// A stop-enabled account whose day booked red via its own
|
// An account the bot puts stops on whose day booked red via
|
||||||
// stop — the personal DLL. Firm DLL lockouts show via
|
// its own stop — the personal DLL. Firm DLL lockouts show
|
||||||
// !active instead.
|
// via !active instead.
|
||||||
personalDll: !!cfg && (cfg.firstDayStop || cfg.minDayStop)
|
personalDll: !!cfg && botStopsActive(cfg)
|
||||||
&& acc.active && !client.positions[acc.id]
|
&& acc.active && !client.positions[acc.id]
|
||||||
&& combined.realizedPnL <= -25,
|
&& combined.realizedPnL <= -25,
|
||||||
positionDirection: client.positions[acc.id]
|
positionDirection: client.positions[acc.id]
|
||||||
@@ -220,6 +263,9 @@ export async function GET() {
|
|||||||
stageDrain: effective?.drain ?? false,
|
stageDrain: effective?.drain ?? false,
|
||||||
dailyTarget,
|
dailyTarget,
|
||||||
nextStop,
|
nextStop,
|
||||||
|
dllRoom,
|
||||||
|
holdEstimate,
|
||||||
|
lastStand: lastStandNext,
|
||||||
remainingToday,
|
remainingToday,
|
||||||
effectiveProfitTarget,
|
effectiveProfitTarget,
|
||||||
dailyPnL,
|
dailyPnL,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { runTrade, startScheduler } from '@/lib/auto-trade';
|
import { runTrade, startScheduler } from '@/lib/auto-trade';
|
||||||
import { errorMessage } from '@/lib/platform-client';
|
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) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -12,7 +12,7 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Missing required fields: action, symbol' }, { status: 400 });
|
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 });
|
return NextResponse.json({ error: `Unknown symbol: ${symbol}` }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -137,20 +137,25 @@ export default function FirmSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mt-10 mb-3">
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mt-10 mb-3">
|
||||||
Banned Symbols
|
Instruments
|
||||||
</h2>
|
</h2>
|
||||||
<div className="bg-white border border-slate-200 rounded-xl p-4 shadow-sm">
|
<div className="bg-white border border-slate-200 rounded-xl p-4 shadow-sm">
|
||||||
{!firm || firm.bannedSymbols.length === 0 ? (
|
{!firm || firm.instruments.length === 0 ? (
|
||||||
<span className="text-sm text-slate-400 italic">None</span>
|
<span className="text-sm text-slate-400 italic">None — this firm won't trade</span>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{firm.bannedSymbols.map((sym) => (
|
{firm.instruments.map((sym) => (
|
||||||
<span key={sym} className="px-2 py-1 rounded-md text-xs font-mono font-semibold bg-red-100 text-red-700">
|
<span key={sym} className="px-2 py-1 rounded-md text-xs font-mono font-semibold bg-slate-100 text-slate-700">
|
||||||
{sym}
|
{sym}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+54
-18
@@ -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.
|
/** Inline credential entry, shown to the right of a firm that has none.
|
||||||
* Tradovate wants username + password; TopstepX wants the API key (username
|
* Tradovate and DXtrade want username + password; TopstepX wants the API key
|
||||||
* optional for now, though login needs it eventually — password unused). */
|
* (username optional for now, though login needs it eventually — password
|
||||||
|
* unused). */
|
||||||
function CredentialForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => void }) {
|
function CredentialForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => void }) {
|
||||||
const isTopstepX = firm.platform === 'topstepx';
|
const isTopstepX = firm.platform === 'topstepx';
|
||||||
const [username, setUsername] = useState('');
|
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()}>
|
<div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
|
||||||
<input
|
<input
|
||||||
className={cls}
|
className={cls}
|
||||||
placeholder={isTopstepX ? 'Username (optional)' : 'Tradovate username'}
|
placeholder={isTopstepX ? 'Username (optional)' : firm.platform === 'dxtrade' ? 'DXtrade username' : 'Tradovate username'}
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -375,7 +385,27 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
|
|||||||
<span className="text-xs text-slate-400">
|
<span className="text-xs text-slate-400">
|
||||||
${fmt(account.dailyTarget.amount)} today
|
${fmt(account.dailyTarget.amount)} today
|
||||||
{account.nextStop !== null && (
|
{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>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -392,7 +422,7 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
|
|||||||
: account.positionDirection
|
: 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>
|
? <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
|
: 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
|
: 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-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>}
|
: <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">
|
<td className="px-4 py-3.5">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<span className="font-bold text-sm text-slate-800">{firm.firm}</span>
|
<span className="font-bold text-sm text-slate-800">{firm.firm}</span>
|
||||||
{firm.bannedSymbols?.includes(tradeSymbol) && (
|
{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">
|
<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} BANNED
|
{tradeSymbol} not listed
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{firm.platform === 'topstepx' && (
|
{firm.platform === 'topstepx' && (
|
||||||
@@ -478,6 +508,11 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
|||||||
TopstepX
|
TopstepX
|
||||||
</span>
|
</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 && (
|
{firm.platform === 'topstepx' && hasCredentials && !firm.username && (
|
||||||
<span
|
<span
|
||||||
className="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-semibold"
|
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">
|
<td colSpan={5} className="px-4 py-2.5">
|
||||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||||
<span className="text-xs text-slate-500">
|
<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>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={removeCredentials}
|
onClick={removeCredentials}
|
||||||
@@ -532,7 +567,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
|||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setConfirmCreds(true); }}
|
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"
|
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 />
|
<KeyOffIcon />
|
||||||
</button>
|
</button>
|
||||||
@@ -554,7 +589,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
|||||||
state.accounts.length === 0 ? (
|
state.accounts.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="px-4 py-5 pl-12 text-sm text-slate-400 italic">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
@@ -594,7 +629,7 @@ export default function Home() {
|
|||||||
const [sortDir, setSortDir] = useState<SortDir>('asc');
|
const [sortDir, setSortDir] = useState<SortDir>('asc');
|
||||||
|
|
||||||
// Trade controls
|
// 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 [enabledSymbols, setEnabledSymbols] = useState<string[]>([]);
|
||||||
const [tradeSymbol, setTradeSymbol] = useState('');
|
const [tradeSymbol, setTradeSymbol] = useState('');
|
||||||
const [tradeAction, setTradeAction] = useState<'Buy' | 'Sell' | 'Auto'>('Buy');
|
const [tradeAction, setTradeAction] = useState<'Buy' | 'Sell' | 'Auto'>('Buy');
|
||||||
@@ -646,12 +681,13 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// The roots this machine's futures firms list; 'Auto' draws among
|
||||||
|
// each batch firm's list and leads the dropdown.
|
||||||
fetch('/api/instruments')
|
fetch('/api/instruments')
|
||||||
.then((r) => r.json() as Promise<{ symbol: string; enabled: boolean }[]>)
|
.then((r) => r.json() as Promise<{ roots: string[] }>)
|
||||||
.then((instruments) => {
|
.then(({ roots }) => {
|
||||||
const syms = instruments.filter((i) => i.enabled).map((i) => i.symbol);
|
setEnabledSymbols(roots ?? []);
|
||||||
setEnabledSymbols(syms);
|
setTradeSymbol((prev) => prev || 'Auto');
|
||||||
setTradeSymbol((prev) => prev || syms[0] || 'NQ');
|
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
@@ -804,8 +840,8 @@ export default function Home() {
|
|||||||
onChange={(e) => setTradeSymbol(e.target.value)}
|
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"
|
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>
|
<option value="Auto">Auto</option>
|
||||||
|
{enabledSymbols.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<div className="flex rounded-lg border border-slate-200 overflow-hidden text-sm font-semibold">
|
<div className="flex rounded-lg border border-slate-200 overflow-hidden text-sm font-semibold">
|
||||||
<button
|
<button
|
||||||
|
|||||||
+83
-74
@@ -3,9 +3,30 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
interface Instrument {
|
/** GET /api/instruments: each firm's list (from the master) and, for
|
||||||
|
* futures firms, how the Auto draw sees each instrument. */
|
||||||
|
interface InstrumentsOverview {
|
||||||
|
roots: string[];
|
||||||
|
firms: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
platform: string;
|
||||||
|
source: 'master' | 'legacy';
|
||||||
|
instruments: string[];
|
||||||
|
stats?: {
|
||||||
|
maxHoldHours: number;
|
||||||
|
sessionProgress: number | null;
|
||||||
|
instruments: {
|
||||||
symbol: string;
|
symbol: string;
|
||||||
enabled: boolean;
|
listed: boolean;
|
||||||
|
spreadDollars: number;
|
||||||
|
dailyMoveDollars: number | null;
|
||||||
|
holdHours: number | null;
|
||||||
|
disabled: boolean;
|
||||||
|
chance: number | null;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ResolvedContract {
|
interface ResolvedContract {
|
||||||
@@ -33,7 +54,7 @@ interface BackupMeta {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsPage() {
|
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 [contracts, setContracts] = useState<Record<string, ResolvedContract | null>>({});
|
||||||
const [maxConcurrent, setMaxConcurrent] = useState<string>('5');
|
const [maxConcurrent, setMaxConcurrent] = useState<string>('5');
|
||||||
const [tradingHours, setTradingHours] = useState<string>('full_cme');
|
const [tradingHours, setTradingHours] = useState<string>('full_cme');
|
||||||
@@ -75,7 +96,8 @@ export default function SettingsPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/instruments')
|
fetch('/api/instruments')
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then(setInstruments);
|
.then(setOverview)
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
fetchCatalog();
|
fetchCatalog();
|
||||||
|
|
||||||
@@ -125,33 +147,6 @@ export default function SettingsPage() {
|
|||||||
.catch(() => {});
|
.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() {
|
async function saveSettings() {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setSaved(false);
|
setSaved(false);
|
||||||
@@ -443,68 +438,82 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Instruments ── */}
|
{/* ── 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
|
Instruments
|
||||||
</h2>
|
</h2>
|
||||||
|
<p className="text-xs text-slate-400 mb-3">
|
||||||
|
What each firm trades is set on the master's{' '}
|
||||||
|
{masterUrl ? <a className="text-blue-500 hover:underline" href={`${masterUrl.replace(/\/+$/, '')}/firms`} target="_blank" rel="noreferrer">Firms page</a> : 'Firms page'}.
|
||||||
|
Futures “Auto” draws one per batch: cheaper per move early in the session (every instrument gets a floor),
|
||||||
|
the fastest near the last entry, never one expected to hold 12h or more. Holds below are for a $1,000 leg.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm">
|
{!overview ? (
|
||||||
{instruments.length === 0 ? (
|
<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>
|
||||||
<div className="px-4 py-8 text-center text-sm text-slate-400 italic">
|
) : overview.firms.map((firm) => (
|
||||||
Loading…
|
<div key={firm.id} className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm mb-3">
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2.5 bg-slate-50 border-b border-slate-200">
|
||||||
|
<span className="font-semibold text-sm text-slate-800">{firm.name}</span>
|
||||||
|
<span className="text-xs text-slate-400">{firm.platform}</span>
|
||||||
|
{firm.source === 'legacy' && (
|
||||||
|
<span className="text-xs bg-amber-50 text-amber-700 border border-amber-200 rounded px-1.5 py-0.5" title="The master doesn't send instrument lists yet: this machine's enabled instruments, less the firm's banned symbols">
|
||||||
|
from this machine (older master)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{firm.instruments.length === 0 && <span className="text-xs text-amber-700">no instruments — won't trade</span>}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
{firm.stats ? (
|
||||||
<table className="w-full text-sm border-collapse">
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm border-collapse whitespace-nowrap">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-slate-200 bg-slate-50">
|
<tr className="border-b border-slate-100 text-xs uppercase tracking-wider text-slate-400">
|
||||||
<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-2 text-left font-semibold">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-2 text-left font-semibold">Contract</th>
|
||||||
<th className="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-slate-400">Enabled</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{instruments.map((instr) => {
|
{firm.instruments.map((sym) => {
|
||||||
const c = contracts[instr.symbol];
|
const x = firm.stats!.instruments.find((i) => i.symbol === sym);
|
||||||
const wasRolled = !!c?.alternative;
|
const c = contracts[sym];
|
||||||
return (
|
return (
|
||||||
<tr key={instr.symbol} className="border-b border-slate-100 last:border-0">
|
<tr key={sym} className="border-b border-slate-100 last:border-0">
|
||||||
<td className="px-4 py-2.5 font-mono font-semibold text-slate-800">
|
<td className="px-4 py-2 font-mono font-semibold text-slate-800">{sym}</td>
|
||||||
{instr.symbol}
|
<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>
|
||||||
<td className="px-4 py-2.5">
|
<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">
|
||||||
{instr.enabled && c ? (
|
{x ? `$${x.spreadDollars.toFixed(2)}` : '—'}
|
||||||
<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>
|
||||||
<td className="px-4 py-2.5 text-right">
|
<td className="px-4 py-2 text-right tabular-nums text-slate-600">
|
||||||
<button
|
{x?.dailyMoveDollars != null ? `$${x.dailyMoveDollars.toLocaleString()}` : '—'}
|
||||||
onClick={() => toggle(instr.symbol, !instr.enabled)}
|
</td>
|
||||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none ${
|
<td className={`px-4 py-2 text-right tabular-nums ${x?.disabled ? 'text-amber-600' : 'text-slate-600'}`}>
|
||||||
instr.enabled ? 'bg-blue-500' : 'bg-slate-200'
|
{x?.holdHours != null ? (x.holdHours < 48 ? `${x.holdHours.toFixed(1)}h` : `${(x.holdHours / 24).toFixed(1)}d`) : '—'}
|
||||||
}`}
|
{x?.disabled && ' · disabled'}
|
||||||
>
|
</td>
|
||||||
<span
|
<td className="px-4 py-2 text-right tabular-nums font-semibold text-slate-700">
|
||||||
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
|
{x?.chance != null ? `${Math.round(x.chance * 100)}%` : '—'}
|
||||||
instr.enabled ? 'translate-x-4' : 'translate-x-1'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+875
-471
File diff suppressed because it is too large
Load Diff
+68
-19
@@ -1,8 +1,11 @@
|
|||||||
import { TradovateClient } from './tradovate-class';
|
import { TradovateClient } from './tradovate-class';
|
||||||
import { TopstepXClient } from './topstepx-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 { errorMessage, isClientReady, type PlatformClient } from './platform-client';
|
||||||
import { getInstruments } from './db';
|
|
||||||
import { getTradableFirms, startCatalogSync } from './firm-catalog';
|
import { getTradableFirms, startCatalogSync } from './firm-catalog';
|
||||||
|
import { firmInstruments, isFuturesFirm, prewarmMarket } from './instruments';
|
||||||
|
import { housekeepingRoot } from './instrument-specs';
|
||||||
import { resolveFirmAssignments } from './account-type-resolver';
|
import { resolveFirmAssignments } from './account-type-resolver';
|
||||||
import { resolveContracts } from './contract-resolver';
|
import { resolveContracts } from './contract-resolver';
|
||||||
import { startReporter, sendReport } from './reporter';
|
import { startReporter, sendReport } from './reporter';
|
||||||
@@ -25,6 +28,7 @@ const g = global as typeof globalThis & {
|
|||||||
__contractResolverTimer?: ReturnType<typeof setInterval>;
|
__contractResolverTimer?: ReturnType<typeof setInterval>;
|
||||||
__contractResolveRetryTimer?: ReturnType<typeof setTimeout>;
|
__contractResolveRetryTimer?: ReturnType<typeof setTimeout>;
|
||||||
__contractResolveRetries?: number;
|
__contractResolveRetries?: number;
|
||||||
|
__contractResolvedRoots?: Set<string>;
|
||||||
__clientSupervisorTimer?: ReturnType<typeof setInterval>;
|
__clientSupervisorTimer?: ReturnType<typeof setInterval>;
|
||||||
__clientSupervision?: Map<string, SupervisionEntry>;
|
__clientSupervision?: Map<string, SupervisionEntry>;
|
||||||
};
|
};
|
||||||
@@ -43,15 +47,18 @@ function credsMap(): Map<string, string> {
|
|||||||
return g.__tradovateClientCreds;
|
return g.__tradovateClientCreds;
|
||||||
}
|
}
|
||||||
|
|
||||||
function credsSignature(firm: { platform: string; username: string; password: string; apiKey: string }): string {
|
/** What a client was built from — a change to any of it rebuilds the client.
|
||||||
return `${firm.platform} ${firm.username} ${firm.password} ${firm.apiKey}`;
|
* 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(
|
export function initClient(
|
||||||
id: string,
|
id: string,
|
||||||
firmName: string,
|
firmName: string,
|
||||||
platform: 'tradovate' | 'topstepx',
|
platform: FirmPlatform,
|
||||||
creds: { username: string; password: string; apiKey: string },
|
creds: { username: string; password: string; apiKey: string; serverUrl?: string; loginDomain?: string },
|
||||||
): PlatformClient {
|
): PlatformClient {
|
||||||
const map = ensureMap();
|
const map = ensureMap();
|
||||||
const onSyncComplete = async () => {
|
const onSyncComplete = async () => {
|
||||||
@@ -73,9 +80,29 @@ export function initClient(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const client: PlatformClient = platform === 'topstepx'
|
let client: PlatformClient;
|
||||||
? new TopstepXClient(creds.username, creds.apiKey, onSyncComplete)
|
switch (platform) {
|
||||||
: new TradovateClient(creds.username, creds.password, onSyncComplete);
|
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);
|
map.set(id, client);
|
||||||
credsMap().set(id, credsSignature({ platform, ...creds }));
|
credsMap().set(id, credsSignature({ platform, ...creds }));
|
||||||
return client;
|
return client;
|
||||||
@@ -166,6 +193,8 @@ export function getClients(): Map<string, PlatformClient> {
|
|||||||
// sitting Unassigned without waiting for a Tradovate sync.
|
// sitting Unassigned without waiting for a Tradovate sync.
|
||||||
startCatalogSync(() => {
|
startCatalogSync(() => {
|
||||||
reconcileClients();
|
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()) {
|
for (const [firmId, client] of ensureMap()) {
|
||||||
try {
|
try {
|
||||||
resolveFirmAssignments(firmId, client);
|
resolveFirmAssignments(firmId, client);
|
||||||
@@ -178,8 +207,10 @@ export function getClients(): Map<string, PlatformClient> {
|
|||||||
reconcileClients();
|
reconcileClients();
|
||||||
console.log(`[clients] Initialized ${map.size} platform client(s)`);
|
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);
|
setTimeout(() => triggerContractResolve(), 15_000);
|
||||||
|
prewarmMarket();
|
||||||
|
|
||||||
// Schedule daily resolve at midnight
|
// Schedule daily resolve at midnight
|
||||||
scheduleDailyResolve();
|
scheduleDailyResolve();
|
||||||
@@ -217,6 +248,18 @@ export function getClients(): Map<string, PlatformClient> {
|
|||||||
const CONTRACT_RESOLVE_RETRY_MS = 60_000;
|
const CONTRACT_RESOLVE_RETRY_MS = 60_000;
|
||||||
const CONTRACT_RESOLVE_MAX_RETRIES = 30;
|
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 {
|
function triggerContractResolve(): void {
|
||||||
// A midnight run supersedes a pending retry; either way only one chain.
|
// A midnight run supersedes a pending retry; either way only one chain.
|
||||||
if (g.__contractResolveRetryTimer) clearTimeout(g.__contractResolveRetryTimer);
|
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
|
// Runs from timers: a throw here (the instruments read) would escape as
|
||||||
// an uncaught exception.
|
// an uncaught exception.
|
||||||
try {
|
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...`);
|
console.log(`[contract-resolver] Resolving ${symbols.length} instruments...`);
|
||||||
resolveContracts(symbols, accessToken)
|
resolveContracts(symbols, accessToken)
|
||||||
.then((results) => {
|
.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.
|
// Its own half hour of retries, not what's left of a chain it cuts off.
|
||||||
g.__contractResolveRetries = 0;
|
g.__contractResolveRetries = 0;
|
||||||
triggerContractResolve();
|
triggerContractResolve();
|
||||||
|
prewarmMarket();
|
||||||
}
|
}
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
}
|
}
|
||||||
@@ -316,11 +361,16 @@ const SUPERVISOR_INTERVAL_MS = 60_000;
|
|||||||
const REBUILD_AFTER_UNREADY_MS = 20 * 60_000;
|
const REBUILD_AFTER_UNREADY_MS = 20 * 60_000;
|
||||||
const REBUILD_COOLDOWN_MS = 20 * 60_000;
|
const REBUILD_COOLDOWN_MS = 20 * 60_000;
|
||||||
|
|
||||||
/** The message TopstepXClient records when a firm has an API key but no
|
/** Clients that can't log in until someone acts — a rebuild changes nothing,
|
||||||
* username. That client never even tries to log in, so a rebuild changes
|
* and fixing the credentials (or the firm's settings) rebuilds them through
|
||||||
* nothing — adding the username changes the credentials, and
|
* reconcileClients anyway. TopstepX with an API key but no username never
|
||||||
* reconcileClients rebuilds it then. */
|
* tries to log in; DXTrade with no server URL can't, and after refused
|
||||||
const TOPSTEPX_USERNAME_MISSING = /username missing/i;
|
* 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 {
|
interface SupervisionEntry {
|
||||||
/** The instance being judged. A different one (rebuilt here, or replaced
|
/** 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)`;
|
+ ` (${health.consecutiveLoginFailures} failure(s) in a row)`;
|
||||||
}
|
}
|
||||||
} catch { /* no health: judge it on the rest */ }
|
} catch { /* no health: judge it on the rest */ }
|
||||||
if (platform === 'topstepx' && Object.keys(client.positions ?? {}).length > 0) {
|
if ((platform === 'topstepx' || platform === 'dxtrade') && Object.keys(client.positions ?? {}).length > 0) {
|
||||||
return 'it holds an open position (TopstepX exit brackets are tracked only inside the client)';
|
return 'it holds an open position (its exit pairs are tracked only inside the client)';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -429,8 +479,7 @@ function superviseClients(): void {
|
|||||||
firms ??= getTradableFirms();
|
firms ??= getTradableFirms();
|
||||||
const firm = firms.find((f) => f.id === id);
|
const firm = firms.find((f) => f.id === id);
|
||||||
if (!firm) continue; // no longer tradable: reconcileClients drops it
|
if (!firm) continue; // no longer tradable: reconcileClients drops it
|
||||||
if (firm.platform === 'topstepx'
|
if (needsAttention(firm.platform, client)) continue;
|
||||||
&& TOPSTEPX_USERNAME_MISSING.test(client.lastFetchErrors?.['login'] ?? '')) continue;
|
|
||||||
|
|
||||||
const minutes = Math.round((now - entry.unhealthySince) / 60_000);
|
const minutes = Math.round((now - entry.unhealthySince) / 60_000);
|
||||||
const hold = rebuildHold(firm.platform, client, now);
|
const hold = rebuildHold(firm.platform, client, now);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -17,7 +17,11 @@
|
|||||||
* the two are not linked yet.)
|
* 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 {
|
import {
|
||||||
getSetting,
|
getSetting,
|
||||||
setSetting,
|
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
|
/** Firms this machine can actually trade: in the catalog AND holding the
|
||||||
* credentials their platform needs — a Tradovate username + password, or a
|
* credentials their platform needs. Everything that starts a platform client
|
||||||
* TopstepX API key. Everything that starts a platform client goes through
|
* goes through here. */
|
||||||
* here. */
|
|
||||||
export function getTradableFirms(): (CatalogFirm & { username: string; password: string; apiKey: string })[] {
|
export function getTradableFirms(): (CatalogFirm & { username: string; password: string; apiKey: string })[] {
|
||||||
const result: (CatalogFirm & { username: string; password: string; apiKey: string })[] = [];
|
const result: (CatalogFirm & { username: string; password: string; apiKey: string })[] = [];
|
||||||
for (const firm of getCachedCatalog()) {
|
for (const firm of getCachedCatalog()) {
|
||||||
const creds = getCredentials(firm.id);
|
const creds = getCredentials(firm.id);
|
||||||
if (!creds) continue;
|
if (creds && hasPlatformCredentials(firm.platform, creds)) result.push({ ...firm, ...creds });
|
||||||
if (firm.platform === 'topstepx') {
|
|
||||||
if (creds.apiKey) result.push({ ...firm, ...creds });
|
|
||||||
} else {
|
|
||||||
if (creds.username && creds.password) result.push({ ...firm, ...creds });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Sync ─────────────────────────────────────────────────────────────────────
|
// ── 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 }[] {
|
function isStages(value: unknown): value is { profit: number; consistency: number; minTradingDays: number }[] {
|
||||||
return Array.isArray(value) && value.every((s) =>
|
return Array.isArray(value) && value.every((s) =>
|
||||||
typeof s === 'object' && s !== null
|
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 (typeof body.updatedAt !== 'string') throw new Error('missing updatedAt');
|
||||||
if (!Array.isArray(body.firms)) throw new Error('missing firms');
|
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`);
|
if (typeof f !== 'object' || f === null) throw new Error(`firm ${i} is not an object`);
|
||||||
const firm = f as Record<string, unknown>;
|
const firm = f as Record<string, unknown>;
|
||||||
if (typeof firm.id !== 'string' || typeof firm.name !== 'string') {
|
if (typeof firm.id !== 'string' || typeof firm.name !== 'string') {
|
||||||
throw new Error(`firm ${i} is missing id or name`);
|
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`);
|
if (!Array.isArray(firm.accountTypes)) throw new Error(`firm ${firm.name} is missing accountTypes`);
|
||||||
|
|
||||||
const accountTypes: CatalogAccountType[] = firm.accountTypes.map((t: unknown, j: number) => {
|
const accountTypes: CatalogAccountType[] = firm.accountTypes.map((t: unknown, j: number) => {
|
||||||
@@ -163,26 +223,39 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
|
|||||||
return {
|
return {
|
||||||
...type,
|
...type,
|
||||||
splitEnabled: type.splitEnabled !== false,
|
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".
|
// Lenient: a master from before the min-legs field means "no minimum".
|
||||||
minLegs: typeof type.minLegs === 'number' ? type.minLegs : 0,
|
minLegs: typeof type.minLegs === 'number' ? type.minLegs : 0,
|
||||||
// Lenient: the obfuscation stops are opt-in, so absent means off.
|
// Lenient: the obfuscation stops are opt-in, so absent means off.
|
||||||
firstDayStop: type.firstDayStop === true,
|
firstDayStop: type.firstDayStop === true,
|
||||||
minDayStop: type.minDayStop === 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;
|
} as unknown as CatalogAccountType;
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
firms.push({
|
||||||
id: firm.id,
|
id: firm.id,
|
||||||
name: firm.name,
|
name: firm.name,
|
||||||
// Lenient on purpose: a master from before the platform field (or
|
platform,
|
||||||
// with a platform this build doesn't know) must not fail the whole
|
// The master's list when it sends one; absent from an older
|
||||||
// sync, and 'tradovate' is what every firm was until then.
|
// master, whose bannedSymbols / symbolMap then apply.
|
||||||
platform: firm.platform === 'topstepx' ? 'topstepx' : 'tradovate',
|
...(Array.isArray(firm.instruments) ? { instruments: sanitizeInstruments(platform, firm.instruments) } : {}),
|
||||||
bannedSymbols: Array.isArray(firm.bannedSymbols) ? (firm.bannedSymbols as string[]) : [],
|
bannedSymbols: Array.isArray(firm.bannedSymbols) ? (firm.bannedSymbols as string[]) : [],
|
||||||
accountTypes,
|
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(),
|
updatedAt: typeof firm.updatedAt === 'string' ? firm.updatedAt : new Date(0).toISOString(),
|
||||||
};
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return { version: body.version, updatedAt: body.updatedAt, firms };
|
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
|
// Only the URL is required — deliberately not instance_name, unlike the
|
||||||
// reporter. A freshly imaged machine has to be able to pull its catalog
|
// reporter. A freshly imaged machine has to be able to pull its catalog
|
||||||
// before anyone has got round to naming it.
|
// 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;
|
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 {
|
try {
|
||||||
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
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();
|
const body = await res.json();
|
||||||
|
|
||||||
if (body && typeof body === 'object' && (body as Record<string, unknown>).unchanged === true) {
|
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_sync_at', new Date().toISOString());
|
||||||
setSetting('catalog_last_error', '');
|
setSetting('catalog_last_error', '');
|
||||||
return { ok: true, changed: false, version };
|
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_payload', JSON.stringify(payload));
|
||||||
setSetting('catalog_version', String(payload.version));
|
setSetting('catalog_version', String(payload.version));
|
||||||
|
setSetting('catalog_source_url', source);
|
||||||
setSetting('catalog_updated_at', payload.updatedAt);
|
setSetting('catalog_updated_at', payload.updatedAt);
|
||||||
setSetting('catalog_last_sync_at', new Date().toISOString());
|
setSetting('catalog_last_sync_at', new Date().toISOString());
|
||||||
setSetting('catalog_last_error', suspicious.length > 0
|
setSetting('catalog_last_error', suspicious.length > 0
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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' };
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
@@ -15,10 +15,11 @@
|
|||||||
* master you control, and prefer https so it cannot be redirected in transit.
|
* 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 { runTrade, startScheduler, stopScheduler, getSchedulerStatus } from './auto-trade';
|
||||||
import { FIRMS, automationKey } from './automations';
|
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 { queueRun } from './queue-run';
|
||||||
import { pushBackup } from './backup-sync';
|
import { pushBackup } from './backup-sync';
|
||||||
import { getBuildVersion } from './version';
|
import { getBuildVersion } from './version';
|
||||||
@@ -35,6 +36,8 @@ const RECONNECT_MAX_MS = 60_000;
|
|||||||
|
|
||||||
interface LinkState {
|
interface LinkState {
|
||||||
socket: WebSocket | null;
|
socket: WebSocket | null;
|
||||||
|
/** The master_dashboard_url the socket was opened for. */
|
||||||
|
url: string | null;
|
||||||
/** Only true once the master has answered the hello. */
|
/** Only true once the master has answered the hello. */
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
attempt: number;
|
attempt: number;
|
||||||
@@ -47,7 +50,7 @@ const _g = globalThis as typeof globalThis & { __masterLink?: LinkState };
|
|||||||
|
|
||||||
function state(): LinkState {
|
function state(): LinkState {
|
||||||
if (!_g.__masterLink) {
|
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;
|
return _g.__masterLink;
|
||||||
}
|
}
|
||||||
@@ -56,16 +59,16 @@ function state(): LinkState {
|
|||||||
|
|
||||||
/** What this instance can be asked to do, as of right now. */
|
/** What this instance can be asked to do, as of right now. */
|
||||||
export function collectCapabilities(): InstanceCapabilities {
|
export function collectCapabilities(): InstanceCapabilities {
|
||||||
// Only symbols the operator has enabled here. An empty instruments table
|
// The roots this machine's futures firms list (the master's per-firm
|
||||||
// means nothing has been configured yet, so offer everything we can price.
|
// instrument lists); none listed yet means offer everything we can price.
|
||||||
const enabled = getInstruments().filter((i) => i.enabled).map((i) => i.symbol);
|
const listed = futuresRootsHere();
|
||||||
|
|
||||||
// Same test the AutoBuyer page uses: `at` is epoch ms from the last ping.
|
// Same test the AutoBuyer page uses: `at` is epoch ms from the last ping.
|
||||||
const heartbeat = getRunnerHeartbeat();
|
const heartbeat = getRunnerHeartbeat();
|
||||||
const runnerOnline = heartbeat !== null && Date.now() - heartbeat.at < RUNNER_TIMEOUT_MS;
|
const runnerOnline = heartbeat !== null && Date.now() - heartbeat.at < RUNNER_TIMEOUT_MS;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
symbols: enabled.length > 0 ? enabled : Object.keys(POINT_VALUES),
|
symbols: listed.length > 0 ? listed : [...CME_ROOTS],
|
||||||
automations: FIRMS.flatMap((firm) =>
|
automations: FIRMS.flatMap((firm) =>
|
||||||
firm.automations
|
firm.automations
|
||||||
.filter((a) => a.steps.length > 0)
|
.filter((a) => a.steps.length > 0)
|
||||||
@@ -96,7 +99,7 @@ export function collectCapabilities(): InstanceCapabilities {
|
|||||||
async function execute(command: Command): Promise<unknown> {
|
async function execute(command: Command): Promise<unknown> {
|
||||||
switch (command.kind) {
|
switch (command.kind) {
|
||||||
case 'trade': {
|
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}`);
|
throw new Error(`Unknown symbol: ${command.symbol}`);
|
||||||
}
|
}
|
||||||
// Same order as POST /api/trade: (re)start the scheduler first,
|
// 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. */
|
/** True when the report went up the socket, so the caller can skip the POST. */
|
||||||
export function pushReport(firms: unknown[], scheduler: unknown): boolean {
|
export function pushReport(firms: unknown[], scheduler: unknown): boolean {
|
||||||
|
followMasterUrl();
|
||||||
return send({
|
return send({
|
||||||
type: 'report',
|
type: 'report',
|
||||||
firms: firms as never,
|
firms: firms as never,
|
||||||
@@ -224,6 +238,7 @@ function connect() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
s.socket = socket;
|
s.socket = socket;
|
||||||
|
s.url = masterUrl;
|
||||||
s.ready = false;
|
s.ready = false;
|
||||||
|
|
||||||
socket.addEventListener('open', () => {
|
socket.addEventListener('open', () => {
|
||||||
|
|||||||
+118
-4
@@ -112,6 +112,68 @@ export interface FirmStats {
|
|||||||
/** Per account type, summing to the firm's own totals. Optional so a master
|
/** Per account type, summing to the firm's own totals. Optional so a master
|
||||||
* on an older build simply ignores it. */
|
* on an older build simply ignores it. */
|
||||||
accountTypes?: AccountTypeStats[];
|
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 {
|
export interface SchedulerState {
|
||||||
@@ -205,19 +267,71 @@ export interface CatalogAccountType {
|
|||||||
payoutCap?: number;
|
payoutCap?: number;
|
||||||
/** Share of the gross the person actually receives (default 0.9). */
|
/** Share of the gross the person actually receives (default 0.9). */
|
||||||
profitSplit?: number;
|
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. */
|
/** ISO. Ties between two matching account types are broken by this. */
|
||||||
updatedAt: string;
|
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 {
|
export interface CatalogFirm {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
/** Which platform the firm's accounts trade on. Only 'tradovate' firms get
|
/** Which platform the firm's accounts trade on. Older masters omit the
|
||||||
* a client here; anything else is synced but sits idle until its trading
|
* field — treated as 'tradovate'; a value this build doesn't know makes
|
||||||
* logic exists. Older masters omit the field — treated as 'tradovate'. */
|
* the sync skip the firm. */
|
||||||
platform: 'tradovate' | 'topstepx';
|
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[];
|
bannedSymbols: string[];
|
||||||
accountTypes: CatalogAccountType[];
|
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;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+52
-8
@@ -4,29 +4,60 @@
|
|||||||
* reporter, state route and resolver can drive either platform without caring
|
* reporter, state route and resolver can drive either platform without caring
|
||||||
* which is behind the map entry.
|
* which is behind the map entry.
|
||||||
*
|
*
|
||||||
* Contract ids are `number` on Tradovate and `string` (e.g.
|
* Contract ids are `number` on Tradovate and `string` on TopstepX (e.g.
|
||||||
* "CON.F.US.ENQ.Z25") on TopstepX — everything that compares them only ever
|
* "CON.F.US.ENQ.Z25") and DXTrade (the symbol, e.g. "BTC/USD") — everything
|
||||||
* compares within one client, so the union is safe.
|
* that compares them only ever compares within one client, so the union is
|
||||||
|
* safe.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { FirmPlatform } from './master-protocol';
|
||||||
|
|
||||||
export interface PlatformAccount {
|
export interface PlatformAccount {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
active: boolean;
|
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 {
|
export interface PlatformContract {
|
||||||
id: number | string;
|
id: number | string;
|
||||||
name: string;
|
name: string;
|
||||||
tickSize: number;
|
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 {
|
export interface PlatformClient {
|
||||||
accountList: PlatformAccount[];
|
accountList: PlatformAccount[];
|
||||||
/** realizedPnL is today's. pendingRoll (Tradovate): the broker still shows
|
/** 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 ended day's record and today's share of it can't be told apart —
|
||||||
* the account waits for the broker to roll it. */
|
* the account waits for the broker to roll it. equity: balance plus open
|
||||||
accountCashBalances: { [accountId: number]: { amount: number; realizedPnL: number; pendingRoll?: boolean } };
|
* 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: {
|
positions: {
|
||||||
[accountId: number]: {
|
[accountId: number]: {
|
||||||
contractId: number | string;
|
contractId: number | string;
|
||||||
@@ -69,10 +100,22 @@ export interface PlatformClient {
|
|||||||
* read as a fresh stage-1 cycle, so such an account must not trade.
|
* read as a fresh stage-1 cycle, so such an account must not trade.
|
||||||
*/
|
*/
|
||||||
historyReady(accountId: number): boolean;
|
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
|
/** Drop the account's history from memory (the wipe routes), so it reads
|
||||||
* unloaded until the next fetch rebuilds it. */
|
* unloaded until the next fetch rebuilds it. */
|
||||||
forgetHistory(accountId: number, accountName: string): void;
|
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 }>;
|
fetchInstrumentFees(symbols: string[]): Promise<{ [symbol: string]: number }>;
|
||||||
|
|
||||||
sendOrder(
|
sendOrder(
|
||||||
@@ -113,10 +156,11 @@ export interface PlatformClient {
|
|||||||
* Timestamps are epoch ms, 0 = never. Never carries tokens or credentials.
|
* Timestamps are epoch ms, 0 = never. Never carries tokens or credentials.
|
||||||
*/
|
*/
|
||||||
export interface ClientHealth {
|
export interface ClientHealth {
|
||||||
platform: 'tradovate' | 'topstepx';
|
platform: FirmPlatform;
|
||||||
/** Holding a session token right now. */
|
/** Holding a session token right now. */
|
||||||
authenticated: boolean;
|
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;
|
transport: string;
|
||||||
syncComplete: boolean;
|
syncComplete: boolean;
|
||||||
lastSyncAt: number;
|
lastSyncAt: number;
|
||||||
|
|||||||
+92
-7
@@ -18,8 +18,10 @@ import { getClients } from './clients';
|
|||||||
import { getTradableFirms } from './firm-catalog';
|
import { getTradableFirms } from './firm-catalog';
|
||||||
import { getAccountConfig } from './account-type-resolver';
|
import { getAccountConfig } from './account-type-resolver';
|
||||||
import { getSchedulerStatus } from './auto-trade';
|
import { getSchedulerStatus } from './auto-trade';
|
||||||
import { combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig } from './trading-logic';
|
import { botStopsActive, combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig, DEFAULT_TARGET_HOLD_HOURS } from './trading-logic';
|
||||||
import type { AccountTypeStats, FirmStats } from './master-protocol';
|
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';
|
import { historyFresh } from './platform-client';
|
||||||
|
|
||||||
function collectFirmStats() {
|
function collectFirmStats() {
|
||||||
@@ -288,6 +290,7 @@ function collectFirmStats() {
|
|||||||
cycleDailyPnL: client.dailyPnL[acc.id],
|
cycleDailyPnL: client.dailyPnL[acc.id],
|
||||||
rawRealizedPnL: cash.realizedPnL,
|
rawRealizedPnL: cash.realizedPnL,
|
||||||
combineCmeHolidays: cfg.combineCmeHolidays,
|
combineCmeHolidays: cfg.combineCmeHolidays,
|
||||||
|
tradesEveryDay: cfg.tradesEveryDay === true,
|
||||||
effective,
|
effective,
|
||||||
equityProfit: cash.amount - cfg.accountSize,
|
equityProfit: cash.amount - cfg.accountSize,
|
||||||
splitEnabled: cfg.splitEnabled,
|
splitEnabled: cfg.splitEnabled,
|
||||||
@@ -349,11 +352,12 @@ function collectFirmStats() {
|
|||||||
slice.accountsTraded++;
|
slice.accountsTraded++;
|
||||||
// A grind-day fill (dayTarget 0) can flag targetHit on a
|
// A grind-day fill (dayTarget 0) can flag targetHit on a
|
||||||
// negative day — a loss is still a loss, so red wins.
|
// negative day — a loss is still a loss, so red wins.
|
||||||
// A red day on a stop-enabled account is its own personal
|
// A red day on an account the bot puts stops on (the
|
||||||
// DLL, booked on purpose — not a genuine loss. (A firm DLL
|
// obfuscation stops, or a hard daily loss limit) is its own
|
||||||
// lockout took the !acc.active branch above and stays a
|
// personal DLL, booked on purpose — not a genuine loss. (A
|
||||||
// loss.)
|
// firm DLL lockout took the !acc.active branch above and
|
||||||
if ((cfg.firstDayStop || cfg.minDayStop) && combined.realizedPnL <= -25) {
|
// stays a loss.)
|
||||||
|
if (botStopsActive(cfg) && combined.realizedPnL <= -25) {
|
||||||
personalDllCount++;
|
personalDllCount++;
|
||||||
slice.personalDll = (slice.personalDll ?? 0) + 1;
|
slice.personalDll = (slice.personalDll ?? 0) + 1;
|
||||||
} else if (combined.realizedPnL < 0) {
|
} else if (combined.realizedPnL < 0) {
|
||||||
@@ -385,10 +389,91 @@ function collectFirmStats() {
|
|||||||
// Unknown until the firm has synced, not empty.
|
// Unknown until the firm has synced, not empty.
|
||||||
...(client.syncComplete ? { accounts } : {}),
|
...(client.syncComplete ? { accounts } : {}),
|
||||||
accountTypes,
|
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
|
// HMR-safe global to avoid duplicate intervals
|
||||||
const _g = globalThis as typeof globalThis & { __reporterInterval?: ReturnType<typeof setInterval> };
|
const _g = globalThis as typeof globalThis & { __reporterInterval?: ReturnType<typeof setInterval> };
|
||||||
|
|
||||||
|
|||||||
+591
-30
@@ -366,13 +366,22 @@ export interface ProjectedDay {
|
|||||||
net?: number;
|
net?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Next weekday after d (YYYY-MM-DD). Trade dates never fall on Sat/Sun. */
|
/** Next trade date after d (YYYY-MM-DD): the next weekday on the CME calendar,
|
||||||
function nextTradingDate(d: string): string {
|
* 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`);
|
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);
|
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
|
* Simulate the account's future assuming every day goes perfectly: each
|
||||||
* trading day books exactly its planned target (deterministic bumps included,
|
* 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
|
* (withdraw, reset, re-target). False = it just PASSES once (an
|
||||||
* eval) and the projection stops. */
|
* eval) and the projection stops. */
|
||||||
payoutable?: boolean;
|
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). */
|
/** Current-cycle daily P&L (account.dailyPnL). */
|
||||||
cycleDailyPnL: { date: string; pnl: number }[];
|
cycleDailyPnL: { date: string; pnl: number }[];
|
||||||
@@ -424,6 +436,8 @@ export function projectPerfectPath(opts: {
|
|||||||
* so today's room is exact and only tightens going forward. */
|
* so today's room is exact and only tightens going forward. */
|
||||||
startThreshold?: number;
|
startThreshold?: number;
|
||||||
};
|
};
|
||||||
|
/** Defaults to the current time. */
|
||||||
|
now?: Date;
|
||||||
}): ProjectedDay[] {
|
}): ProjectedDay[] {
|
||||||
const { accountId, cfg } = opts;
|
const { accountId, cfg } = opts;
|
||||||
const horizon = opts.horizonDays ?? 40;
|
const horizon = opts.horizonDays ?? 40;
|
||||||
@@ -432,30 +446,33 @@ export function projectPerfectPath(opts: {
|
|||||||
const baseCap = opts.payout?.payoutCap ?? 0;
|
const baseCap = opts.payout?.payoutCap ?? 0;
|
||||||
const profitSplit = opts.payout?.profitSplit ?? 0.9;
|
const profitSplit = opts.payout?.profitSplit ?? 0.9;
|
||||||
const dd = Math.max(0, opts.payout?.drawdown ?? 0);
|
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
|
// 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
|
// 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
|
// ONE trading day, exactly as the firm counts min days. Identity view
|
||||||
// when combining is off.
|
// when combining is off.
|
||||||
const combine = cfg.combineCmeHolidays === true;
|
const combine = effectiveCombine(cfg);
|
||||||
const view = combineCmeSessions(combine, opts.cycleDailyPnL, rawToday, opts.realizedPnL);
|
const view = combineCmeSessions(combine, opts.cycleDailyPnL, rawToday, opts.realizedPnL);
|
||||||
// A weekend "today" (Saturday, or Sunday before the evening open) has no
|
// A weekend "today" (Saturday, or Sunday before the evening open) has no
|
||||||
// session — the platform's realizedPnL is the LAST session's stale number,
|
// session — the platform's realizedPnL is the LAST session's stale number,
|
||||||
// already booked in cycleDailyPnL under its own date. Adding it as a
|
// 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 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
|
// 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
|
// date so the projection's first day is Monday, not the day after it
|
||||||
// (combinedTradeDate rolls weekend dates forward).
|
// (combinedTradeDate rolls weekend dates forward).
|
||||||
const today = todayHasSession ? view.tradeDate : rawToday;
|
const today = todayHasSession ? view.tradeDate : rawToday;
|
||||||
let sim = view.dailyPnL.filter((d) => d.date !== today);
|
let sim = view.dailyPnL.filter((d) => d.date !== today);
|
||||||
if (todayHasSession && view.realizedPnL !== 0) sim = [...sim, { date: today, pnl: view.realizedPnL }];
|
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
|
/** Next date the firm settles a session on: weekends skip (except for an
|
||||||
* combining firm's holiday sessions clear into the next full day. */
|
* every-day firm), and a combining firm's holiday sessions clear into
|
||||||
|
* the next full day. */
|
||||||
const nextDay = (d: string): string => {
|
const nextDay = (d: string): string => {
|
||||||
let n = nextTradingDate(d);
|
let n = nextTradingDate(d, everyDay);
|
||||||
while (combine && combinedTradeDate(n) !== n) n = nextTradingDate(n);
|
while (combine && combinedTradeDate(n) !== n) n = nextTradingDate(n);
|
||||||
return n;
|
return n;
|
||||||
};
|
};
|
||||||
@@ -662,12 +679,18 @@ function cycleOwesNothing(
|
|||||||
* weekend (it is the last session's stale number, already booked under its
|
* 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
|
* 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
|
* a weekend date, which would count it twice — and the first minutes after
|
||||||
* the 5 PM roll. */
|
* the 5 PM roll. An every-day firm's weekends are real sessions, so only the
|
||||||
function sessionDoubtful(rawTradeDate: string, cycleDailyPnL: { date: string; pnl: number }[], now: Date): boolean {
|
* 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 day = new Date(`${rawTradeDate}T00:00:00Z`);
|
||||||
const dow = day.getUTCDay();
|
const dow = day.getUTCDay();
|
||||||
if (dow === 0 || dow === 6) return true;
|
if (!everyDay && (dow === 0 || dow === 6)) return true;
|
||||||
if (dow === 1) {
|
if (!everyDay && dow === 1) {
|
||||||
const sunday = fmtUTC(new Date(day.getTime() - 24 * 60 * 60 * 1000));
|
const sunday = fmtUTC(new Date(day.getTime() - 24 * 60 * 60 * 1000));
|
||||||
const saturday = fmtUTC(new Date(day.getTime() - 2 * 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;
|
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 }[];
|
cycleDailyPnL: { date: string; pnl: number }[];
|
||||||
rawRealizedPnL: number;
|
rawRealizedPnL: number;
|
||||||
combineCmeHolidays: boolean;
|
combineCmeHolidays: boolean;
|
||||||
|
/** The firm trades every calendar day — weekends are real sessions. */
|
||||||
|
tradesEveryDay?: boolean;
|
||||||
effective: EffectiveConfig;
|
effective: EffectiveConfig;
|
||||||
/** amount − accountSize — today's P&L included. */
|
/** amount − accountSize — today's P&L included. */
|
||||||
equityProfit: number;
|
equityProfit: number;
|
||||||
@@ -706,14 +731,18 @@ export function cycleCompletion(opts: {
|
|||||||
now?: Date;
|
now?: Date;
|
||||||
}): CycleCompletion {
|
}): CycleCompletion {
|
||||||
const { accountId, rawTradeDate, effective: eff, equityProfit, splitEnabled } = opts;
|
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);
|
const prior = view.dailyPnL.filter((d) => d.date !== view.tradeDate);
|
||||||
|
|
||||||
if (cycleOwesNothing(accountId, eff, prior, view.tradeDate, equityProfit, view.realizedPnL, splitEnabled)) return 'before';
|
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 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';
|
if (view.realizedPnL !== 0 && cycleOwesNothing(accountId, eff, withToday, probe, equityProfit, 0, splitEnabled)) return 'today';
|
||||||
return 'none';
|
return 'none';
|
||||||
}
|
}
|
||||||
@@ -764,15 +793,18 @@ export function endedCycleCompletion(opts: {
|
|||||||
profitTarget: number; consistency: number; minTradingDays: number; minDayPnL: number;
|
profitTarget: number; consistency: number; minTradingDays: number; minDayPnL: number;
|
||||||
targetSameEquity: boolean; withdrawalStages: WithdrawalStage[];
|
targetSameEquity: boolean; withdrawalStages: WithdrawalStage[];
|
||||||
combineCmeHolidays: boolean; splitEnabled: boolean;
|
combineCmeHolidays: boolean; splitEnabled: boolean;
|
||||||
|
tradesEveryDay?: boolean;
|
||||||
};
|
};
|
||||||
/** Defaults to the current time. */
|
/** Defaults to the current time. */
|
||||||
now?: Date;
|
now?: Date;
|
||||||
}): { date: string; stage: number; drain: boolean; cycle: string } | null {
|
}): { date: string; stage: number; drain: boolean; cycle: string } | null {
|
||||||
const { accountId, rawTradeDate, lastFundDate, endedCycleStart: start, fundTransactions, cfg } = opts;
|
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;
|
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);
|
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 (!(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 latest = (a: string, b: string) => (b > a ? b : a);
|
||||||
const earlierFunds = fundTransactions.filter((f) => f.date < lastFundDate);
|
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
|
// Equity the ended cycle finished on, worked back from live equity: less
|
||||||
// everything the current cycle booked (today's live P&L included) and
|
// everything the current cycle booked (today's live P&L included) and
|
||||||
// less the fund transaction between the two.
|
// 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)
|
const curTotal = cur.dailyPnL.filter((d) => d.date !== cur.tradeDate).reduce((s, d) => s + d.pnl, 0)
|
||||||
+ cur.realizedPnL;
|
+ cur.realizedPnL;
|
||||||
const fundAmt = fundTransactions.filter((f) => f.date === lastFundDate).reduce((s, f) => s + f.amount, 0);
|
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.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity,
|
||||||
cfg.withdrawalStages, 0, earlierFunds, cfg.minDayPnL,
|
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.
|
// 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);
|
const traded = rows.filter((d) => d.pnl !== 0);
|
||||||
if (traded.length === 0) return null;
|
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;
|
if (!cycleOwesNothing(accountId, eff, rows, probe, equityBefore, 0, cfg.splitEnabled)) return null;
|
||||||
|
|
||||||
return {
|
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.
|
* Compute the next trading day's profit target for an account.
|
||||||
*
|
*
|
||||||
@@ -1040,3 +1063,541 @@ export function computeDayPlan(
|
|||||||
splitAllowed,
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { AccountItem, AuthLoginResponse, Contract } from './tradovate-helpers';
|
import type { AccountItem, AuthLoginResponse, Contract } from './tradovate-helpers';
|
||||||
import { computeSec, randomUUIDV4 } 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 { getCachedContract, resolveContracts } from './contract-resolver';
|
||||||
import { saveDailyPnL, loadDailyPnL, saveAccountMeta, loadAccountMeta, saveFundTransactions, loadFundTransactions, saveEndedCycleStart } from './db';
|
import { saveDailyPnL, loadDailyPnL, saveAccountMeta, loadAccountMeta, saveFundTransactions, loadFundTransactions, saveEndedCycleStart } from './db';
|
||||||
import { brokerWebSocket } from './outbound-proxy';
|
import { brokerWebSocket } from './outbound-proxy';
|
||||||
|
|||||||
+2
-1
@@ -6,7 +6,8 @@
|
|||||||
"dev": "next dev --webpack",
|
"dev": "next dev --webpack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"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": {
|
"dependencies": {
|
||||||
"@microsoft/signalr": "^10.0.11",
|
"@microsoft/signalr": "^10.0.11",
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { FirmPlatform } from './lib/master-protocol';
|
||||||
|
|
||||||
export interface AccountConfig {
|
export interface AccountConfig {
|
||||||
/** Master's account type id — what a manual assignment pins to. */
|
/** Master's account type id — what a manual assignment pins to. */
|
||||||
id: string;
|
id: string;
|
||||||
@@ -8,7 +10,7 @@ export interface AccountConfig {
|
|||||||
minDayPnL: number;
|
minDayPnL: number;
|
||||||
minTradingDays: number;
|
minTradingDays: number;
|
||||||
accountSize: 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
|
maxPositionSize: number; // 0 = no limit; positive = max contracts per trade
|
||||||
targetSameEquity: boolean; // if true, reduce profitTarget by priorProfit after withdrawal
|
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
|
splitEnabled: boolean; // false = big days trade as one unsplit leg instead of random ≥$500 pieces
|
||||||
@@ -27,6 +29,11 @@ export interface AccountConfig {
|
|||||||
payoutCap?: number;
|
payoutCap?: number;
|
||||||
profitSplit?: number;
|
profitSplit?: number;
|
||||||
trailingDrawdown?: 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 {
|
export interface FirmConfig {
|
||||||
@@ -35,14 +42,19 @@ export interface FirmConfig {
|
|||||||
firm: string;
|
firm: string;
|
||||||
/** Which platform the firm's accounts trade on — decides both which client
|
/** Which platform the firm's accounts trade on — decides both which client
|
||||||
* drives it and which credential fields the dashboard asks for. */
|
* 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. */
|
/** Blank when this machine has no credentials for the firm. */
|
||||||
username: string;
|
username: string;
|
||||||
/** True once the platform's credentials are stored locally — Tradovate
|
/** True once the platform's credentials are stored locally — a username +
|
||||||
* username + password, or a TopstepX API key. Firms without them are
|
* password (Tradovate, DXTrade) or a TopstepX API key. Firms without them
|
||||||
* shown greyed out with an inline credential form. */
|
* are shown greyed out with an inline credential form. */
|
||||||
hasCredentials: boolean;
|
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
|
/** The firm's account types, straight from the catalog. Doubles as the
|
||||||
* option list for a manual account-type override. */
|
* option list for a manual account-type override. */
|
||||||
accounts: AccountConfig[];
|
accounts: AccountConfig[];
|
||||||
@@ -76,10 +88,38 @@ export interface AccountState {
|
|||||||
stageDrain: boolean;
|
stageDrain: boolean;
|
||||||
/** Next trading day's target, computed server-side. null when account is dead or config is missing. */
|
/** 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;
|
dailyTarget: { amount: number; path: 'first_day' | 'normal_day' | 'reduced_day' } | null;
|
||||||
/** Dollars the next trade may lose before its obfuscation stop fires.
|
/** Dollars of price movement the next trade may lose before its stop
|
||||||
* null = the next trade rides stopless (options off, big non-first day,
|
* fires — the tighter of the obfuscation stop and the hard daily-loss
|
||||||
* or a make-back day after a 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;
|
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). */
|
/** Actual profit target to hit — max(stage profit target, consistency realTarget = maxDay/consistency). */
|
||||||
effectiveProfitTarget: number | null;
|
effectiveProfitTarget: number | null;
|
||||||
/** Current cycle daily P&L — used for target calculations. */
|
/** Current cycle daily P&L — used for target calculations. */
|
||||||
|
|||||||
Reference in New Issue
Block a user