diff --git a/README.md b/README.md index 45237f2..781184f 100644 --- a/README.md +++ b/README.md @@ -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,45 @@ stores the Tradovate login for each firm. --- +## 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. +- **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. +- **Symbol map** — which DXtrade symbols each CME root may trade as. A `*` row + covers every root (drain and extra-day legs trade `MNQ`, so map it or use + `*`). When a root lists several, the cheapest tradable one right now wins: + spread plus both commissions, divided by its recent hourly volatility. +- **Target hold hours** — size each leg's notional so its expected hold is + about this long (0 = the symbol map's fixed notional per $1,000 of leg), and + **financing %/day** if the firm charges swap. + +Per account type: **Daily loss limit** (Tradeify: 3% of size — every leg then +carries a stop that keeps the firm's day inside it, less a 10% reserve and +fees) and **Max notional**. + +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=` 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 instrument selector). + +--- + ## The master dashboard Set **Instance Name** and **Dashboard URL** on the Settings page and this diff --git a/app/api/firms/[id]/credentials/route.ts b/app/api/firms/[id]/credentials/route.ts index bebda60..b69dac2 100644 --- a/app/api/firms/[id]/credentials/route.ts +++ b/app/api/firms/[id]/credentials/route.ts @@ -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 { diff --git a/app/api/firms/route.ts b/app/api/firms/route.ts index c375d04..d0a221d 100644 --- a/app/api/firms/route.ts +++ b/app/api/firms/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from 'next/server'; -import { getCachedCatalog } from '@/lib/firm-catalog'; +import { getCachedCatalog, hasPlatformCredentials } from '@/lib/firm-catalog'; import { getCredentials } from '@/lib/db'; import type { FirmConfig } from '@/types'; @@ -18,9 +18,7 @@ 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, @@ -46,6 +44,9 @@ export async function GET() { minDayStop: a.minDayStop, combineCmeHolidays: a.combineCmeHolidays, withdrawalStages: a.withdrawalStages, + dailyLossLimit: a.dailyLossLimit ?? 0, + maxNotional: a.maxNotional ?? 0, + tradesEveryDay: a.tradesEveryDay === true, })), catalogUpdatedAt: firm.updatedAt, }; diff --git a/app/api/state/route.ts b/app/api/state/route.ts index 0a8add8..63ed09b 100644 --- a/app/api/state/route.ts +++ b/app/api/state/route.ts @@ -6,7 +6,8 @@ import { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/accou import { historyFresh } from '@/lib/platform-client'; 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, + LEG_COMPLETION_EPSILON, } from '@/lib/trading-logic'; export async function GET() { @@ -84,6 +85,7 @@ export async function GET() { let remainingToday: number | null = null; let effectiveProfitTarget: number | null = null; let nextStop: number | null = null; + let dllRoom: number | null = null; if (cfg && effective && !isDead) { const equityProfit = cash.amount - cfg.accountSize; const plan = computeDayPlan( @@ -94,25 +96,29 @@ 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), + }); + } + if (plan && (botStopsActive(cfg))) { + const obf = obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday); + const hard = dllRoom !== null + ? 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; } // Effective profit target = max(stage target, consistency realTarget). @@ -187,6 +193,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 +209,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 +227,7 @@ export async function GET() { stageDrain: effective?.drain ?? false, dailyTarget, nextStop, + dllRoom, remainingToday, effectiveProfitTarget, dailyPnL, diff --git a/app/page.tsx b/app/page.tsx index cf204cb..7555cc0 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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
e.stopPropagation()}> setUsername(e.target.value)} /> @@ -375,7 +385,10 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: { ${fmt(account.dailyTarget.amount)} today {account.nextStop !== null && ( - · stop ${fmt(account.nextStop)} + · stop ${fmt(account.nextStop)} + )} + {typeof account.dllRoom === 'number' && ( + · DLL room ${fmt(account.dllRoom)} )} )} @@ -392,7 +405,7 @@ function AccountRow({ account, firm, hideDead, privacy, onAssigned }: { : account.positionDirection ? {account.positionDirection === 'long' ? 'Long' : 'Short'} : account.personalDll - ? Personal DLL + ? Personal DLL : account.targetHit ? Target Hit : Flat} @@ -478,6 +491,11 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo TopstepX )} + {firm.platform === 'dxtrade' && ( + + DXtrade + + )} {firm.platform === 'topstepx' && hasCredentials && !firm.username && (
e.stopPropagation()}> - 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. @@ -554,7 +572,7 @@ function FirmRows({ state, firm, hideDead, privacy, sortKey, sortDir, tradeSymbo state.accounts.length === 0 ? ( - {state.connected ? 'No accounts at this firm' : 'Connecting to Tradovate…'} + {state.connected ? 'No accounts at this firm' : 'Connecting…'} ) : ( diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 55f9ed8..0a4ce81 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -9,16 +9,18 @@ import { getInstruments } from './db'; import { getClients } from './clients'; -import { getTradableFirms } from './firm-catalog'; +import { getCachedFirm, getTradableFirms } from './firm-catalog'; import { getAccountConfig } from './account-type-resolver'; import { combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT, + obfuscationStopDollars, takeProfitPlan, percentFee, dllRoom, dllStopDollars, dllViable, stopTicks, sizeNotional, + isNoTradeTime, isPastSessionClose, BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY, POINT_VALUES, - FIRST_DAY_STOP, MIN_DAY_STOP_MULT, + FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP, type DayPlan, } from './trading-logic'; import { getSetting, setSetting } from './db'; -import { errorMessage, isClientReady, isOrderNotPlaced, type PlatformClient } from './platform-client'; +import { errorMessage, isClientReady, isOrderNotPlaced, type PlatformClient, type PlatformContract } from './platform-client'; // ── process-wide state ─────────────────────────────────────────────────────── @@ -52,11 +54,18 @@ interface TradeRuntime { unreadySince: Map; /** Firm id → when the tick last logged it as quarantined (rate limit). */ quarantineLoggedAt: Map; - /** When the batch holding the trade mutex took it (0 = no batch in flight). */ - tradeInFlightSince: number; + /** Lane → when the batch holding that lane's trade mutex took it (absent + * = no batch in flight there). See laneOf. */ + tradeInFlight?: Map; + /** Accounts whose exit bracket the broker refused while a hard daily loss + * limit was on: the position was flattened and the account sits out the + * trade date rather than bleed a round trip per leg. */ + exitRefused?: Map; // What getSchedulerHealth reports. lastTickAt: number; lastTickOutcome: TickOutcome; + /** The last tick's outcome per lane. */ + laneOutcomes?: Map; lastFireAt: number; holding: string[]; quarantined: string[]; @@ -77,7 +86,6 @@ function runtime(): TradeRuntime { accountsInFlight: new Set(), unreadySince: new Map(), quarantineLoggedAt: new Map(), - tradeInFlightSince: 0, lastTickAt: 0, lastTickOutcome: 'not-running', lastFireAt: 0, @@ -261,6 +269,36 @@ async function withAccountInFlight(accountId: number, fire: () => Promise) type TradableFirm = ReturnType[number]; interface FirmClient { firm: TradableFirm; client: PlatformClient } +// ── lanes ──────────────────────────────────────────────────────────────────── + +/** + * Which lane a firm trades in. Lanes are independent: each has its own + * readiness hold, trading hours, all-flat gate, trade mutex and batch, so a + * multi-hour crypto position never holds up the futures open and a stuck + * futures position never freezes a 24/7 firm's weekend. Every CME-futures + * firm (Tradovate, TopstepX) shares the one 'futures' lane — exactly how all + * firms traded before lanes existed; each DXTrade firm is a lane of its own. + */ +export function laneOf(firm: { id: string; platform: string }): string { + return firm.platform === 'dxtrade' ? firm.id : FUTURES_LANE; +} +const FUTURES_LANE = 'futures'; + +function inLane(items: T[], lane: string): T[] { + return items.filter((i) => laneOf(i.firm) === lane); +} + +/** The lanes of every tradable firm, futures first. */ +function tradableLanes(): string[] { + const lanes = new Set(getTradableFirms().map((f) => laneOf(f))); + return [...lanes].sort((a, b) => (a === FUTURES_LANE ? -1 : b === FUTURES_LANE ? 1 : a.localeCompare(b))); +} + +function laneName(lane: string): string { + if (lane === FUTURES_LANE) return FUTURES_LANE; + return getTradableFirms().find((f) => f.id === lane)?.name ?? lane; +} + /** * Tradable firms that have a client, split by isClientReady. Everything that * trades or gates works from `ready` only: a client whose sync is missing or @@ -339,10 +377,11 @@ function assessReadiness( // ── trade mutex ────────────────────────────────────────────────────────────── /** - * One batch at a time, instance-wide. The tick, a master "trade" command, + * One batch at a time per lane. The tick, a master "trade" command, * POST /api/trade and /api/copy-trade can all fire a batch, and two batches * collecting at once would each see the other's accounts as still flat — the - * position gate only trips once a fill has come back over the socket. + * position gate only trips once a fill has come back over the socket. Lanes + * never share accounts, so each has its own mutex. * * A batch that has held the mutex past TRADE_WEDGED_MS is presumed stuck on a * broker call that will never answer, and the next one goes ahead without it @@ -353,91 +392,69 @@ function assessReadiness( * * Returns the lease to hand to releaseTradeMutex, or null to skip. */ -function acquireTradeMutex(tag: string): number | null { - const rt = runtime(); +function acquireTradeMutex(tag: string, lane: string): number | null { + const inFlight = (runtime().tradeInFlight ??= new Map()); const now = Date.now(); - if (rt.tradeInFlightSince > 0) { - const heldMs = now - rt.tradeInFlightSince; + const since = inFlight.get(lane) ?? 0; + if (since > 0) { + const heldMs = now - since; if (heldMs < TRADE_WEDGED_MS) { - console.log(`${tag} another batch in flight for ${Math.round(heldMs / 1000)}s — skipping`); + console.log(`${tag} another ${laneName(lane)} batch in flight for ${Math.round(heldMs / 1000)}s — skipping`); return null; } - console.warn(`${tag} batch in flight for ${Math.round(heldMs / 60_000)}m looks wedged — proceeding without it`); + console.warn(`${tag} ${laneName(lane)} batch in flight for ${Math.round(heldMs / 60_000)}m looks wedged — proceeding without it`); } - rt.tradeInFlightSince = now; - return now; + // Leases must differ even when two lanes lock in the same millisecond. + const lease = Math.max(now, since + 1); + inFlight.set(lane, lease); + return lease; } -function holdsTradeMutex(lease: number): boolean { - return runtime().tradeInFlightSince === lease; +function holdsTradeMutex(lease: number, lane: string): boolean { + return runtime().tradeInFlight?.get(lane) === lease; } /** Only the holder unlocks: a wedged batch that finally returns must not * release the batch that took over from it. */ -function releaseTradeMutex(lease: number): void { - if (holdsTradeMutex(lease)) runtime().tradeInFlightSince = 0; +function releaseTradeMutex(lease: number, lane: string): void { + if (holdsTradeMutex(lease, lane)) runtime().tradeInFlight?.delete(lane); } -/** 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. */ -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; -} - -/** The instrument a leg actually trades: drain legs go on MNQ, hardcoded - * (like the extra-day path) — tiny targets belong on the micro regardless of - * the batch symbol. */ +/** The instrument a leg actually trades: drain legs go on MNQ (whatever the + * platform maps MNQ to), hardcoded like the extra-day path — tiny targets + * belong on the micro regardless of the batch symbol. */ async function legInstrument( // eslint-disable-next-line @typescript-eslint/no-explicit-any client: any, drain: boolean, - batchContract: { name: string; tickSize: number }, + batchContract: LegContract, batchPointValue: number, -): Promise<{ contract: { name: string; tickSize: number }; pointValue: number }> { +): Promise<{ contract: LegContract; pointValue: number }> { if (!drain) return { contract: batchContract, pointValue: batchPointValue }; - const mnq = await client.findFrontMonthContract('MNQ'); + const mnq: LegContract | null = await client.findFrontMonthContract('MNQ'); if (!mnq) throw new Error('MNQ contract not found for drain trade'); - return { contract: mnq, pointValue: POINT_VALUES['MNQ'] }; + return { contract: mnq, pointValue: mnq.pointValue ?? POINT_VALUES['MNQ'] }; } -/** An entry's fee when the balance update hasn't shown it yet — at least one - * extra tick on the exit. */ -const FALLBACK_COMMISSION_PER_CONTRACT = 2.5; -/** No real entry fee comes near this per contract. */ -const MAX_COMMISSION_PER_CONTRACT = 10; +/** What the fire path needs of a contract — PlatformContract minus the id. */ +type LegContract = Omit; /** * The entry fee, read off the realized-P&L delta around the fill. Anything * past a real per-contract fee is something else moving realized P&L — a * broker record rolling to the new trade date under the entry swapped a whole * day's P&L into the "fee", and doubled into the exit target that parked the - * take-profit out of reach — so it falls back like a missing update does. + * take-profit out of reach. Null when there is no usable reading — no update + * yet, or that. */ -function entryCommissionFrom(before: number, after: number, contracts: number): number { +function entryCommissionMeasured(before: number, after: number, contracts: number): number | null { const delta = Math.abs(after - before); if (delta > 0 && delta <= MAX_COMMISSION_PER_CONTRACT * contracts) return delta; if (delta > 0) { console.warn(`[auto-trade] realized P&L moved $${Math.round(delta * 100) / 100} around a ${contracts}-contract entry` + ` — not a fee; using $${FALLBACK_COMMISSION_PER_CONTRACT}/contract`); } - return FALLBACK_COMMISSION_PER_CONTRACT * contracts; + return null; } // ── per-account trade evaluation (shared by runTrade / copyTrade / hasRemaining…) ── @@ -450,6 +467,13 @@ const HISTORY_PENDING = 'history not loaded yet — waiting for the fetch'; const ROLL_PENDING = "waiting for the broker to roll the account's day"; /** An entry whose outcome is unknown, waiting for the broker to show it. */ const ENTRY_UNKNOWN = 'entry outcome unknown — waiting for the broker to show it'; +/** A hard daily loss limit is set but the platform can't tell yet where the + * firm's day started — still in play, like a pending history. */ +const DLL_BASE_PENDING = "daily loss limit: waiting for the day's starting balance"; + +/** Reasons that mean "not yet", not "done for the day" — stop-after-all keeps + * the scheduler up for these until the session closes. */ +const PENDING_REASONS = new Set([HISTORY_PENDING, ROLL_PENDING, ENTRY_UNKNOWN, DLL_BASE_PENDING]); interface AccountEvaluation { eligible: boolean; @@ -461,6 +485,9 @@ interface AccountEvaluation { cash: { amount: number; realizedPnL: number }; dailyPnL: { date: string; pnl: number }[]; daysTraded: number; + /** What the firm's day may still lose before a hard daily-loss breach + * (Infinity = no limit). */ + dllRoom: number; } /** @@ -489,7 +516,7 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a const dailyPnL = combined.dailyPnL; const daysTraded = combined.daysTraded; - const base = { plan: null, isExtraDay: false, drain: false, cash, dailyPnL, daysTraded }; + const base = { plan: null, isExtraDay: false, drain: false, cash, dailyPnL, daysTraded, dllRoom: Infinity }; if (isAccountDead(cash.amount, autoLiqThreshold)) return { ...base, eligible: false, reason: 'dead' }; if (!acc.active) return { ...base, eligible: false, reason: 'inactive (DLL)' }; if (client.positions[acc.id]) return { ...base, eligible: false, reason: 'already in trade' }; @@ -502,6 +529,9 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a if (!client.historyReady(acc.id)) return { ...base, eligible: false, reason: HISTORY_PENDING }; if (cash.pendingRoll) return { ...base, eligible: false, reason: ROLL_PENDING }; if (unknownEntryHolds(client, acc.id, tradeDate)) return { ...base, eligible: false, reason: ENTRY_UNKNOWN }; + if (runtime().exitRefused?.get(acc.id) === tradeDate) { + return { ...base, eligible: false, reason: 'exit bracket refused — flattened, benched for the day' }; + } // Obfuscation stops: a stop-out shows as realized P&L falling short of the // legs fired today — the day is booked; the make-back ride is tomorrow. @@ -538,7 +568,7 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a && effective.minTradingDays > daysTraded && totalProfit >= effective.profitTarget; - const evaluated = { plan, isExtraDay, drain: effective.drain, cash, dailyPnL, daysTraded }; + const evaluated = { plan, isExtraDay, drain: effective.drain, cash, dailyPnL, daysTraded, dllRoom: Infinity }; if (isExtraDay || !plan.splitAllowed) { // Legacy one-and-done gate: any realized PnL today means this account is @@ -573,6 +603,35 @@ function evaluateAccountForTrade(client: any, acc: { id: number; name: string; a } } + // Hard daily loss limit: checked last, so the more telling reasons above + // (day done, challenge complete, leg cap) win. A daily-loss stop-out + // leaves about the reserve plus fees, which is below what a trade needs — + // so a stop-out benches the account for the rest of the day by itself. + const dll = cfg.dailyLossLimit ?? 0; + if (dll > 0) { + const dayStart: number | null | undefined = client.dayStartBalance?.(acc.id); + if (dayStart === null) return { ...evaluated, eligible: false, reason: DLL_BASE_PENDING }; + const room = dllRoom({ + dailyLossLimit: dll, + closedBalance: cash.amount, + dayStartBalance: dayStart, + // The broker's day, raw — and on a combining firm the worse of it + // and the combined figure, whichever the firm turns out to use. + realizedToday: Math.min(cash.realizedPnL, combined.realizedPnL), + }); + // Measured-fee firms: a typical round trip on the contracts this leg + // could use. Percent-fee firms size by notional, which isn't known + // here — fireLeg re-checks with the real size before any entry. + const contractsBound = isExtraDay || effective.drain ? 1 + : Math.min(Math.max(1, Math.ceil(plan.remainingToday / 1000)), cfg.maxPositionSize > 0 ? cfg.maxPositionSize : Infinity); + const percentFees = (getCachedFirm(cfg.firmId)?.commissionPct ?? 0) > 0; + const feeEstimate = percentFees ? 0 : 2 * FALLBACK_COMMISSION_PER_CONTRACT * contractsBound; + if (!dllViable(room, dll, feeEstimate)) { + return { ...evaluated, eligible: false, reason: `daily loss limit: $${Math.round(room * 100) / 100} of $${dll} left — day booked` }; + } + return { ...evaluated, eligible: true, reason: '', dllRoom: room }; + } + return { ...evaluated, eligible: true, reason: '' }; } @@ -605,74 +664,27 @@ export function todaysSessionOpenDelay(): number { return openJitterMinutes(centralDay); } -/** Weekdays 2:55–5:00 PM CT: the trade date's session has closed and the 5 PM - * roll hasn't opened the next one. The rest of the no-trade window — the - * roll to the open, and weekends — is waiting on a session to come. */ -function isAfterSessionClose(now: Date = new Date()): boolean { - 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 ?? ''; - 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; +/** The trade date's session has closed and the next hasn't opened: weekdays + * 2:55–5:00 PM CT for CME firms, the pre-roll guard for an every-day firm. + * The rest of the no-trade window — the roll to the open, and weekends — is + * waiting on a session to come. */ +function isAfterSessionClose(firm: { tradesEveryDay?: boolean }, now: Date = new Date()): boolean { + return isPastSessionClose(now, firm.tradesEveryDay === true); } /** - * Returns true when trading is not allowed based on 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) - * - * The open side of each window is pushed back by a random 1–20 minutes per day - * (see openJitterMinutes) so sessions never start at the exact same time. + * Whether the firm takes no new trades right now (see isNoTradeTime). CME + * firms follow the trading_hours setting — "full_cme" or "equity_hours" — with + * the open pushed back by the day's 1–20 minute jitter (openJitterMinutes), so + * sessions never start at the exact same time. Every-day firms ignore the + * setting and only stand down around the daily roll. */ -function isInNoTradeWindow(): boolean { - const now = new Date(); - 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); // "Sun", "Mon", ... "Sat" - const timeMinutes = hour * 60 + minute; // minutes since midnight - const jitter = todaysSessionOpenDelay(); - - const mode = getSetting('trading_hours') ?? 'full_cme'; - - if (mode === 'equity_hours') { - // Equity hours: 8:30 AM – 3:00 PM Central with 5 min buffer = 8:35 AM – 2:55 PM - // Weekdays only - if (day === 'Sat' || day === 'Sun') return true; - const open = 8 * 60 + 35 + jitter; // 8:35 AM + daily jitter - const close = 14 * 60 + 55; // 2:55 PM - return timeMinutes < open || timeMinutes >= close; - } - - // Full CME: Sun 5:00 PM – Fri 4:00 PM Central with 5 min buffer - // Open: 5:05 PM, Close: 2:55 PM (stop early), Daily halt: 2:55 PM – 5:05 PM - - // Saturday — market closed all day - if (day === 'Sat') return true; - - // Sunday — market opens at 5:05 PM Central (+ daily jitter) - const cmeOpen = 17 * 60 + 5 + jitter; // 5:05 PM + daily jitter - if (day === 'Sun') return timeMinutes < cmeOpen; - - // Friday — stop trading at 2:55 PM Central - const cmeClose = 14 * 60 + 55; // 2:55 PM - if (day === 'Fri' && timeMinutes >= cmeClose) return true; - - // Mon–Thu: block 2:55 PM – 5:05 PM Central (early stop + daily halt + buffer) - if (timeMinutes >= cmeClose && timeMinutes < cmeOpen) return true; - - return false; +function isInNoTradeWindow(firm: { tradesEveryDay?: boolean }, now: Date = new Date()): boolean { + return isNoTradeTime(now, { + everyDay: firm.tradesEveryDay === true, + mode: getSetting('trading_hours') ?? 'full_cme', + jitterMin: todaysSessionOpenDelay(), + }); } // ── core trade logic ────────────────────────────────────────────────────────── @@ -682,79 +694,96 @@ type LegResult = { status: string; [key: string]: unknown }; type FirmTradeResults = { firm: string; results: LegResult[] }; type TradeOutcome = Extract; +/** One batch in every lane — a master "trade" command, POST /api/trade. */ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string): Promise { - return (await runTradeBatch(action, symbol)).results; + const results: FirmTradeResults[] = []; + for (const lane of tradableLanes()) { + results.push(...(await runTradeBatch(action, symbol, lane)).results); + } + return results; } -/** runTrade plus what it decided, which the tick records for getSchedulerHealth. */ +/** One lane's batch plus what it decided, which the tick records for getSchedulerHealth. */ async function runTradeBatch( action: 'Buy' | 'Sell' | 'Auto', symbol: string, + lane: string, ): Promise<{ outcome: TradeOutcome; results: FirmTradeResults[] }> { - const lease = acquireTradeMutex('[auto-trade]'); + const lease = acquireTradeMutex('[auto-trade]', lane); if (lease === null) return { outcome: 'skipped-in-flight', results: [] }; try { - return await runTradeLocked(action, symbol, lease); + return await runTradeLocked(action, symbol, lease, lane); } finally { - releaseTradeMutex(lease); + releaseTradeMutex(lease, lane); } } +/** An eligible account and everything fireLeg needs to trade it. */ +interface LegItem { + firmName: string; + firm: TradableFirm; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + client: any; + acc: { id: number; name: string; active: boolean }; + contract: LegContract; + /** Dollars per 1.0 of price per contract of the batch contract. */ + pv: number; + cash: { amount: number; realizedPnL: number }; + dailyPnL: { date: string; pnl: number }[]; + plan: DayPlan; + drain: boolean; + prefix: string; + /** Hard daily-loss room (Infinity = no limit). */ + dllRoom: number; +} + async function runTradeLocked( action: 'Buy' | 'Sell' | 'Auto', symbol: string, lease: number, + lane: string, ): Promise<{ outcome: TradeOutcome; results: FirmTradeResults[] }> { - if (isInNoTradeWindow()) { - console.log('[auto-trade] CME market closed — skipping'); + const laneFirms = getTradableFirms().filter((f) => laneOf(f) === lane); + if (laneFirms.every((f) => isInNoTradeWindow(f))) { + console.log(`[auto-trade] ${laneName(lane)}: market closed — skipping`); return { outcome: 'closed', results: [] }; } - // Block if any account still has an open position (same gate as the scheduler tick). - // Ready clients only, like everything below: a stale snapshot must neither trade nor gate. - const { ready } = firmClients(); - const openPositions = countOpenPositions(ready); + // Block if any account in the lane still has an open position (same gate + // as the scheduler tick). Ready clients only, like everything below: a + // stale snapshot must neither trade nor gate. + const laneReady = inLane(firmClients().ready, lane); + const openPositions = countOpenPositions(laneReady); if (openPositions > 0) { - console.log(`[auto-trade] ${openPositions} position(s) still open — skipping`); + console.log(`[auto-trade] ${laneName(lane)}: ${openPositions} position(s) still open — skipping`); return { outcome: 'positions-open', results: [] }; } + const open = laneReady.filter(({ firm }) => !isInNoTradeWindow(firm)); // Resolve 'Auto' symbol once per batch so all accounts trade the same symbol let resolvedSymbol = symbol; if (symbol === 'Auto') { const enabled = getInstruments().filter((i) => i.enabled).map((i) => i.symbol); resolvedSymbol = enabled.length > 0 ? enabled[Math.floor(Math.random() * enabled.length)] : 'NQ'; - console.log(`[auto-trade] random symbol resolved to: ${resolvedSymbol}`); + console.log(`[auto-trade] ${laneName(lane)}: random symbol resolved to: ${resolvedSymbol}`); } // Resolve Auto action once per batch so all accounts trade the same direction const resolvedAction: 'Buy' | 'Sell' = action === 'Auto' ? (Math.random() < 0.5 ? 'Buy' : 'Sell') : action; - const pointValue = POINT_VALUES[resolvedSymbol]; - if (!pointValue) throw new Error(`Unknown symbol: ${resolvedSymbol}`); + // The CME product's point value. A platform that maps the root to its own + // instrument (DXTrade) supplies the contract's point value instead. + const basePointValue: number | undefined = POINT_VALUES[resolvedSymbol]; const tradeDate = currentTradeDateCT(); const maxConcurrent = maxConcurrentAccounts(); - // ── Phase 1: collect ALL eligible accounts across ALL firms in parallel ── - type EligibleItem = { - firmName: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - client: any; - acc: { id: number; name: string; active: boolean }; - contract: { name: string; tickSize: number }; - cash: { amount: number; realizedPnL: number }; - dailyPnL: { date: string; pnl: number }[]; - daysTraded: number; - plan: DayPlan; - isExtraDay: boolean; - drain: boolean; - prefix: string; - }; + // ── Phase 1: collect ALL eligible accounts across the lane's firms in parallel ── + type EligibleItem = LegItem & { daysTraded: number; isExtraDay: boolean }; const allEligible: EligibleItem[] = []; - await Promise.all(ready.map(async ({ firm, client }) => { + await Promise.all(open.map(async ({ firm, client }) => { if (client.accountList.length === 0) return; // Skip this firm entirely if the symbol is banned for it @@ -769,6 +798,11 @@ async function runTradeLocked( try { const contract = await client.findFrontMonthContract(resolvedSymbol); if (!contract) return; + const pv = contract.pointValue ?? basePointValue; + if (!pv) { + console.log(`[auto-trade] ${firm.name}: no point value for ${resolvedSymbol} — skipping firm`); + return; + } const found: EligibleItem[] = []; for (const acc of client.accountList) { @@ -776,10 +810,11 @@ async function runTradeLocked( if (!ev || !ev.eligible || !ev.plan) continue; found.push({ - firmName: firm.name, client, acc, contract, + firmName: firm.name, firm, client, acc, contract, pv, cash: ev.cash, dailyPnL: ev.dailyPnL, daysTraded: ev.daysTraded, plan: ev.plan, isExtraDay: ev.isExtraDay, drain: ev.drain, prefix: getAccountConfig(acc.id).accountType?.prefix ?? '', + dllRoom: ev.dllRoom, }); } allEligible.push(...found); @@ -789,14 +824,14 @@ async function runTradeLocked( })); if (allEligible.length === 0) { - console.log('[auto-trade] no eligible accounts found'); + console.log(`[auto-trade] ${laneName(lane)}: no eligible accounts found`); return { outcome: 'no-eligible', results: [] }; } // Collecting took so long that another batch presumed this one wedged and // took the mutex. That batch traded on fresher numbers than these, so // firing now could trade an account twice. - if (!holdsTradeMutex(lease)) { + if (!holdsTradeMutex(lease, lane)) { console.warn('[auto-trade] superseded while collecting accounts — abandoning this batch'); return { outcome: 'skipped-in-flight', results: [] }; } @@ -817,15 +852,14 @@ async function runTradeLocked( const [batchPrefix, batchGroup] = [...groups.entries()] .sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]))[0]; const batch = batchGroup.slice(0, maxConcurrent); - console.log(`[auto-trade] ${allEligible.length} eligible account(s) across ${groups.size} prefix group(s) — firing batch of ${batch.length} ${batchPrefix || '(no prefix)'}`); + console.log(`[auto-trade] ${laneName(lane)}: ${allEligible.length} eligible account(s) across ${groups.size} prefix group(s) — firing batch of ${batch.length} ${batchPrefix || '(no prefix)'}`); runtime().lastFireAt = Date.now(); // ── Phase 2: fire the batch simultaneously ── // Each account is marked in flight before anything is placed and released // once its orders are done, so no overlapping batch can pick it meanwhile. const tradeResults = await Promise.allSettled(batch.map((item) => withAccountInFlight(item.acc.id, async () => { - const { client, acc, contract } = item; - const cfg = getAccountConfig(acc.id).accountType!; + const { client, acc } = item; // Extra-day mode: profit target already met, no min day P&L, days still needed. // Just trade 1 MNQ in and out at market immediately — P&L doesn't matter. @@ -837,7 +871,7 @@ async function runTradeLocked( const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy'; const exitFill = await client.sendOrder(acc.id, mnqContract.name, 1, exitAction, 'Market'); - console.log(`[auto-trade] ${acc.name} (${item.firmName}) extra-day: ${resolvedAction} 1xMNQ @ ${fill.price} | exited @ ${exitFill.price} (market)`); + console.log(`[auto-trade] ${acc.name} (${item.firmName}) extra-day: ${resolvedAction} 1x${mnqContract.name} @ ${fill.price} | exited @ ${exitFill.price} (market)`); return { account: acc.name, @@ -854,99 +888,7 @@ async function runTradeLocked( }; } - // Trade one leg toward today's remaining target. Split-allowed big days carve a - // ≥$500 piece; everything else trades the full remaining amount in one go. The - // carve fraction is seeded per (prefix, date, leg number) so like accounts in a - // batch split their targets the same way, or close to it when remainders differ. - // An account type's min-legs setting forces at least that many carves - // on BIG days only — a day at or below the threshold still trades as - // one leg. Set per prefix, so like accounts still split alike. - const minLegs = item.plan.dayTarget > BIG_DAY_THRESHOLD ? (cfg.minLegs ?? 0) : 0; - const legAmount = item.plan.splitAllowed - ? pickLegAmount( - item.plan.remainingToday, - () => groupLegFraction(item.prefix, tradeDate, legsToday(acc.id, tradeDate)), - minLegs - legsToday(acc.id, tradeDate), - ) - : item.plan.remainingToday; - // Counted before anything is sent so the runaway guard sees it while - // the batch is in flight; taken back only if the entry never reached - // the broker (here, or in sendEntry). - recordLeg(acc.id, tradeDate, legAmount); - - const inst = await legInstrument(client, item.drain, contract, pointValue) - .catch((err: unknown) => { unrecordLeg(acc.id, tradeDate, legAmount); throw err; }); - const legContract = inst.contract; - const pv = inst.pointValue; - - const rawContracts = Math.max(1, Math.ceil(legAmount / 1000)); - const contracts = cfg.maxPositionSize > 0 ? Math.min(rawContracts, cfg.maxPositionSize) : rawContracts; - const fill = await sendEntry('[auto-trade]', client, acc, legContract.name, contracts, resolvedAction, tradeDate, legAmount); - - // Wait briefly for the cash balance WebSocket update to reflect entry commission - await new Promise(r => setTimeout(r, 1000)); - const updatedCash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 }; - // After entry, realizedPnL drops by the entry commission; the delta from the - // pre-entry value is the entry fee paid (pre-entry may be nonzero mid-day). - // Fall back to $2.50/contract if the WS hasn't updated yet (guarantees at least 1 extra tick). - const entryCommission = entryCommissionFrom(item.cash.realizedPnL, updatedCash.realizedPnL, contracts); - const totalCommission = entryCommission * 2; // entry + exit round-trip - const grossTarget = legAmount + totalCommission; - - const targetPoints = grossTarget / (pv * contracts); - const ticks = Math.ceil(targetPoints / legContract.tickSize); - const exitPrice = resolvedAction === 'Buy' - ? fill.price + (ticks * legContract.tickSize) - : fill.price - (ticks * legContract.tickSize); - - const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy'; - const stopDollars = obfuscationStopDollars(cfg, item.dailyPnL, item.plan, tradeDate, legAmount); - let exitOrder: { orderId?: number }; - let stopPrice: number | null = null; - if (Number.isFinite(stopDollars) && stopDollars > 0) { - const stopTicks = Math.max(1, Math.ceil(stopDollars / (pv * contracts) / legContract.tickSize)); - stopPrice = resolvedAction === 'Buy' - ? fill.price - (stopTicks * legContract.tickSize) - : fill.price + (stopTicks * legContract.tickSize); - try { - exitOrder = await client.placeOcoExit(acc.id, legContract.name, contracts, exitAction, exitPrice, stopPrice); - } catch (err) { - // The position is already open — never leave it exit-less when - // the pair never went in (refused, or not sent): fall back to - // the classic lone limit and shout about it. An unknown outcome - // (the ack timed out, the connection went after the send) is - // different: the pair may be working, and a lone limit on top - // doubles the exit — both filling flips the account into an - // opposite position nothing manages. Place nothing more then. - if (!isOrderNotPlaced(err)) { - console.error(`[auto-trade] ${acc.name}: placeoco outcome UNKNOWN (${errorMessage(err)}) — NOT placing a second exit; check this position's exits at the broker`); - throw new Error(`entry filled @ ${fill.price} but the exit's outcome is unknown — ${errorMessage(err)}`); - } - console.error(`[auto-trade] ${acc.name}: placeoco REJECTED (${errorMessage(err)}) — falling back to lone limit exit, NO STOP`); - stopPrice = null; - exitOrder = await client.placeOrderNoWait(acc.id, legContract.name, contracts, exitAction, 'Limit', exitPrice); - } - } else { - exitOrder = await client.placeOrderNoWait(acc.id, legContract.name, contracts, exitAction, 'Limit', exitPrice); - } - - console.log(`[auto-trade] ${acc.name} (${item.firmName}) ${resolvedAction} ${contracts}x${item.drain ? legContract.name : resolvedSymbol} @ ${fill.price} | leg $${legAmount} of day $${item.plan.dayTarget} (remaining $${item.plan.remainingToday}) [${item.plan.path}${item.drain ? ' drain' : ''}] (+$${totalCommission.toFixed(2)} comm) | exit @ ${exitPrice}${stopPrice !== null ? ` / stop @ ${stopPrice}` : ''} (orderId=${exitOrder.orderId})`); - - return { - account: acc.name, - firm: item.firmName, - status: 'filled', - contracts, - target: legAmount, - dayTarget: item.plan.dayTarget, - remainingBefore: item.plan.remainingToday, - grossTarget, - totalCommission, - targetPath: item.plan.path, - entryPrice: fill.price, - exitPrice, - commission: entryCommission, - }; + return fireLeg('[auto-trade]', item, resolvedAction, resolvedSymbol, tradeDate); }))); // Group results by firm for the response @@ -971,35 +913,279 @@ async function runTradeLocked( }; } +const round2 = (n: number) => Math.round(n * 100) / 100; + +/** + * Close a just-filled position at market because it must not stay open — its + * daily-loss stop has no room, or the broker refused its exit bracket — and + * report the leg as failed. A flatten that fails too is the worst case: a + * position with no stop, so it is shouted about. + */ +async function flattenAndFail( + tag: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + client: any, + acc: { id: number; name: string }, + contractName: string, + contracts: number, + exitAction: 'Buy' | 'Sell', + entryPrice: number, + why: string, +): Promise { + try { + const exit = await client.sendOrder(acc.id, contractName, contracts, exitAction, 'Market'); + console.warn(`${tag} ${acc.name}: flattened @ ${exit.price} — ${why}`); + } catch (err) { + console.error(`${tag} ${acc.name}: FLATTEN FAILED (${errorMessage(err)}) — the position may be open WITHOUT A STOP; close it at the broker (${why})`); + throw new Error(`entry filled @ ${entryPrice}; ${why}; flatten failed — ${errorMessage(err)}`); + } + throw new Error(`entry filled @ ${entryPrice}; flattened — ${why}`); +} + +/** + * Trade one leg toward the account's remaining target for today — shared by + * the batch and copy-trade, so every fee, stop and sizing rule lives once. + * + * Size: futures trade one contract per $1,000 of leg; a platform that prices + * positions (stepNotional) sizes by notional instead — volatility sizing for + * the firm's target hold, or the symbol map's fixed notional — under the + * account type's notional cap. + * + * Fees: a percent-of-notional firm's fee is computed off the fill; otherwise + * it is read off the realized-P&L change a second after it. + * + * Stop: the tighter of the obfuscation stop and the hard daily-loss stop. + * With a daily loss limit, no position is left without one — no room for a + * tick, or a refused bracket, flattens it instead. + */ +async function fireLeg( + tag: string, + item: LegItem, + action: 'Buy' | 'Sell', + symbolLabel: string, + tradeDate: string, +): Promise { + const { client, acc, firm } = item; + const cfg = getAccountConfig(acc.id).accountType!; + + // Trade one leg toward today's remaining target. Split-allowed big days carve a + // ≥$500 piece; everything else trades the full remaining amount in one go. The + // carve fraction is seeded per (prefix, date, leg number) so like accounts in a + // batch split their targets the same way, or close to it when remainders differ. + // An account type's min-legs setting forces at least that many carves + // on BIG days only — a day at or below the threshold still trades as + // one leg. Set per prefix, so like accounts still split alike. + const minLegs = item.plan.dayTarget > BIG_DAY_THRESHOLD ? (cfg.minLegs ?? 0) : 0; + const legAmount = item.plan.splitAllowed + ? pickLegAmount( + item.plan.remainingToday, + () => groupLegFraction(item.prefix, tradeDate, legsToday(acc.id, tradeDate)), + minLegs - legsToday(acc.id, tradeDate), + ) + : item.plan.remainingToday; + // Counted before anything is sent so the runaway guard sees it while + // the batch is in flight; taken back only if the entry never reached + // the broker (here, or in sendEntry). + recordLeg(acc.id, tradeDate, legAmount); + + const dll = cfg.dailyLossLimit ?? 0; + const dllOn = Number.isFinite(item.dllRoom) && dll > 0; + const obfuscation = obfuscationStopDollars(cfg, item.dailyPnL, item.plan, tradeDate, legAmount); + + // Everything before the entry goes out: a failure here never reached the + // broker, so the leg is taken back. + let legContract: LegContract; + let pv: number; + let pct: number; + let contracts: number; + let sizing: ReturnType = null; + try { + const inst = await legInstrument(client, item.drain, item.contract, item.pv); + legContract = inst.contract; + pv = inst.pointValue; + pct = legContract.commissionPct ?? firm.commissionPct ?? 0; + + const stepNotional = legContract.stepNotional ?? 0; + if (stepNotional > 0) { + const obfStop = Number.isFinite(obfuscation) && obfuscation > 0 ? obfuscation : Infinity; + const stopDollarsAt = (notional: number) => Math.min( + obfStop, + dllOn ? dllStopDollars({ room: item.dllRoom, dailyLossLimit: dll, entryFee: (pct / 100) * notional, commissionPct: pct }) : Infinity, + ); + sizing = sizeNotional({ + leg: legAmount, + stopDollarsAt, + volatilityPerHour: legContract.volatilityPerHour, + targetHoldHours: firm.targetHoldHours ?? 0, + commissionPct: pct, + spreadFraction: legContract.spreadFraction, + financingPctPerDay: firm.financingPctPerDay ?? 0, + fixedNotional: legContract.fixedNotional ?? 0, + maxNotional: cfg.maxNotional ?? 0, + }); + if (!sizing) { + throw new Error(`no position size for ${legContract.name} — set a notional per contract in the symbol map, or a target hold on the firm`); + } + contracts = Math.max(1, Math.round(sizing.notional / stepNotional)); + const maxNotional = cfg.maxNotional ?? 0; + if (maxNotional > 0) contracts = Math.min(contracts, Math.max(1, Math.floor(maxNotional / stepNotional))); + if ((legContract.maxContracts ?? 0) > 0) contracts = Math.min(contracts, legContract.maxContracts!); + contracts = Math.max(contracts, legContract.minContracts ?? 1); + // The real size is known now: re-check the daily-loss room with it. + if (dllOn) { + const estimate = dllStopDollars({ + room: item.dllRoom, dailyLossLimit: dll, entryFee: (pct / 100) * contracts * stepNotional, commissionPct: pct, + }); + if (estimate < DLL_MIN_STOP) { + throw new Error(`daily loss limit: $${round2(item.dllRoom)} room leaves a $${round2(estimate)} stop at this size — not trading`); + } + } + } else { + const rawContracts = Math.max(1, Math.ceil(legAmount / 1000)); + contracts = cfg.maxPositionSize > 0 ? Math.min(rawContracts, cfg.maxPositionSize) : rawContracts; + } + } catch (err) { + unrecordLeg(acc.id, tradeDate, legAmount); + throw err; + } + + const fill = await sendEntry(tag, client, acc, legContract.name, contracts, action, tradeDate, legAmount); + const exitAction: 'Buy' | 'Sell' = action === 'Buy' ? 'Sell' : 'Buy'; + + // The entry fee. Percent firms: straight off the fill. Otherwise wait + // briefly for the cash balance update: realizedPnL drops by the entry + // commission, and the delta from the pre-entry value is the fee (the + // pre-entry value may be nonzero mid-day). The take-profit falls back to + // $2.50/contract if the update hasn't landed (at least one extra tick). + let entryFee: number; + let measured: number | null = null; + if (pct > 0) { + entryFee = percentFee(pct, fill.price, pv, contracts); + } else { + await new Promise(r => setTimeout(r, 1000)); + const updatedCash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 }; + measured = entryCommissionMeasured(item.cash.realizedPnL, updatedCash.realizedPnL, contracts); + entryFee = measured ?? FALLBACK_COMMISSION_PER_CONTRACT * contracts; + } + const tp = takeProfitPlan({ + side: action, entry: fill.price, leg: legAmount, pointValue: pv, contracts, + tickSize: legContract.tickSize, commissionPct: pct, entryCommission: entryFee, + }); + + // The stop. On a measured-fee firm whose fee didn't show, the daily-loss + // stop reserves the worst per-contract fee, not the $2.50 fallback. + const dllFee = pct > 0 ? entryFee : (measured ?? MAX_COMMISSION_PER_CONTRACT * contracts); + const dllDollars = dllOn ? dllStopDollars({ room: item.dllRoom, dailyLossLimit: dll, entryFee: dllFee, commissionPct: pct }) : Infinity; + const ticks = stopTicks({ obfuscationDollars: obfuscation, dllDollars, pointValue: pv, contracts, tickSize: legContract.tickSize }); + if (ticks === 0) { + await flattenAndFail(tag, client, acc, legContract.name, contracts, exitAction, fill.price, + `daily-loss room ($${round2(item.dllRoom)}) doesn't cover a one-tick stop`); + } + + let exitOrder: { orderId?: number }; + let stopPrice: number | null = null; + if (ticks !== null) { + stopPrice = action === 'Buy' + ? fill.price - (ticks * legContract.tickSize) + : fill.price + (ticks * legContract.tickSize); + try { + exitOrder = await client.placeOcoExit(acc.id, legContract.name, contracts, exitAction, tp.exitPrice, stopPrice); + } catch (err) { + // An unknown outcome (the ack timed out, the connection went after + // the send) means the pair may be working, and a second exit on top + // doubles it — both filling flips the account into an opposite + // position nothing manages. Place nothing more then. + if (!isOrderNotPlaced(err)) { + console.error(`${tag} ${acc.name}: placeoco outcome UNKNOWN (${errorMessage(err)}) — NOT placing a second exit; check this position's exits at the broker`); + throw new Error(`entry filled @ ${fill.price} but the exit's outcome is unknown — ${errorMessage(err)}`); + } + // The pair never went in. With a hard daily loss limit the position + // must not ride without its stop: flatten and sit out the day. + if (dllOn) { + (runtime().exitRefused ??= new Map()).set(acc.id, tradeDate); + console.error(`${tag} ${acc.name}: placeoco REJECTED (${errorMessage(err)}) with a daily loss limit — flattening`); + return flattenAndFail(tag, client, acc, legContract.name, contracts, exitAction, fill.price, + `exit bracket refused (${errorMessage(err)})`); + } + // No limit: the position is already open — never leave it exit-less. + console.error(`${tag} ${acc.name}: placeoco REJECTED (${errorMessage(err)}) — falling back to lone limit exit, NO STOP`); + stopPrice = null; + exitOrder = await client.placeOrderNoWait(acc.id, legContract.name, contracts, exitAction, 'Limit', tp.exitPrice); + } + } else { + exitOrder = await client.placeOrderNoWait(acc.id, legContract.name, contracts, exitAction, 'Limit', tp.exitPrice); + } + + const instrumentLabel = legContract.stepNotional || item.drain ? legContract.name : symbolLabel; + const sizeNote = sizing + ? ` | notional $${Math.round(contracts * (legContract.stepNotional ?? 0))} (${sizing.mode}` + + `${sizing.predictedHoldHours !== null ? `, hold ~${sizing.predictedHoldHours.toFixed(1)}h` : ''}` + + `${sizing.winProbability !== null ? `, win ${Math.round(sizing.winProbability * 100)}%` : ''})` + : ''; + const stopNote = stopPrice !== null + ? ` / stop @ ${stopPrice}` + + (dllOn ? ` (dll room $${round2(item.dllRoom)} → $${round2(dllDollars)}${Number.isFinite(obfuscation) && obfuscation > 0 ? `, obf $${round2(obfuscation)}` : ''})` : '') + : ''; + console.log(`${tag} ${acc.name} (${item.firmName}) ${action} ${contracts}x${instrumentLabel} @ ${fill.price} | leg $${legAmount} of day $${item.plan.dayTarget} (remaining $${item.plan.remainingToday}) [${item.plan.path}${item.drain ? ' drain' : ''}] (+$${tp.totalCommission.toFixed(2)} comm)${sizeNote} | exit @ ${tp.exitPrice}${stopNote} (orderId=${exitOrder.orderId})`); + + return { + account: acc.name, + firm: item.firmName, + status: 'filled', + contracts, + target: legAmount, + dayTarget: item.plan.dayTarget, + remainingBefore: item.plan.remainingToday, + grossTarget: tp.grossTarget, + totalCommission: tp.totalCommission, + targetPath: item.plan.path, + entryPrice: fill.price, + exitPrice: tp.exitPrice, + stopPrice, + commission: entryFee, + }; +} + // ── copy trade ─────────────────────────────────────────────────────────────── /** * Copy the current trade direction to up to maxConcurrent accounts. * Finds accounts with open positions, determines direction, then fires - * orders for eligible accounts that haven't traded yet. + * orders for eligible accounts that haven't traded yet — within the lane of + * the first open position found, never across lanes. */ export async function copyTrade() { console.log('[copy-trade] called'); - // Shares runTrade's mutex: a copy landing mid-batch would see the batch's + const positioned = firmClients().ready + .find(({ client }) => client.accountList.some((acc) => !!client.positions[acc.id])); + if (!positioned) { + console.log('[copy-trade] no open positions to copy from'); + return []; + } + const lane = laneOf(positioned.firm); + // Shares the batch's mutex: a copy landing mid-batch would see the batch's // accounts as still flat and could fill past max_concurrent_accounts. - const lease = acquireTradeMutex('[copy-trade]'); + const lease = acquireTradeMutex('[copy-trade]', lane); if (lease === null) return []; try { - return await copyTradeLocked(lease); + return await copyTradeLocked(lease, lane); } finally { - releaseTradeMutex(lease); + releaseTradeMutex(lease, lane); } } -async function copyTradeLocked(lease: number): Promise { - if (isInNoTradeWindow()) { +async function copyTradeLocked(lease: number, lane: string): Promise { + const laneFirms = getTradableFirms().filter((f) => laneOf(f) === lane); + if (laneFirms.every((f) => isInNoTradeWindow(f))) { console.log('[copy-trade] outside trading hours — skipping'); return []; } // Ready clients only, as in runTrade: a stale snapshot must neither be // copied from, counted against the slots, nor traded. - const { ready, unready } = firmClients(); + const all = firmClients(); + const ready = inLane(all.ready, lane); + const unready = inLane(all.unready, lane); if (unready.length > 0) { console.log(`[copy-trade] not synced — leaving out: ${unready.map((u) => u.firm.name).join(', ')}`); } @@ -1007,8 +1193,8 @@ async function copyTradeLocked(lease: number): Promise { // Find all accounts with open positions to determine direction + symbol let resolvedAction: 'Buy' | 'Sell' | null = null; - // number on Tradovate, string on TopstepX — only ever compared against - // contracts from the same client, so the union stays coherent. + // number on Tradovate, string on TopstepX/DXTrade — only ever compared + // against contracts from the same client, so the union stays coherent. let positionContractId: number | string | null = null; let positionedCount = 0; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -1063,12 +1249,7 @@ async function copyTradeLocked(lease: number): Promise { const state = getState(); resolvedSymbol = state.symbol !== 'Auto' ? state.symbol : (enabledSymbols[0] ?? 'NQ'); } - - const pointValue = POINT_VALUES[resolvedSymbol]; - if (!pointValue) { - console.log(`[copy-trade] unknown symbol ${resolvedSymbol}`); - return []; - } + const basePointValue: number | undefined = POINT_VALUES[resolvedSymbol]; const slotsAvailable = maxConcurrent - positionedCount; if (slotsAvailable <= 0) { @@ -1079,21 +1260,9 @@ async function copyTradeLocked(lease: number): Promise { const tradeDate = currentTradeDateCT(); // Collect eligible accounts (same logic as Phase 1 of runTrade) - type CopyItem = { - firmName: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - client: any; - acc: { id: number; name: string; active: boolean }; - contract: { name: string; tickSize: number }; - cash: { amount: number; realizedPnL: number }; - dailyPnL: { date: string; pnl: number }[]; - plan: DayPlan; - drain: boolean; - prefix: string; - }; - const eligible: CopyItem[] = []; + const eligible: LegItem[] = []; - await Promise.all(ready.map(async ({ firm, client }) => { + await Promise.all(ready.filter(({ firm }) => !isInNoTradeWindow(firm)).map(async ({ firm, client }) => { if (client.accountList.length === 0) { console.log(`[copy-trade] ${firm.name}: empty account list — skip firm`); return; @@ -1110,8 +1279,13 @@ async function copyTradeLocked(lease: number): Promise { console.log(`[copy-trade] ${firm.name}: cannot resolve front-month contract for ${resolvedSymbol} — skip firm`); return; } + const pv = contract.pointValue ?? basePointValue; + if (!pv) { + console.log(`[copy-trade] ${firm.name}: no point value for ${resolvedSymbol} — skip firm`); + return; + } - const found: CopyItem[] = []; + const found: LegItem[] = []; for (const acc of client.accountList) { const ev = evaluateAccountForTrade(client, acc, tradeDate); if (!ev) { console.log(`[copy-trade] ${acc.name}: no matching config prefix — skip`); continue; } @@ -1120,9 +1294,10 @@ async function copyTradeLocked(lease: number): Promise { if (ev.isExtraDay) { console.log(`[copy-trade] ${acc.name}: extra-day account — skip`); continue; } found.push({ - firmName: firm.name, client, acc, contract, cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan, + firmName: firm.name, firm, client, acc, contract, pv, cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan, drain: ev.drain, prefix: getAccountConfig(acc.id).accountType?.prefix ?? '', + dllRoom: ev.dllRoom, }); } eligible.push(...found); @@ -1133,7 +1308,7 @@ async function copyTradeLocked(lease: number): Promise { // Fill the open slots with accounts whose prefix matches the already-positioned // accounts before falling back to mismatched prefixes. - const matchesPositioned = (item: CopyItem) => positionedPrefixes.has(item.prefix); + const matchesPositioned = (item: LegItem) => positionedPrefixes.has(item.prefix); const ordered = [...eligible.filter(matchesPositioned), ...eligible.filter((i) => !matchesPositioned(i))]; const batch = ordered.slice(0, slotsAvailable); if (batch.length === 0) { @@ -1142,7 +1317,7 @@ async function copyTradeLocked(lease: number): Promise { } // Superseded while collecting (see runTrade) — this selection is stale. - if (!holdsTradeMutex(lease)) { + if (!holdsTradeMutex(lease, lane)) { console.warn('[copy-trade] superseded while collecting accounts — abandoning this copy'); return []; } @@ -1151,85 +1326,10 @@ async function copyTradeLocked(lease: number): Promise { runtime().lastFireAt = Date.now(); // Fire orders (same as Phase 2 of runTrade, accounts marked in flight the same way) - const tradeResults = await Promise.allSettled(batch.map((item) => withAccountInFlight(item.acc.id, async () => { - const { client, acc, contract } = item; - const cfg = getAccountConfig(acc.id).accountType!; - - const minLegs = item.plan.dayTarget > BIG_DAY_THRESHOLD ? (cfg.minLegs ?? 0) : 0; - const legAmount = item.plan.splitAllowed - ? pickLegAmount( - item.plan.remainingToday, - () => groupLegFraction(item.prefix, tradeDate, legsToday(acc.id, tradeDate)), - minLegs - legsToday(acc.id, tradeDate), - ) - : item.plan.remainingToday; - // Counted before sending, taken back only if the entry never reached the broker (see runTrade). - recordLeg(acc.id, tradeDate, legAmount); - - const inst = await legInstrument(client, item.drain, contract, pointValue) - .catch((err: unknown) => { unrecordLeg(acc.id, tradeDate, legAmount); throw err; }); - const legContract = inst.contract; - const pv = inst.pointValue; - - const rawContracts = Math.max(1, Math.ceil(legAmount / 1000)); - const contracts = cfg.maxPositionSize > 0 ? Math.min(rawContracts, cfg.maxPositionSize) : rawContracts; - const fill = await sendEntry('[copy-trade]', client, acc, legContract.name, contracts, resolvedAction!, tradeDate, legAmount); - - await new Promise(r => setTimeout(r, 1000)); - const updatedCash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 }; - const entryCommission = entryCommissionFrom(item.cash.realizedPnL, updatedCash.realizedPnL, contracts); - const totalCommission = entryCommission * 2; - const grossTarget = legAmount + totalCommission; - - const targetPoints = grossTarget / (pv * contracts); - const ticks = Math.ceil(targetPoints / legContract.tickSize); - const exitPrice = resolvedAction === 'Buy' - ? fill.price + (ticks * legContract.tickSize) - : fill.price - (ticks * legContract.tickSize); - - const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy'; - const stopDollars = obfuscationStopDollars(cfg, item.dailyPnL, item.plan, tradeDate, legAmount); - let exitOrder: { orderId?: number }; - let stopPrice: number | null = null; - if (Number.isFinite(stopDollars) && stopDollars > 0) { - const stopTicks = Math.max(1, Math.ceil(stopDollars / (pv * contracts) / legContract.tickSize)); - stopPrice = resolvedAction === 'Buy' - ? fill.price - (stopTicks * legContract.tickSize) - : fill.price + (stopTicks * legContract.tickSize); - try { - exitOrder = await client.placeOcoExit(acc.id, legContract.name, contracts, exitAction, exitPrice, stopPrice); - } catch (err) { - // Lone-limit fallback only for a pair that never went in (see runTrade). - if (!isOrderNotPlaced(err)) { - console.error(`[copy-trade] ${acc.name}: placeoco outcome UNKNOWN (${errorMessage(err)}) — NOT placing a second exit; check this position's exits at the broker`); - throw new Error(`entry filled @ ${fill.price} but the exit's outcome is unknown — ${errorMessage(err)}`); - } - console.error(`[copy-trade] ${acc.name}: placeoco REJECTED (${errorMessage(err)}) — falling back to lone limit exit, NO STOP`); - stopPrice = null; - exitOrder = await client.placeOrderNoWait(acc.id, legContract.name, contracts, exitAction, 'Limit', exitPrice); - } - } else { - exitOrder = await client.placeOrderNoWait(acc.id, legContract.name, contracts, exitAction, 'Limit', exitPrice); - } - - console.log(`[copy-trade] ${acc.name} (${item.firmName}) ${resolvedAction} ${contracts}x${item.drain ? legContract.name : resolvedSymbol} @ ${fill.price} | leg $${legAmount} of day $${item.plan.dayTarget} (remaining $${item.plan.remainingToday}) [${item.plan.path}${item.drain ? ' drain' : ''}] | exit @ ${exitPrice}${stopPrice !== null ? ` / stop @ ${stopPrice}` : ''} (orderId=${exitOrder.orderId})`); - - return { - account: acc.name, - firm: item.firmName, - status: 'filled', - contracts, - target: legAmount, - dayTarget: item.plan.dayTarget, - remainingBefore: item.plan.remainingToday, - grossTarget, - totalCommission, - targetPath: item.plan.path, - entryPrice: fill.price, - exitPrice, - commission: entryCommission, - }; - }))); + const tradeResults = await Promise.allSettled(batch.map((item) => withAccountInFlight( + item.acc.id, + () => fireLeg('[copy-trade]', item, resolvedAction!, resolvedSymbol!, tradeDate), + ))); const results: unknown[] = []; for (let i = 0; i < batch.length; i++) { @@ -1266,14 +1366,13 @@ function hasRemainingConfiguredAccounts(): boolean { // the ready firms every session after — exactly what the operator opted // out of. (Before an open with every ready account already done, this // stops rather than wait on the unready firm: the safe side.) - if (!isInNoTradeWindow()) { - for (const { client } of unready) { - if (client.lastSyncAt === 0 && client.accountList.length === 0) return true; - if (client.accountList.some((acc) => getAccountConfig(acc.id).accountType)) return true; - } + for (const { firm, client } of unready) { + if (isInNoTradeWindow(firm)) continue; + if (client.lastSyncAt === 0 && client.accountList.length === 0) return true; + if (client.accountList.some((acc) => getAccountConfig(acc.id).accountType)) return true; } - for (const { client } of ready) { + for (const { firm, client } of ready) { if (client.accountList.length === 0) continue; for (const acc of client.accountList) { @@ -1287,12 +1386,12 @@ function hasRemainingConfiguredAccounts(): boolean { const ev = evaluateAccountForTrade(client, acc, tradeDate); if (ev?.eligible) return true; - // Waiting on its history, not done: it may still owe today's trade — - // during the session and before it opens alike (a restart between - // the 5 PM roll and the open loads histories right then). Only - // once the session has closed is a still-loading account let go. - if ((ev?.reason === HISTORY_PENDING || ev?.reason === ROLL_PENDING || ev?.reason === ENTRY_UNKNOWN) - && !isAfterSessionClose()) return true; + // Waiting on its history (or its day-start balance), not done: it may + // still owe today's trade — during the session and before it opens + // alike (a restart between the 5 PM roll and the open loads + // histories right then). Only once the firm's session has closed is + // a still-loading account let go. + if (ev && PENDING_REASONS.has(ev.reason) && !isAfterSessionClose(firm)) return true; } } @@ -1356,36 +1455,49 @@ export function startScheduler(action: 'Buy' | 'Sell' | 'Auto', symbol: string, rt.lastTickAt = now; try { - // Hold the tick while any firm's client is still inside its sync - // grace period (positions may not be populated yet); trade around - // firms that have been out past it (see assessReadiness). + // Hold a lane while any of its firms' clients is still inside its + // sync grace period (positions may not be populated yet); trade + // around firms that have been out past it (see assessReadiness). + // Lanes hold, gate and fire independently. const { ready, unready } = firmClients(now); const { holding, quarantined } = assessReadiness(ready, unready, now); rt.holding = holding.map((f) => f.name); rt.quarantined = quarantined.map((q) => q.firm.name); - if (holding.length > 0) { - rt.lastTickOutcome = 'holding-for-sync'; - console.log(`[scheduler] waiting for sync: ${rt.holding.join(', ')}`); - return; - } for (const { firm, unreadyMs } of quarantined) { if (now - (rt.quarantineLoggedAt.get(firm.id) ?? 0) < QUARANTINE_LOG_EVERY_MS) continue; rt.quarantineLoggedAt.set(firm.id, now); console.warn(`[scheduler] ${firm.name} not synced for ${Math.round(unreadyMs / 60_000)}m — trading the other firms without it`); } - // Skip this tick if any account still has an open position from the - // previous batch — one batch fires only when every (ready) account is flat. - const openPositions = countOpenPositions(ready); - if (openPositions > 0) { - rt.lastTickOutcome = 'positions-open'; - console.log(`[scheduler] ${openPositions} position(s) still open — skipping tick`); - return; + const outcomes = new Map(); + let filled = 0; + for (const lane of tradableLanes()) { + try { + const laneHolding = holding.filter((f) => laneOf(f) === lane); + if (laneHolding.length > 0) { + outcomes.set(lane, 'holding-for-sync'); + console.log(`[scheduler] ${laneName(lane)}: waiting for sync: ${laneHolding.map((f) => f.name).join(', ')}`); + continue; + } + // Skip this lane if any of its accounts still has an open + // position from its previous batch — a batch fires only + // when every (ready) account in the lane is flat. + const openPositions = countOpenPositions(inLane(ready, lane)); + if (openPositions > 0) { + outcomes.set(lane, 'positions-open'); + console.log(`[scheduler] ${laneName(lane)}: ${openPositions} position(s) still open — skipping tick`); + continue; + } + const { outcome, results } = await runTradeBatch(state.action, state.symbol, lane); + outcomes.set(lane, outcome); + filled += results.flatMap((r) => r.results).filter((r) => r.status === 'filled').length; + } catch (err) { + outcomes.set(lane, `error: ${errorMessage(err)}`); + console.error(`[scheduler] ${laneName(lane)}: tick error: ${errorMessage(err)}`); + } } - - const { outcome, results } = await runTradeBatch(state.action, state.symbol); - rt.lastTickOutcome = outcome; - const filled = results.flatMap((r) => r.results).filter((r) => r.status === 'filled').length; + rt.laneOutcomes = outcomes; + rt.lastTickOutcome = overallOutcome([...outcomes.values()]); if (filled > 0) console.log(`[scheduler] tick: ${filled} account(s) filled`); // Auto-stop if user opted in and no configured accounts can trade anymore @@ -1403,6 +1515,19 @@ export function startScheduler(action: 'Buy' | 'Sell' | 'Auto', symbol: string, console.log(`[scheduler] started — ${action} ${symbol} every ${intervalSecs}s`); } +/** One outcome for the whole tick, for the master and /api/health: the most + * telling lane's (a fill beats an error beats waiting beats nothing to do). */ +function overallOutcome(outcomes: TickOutcome[]): TickOutcome { + if (outcomes.length === 0) return 'no-eligible'; + const rank = (o: TickOutcome): number => { + if (o === 'fired') return 0; + if (o.startsWith('error')) return 1; + return ['skipped-in-flight', 'positions-open', 'holding-for-sync', 'no-eligible', 'closed', 'not-running'] + .indexOf(o) + 2; + }; + return [...outcomes].sort((a, b) => rank(a) - rank(b))[0]; +} + export function stopScheduler() { const state = getState(); if (state.intervalId !== null) { @@ -1420,6 +1545,7 @@ export function stopScheduler() { rt.holding = []; rt.quarantined = []; rt.lastTickOutcome = 'not-running'; + rt.laneOutcomes = new Map(); setSetting('scheduler_running', '0'); console.log('[scheduler] stopped'); @@ -1461,9 +1587,11 @@ export function getSchedulerStatus() { * Why the scheduler is or isn't trading, for health checks — separate from * getSchedulerStatus, whose shape is what the master receives. `holding` are * the firms the last tick waited on (inside their sync grace period); - * `quarantined` the ones past it, traded around. Timestamps are epoch ms, - * 0 = never; lastFireAt counts batches from any caller, not just the tick. - * Until the first tick after a start, the outcome still reads 'not-running'. + * `quarantined` the ones past it, traded around. `lanes` is the last tick's + * outcome per lane (see laneOf), keyed by lane name. Timestamps are epoch ms, + * 0 = never; lastFireAt counts batches from any caller, not just the tick; + * tradeInFlightSince is the oldest lane batch still in flight. Until the + * first tick after a start, the outcome still reads 'not-running'. */ export function getSchedulerHealth(): { lastTickAt: number; @@ -1472,15 +1600,20 @@ export function getSchedulerHealth(): { holding: string[]; quarantined: string[]; tradeInFlightSince: number; + lanes: Record; } { const running = getState().running; const rt = runtime(); + const inFlight = [...(rt.tradeInFlight?.values() ?? [])]; return { lastTickAt: rt.lastTickAt, lastTickOutcome: running ? rt.lastTickOutcome : 'not-running', lastFireAt: rt.lastFireAt, holding: running ? [...rt.holding] : [], quarantined: running ? [...rt.quarantined] : [], - tradeInFlightSince: rt.tradeInFlightSince, + tradeInFlightSince: inFlight.length > 0 ? Math.min(...inFlight) : 0, + lanes: running + ? Object.fromEntries([...(rt.laneOutcomes ?? new Map())].map(([lane, o]) => [laneName(lane), o])) + : {}, }; } diff --git a/lib/clients.ts b/lib/clients.ts index f4d3de3..7f79821 100644 --- a/lib/clients.ts +++ b/lib/clients.ts @@ -1,5 +1,7 @@ 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'; @@ -43,15 +45,18 @@ function credsMap(): Map { 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 +78,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; @@ -316,11 +341,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 +413,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 +459,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); diff --git a/lib/dxtrade-class.ts b/lib/dxtrade-class.ts new file mode 100644 index 0000000..e43f736 --- /dev/null +++ b/lib/dxtrade-class.ts @@ -0,0 +1,1687 @@ +/** + * DXtrade client — the Devexperts DXtrade twin of TradovateClient and + * TopstepXClient, first for Tradeify247 (crypto, tokenized stocks, index and + * commodity CFDs, 24/7). + * + * REST only: https:///dxsca-web (the firm's server URL on the master). + * POST /login {username, domain, password} → a session token, sent as + * `Authorization: DXAPI ` and kept alive with POST /ping. Everything + * is polled: a 60s sync of accounts, metrics, positions, open orders and the + * day's cash transactions; a fast poll while an order waits for its fill or + * an exit pair is live. (The Push API websocket would make fills quicker; it + * waits until the smoke test has confirmed its message format.) + * + * Differences from the futures platforms that shape this file: + * - Account codes are strings ("clearing:account"); ids are a stable hash of + * them (dxAccountId) so every numeric account table keeps working. + * - There are no CME products. The firm's symbol map (master Firms page) says + * which DXtrade symbols a CME root may trade as, and findFrontMonthContract + * picks the cheapest per unit of volatility right now (scoreCandidate). + * One engine "contract" is one quantity step of the symbol, and the step + * rides in the contract name, so the engine's target/stop math is unchanged. + * - Quantities are units, and positions are closed by positionCode + * (positionEffect CLOSE): an exit order can't open or flip a position. + * - Commission is a percent of notional; the engine computes it itself. + * - Cash transactions (SETTLEMENT, COMMISSION, FINANCING, DEPOSIT, + * WITHDRAWAL…) are the books: daily P&L, fund transactions, today's + * realized P&L and the balance at the firm's 22:00 UTC reset all come from + * them. + * - No auto-liquidation profile: autoLiqThresholds stays 0; a breached + * account shows as accountStatus ≠ FULL_TRADING → active=false. + */ + +import axios from 'axios'; +import { currentTradeDateCT, FIRM_RESET_UTC_HOUR } from './trading-logic'; +import { + saveDailyPnL, loadDailyPnL, loadAccountMeta, saveAccountMeta, loadFundTransactions, saveFundTransactions, + saveEndedCycleStart, +} from './db'; +import { getAccountConfig } from './account-type-resolver'; +import { getCachedFirm } from './firm-catalog'; +import type { SymbolMapCandidate } from './master-protocol'; +import type { PlatformClient, PlatformAccount, PlatformContract, ClientHealth } from './platform-client'; +import { OrderNotPlacedError, isOrderNotPlaced, errorMessage } from './platform-client'; +import { + dxAccountId, accountDisplayName, accountCodeOf, encodeContractName, decodeContractName, roundToStep, + lastUtcReset, ctTradeDayStart, timeOf, parseDurationMs, ciNum, isFundType, signedFundValue, newOrderCode, + inTradingHours, realizedVolPerHour, scoreCandidate, candidateRejection, +} from './dxtrade-helpers'; + +// ── timings ────────────────────────────────────────────────────────────────── + +const SYNC_INTERVAL_MS = 60_000; +const SYNC_WEDGED_MS = 3 * 60_000; +const WATCHDOG_INTERVAL_MS = 60_000; +/** Logged in longer than this with no good sync for as long → re-login. */ +const SESSION_STALE_MS = 10 * 60_000; +/** Transport failures and 5xx back off 60s → 10 min, ±20%. */ +const LOGIN_RETRY_BASE_MS = 60_000; +const LOGIN_RETRY_MAX_MS = 10 * 60_000; +/** Refused credentials: DXtrade locks an account after repeated failures, so + * retry slowly and then stop until someone fixes the login. */ +const CREDS_RETRY_MS = [15 * 60_000, 60 * 60_000]; +const CREDS_MAX_ATTEMPTS = 3; +/** Every DXTrade client on the machine shares one IP's login limit (1/s). */ +const LOGIN_SPACING_MS = 1_200; +/** Request spacing per bucket, under the 10/s read and trade limits and the + * 1/s limit on large/historical queries. */ +const SPACING_MS = { read: 125, trade: 125, history: 1_100 } as const; +type Bucket = keyof typeof SPACING_MS; +const TIMEOUT_MS = { read: 30_000, trade: 15_000, history: 60_000 } as const; +/** Account codes per multi-account request. */ +const ACCOUNT_CHUNK = 25; +const FILL_POLL_MS = 750; +const FILL_TIMEOUT_MS = 30_000; +const OCO_POLL_MS = 5_000; +/** A pick is reused this long, so a batch's lookups (Phase 1, drain legs) + * all land on the same symbol. */ +const PICK_TTL_MS = 45_000; +const INSTRUMENT_TTL_MS = 60 * 60_000; +const ACCOUNT_INSTRUMENT_TTL_MS = 10 * 60_000; +const CANDLE_TTL_MS = 15 * 60_000; +const CANDLE_HOURS = 48; +/** History is fetched this far back at most (or from registration). */ +const HISTORY_MAX_DAYS = 180; +/** Balance the books can't explain by this much holds the account. */ +const BOOKS_TOLERANCE = 200; +/** An unknown-outcome order stops holding its account this long after, once + * the account shows no working order. */ +const SUSPECT_SETTLE_MS = 90_000; +/** Our own orders (af-…) left working on a flat account this long are reaped. */ +const REAP_AFTER_MS = 60_000; +/** A position this old with no working order is flagged as unprotected. */ +const NAKED_AFTER_MS = 2 * 60_000; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const round2 = (n: number) => Math.round(n * 100) / 100; + +// ── wire types (loose: the server's serializer and the docs disagree) ─────── + +interface DxPosition { + account: string; + positionCode: string; + symbol: string; + quantity: number; + side: 'BUY' | 'SELL'; + openTime: number; + openPrice: number; +} + +interface DxOrder { + account: string; + orderId: number | undefined; + orderCode: string; + version: number | undefined; + type: string; + status: string; + finalStatus: boolean; + instrument: string; + positionCode: string | undefined; + filledQuantity: number; + averagePrice: number | undefined; + issueTime: number; + rejectReason: string | undefined; +} + +interface DxTxn { + key: string; + account: string; + type: string; + value: number; + time: number; + orderCode: string | undefined; +} + +/** DXtrade answered with an error body. */ +class DxApiError extends Error { + constructor(message: string, readonly status: number, readonly code: number | undefined) { + super(message); + this.name = 'DxApiError'; + } +} + +function parsePosition(raw: unknown): DxPosition | null { + const p = raw as Record; + const account = accountCodeOf(p?.account); + if (!account || typeof p.symbol !== 'string') return null; + const quantity = Math.abs(ciNum(p, 'quantity') ?? 0); + if (!(quantity > 0)) return null; + return { + account, + positionCode: String(p.positionCode ?? ''), + symbol: p.symbol, + quantity, + side: p.side === 'SELL' ? 'SELL' : 'BUY', + openTime: timeOf(p.openTime), + openPrice: ciNum(p, 'openPrice') ?? NaN, + }; +} + +function parseOrder(raw: unknown): DxOrder | null { + const o = raw as Record; + const account = accountCodeOf(o?.account); + if (!account || typeof o.orderCode !== 'string') return null; + const legs = Array.isArray(o.legs) ? (o.legs as Record[]) : []; + const leg = legs[0] ?? {}; + const executions = Array.isArray(o.executions) ? (o.executions as Record[]) : []; + const rejected = executions.find((e) => typeof e.rejectReason === 'string'); + return { + account, + orderId: ciNum(o, 'orderId'), + orderCode: o.orderCode, + version: ciNum(o, 'version'), + type: String(o.type ?? ''), + status: String(o.status ?? ''), + finalStatus: o.finalStatus === true, + instrument: String(o.instrument ?? leg.instrument ?? ''), + positionCode: typeof leg.positionCode === 'string' ? leg.positionCode : undefined, + filledQuantity: ciNum(leg, 'filledQuantity') ?? 0, + averagePrice: ciNum(leg, 'averagePrice'), + issueTime: timeOf(o.issueTime), + rejectReason: rejected ? String(rejected.rejectReason) : undefined, + }; +} + +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; + const txns = Array.isArray(transfer.cashTransactions) ? transfer.cashTransactions : []; + for (const x of txns) { + const tx = x as Record; + 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 : ''; + out.push({ + key: code || `${transfer.transferCode ?? ''}|${tx.type}|${time}|${value}|${tx.orderCode ?? ''}|${tx.tradeCode ?? ''}`, + account, + type: tx.type, + value, + time, + orderCode: typeof tx.orderCode === 'string' ? tx.orderCode : undefined, + }); + } + } + return out; +} + +function chunks(items: T[], size: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size)); + return out; +} + +const g = globalThis as typeof globalThis & { __dxLoginNextAt?: number }; + +export interface DXTradeConfig { + firmId: string; + firmName: string; + /** REST base, e.g. https://dx.tradeify247.co/dxsca-web */ + serverUrl: string; + /** Login domain ('' = 'default'). */ + loginDomain: string; + username: string; + password: string; +} + +export class DXTradeClient implements PlatformClient { + private readonly cfg: DXTradeConfig; + private readonly base: string | null; + private readonly host: string; + private readonly domain: string; + private token: string | null = null; + private sessionTimeoutMs = 30 * 60_000; + private lastRequestAt = 0; + private lastResponseAt = 0; + /** Which users path answered: "login@domain" or the bare login. */ + private userPath: string | null = null; + + public accountList: PlatformAccount[] = []; + public accountCashBalances: { [accountId: number]: { amount: number; realizedPnL: number; equity?: number } } = {}; + public positions: { + [accountId: number]: { contractId: string; netPos: number; netPrice: number; timestamp: Date }; + } = {}; + + public daysTraded: { [accountId: number]: number } = {}; + public dailyPnL: { [accountId: number]: { date: string; pnl: number }[] } = {}; + public fullDailyPnL: { [accountId: number]: { date: string; pnl: number }[] } = {}; + public lastFundDates: { [accountId: number]: string | null } = {}; + public fundTransactions: { [accountId: number]: { date: string; amount: number }[] } = {}; + public priorProfit: { [accountId: number]: number } = {}; + public autoLiqThresholds: { [accountId: number]: number } = {}; + + public fetchDaysComplete = false; + public syncComplete = false; + public lastSyncAt = 0; + public lastSyncStartedAt = 0; + public lastFetchErrors: Record = {}; + public lastFetchRaw: Record = {}; + public recentEntityEvents: { entityType: string; eventType: string; entity: unknown; ts: number }[] = []; + /** Tradovate-only concept. */ + public products: { id: number; name: string }[] = []; + + private readonly onSync: () => Promise; + + private codeById = new Map(); + private idByCode = new Map(); + private accountMeta = new Map(); + private positionsDetail = new Map(); + private openOrders = new Map(); + /** The day's cash transactions (since the earlier of the CT trade-date + * start and the last firm reset), per account. */ + private recentTxns = new Map>(); + /** Accounts whose balance and day's transactions came back this pass. */ + private dayFresh = new Set(); + /** When each open position was first seen, for hold-time logging. */ + private seenOpenAt = new Map(); + + private syncInterval: ReturnType | null = null; + private pingInterval: ReturnType | null = null; + private watchdogInterval: ReturnType | null = null; + private ocoPollInterval: ReturnType | null = null; + private loginRetryTimer: ReturnType | null = null; + private intentionalDisconnect = false; + + private loginPromise: Promise | null = null; + private lastLoginAt = 0; + private lastLoginError: string | null = null; + private consecutiveLoginFailures = 0; + private credsFailures = 0; + private credsRejected = false; + private nextReconnectAt = 0; + + private syncInFlight = false; + private syncInFlightSince = 0; + private syncGeneration = 0; + private syncRunning: Promise = Promise.resolve(); + + private daysFetchInFlight = false; + private daysFetchInFlightSince = 0; + private lastDaysFetchStartedAt = 0; + private lastDaysFetchSucceededAt = 0; + private nextDaysFetchRetryAt = 0; + private historyLoaded = false; + private historyLoadedIds = new Set(); + private booksHeld = new Set(); + private fetchingIds = new Set(); + + private gateNext: Record = { read: 0, trade: 0, history: 0 }; + + /** Accounts whose last order ended with its outcome unknown, and since when. */ + private suspectAccounts = new Map(); + /** Opens in flight per symbol, for the hedge guard. */ + private openingSides = new Map(); + private fillWaiters: { orderId: number; callback: (fill: Record) => void }[] = []; + private pendingFills = 0; + + /** Exit pairs: when the position closes, whatever leg survives is cancelled. */ + private ocoPairs: { accountId: number; account: string; positionCode: string; codes: string[]; native: boolean; done: boolean; placedAt: number }[] = []; + /** Whether the server takes an OCO group ('no' after a definitive refusal). */ + private nativeOco: 'unknown' | 'no' = 'unknown'; + + private pickCache = new Map(); + private instrumentCache = new Map; at: number }>(); + private accountInstrumentCache = new Map; at: number }>(); + private candleCache = new Map(); + private unmappedLoggedAt = new Map(); + + constructor(cfg: DXTradeConfig, onSync: () => Promise) { + this.cfg = cfg; + this.onSync = onSync; + this.domain = cfg.loginDomain.trim() || 'default'; + let base: string | null = null; + let host = ''; + try { + const url = new URL(cfg.serverUrl); + if (url.protocol === 'https:') { + base = `${url.origin}${url.pathname.replace(/\/+$/, '')}`; + host = url.host; + } + } catch { /* reported below */ } + this.base = base; + this.host = host; + if (!base) { + this.lastLoginError = this.lastFetchErrors['login'] = 'DXtrade server URL not set on the master Firms page'; + console.error(`[dxtrade] ${cfg.firmName}: ${this.lastLoginError}`); + return; + } + this.watchdogInterval = setInterval(() => this.watchdog(), WATCHDOG_INTERVAL_MS); + void this.connectAndAuth(); + } + + private get tag(): string { + return `[dxtrade] ${this.cfg.firmName}:`; + } + + // ── transport ──────────────────────────────────────────────────────────── + + private async gate(bucket: Bucket): Promise { + const now = Date.now(); + const at = Math.max(now, this.gateNext[bucket]); + this.gateNext[bucket] = at + SPACING_MS[bucket]; + if (at > now) await sleep(at - now); + } + + /** + * One REST call. Non-2xx answers become DxApiError (status + DXtrade's + * numeric error code); transport failures become a plain Error carrying + * only the message — never the axios error, whose config holds the + * session token (and, for /login, the password). An expired session is + * renewed once and the call retried; a rate limit waits and retries once. + */ + private async request( + bucket: Bucket, + method: 'GET' | 'POST' | 'PUT' | 'DELETE', + path: string, + opts: { params?: Record; body?: unknown; headers?: Record; auth?: boolean; retried?: boolean } = {}, + ): Promise<{ data: T; headers: Record }> { + if (!this.base) throw new Error('DXtrade server URL not set'); + const auth = opts.auth !== false; + if (auth && !this.token) throw new DxApiError(`${path}: not logged in`, 401, 1); + await this.gate(bucket); + this.lastRequestAt = Date.now(); + let res; + try { + res = await axios.request({ + method, + url: `${this.base}${path}`, + params: opts.params, + data: opts.body, + headers: { + ...(auth ? { Authorization: `DXAPI ${this.token}` } : {}), + ...(opts.body !== undefined ? { 'Content-Type': 'application/json' } : {}), + Accept: 'application/json', + ...opts.headers, + }, + timeout: TIMEOUT_MS[bucket], + signal: AbortSignal.timeout(TIMEOUT_MS[bucket] + 5_000), + validateStatus: () => true, + }); + } catch (err) { + if (axios.isCancel(err)) throw new Error(`${path} timed out after ${TIMEOUT_MS[bucket] / 1000}s`); + throw new Error(`${path}: ${errorMessage(err)}`); + } + this.lastResponseAt = Date.now(); + const headers = Object.fromEntries(Object.entries(res.headers ?? {}).map(([k, v]) => [k.toLowerCase(), String(v)])); + if (res.status >= 200 && res.status < 300) return { data: res.data as T, headers }; + + const body = (res.data && typeof res.data === 'object' ? res.data : {}) as { errorCode?: unknown; description?: unknown }; + const code = Number(body.errorCode); + const description = typeof body.description === 'string' ? body.description : `HTTP ${res.status}`; + const error = new DxApiError(`${path}: ${description} (HTTP ${res.status}${Number.isFinite(code) ? `, code ${code}` : ''})`, + res.status, Number.isFinite(code) ? code : undefined); + + if (!opts.retried && auth && res.status === 401 && error.code !== 3) { + // Session expired (code 1) or dropped: log in again, then retry + // once. An order retries with its own orderCode, so a duplicate + // is refused (409 code 100), never placed twice. + this.token = null; + await this.connectAndAuth(); + if (this.token) return this.request(bucket, method, path, { ...opts, retried: true }); + } + if (!opts.retried && res.status === 429) { + const wait = Math.min(10_000, Math.max(1_000, Number(headers['retry-after'] ?? 1.5) * 1000)); + await sleep(wait); + return this.request(bucket, method, path, { ...opts, retried: true }); + } + throw error; + } + + // ── auth ───────────────────────────────────────────────────────────────── + + /** Log in, single-flight; a caller arriving mid-login waits for it. Never rejects. */ + private connectAndAuth(): Promise { + if (this.intentionalDisconnect || this.credsRejected || !this.base) return Promise.resolve(); + if (this.loginPromise) return this.loginPromise; + this.loginPromise = this.login().finally(() => { this.loginPromise = null; }); + return this.loginPromise; + } + + private async login(): Promise { + if (this.loginRetryTimer) { clearTimeout(this.loginRetryTimer); this.loginRetryTimer = null; } + this.nextReconnectAt = 0; + // One IP's login limit is shared by every DXTrade client here. + const now = Date.now(); + const at = Math.max(now, g.__dxLoginNextAt ?? 0); + g.__dxLoginNextAt = at + LOGIN_SPACING_MS; + if (at > now) await sleep(at - now); + try { + const { data } = await this.request<{ sessionToken?: string; timeout?: unknown }>('trade', 'POST', '/login', { + auth: false, + body: { username: this.cfg.username, domain: this.domain, password: this.cfg.password }, + }); + if (this.intentionalDisconnect) return; + if (!data?.sessionToken) throw new Error('/login answered without a session token'); + this.token = data.sessionToken; + this.sessionTimeoutMs = parseDurationMs(data.timeout); + this.lastLoginAt = Date.now(); + this.consecutiveLoginFailures = 0; + this.credsFailures = 0; + this.lastLoginError = null; + delete this.lastFetchErrors['login']; + console.log(`${this.tag} logged in as ${this.cfg.username}@${this.domain} (session idles out after ${Math.round(this.sessionTimeoutMs / 60_000)}m)`); + + if (this.pingInterval) clearInterval(this.pingInterval); + const pingEvery = Math.max(30_000, Math.min(this.sessionTimeoutMs / 3, 4 * 60_000)); + this.pingInterval = setInterval(() => void this.ping(), pingEvery); + + if (!this.syncInterval) { + this.syncInterval = setInterval(() => void this.requestSync(), SYNC_INTERVAL_MS); + } + void this.requestSync(); + } catch (err) { + if (this.intentionalDisconnect) return; + this.token = null; + this.consecutiveLoginFailures++; + const msg = errorMessage(err); + if (err instanceof DxApiError && err.status === 401) { + // Refused credentials (code 3) — or a locked account. Each + // retry risks the lock, so go slow, then stop. + this.credsFailures++; + const locked = /lock/i.test(msg); + if (locked || this.credsFailures >= CREDS_MAX_ATTEMPTS) { + this.credsRejected = true; + this.lastLoginError = this.lastFetchErrors['login'] = + `DXtrade refused the login for ${this.cfg.username} (domain '${this.domain}')` + + `${locked ? ' — the account is LOCKED' : ` ${this.credsFailures}×`}; stopped retrying.` + + ' Check the login domain on the master Firms page and re-save the credentials here.'; + console.error(`${this.tag} ${this.lastLoginError}`); + return; + } + const delay = CREDS_RETRY_MS[Math.min(this.credsFailures - 1, CREDS_RETRY_MS.length - 1)]; + this.lastLoginError = this.lastFetchErrors['login'] = `${msg} — refused credentials, retrying in ${Math.round(delay / 60_000)}m`; + this.scheduleLogin(delay); + console.error(`${this.tag} ${this.lastLoginError}`); + return; + } + const backoff = Math.min(LOGIN_RETRY_BASE_MS * 2 ** Math.min(this.consecutiveLoginFailures - 1, 10), LOGIN_RETRY_MAX_MS); + const delay = Math.round(backoff * (0.8 + Math.random() * 0.4)); + this.lastLoginError = this.lastFetchErrors['login'] = msg; + this.scheduleLogin(delay); + console.error(`${this.tag} login failed: ${msg} — attempt ${this.consecutiveLoginFailures}, retrying in ${Math.round(delay / 1000)}s`); + } + } + + private scheduleLogin(delay: number): void { + this.nextReconnectAt = Date.now() + delay; + this.loginRetryTimer = setTimeout(() => { + this.loginRetryTimer = null; + this.nextReconnectAt = 0; + void this.connectAndAuth(); + }, delay); + } + + private async ping(): Promise { + if (!this.token || this.intentionalDisconnect) return; + // Any request resets the idle timer; only ping when nothing has. + if (Date.now() - this.lastRequestAt < this.sessionTimeoutMs / 3) return; + try { + await this.request('read', 'POST', '/ping'); + } catch (err) { + console.warn(`${this.tag} ping failed: ${errorMessage(err)}`); + } + } + + /** Every failure path leaves a retry scheduled; this catches the ones + * that don't, and sessions that died without anything failing loudly. */ + private watchdog(): void { + if (this.intentionalDisconnect || this.credsRejected || this.loginPromise || this.loginRetryTimer) return; + const now = Date.now(); + if (!this.token) { + console.warn(`${this.tag} watchdog: no session and no login pending — logging in`); + void this.connectAndAuth(); + return; + } + if (now - this.lastLoginAt > SESSION_STALE_MS && now - this.lastSyncAt > SESSION_STALE_MS) { + console.warn(`${this.tag} watchdog: no good sync in ${Math.round((now - this.lastSyncAt) / 60_000)} min — re-logging in`); + this.token = null; + void this.connectAndAuth(); + } + } + + // ── sync ───────────────────────────────────────────────────────────────── + + private async fetchAccounts(): Promise<{ code: string; status: string; positionBased: boolean; registrationTime: number; currency: string }[]> { + const paths = this.userPath ? [this.userPath] : [`${this.cfg.username}@${this.domain}`, this.cfg.username]; + let lastErr: unknown = null; + for (const p of paths) { + try { + const { data } = await this.request<{ userDetails?: { accounts?: unknown[] }[] }>('read', 'GET', `/users/${encodeURIComponent(p)}`); + this.userPath = p; + const accounts = (data?.userDetails ?? []).flatMap((u) => u.accounts ?? []); + return accounts.flatMap((a) => { + const acc = a as Record; + const code = accountCodeOf(acc.account); + if (!code) return []; + return [{ + code, + status: String(acc.accountStatus ?? ''), + positionBased: acc.positionBased !== false, + registrationTime: timeOf(acc.registrationTime), + currency: typeof acc.baseCurrency === 'string' ? acc.baseCurrency : 'USD', + }]; + }); + } catch (err) { + lastErr = err; + if (!(err instanceof DxApiError) || (err.status !== 404 && err.status !== 400)) throw err; + } + } + throw lastErr ?? new Error('no user details'); + } + + /** One multi-account GET per chunk of account codes; the lists concatenated. */ + private async perAccounts(path: string, codes: string[], pick: (data: unknown) => unknown[], params: Record = {}, bucket: Bucket = 'read'): Promise { + const out: T[] = []; + for (const chunk of chunks(codes, ACCOUNT_CHUNK)) { + const { data } = await this.request(bucket, 'GET', path, { params: { ...params, accounts: chunk.join(',') } }); + out.push(...(pick(data) as T[])); + } + return out; + } + + /** + * One REST sync. Single-flight, with the same wedge recovery as the other + * clients. lastSyncAt — what the scheduler trades on — is certified only + * when every account's balance, positions, open orders and the day's + * transactions came back, and the first history pass has landed. + */ + private async requestSync(): Promise { + if (!this.token || this.intentionalDisconnect) return; + const startedAt = Date.now(); + if (this.syncInFlight) { + const runningMs = startedAt - this.syncInFlightSince; + if (runningMs < SYNC_WEDGED_MS) return this.syncRunning; + console.warn(`${this.tag} sync running for ${Math.round(runningMs / 1000)}s — looks wedged, starting a fresh one`); + } + const gen = ++this.syncGeneration; + const superseded = () => gen !== this.syncGeneration || this.intentionalDisconnect; + this.syncInFlight = true; + this.syncInFlightSince = startedAt; + let finished!: () => void; + this.syncRunning = new Promise((resolve) => { finished = resolve; }); + try { + const accounts = await this.fetchAccounts(); + if (superseded()) return; + + const list: PlatformAccount[] = []; + for (const a of accounts) { + const id = dxAccountId(this.host, a.code); + const clash = this.codeById.get(id); + if (clash && clash !== a.code) { + console.error(`${this.tag} account id collision between ${clash} and ${a.code} — leaving ${a.code} out`); + continue; + } + this.codeById.set(id, a.code); + this.idByCode.set(a.code, id); + this.accountMeta.set(id, { status: a.status, positionBased: a.positionBased, registrationTime: a.registrationTime, currency: a.currency }); + list.push({ id, name: accountDisplayName(a.code), active: a.status === 'FULL_TRADING' }); + } + this.accountList = list; + const codes = list.map((a) => this.codeById.get(a.id)!); + + let fresh = true; + const metrics = new Map>(); + const positions = new Map(); + const orders = new Map(); + let txns: DxTxn[] = []; + const now = new Date(); + const windowStart = new Date(Math.min(ctTradeDayStart(now).getTime(), lastUtcReset(now, FIRM_RESET_UTC_HOUR).getTime()) - 5 * 60_000); + if (codes.length > 0) { + try { + for (const m of await this.perAccounts>('/accounts/metrics', codes, (d) => (d as { metrics?: unknown[] })?.metrics ?? [])) { + const code = accountCodeOf(m.account); + if (code) metrics.set(code, m); + } + for (const p of await this.perAccounts('/accounts/positions', codes, (d) => (d as { positions?: unknown[] })?.positions ?? [])) { + const pos = parsePosition(p); + if (pos) positions.set(pos.account, [...(positions.get(pos.account) ?? []), pos]); + } + for (const o of await this.perAccounts('/accounts/orders', codes, (d) => (d as { orders?: unknown[] })?.orders ?? [])) { + const order = parseOrder(o); + if (order && !order.finalStatus) orders.set(order.account, [...(orders.get(order.account) ?? []), order]); + } + const raw = await this.perAccounts('/accounts/transfers', codes, (d) => [d], + { 'time-from': windowStart.toISOString(), limit: 1000 }); + txns = raw.flatMap((body) => parseTransfers(body)); + } catch (err) { + fresh = false; + console.error(`${this.tag} sync failed: ${errorMessage(err)}`); + } + } + if (superseded()) return; + + const today = currentTradeDateCT(); + const resetMs = lastUtcReset(now, FIRM_RESET_UTC_HOUR).getTime(); + this.dayFresh.clear(); + for (const acc of list) { + const code = this.codeById.get(acc.id)!; + if (!fresh) continue; + const m = metrics.get(code); + const balance = ciNum(m, 'balance'); + if (balance === undefined) continue; // an account the metrics call left out: stays stale + + const accPositions = positions.get(code) ?? []; + this.positionsDetail.set(acc.id, accPositions); + this.openOrders.set(acc.id, orders.get(code) ?? []); + this.applyPosition(acc, accPositions); + + const map = new Map(); + for (const t of txns) if (t.account === code) map.set(t.key, t); + this.recentTxns.set(acc.id, map); + let realized = 0; + for (const t of map.values()) { + if (!isFundType(t.type) && currentTradeDateCT(new Date(t.time)) === today) realized += t.value; + } + this.accountCashBalances[acc.id] = { amount: balance, realizedPnL: round2(realized), equity: ciNum(m, 'equity') }; + this.autoLiqThresholds[acc.id] = 0; + this.dayFresh.add(acc.id); + // Keep the reset snapshot handy for the debug view. + this.lastFetchRaw[`dayStart:${acc.name}`] = String(this.dayStartFromTxns(acc.id, balance, resetMs)); + } + + this.settleSuspects(); + await this.reapStrayOrders(); + this.checkNakedPositions(); + if (superseded()) return; + + console.log(`${this.tag} sync — ${list.length} account(s), ${Object.keys(this.positions).length} open position(s)`); + this.maybeFetchDaysTraded(); + + this.syncComplete = true; + if (!fresh || this.dayFresh.size < list.length) { + console.warn(`${this.tag} sync incomplete — not counted as a good sync`); + } else if (this.historyLoaded) { + this.lastSyncAt = Date.now(); + this.lastSyncStartedAt = startedAt; + } + try { + await this.onSync(); + } catch (err) { + console.error(`${this.tag} post-sync callback failed: ${errorMessage(err)}`); + } + } catch (err) { + console.error(`${this.tag} sync failed: ${errorMessage(err)}`); + } finally { + if (gen === this.syncGeneration) { + this.syncInFlight = false; + this.syncInFlightSince = 0; + } + finished(); + } + } + + private applyPosition(acc: PlatformAccount, list: DxPosition[]): void { + if (list.length > 1) console.warn(`${this.tag} ${acc.name} holds ${list.length} positions — tracking the first`); + const pos = list[0]; + if (!pos) { + const seen = this.seenOpenAt.get(acc.id); + if (seen) { + console.log(`${this.tag} ${acc.name}: position closed after ${((Date.now() - seen.at) / 3_600_000).toFixed(2)}h`); + this.seenOpenAt.delete(acc.id); + } + delete this.positions[acc.id]; + return; + } + const seen = this.seenOpenAt.get(acc.id); + if (!seen || seen.positionCode !== pos.positionCode) { + this.seenOpenAt.set(acc.id, { positionCode: pos.positionCode, at: Number.isFinite(pos.openTime) ? pos.openTime : Date.now(), naggedAt: 0 }); + } + this.positions[acc.id] = { + contractId: pos.symbol, + netPos: pos.side === 'BUY' ? pos.quantity : -pos.quantity, + netPrice: pos.openPrice, + timestamp: new Date(Number.isFinite(pos.openTime) ? pos.openTime : Date.now()), + }; + } + + /** Closed balance at the last reset: today's balance less everything the + * books moved since — trading and fund movements alike. A withdrawal + * after the reset therefore narrows the day's room rather than hide + * inside it, which is the safe reading until the firm says otherwise. */ + private dayStartFromTxns(accountId: number, balance: number, resetMs: number): number { + let since = 0; + for (const t of this.recentTxns.get(accountId)?.values() ?? []) if (t.time >= resetMs) since += t.value; + return round2(balance - since); + } + + /** See PlatformClient. null until this pass read the account's balance + * and its transactions since the reset. */ + public dayStartBalance(accountId: number): number | null { + if (!this.dayFresh.has(accountId)) return null; + const cash = this.accountCashBalances[accountId]; + if (!cash) return null; + return this.dayStartFromTxns(accountId, cash.amount, lastUtcReset(new Date(), FIRM_RESET_UTC_HOUR).getTime()); + } + + /** An unknown-outcome order stops holding its account once the account + * shows no working order a while after it. */ + private settleSuspects(): void { + const now = Date.now(); + for (const [id, since] of this.suspectAccounts) { + if (now - since >= SUSPECT_SETTLE_MS && (this.openOrders.get(id) ?? []).length === 0) this.suspectAccounts.delete(id); + } + } + + public hasWorkingOrders(accountId: number): boolean { + return this.suspectAccounts.has(accountId) || (this.openOrders.get(accountId) ?? []).length > 0; + } + + /** Our own orders still working on a flat account are leftovers — an exit + * whose position closed while no poll was watching, or one this client + * lost track of in a restart. A CLOSE order can't open a position, but + * it has no business sitting there. */ + private async reapStrayOrders(): Promise { + const now = Date.now(); + for (const acc of this.accountList) { + if (this.positions[acc.id]) continue; + const stray = (this.openOrders.get(acc.id) ?? []) + .filter((o) => o.orderCode.startsWith('af-') && Number.isFinite(o.issueTime) && now - o.issueTime > REAP_AFTER_MS); + for (const o of stray) { + console.warn(`${this.tag} ${acc.name}: cancelling stray order ${o.orderCode} (account flat)`); + await this.cancelOrder(acc.id, o.orderCode).catch((err) => + console.error(`${this.tag} failed to cancel stray ${o.orderCode}: ${errorMessage(err)}`)); + } + } + } + + /** A position with nothing working to close it — a restart mid-exit, or + * a leg that failed. Not closed automatically (it may be a manual trade), + * but loud, and on the account's fetch-error note. */ + private checkNakedPositions(): void { + const now = Date.now(); + for (const acc of this.accountList) { + const seen = this.seenOpenAt.get(acc.id); + if (!seen || !this.positions[acc.id]) continue; + if ((this.openOrders.get(acc.id) ?? []).length > 0 || now - seen.at < NAKED_AFTER_MS) continue; + if (now - seen.naggedAt < 15 * 60_000) continue; + seen.naggedAt = now; + console.error(`${this.tag} ${acc.name}: position ${seen.positionCode} has NO working exit or stop — close it or place exits at the broker`); + } + } + + // ── history ────────────────────────────────────────────────────────────── + + private maybeFetchDaysTraded(): void { + if (this.daysFetchInFlight || this.intentionalDisconnect) return; + const now = Date.now(); + const initialFetchDue = this.lastDaysFetchStartedAt === 0; + const retryPending = this.nextDaysFetchRetryAt > 0; + const retryDue = retryPending && now >= this.nextDaysFetchRetryAt; + const hourlyRefreshDue = !retryPending && this.lastDaysFetchSucceededAt > 0 && now - this.lastDaysFetchSucceededAt >= 60 * 60_000; + const waiting = !initialFetchDue && !retryPending + ? this.accountList.filter((a) => !this.historyLoadedIds.has(a.id) && !this.fetchingIds.has(a.id)).map((a) => a.id) + : []; + const targeted = !retryDue && !hourlyRefreshDue && !initialFetchDue && waiting.length > 0; + if (!initialFetchDue && !retryDue && !hourlyRefreshDue && !targeted) return; + + this.daysFetchInFlight = true; + this.daysFetchInFlightSince = now; + this.lastDaysFetchStartedAt = now; + if (retryDue) this.nextDaysFetchRetryAt = 0; + + this.fetchDaysTraded(targeted ? waiting : undefined) + .then(({ failedAccounts }) => { + if (!this.historyLoaded && !this.intentionalDisconnect) { + this.historyLoaded = true; + void this.requestSync(); + } + if (failedAccounts > 0) { + this.nextDaysFetchRetryAt = Date.now() + 5 * 60_000; + console.warn(`${this.tag} ${failedAccounts} account(s) failed history fetch — retrying in 5 minutes`); + return; + } + if (!targeted) this.lastDaysFetchSucceededAt = Date.now(); + }) + .catch((err) => { + this.nextDaysFetchRetryAt = Date.now() + 5 * 60_000; + console.error(`${this.tag} history fetch failed — retrying in 5 minutes: ${errorMessage(err)}`); + }) + .finally(() => { + this.daysFetchInFlight = false; + this.daysFetchInFlightSince = 0; + }); + } + + public async fetchDaysTraded(onlyAccountIds?: number[]): Promise<{ failedAccounts: number }> { + const ids = this.accountList.filter((a) => !onlyAccountIds || onlyAccountIds.includes(a.id)).map((a) => a.id); + for (const id of ids) this.fetchingIds.add(id); + try { + return await this.fetchDaysTradedPass(ids); + } finally { + for (const id of ids) this.fetchingIds.delete(id); + } + } + + /** + * Every cash transaction since the accounts opened (capped at + * HISTORY_MAX_DAYS), paged. The page direction isn't documented, so it + * follows nextPageTransactionTime whichever end of the page it names. + */ + private async fetchAllTransfers(codes: string[], from: Date): Promise { + const all = new Map(); + for (const chunk of chunks(codes, ACCOUNT_CHUNK)) { + let timeFrom = from.toISOString(); + let timeTo = new Date(Date.now() + 60 * 60_000).toISOString(); + for (let page = 0; page < 30; page++) { + const { data } = await this.request<{ nextPageTransactionTime?: unknown }>('history', 'GET', '/accounts/transfers', { + params: { accounts: chunk.join(','), 'time-from': timeFrom, 'time-to': timeTo, limit: 1000 }, + }); + const txns = parseTransfers(data); + const before = all.size; + for (const t of txns) all.set(t.key, t); + const next = timeOf(data?.nextPageTransactionTime); + if (!Number.isFinite(next) || txns.length === 0 || all.size === before) break; + const times = txns.map((t) => t.time); + const oldest = Math.min(...times); + const newest = Math.max(...times); + if (Math.abs(next - oldest) <= Math.abs(next - newest)) timeTo = new Date(next).toISOString(); + else timeFrom = new Date(next).toISOString(); + } + } + return [...all.values()]; + } + + private async fetchDaysTradedPass(ids: number[]): Promise<{ failedAccounts: number }> { + if (!this.token || ids.length === 0) return { failedAccounts: 0 }; + this.fetchDaysComplete = false; + const accounts = this.accountList.filter((a) => ids.includes(a.id)); + const codes = accounts.map((a) => this.codeById.get(a.id)!).filter(Boolean); + const earliest = Math.min(...accounts.map((a) => this.accountMeta.get(a.id)?.registrationTime ?? NaN).filter(Number.isFinite)); + const floor = Date.now() - HISTORY_MAX_DAYS * 86_400_000; + const from = new Date(Math.max(floor, Number.isFinite(earliest) ? earliest - 86_400_000 : floor)); + + let txns: DxTxn[]; + try { + txns = await this.fetchAllTransfers(codes, from); + } catch (err) { + const msg = errorMessage(err); + console.error(`${this.tag} history fetch failed: ${msg}`); + for (const account of accounts) this.fallBackToCache(account, msg); + this.fetchDaysComplete = true; + return { failedAccounts: accounts.length }; + } + + for (const account of accounts) { + if (this.intentionalDisconnect) break; + const code = this.codeById.get(account.id)!; + const mine = txns.filter((t) => t.account === code).sort((a, b) => a.time - b.time); + this.lastFetchRaw[account.name] = `${mine.length} transaction(s): ${[...new Set(mine.map((t) => t.type))].join(', ') || 'none'}`; + + const daily = new Map(); + const funds = new Map(); + for (const t of mine) { + const date = currentTradeDateCT(new Date(t.time)); + if (isFundType(t.type)) { + funds.set(date, (funds.get(date) ?? 0) + signedFundValue(t.type, t.value)); + continue; + } + const e = daily.get(date) ?? { pnl: 0, fees: 0 }; + e.pnl += t.value; + if (t.type === 'COMMISSION') e.fees += Math.abs(t.value); + daily.set(date, e); + } + const fresh = [...daily.entries()] + .map(([date, e]) => ({ date, pnl: round2(e.pnl), fees: Math.round(e.fees * 10000) / 10000 })) + .sort((a, b) => a.date.localeCompare(b.date)); + const merged = new Map(); + for (const e of loadDailyPnL(account.id)) merged.set(e.date, e.pnl); + for (const e of fresh) merged.set(e.date, e.pnl); + this.fullDailyPnL[account.id] = [...merged.entries()] + .map(([date, pnl]) => ({ date, pnl })) + .sort((a, b) => a.date.localeCompare(b.date)); + saveDailyPnL(account.id, account.name, fresh); + + // Fund transactions: the API has them, so they replace guesswork. + const storedFundDate = loadAccountMeta(account.id, 'last_fund_date'); + const fundRows = [...funds.entries()] + .map(([date, amount]) => ({ date, amount: round2(amount) })) + .filter((f) => f.amount !== 0) + .sort((a, b) => a.date.localeCompare(b.date)); + if (fundRows.length > 0) saveFundTransactions(account.id, account.name, fundRows); + this.fundTransactions[account.id] = loadFundTransactions(account.id); + const lastFundDate = fundRows.length > 0 ? fundRows[fundRows.length - 1].date : storedFundDate; + if (lastFundDate && storedFundDate && lastFundDate > storedFundDate) { + // A new cycle: note where the one it ends began, for the + // reporter's catch-up (a withdrawal's own day stays behind). + const prevAmt = this.fundTransactions[account.id]?.find((f) => f.date === storedFundDate)?.amount ?? null; + saveEndedCycleStart(account.id, { fundDate: storedFundDate, includesDay: !(prevAmt !== null && prevAmt < 0) }); + } + if (lastFundDate) saveAccountMeta(account.id, 'last_fund_date', lastFundDate); + this.lastFundDates[account.id] = lastFundDate ?? null; + this.applyCycleSlice(account.id); + + // Books check: the balance must be what the transactions add up + // to. A payout booked some unexpected way would otherwise leave the + // account trading its finished cycle as if nothing happened. + const cash = this.accountCashBalances[account.id]; + const size = getAccountConfig(account.id).accountType?.accountSize ?? 0; + const hasDeposit = mine.some((t) => t.type === 'DEPOSIT'); + const expected = mine.reduce((s, t) => s + (isFundType(t.type) ? signedFundValue(t.type, t.value) : t.value), 0) + (hasDeposit ? 0 : size); + if (cash && !this.positions[account.id] && Math.abs(cash.amount - expected) >= BOOKS_TOLERANCE) { + this.booksHeld.add(account.id); + this.lastFetchErrors[account.name] = `balance ${round2(cash.amount)} vs transactions ${round2(expected)} — held until they agree`; + console.warn(`${this.tag} ${account.name}: ${this.lastFetchErrors[account.name]}`); + } else { + this.booksHeld.delete(account.id); + delete this.lastFetchErrors[account.name]; + } + this.historyLoadedIds.add(account.id); + } + this.fetchDaysComplete = true; + return { failedAccounts: 0 }; + } + + private fallBackToCache(account: PlatformAccount, msg: string): void { + const cached = loadDailyPnL(account.id); + this.fundTransactions[account.id] ??= loadFundTransactions(account.id); + this.lastFundDates[account.id] ??= loadAccountMeta(account.id, 'last_fund_date'); + if (cached.length > 0) { + this.fullDailyPnL[account.id] = cached; + this.applyCycleSlice(account.id); + this.lastFetchErrors[account.name] = `${msg} (using ${cached.length} cached entries from database)`; + } else { + this.daysTraded[account.id] ??= 0; + this.lastFetchErrors[account.name] = msg; + } + } + + /** The active cycle out of the full history: a withdrawal's day belongs to + * the OLD cycle, a deposit's day to the new one (as on TopstepX). */ + private applyCycleSlice(accountId: number): void { + const merged = this.fullDailyPnL[accountId] ?? []; + const fundDate = this.lastFundDates[accountId]; + const txns = this.fundTransactions[accountId] ?? []; + const lastAmt = fundDate ? (txns.find((f) => f.date === fundDate)?.amount ?? null) : null; + const isWithdrawal = lastAmt !== null && lastAmt < 0; + const active = fundDate ? merged.filter((d) => (isWithdrawal ? d.date > fundDate : d.date >= fundDate)) : merged; + const prior = fundDate ? merged.filter((d) => (isWithdrawal ? d.date <= fundDate : d.date < fundDate)) : []; + this.priorProfit[accountId] = round2(prior.reduce((s, d) => s + d.pnl, 0)); + this.dailyPnL[accountId] = active; + this.daysTraded[accountId] = active.filter((d) => d.pnl !== 0).length; + } + + public historyReady(accountId: number): boolean { + return this.historyLoadedIds.has(accountId) + && !this.booksHeld.has(accountId) + && this.dailyPnL[accountId] !== undefined + && this.fundTransactions[accountId] !== undefined; + } + + public forgetHistory(accountId: number, accountName: string): void { + this.historyLoadedIds.delete(accountId); + this.booksHeld.delete(accountId); + delete this.dailyPnL[accountId]; + delete this.fullDailyPnL[accountId]; + delete this.daysTraded[accountId]; + delete this.priorProfit[accountId]; + delete this.lastFundDates[accountId]; + delete this.fundTransactions[accountId]; + delete this.lastFetchErrors[accountName]; + delete this.lastFetchRaw[accountName]; + } + + // ── instrument selector ────────────────────────────────────────────────── + + /** An account every lookup can use: the first that can trade. */ + private referenceAccount(): { code: string; currency: string } | null { + for (const acc of this.accountList) { + if (!acc.active) continue; + const code = this.codeById.get(acc.id); + if (code) return { code, currency: this.accountMeta.get(acc.id)?.currency ?? 'USD' }; + } + return null; + } + + private async instruments(symbols: string[]): Promise>> { + const now = Date.now(); + const missing = symbols.filter((s) => !(now - (this.instrumentCache.get(s)?.at ?? 0) < INSTRUMENT_TTL_MS)); + if (missing.length > 0) { + const { data } = await this.request<{ instruments?: Record[] }>('read', 'GET', '/instruments/query', { params: { symbols: missing.join(',') } }); + for (const inst of data?.instruments ?? []) { + if (typeof inst.symbol === 'string') this.instrumentCache.set(inst.symbol, { inst, at: now }); + } + } + return new Map(symbols.flatMap((s) => { const c = this.instrumentCache.get(s); return c ? [[s, c.inst] as const] : []; })); + } + + private async accountInstruments(account: string, symbols: string[]): Promise>> { + const now = Date.now(); + const missing = symbols.filter((s) => !(now - (this.accountInstrumentCache.get(s)?.at ?? 0) < ACCOUNT_INSTRUMENT_TTL_MS)); + if (missing.length > 0) { + const { data } = await this.request<{ instrumentDetails?: Record[] }>('read', 'GET', + `/accounts/${encodeURIComponent(account)}/instruments/query`, { params: { symbols: missing.join(',') } }); + for (const det of data?.instrumentDetails ?? []) { + if (typeof det.symbol === 'string') this.accountInstrumentCache.set(det.symbol, { det, at: now }); + } + } + return new Map(symbols.flatMap((s) => { const c = this.accountInstrumentCache.get(s); return c ? [[s, c.det] as const] : []; })); + } + + private async marketData(account: string, symbols: string[], eventType: Record): Promise[]> { + const { data } = await this.request<{ events?: Record[] }>('read', 'POST', '/marketdata', { + body: { account, symbols, eventTypes: [eventType] }, + }); + return data?.events ?? []; + } + + private async quotes(account: string, symbols: string[]): Promise> { + const events = await this.marketData(account, symbols, { type: 'Quote', format: 'COMPACT' }); + const out = new Map(); + for (const e of events) { + if (typeof e.symbol !== 'string') continue; + out.set(e.symbol, { bid: ciNum(e, 'bid'), ask: ciNum(e, 'ask'), time: timeOf(e.time ?? e.timestamp) }); + } + return out; + } + + private async volatility(account: string, symbols: string[]): Promise> { + const now = Date.now(); + const missing = symbols.filter((s) => !(now - (this.candleCache.get(s)?.at ?? 0) < CANDLE_TTL_MS)); + if (missing.length > 0) { + const events = await this.marketData(account, missing, { + type: 'Candle', format: 'COMPACT', candleType: '1h', + fromTime: new Date(now - (CANDLE_HOURS + 1) * 3_600_000).toISOString(), + toTime: new Date(now).toISOString(), + }); + const bySymbol = new Map(); + for (const e of events) { + const close = ciNum(e, 'close'); + if (typeof e.symbol !== 'string' || close === undefined) continue; + bySymbol.set(e.symbol, [...(bySymbol.get(e.symbol) ?? []), { time: timeOf(e.time ?? e.timestamp), close }]); + } + for (const s of missing) { + const candles = (bySymbol.get(s) ?? []).sort((a, b) => a.time - b.time); + this.candleCache.set(s, { vol: realizedVolPerHour(candles.map((c) => c.close)), candles: candles.length, at: now }); + } + } + return new Map(symbols.map((s) => [s, this.candleCache.get(s) ?? { vol: null, candles: 0 }])); + } + + /** + * The contract a CME root trades as on this firm right now: the cheapest + * tradable candidate from the symbol map, per unit of volatility when the + * firm sizes by target hold (see scoreCandidate). Sticky while an account + * holds one of the candidates, cached briefly so a batch's lookups agree. + */ + public async findFrontMonthContract(root: string): Promise { + if (!this.token) return null; + const firm = getCachedFirm(this.cfg.firmId); + const map = firm?.symbolMap ?? {}; + const candidates = map[root.toUpperCase()] ?? map['*'] ?? []; + const now = Date.now(); + if (candidates.length === 0) { + if (now - (this.unmappedLoggedAt.get(root) ?? 0) > 10 * 60_000) { + this.unmappedLoggedAt.set(root, now); + console.log(`${this.tag} no symbol mapped for ${root} (nor '*') — skipping; set the symbol map on the master Firms page`); + } + return null; + } + const held = new Set([...this.positionsDetail.values()].flat().map((p) => p.symbol)); + const sticky = candidates.find((c) => held.has(c.symbol)); + if (!sticky) { + const cached = this.pickCache.get(root); + if (cached && now - cached.at < PICK_TTL_MS) return cached.contract; + } + const pick = await this.pickCandidate(root, sticky ? [sticky] : candidates, sticky !== undefined); + if (pick && !sticky) this.pickCache.set(root, { contract: pick, at: now }); + return pick; + } + + private async pickCandidate(root: string, candidates: SymbolMapCandidate[], sticky: boolean): Promise { + const ref = this.referenceAccount(); + if (!ref) return null; + const firm = getCachedFirm(this.cfg.firmId); + const targetHold = firm?.targetHoldHours ?? 0; + const symbols = [...new Set(candidates.map((c) => c.symbol))]; + const [insts, dets, quotes, vols] = await Promise.all([ + this.instruments(symbols), + this.accountInstruments(ref.code, symbols), + this.quotes(ref.code, symbols), + targetHold > 0 ? this.volatility(ref.code, symbols) : Promise.resolve(new Map()), + ]); + + const now = new Date(); + const board: { symbol: string; score: number | null; note: string; contract?: PlatformContract }[] = []; + for (const cand of candidates) { + const inst = insts.get(cand.symbol); + if (!inst) { board.push({ symbol: cand.symbol, score: null, note: 'unknown symbol' }); continue; } + const det = dets.get(cand.symbol); + const quote = quotes.get(cand.symbol); + const vol = vols.get(cand.symbol); + const increment = ciNum(inst, 'quantityIncrement') ?? 0; + const detIncrement = ciNum(det, 'minOrderSizeIncrement') ?? 0; + const step = Math.max(increment, detIncrement) || ciNum(inst, 'lotSize') || 1; + const multiplier = ciNum(inst, 'multiplier') || 1; + const tick = ciNum(inst, 'priceIncrement') ?? 0; + const minOrder = ciNum(det, 'minOrderSize') ?? 0; + const maxOrder = det?.maxOrderSize as { value?: unknown; limitType?: unknown } | undefined; + const bid = quote?.bid; + const ask = quote?.ask; + const mid = bid !== undefined && ask !== undefined ? (bid + ask) / 2 : NaN; + const spread = Number.isFinite(mid) && mid > 0 ? (ask! - bid!) / mid : NaN; + const stepNotional = step * mid * multiplier; + let maxContracts: number | undefined; + if (maxOrder && typeof maxOrder.value === 'number' && maxOrder.value > 0) { + maxContracts = maxOrder.limitType === 'NOTIONAL_CURRENCY' + ? Math.floor(maxOrder.value / stepNotional) + : Math.floor((maxOrder.value * (ciNum(inst, 'lotSize') || 1)) / step); + } + const minContracts = minOrder > 0 ? Math.ceil(minOrder / step - 1e-9) : 1; + const rejection = !(tick > 0) + ? 'no price increment' + : candidateRejection({ + tradingStatus: typeof det?.tradingStatus === 'string' ? det.tradingStatus : undefined, + bid, ask, + quoteAgeMs: quote && Number.isFinite(quote.time) ? Date.now() - quote.time : null, + inHours: inTradingHours(inst.tradingHours, now), + candles: vol?.candles ?? 0, + needVolatility: targetHold > 0, + minContractsOk: maxContracts === undefined || maxContracts >= minContracts, + currency: typeof inst.currency === 'string' ? inst.currency : undefined, + accountCurrency: ref.currency, + }); + if (rejection) { board.push({ symbol: cand.symbol, score: null, note: rejection }); continue; } + const pct = cand.commissionPct ?? firm?.commissionPct ?? 0; + const score = scoreCandidate({ + spreadFraction: spread, + commissionPct: pct, + financingPctPerDay: firm?.financingPctPerDay ?? 0, + targetHoldHours: targetHold, + volatilityPerHour: vol?.vol ?? null, + }); + if (score === null) { board.push({ symbol: cand.symbol, score: null, note: 'no volatility' }); continue; } + board.push({ + symbol: cand.symbol, + score, + note: `spread ${(spread * 100).toFixed(3)}%${vol?.vol ? `, σ ${(vol.vol * Math.sqrt(24) * 100).toFixed(2)}%/day` : ''}`, + contract: { + id: cand.symbol, + name: encodeContractName(cand.symbol, step), + tickSize: tick, + pointValue: step * multiplier, + commissionPct: pct, + stepNotional, + fixedNotional: cand.notionalPerContract, + volatilityPerHour: vol?.vol ?? undefined, + spreadFraction: spread, + maxContracts, + minContracts, + }, + }); + } + + const ranked = board.filter((b) => b.contract).sort((a, b) => a.score! - b.score!); + const winner = ranked[0]; + const summary = board + .map((b) => `${b.symbol}${b.score !== null ? ` ${b.score.toFixed(4)}` : ''} (${b.note})`) + .join('; '); + this.lastFetchRaw[`pick:${root}`] = `${new Date().toISOString()} → ${winner?.symbol ?? 'none'} — ${summary}`; + console.log(`${this.tag} ${root}${sticky ? ' (held)' : ''} → ${winner?.symbol ?? 'nothing tradable'} — ${summary}`); + return winner?.contract ?? null; + } + + public async fetchInstrumentFees(): Promise<{ [symbol: string]: number }> { + // Commission is a percent of notional, set per firm on the master. + return {}; + } + + // ── orders ─────────────────────────────────────────────────────────────── + + private async freshPositions(account: string): Promise { + const { data } = await this.request<{ positions?: unknown[] }>('trade', 'GET', `/accounts/${encodeURIComponent(account)}/positions`); + return (data?.positions ?? []).map(parsePosition).filter((p): p is DxPosition => p !== null); + } + + private async freshOpenOrders(account: string): Promise { + const { data } = await this.request<{ orders?: unknown[] }>('trade', 'GET', `/accounts/${encodeURIComponent(account)}/orders`); + return (data?.orders ?? []).map(parseOrder).filter((o): o is DxOrder => o !== null && !o.finalStatus); + } + + /** An order by its client code, from the open list or the day's history. */ + private async findOrder(account: string, orderCode: string): Promise { + const open = (await this.freshOpenOrders(account)).find((o) => o.orderCode === orderCode); + if (open) return open; + const { data } = await this.request<{ orders?: unknown[] }>('history', 'GET', `/accounts/${encodeURIComponent(account)}/orders/history`, { + params: { 'with-client-id': orderCode, limit: 5 }, + }); + return (data?.orders ?? []).map(parseOrder).find((o) => o?.orderCode === orderCode) ?? null; + } + + /** + * POST one order (or an OCO group). Only an answer that proves nothing + * was placed becomes OrderNotPlacedError; a transport failure or a 5xx is + * checked by its orderCode, and stays a plain Error when that can't tell. + */ + private async submit(account: string, body: Record, codes: string[]): Promise<{ orderId?: number }> { + try { + const { data } = await this.request<{ orderId?: number; orders?: { orderId?: number }[] }>('trade', 'POST', + `/accounts/${encodeURIComponent(account)}/orders`, { body }); + return { orderId: data?.orderId ?? data?.orders?.[0]?.orderId }; + } catch (err) { + if (err instanceof DxApiError) { + if (err.status === 409 && err.code === 100) return {}; // the retry found it placed + if (err.status === 400 || err.status === 401 || err.status === 403 || err.status === 404 || err.status === 422 || err.status === 429) { + throw new OrderNotPlacedError(`DXtrade refused the order: ${err.message}`); + } + } + // Ambiguous: look for it before calling it either way. + for (let i = 0; i < 3; i++) { + await sleep(2_000); + try { + const found = await Promise.all(codes.map((c) => this.findOrder(account, c))); + if (found.some((o) => o !== null)) return { orderId: found.find((o) => o)?.orderId }; + } catch { /* try again */ } + } + throw new Error(`DXtrade order outcome unknown (it may still have been placed): ${errorMessage(err)}`); + } + } + + private orderBody(account: string, symbol: string, o: { + code: string; type: 'MARKET' | 'LIMIT' | 'STOP'; side: 'BUY' | 'SELL'; quantity: number; + positionEffect: 'OPEN' | 'CLOSE'; positionCode?: string; limitPrice?: number; stopPrice?: number; + }): Record { + return { + account, + orderCode: o.code, + type: o.type, + instrument: symbol, + quantity: o.quantity, + positionEffect: o.positionEffect, + ...(o.positionCode ? { positionCode: o.positionCode } : {}), + side: o.side, + ...(o.limitPrice !== undefined ? { limitPrice: o.limitPrice } : {}), + ...(o.stopPrice !== undefined ? { stopPrice: o.stopPrice } : {}), + tif: 'GTC', + }; + } + + /** What an order needs before it is sent. Failing here reached nothing. */ + private orderContext(accountId: number, contractName: string): { account: string; symbol: string; step: number } { + if (!this.token) throw new OrderNotPlacedError('DXtrade not logged in — order not sent'); + const account = this.codeById.get(accountId); + if (!account) throw new OrderNotPlacedError(`DXtrade account ${accountId} unknown — order not sent`); + const decoded = decodeContractName(contractName); + if (!decoded) throw new OrderNotPlacedError(`DXtrade contract ${contractName} unreadable — order not sent`); + return { account, symbol: decoded.symbol, step: decoded.step }; + } + + private tickOf(symbol: string): number { + return ciNum(this.instrumentCache.get(symbol)?.inst, 'priceIncrement') ?? 0; + } + + /** An exit price on the tick grid, rounded toward the position's safe + * side: a sell (closing a long) up, a buy (closing a short) down — the + * take-profit never nets less, the stop never sits further out. */ + private exitPrice(symbol: string, price: number, side: 'BUY' | 'SELL'): number { + const tick = this.tickOf(symbol); + return tick > 0 ? roundToStep(price, tick, side === 'SELL' ? 'up' : 'down') : price; + } + + /** + * Place an order and wait for its fill: an OPEN when the account is flat, + * a CLOSE of the position when it holds the opposite side. Adding to a + * position is refused, and so is an OPEN against another account's + * opposite position in the same symbol (no hedging). Resolves with the + * fill `price`. + */ + public async sendOrder( + accountId: number, + contractSymbol: string, + quantity: number, + action: 'Buy' | 'Sell', + orderType: 'Market' | 'Limit', + price?: number, + ): Promise> { + try { + return await this.sendOrderAndWait(accountId, contractSymbol, quantity, action, orderType, price); + } catch (err) { + if (!isOrderNotPlaced(err)) this.suspectAccounts.set(accountId, Date.now()); + throw err; + } + } + + private async sendOrderAndWait( + accountId: number, + contractSymbol: string, + contracts: number, + action: 'Buy' | 'Sell', + orderType: 'Market' | 'Limit', + price?: number, + ): Promise> { + const { account, symbol, step } = this.orderContext(accountId, contractSymbol); + const side: 'BUY' | 'SELL' = action === 'Buy' ? 'BUY' : 'SELL'; + let positions: DxPosition[]; + try { + positions = await this.freshPositions(account); + } catch (err) { + throw new OrderNotPlacedError(`DXtrade position check failed — order not sent: ${errorMessage(err)}`); + } + const existing = positions.find((p) => p.symbol === symbol); + if (existing && existing.side === side) throw new OrderNotPlacedError(`adding to an open ${symbol} position is not supported`); + + const code = newOrderCode(existing ? 'x' : 'e'); + let body: Record; + let unitsOrdered: number; + if (existing) { + unitsOrdered = Math.min(existing.quantity, roundToStep(contracts * step, step)); + body = this.orderBody(account, symbol, { + code, type: orderType === 'Market' ? 'MARKET' : 'LIMIT', side, quantity: unitsOrdered, + positionEffect: 'CLOSE', positionCode: existing.positionCode, + ...(orderType === 'Limit' && price !== undefined ? { limitPrice: this.exitPrice(symbol, price, side) } : {}), + }); + } else { + if (this.accountMeta.get(accountId)?.status !== 'FULL_TRADING') { + throw new OrderNotPlacedError(`account is ${this.accountMeta.get(accountId)?.status ?? 'unknown'} — order not sent`); + } + const opposite = side === 'BUY' ? 'SELL' : 'BUY'; + const hedged = [...this.positionsDetail.values()].flat().some((p) => p.symbol === symbol && p.side === opposite) + || this.openingSides.get(symbol)?.side === opposite; + if (hedged) throw new OrderNotPlacedError(`another account holds the opposite side of ${symbol} — no hedging`); + unitsOrdered = roundToStep(contracts * step, step); + body = this.orderBody(account, symbol, { + code, type: orderType === 'Market' ? 'MARKET' : 'LIMIT', side, quantity: unitsOrdered, positionEffect: 'OPEN', + ...(orderType === 'Limit' && price !== undefined ? { limitPrice: price } : {}), + }); + } + + const opening = !existing; + if (opening) { + const cur = this.openingSides.get(symbol); + this.openingSides.set(symbol, { side, count: (cur?.side === side ? cur.count : 0) + 1 }); + } + this.pendingFills++; + try { + const { orderId } = await this.submit(account, body, [code]); + console.log(`${this.tag} order ${code}${orderId ? ` (${orderId})` : ''}: ${side} ${unitsOrdered} ${symbol} ${orderType}${existing ? ' CLOSE' : ' OPEN'}`); + const fill = await this.waitForFill(account, code, orderId, symbol, side, existing ?? null); + // Visible at once — the batch gates ("already in trade", the + // all-flat gate, the hedge guard) must not wait for the next sync. + if (opening) { + const pos = fill.position ?? { account, positionCode: String(orderId ?? ''), symbol, quantity: unitsOrdered, side, openTime: Date.now(), openPrice: fill.price }; + this.positionsDetail.set(accountId, [pos]); + this.positions[accountId] = { + contractId: symbol, netPos: side === 'BUY' ? pos.quantity : -pos.quantity, netPrice: pos.openPrice, timestamp: new Date(), + }; + } else if (unitsOrdered >= existing.quantity) { + this.positionsDetail.set(accountId, []); + delete this.positions[accountId]; + } + const result = { orderId, orderCode: code, price: fill.price, qty: contracts, units: unitsOrdered, accountId, contractId: symbol }; + for (const w of this.fillWaiters.filter((x) => x.orderId === orderId)) w.callback(result); + this.fillWaiters = this.fillWaiters.filter((x) => x.orderId !== orderId); + return result; + } finally { + this.pendingFills--; + if (opening) { + const cur = this.openingSides.get(symbol); + if (cur && cur.count <= 1) this.openingSides.delete(symbol); + else if (cur) this.openingSides.set(symbol, { ...cur, count: cur.count - 1 }); + } + } + } + + /** + * Poll until the order fills: an OPEN shows as a new position (its + * positionCode is the order id on position-based accounts), a CLOSE as + * the position gone. An order that ended unfilled was never placed; one + * still unfilled after FILL_TIMEOUT_MS is cancelled and reported as + * unknown, since it may fill yet. + */ + private async waitForFill( + account: string, + orderCode: string, + orderId: number | undefined, + symbol: string, + side: 'BUY' | 'SELL', + closing: DxPosition | null, + ): Promise<{ price: number; position?: DxPosition }> { + const deadline = Date.now() + FILL_TIMEOUT_MS; + for (let poll = 1; Date.now() < deadline; poll++) { + await sleep(FILL_POLL_MS); + try { + const positions = await this.freshPositions(account); + if (closing) { + if (!positions.some((p) => p.positionCode === closing.positionCode)) { + const order = await this.findOrder(account, orderCode).catch(() => null); + return { price: order?.averagePrice ?? NaN }; + } + } else { + const pos = positions.find((p) => p.symbol === symbol && p.side === side + && (orderId === undefined || p.positionCode === String(orderId) || positions.filter((q) => q.symbol === symbol).length === 1)); + if (pos) { + const order = await this.findOrder(account, orderCode).catch(() => null); + return { price: order?.averagePrice ?? pos.openPrice, position: pos }; + } + } + if (poll % 4 === 0) { + const order = await this.findOrder(account, orderCode); + if (order?.finalStatus && order.filledQuantity === 0) { + throw new OrderNotPlacedError(`order ${orderCode} ended ${order.status} unfilled${order.rejectReason ? `: ${order.rejectReason}` : ''}`); + } + } + } catch (err) { + if (isOrderNotPlaced(err)) throw err; + /* transient — the next poll tries again */ + } + } + console.warn(`${this.tag} order ${orderCode} not filled within ${FILL_TIMEOUT_MS / 1000}s — cancelling it`); + await this.cancelByCode(account, orderCode).catch((err) => + console.error(`${this.tag} failed to cancel unfilled ${orderCode}: ${errorMessage(err)}`)); + throw new Error(`order ${orderCode} placed but not filled within ${FILL_TIMEOUT_MS / 1000}s — cancel requested`); + } + + /** A take-profit (or any exit) limit on the whole open position. Exit + * only: with no opposite position there is nothing to close, so nothing + * is sent — it can never open or hedge. */ + public async placeOrderNoWait( + accountId: number, + contractSymbol: string, + _quantity: number, + action: 'Buy' | 'Sell', + orderType: 'Market' | 'Limit', + price?: number, + ): Promise<{ orderId?: number }> { + const { account, symbol } = this.orderContext(accountId, contractSymbol); + const side: 'BUY' | 'SELL' = action === 'Buy' ? 'BUY' : 'SELL'; + const pos = (await this.freshPositions(account).catch((err) => { + throw new OrderNotPlacedError(`DXtrade position check failed — exit not sent: ${errorMessage(err)}`); + })).find((p) => p.symbol === symbol && p.side !== side); + if (!pos) throw new OrderNotPlacedError(`no ${symbol} position to exit — nothing sent`); + const code = newOrderCode('tp'); + const body = this.orderBody(account, symbol, { + code, type: orderType === 'Market' ? 'MARKET' : 'LIMIT', side, quantity: pos.quantity, + positionEffect: 'CLOSE', positionCode: pos.positionCode, + ...(orderType === 'Limit' && price !== undefined ? { limitPrice: this.exitPrice(symbol, price, side) } : {}), + }); + const res = await this.submit(account, body, [code]); + this.registerPair(accountId, account, pos.positionCode, [code], false); + return res; + } + + /** + * Take-profit and stop on the open position, one cancelling the other. A + * native OCO group first; where the server won't take one, two separate + * CLOSE orders — the stop first, so the position is never without it — + * and the survivor cancelled once the position is gone (poll). + * + * What the caller may do next depends on what is left at the broker: + * OrderNotPlacedError = nothing of the pair is working; a plain Error = + * something may be (a stop, at least) — place nothing more. + */ + public async placeOcoExit( + accountId: number, + contractSymbol: string, + _quantity: number, + exitAction: 'Buy' | 'Sell', + limitPrice: number, + stopPrice: number, + ): Promise<{ orderId?: number }> { + const { account, symbol } = this.orderContext(accountId, contractSymbol); + const side: 'BUY' | 'SELL' = exitAction === 'Buy' ? 'BUY' : 'SELL'; + const pos = (await this.freshPositions(account).catch((err) => { + throw new OrderNotPlacedError(`DXtrade position check failed — exits not sent: ${errorMessage(err)}`); + })).find((p) => p.symbol === symbol && p.side !== side); + if (!pos) throw new OrderNotPlacedError(`no ${symbol} position to protect — nothing sent`); + + const limitCode = newOrderCode('tp'); + const stopCode = newOrderCode('sl'); + const limit = this.orderBody(account, symbol, { + code: limitCode, type: 'LIMIT', side, quantity: pos.quantity, positionEffect: 'CLOSE', + positionCode: pos.positionCode, limitPrice: this.exitPrice(symbol, limitPrice, side), + }); + const stop = this.orderBody(account, symbol, { + code: stopCode, type: 'STOP', side, quantity: pos.quantity, positionEffect: 'CLOSE', + positionCode: pos.positionCode, stopPrice: this.exitPrice(symbol, stopPrice, side), + }); + + if (this.nativeOco !== 'no') { + try { + const res = await this.submit(account, { orders: [limit, stop], contingencyType: 'OCO' }, [limitCode, stopCode]); + this.registerPair(accountId, account, pos.positionCode, [limitCode, stopCode], true); + console.log(`${this.tag} OCO exit group placed — limit ${limitCode} @ ${limit.limitPrice}, stop ${stopCode} @ ${stop.stopPrice}`); + return res; + } catch (err) { + if (!isOrderNotPlaced(err)) { + // It may be working: see what went in before doing more. + const open = await this.freshOpenOrders(account).catch(() => null); + if (!open) throw err; + const inGroup = open.filter((o) => o.orderCode === limitCode || o.orderCode === stopCode); + if (inGroup.length === 2) { + this.registerPair(accountId, account, pos.positionCode, [limitCode, stopCode], true); + return {}; + } + for (const o of inGroup) await this.cancelByCode(account, o.orderCode).catch(() => { /* reaper backs it up */ }); + } else { + console.warn(`${this.tag} OCO groups refused (${errorMessage(err)}) — pairing exits by hand from now on`); + this.nativeOco = 'no'; + } + } + } + + await this.submit(account, stop, [stopCode]); // throws OrderNotPlacedError when nothing went in + let limitRes: { orderId?: number }; + try { + limitRes = await this.submit(account, limit, [limitCode]); + } catch (err) { + this.registerPair(accountId, account, pos.positionCode, [stopCode], false); + console.error(`${this.tag} ${symbol}: stop ${stopCode} is working but the take-profit failed (${errorMessage(err)}) — place it by hand`); + throw new Error(`stop working, take-profit not placed: ${errorMessage(err)}`); + } + this.registerPair(accountId, account, pos.positionCode, [limitCode, stopCode], false); + console.log(`${this.tag} exit pair placed — limit ${limitCode} @ ${limit.limitPrice}, stop ${stopCode} @ ${stop.stopPrice}`); + return limitRes; + } + + private registerPair(accountId: number, account: string, positionCode: string, codes: string[], native: boolean): void { + this.ocoPairs.push({ accountId, account, positionCode, codes, native, done: false, placedAt: Date.now() }); + this.ensureOcoPoll(); + } + + /** While exit pairs are live: once a pair's position is gone, cancel + * whatever leg is still working. */ + private ensureOcoPoll(): void { + if (this.ocoPollInterval) return; + this.ocoPollInterval = setInterval(() => { + void (async () => { + const live = this.ocoPairs.filter((p) => !p.done); + if (live.length === 0) { + if (this.ocoPollInterval) { clearInterval(this.ocoPollInterval); this.ocoPollInterval = null; } + this.ocoPairs = []; + return; + } + for (const account of new Set(live.map((p) => p.account))) { + try { + const [positions, open] = await Promise.all([this.freshPositions(account), this.freshOpenOrders(account)]); + const openCodes = new Set(open.map((o) => o.orderCode)); + for (const pair of live.filter((p) => p.account === account)) { + const working = pair.codes.filter((c) => openCodes.has(c)); + if (working.length === 0) { pair.done = true; continue; } + if (positions.some((p) => p.positionCode === pair.positionCode)) continue; + pair.done = true; + for (const c of working) { + console.log(`${this.tag} position ${pair.positionCode} closed — cancelling leftover exit ${c}`); + await this.cancelByCode(account, c).catch((err) => + console.error(`${this.tag} failed to cancel leftover ${c}: ${errorMessage(err)}`)); + } + } + } catch { /* transient — the next poll retries */ } + } + })(); + }, OCO_POLL_MS); + } + + private async cancelOrder(accountId: number, orderCode: string): Promise { + const account = this.codeById.get(accountId); + if (account) await this.cancelByCode(account, orderCode); + } + + /** DELETE by client code. Servers that want If-Match get the order's + * version on a second try; a 404 means it is already gone. */ + private async cancelByCode(account: string, orderCode: string): Promise { + const path = `/accounts/${encodeURIComponent(account)}/orders/${encodeURIComponent(orderCode)}`; + try { + await this.request('trade', 'DELETE', path); + } catch (err) { + if (err instanceof DxApiError && err.status === 404) return; + if (!(err instanceof DxApiError) || ![403, 412, 428].includes(err.status)) throw err; + const order = (await this.freshOpenOrders(account)).find((o) => o.orderCode === orderCode); + if (!order) return; + await this.request('trade', 'DELETE', path, { headers: order.version !== undefined ? { 'If-Match': String(order.version) } : {} }); + } + } + + public onFill(orderId: number, callback: (fill: Record) => void): void { + this.fillWaiters.push({ orderId, callback }); + } + + // ── lifecycle ──────────────────────────────────────────────────────────── + + public health(): ClientHealth { + return { + platform: 'dxtrade', + authenticated: !!this.token, + transport: this.base ? 'rest' : 'none', + syncComplete: this.syncComplete, + lastSyncAt: this.lastSyncAt, + lastLoginAt: this.lastLoginAt, + lastLoginError: this.lastLoginError, + consecutiveLoginFailures: this.consecutiveLoginFailures, + nextReconnectAt: this.nextReconnectAt, + tokenExpiresAt: this.token ? this.lastRequestAt + this.sessionTimeoutMs : 0, + lastInboundAt: this.lastResponseAt, + pendingRequests: this.pendingFills, + syncInFlightSince: this.syncInFlight ? this.syncInFlightSince : 0, + daysFetchInFlightSince: this.daysFetchInFlight ? this.daysFetchInFlightSince : 0, + }; + } + + /** Whether a login can't happen until someone acts — the supervisor + * doesn't rebuild those (a rebuild can't fix a URL or a password). */ + public get needsAttention(): boolean { + return !this.base || this.credsRejected; + } + + /** Final: nothing this client scheduled fires afterwards. The session is + * logged out best-effort, so a rebuilt client doesn't collide with it. */ + public disconnect(): void { + this.intentionalDisconnect = true; + for (const t of [this.syncInterval, this.pingInterval, this.watchdogInterval, this.ocoPollInterval]) if (t) clearInterval(t); + this.syncInterval = this.pingInterval = this.watchdogInterval = this.ocoPollInterval = null; + if (this.loginRetryTimer) { clearTimeout(this.loginRetryTimer); this.loginRetryTimer = null; } + this.nextReconnectAt = 0; + this.daysFetchInFlight = false; + this.daysFetchInFlightSince = 0; + const token = this.token; + this.token = null; + if (token && this.base) { + void axios.post(`${this.base}/logout`, {}, { + headers: { Authorization: `DXAPI ${token}` }, + timeout: 5_000, + validateStatus: () => true, + }).catch(() => { /* best effort */ }); + } + } +} diff --git a/lib/dxtrade-helpers.ts b/lib/dxtrade-helpers.ts new file mode 100644 index 0000000..284b686 --- /dev/null +++ b/lib/dxtrade-helpers.ts @@ -0,0 +1,270 @@ +/** + * 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; +} + +/** 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)) { + 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); +} + +/** 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. + * + * Volatility sizing (targetHoldHours > 0): 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. + * + * Fixed sizing: the notional is set by the symbol map, so only the costs per + * dollar of notional matter. + * + * null = can't be scored (volatility sizing without a σ). + */ +export function scoreCandidate(o: { + spreadFraction: number; + commissionPct: number; + financingPctPerDay: number; + targetHoldHours: number; + volatilityPerHour: number | null; +}): number | null { + const perNotional = o.spreadFraction + 2 * (o.commissionPct / 100); + if (o.targetHoldHours > 0) { + if (!o.volatilityPerHour || !(o.volatilityPerHour > 0)) return null; + const financing = (o.financingPctPerDay / 100) * (o.targetHoldHours / 24); + return (perNotional + financing) / o.volatilityPerHour; + } + return perNotional; +} + +/** 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; + needVolatility: boolean; + 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.needVolatility && 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; +} diff --git a/lib/firm-catalog.ts b/lib/firm-catalog.ts index bd95723..145972d 100644 --- a/lib/firm-catalog.ts +++ b/lib/firm-catalog.ts @@ -17,7 +17,10 @@ * the two are not linked yet.) */ -import type { CatalogAccountType, CatalogFirm, FirmCatalogPayload } from './master-protocol'; +import { isFirmPlatform } from './master-protocol'; +import type { + CatalogAccountType, CatalogFirm, FirmCatalogPayload, FirmPlatform, SymbolMap, SymbolMapCandidate, +} from './master-protocol'; import { getSetting, setSetting, @@ -99,26 +102,70 @@ 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 }; + +/** 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)) { + if (!Array.isArray(list)) continue; + const candidates = list.flatMap((c): SymbolMapCandidate[] => { + if (typeof c !== 'object' || c === null) return []; + const cand = c as Record; + if (typeof cand.symbol !== 'string' || !cand.symbol || cand.symbol.includes('|')) return []; + const notional = nonNegative(cand.notionalPerContract); + const pct = typeof cand.commissionPct === 'number' && Number.isFinite(cand.commissionPct) && cand.commissionPct >= 0 + ? cand.commissionPct : undefined; + return [{ symbol: cand.symbol, notionalPerContract: notional, ...(pct !== undefined ? { commissionPct: pct } : {}) }]; + }); + 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 +183,24 @@ 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; 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; + } + const tradesEveryDay = 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 +222,37 @@ 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), + maxNotional: nonNegative(type.maxNotional), + // 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, 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: nonNegative(firm.commissionPct), + 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 }; diff --git a/lib/master-protocol.ts b/lib/master-protocol.ts index 7527b42..f9f61eb 100644 --- a/lib/master-protocol.ts +++ b/lib/master-protocol.ts @@ -205,19 +205,69 @@ 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; + /** Cap on one position's notional in dollars (0 = none), where the + * platform prices positions (DXTrade). */ + maxNotional?: 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. */ +export interface SymbolMapCandidate { + /** The platform's symbol, e.g. "BTC/USD". */ + symbol: string; + /** Fixed sizing: notional per $1,000 of leg (like futures' one contract + * per $1,000). Used when volatility sizing is off or can't run. */ + notionalPerContract: number; + /** Per-side commission % for this symbol; absent = the firm's. */ + commissionPct?: number; +} + +/** 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; + 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; 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: which symbols each CME root may trade as. */ + symbolMap?: SymbolMap; + /** DXTrade volatility sizing: aim each leg's expected hold at this many + * hours (0 = fixed sizing from the symbol map). */ + targetHoldHours?: number; + /** Overnight financing the firm charges, % of notional per day (0 = none). */ + financingPctPerDay?: number; updatedAt: string; } diff --git a/lib/platform-client.ts b/lib/platform-client.ts index 6a8fd63..b8a959d 100644 --- a/lib/platform-client.ts +++ b/lib/platform-client.ts @@ -4,11 +4,14 @@ * 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; @@ -19,14 +22,33 @@ 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; + /** Fixed sizing: notional per $1,000 of leg (the symbol map's setting). */ + fixedNotional?: 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; } 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. */ + accountCashBalances: { [accountId: number]: { amount: number; realizedPnL: number; pendingRoll?: boolean; equity?: number } }; positions: { [accountId: number]: { contractId: number | string; @@ -69,6 +91,15 @@ 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; @@ -113,10 +144,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; diff --git a/lib/reporter.ts b/lib/reporter.ts index 77ee0d9..54f046e 100644 --- a/lib/reporter.ts +++ b/lib/reporter.ts @@ -18,7 +18,7 @@ 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 { botStopsActive, combineCmeSessions, computeDayPlan, currentTradeDateCT, cycleCompletion, endedCycleCompletion, LEG_COMPLETION_EPSILON, projectPerfectPath, resolveEffectiveConfig } from './trading-logic'; import type { AccountTypeStats, FirmStats } from './master-protocol'; import { historyFresh } from './platform-client'; @@ -288,6 +288,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 +350,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) { diff --git a/lib/trading-logic.ts b/lib/trading-logic.ts index cf0ebcb..f972946 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -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 { @@ -1040,3 +1072,339 @@ 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; +/** 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. + */ +export function dllStopDollars(o: { room: number; dailyLossLimit: number; entryFee: number; commissionPct: number }): number { + if (!Number.isFinite(o.room)) return Infinity; + const r = Math.max(0, o.commissionPct) / 100; + return (o.room - DLL_RESERVE_FRACTION * o.dailyLossLimit - 2 * o.entryFee - 0.01) / (1 + r); +} + +/** 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 ──────────────────────────────────────────────────────────── + +/** + * 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) 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. + * + * Fixed mode (no target hold, no σ, or a stopless leg): the symbol map's + * notional per $1,000 of leg, like futures' one contract per $1,000. + * Returns null when neither mode can size the leg. + */ +export function sizeNotional(o: { + leg: number; + /** Stop distance in dollars at a given notional (fees move with it). */ + stopDollarsAt: (notional: number) => number; + volatilityPerHour?: number; + targetHoldHours: number; + /** Per side, percent. */ + commissionPct: number; + spreadFraction?: number; + financingPctPerDay?: number; + /** Notional per $1,000 of leg for fixed sizing; 0 = none. */ + fixedNotional: number; + /** Hard cap on the position; 0 = none. */ + maxNotional: number; +}): { notional: number; mode: 'volatility' | 'fixed'; predictedHoldHours: number | null; winProbability: number | null } | null { + const c = Math.max(0, o.commissionPct) / 100; + const s = Math.max(0, o.spreadFraction ?? 0); + const sigma = o.volatilityPerHour ?? 0; + const fixed = o.fixedNotional > 0 ? Math.max(1, Math.ceil(o.leg / 1000)) * o.fixedNotional : 0; + const cap = (n: number) => (o.maxNotional > 0 ? Math.min(n, o.maxNotional) : n); + const describe = (n: number) => { + const a = o.leg + 2 * c * n; + const b = o.stopDollarsAt(n); + if (!(sigma > 0) || !Number.isFinite(b) || b <= 0 || n <= 0) { + return { predictedHoldHours: null, winProbability: Number.isFinite(b) && b > 0 ? b / (a + b) : null }; + } + return { predictedHoldHours: (a / n) * (b / n) / (sigma * sigma), winProbability: b / (a + b) }; + }; + + if (o.targetHoldHours > 0 && sigma > 0) { + let n = fixed > 0 ? fixed : o.leg * 20; + let ok = true; + // Fees depend on N and the daily-loss stop depends on fees: a couple of + // fixed-point passes settle it (the correction is second order). + for (let pass = 0; pass < 3; pass++) { + const a = o.leg + 2 * c * n; + const b = o.stopDollarsAt(n); + if (!Number.isFinite(b) || b <= 0) { ok = false; break; } + const nTime = Math.sqrt((a * b) / (sigma * sigma * o.targetHoldHours)); + const fh = Math.max(0, o.financingPctPerDay ?? 0) / 100 / 24; + const nCost = fh > 0 ? Math.sqrt((fh * a * b) / (sigma * sigma * (2 * c + s))) : 0; + n = Math.max(nTime, nCost); + } + if (ok && Number.isFinite(n) && n > 0) { + const notional = cap(n); + return { notional, mode: 'volatility', ...describe(notional) }; + } + } + if (fixed > 0) { + const notional = cap(fixed); + return { notional, mode: 'fixed', ...describe(notional) }; + } + return null; +} + +// ── 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; +} diff --git a/package.json b/package.json index 5e4c2e0..cb99ee1 100644 --- a/package.json +++ b/package.json @@ -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" }, "dependencies": { "@microsoft/signalr": "^10.0.11", diff --git a/scripts/check-trading-logic.mjs b/scripts/check-trading-logic.mjs new file mode 100644 index 0000000..a6aa66a --- /dev/null +++ b/scripts/check-trading-logic.mjs @@ -0,0 +1,483 @@ +// 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, + projectPerfectPath, cycleCompletion, DLL_RESERVE_FRACTION, DLL_MIN_STOP, +} 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, fixedNotional: 0, maxNotional: 0 }); + for (const [hours, notional] of [[54, 20000], [24, 30000], [6, 60000]]) { + const s = at(hours); + assert.equal(s.mode, 'volatility'); + 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, fixed fallback', () => { + const base = { leg: 600, volatilityPerHour: btcSigma, targetHoldHours: 6, commissionPct: 0.04, spreadFraction: 0.0002, fixedNotional: 20000, 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); + + const fixed = sizeNotional({ ...base, stopDollarsAt: () => Infinity, leg: 1700 }); + assert.equal(fixed.mode, 'fixed'); + assert.equal(fixed.notional, 40000); + assert.equal(sizeNotional({ ...base, stopDollarsAt: () => Infinity, fixedNotional: 0 }), null); + assert.equal(sizeNotional({ ...base, targetHoldHours: 0, stopDollarsAt }).mode, 'fixed'); +}); + +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; + 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++; + 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}`); +}); + +// ── 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, parseDurationMs, ciNum, signedFundValue, inTradingHours, + realizedVolPerHour, scoreCandidate, candidateRejection, MIN_VOL_CANDLES, +} from '../lib/dxtrade-helpers.ts'; + +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('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); + // Fixed sizing ignores volatility; volatility sizing needs it. + assert.equal(scoreCandidate({ spreadFraction: 0.0002, commissionPct: 0.04, financingPctPerDay: 0, targetHoldHours: 0, volatilityPerHour: null }), 0.001); + assert.equal(scoreCandidate({ spreadFraction: 0.0002, commissionPct: 0.04, financingPctPerDay: 0, targetHoldHours: 6, volatilityPerHour: null }), null); +}); + +test('the selector drops what cannot trade right now', () => { + const ok = { + tradingStatus: 'FULL', bid: 100, ask: 100.02, quoteAgeMs: 1000, inHours: true, candles: 49, + needVolatility: true, 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.equal(candidateRejection({ ...ok, candles: 10, needVolatility: false }), null); + assert.match(candidateRejection({ ...ok, currency: 'EUR' }), /EUR/); +}); diff --git a/scripts/dxtrade-smoke.mjs b/scripts/dxtrade-smoke.mjs new file mode 100644 index 0000000..6332c03 --- /dev/null +++ b/scripts/dxtrade-smoke.mjs @@ -0,0 +1,217 @@ +#!/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= 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; + const leg = (c, type, price) => ({ + account: ORDER_ACCOUNT, orderCode: c, type, instrument: symbol, quantity: pos.quantity, 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); + show('open orders now', (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); + 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= to also test a live minimum-size round trip)'); +} + +await call('POST', '/logout'); diff --git a/types.ts b/types.ts index 3a354a9..c2c38fe 100644 --- a/types.ts +++ b/types.ts @@ -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; @@ -27,6 +29,13 @@ 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 10% reserve and fees. */ + dailyLossLimit?: number; + /** Cap on one position's notional in dollars (0/absent = none). */ + maxNotional?: number; + /** The firm trades every calendar day, weekends and CME holidays included. */ + tradesEveryDay?: boolean; } export interface FirmConfig { @@ -35,12 +44,12 @@ 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[]; /** The firm's account types, straight from the catalog. Doubles as the @@ -76,10 +85,15 @@ 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; /** 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. */