Compare commits
19
Commits
49c4725e0c
...
1782c16145
@@ -1,7 +1,8 @@
|
||||
# AutoFirmer
|
||||
|
||||
Automated futures trading dashboard for prop firm accounts via Tradovate and
|
||||
TopstepX (ProjectX Gateway).
|
||||
Automated trading dashboard for prop firm accounts via Tradovate and TopstepX
|
||||
(ProjectX Gateway) for CME futures, and DXtrade for 24/7 crypto/CFD firms
|
||||
(Tradeify247).
|
||||
|
||||
---
|
||||
|
||||
@@ -73,7 +74,7 @@ On first run, the SQLite database (`autotrader.sqlite`) is created automatically
|
||||
|
||||
Firms and their account types live on the master dashboard, not here — every
|
||||
machine pulls the same catalog so they cannot drift apart. This machine only
|
||||
stores the Tradovate login for each firm.
|
||||
stores the platform login for each firm.
|
||||
|
||||
1. On the **master dashboard**, open **Firms**, add the firm, and give it one
|
||||
account type per prefix + account size combination (profit target,
|
||||
@@ -86,7 +87,10 @@ stores the Tradovate login for each firm.
|
||||
**TopstepX** on the master asks for an API key instead (generated in the
|
||||
TopstepX platform under Settings → API); the username is optional to store
|
||||
but the ProjectX login needs it before the firm can connect, so add it when
|
||||
you have it. Password is unused on TopstepX
|
||||
you have it. Password is unused on TopstepX. A **DXtrade** firm asks for
|
||||
its DXtrade username and password (the ones behind the key icon on the
|
||||
firm's dashboard, not the dashboard login); the server URL and login domain
|
||||
come from the master
|
||||
4. Accounts populate once the firm connects. Each is matched to an account type
|
||||
by its name prefix *and* its size, so a new account shows as **Unassigned**
|
||||
until its opening funding transaction has been fetched. Use the **Account
|
||||
@@ -94,6 +98,97 @@ stores the Tradovate login for each firm.
|
||||
|
||||
---
|
||||
|
||||
## Instruments: what each firm trades
|
||||
|
||||
Each firm's **Instruments** list on the master's Firms page is the one place
|
||||
that says what it trades — there are no per-machine toggles or banned symbols
|
||||
any more (the instance's Settings page shows the lists read-only).
|
||||
|
||||
- **Futures firms** (Tradovate, TopstepX) list CME roots — the same names on
|
||||
both platforms; each client turns a root into its own front-month contract.
|
||||
- **DXtrade firms** list their own symbols (`ETH/USD`), whatever root a batch
|
||||
drew.
|
||||
|
||||
With the scheduler on **Auto**, each futures batch draws one instrument from
|
||||
the batch firm's list, at random but weighted:
|
||||
|
||||
- **Efficiency** — one contract's typical daily move ÷ one tick of spread
|
||||
(from Yahoo's hourly candles over the past week, measured once a day).
|
||||
Commissions don't count — they barely differ between instruments.
|
||||
- **Session timing** — at the open every instrument gets a 10% floor and the
|
||||
weights follow efficiency; toward the last entry (14:55 CT) the floor fades
|
||||
and the weight moves sharply to the fastest movers.
|
||||
- **12-hour cap** — sizing is unchanged (a contract per $1,000 of leg, up to
|
||||
the type's max contracts); an instrument whose expected hold for the
|
||||
batch's leg is 12 h or more is out of the draw (e.g. 6E at a $1,000 leg,
|
||||
about a week).
|
||||
- Micros stay out of the draw; drain and extra-day legs trade the micro of a
|
||||
listed product (MNQ when NQ is listed).
|
||||
|
||||
Every draw logs its odds. `GET /api/instruments` shows each firm's list with
|
||||
spread, daily move, hold and chance (`?simulate=10000` runs the real draw
|
||||
with no orders), and the master's Firms page shows the same next to each
|
||||
instrument.
|
||||
|
||||
Against a master from before the lists, an instance falls back to the old
|
||||
rules: this machine's enabled instruments less each firm's banned symbols,
|
||||
and a DXtrade firm's symbol map.
|
||||
|
||||
---
|
||||
|
||||
## DXtrade firms (Tradeify247)
|
||||
|
||||
A DXtrade firm is set up on the master's **Firms** page like any other, plus:
|
||||
|
||||
- **Server URL** (`https://dx.tradeify247.co/dxsca-web`) and **login domain**
|
||||
(usually `default`). Changing the URL re-keys the firm's accounts.
|
||||
These settings only exist for DXtrade firms; futures firms keep their measured
|
||||
per-contract fees and the CME calendar.
|
||||
|
||||
- **Commission %** of notional per side (Tradeify: `0.04`). The engine prices
|
||||
every fee off the fill with it.
|
||||
- **Trades every day** — weekends and CME holidays included. Such a firm runs
|
||||
in its own scheduler *lane*: its own trading hours (closed only around the
|
||||
daily roll), its own wait-until-flat gate and its own batches, so a
|
||||
multi-hour crypto position never holds up the futures open.
|
||||
- **Instruments** — the firm's own symbols (see above). The cheapest
|
||||
tradable one right now wins: spread plus both commissions, per unit of its
|
||||
volatility (hourly closes over the past week, measured once a day).
|
||||
- **Target hold hours** — each leg's notional is sized from that volatility so
|
||||
its expected hold is about this long (blank = 12 h). The position never
|
||||
exceeds what the account's available funds can margin at the broker's
|
||||
rate for the symbol (Tradeify: 5:1 crypto, 2:1 stocks — read from DXtrade,
|
||||
nothing to set). A batch sizes its leg on every candidate and trades the
|
||||
cheapest (spread, commissions, swap) that reaches the target hold; if the
|
||||
leverage caps keep all of them short of it, the quickest.
|
||||
- **Financing %/day** if the firm charges swap (Tradeify 1-Step: `0.033`).
|
||||
Each take-profit then also carries the swap a winning leg is expected to pay
|
||||
before it fills, so a win still clears the full leg.
|
||||
|
||||
Per account type, in the **Hard Daily Loss** column: the **daily loss limit**
|
||||
(Tradeify: 3% of size — every leg then carries a stop that keeps the firm's
|
||||
day inside it, less fees and a reserve of 10% of the limit or 0.1% of the
|
||||
position, whichever is more) and the **static max drawdown** (Tradeify
|
||||
1-Step: $6,000 on 100K). When hitting the day's loss limit would leave the
|
||||
account within 15% of that loss above its max-drawdown floor, the leg is a
|
||||
*last stand*: no daily-loss stop, running to its take-profit or the firm's
|
||||
breach.
|
||||
|
||||
Before the scheduler trades a DXtrade firm, run the smoke test against a real
|
||||
login (with this instance's client for the firm disconnected):
|
||||
|
||||
```bash
|
||||
DX_BASE=https://dx.tradeify247.co/dxsca-web DX_USER=… DX_PASS=… DX_SYMBOLS=BTC/USD,ETH/USD node scripts/dxtrade-smoke.mjs
|
||||
```
|
||||
|
||||
It is read-only unless `DX_CONFIRM=yes DX_ACCOUNT=<code>` are set too, which
|
||||
adds one minimum-size live round trip.
|
||||
|
||||
`npm run check` runs the pure-logic checks (fees, stops, sizing, trading
|
||||
hours, the DXtrade selector, the futures draw and instrument lists).
|
||||
|
||||
---
|
||||
|
||||
## The master dashboard
|
||||
|
||||
Set **Instance Name** and **Dashboard URL** on the Settings page and this
|
||||
|
||||
@@ -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 { getCachedFirm } from '@/lib/firm-catalog';
|
||||
import { getCachedFirm, hasPlatformCredentials } from '@/lib/firm-catalog';
|
||||
import { setCredentials, clearCredentials } from '@/lib/db';
|
||||
import { reconcileClients, removeClient } from '@/lib/clients';
|
||||
import { scheduleBackupPush } from '@/lib/backup-sync';
|
||||
@@ -12,10 +12,11 @@ type Params = { params: Promise<{ id: string }> };
|
||||
* (lib/backup-sync.ts); they are still absent from the catalog and the
|
||||
* control socket.
|
||||
*
|
||||
* Tradovate firms need username + password. TopstepX firms need the API key;
|
||||
* username is optional for now (the loginKey call still wants it, so the
|
||||
* client sits idle with a clear error until one is added) and password is
|
||||
* unused. */
|
||||
* Tradovate and DXTrade firms need username + password (DXTrade's server URL
|
||||
* and login domain are set on the master, per firm). TopstepX firms need the
|
||||
* API key; username is optional for now (the loginKey call still wants it,
|
||||
* so the client sits idle with a clear error until one is added) and
|
||||
* password is unused. */
|
||||
export async function PUT(req: NextRequest, { params }: Params) {
|
||||
const { id } = await params;
|
||||
|
||||
@@ -27,12 +28,13 @@ export async function PUT(req: NextRequest, { params }: Params) {
|
||||
const password = body.password?.trim() ?? '';
|
||||
const apiKey = body.apiKey?.trim() ?? '';
|
||||
|
||||
if (firm.platform === 'topstepx') {
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: 'apiKey is required for a TopstepX firm' }, { status: 400 });
|
||||
}
|
||||
} else if (!username || !password) {
|
||||
return NextResponse.json({ error: 'username and password are required' }, { status: 400 });
|
||||
if (!hasPlatformCredentials(firm.platform, { username, password, apiKey })) {
|
||||
const error = firm.platform === 'topstepx'
|
||||
? 'apiKey is required for a TopstepX firm'
|
||||
: firm.platform === 'dxtrade'
|
||||
? 'username and password are required for a DXtrade firm'
|
||||
: 'username and password are required';
|
||||
return NextResponse.json({ error }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCachedFirm } from '@/lib/firm-catalog';
|
||||
import { firmInstruments, isFuturesFirm } from '@/lib/instruments';
|
||||
import { getClients } from '@/lib/clients';
|
||||
|
||||
export async function POST(
|
||||
@@ -26,6 +28,13 @@ export async function POST(
|
||||
const client = clients.get(firmId);
|
||||
if (!client) return NextResponse.json({ error: 'Client not found for firm' }, { status: 404 });
|
||||
|
||||
// A futures firm trades only what its instrument list (master Firms
|
||||
// page) names.
|
||||
const firm = getCachedFirm(firmId);
|
||||
if (firm && isFuturesFirm(firm) && !firmInstruments(firm).includes(symbol)) {
|
||||
return NextResponse.json({ error: `${symbol} isn't on ${firm.name}'s instrument list` }, { status: 400 });
|
||||
}
|
||||
|
||||
// 1. Find the active contract chosen by the shared resolver
|
||||
const contract = await client.findFrontMonthContract(symbol);
|
||||
if (!contract) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCachedFirm } from '@/lib/firm-catalog';
|
||||
import { firmInstruments, instrumentsSource } from '@/lib/instruments';
|
||||
import { getCredentials } from '@/lib/db';
|
||||
|
||||
/** One firm from the cached catalog. Read-only: firms and their account types
|
||||
@@ -22,7 +23,8 @@ export async function GET(
|
||||
firm: firm.name,
|
||||
username: creds?.username ?? '',
|
||||
hasCredentials: !!creds,
|
||||
bannedSymbols: firm.bannedSymbols,
|
||||
instruments: firmInstruments(firm),
|
||||
instrumentsSource: instrumentsSource(firm),
|
||||
accounts: firm.accountTypes,
|
||||
catalogUpdatedAt: firm.updatedAt,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getCachedCatalog } from '@/lib/firm-catalog';
|
||||
import { firmInstruments, instrumentsSource } from '@/lib/instruments';
|
||||
import { getCachedCatalog, hasPlatformCredentials } from '@/lib/firm-catalog';
|
||||
import { getCredentials } from '@/lib/db';
|
||||
import type { FirmConfig } from '@/types';
|
||||
|
||||
@@ -18,16 +19,15 @@ export async function GET() {
|
||||
try {
|
||||
const result: FirmConfig[] = getCachedCatalog().map((firm) => {
|
||||
const creds = getCredentials(firm.id);
|
||||
const hasCredentials = firm.platform === 'topstepx'
|
||||
? !!creds?.apiKey
|
||||
: !!(creds?.username && creds?.password);
|
||||
const hasCredentials = hasPlatformCredentials(firm.platform, creds);
|
||||
return {
|
||||
id: firm.id,
|
||||
firm: firm.name,
|
||||
platform: firm.platform,
|
||||
username: creds?.username ?? '',
|
||||
hasCredentials,
|
||||
bannedSymbols: firm.bannedSymbols,
|
||||
instruments: firmInstruments(firm),
|
||||
instrumentsSource: instrumentsSource(firm),
|
||||
accounts: firm.accountTypes.map((a) => ({
|
||||
id: a.id,
|
||||
label: a.label,
|
||||
@@ -46,6 +46,8 @@ export async function GET() {
|
||||
minDayStop: a.minDayStop,
|
||||
combineCmeHolidays: a.combineCmeHolidays,
|
||||
withdrawalStages: a.withdrawalStages,
|
||||
dailyLossLimit: a.dailyLossLimit ?? 0,
|
||||
tradesEveryDay: a.tradesEveryDay === true,
|
||||
})),
|
||||
catalogUpdatedAt: firm.updatedAt,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getClients } from '@/lib/clients';
|
||||
import { getInstruments } from '@/lib/db';
|
||||
import { futuresRootsHere } from '@/lib/instruments';
|
||||
import { resolveContracts, getAllCachedContracts } from '@/lib/contract-resolver';
|
||||
|
||||
/**
|
||||
@@ -15,7 +15,7 @@ export async function GET() {
|
||||
* POST /api/instruments/contracts
|
||||
* Triggers a fresh resolve via Tradovate + Yahoo Finance.
|
||||
* Body (optional): { symbols: string[] } — when provided, resolves only those symbols.
|
||||
* Otherwise resolves all enabled instruments.
|
||||
* Otherwise resolves every root this machine's futures firms list.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
@@ -35,7 +35,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const symbols = requestedSymbols?.length
|
||||
? requestedSymbols
|
||||
: getInstruments().filter((i) => i.enabled).map((i) => i.symbol);
|
||||
: futuresRootsHere();
|
||||
|
||||
const resolved = await resolveContracts(symbols, accessToken);
|
||||
return NextResponse.json(resolved);
|
||||
|
||||
@@ -1,6 +1,36 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getInstruments } from '@/lib/db';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCachedCatalog } from '@/lib/firm-catalog';
|
||||
import {
|
||||
ensureMarket, firmInstruments, futuresRootsHere, instrumentStatsFor, instrumentsSource, isFuturesFirm, simulateDraws,
|
||||
} from '@/lib/instruments';
|
||||
import { CME_ROOTS } from '@/lib/instrument-specs';
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(getInstruments());
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** The leg the overview sizes holds for: $1,000, stopless, one contract. */
|
||||
const EXAMPLE_LEG = { leg: 1000, stopDollars: Infinity, maxPositionSize: 0 };
|
||||
|
||||
/**
|
||||
* GET /api/instruments — what each firm trades (the master's lists) and, for
|
||||
* futures firms, how the Auto draw sees every instrument right now: a tick
|
||||
* of spread, one contract's daily move, the hold of a $1,000 leg, the chance.
|
||||
* ?simulate=N adds N draws from the real draw code — no orders.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
await ensureMarket(CME_ROOTS, 8_000);
|
||||
const n = Math.min(100_000, Math.max(0, Math.floor(Number(req.nextUrl.searchParams.get('simulate')) || 0)));
|
||||
const firms = getCachedCatalog().map((firm) => {
|
||||
const instruments = firmInstruments(firm);
|
||||
const futures = isFuturesFirm(firm);
|
||||
return {
|
||||
id: firm.id,
|
||||
name: firm.name,
|
||||
platform: firm.platform,
|
||||
source: instrumentsSource(firm),
|
||||
instruments,
|
||||
...(futures ? { stats: instrumentStatsFor(firm, { ...EXAMPLE_LEG, account: 'a $1,000 leg' }) } : {}),
|
||||
...(futures && n > 0 ? { simulated: simulateDraws(instruments, EXAMPLE_LEG, n) } : {}),
|
||||
};
|
||||
});
|
||||
return NextResponse.json({ roots: futuresRootsHere(), firms });
|
||||
}
|
||||
|
||||
+69
-23
@@ -4,9 +4,13 @@ import { getClients } from '@/lib/clients';
|
||||
import { getCachedCatalog } from '@/lib/firm-catalog';
|
||||
import { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/account-type-resolver';
|
||||
import { historyFresh } from '@/lib/platform-client';
|
||||
import { rankNextLeg } from '@/lib/leg-estimate';
|
||||
import type { AccountState } from '@/types';
|
||||
import {
|
||||
combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, currentTradeDateCT,
|
||||
BIG_DAY_THRESHOLD, FIRST_DAY_STOP, LEG_COMPLETION_EPSILON, MIN_DAY_STOP_MULT,
|
||||
obfuscationStopDollars, dllRoom as computeDllRoom, dllStopDollars, botStopsActive,
|
||||
maxLossRoom, lastStand as isLastStand,
|
||||
LEG_COMPLETION_EPSILON,
|
||||
} from '@/lib/trading-logic';
|
||||
|
||||
export async function GET() {
|
||||
@@ -84,6 +88,9 @@ export async function GET() {
|
||||
let remainingToday: number | null = null;
|
||||
let effectiveProfitTarget: number | null = null;
|
||||
let nextStop: number | null = null;
|
||||
let dllRoom: number | null = null;
|
||||
let holdEstimate: AccountState['holdEstimate'] = null;
|
||||
let lastStandNext = false;
|
||||
if (cfg && effective && !isDead) {
|
||||
const equityProfit = cash.amount - cfg.accountSize;
|
||||
const plan = computeDayPlan(
|
||||
@@ -94,24 +101,59 @@ export async function GET() {
|
||||
dailyTarget = plan ? { amount: plan.dayTarget, path: plan.path } : null;
|
||||
remainingToday = plan?.remainingToday ?? null;
|
||||
|
||||
// The stop the next trade would carry under the obfuscation
|
||||
// options, mirroring obfuscationStopDollars in auto-trade:
|
||||
// day 1 keeps the day's net floor at −$1,000, min days stop
|
||||
// at 2× the target, and the day after a loss rides stopless.
|
||||
if (plan && (cfg.firstDayStop || cfg.minDayStop)) {
|
||||
const prior = dailyPnL
|
||||
.filter((d) => d.date < combined.tradeDate && d.pnl !== 0)
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
const recoveryDay = prior.length > 0 && prior[prior.length - 1].pnl < 0;
|
||||
if (!recoveryDay) {
|
||||
let stop = Infinity;
|
||||
if (cfg.firstDayStop && prior.length === 0) {
|
||||
stop = Math.max(0, Math.round((FIRST_DAY_STOP + combined.realizedPnL) * 100) / 100);
|
||||
}
|
||||
if (cfg.minDayStop && plan.dayTarget <= BIG_DAY_THRESHOLD) {
|
||||
stop = Math.min(stop, MIN_DAY_STOP_MULT * plan.dayTarget);
|
||||
}
|
||||
if (Number.isFinite(stop) && stop > 0) nextStop = Math.round(stop * 100) / 100;
|
||||
// The stop the next trade would carry, from the same helpers
|
||||
// the fire path uses: the obfuscation stop (day 1 keeps the
|
||||
// day's net floor at −$1,000, min days stop at 2× the leg,
|
||||
// the day after a loss rides stopless) against the hard
|
||||
// daily-loss stop, before fees. A min day trades its whole
|
||||
// remaining target as one leg.
|
||||
const dll = cfg.dailyLossLimit ?? 0;
|
||||
if (dll > 0) {
|
||||
const dayStart = client.dayStartBalance?.(acc.id);
|
||||
dllRoom = dayStart === null ? null : computeDllRoom({
|
||||
dailyLossLimit: dll,
|
||||
closedBalance: cash.amount,
|
||||
dayStartBalance: dayStart,
|
||||
realizedToday: Math.min(cash.realizedPnL, combined.realizedPnL),
|
||||
});
|
||||
}
|
||||
// The max-drawdown floor, and whether the next leg is a last
|
||||
// stand — no daily-loss stop — as fireLeg decides it.
|
||||
const floorRoom = maxLossRoom({
|
||||
balance: cash.amount, accountSize: cfg.accountSize, maxLoss: cfg.maxLoss ?? 0,
|
||||
autoLiqThreshold: client.autoLiqThresholds?.[acc.id],
|
||||
});
|
||||
lastStandNext = dllRoom !== null && isLastStand({ dllRoom, dailyLossLimit: dll, maxLossRoom: floorRoom });
|
||||
if (plan && (botStopsActive(cfg))) {
|
||||
const obf = obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday);
|
||||
const hard = dllRoom !== null && !lastStandNext
|
||||
? dllStopDollars({ room: dllRoom, dailyLossLimit: dll, entryFee: 0, commissionPct: 0 })
|
||||
: Infinity;
|
||||
const stop = Math.min(Number.isFinite(obf) && obf > 0 ? obf : Infinity, hard);
|
||||
if (Number.isFinite(stop) && stop > 0) nextStop = Math.round(stop * 100) / 100;
|
||||
}
|
||||
|
||||
// DXtrade: the next leg sized on every surveyed instrument as
|
||||
// fireLeg would, and the one a batch would pick.
|
||||
if (plan && plan.remainingToday > 0) {
|
||||
const best = rankNextLeg({
|
||||
client, accountId: acc.id, cfg, firm: f, leg: plan.remainingToday, dllRoom,
|
||||
obfuscationDollars: obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday),
|
||||
cash,
|
||||
})[0];
|
||||
if (best) {
|
||||
holdEstimate = {
|
||||
hours: best.holdHours,
|
||||
symbol: best.symbol,
|
||||
notional: best.notional,
|
||||
multiplier: best.multiplier,
|
||||
volatilityPerDay: best.volatilityPerDay,
|
||||
winProbability: best.winProbability,
|
||||
winProbabilityNoFees: best.winProbabilityNoFees,
|
||||
swapReserve: best.swapReserve,
|
||||
leverage: best.leverage,
|
||||
cost: best.cost,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +229,7 @@ export async function GET() {
|
||||
cycleDailyPnL: client.dailyPnL[acc.id],
|
||||
rawRealizedPnL: cash.realizedPnL,
|
||||
combineCmeHolidays: cfg.combineCmeHolidays,
|
||||
tradesEveryDay: cfg.tradesEveryDay === true,
|
||||
effective,
|
||||
equityProfit: cash.amount - cfg.accountSize,
|
||||
splitEnabled: cfg.splitEnabled,
|
||||
@@ -202,10 +245,10 @@ export async function GET() {
|
||||
// on the Tuesday after a holiday this includes Sun/Mon.
|
||||
realizedPnL: combined.realizedPnL,
|
||||
daysTraded,
|
||||
// A stop-enabled account whose day booked red via its own
|
||||
// stop — the personal DLL. Firm DLL lockouts show via
|
||||
// !active instead.
|
||||
personalDll: !!cfg && (cfg.firstDayStop || cfg.minDayStop)
|
||||
// An account the bot puts stops on whose day booked red via
|
||||
// its own stop — the personal DLL. Firm DLL lockouts show
|
||||
// via !active instead.
|
||||
personalDll: !!cfg && botStopsActive(cfg)
|
||||
&& acc.active && !client.positions[acc.id]
|
||||
&& combined.realizedPnL <= -25,
|
||||
positionDirection: client.positions[acc.id]
|
||||
@@ -220,6 +263,9 @@ export async function GET() {
|
||||
stageDrain: effective?.drain ?? false,
|
||||
dailyTarget,
|
||||
nextStop,
|
||||
dllRoom,
|
||||
holdEstimate,
|
||||
lastStand: lastStandNext,
|
||||
remainingToday,
|
||||
effectiveProfitTarget,
|
||||
dailyPnL,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { runTrade, startScheduler } from '@/lib/auto-trade';
|
||||
import { errorMessage } from '@/lib/platform-client';
|
||||
import { POINT_VALUES } from '@/lib/trading-logic';
|
||||
import { CME_SPECS } from '@/lib/instrument-specs';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
@@ -12,7 +12,7 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: 'Missing required fields: action, symbol' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (symbol !== 'Auto' && !POINT_VALUES[symbol]) {
|
||||
if (symbol !== 'Auto' && !CME_SPECS[symbol]) {
|
||||
return NextResponse.json({ error: `Unknown symbol: ${symbol}` }, { status: 400 });
|
||||
}
|
||||
|
||||
|
||||
@@ -137,20 +137,25 @@ export default function FirmSettingsPage() {
|
||||
</div>
|
||||
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mt-10 mb-3">
|
||||
Banned Symbols
|
||||
Instruments
|
||||
</h2>
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4 shadow-sm">
|
||||
{!firm || firm.bannedSymbols.length === 0 ? (
|
||||
<span className="text-sm text-slate-400 italic">None</span>
|
||||
{!firm || firm.instruments.length === 0 ? (
|
||||
<span className="text-sm text-slate-400 italic">None — this firm won't trade</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{firm.bannedSymbols.map((sym) => (
|
||||
<span key={sym} className="px-2 py-1 rounded-md text-xs font-mono font-semibold bg-red-100 text-red-700">
|
||||
{firm.instruments.map((sym) => (
|
||||
<span key={sym} className="px-2 py-1 rounded-md text-xs font-mono font-semibold bg-slate-100 text-slate-700">
|
||||
{sym}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-slate-400">
|
||||
{firm?.instrumentsSource === 'legacy'
|
||||
? 'The master doesn\'t send instrument lists yet: this machine\'s enabled instruments, less the firm\'s banned symbols.'
|
||||
: 'Edited on the master\'s Firms page.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
+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.
|
||||
* Tradovate wants username + password; TopstepX wants the API key (username
|
||||
* optional for now, though login needs it eventually — password unused). */
|
||||
* Tradovate and DXtrade want username + password; TopstepX wants the API key
|
||||
* (username optional for now, though login needs it eventually — password
|
||||
* unused). */
|
||||
function CredentialForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => void }) {
|
||||
const isTopstepX = firm.platform === 'topstepx';
|
||||
const [username, setUsername] = useState('');
|
||||
@@ -231,7 +241,7 @@ function CredentialForm({ firm, onSaved }: { firm: FirmConfig; onSaved: () => vo
|
||||
<div className="flex items-center gap-1.5" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
className={cls}
|
||||
placeholder={isTopstepX ? 'Username (optional)' : 'Tradovate username'}
|
||||
placeholder={isTopstepX ? 'Username (optional)' : firm.platform === 'dxtrade' ? 'DXtrade username' : 'Tradovate username'}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
@@ -375,7 +385,27 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
|
||||
<span className="text-xs text-slate-400">
|
||||
${fmt(account.dailyTarget.amount)} today
|
||||
{account.nextStop !== null && (
|
||||
<span className="text-amber-600" title="The next trade's stop — dollars it may lose before booking the day"> · stop ${fmt(account.nextStop)}</span>
|
||||
<span className="text-amber-600" title="The next trade's stop — dollars of price movement it may lose before booking the day (fees come on top; the daily-loss stop already reserves for them)"> · stop ${fmt(account.nextStop)}</span>
|
||||
)}
|
||||
{account.lastStand && (
|
||||
<span className="text-red-600" title="Last stand: hitting the day's loss limit would leave this account within 15% of that loss above its max-drawdown floor — nothing left to trade on — so the next leg carries no daily-loss stop and runs to its take-profit or the firm's breach"> · no stop (last stand)</span>
|
||||
)}
|
||||
{typeof account.dllRoom === 'number' && (
|
||||
<span className="text-slate-500" title="Hard daily loss limit: what the firm's day may still lose before a breach. A reserve — 10% of the limit, or 0.1% of the position if larger — is never spent."> · DLL room ${fmt(account.dllRoom)}</span>
|
||||
)}
|
||||
{account.holdEstimate && (
|
||||
<span
|
||||
className="text-slate-500"
|
||||
title={`Next leg on ${account.holdEstimate.symbol}: $${Math.round(account.holdEstimate.notional).toLocaleString()} notional, ${account.holdEstimate.multiplier}× the account`
|
||||
+ (account.holdEstimate.leverage !== null ? ` (${account.holdEstimate.leverage}× max)` : '')
|
||||
+ ` at a measured volatility of ${(account.holdEstimate.volatilityPerDay * 100).toFixed(2)}%/day over the past week`
|
||||
+ (account.holdEstimate.winProbability !== null ? `, wins ~${Math.round(account.holdEstimate.winProbability * 100)}% of the time` : '')
|
||||
+ (account.holdEstimate.winProbabilityNoFees !== null ? ` (~${Math.round(account.holdEstimate.winProbabilityNoFees * 100)}% with no commission, spread or swap)` : '')
|
||||
+ (account.holdEstimate.swapReserve > 0 ? `; its take-profit carries ~$${fmt(account.holdEstimate.swapReserve)} of expected swap` : '')
|
||||
+ `. Expected cost ~$${fmt(account.holdEstimate.cost)} (spread, commissions, swap) — the cheapest candidate that reaches the target hold`}
|
||||
>
|
||||
{' · hold ~'}{account.holdEstimate.hours < 48 ? `${Math.round(account.holdEstimate.hours)}h` : `${(account.holdEstimate.hours / 24).toFixed(1)}d`}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
@@ -392,7 +422,7 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: {
|
||||
: account.positionDirection
|
||||
? <span className={`inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold ${account.positionDirection === 'long' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>{account.positionDirection === 'long' ? 'Long' : 'Short'}</span>
|
||||
: account.personalDll
|
||||
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-700" title="Booked the day red via its own stop — make-back rides next session">Personal DLL</span>
|
||||
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-100 text-amber-700" title="Booked the day red via its own stop (obfuscation or daily-loss) — trading resumes next session">Personal DLL</span>
|
||||
: account.targetHit
|
||||
? <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-700">Target Hit</span>
|
||||
: <span className="inline-block px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-100 text-slate-500">Flat</span>}
|
||||
@@ -468,9 +498,9 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
||||
<td className="px-4 py-3.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="font-bold text-sm text-slate-800">{firm.firm}</span>
|
||||
{firm.bannedSymbols?.includes(tradeSymbol) && (
|
||||
<span className="text-xs bg-red-100 text-red-700 px-1.5 py-0.5 rounded font-semibold">
|
||||
{tradeSymbol} BANNED
|
||||
{firm.platform !== 'dxtrade' && tradeSymbol !== 'Auto' && !firm.instruments?.includes(tradeSymbol) && (
|
||||
<span className="text-xs bg-red-100 text-red-700 px-1.5 py-0.5 rounded font-semibold" title="Not on this firm's instrument list (master Firms page) — it sits this symbol out">
|
||||
{tradeSymbol} not listed
|
||||
</span>
|
||||
)}
|
||||
{firm.platform === 'topstepx' && (
|
||||
@@ -478,6 +508,11 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
||||
TopstepX
|
||||
</span>
|
||||
)}
|
||||
{firm.platform === 'dxtrade' && (
|
||||
<span className="text-xs bg-emerald-100 text-emerald-700 px-1.5 py-0.5 rounded font-semibold">
|
||||
DXtrade
|
||||
</span>
|
||||
)}
|
||||
{firm.platform === 'topstepx' && hasCredentials && !firm.username && (
|
||||
<span
|
||||
className="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-semibold"
|
||||
@@ -506,7 +541,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
||||
<td colSpan={5} className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="text-xs text-slate-500">
|
||||
Remove the saved {firm.platform === 'topstepx' ? 'TopstepX API key' : 'Tradovate login'} for {firm.firm}? Its accounts disappear until you enter one again.
|
||||
Remove the saved {credentialNoun(firm.platform)} for {firm.firm}? Its accounts disappear until you enter one again.
|
||||
</span>
|
||||
<button
|
||||
onClick={removeCredentials}
|
||||
@@ -532,7 +567,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setConfirmCreds(true); }}
|
||||
className="text-slate-400 hover:text-red-500 hover:bg-red-50 p-1 rounded-md transition-colors inline-flex"
|
||||
title={firm.platform === 'topstepx' ? 'Remove saved TopstepX API key' : 'Remove saved Tradovate login'}
|
||||
title={`Remove saved ${credentialNoun(firm.platform)}`}
|
||||
>
|
||||
<KeyOffIcon />
|
||||
</button>
|
||||
@@ -554,7 +589,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo
|
||||
state.accounts.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-5 pl-12 text-sm text-slate-400 italic">
|
||||
{state.connected ? 'No accounts at this firm' : 'Connecting to Tradovate…'}
|
||||
{state.connected ? 'No accounts at this firm' : 'Connecting…'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
@@ -594,7 +629,7 @@ export default function Home() {
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc');
|
||||
|
||||
// Trade controls
|
||||
const [scheduler, setScheduler] = useState<SchedulerStatus>({ running: false, action: 'Buy', symbol: 'NQ', lastRun: null, intervalSeconds: 60, stopAfterAll: false });
|
||||
const [scheduler, setScheduler] = useState<SchedulerStatus>({ running: false, action: 'Buy', symbol: 'Auto', lastRun: null, intervalSeconds: 60, stopAfterAll: false });
|
||||
const [enabledSymbols, setEnabledSymbols] = useState<string[]>([]);
|
||||
const [tradeSymbol, setTradeSymbol] = useState('');
|
||||
const [tradeAction, setTradeAction] = useState<'Buy' | 'Sell' | 'Auto'>('Buy');
|
||||
@@ -646,12 +681,13 @@ export default function Home() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// The roots this machine's futures firms list; 'Auto' draws among
|
||||
// each batch firm's list and leads the dropdown.
|
||||
fetch('/api/instruments')
|
||||
.then((r) => r.json() as Promise<{ symbol: string; enabled: boolean }[]>)
|
||||
.then((instruments) => {
|
||||
const syms = instruments.filter((i) => i.enabled).map((i) => i.symbol);
|
||||
setEnabledSymbols(syms);
|
||||
setTradeSymbol((prev) => prev || syms[0] || 'NQ');
|
||||
.then((r) => r.json() as Promise<{ roots: string[] }>)
|
||||
.then(({ roots }) => {
|
||||
setEnabledSymbols(roots ?? []);
|
||||
setTradeSymbol((prev) => prev || 'Auto');
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
@@ -804,8 +840,8 @@ export default function Home() {
|
||||
onChange={(e) => setTradeSymbol(e.target.value)}
|
||||
className="rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-1.5 text-sm font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{enabledSymbols.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
<option value="Auto">Auto</option>
|
||||
{enabledSymbols.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<div className="flex rounded-lg border border-slate-200 overflow-hidden text-sm font-semibold">
|
||||
<button
|
||||
|
||||
+99
-90
@@ -3,9 +3,30 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Instrument {
|
||||
symbol: string;
|
||||
enabled: boolean;
|
||||
/** GET /api/instruments: each firm's list (from the master) and, for
|
||||
* futures firms, how the Auto draw sees each instrument. */
|
||||
interface InstrumentsOverview {
|
||||
roots: string[];
|
||||
firms: {
|
||||
id: string;
|
||||
name: string;
|
||||
platform: string;
|
||||
source: 'master' | 'legacy';
|
||||
instruments: string[];
|
||||
stats?: {
|
||||
maxHoldHours: number;
|
||||
sessionProgress: number | null;
|
||||
instruments: {
|
||||
symbol: string;
|
||||
listed: boolean;
|
||||
spreadDollars: number;
|
||||
dailyMoveDollars: number | null;
|
||||
holdHours: number | null;
|
||||
disabled: boolean;
|
||||
chance: number | null;
|
||||
}[];
|
||||
};
|
||||
}[];
|
||||
}
|
||||
|
||||
interface ResolvedContract {
|
||||
@@ -33,7 +54,7 @@ interface BackupMeta {
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [instruments, setInstruments] = useState<Instrument[]>([]);
|
||||
const [overview, setOverview] = useState<InstrumentsOverview | null>(null);
|
||||
const [contracts, setContracts] = useState<Record<string, ResolvedContract | null>>({});
|
||||
const [maxConcurrent, setMaxConcurrent] = useState<string>('5');
|
||||
const [tradingHours, setTradingHours] = useState<string>('full_cme');
|
||||
@@ -75,7 +96,8 @@ export default function SettingsPage() {
|
||||
useEffect(() => {
|
||||
fetch('/api/instruments')
|
||||
.then((r) => r.json())
|
||||
.then(setInstruments);
|
||||
.then(setOverview)
|
||||
.catch(() => {});
|
||||
|
||||
fetchCatalog();
|
||||
|
||||
@@ -125,33 +147,6 @@ export default function SettingsPage() {
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function toggle(symbol: string, enabled: boolean) {
|
||||
setInstruments((prev) =>
|
||||
prev.map((i) => (i.symbol === symbol ? { ...i, enabled } : i))
|
||||
);
|
||||
await fetch(`/api/instruments/${symbol}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
|
||||
// When enabling a symbol, auto-resolve its active contract
|
||||
if (enabled) {
|
||||
fetch('/api/instruments/contracts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ symbols: [symbol] }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((fresh: Record<string, ResolvedContract | null>) => {
|
||||
if (!fresh.error) {
|
||||
setContracts((prev) => ({ ...prev, ...fresh }));
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
setSaving(true);
|
||||
setSaved(false);
|
||||
@@ -443,68 +438,82 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
|
||||
{/* ── Instruments ── */}
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-3">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-500 mb-1">
|
||||
Instruments
|
||||
</h2>
|
||||
<p className="text-xs text-slate-400 mb-3">
|
||||
What each firm trades is set on the master'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">
|
||||
{instruments.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-sm text-slate-400 italic">
|
||||
Loading…
|
||||
{!overview ? (
|
||||
<div className="bg-white border border-slate-200 rounded-xl px-4 py-8 text-center text-sm text-slate-400 italic shadow-sm">Loading…</div>
|
||||
) : overview.firms.map((firm) => (
|
||||
<div key={firm.id} className="bg-white border border-slate-200 rounded-xl overflow-hidden shadow-sm mb-3">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 bg-slate-50 border-b border-slate-200">
|
||||
<span className="font-semibold text-sm text-slate-800">{firm.name}</span>
|
||||
<span className="text-xs text-slate-400">{firm.platform}</span>
|
||||
{firm.source === 'legacy' && (
|
||||
<span className="text-xs bg-amber-50 text-amber-700 border border-amber-200 rounded px-1.5 py-0.5" title="The master doesn't send instrument lists yet: this machine's enabled instruments, less the firm's banned symbols">
|
||||
from this machine (older master)
|
||||
</span>
|
||||
)}
|
||||
{firm.instruments.length === 0 && <span className="text-xs text-amber-700">no instruments — won't trade</span>}
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 bg-slate-50">
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Symbol</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Active Contract</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-semibold uppercase tracking-wider text-slate-400">Enabled</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{instruments.map((instr) => {
|
||||
const c = contracts[instr.symbol];
|
||||
const wasRolled = !!c?.alternative;
|
||||
return (
|
||||
<tr key={instr.symbol} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-4 py-2.5 font-mono font-semibold text-slate-800">
|
||||
{instr.symbol}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{instr.enabled && c ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-mono text-sm ${wasRolled ? 'text-amber-600 font-semibold' : 'text-slate-600'}`}>
|
||||
{c.name}
|
||||
</span>
|
||||
{wasRolled && (
|
||||
<span className="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-medium">
|
||||
rolled from {c.alternative}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<button
|
||||
onClick={() => toggle(instr.symbol, !instr.enabled)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none ${
|
||||
instr.enabled ? 'bg-blue-500' : 'bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white shadow transition-transform ${
|
||||
instr.enabled ? 'translate-x-4' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
{firm.stats ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse whitespace-nowrap">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100 text-xs uppercase tracking-wider text-slate-400">
|
||||
<th className="px-4 py-2 text-left font-semibold">Symbol</th>
|
||||
<th className="px-4 py-2 text-left font-semibold">Contract</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">Spread</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">Daily move</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">Hold</th>
|
||||
<th className="px-4 py-2 text-right font-semibold">Chance now</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{firm.instruments.map((sym) => {
|
||||
const x = firm.stats!.instruments.find((i) => i.symbol === sym);
|
||||
const c = contracts[sym];
|
||||
return (
|
||||
<tr key={sym} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-4 py-2 font-mono font-semibold text-slate-800">{sym}</td>
|
||||
<td className="px-4 py-2 font-mono text-slate-600">
|
||||
{c?.name ?? '—'}
|
||||
{c?.alternative && <span className="ml-2 text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded font-sans">rolled from {c.alternative}</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums text-slate-600" title="One tick of spread on one contract — what entering costs, commissions aside">
|
||||
{x ? `$${x.spreadDollars.toFixed(2)}` : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums text-slate-600">
|
||||
{x?.dailyMoveDollars != null ? `$${x.dailyMoveDollars.toLocaleString()}` : '—'}
|
||||
</td>
|
||||
<td className={`px-4 py-2 text-right tabular-nums ${x?.disabled ? 'text-amber-600' : 'text-slate-600'}`}>
|
||||
{x?.holdHours != null ? (x.holdHours < 48 ? `${x.holdHours.toFixed(1)}h` : `${(x.holdHours / 24).toFixed(1)}d`) : '—'}
|
||||
{x?.disabled && ' · disabled'}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right tabular-nums font-semibold text-slate-700">
|
||||
{x?.chance != null ? `${Math.round(x.chance * 100)}%` : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5 px-4 py-3">
|
||||
{firm.instruments.map((sym) => (
|
||||
<span key={sym} className="px-2 py-1 rounded-md text-xs font-mono font-semibold bg-slate-100 text-slate-700">{sym}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+877
-473
File diff suppressed because it is too large
Load Diff
+68
-19
@@ -1,8 +1,11 @@
|
||||
import { TradovateClient } from './tradovate-class';
|
||||
import { TopstepXClient } from './topstepx-class';
|
||||
import { DXTradeClient } from './dxtrade-class';
|
||||
import type { FirmPlatform } from './master-protocol';
|
||||
import { errorMessage, isClientReady, type PlatformClient } from './platform-client';
|
||||
import { getInstruments } from './db';
|
||||
import { getTradableFirms, startCatalogSync } from './firm-catalog';
|
||||
import { firmInstruments, isFuturesFirm, prewarmMarket } from './instruments';
|
||||
import { housekeepingRoot } from './instrument-specs';
|
||||
import { resolveFirmAssignments } from './account-type-resolver';
|
||||
import { resolveContracts } from './contract-resolver';
|
||||
import { startReporter, sendReport } from './reporter';
|
||||
@@ -25,6 +28,7 @@ const g = global as typeof globalThis & {
|
||||
__contractResolverTimer?: ReturnType<typeof setInterval>;
|
||||
__contractResolveRetryTimer?: ReturnType<typeof setTimeout>;
|
||||
__contractResolveRetries?: number;
|
||||
__contractResolvedRoots?: Set<string>;
|
||||
__clientSupervisorTimer?: ReturnType<typeof setInterval>;
|
||||
__clientSupervision?: Map<string, SupervisionEntry>;
|
||||
};
|
||||
@@ -43,15 +47,18 @@ function credsMap(): Map<string, string> {
|
||||
return g.__tradovateClientCreds;
|
||||
}
|
||||
|
||||
function credsSignature(firm: { platform: string; username: string; password: string; apiKey: string }): string {
|
||||
return `${firm.platform} ${firm.username} ${firm.password} ${firm.apiKey}`;
|
||||
/** What a client was built from — a change to any of it rebuilds the client.
|
||||
* DXTrade's server URL and login domain are the firm's (master), not the
|
||||
* machine's, but they decide the login just as much. */
|
||||
function credsSignature(firm: { platform: string; username: string; password: string; apiKey: string; serverUrl?: string; loginDomain?: string }): string {
|
||||
return `${firm.platform} ${firm.username} ${firm.password} ${firm.apiKey} ${firm.serverUrl ?? ''} ${firm.loginDomain ?? ''}`;
|
||||
}
|
||||
|
||||
export function initClient(
|
||||
id: string,
|
||||
firmName: string,
|
||||
platform: 'tradovate' | 'topstepx',
|
||||
creds: { username: string; password: string; apiKey: string },
|
||||
platform: FirmPlatform,
|
||||
creds: { username: string; password: string; apiKey: string; serverUrl?: string; loginDomain?: string },
|
||||
): PlatformClient {
|
||||
const map = ensureMap();
|
||||
const onSyncComplete = async () => {
|
||||
@@ -73,9 +80,29 @@ export function initClient(
|
||||
}
|
||||
};
|
||||
|
||||
const client: PlatformClient = platform === 'topstepx'
|
||||
? new TopstepXClient(creds.username, creds.apiKey, onSyncComplete)
|
||||
: new TradovateClient(creds.username, creds.password, onSyncComplete);
|
||||
let client: PlatformClient;
|
||||
switch (platform) {
|
||||
case 'topstepx':
|
||||
client = new TopstepXClient(creds.username, creds.apiKey, onSyncComplete);
|
||||
break;
|
||||
case 'dxtrade':
|
||||
client = new DXTradeClient({
|
||||
firmId: id,
|
||||
firmName,
|
||||
serverUrl: creds.serverUrl ?? '',
|
||||
loginDomain: creds.loginDomain ?? '',
|
||||
username: creds.username,
|
||||
password: creds.password,
|
||||
}, onSyncComplete);
|
||||
break;
|
||||
case 'tradovate':
|
||||
client = new TradovateClient(creds.username, creds.password, onSyncComplete);
|
||||
break;
|
||||
default: {
|
||||
const unknown: never = platform;
|
||||
throw new Error(`no client for platform ${String(unknown)}`);
|
||||
}
|
||||
}
|
||||
map.set(id, client);
|
||||
credsMap().set(id, credsSignature({ platform, ...creds }));
|
||||
return client;
|
||||
@@ -166,6 +193,8 @@ export function getClients(): Map<string, PlatformClient> {
|
||||
// sitting Unassigned without waiting for a Tradovate sync.
|
||||
startCatalogSync(() => {
|
||||
reconcileClients();
|
||||
// A newly listed root needs its front-month contract.
|
||||
if (resolveRootsHere().some((r) => !g.__contractResolvedRoots?.has(r))) triggerContractResolve();
|
||||
for (const [firmId, client] of ensureMap()) {
|
||||
try {
|
||||
resolveFirmAssignments(firmId, client);
|
||||
@@ -178,8 +207,10 @@ export function getClients(): Map<string, PlatformClient> {
|
||||
reconcileClients();
|
||||
console.log(`[clients] Initialized ${map.size} platform client(s)`);
|
||||
|
||||
// Auto-resolve contracts after clients have time to authenticate
|
||||
// Auto-resolve contracts after clients have time to authenticate,
|
||||
// and measure every future's movement for the Auto draw.
|
||||
setTimeout(() => triggerContractResolve(), 15_000);
|
||||
prewarmMarket();
|
||||
|
||||
// Schedule daily resolve at midnight
|
||||
scheduleDailyResolve();
|
||||
@@ -217,6 +248,18 @@ export function getClients(): Map<string, PlatformClient> {
|
||||
const CONTRACT_RESOLVE_RETRY_MS = 60_000;
|
||||
const CONTRACT_RESOLVE_MAX_RETRIES = 30;
|
||||
|
||||
/** Every root a futures firm here lists, plus each one's drain micro. */
|
||||
function resolveRootsHere(): string[] {
|
||||
const roots = new Set<string>();
|
||||
for (const firm of getTradableFirms().filter(isFuturesFirm)) {
|
||||
const listed = firmInstruments(firm);
|
||||
for (const r of listed) roots.add(r);
|
||||
const micro = housekeepingRoot(listed);
|
||||
if (micro) roots.add(micro);
|
||||
}
|
||||
return [...roots];
|
||||
}
|
||||
|
||||
function triggerContractResolve(): void {
|
||||
// A midnight run supersedes a pending retry; either way only one chain.
|
||||
if (g.__contractResolveRetryTimer) clearTimeout(g.__contractResolveRetryTimer);
|
||||
@@ -259,7 +302,8 @@ function triggerContractResolve(): void {
|
||||
// Runs from timers: a throw here (the instruments read) would escape as
|
||||
// an uncaught exception.
|
||||
try {
|
||||
const symbols = getInstruments().filter((i) => i.enabled).map((i) => i.symbol);
|
||||
const symbols = resolveRootsHere();
|
||||
g.__contractResolvedRoots = new Set(symbols);
|
||||
console.log(`[contract-resolver] Resolving ${symbols.length} instruments...`);
|
||||
resolveContracts(symbols, accessToken)
|
||||
.then((results) => {
|
||||
@@ -284,6 +328,7 @@ function scheduleDailyResolve(): void {
|
||||
// Its own half hour of retries, not what's left of a chain it cuts off.
|
||||
g.__contractResolveRetries = 0;
|
||||
triggerContractResolve();
|
||||
prewarmMarket();
|
||||
}
|
||||
}, 60_000);
|
||||
}
|
||||
@@ -316,11 +361,16 @@ const SUPERVISOR_INTERVAL_MS = 60_000;
|
||||
const REBUILD_AFTER_UNREADY_MS = 20 * 60_000;
|
||||
const REBUILD_COOLDOWN_MS = 20 * 60_000;
|
||||
|
||||
/** The message TopstepXClient records when a firm has an API key but no
|
||||
* username. That client never even tries to log in, so a rebuild changes
|
||||
* nothing — adding the username changes the credentials, and
|
||||
* reconcileClients rebuilds it then. */
|
||||
const TOPSTEPX_USERNAME_MISSING = /username missing/i;
|
||||
/** Clients that can't log in until someone acts — a rebuild changes nothing,
|
||||
* and fixing the credentials (or the firm's settings) rebuilds them through
|
||||
* reconcileClients anyway. TopstepX with an API key but no username never
|
||||
* tries to log in; DXTrade with no server URL can't, and after refused
|
||||
* credentials it stops trying so as not to lock the account. */
|
||||
function needsAttention(platform: FirmPlatform, client: PlatformClient): boolean {
|
||||
if (platform === 'topstepx') return /username missing/i.test(client.lastFetchErrors?.['login'] ?? '');
|
||||
if (platform === 'dxtrade') return (client as DXTradeClient).needsAttention === true;
|
||||
return false;
|
||||
}
|
||||
|
||||
interface SupervisionEntry {
|
||||
/** The instance being judged. A different one (rebuilt here, or replaced
|
||||
@@ -383,8 +433,8 @@ function rebuildHold(platform: string, client: PlatformClient, now: number): str
|
||||
+ ` (${health.consecutiveLoginFailures} failure(s) in a row)`;
|
||||
}
|
||||
} catch { /* no health: judge it on the rest */ }
|
||||
if (platform === 'topstepx' && Object.keys(client.positions ?? {}).length > 0) {
|
||||
return 'it holds an open position (TopstepX exit brackets are tracked only inside the client)';
|
||||
if ((platform === 'topstepx' || platform === 'dxtrade') && Object.keys(client.positions ?? {}).length > 0) {
|
||||
return 'it holds an open position (its exit pairs are tracked only inside the client)';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -429,8 +479,7 @@ function superviseClients(): void {
|
||||
firms ??= getTradableFirms();
|
||||
const firm = firms.find((f) => f.id === id);
|
||||
if (!firm) continue; // no longer tradable: reconcileClients drops it
|
||||
if (firm.platform === 'topstepx'
|
||||
&& TOPSTEPX_USERNAME_MISSING.test(client.lastFetchErrors?.['login'] ?? '')) continue;
|
||||
if (needsAttention(firm.platform, client)) continue;
|
||||
|
||||
const minutes = Math.round((now - entry.unhealthySince) / 60_000);
|
||||
const hold = rebuildHold(firm.platform, client, now);
|
||||
|
||||
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.)
|
||||
*/
|
||||
|
||||
import type { CatalogAccountType, CatalogFirm, FirmCatalogPayload } from './master-protocol';
|
||||
import { isFirmPlatform } from './master-protocol';
|
||||
import { sanitizeInstruments } from './instrument-specs';
|
||||
import type {
|
||||
CatalogAccountType, CatalogFirm, FirmCatalogPayload, FirmPlatform, SymbolMap, SymbolMapCandidate,
|
||||
} from './master-protocol';
|
||||
import {
|
||||
getSetting,
|
||||
setSetting,
|
||||
@@ -99,26 +103,67 @@ export function getCatalogMeta(): {
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether stored credentials are what the firm's platform logs in with — a
|
||||
* TopstepX API key, or a username + password (Tradovate, DXTrade). */
|
||||
export function hasPlatformCredentials(
|
||||
platform: FirmPlatform,
|
||||
creds: { username: string; password: string; apiKey: string } | null | undefined,
|
||||
): boolean {
|
||||
if (!creds) return false;
|
||||
switch (platform) {
|
||||
case 'topstepx': return !!creds.apiKey;
|
||||
case 'tradovate':
|
||||
case 'dxtrade': return !!creds.username && !!creds.password;
|
||||
}
|
||||
}
|
||||
|
||||
/** Firms this machine can actually trade: in the catalog AND holding the
|
||||
* credentials their platform needs — a Tradovate username + password, or a
|
||||
* TopstepX API key. Everything that starts a platform client goes through
|
||||
* here. */
|
||||
* credentials their platform needs. Everything that starts a platform client
|
||||
* goes through here. */
|
||||
export function getTradableFirms(): (CatalogFirm & { username: string; password: string; apiKey: string })[] {
|
||||
const result: (CatalogFirm & { username: string; password: string; apiKey: string })[] = [];
|
||||
for (const firm of getCachedCatalog()) {
|
||||
const creds = getCredentials(firm.id);
|
||||
if (!creds) continue;
|
||||
if (firm.platform === 'topstepx') {
|
||||
if (creds.apiKey) result.push({ ...firm, ...creds });
|
||||
} else {
|
||||
if (creds.username && creds.password) result.push({ ...firm, ...creds });
|
||||
}
|
||||
if (creds && hasPlatformCredentials(firm.platform, creds)) result.push({ ...firm, ...creds });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Sync ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const g2 = global as typeof globalThis & { __catalogUnknownPlatformWarned?: Set<string> };
|
||||
|
||||
/** Once per firm per process — the catalog is re-validated every 60s. */
|
||||
function warnUnknownPlatform(id: string, name: string, platform: unknown): void {
|
||||
const warned = (g2.__catalogUnknownPlatformWarned ??= new Set());
|
||||
const key = `${id}|${String(platform)}`;
|
||||
if (warned.has(key)) return;
|
||||
warned.add(key);
|
||||
console.warn(`[catalog] skipping "${name}": platform ${JSON.stringify(platform)} is unknown to this build — update this instance`);
|
||||
}
|
||||
|
||||
function nonNegative(value: unknown): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0;
|
||||
}
|
||||
|
||||
/** Drops anything malformed rather than failing the sync: a bad symbol-map
|
||||
* entry only means that candidate isn't traded. */
|
||||
function sanitizeSymbolMap(raw: unknown): SymbolMap {
|
||||
const map: SymbolMap = {};
|
||||
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return map;
|
||||
for (const [root, list] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (!Array.isArray(list)) continue;
|
||||
const candidates = list.flatMap((c): SymbolMapCandidate[] => {
|
||||
if (typeof c !== 'object' || c === null) return [];
|
||||
const cand = c as Record<string, unknown>;
|
||||
if (typeof cand.symbol !== 'string' || !cand.symbol || cand.symbol.includes('|')) return [];
|
||||
return [{ symbol: cand.symbol }];
|
||||
});
|
||||
if (candidates.length > 0) map[root.toUpperCase()] = candidates;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function isStages(value: unknown): value is { profit: number; consistency: number; minTradingDays: number }[] {
|
||||
return Array.isArray(value) && value.every((s) =>
|
||||
typeof s === 'object' && s !== null
|
||||
@@ -136,12 +181,27 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
|
||||
if (typeof body.updatedAt !== 'string') throw new Error('missing updatedAt');
|
||||
if (!Array.isArray(body.firms)) throw new Error('missing firms');
|
||||
|
||||
const firms: CatalogFirm[] = body.firms.map((f: unknown, i: number) => {
|
||||
const firms: CatalogFirm[] = [];
|
||||
body.firms.forEach((f: unknown, i: number) => {
|
||||
if (typeof f !== 'object' || f === null) throw new Error(`firm ${i} is not an object`);
|
||||
const firm = f as Record<string, unknown>;
|
||||
if (typeof firm.id !== 'string' || typeof firm.name !== 'string') {
|
||||
throw new Error(`firm ${i} is missing id or name`);
|
||||
}
|
||||
// An old master sends no platform: that is Tradovate. A platform this
|
||||
// build doesn't know is NOT — coercing it would start the wrong client
|
||||
// with another platform's credentials. Skip the firm (before its
|
||||
// account types can fail the whole sync); it comes back once this
|
||||
// build knows the platform.
|
||||
const platform = firm.platform === undefined ? 'tradovate' : firm.platform;
|
||||
if (!isFirmPlatform(platform)) {
|
||||
warnUnknownPlatform(firm.id, firm.name, platform);
|
||||
return;
|
||||
}
|
||||
// Percent commission and every-day trading are DXtrade settings;
|
||||
// futures firms keep measured per-contract fees and the CME calendar.
|
||||
const dx = platform === 'dxtrade';
|
||||
const tradesEveryDay = dx && firm.tradesEveryDay === true;
|
||||
if (!Array.isArray(firm.accountTypes)) throw new Error(`firm ${firm.name} is missing accountTypes`);
|
||||
|
||||
const accountTypes: CatalogAccountType[] = firm.accountTypes.map((t: unknown, j: number) => {
|
||||
@@ -163,26 +223,39 @@ function validatePayload(raw: unknown): FirmCatalogPayload {
|
||||
return {
|
||||
...type,
|
||||
splitEnabled: type.splitEnabled !== false,
|
||||
combineCmeHolidays: type.combineCmeHolidays === true,
|
||||
// An every-day firm has no CME calendar to fold holidays by.
|
||||
combineCmeHolidays: type.combineCmeHolidays === true && !tradesEveryDay,
|
||||
// Lenient: a master from before the min-legs field means "no minimum".
|
||||
minLegs: typeof type.minLegs === 'number' ? type.minLegs : 0,
|
||||
// Lenient: the obfuscation stops are opt-in, so absent means off.
|
||||
firstDayStop: type.firstDayStop === true,
|
||||
minDayStop: type.minDayStop === true,
|
||||
// Lenient: no hard daily limit, no notional cap.
|
||||
dailyLossLimit: nonNegative(type.dailyLossLimit),
|
||||
// The firm's calendar, stamped on every type so the cfg-based
|
||||
// paths (projections, completion, the dashboard) all see it.
|
||||
tradesEveryDay,
|
||||
} as unknown as CatalogAccountType;
|
||||
});
|
||||
|
||||
return {
|
||||
firms.push({
|
||||
id: firm.id,
|
||||
name: firm.name,
|
||||
// Lenient on purpose: a master from before the platform field (or
|
||||
// with a platform this build doesn't know) must not fail the whole
|
||||
// sync, and 'tradovate' is what every firm was until then.
|
||||
platform: firm.platform === 'topstepx' ? 'topstepx' : 'tradovate',
|
||||
platform,
|
||||
// The master's list when it sends one; absent from an older
|
||||
// master, whose bannedSymbols / symbolMap then apply.
|
||||
...(Array.isArray(firm.instruments) ? { instruments: sanitizeInstruments(platform, firm.instruments) } : {}),
|
||||
bannedSymbols: Array.isArray(firm.bannedSymbols) ? (firm.bannedSymbols as string[]) : [],
|
||||
accountTypes,
|
||||
serverUrl: typeof firm.serverUrl === 'string' ? firm.serverUrl : '',
|
||||
loginDomain: typeof firm.loginDomain === 'string' ? firm.loginDomain : '',
|
||||
commissionPct: dx ? nonNegative(firm.commissionPct) : 0,
|
||||
tradesEveryDay,
|
||||
symbolMap: sanitizeSymbolMap(firm.symbolMap),
|
||||
targetHoldHours: nonNegative(firm.targetHoldHours),
|
||||
financingPctPerDay: nonNegative(firm.financingPctPerDay),
|
||||
updatedAt: typeof firm.updatedAt === 'string' ? firm.updatedAt : new Date(0).toISOString(),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
return { version: body.version, updatedAt: body.updatedAt, firms };
|
||||
@@ -233,8 +306,12 @@ export async function syncCatalog(): Promise<{ ok: boolean; changed: boolean; ve
|
||||
// Only the URL is required — deliberately not instance_name, unlike the
|
||||
// reporter. A freshly imaged machine has to be able to pull its catalog
|
||||
// before anyone has got round to naming it.
|
||||
// `since` only against the master the cache came from: two masters'
|
||||
// version numbers can collide, and an 'unchanged' from the wrong one
|
||||
// would keep the other's catalog.
|
||||
const hasCache = getCachedCatalog().length > 0;
|
||||
if (hasCache) url.searchParams.set('since', String(version));
|
||||
const source = url.origin;
|
||||
if (hasCache && getSetting('catalog_source_url') === source) url.searchParams.set('since', String(version));
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
||||
@@ -242,6 +319,7 @@ export async function syncCatalog(): Promise<{ ok: boolean; changed: boolean; ve
|
||||
const body = await res.json();
|
||||
|
||||
if (body && typeof body === 'object' && (body as Record<string, unknown>).unchanged === true) {
|
||||
setSetting('catalog_source_url', source);
|
||||
setSetting('catalog_last_sync_at', new Date().toISOString());
|
||||
setSetting('catalog_last_error', '');
|
||||
return { ok: true, changed: false, version };
|
||||
@@ -273,6 +351,7 @@ export async function syncCatalog(): Promise<{ ok: boolean; changed: boolean; ve
|
||||
|
||||
setSetting('catalog_payload', JSON.stringify(payload));
|
||||
setSetting('catalog_version', String(payload.version));
|
||||
setSetting('catalog_source_url', source);
|
||||
setSetting('catalog_updated_at', payload.updatedAt);
|
||||
setSetting('catalog_last_sync_at', new Date().toISOString());
|
||||
setSetting('catalog_last_error', suspicious.length > 0
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import { getSetting, setSetting, getInstruments, getRunnerHeartbeat, RUNNER_TIMEOUT_MS } from './db';
|
||||
import { getSetting, setSetting, getRunnerHeartbeat, RUNNER_TIMEOUT_MS } from './db';
|
||||
import { runTrade, startScheduler, stopScheduler, getSchedulerStatus } from './auto-trade';
|
||||
import { FIRMS, automationKey } from './automations';
|
||||
import { POINT_VALUES } from './trading-logic';
|
||||
import { CME_ROOTS, CME_SPECS } from './instrument-specs';
|
||||
import { futuresRootsHere } from './instruments';
|
||||
import { queueRun } from './queue-run';
|
||||
import { pushBackup } from './backup-sync';
|
||||
import { getBuildVersion } from './version';
|
||||
@@ -35,6 +36,8 @@ const RECONNECT_MAX_MS = 60_000;
|
||||
|
||||
interface LinkState {
|
||||
socket: WebSocket | null;
|
||||
/** The master_dashboard_url the socket was opened for. */
|
||||
url: string | null;
|
||||
/** Only true once the master has answered the hello. */
|
||||
ready: boolean;
|
||||
attempt: number;
|
||||
@@ -47,7 +50,7 @@ const _g = globalThis as typeof globalThis & { __masterLink?: LinkState };
|
||||
|
||||
function state(): LinkState {
|
||||
if (!_g.__masterLink) {
|
||||
_g.__masterLink = { socket: null, ready: false, attempt: 0, reconnectTimer: null, stopped: false };
|
||||
_g.__masterLink = { socket: null, url: null, ready: false, attempt: 0, reconnectTimer: null, stopped: false };
|
||||
}
|
||||
return _g.__masterLink;
|
||||
}
|
||||
@@ -56,16 +59,16 @@ function state(): LinkState {
|
||||
|
||||
/** What this instance can be asked to do, as of right now. */
|
||||
export function collectCapabilities(): InstanceCapabilities {
|
||||
// Only symbols the operator has enabled here. An empty instruments table
|
||||
// means nothing has been configured yet, so offer everything we can price.
|
||||
const enabled = getInstruments().filter((i) => i.enabled).map((i) => i.symbol);
|
||||
// The roots this machine's futures firms list (the master's per-firm
|
||||
// instrument lists); none listed yet means offer everything we can price.
|
||||
const listed = futuresRootsHere();
|
||||
|
||||
// Same test the AutoBuyer page uses: `at` is epoch ms from the last ping.
|
||||
const heartbeat = getRunnerHeartbeat();
|
||||
const runnerOnline = heartbeat !== null && Date.now() - heartbeat.at < RUNNER_TIMEOUT_MS;
|
||||
|
||||
return {
|
||||
symbols: enabled.length > 0 ? enabled : Object.keys(POINT_VALUES),
|
||||
symbols: listed.length > 0 ? listed : [...CME_ROOTS],
|
||||
automations: FIRMS.flatMap((firm) =>
|
||||
firm.automations
|
||||
.filter((a) => a.steps.length > 0)
|
||||
@@ -96,7 +99,7 @@ export function collectCapabilities(): InstanceCapabilities {
|
||||
async function execute(command: Command): Promise<unknown> {
|
||||
switch (command.kind) {
|
||||
case 'trade': {
|
||||
if (command.symbol !== 'Auto' && !POINT_VALUES[command.symbol]) {
|
||||
if (command.symbol !== 'Auto' && !CME_SPECS[command.symbol]) {
|
||||
throw new Error(`Unknown symbol: ${command.symbol}`);
|
||||
}
|
||||
// Same order as POST /api/trade: (re)start the scheduler first,
|
||||
@@ -163,8 +166,19 @@ function send(message: InstanceMessage): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** A changed master_dashboard_url drops the socket, and the reconnect dials
|
||||
* the new master — otherwise reports and commands would stay with the old
|
||||
* one until a restart, while the catalog followed the setting. */
|
||||
function followMasterUrl(): void {
|
||||
const s = state();
|
||||
if (!s.socket || s.url === getSetting('master_dashboard_url')) return;
|
||||
console.log('[master-link] master_dashboard_url changed — reconnecting');
|
||||
try { s.socket.close(1000, 'master changed'); } catch { /* the close handler reconnects */ }
|
||||
}
|
||||
|
||||
/** True when the report went up the socket, so the caller can skip the POST. */
|
||||
export function pushReport(firms: unknown[], scheduler: unknown): boolean {
|
||||
followMasterUrl();
|
||||
return send({
|
||||
type: 'report',
|
||||
firms: firms as never,
|
||||
@@ -224,6 +238,7 @@ function connect() {
|
||||
return;
|
||||
}
|
||||
s.socket = socket;
|
||||
s.url = masterUrl;
|
||||
s.ready = false;
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
|
||||
+118
-4
@@ -112,6 +112,68 @@ export interface FirmStats {
|
||||
/** Per account type, summing to the firm's own totals. Optional so a master
|
||||
* on an older build simply ignores it. */
|
||||
accountTypes?: AccountTypeStats[];
|
||||
/** DXtrade firms: one account's next leg sized on every symbol-map
|
||||
* candidate, best first — what the master's Firms page shows beside each
|
||||
* symbol. Optional. */
|
||||
symbolStats?: SymbolStats;
|
||||
/** Futures firms: each CME root's entry cost, movement, expected hold and
|
||||
* chance in the Auto draw right now. Optional. */
|
||||
instrumentStats?: InstrumentStats;
|
||||
}
|
||||
|
||||
/** A futures firm's instruments as the Auto draw sees them. */
|
||||
export interface InstrumentStats {
|
||||
/** ISO time computed. */
|
||||
at: string;
|
||||
/** Whose next leg the holds are sized for; null when no account has one. */
|
||||
account: string | null;
|
||||
leg: number | null;
|
||||
/** 0 at the session's open, 1 at its last entry; null outside it. */
|
||||
sessionProgress: number | null;
|
||||
/** Holds at or over this are disabled. */
|
||||
maxHoldHours: number;
|
||||
instruments: {
|
||||
symbol: string;
|
||||
listed: boolean;
|
||||
contract: string | null;
|
||||
/** One tick of spread on one contract, in dollars. */
|
||||
spreadDollars: number;
|
||||
/** One contract's typical daily move (1σ), in dollars. */
|
||||
dailyMoveDollars: number | null;
|
||||
holdHours: number | null;
|
||||
disabled: boolean;
|
||||
/** Share of Auto draws right now (0–1); null when not in the draw. */
|
||||
chance: number | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
/** How one account's next leg sizes on each candidate (see planLegSize). */
|
||||
export interface SymbolStats {
|
||||
account: string;
|
||||
/** The leg's dollar target, before fees and swap. */
|
||||
leg: number;
|
||||
targetHoldHours: number;
|
||||
/** The leg rides without a daily-loss stop (see lastStand). */
|
||||
lastStand: boolean;
|
||||
/** ISO time the stats were computed. */
|
||||
at: string;
|
||||
symbols: {
|
||||
symbol: string;
|
||||
notional: number;
|
||||
/** Notional ÷ account size. */
|
||||
multiplier: number;
|
||||
/** The broker's leverage on the symbol (5 = 5:1); null when unstated. */
|
||||
maxMultiplier: number | null;
|
||||
holdHours: number;
|
||||
/** Expected spread, commissions and swap. */
|
||||
cost: number;
|
||||
winProbability: number | null;
|
||||
/** The same leg and daily room with no commission, spread or swap —
|
||||
* what the costs take off the win chance. Optional. */
|
||||
winProbabilityNoFees?: number | null;
|
||||
volatilityPerDay: number;
|
||||
spreadFraction: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface SchedulerState {
|
||||
@@ -205,19 +267,71 @@ export interface CatalogAccountType {
|
||||
payoutCap?: number;
|
||||
/** Share of the gross the person actually receives (default 0.9). */
|
||||
profitSplit?: number;
|
||||
/** Hard daily loss limit in dollars (0 = none). Every leg then carries a
|
||||
* stop that keeps the firm's day from losing more than this, less a 10%
|
||||
* reserve and fees. Optional: an older master doesn't send it. */
|
||||
dailyLossLimit?: number;
|
||||
/** Copied from the firm by the catalog sync, so every cfg-based path sees
|
||||
* it: the firm trades every calendar day (24/7). */
|
||||
tradesEveryDay?: boolean;
|
||||
/** ISO. Ties between two matching account types are broken by this. */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Where a firm's accounts trade. A build only knows the platforms listed
|
||||
* here; the catalog sync skips a firm on any other rather than guess. */
|
||||
export const FIRM_PLATFORMS = ['tradovate', 'topstepx', 'dxtrade'] as const;
|
||||
export type FirmPlatform = (typeof FIRM_PLATFORMS)[number];
|
||||
|
||||
export function isFirmPlatform(value: unknown): value is FirmPlatform {
|
||||
return typeof value === 'string' && (FIRM_PLATFORMS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/** One symbol a DXTrade firm may trade for a CME root. Position size comes
|
||||
* from volatility and the firm's target hold, and the commission is the
|
||||
* firm's (an older master's notionalPerContract or per-symbol commissionPct
|
||||
* is ignored). */
|
||||
export interface SymbolMapCandidate {
|
||||
/** The platform's symbol, e.g. "BTC/USD". */
|
||||
symbol: string;
|
||||
}
|
||||
|
||||
/** CME root the batch drew (NQ, MNQ, GC…) or '*' for any root not listed →
|
||||
* the candidates to choose from, cheapest per unit of volatility wins. */
|
||||
export type SymbolMap = Record<string, SymbolMapCandidate[]>;
|
||||
|
||||
export interface CatalogFirm {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Which platform the firm's accounts trade on. Only 'tradovate' firms get
|
||||
* a client here; anything else is synced but sits idle until its trading
|
||||
* logic exists. Older masters omit the field — treated as 'tradovate'. */
|
||||
platform: 'tradovate' | 'topstepx';
|
||||
/** Which platform the firm's accounts trade on. Older masters omit the
|
||||
* field — treated as 'tradovate'; a value this build doesn't know makes
|
||||
* the sync skip the firm. */
|
||||
platform: FirmPlatform;
|
||||
/** What the firm trades, in order: CME roots for futures firms, the firm's
|
||||
* own symbols for DXtrade (see lib/instruments.ts). Absent from a master
|
||||
* from before the lists — then bannedSymbols and symbolMap apply. */
|
||||
instruments?: string[];
|
||||
/** Older masters' way to exclude roots; newer ones derive it from
|
||||
* `instruments`. */
|
||||
bannedSymbols: string[];
|
||||
accountTypes: CatalogAccountType[];
|
||||
/** DXTrade REST base, e.g. https://dx.tradeify247.co/dxsca-web ('' otherwise). */
|
||||
serverUrl?: string;
|
||||
/** DXTrade login domain ('' = 'default'). */
|
||||
loginDomain?: string;
|
||||
/** Commission as % of notional per side (e.g. 0.04). 0 = the platform's
|
||||
* per-contract fees, measured off each fill. */
|
||||
commissionPct?: number;
|
||||
/** Trades every calendar day, weekends and CME holidays included. */
|
||||
tradesEveryDay?: boolean;
|
||||
/** DXTrade, older masters: which symbols each CME root may trade as.
|
||||
* Newer ones derive it from `instruments`. */
|
||||
symbolMap?: SymbolMap;
|
||||
/** DXTrade sizing: aim each leg's expected hold at this many hours
|
||||
* (0 = DEFAULT_TARGET_HOLD_HOURS, 12). */
|
||||
targetHoldHours?: number;
|
||||
/** Overnight financing the firm charges, % of notional per day (0 = none). */
|
||||
financingPctPerDay?: number;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
|
||||
+52
-8
@@ -4,29 +4,60 @@
|
||||
* reporter, state route and resolver can drive either platform without caring
|
||||
* which is behind the map entry.
|
||||
*
|
||||
* Contract ids are `number` on Tradovate and `string` (e.g.
|
||||
* "CON.F.US.ENQ.Z25") on TopstepX — everything that compares them only ever
|
||||
* compares within one client, so the union is safe.
|
||||
* Contract ids are `number` on Tradovate and `string` on TopstepX (e.g.
|
||||
* "CON.F.US.ENQ.Z25") and DXTrade (the symbol, e.g. "BTC/USD") — everything
|
||||
* that compares them only ever compares within one client, so the union is
|
||||
* safe.
|
||||
*/
|
||||
|
||||
import type { FirmPlatform } from './master-protocol';
|
||||
|
||||
export interface PlatformAccount {
|
||||
id: number;
|
||||
name: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/** How the batch's leg would size on a candidate instrument (see planLegSize):
|
||||
* null when it can't be sized there. */
|
||||
export interface PickContext {
|
||||
targetHoldHours: number;
|
||||
evaluate(contract: PlatformContract): { notional: number; holdHours: number; cost: number } | null;
|
||||
}
|
||||
|
||||
export interface PlatformContract {
|
||||
id: number | string;
|
||||
name: string;
|
||||
tickSize: number;
|
||||
/** Dollars per 1.0 of price per engine contract. Absent = the CME
|
||||
* product's POINT_VALUES entry (futures). */
|
||||
pointValue?: number;
|
||||
/** Commission as % of notional per side, where the firm charges that way. */
|
||||
commissionPct?: number;
|
||||
/** Notional of one engine contract at the current mid — present where the
|
||||
* platform prices positions, which is what enables notional sizing. */
|
||||
stepNotional?: number;
|
||||
/** σ of log returns per √hour, from recent candles. */
|
||||
volatilityPerHour?: number;
|
||||
/** (ask − bid) / mid when the contract was picked. */
|
||||
spreadFraction?: number;
|
||||
/** Largest order the broker takes, in engine contracts. */
|
||||
maxContracts?: number;
|
||||
/** Smallest order the broker takes, in engine contracts. */
|
||||
minContracts?: number;
|
||||
/** Margin posted per dollar of notional (0.2 = 5:1 leverage), where the
|
||||
* broker says — caps a position at available funds ÷ this. */
|
||||
marginRate?: number;
|
||||
}
|
||||
|
||||
export interface PlatformClient {
|
||||
accountList: PlatformAccount[];
|
||||
/** realizedPnL is today's. pendingRoll (Tradovate): the broker still shows
|
||||
* the ended day's record and today's share of it can't be told apart —
|
||||
* the account waits for the broker to roll it. */
|
||||
accountCashBalances: { [accountId: number]: { amount: number; realizedPnL: number; pendingRoll?: boolean } };
|
||||
* the account waits for the broker to roll it. equity: balance plus open
|
||||
* P&L, where the platform reports it; availableFunds: what margin can
|
||||
* still draw on, where the platform reports it. */
|
||||
accountCashBalances: { [accountId: number]: { amount: number; realizedPnL: number; pendingRoll?: boolean; equity?: number; availableFunds?: number } };
|
||||
positions: {
|
||||
[accountId: number]: {
|
||||
contractId: number | string;
|
||||
@@ -69,10 +100,22 @@ export interface PlatformClient {
|
||||
* read as a fresh stage-1 cycle, so such an account must not trade.
|
||||
*/
|
||||
historyReady(accountId: number): boolean;
|
||||
/**
|
||||
* The account's closed balance at the firm's last daily reset — what a
|
||||
* hard daily loss limit is measured from. Only platforms that know the
|
||||
* firm's own day implement it (DXTrade: 22:00 UTC); the rest leave it out
|
||||
* and the engine falls back to the session's realized P&L. null = the
|
||||
* platform knows it but can't tell yet (history not fetched): hold the
|
||||
* account rather than size a stop on a guess.
|
||||
*/
|
||||
dayStartBalance?(accountId: number): number | null;
|
||||
/** Drop the account's history from memory (the wipe routes), so it reads
|
||||
* unloaded until the next fetch rebuilds it. */
|
||||
forgetHistory(accountId: number, accountName: string): void;
|
||||
findFrontMonthContract(productName: string): Promise<PlatformContract | null>;
|
||||
/** The contract a product trades as. A platform that chooses between
|
||||
* instruments (DXTrade's symbol map) ranks them with `pick` when given —
|
||||
* how a leg would size on each — and platforms that don't, ignore it. */
|
||||
findFrontMonthContract(productName: string, pick?: PickContext): Promise<PlatformContract | null>;
|
||||
fetchInstrumentFees(symbols: string[]): Promise<{ [symbol: string]: number }>;
|
||||
|
||||
sendOrder(
|
||||
@@ -113,10 +156,11 @@ export interface PlatformClient {
|
||||
* Timestamps are epoch ms, 0 = never. Never carries tokens or credentials.
|
||||
*/
|
||||
export interface ClientHealth {
|
||||
platform: 'tradovate' | 'topstepx';
|
||||
platform: FirmPlatform;
|
||||
/** Holding a session token right now. */
|
||||
authenticated: boolean;
|
||||
/** Tradovate: socket readyState name; TopstepX: hub state. 'none' = no transport. */
|
||||
/** Tradovate: socket readyState name; TopstepX: hub state; DXTrade: 'rest'
|
||||
* or its push socket's state. 'none' = no transport. */
|
||||
transport: string;
|
||||
syncComplete: boolean;
|
||||
lastSyncAt: number;
|
||||
|
||||
+92
-7
@@ -18,8 +18,10 @@ import { getClients } from './clients';
|
||||
import { getTradableFirms } from './firm-catalog';
|
||||
import { getAccountConfig } from './account-type-resolver';
|
||||
import { getSchedulerStatus } from './auto-trade';
|
||||
import { combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig } from './trading-logic';
|
||||
import type { AccountTypeStats, FirmStats } from './master-protocol';
|
||||
import { botStopsActive, combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig, DEFAULT_TARGET_HOLD_HOURS } from './trading-logic';
|
||||
import type { AccountTypeStats, FirmStats, InstrumentStats, SymbolStats } from './master-protocol';
|
||||
import { accountNextLeg, accountLegShape } from './leg-estimate';
|
||||
import { instrumentStatsFor, isFuturesFirm } from './instruments';
|
||||
import { historyFresh } from './platform-client';
|
||||
|
||||
function collectFirmStats() {
|
||||
@@ -288,6 +290,7 @@ function collectFirmStats() {
|
||||
cycleDailyPnL: client.dailyPnL[acc.id],
|
||||
rawRealizedPnL: cash.realizedPnL,
|
||||
combineCmeHolidays: cfg.combineCmeHolidays,
|
||||
tradesEveryDay: cfg.tradesEveryDay === true,
|
||||
effective,
|
||||
equityProfit: cash.amount - cfg.accountSize,
|
||||
splitEnabled: cfg.splitEnabled,
|
||||
@@ -349,11 +352,12 @@ function collectFirmStats() {
|
||||
slice.accountsTraded++;
|
||||
// A grind-day fill (dayTarget 0) can flag targetHit on a
|
||||
// negative day — a loss is still a loss, so red wins.
|
||||
// A red day on a stop-enabled account is its own personal
|
||||
// DLL, booked on purpose — not a genuine loss. (A firm DLL
|
||||
// lockout took the !acc.active branch above and stays a
|
||||
// loss.)
|
||||
if ((cfg.firstDayStop || cfg.minDayStop) && combined.realizedPnL <= -25) {
|
||||
// A red day on an account the bot puts stops on (the
|
||||
// obfuscation stops, or a hard daily loss limit) is its own
|
||||
// personal DLL, booked on purpose — not a genuine loss. (A
|
||||
// firm DLL lockout took the !acc.active branch above and
|
||||
// stays a loss.)
|
||||
if (botStopsActive(cfg) && combined.realizedPnL <= -25) {
|
||||
personalDllCount++;
|
||||
slice.personalDll = (slice.personalDll ?? 0) + 1;
|
||||
} else if (combined.realizedPnL < 0) {
|
||||
@@ -385,10 +389,91 @@ function collectFirmStats() {
|
||||
// Unknown until the firm has synced, not empty.
|
||||
...(client.syncComplete ? { accounts } : {}),
|
||||
accountTypes,
|
||||
...symbolStatsFor(f, client, rawToday),
|
||||
...instrumentStatsBlock(f, client, rawToday),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** The live accounts of a firm's largest prefix group first — the group a
|
||||
* batch fires — each with an account type. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function leadCandidates(client: any): { id: number; name: string }[] {
|
||||
const groups = new Map<string, { id: number; name: string }[]>();
|
||||
for (const acc of client.accountList as { id: number; name: string; active: boolean }[]) {
|
||||
const cfg = getAccountConfig(acc.id).accountType;
|
||||
const floor = client.autoLiqThresholds?.[acc.id] ?? 0;
|
||||
const cash = client.accountCashBalances?.[acc.id];
|
||||
if (!cfg || !acc.active || !cash || (floor !== 0 && cash.amount <= floor)) continue;
|
||||
groups.set(cfg.prefix, [...(groups.get(cfg.prefix) ?? []), acc]);
|
||||
}
|
||||
return [...groups.values()].sort((a, b) => b.length - a.length).flat();
|
||||
}
|
||||
|
||||
/**
|
||||
* A futures firm's instruments as the Auto draw sees them, for the master's
|
||||
* Firms page: entry cost, movement and chance for each, with holds sized for
|
||||
* the next leg of the first live account (largest prefix group) that has one.
|
||||
*/
|
||||
function instrumentStatsBlock(
|
||||
f: { id: string; name: string; platform: string; instruments?: string[]; bannedSymbols?: string[] },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client: any,
|
||||
tradeDate: string,
|
||||
): { instrumentStats?: InstrumentStats } {
|
||||
if (!isFuturesFirm(f)) return {};
|
||||
let lead: { account: string; leg: number; stopDollars: number; maxPositionSize: number } | null = null;
|
||||
for (const acc of leadCandidates(client)) {
|
||||
const shape = accountLegShape(client, acc, tradeDate);
|
||||
if (shape) { lead = { ...shape, account: acc.name }; break; }
|
||||
}
|
||||
return { instrumentStats: instrumentStatsFor(f, lead) };
|
||||
}
|
||||
|
||||
/**
|
||||
* A DXtrade firm's next leg on each symbol-map candidate, for the master's
|
||||
* Firms page: one live account from the firm's largest prefix group — the
|
||||
* group a batch fires — with something left to trade today. Nothing for
|
||||
* other platforms, or when no account has a leg to size.
|
||||
*/
|
||||
function symbolStatsFor(
|
||||
f: { name: string; commissionPct?: number; targetHoldHours?: number; financingPctPerDay?: number },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client: any,
|
||||
tradeDate: string,
|
||||
): { symbolStats?: SymbolStats } {
|
||||
if (!(client.lastCandidates?.length > 0)) return {};
|
||||
const groups = new Map<string, { id: number; name: string }[]>();
|
||||
for (const acc of client.accountList as { id: number; name: string; active: boolean }[]) {
|
||||
const cfg = getAccountConfig(acc.id).accountType;
|
||||
const floor = client.autoLiqThresholds?.[acc.id] ?? 0;
|
||||
const cash = client.accountCashBalances?.[acc.id];
|
||||
if (!cfg || !acc.active || !cash || (floor !== 0 && cash.amount <= floor)) continue;
|
||||
groups.set(cfg.prefix, [...(groups.get(cfg.prefix) ?? []), acc]);
|
||||
}
|
||||
for (const group of [...groups.values()].sort((a, b) => b.length - a.length)) {
|
||||
for (const acc of group) {
|
||||
const next = accountNextLeg(client, acc, f, tradeDate);
|
||||
if (!next || next.candidates.length === 0) continue;
|
||||
return {
|
||||
symbolStats: {
|
||||
account: acc.name,
|
||||
leg: next.leg,
|
||||
targetHoldHours: f.targetHoldHours || DEFAULT_TARGET_HOLD_HOURS,
|
||||
lastStand: next.lastStand,
|
||||
at: new Date().toISOString(),
|
||||
symbols: next.candidates.map((c) => ({
|
||||
symbol: c.symbol, notional: c.notional, multiplier: c.multiplier, maxMultiplier: c.maxMultiplier,
|
||||
holdHours: c.holdHours, cost: c.cost, winProbability: c.winProbability, winProbabilityNoFees: c.winProbabilityNoFees,
|
||||
volatilityPerDay: c.volatilityPerDay, spreadFraction: c.spreadFraction,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// HMR-safe global to avoid duplicate intervals
|
||||
const _g = globalThis as typeof globalThis & { __reporterInterval?: ReturnType<typeof setInterval> };
|
||||
|
||||
|
||||
+591
-30
@@ -366,13 +366,22 @@ export interface ProjectedDay {
|
||||
net?: number;
|
||||
}
|
||||
|
||||
/** Next weekday after d (YYYY-MM-DD). Trade dates never fall on Sat/Sun. */
|
||||
function nextTradingDate(d: string): string {
|
||||
/** Next trade date after d (YYYY-MM-DD): the next weekday on the CME calendar,
|
||||
* where trade dates never fall on Sat/Sun — or simply the next calendar day
|
||||
* for a firm that trades every day (24/7 crypto/CFD, e.g. Tradeify247). */
|
||||
export function nextTradingDate(d: string, everyDay = false): string {
|
||||
const dt = new Date(`${d}T00:00:00Z`);
|
||||
do { dt.setUTCDate(dt.getUTCDate() + 1); } while (dt.getUTCDay() === 0 || dt.getUTCDay() === 6);
|
||||
do { dt.setUTCDate(dt.getUTCDate() + 1); } while (!everyDay && (dt.getUTCDay() === 0 || dt.getUTCDay() === 6));
|
||||
return dt.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Whether CME holiday sessions fold into the next day for this account type.
|
||||
* Never for an every-day firm: it has no CME calendar to fold by. The catalog
|
||||
* sync already clears the flag for those firms; this is the backstop. */
|
||||
export function effectiveCombine(cfg: { combineCmeHolidays?: boolean; tradesEveryDay?: boolean }): boolean {
|
||||
return cfg.combineCmeHolidays === true && cfg.tradesEveryDay !== true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate the account's future assuming every day goes perfectly: each
|
||||
* trading day books exactly its planned target (deterministic bumps included,
|
||||
@@ -399,6 +408,9 @@ export function projectPerfectPath(opts: {
|
||||
* (withdraw, reset, re-target). False = it just PASSES once (an
|
||||
* eval) and the projection stops. */
|
||||
payoutable?: boolean;
|
||||
/** The firm trades every calendar day (24/7): weekends and CME
|
||||
* holidays are ordinary projected days, and nothing combines. */
|
||||
tradesEveryDay?: boolean;
|
||||
};
|
||||
/** Current-cycle daily P&L (account.dailyPnL). */
|
||||
cycleDailyPnL: { date: string; pnl: number }[];
|
||||
@@ -424,6 +436,8 @@ export function projectPerfectPath(opts: {
|
||||
* so today's room is exact and only tightens going forward. */
|
||||
startThreshold?: number;
|
||||
};
|
||||
/** Defaults to the current time. */
|
||||
now?: Date;
|
||||
}): ProjectedDay[] {
|
||||
const { accountId, cfg } = opts;
|
||||
const horizon = opts.horizonDays ?? 40;
|
||||
@@ -432,30 +446,33 @@ export function projectPerfectPath(opts: {
|
||||
const baseCap = opts.payout?.payoutCap ?? 0;
|
||||
const profitSplit = opts.payout?.profitSplit ?? 0.9;
|
||||
const dd = Math.max(0, opts.payout?.drawdown ?? 0);
|
||||
const everyDay = cfg.tradesEveryDay === true;
|
||||
|
||||
const rawToday = currentTradeDateCT();
|
||||
const rawToday = currentTradeDateCT(opts.now);
|
||||
// The firm's own day view: a combining firm folds holiday-session P&L
|
||||
// into its clearing date — a Labor-Day trade and the Tuesday after it are
|
||||
// ONE trading day, exactly as the firm counts min days. Identity view
|
||||
// when combining is off.
|
||||
const combine = cfg.combineCmeHolidays === true;
|
||||
const combine = effectiveCombine(cfg);
|
||||
const view = combineCmeSessions(combine, opts.cycleDailyPnL, rawToday, opts.realizedPnL);
|
||||
// A weekend "today" (Saturday, or Sunday before the evening open) has no
|
||||
// session — the platform's realizedPnL is the LAST session's stale number,
|
||||
// already booked in cycleDailyPnL under its own date. Adding it as a
|
||||
// today row would fabricate an extra qualifying day.
|
||||
// today row would fabricate an extra qualifying day. An every-day firm
|
||||
// trades weekends, so its weekend today is a live session like any other.
|
||||
const todayDow = new Date(`${rawToday}T00:00:00Z`).getUTCDay();
|
||||
const todayHasSession = todayDow !== 0 && todayDow !== 6;
|
||||
const todayHasSession = everyDay || (todayDow !== 0 && todayDow !== 6);
|
||||
// Today's clearing date when a session exists; on weekends keep the raw
|
||||
// date so the projection's first day is Monday, not the day after it
|
||||
// (combinedTradeDate rolls weekend dates forward).
|
||||
const today = todayHasSession ? view.tradeDate : rawToday;
|
||||
let sim = view.dailyPnL.filter((d) => d.date !== today);
|
||||
if (todayHasSession && view.realizedPnL !== 0) sim = [...sim, { date: today, pnl: view.realizedPnL }];
|
||||
/** Next date the firm settles a session on: weekends always skip, and a
|
||||
* combining firm's holiday sessions clear into the next full day. */
|
||||
/** Next date the firm settles a session on: weekends skip (except for an
|
||||
* every-day firm), and a combining firm's holiday sessions clear into
|
||||
* the next full day. */
|
||||
const nextDay = (d: string): string => {
|
||||
let n = nextTradingDate(d);
|
||||
let n = nextTradingDate(d, everyDay);
|
||||
while (combine && combinedTradeDate(n) !== n) n = nextTradingDate(n);
|
||||
return n;
|
||||
};
|
||||
@@ -662,12 +679,18 @@ function cycleOwesNothing(
|
||||
* weekend (it is the last session's stale number, already booked under its
|
||||
* own date), a Monday with a weekend-dated row — the live session filed under
|
||||
* a weekend date, which would count it twice — and the first minutes after
|
||||
* the 5 PM roll. */
|
||||
function sessionDoubtful(rawTradeDate: string, cycleDailyPnL: { date: string; pnl: number }[], now: Date): boolean {
|
||||
* the 5 PM roll. An every-day firm's weekends are real sessions, so only the
|
||||
* roll window applies to it. */
|
||||
function sessionDoubtful(
|
||||
rawTradeDate: string,
|
||||
cycleDailyPnL: { date: string; pnl: number }[],
|
||||
now: Date,
|
||||
everyDay = false,
|
||||
): boolean {
|
||||
const day = new Date(`${rawTradeDate}T00:00:00Z`);
|
||||
const dow = day.getUTCDay();
|
||||
if (dow === 0 || dow === 6) return true;
|
||||
if (dow === 1) {
|
||||
if (!everyDay && (dow === 0 || dow === 6)) return true;
|
||||
if (!everyDay && dow === 1) {
|
||||
const sunday = fmtUTC(new Date(day.getTime() - 24 * 60 * 60 * 1000));
|
||||
const saturday = fmtUTC(new Date(day.getTime() - 2 * 24 * 60 * 60 * 1000));
|
||||
if (cycleDailyPnL.some((d) => d.date === saturday || d.date === sunday)) return true;
|
||||
@@ -698,6 +721,8 @@ export function cycleCompletion(opts: {
|
||||
cycleDailyPnL: { date: string; pnl: number }[];
|
||||
rawRealizedPnL: number;
|
||||
combineCmeHolidays: boolean;
|
||||
/** The firm trades every calendar day — weekends are real sessions. */
|
||||
tradesEveryDay?: boolean;
|
||||
effective: EffectiveConfig;
|
||||
/** amount − accountSize — today's P&L included. */
|
||||
equityProfit: number;
|
||||
@@ -706,14 +731,18 @@ export function cycleCompletion(opts: {
|
||||
now?: Date;
|
||||
}): CycleCompletion {
|
||||
const { accountId, rawTradeDate, effective: eff, equityProfit, splitEnabled } = opts;
|
||||
const view = combineCmeSessions(opts.combineCmeHolidays, opts.cycleDailyPnL, rawTradeDate, opts.rawRealizedPnL);
|
||||
const everyDay = opts.tradesEveryDay === true;
|
||||
const view = combineCmeSessions(
|
||||
effectiveCombine({ combineCmeHolidays: opts.combineCmeHolidays, tradesEveryDay: everyDay }),
|
||||
opts.cycleDailyPnL, rawTradeDate, opts.rawRealizedPnL,
|
||||
);
|
||||
const prior = view.dailyPnL.filter((d) => d.date !== view.tradeDate);
|
||||
|
||||
if (cycleOwesNothing(accountId, eff, prior, view.tradeDate, equityProfit, view.realizedPnL, splitEnabled)) return 'before';
|
||||
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date())) return 'none';
|
||||
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay)) return 'none';
|
||||
|
||||
const withToday = [...prior, { date: view.tradeDate, pnl: view.realizedPnL }];
|
||||
const probe = nextTradingDate(view.tradeDate);
|
||||
const probe = nextTradingDate(view.tradeDate, everyDay);
|
||||
if (view.realizedPnL !== 0 && cycleOwesNothing(accountId, eff, withToday, probe, equityProfit, 0, splitEnabled)) return 'today';
|
||||
return 'none';
|
||||
}
|
||||
@@ -764,15 +793,18 @@ export function endedCycleCompletion(opts: {
|
||||
profitTarget: number; consistency: number; minTradingDays: number; minDayPnL: number;
|
||||
targetSameEquity: boolean; withdrawalStages: WithdrawalStage[];
|
||||
combineCmeHolidays: boolean; splitEnabled: boolean;
|
||||
tradesEveryDay?: boolean;
|
||||
};
|
||||
/** Defaults to the current time. */
|
||||
now?: Date;
|
||||
}): { date: string; stage: number; drain: boolean; cycle: string } | null {
|
||||
const { accountId, rawTradeDate, lastFundDate, endedCycleStart: start, fundTransactions, cfg } = opts;
|
||||
const everyDay = cfg.tradesEveryDay === true;
|
||||
const combine = effectiveCombine(cfg);
|
||||
if (lastFundDate === null || start === null) return null;
|
||||
const ageDays = (Date.parse(`${rawTradeDate}T00:00:00Z`) - Date.parse(`${lastFundDate}T00:00:00Z`)) / (24 * 60 * 60 * 1000);
|
||||
if (!(ageDays <= ENDED_CYCLE_WINDOW_DAYS)) return null; // NaN included: an unparseable date
|
||||
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date())) return null;
|
||||
if (sessionDoubtful(rawTradeDate, opts.cycleDailyPnL, opts.now ?? new Date(), everyDay)) return null;
|
||||
|
||||
const latest = (a: string, b: string) => (b > a ? b : a);
|
||||
const earlierFunds = fundTransactions.filter((f) => f.date < lastFundDate);
|
||||
@@ -789,7 +821,7 @@ export function endedCycleCompletion(opts: {
|
||||
// Equity the ended cycle finished on, worked back from live equity: less
|
||||
// everything the current cycle booked (today's live P&L included) and
|
||||
// less the fund transaction between the two.
|
||||
const cur = combineCmeSessions(cfg.combineCmeHolidays, opts.cycleDailyPnL, rawTradeDate, opts.rawRealizedPnL);
|
||||
const cur = combineCmeSessions(combine, opts.cycleDailyPnL, rawTradeDate, opts.rawRealizedPnL);
|
||||
const curTotal = cur.dailyPnL.filter((d) => d.date !== cur.tradeDate).reduce((s, d) => s + d.pnl, 0)
|
||||
+ cur.realizedPnL;
|
||||
const fundAmt = fundTransactions.filter((f) => f.date === lastFundDate).reduce((s, f) => s + f.amount, 0);
|
||||
@@ -800,11 +832,11 @@ export function endedCycleCompletion(opts: {
|
||||
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity,
|
||||
cfg.withdrawalStages, 0, earlierFunds, cfg.minDayPnL,
|
||||
);
|
||||
const rows = combineCmeSessions(cfg.combineCmeHolidays, endedRaw, lastFundDate, 0).dailyPnL;
|
||||
const rows = combineCmeSessions(combine, endedRaw, lastFundDate, 0).dailyPnL;
|
||||
// A combining firm's folded day can net to 0 — nothing traded, as the firm counts it.
|
||||
const traded = rows.filter((d) => d.pnl !== 0);
|
||||
if (traded.length === 0) return null;
|
||||
const probe = nextTradingDate(rows.map((d) => d.date).reduce(latest));
|
||||
const probe = nextTradingDate(rows.map((d) => d.date).reduce(latest), everyDay);
|
||||
if (!cycleOwesNothing(accountId, eff, rows, probe, equityBefore, 0, cfg.splitEnabled)) return null;
|
||||
|
||||
return {
|
||||
@@ -815,15 +847,6 @@ export function endedCycleCompletion(opts: {
|
||||
};
|
||||
}
|
||||
|
||||
/** Dollar-per-point value for common futures products. */
|
||||
export const POINT_VALUES: { [symbol: string]: number } = {
|
||||
NQ: 20, MNQ: 2, ES: 50, MES: 5,
|
||||
YM: 5, MYM: 0.5, RTY: 50, M2K: 5,
|
||||
GC: 100, MGC: 10, SI: 5000, CL: 1000,
|
||||
MCL: 100, NG: 10000, ZB: 1000, ZN: 1000,
|
||||
ZF: 1000, '6E': 125000, '6J': 12500000, '6B': 62500,
|
||||
};
|
||||
|
||||
/**
|
||||
* Compute the next trading day's profit target for an account.
|
||||
*
|
||||
@@ -1040,3 +1063,541 @@ export function computeDayPlan(
|
||||
splitAllowed,
|
||||
};
|
||||
}
|
||||
|
||||
// ── fees ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** An entry's fee when the balance update hasn't shown it yet — at least one
|
||||
* extra tick on the exit. */
|
||||
export const FALLBACK_COMMISSION_PER_CONTRACT = 2.5;
|
||||
/** No real per-contract entry fee comes near this. Also what a daily-loss
|
||||
* stop reserves per contract and side when the fee couldn't be measured —
|
||||
* the $2.50 fallback is fine for a take-profit, but it would under-reserve
|
||||
* a hard stop on NQ-class fees. */
|
||||
export const MAX_COMMISSION_PER_CONTRACT = 10;
|
||||
|
||||
/** One side's commission on a percent-of-notional firm (e.g. Tradeify247's
|
||||
* 0.04%), rounded UP to the cent so every consumer errs toward paying more.
|
||||
* pointValue × contracts is the position's dollars per 1.0 of price, so
|
||||
* price × that is its notional. */
|
||||
export function percentFee(pct: number, price: number, pointValue: number, contracts: number): number {
|
||||
if (!(pct > 0)) return 0;
|
||||
const raw = (pct / 100) * Math.abs(price) * pointValue * contracts;
|
||||
return Math.ceil(raw * 100 - 1e-9) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to put the take-profit so the leg nets at least `leg` after fees.
|
||||
*
|
||||
* Measured-fee firms (commissionPct 0): the exit fee is taken to equal the
|
||||
* entry fee — the same formula the fire path always used, reproduced exactly.
|
||||
*
|
||||
* Percent-fee firms: the exit fee depends on the exit price itself, so solve
|
||||
* for it. With r = pct/100 and k = pointValue × contracts, a long nets
|
||||
* (X − entry)·k − entryFee − r·X·k at exit price X, which reaches `leg` at
|
||||
* X = ((leg + entryFee)/k + entry) / (1 − r); a short mirrors it at
|
||||
* X = (entry − (leg + entryFee)/k) / (1 + r). Rounded out to whole ticks and
|
||||
* re-checked with the cent-rounded exit fee.
|
||||
*/
|
||||
export function takeProfitPlan(o: {
|
||||
side: 'Buy' | 'Sell';
|
||||
entry: number;
|
||||
leg: number;
|
||||
pointValue: number;
|
||||
contracts: number;
|
||||
tickSize: number;
|
||||
commissionPct: number;
|
||||
/** The entry fee: percentFee() on percent firms, the measured one otherwise. */
|
||||
entryCommission: number;
|
||||
}): { exitPrice: number; ticks: number; entryFee: number; exitFee: number; totalCommission: number; grossTarget: number } {
|
||||
const k = o.pointValue * o.contracts;
|
||||
const dir = o.side === 'Buy' ? 1 : -1;
|
||||
if (!(o.commissionPct > 0)) {
|
||||
const totalCommission = o.entryCommission * 2; // entry + exit round-trip
|
||||
const grossTarget = o.leg + totalCommission;
|
||||
const ticks = Math.ceil(grossTarget / k / o.tickSize);
|
||||
return {
|
||||
exitPrice: o.entry + dir * ticks * o.tickSize,
|
||||
ticks,
|
||||
entryFee: o.entryCommission,
|
||||
exitFee: o.entryCommission,
|
||||
totalCommission,
|
||||
grossTarget,
|
||||
};
|
||||
}
|
||||
|
||||
const r = o.commissionPct / 100;
|
||||
const need = (o.leg + o.entryCommission) / k;
|
||||
const target = o.side === 'Buy' ? (need + o.entry) / (1 - r) : (o.entry - need) / (1 + r);
|
||||
let ticks = Math.max(1, Math.ceil(Math.abs(target - o.entry) / o.tickSize - 1e-9));
|
||||
const priceAt = (t: number) => Math.round((o.entry + dir * t * o.tickSize) * 1e10) / 1e10;
|
||||
const netAt = (t: number) => t * o.tickSize * k - o.entryCommission - percentFee(o.commissionPct, priceAt(t), o.pointValue, o.contracts);
|
||||
// Cent rounding on the fees can leave the closed form short — by several
|
||||
// ticks when a tick is worth a fraction of a cent. Step out by the shortfall.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const short = o.leg - netAt(ticks);
|
||||
if (short <= 1e-9) break;
|
||||
ticks += Math.max(1, Math.ceil(short / (o.tickSize * k * (1 - r)) - 1e-9));
|
||||
}
|
||||
const exitPrice = priceAt(ticks);
|
||||
const exitFee = percentFee(o.commissionPct, exitPrice, o.pointValue, o.contracts);
|
||||
const totalCommission = Math.round((o.entryCommission + exitFee) * 100) / 100;
|
||||
return { exitPrice, ticks, entryFee: o.entryCommission, exitFee, totalCommission, grossTarget: round2(o.leg + totalCommission) };
|
||||
}
|
||||
|
||||
// ── stops ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Obfuscation stop for the current leg, in dollars. Infinity = no stop.
|
||||
* Day 1 of a cycle carries the $1,000 account stop (firstDayStop), min-day
|
||||
* (small) trades a 2×-target stop (minDayStop) — and the day after a losing
|
||||
* day always rides stopless, the make-back day. A hard daily loss limit is
|
||||
* separate (dllStopDollars) and never rides stopless. */
|
||||
export function obfuscationStopDollars(
|
||||
cfg: { firstDayStop: boolean; minDayStop: boolean },
|
||||
dailyPnL: { date: string; pnl: number }[],
|
||||
plan: DayPlan,
|
||||
tradeDate: string,
|
||||
legAmount: number,
|
||||
): number {
|
||||
if (!cfg.firstDayStop && !cfg.minDayStop) return Infinity;
|
||||
const prior = dailyPnL
|
||||
.filter((d) => d.date < tradeDate && d.pnl !== 0)
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
if (prior.length > 0 && prior[prior.length - 1].pnl < 0) return Infinity;
|
||||
let stop = Infinity;
|
||||
const realizedToday = Math.round((plan.dayTarget - plan.remainingToday) * 100) / 100;
|
||||
if (cfg.firstDayStop && prior.length === 0) stop = Math.max(0, FIRST_DAY_STOP + realizedToday);
|
||||
if (cfg.minDayStop && plan.dayTarget <= BIG_DAY_THRESHOLD) stop = Math.min(stop, MIN_DAY_STOP_MULT * legAmount);
|
||||
return stop;
|
||||
}
|
||||
|
||||
/** Share of a hard daily loss limit the bot never spends — room for a stop
|
||||
* that fills worse than its price (gaps, thin weekend books). */
|
||||
export const DLL_RESERVE_FRACTION = 0.10;
|
||||
/** …or this share of the position's notional, when that is more: a stop's
|
||||
* slippage grows with the position, a flat reserve doesn't. */
|
||||
export const DLL_SLIPPAGE_FRACTION = 0.001;
|
||||
|
||||
/** The part of the day's room a stop leaves unspent. */
|
||||
export function dllReserve(dailyLossLimit: number, notional = 0): number {
|
||||
return Math.max(DLL_RESERVE_FRACTION * dailyLossLimit, DLL_SLIPPAGE_FRACTION * Math.max(0, notional));
|
||||
}
|
||||
/** Smallest daily-loss stop worth trading. Under a fair walk a leg of L with a
|
||||
* stop of S wins S/(S + L) of the time — about 9% for $50 against $500 — so
|
||||
* anything tighter just spends the day's room on fees. */
|
||||
export const DLL_MIN_STOP = 50;
|
||||
|
||||
/**
|
||||
* How much the firm's day may still lose before a hard daily-loss breach.
|
||||
* Infinity when the account type has no limit.
|
||||
*
|
||||
* The day's P&L is the closed balance against the platform's snapshot at the
|
||||
* firm's daily reset when the client knows it (DXTrade: 22:00 UTC), else the
|
||||
* broker's realized P&L for the session. Only a red day counts: a green day
|
||||
* does NOT widen the room past the limit, because a position still open at
|
||||
* the next reset has its whole move counted against the NEW day — room
|
||||
* borrowed from today's profit would be a breach tomorrow.
|
||||
*/
|
||||
export function dllRoom(o: {
|
||||
dailyLossLimit: number;
|
||||
closedBalance: number;
|
||||
/** Platform snapshot at the firm's daily reset; undefined/null = unknown. */
|
||||
dayStartBalance?: number | null;
|
||||
/** Fallback when there's no snapshot: the day's realized P&L (pass the
|
||||
* worse of the raw and combined figures). */
|
||||
realizedToday: number;
|
||||
}): number {
|
||||
if (!(o.dailyLossLimit > 0)) return Infinity;
|
||||
const dayPnL = typeof o.dayStartBalance === 'number' ? o.closedBalance - o.dayStartBalance : o.realizedToday;
|
||||
return round2(o.dailyLossLimit + Math.min(0, dayPnL));
|
||||
}
|
||||
|
||||
/**
|
||||
* The widest stop, in dollars of price movement, that keeps a loss inside
|
||||
* room − reserve with both fees paid. The exit fee at the stop is at most
|
||||
* entryFee + r × (stop distance) — a short's stop sits above its entry — so
|
||||
* the worst loss is d(1 + r) + 2·entryFee, plus a cent of fee rounding.
|
||||
* Infinity when there is no limit. Can go negative: no room at all.
|
||||
*
|
||||
* Overnight swap needs no reserve of its own while a day's swap is below the
|
||||
* commission rate (Tradeify: 0.033% < 0.04%): a position carried past the
|
||||
* reset has its entry fee on the old day's books, and the swap takes no more
|
||||
* than that slot on the new day's.
|
||||
*/
|
||||
export function dllStopDollars(o: { room: number; dailyLossLimit: number; entryFee: number; commissionPct: number; notional?: number }): number {
|
||||
if (!Number.isFinite(o.room)) return Infinity;
|
||||
const r = Math.max(0, o.commissionPct) / 100;
|
||||
return (o.room - dllReserve(o.dailyLossLimit, o.notional) - 2 * o.entryFee - 0.01) / (1 + r);
|
||||
}
|
||||
|
||||
/**
|
||||
* How far the account's balance is above its max-drawdown floor, or null when
|
||||
* it isn't known: the broker's liquidation floor where it reports one, else
|
||||
* the account type's static max loss below the starting size — whichever is
|
||||
* higher.
|
||||
*/
|
||||
export function maxLossRoom(o: { balance: number; accountSize: number; maxLoss: number; autoLiqThreshold?: number }): number | null {
|
||||
const floors: number[] = [];
|
||||
if ((o.autoLiqThreshold ?? 0) > 0) floors.push(o.autoLiqThreshold!);
|
||||
if (Math.abs(o.maxLoss) > 0 && o.accountSize > 0) floors.push(o.accountSize - Math.abs(o.maxLoss));
|
||||
return floors.length > 0 ? round2(o.balance - Math.max(...floors)) : null;
|
||||
}
|
||||
|
||||
/** A daily-loss stop is kept only while the max-drawdown floor lies more than
|
||||
* this share of a full daily loss beyond it. */
|
||||
export const LAST_STAND_FRACTION = 0.15;
|
||||
|
||||
/**
|
||||
* Whether a leg should ride without its daily-loss stop. The stop is there to
|
||||
* keep the account alive for another day; when hitting the day's loss limit
|
||||
* would leave it within 15% of that loss of its max-drawdown floor — or the
|
||||
* floor is nearer than the limit — there is no useful day after, and the stop
|
||||
* only gives up room (the reserve, and whatever the floor allows past it)
|
||||
* for nothing. The leg then runs until its take-profit or the firm's own
|
||||
* breach. E.g. $3,000 of daily room with $3,294.70 to the floor: $294.70
|
||||
* left, 9.8% — a last stand.
|
||||
*/
|
||||
export function lastStand(o: { dllRoom: number; dailyLossLimit: number; maxLossRoom: number | null }): boolean {
|
||||
if (o.maxLossRoom === null || !(o.dailyLossLimit > 0) || !Number.isFinite(o.dllRoom) || !(o.dllRoom > 0)) return false;
|
||||
return o.maxLossRoom - o.dllRoom <= LAST_STAND_FRACTION * o.dllRoom;
|
||||
}
|
||||
|
||||
/** On a last stand: how far price can move against the leg before the firm's
|
||||
* nearer floor breaches the account — its equity carries the entry fee, not
|
||||
* the exit's. What sizing and the swap estimate take as the walk's far side. */
|
||||
export function breachDistance(o: { dllRoom: number; maxLossRoom: number | null; entryFee: number }): number {
|
||||
return Math.min(o.dllRoom, o.maxLossRoom ?? Infinity) - o.entryFee;
|
||||
}
|
||||
|
||||
/** Is there enough daily-loss room left to trade at all? */
|
||||
export function dllViable(room: number, dailyLossLimit: number, roundTripFeeEstimate: number): boolean {
|
||||
if (!Number.isFinite(room)) return true;
|
||||
return room - DLL_RESERVE_FRACTION * dailyLossLimit >= DLL_MIN_STOP + roundTripFeeEstimate;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stop distance in ticks: the tighter of the obfuscation stop (rounded
|
||||
* out, as it always was — it is a soft target) and the daily-loss stop
|
||||
* (rounded IN — it is a hard limit). null = no stop at all (lone take-profit);
|
||||
* 0 = the daily-loss room doesn't cover a single tick, so the position must
|
||||
* not stay open. An obfuscation value of 0 or Infinity means no obfuscation stop.
|
||||
*/
|
||||
export function stopTicks(o: {
|
||||
obfuscationDollars: number;
|
||||
dllDollars: number;
|
||||
pointValue: number;
|
||||
contracts: number;
|
||||
tickSize: number;
|
||||
}): number | null {
|
||||
const perTick = o.pointValue * o.contracts * o.tickSize;
|
||||
let ticks: number | null = null;
|
||||
if (Number.isFinite(o.obfuscationDollars) && o.obfuscationDollars > 0) {
|
||||
ticks = Math.max(1, Math.ceil(o.obfuscationDollars / (o.pointValue * o.contracts) / o.tickSize));
|
||||
}
|
||||
if (Number.isFinite(o.dllDollars)) {
|
||||
const dllTicks = Math.max(0, Math.floor(o.dllDollars / perTick + 1e-9));
|
||||
ticks = ticks === null ? dllTicks : Math.min(ticks, dllTicks);
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
||||
/** Whether the bot places its own stops for this account type — the
|
||||
* obfuscation options or a hard daily loss limit. A red day on such an
|
||||
* account is the bot's own stop (the personal DLL), not a firm lockout. */
|
||||
export function botStopsActive(cfg: { firstDayStop: boolean; minDayStop: boolean; dailyLossLimit?: number }): boolean {
|
||||
return cfg.firstDayStop || cfg.minDayStop || (cfg.dailyLossLimit ?? 0) > 0;
|
||||
}
|
||||
|
||||
// ── position size ────────────────────────────────────────────────────────────
|
||||
|
||||
/** The hold a leg is sized for when the firm doesn't set one. */
|
||||
export const DEFAULT_TARGET_HOLD_HOURS = 12;
|
||||
|
||||
/** Share of the broker's margin headroom a new position may use: the rest
|
||||
* absorbs the price moving between sizing and the fill, and the entry fee. */
|
||||
export const MARGIN_HEADROOM = 0.97;
|
||||
|
||||
/**
|
||||
* The largest position an account may open: what its available funds can
|
||||
* margin at the broker's rate for the instrument — the firm's leverage per
|
||||
* symbol (5:1 crypto, 2:1 stocks at Tradeify247). 0 = no cap known.
|
||||
*/
|
||||
export function notionalCap(o: { marginRate?: number; availableFunds?: number }): number {
|
||||
if (!((o.marginRate ?? 0) > 0) || !Number.isFinite(o.availableFunds) || !(o.availableFunds! > 0)) return 0;
|
||||
return (MARGIN_HEADROOM * o.availableFunds!) / o.marginRate!;
|
||||
}
|
||||
|
||||
/** The swap reserve never grows past this share of the take-profit distance
|
||||
* — beyond it the leg is sized too small to hold at all. */
|
||||
const MAX_SWAP_RESERVE_FRACTION = 0.25;
|
||||
|
||||
/**
|
||||
* The overnight swap a winning leg is expected to pay before its take-profit
|
||||
* fills, so the take-profit can carry it and a win still clears the leg.
|
||||
*
|
||||
* Same driftless walk as sizeNotional, in dollars: σ$ = notional·σ per √hour,
|
||||
* the take-profit A away, the stop B away. Given it wins, the walk takes
|
||||
* (A² + 2AB)/(3σ$²) hours on average — longer than the unconditional hold,
|
||||
* since the quick endings are mostly stop-outs. The swap is charged at each
|
||||
* daily reset the position is held over, about one per 24 h held, on the
|
||||
* notional. A includes the reserve itself, so it is solved as a fixed point.
|
||||
* A stopless leg is taken as B = A.
|
||||
*
|
||||
* `distance` = the take-profit distance before swap: leg + round-trip fees.
|
||||
*/
|
||||
export function expectedSwapToWin(o: {
|
||||
distance: number;
|
||||
stopDollars: number;
|
||||
notional: number;
|
||||
volatilityPerHour?: number;
|
||||
financingPctPerDay?: number;
|
||||
}): number {
|
||||
const daily = Math.max(0, o.financingPctPerDay ?? 0) / 100;
|
||||
const sigma = o.volatilityPerHour ?? 0;
|
||||
if (!(daily > 0) || !(sigma > 0) || !(o.notional > 0) || !(o.distance > 0)) return 0;
|
||||
const variance = (o.notional * sigma) ** 2;
|
||||
const stopped = Number.isFinite(o.stopDollars) && o.stopDollars > 0;
|
||||
const cap = o.distance * MAX_SWAP_RESERVE_FRACTION;
|
||||
let swap = 0;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const a = o.distance + swap;
|
||||
const b = stopped ? o.stopDollars : a;
|
||||
const winHours = (a * a + 2 * a * b) / (3 * variance);
|
||||
const next = Math.min(cap, daily * o.notional * (winHours / 24));
|
||||
const done = Math.abs(next - swap) < 0.005;
|
||||
swap = next;
|
||||
if (done) break;
|
||||
}
|
||||
return Math.ceil(swap * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* How much notional a leg should carry, on platforms that price it (DXTrade).
|
||||
*
|
||||
* Model price as a driftless random walk with σ per √hour. With the
|
||||
* take-profit A dollars away (leg + fees + expected swap) and the stop B
|
||||
* dollars away, a position of notional N wins B/(A+B) of the time whatever N
|
||||
* is, its expected hold is (A/N)(B/N)/σ² hours, and its expected P&L is minus
|
||||
* fees and spread — proportional to N. Smaller is cheaper, bigger is faster,
|
||||
* so the knob is the target hold T: N = √(A·B/(σ²·T)). A firm that charges
|
||||
* overnight financing adds a floor: below √(f·A·B/(σ²·(2c + s))), a bigger
|
||||
* position is both cheaper and faster.
|
||||
*
|
||||
* A stopless leg (no daily loss limit, no obfuscation stop) is sized as if
|
||||
* its stop sat as far away as its target. Returns null without a volatility.
|
||||
*/
|
||||
export function sizeNotional(o: {
|
||||
leg: number;
|
||||
/** Stop distance in dollars at a given notional (fees move with it). */
|
||||
stopDollarsAt: (notional: number) => number;
|
||||
volatilityPerHour?: number;
|
||||
/** 0 = DEFAULT_TARGET_HOLD_HOURS. */
|
||||
targetHoldHours: number;
|
||||
/** Per side, percent. */
|
||||
commissionPct: number;
|
||||
spreadFraction?: number;
|
||||
financingPctPerDay?: number;
|
||||
/** Hard cap on the position (see notionalCap); 0 = none. */
|
||||
maxNotional: number;
|
||||
/** The smallest order the broker takes; 0 = none. It wins over the cap,
|
||||
* as the order does. */
|
||||
minNotional?: number;
|
||||
}): { notional: number; predictedHoldHours: number; winProbability: number | null; swapReserve: number } | null {
|
||||
const sigma = o.volatilityPerHour ?? 0;
|
||||
if (!(sigma > 0)) return null;
|
||||
const hold = o.targetHoldHours > 0 ? o.targetHoldHours : DEFAULT_TARGET_HOLD_HOURS;
|
||||
const c = Math.max(0, o.commissionPct) / 100;
|
||||
const s = Math.max(0, o.spreadFraction ?? 0);
|
||||
const fh = Math.max(0, o.financingPctPerDay ?? 0) / 100 / 24;
|
||||
const distances = (n: number) => {
|
||||
const stop = o.stopDollarsAt(n);
|
||||
const stopped = Number.isFinite(stop) && stop > 0;
|
||||
const swap = expectedSwapToWin({
|
||||
distance: o.leg + 2 * c * n, stopDollars: stop, notional: n,
|
||||
volatilityPerHour: sigma, financingPctPerDay: o.financingPctPerDay,
|
||||
});
|
||||
const a = o.leg + 2 * c * n + swap;
|
||||
return { a, b: stopped ? stop : a, stopped, swap };
|
||||
};
|
||||
|
||||
// Fees and swap depend on N and the daily-loss stop depends on fees: a
|
||||
// few fixed-point passes settle it (the correction is second order).
|
||||
let n = o.leg * 20;
|
||||
for (let pass = 0; pass < 4; pass++) {
|
||||
const { a, b } = distances(n);
|
||||
const nTime = Math.sqrt((a * b) / (sigma * sigma * hold));
|
||||
// No per-trade cost (a zero-commission firm on a zero spread): no
|
||||
// floor — there is nothing for a bigger position to save.
|
||||
const nCost = fh > 0 && 2 * c + s > 0 ? Math.sqrt((fh * a * b) / (sigma * sigma * (2 * c + s))) : 0;
|
||||
n = Math.max(nTime, nCost);
|
||||
}
|
||||
if (!Number.isFinite(n) || n <= 0) return null;
|
||||
const notional = Math.max(o.maxNotional > 0 ? Math.min(n, o.maxNotional) : n, o.minNotional ?? 0);
|
||||
const { a, b, stopped, swap } = distances(notional);
|
||||
return {
|
||||
notional,
|
||||
predictedHoldHours: (a / notional) * (b / notional) / (sigma * sigma),
|
||||
winProbability: stopped ? b / (a + b) : null,
|
||||
swapReserve: swap,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One leg's size on one instrument for one account — the rule fireLeg
|
||||
* trades, the instrument pick ranks by and the dashboard shows.
|
||||
*
|
||||
* The walk's far side (what sizing, the win chance and the swap estimate
|
||||
* use) is the stop the leg carries: the tighter of the obfuscation stop and
|
||||
* the hard daily-loss stop. On a last stand there is no daily-loss stop and
|
||||
* the far side is the firm's own breach. `cost` is what the leg is expected
|
||||
* to spend: spread and both commissions on its notional, plus swap over its
|
||||
* expected hold.
|
||||
*
|
||||
* The size is held to what the order can be — the margin cap and the
|
||||
* broker's largest order, then its smallest — so the pick ranks each
|
||||
* instrument by the hold it can actually reach. `cap` is the margin cap
|
||||
* alone.
|
||||
*/
|
||||
export function planLegSize(o: {
|
||||
leg: number;
|
||||
contract: {
|
||||
stepNotional?: number; volatilityPerHour?: number; spreadFraction?: number; marginRate?: number;
|
||||
/** The broker's largest and smallest order, in steps. */
|
||||
maxContracts?: number; minContracts?: number;
|
||||
};
|
||||
/** Per side, percent. */
|
||||
commissionPct: number;
|
||||
targetHoldHours: number;
|
||||
financingPctPerDay: number;
|
||||
dailyLossLimit: number;
|
||||
/** Infinity = no hard daily limit. */
|
||||
dllRoom: number;
|
||||
maxLossRoom: number | null;
|
||||
/** Infinity (or ≤ 0) = no obfuscation stop. */
|
||||
obfuscationDollars: number;
|
||||
availableFunds?: number;
|
||||
}): {
|
||||
lastStand: boolean;
|
||||
cap: number;
|
||||
walkAt: (notional: number) => number;
|
||||
sizing: ReturnType<typeof sizeNotional>;
|
||||
cost: number | null;
|
||||
} {
|
||||
const dllOn = Number.isFinite(o.dllRoom) && o.dailyLossLimit > 0;
|
||||
const stand = dllOn && lastStand({ dllRoom: o.dllRoom, dailyLossLimit: o.dailyLossLimit, maxLossRoom: o.maxLossRoom });
|
||||
const obf = Number.isFinite(o.obfuscationDollars) && o.obfuscationDollars > 0 ? o.obfuscationDollars : Infinity;
|
||||
const r = Math.max(0, o.commissionPct) / 100;
|
||||
const walkAt = (notional: number) => Math.min(
|
||||
obf,
|
||||
!dllOn ? Infinity
|
||||
: stand ? breachDistance({ dllRoom: o.dllRoom, maxLossRoom: o.maxLossRoom, entryFee: r * notional })
|
||||
: dllStopDollars({ room: o.dllRoom, dailyLossLimit: o.dailyLossLimit, entryFee: r * notional, commissionPct: o.commissionPct, notional }),
|
||||
);
|
||||
const cap = notionalCap({ marginRate: o.contract.marginRate, availableFunds: o.availableFunds });
|
||||
const step = o.contract.stepNotional ?? 0;
|
||||
const orderMax = (o.contract.maxContracts ?? 0) > 0 ? o.contract.maxContracts! * step : 0;
|
||||
const sizing = step > 0
|
||||
? sizeNotional({
|
||||
leg: o.leg,
|
||||
stopDollarsAt: walkAt,
|
||||
volatilityPerHour: o.contract.volatilityPerHour,
|
||||
targetHoldHours: o.targetHoldHours,
|
||||
commissionPct: o.commissionPct,
|
||||
spreadFraction: o.contract.spreadFraction,
|
||||
financingPctPerDay: o.financingPctPerDay,
|
||||
maxNotional: cap > 0 && orderMax > 0 ? Math.min(cap, orderMax) : cap || orderMax,
|
||||
minNotional: Math.max(1, o.contract.minContracts ?? 1) * step,
|
||||
})
|
||||
: null;
|
||||
const cost = sizing
|
||||
? sizing.notional * (Math.max(0, o.contract.spreadFraction ?? 0) + 2 * r)
|
||||
+ (Math.max(0, o.financingPctPerDay) / 100) * sizing.notional * (sizing.predictedHoldHours / 24)
|
||||
: null;
|
||||
return { lastStand: stand, cap, walkAt, sizing, cost };
|
||||
}
|
||||
|
||||
/**
|
||||
* Order candidates for a leg: those that reach the target hold (within a
|
||||
* few percent) first, cheapest first; then those whose leverage cap keeps
|
||||
* them short of it, quickest first. Returns indices into `options`.
|
||||
*/
|
||||
export function rankForTarget(options: { holdHours: number; cost: number }[], targetHoldHours: number): number[] {
|
||||
const reaches = (h: number) => h <= targetHoldHours * 1.02 + 0.01;
|
||||
return options
|
||||
.map((opt, i) => ({ ...opt, i, reach: reaches(opt.holdHours) }))
|
||||
.sort((a, b) => (a.reach === b.reach
|
||||
? (a.reach ? a.cost - b.cost : a.holdHours - b.holdHours) || a.i - b.i
|
||||
: a.reach ? -1 : 1))
|
||||
.map((x) => x.i);
|
||||
}
|
||||
|
||||
// ── trading hours ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Tradeify247 (and DXtrade brokers generally) roll the trading day at 22:00
|
||||
* UTC — 5 PM Central in summer, 4 PM in winter. */
|
||||
export const FIRM_RESET_UTC_HOUR = 22;
|
||||
/** Every-day firms stop opening new trades this long before the earlier of
|
||||
* their reset and the 5 PM CT roll. */
|
||||
const ROLL_GUARD_MIN = 5;
|
||||
|
||||
function centralClock(now: Date): { day: string; minutes: number; resetMinutes: number } {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
weekday: 'short', hour: 'numeric', minute: 'numeric', hour12: false, timeZone: 'America/Chicago',
|
||||
}).formatToParts(now);
|
||||
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '';
|
||||
const minutes = (parseInt(get('hour'), 10) % 24) * 60 + parseInt(get('minute'), 10);
|
||||
// Central's offset from UTC right now (−300 in CDT, −360 in CST) places
|
||||
// the firm's 22:00 UTC reset on the Central clock.
|
||||
const utcMinutes = now.getUTCHours() * 60 + now.getUTCMinutes();
|
||||
const offset = ((minutes - utcMinutes + 720 + 1440) % 1440) - 720;
|
||||
const resetMinutes = (FIRM_RESET_UTC_HOUR * 60 + offset + 1440) % 1440;
|
||||
return { day: get('weekday'), minutes, resetMinutes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether no new trade may open right now.
|
||||
*
|
||||
* CME firms follow the trading_hours setting:
|
||||
* "full_cme" — Sun 5:05 PM – Fri 2:55 PM Central (5 min buffer on each side)
|
||||
* "equity_hours" — 8:35 AM – 2:55 PM Central, Mon–Fri (5 min buffer on each side)
|
||||
* with the open pushed back by the day's 1–20 minute jitter.
|
||||
*
|
||||
* Every-day firms ignore trading_hours and close only around the day's roll:
|
||||
* from 5 minutes before the earlier of their 22:00 UTC reset and 5 PM CT, to
|
||||
* 5:05 PM CT + jitter. In CST that is 3:55 → 5:05 PM, which keeps new entries
|
||||
* out of the hour where the firm's day (reset at 4 PM CT) and our trade date
|
||||
* (5 PM CT) disagree.
|
||||
*/
|
||||
export function isNoTradeTime(now: Date, o: { everyDay: boolean; mode: string; jitterMin: number }): boolean {
|
||||
const { day, minutes, resetMinutes } = centralClock(now);
|
||||
const cmeOpen = 17 * 60 + 5 + o.jitterMin;
|
||||
|
||||
if (o.everyDay) {
|
||||
const guardStart = Math.min(resetMinutes, 17 * 60) - ROLL_GUARD_MIN;
|
||||
return minutes >= guardStart && minutes < cmeOpen;
|
||||
}
|
||||
|
||||
if (o.mode === 'equity_hours') {
|
||||
if (day === 'Sat' || day === 'Sun') return true;
|
||||
const open = 8 * 60 + 35 + o.jitterMin;
|
||||
const close = 14 * 60 + 55;
|
||||
return minutes < open || minutes >= close;
|
||||
}
|
||||
|
||||
// Full CME: Sun 5:00 PM – Fri 4:00 PM Central with 5 min buffer.
|
||||
if (day === 'Sat') return true;
|
||||
if (day === 'Sun') return minutes < cmeOpen;
|
||||
const cmeClose = 14 * 60 + 55;
|
||||
if (day === 'Fri' && minutes >= cmeClose) return true;
|
||||
// Mon–Thu: block 2:55 PM – 5:05 PM Central (early stop + daily halt + buffer).
|
||||
return minutes >= cmeClose && minutes < cmeOpen;
|
||||
}
|
||||
|
||||
/** The trade date's session has closed and the 5 PM roll hasn't opened the
|
||||
* next one: weekdays 2:55–5:00 PM CT on the CME calendar, the pre-roll guard
|
||||
* for an every-day firm. */
|
||||
export function isPastSessionClose(now: Date, everyDay: boolean): boolean {
|
||||
const { day, minutes, resetMinutes } = centralClock(now);
|
||||
if (everyDay) return minutes >= Math.min(resetMinutes, 17 * 60) - ROLL_GUARD_MIN && minutes < 17 * 60;
|
||||
if (day === 'Sat' || day === 'Sun') return false;
|
||||
return minutes >= 14 * 60 + 55 && minutes < 17 * 60;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import axios from 'axios';
|
||||
import type { AccountItem, AuthLoginResponse, Contract } from './tradovate-helpers';
|
||||
import { computeSec, randomUUIDV4 } from './tradovate-helpers';
|
||||
import { POINT_VALUES, currentTradeDateCT } from './trading-logic';
|
||||
import { currentTradeDateCT } from './trading-logic';
|
||||
import { POINT_VALUES } from './instrument-specs';
|
||||
import { getCachedContract, resolveContracts } from './contract-resolver';
|
||||
import { saveDailyPnL, loadDailyPnL, saveAccountMeta, loadAccountMeta, saveFundTransactions, loadFundTransactions, saveEndedCycleStart } from './db';
|
||||
import { brokerWebSocket } from './outbound-proxy';
|
||||
|
||||
+2
-1
@@ -6,7 +6,8 @@
|
||||
"dev": "next dev --webpack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs scripts/check-instruments.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@microsoft/signalr": "^10.0.11",
|
||||
|
||||
@@ -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 {
|
||||
/** Master's account type id — what a manual assignment pins to. */
|
||||
id: string;
|
||||
@@ -8,7 +10,7 @@ export interface AccountConfig {
|
||||
minDayPnL: number;
|
||||
minTradingDays: number;
|
||||
accountSize: number;
|
||||
maxLoss: number; // 0 = no limit; positive = account is "Dead" when loss exceeds this
|
||||
maxLoss: number; // static max drawdown below the starting size (0 = none); with a hard daily loss, decides last stands
|
||||
maxPositionSize: number; // 0 = no limit; positive = max contracts per trade
|
||||
targetSameEquity: boolean; // if true, reduce profitTarget by priorProfit after withdrawal
|
||||
splitEnabled: boolean; // false = big days trade as one unsplit leg instead of random ≥$500 pieces
|
||||
@@ -27,6 +29,11 @@ export interface AccountConfig {
|
||||
payoutCap?: number;
|
||||
profitSplit?: number;
|
||||
trailingDrawdown?: number;
|
||||
/** Hard daily loss limit in dollars (0/absent = none): every leg carries a
|
||||
* stop that keeps the firm's day inside it, less a reserve and fees. */
|
||||
dailyLossLimit?: number;
|
||||
/** The firm trades every calendar day, weekends and CME holidays included. */
|
||||
tradesEveryDay?: boolean;
|
||||
}
|
||||
|
||||
export interface FirmConfig {
|
||||
@@ -35,14 +42,19 @@ export interface FirmConfig {
|
||||
firm: string;
|
||||
/** Which platform the firm's accounts trade on — decides both which client
|
||||
* drives it and which credential fields the dashboard asks for. */
|
||||
platform: 'tradovate' | 'topstepx';
|
||||
platform: FirmPlatform;
|
||||
/** Blank when this machine has no credentials for the firm. */
|
||||
username: string;
|
||||
/** True once the platform's credentials are stored locally — Tradovate
|
||||
* username + password, or a TopstepX API key. Firms without them are
|
||||
* shown greyed out with an inline credential form. */
|
||||
/** True once the platform's credentials are stored locally — a username +
|
||||
* password (Tradovate, DXTrade) or a TopstepX API key. Firms without them
|
||||
* are shown greyed out with an inline credential form. */
|
||||
hasCredentials: boolean;
|
||||
bannedSymbols: string[];
|
||||
/** What the firm trades (lib/instruments.ts): CME roots for futures
|
||||
* firms, its own symbols for DXtrade. */
|
||||
instruments: string[];
|
||||
/** 'master' = the catalog's list; 'legacy' = a master from before the
|
||||
* lists, read the old way (this machine's enabled set less bans). */
|
||||
instrumentsSource: 'master' | 'legacy';
|
||||
/** The firm's account types, straight from the catalog. Doubles as the
|
||||
* option list for a manual account-type override. */
|
||||
accounts: AccountConfig[];
|
||||
@@ -76,10 +88,38 @@ export interface AccountState {
|
||||
stageDrain: boolean;
|
||||
/** Next trading day's target, computed server-side. null when account is dead or config is missing. */
|
||||
dailyTarget: { amount: number; path: 'first_day' | 'normal_day' | 'reduced_day' } | null;
|
||||
/** Dollars the next trade may lose before its obfuscation stop fires.
|
||||
* null = the next trade rides stopless (options off, big non-first day,
|
||||
* or a make-back day after a loss). */
|
||||
/** Dollars of price movement the next trade may lose before its stop
|
||||
* fires — the tighter of the obfuscation stop and the hard daily-loss
|
||||
* stop, before fees. null = the next trade rides stopless (options off,
|
||||
* big non-first day, or a make-back day after a loss, with no daily
|
||||
* loss limit). */
|
||||
nextStop: number | null;
|
||||
/** What the firm's day may still lose before a hard daily-loss breach.
|
||||
* null = no daily loss limit (or its day-start balance isn't known yet). */
|
||||
dllRoom: number | null;
|
||||
/** DXtrade: how the next leg would be sized and how long it is expected to
|
||||
* hold, from today's measured volatility of the last-picked instrument.
|
||||
* null on other platforms, or before the day's first pick. */
|
||||
holdEstimate: {
|
||||
hours: number;
|
||||
symbol: string;
|
||||
notional: number;
|
||||
/** Notional ÷ account size. */
|
||||
multiplier: number;
|
||||
volatilityPerDay: number;
|
||||
winProbability: number | null;
|
||||
/** The same leg with no commission, spread or swap. */
|
||||
winProbabilityNoFees: number | null;
|
||||
/** Expected swap a winning leg pays, added to its take-profit. */
|
||||
swapReserve: number;
|
||||
/** The broker's leverage on the symbol (5 = 5:1); null when unstated. */
|
||||
leverage: number | null;
|
||||
/** Expected spread, commissions and swap for the leg. */
|
||||
cost: number;
|
||||
} | null;
|
||||
/** The next leg rides without a daily-loss stop: a stop-out would leave
|
||||
* the account too close to its max-drawdown floor to trade on. */
|
||||
lastStand: boolean;
|
||||
/** Actual profit target to hit — max(stage profit target, consistency realTarget = maxDay/consistency). */
|
||||
effectiveProfitTarget: number | null;
|
||||
/** Current cycle daily P&L — used for target calculations. */
|
||||
|
||||
Reference in New Issue
Block a user