/** * Auto-trade scheduler * * When a trade is triggered (POST /api/trade), the scheduler stores the * action + symbol and fires the same trade logic every 60 seconds to pick * up accounts that were busy (in a position) at the time of the original * signal but have since exited and are now eligible. */ import { getInstruments } from './db'; import { getClients } from './clients'; import { getTradableFirms } from './firm-catalog'; import { getAccountConfig } from './account-type-resolver'; import { combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT, BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY, POINT_VALUES, FIRST_DAY_STOP, MIN_DAY_STOP_MULT, type DayPlan, } from './trading-logic'; import { getSetting, setSetting } from './db'; import { errorMessage, isClientReady, isOrderNotPlaced, type PlatformClient } from './platform-client'; // ── process-wide state ─────────────────────────────────────────────────────── /** * Everything the batches share, held on globalThis like the scheduler state * further down. Next can bundle this module more than once — the scheduler * tick runs from whichever bundle booted the clients, POST /api/trade and * /api/copy-trade from their own route bundles — and plain module-level state * would split into one private copy per bundle: a leg recorded by the tick * would be invisible to a copy-trade, the trade mutex would lock nothing, and * the session-open jitter could be rolled twice in one day. */ interface TradeRuntime { /** In-memory runaway guard: legs fired per account per trade date, plus * the sum of their targets — realized P&L falling short of that sum is * how a stop-out is detected. Resets on restart, which is fine — day * completion is derived from realized PnL, not from this counter. */ legCounts: Map; /** Per account, the trade date of its last entry the broker confirmed as * filled — the one-and-done guard's "already traded", which an entry * whose outcome is unknown or that never filled must not trip. */ filledEntries?: Map; /** Today's session-open delay, keyed by Central calendar day (see openJitterMinutes). */ sessionOpenJitter: Map; /** Accounts whose orders are being placed right now. Their position only * shows once the fill comes back over the socket, so until then this is * what keeps them out of every other batch. */ accountsInFlight: Set; /** Firm id → when the tick first saw its client not ready (see UNREADY_GRACE_MS). */ 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; // What getSchedulerHealth reports. lastTickAt: number; lastTickOutcome: TickOutcome; lastFireAt: number; holding: string[]; quarantined: string[]; } /** What the scheduler's last tick decided (getSchedulerHealth). */ export type TickOutcome = | 'not-running' | 'holding-for-sync' | 'positions-open' | 'closed' | 'no-eligible' | 'fired' | 'skipped-in-flight' | `error: ${string}`; const _tradeGlobal = globalThis as typeof globalThis & { __autoTradeRuntime?: TradeRuntime }; function runtime(): TradeRuntime { if (!_tradeGlobal.__autoTradeRuntime) { _tradeGlobal.__autoTradeRuntime = { legCounts: new Map(), sessionOpenJitter: new Map(), accountsInFlight: new Set(), unreadySince: new Map(), quarantineLoggedAt: new Map(), tradeInFlightSince: 0, lastTickAt: 0, lastTickOutcome: 'not-running', lastFireAt: 0, holding: [], quarantined: [], }; } return _tradeGlobal.__autoTradeRuntime; } /** How long the tick holds every firm for one whose client isn't ready. Long * enough for a restart or a reconnect to finish syncing; past it the client * is presumed dead and its firm is quarantined (see assessReadiness). */ const UNREADY_GRACE_MS = 10 * 60 * 1000; /** How often a quarantined firm is re-announced in the log. */ const QUARANTINE_LOG_EVERY_MS = 5 * 60 * 1000; /** A batch that has held the trade mutex this long is presumed wedged. */ const TRADE_WEDGED_MS = 10 * 60 * 1000; // ── helpers ────────────────────────────────────────────────────────────────── /** How many accounts one batch may fire, never below 1. The settings PATCH * stores whatever the form sends, and a cleared field arrives as '' — * parseInt('') is NaN, which slice() takes as 0: every batch would fire * nobody while the tick reported 'fired'. Non-numeric falls back to the * default 5, like a missing setting; the form's own floor is 1. */ function maxConcurrentAccounts(): number { const configured = parseInt(getSetting('max_concurrent_accounts') ?? '5', 10); return Math.max(1, Number.isFinite(configured) ? configured : 5); } function isAccountDead(amount: number, autoLiqThreshold: number): boolean { // Tradovate floors are positive balances; reconstructed TopstepX MLL // floors are negative (or +$0.01 once locked at breakeven). 0 = no floor. return autoLiqThreshold !== 0 && amount <= autoLiqThreshold; } function legsToday(accountId: number, tradeDate: string): number { const entry = runtime().legCounts.get(accountId); return entry && entry.date === tradeDate ? entry.count : 0; } function legSumToday(accountId: number, tradeDate: string): number { const entry = runtime().legCounts.get(accountId); return entry && entry.date === tradeDate ? entry.sum : 0; } function filledEntryToday(accountId: number, tradeDate: string): boolean { return runtime().filledEntries?.get(accountId) === tradeDate; } function recordLeg(accountId: number, tradeDate: string, legAmount: number): void { runtime().legCounts.set(accountId, { date: tradeDate, count: legsToday(accountId, tradeDate) + 1, sum: Math.round((legSumToday(accountId, tradeDate) + legAmount) * 100) / 100, }); } /** Take back a leg recordLeg counted for an entry that never reached the * broker. Left standing, it would bench a stop-type account for the day — * realized P&L "falls short" of a leg that never traded — and burn one of * the day's MAX_LEGS_PER_DAY. */ function unrecordLeg(accountId: number, tradeDate: string, legAmount: number): void { const legCounts = runtime().legCounts; const entry = legCounts.get(accountId); if (!entry || entry.date !== tradeDate || entry.count <= 0) return; if (entry.count === 1) { legCounts.delete(accountId); return; } legCounts.set(accountId, { date: tradeDate, count: entry.count - 1, sum: Math.round((entry.sum - legAmount) * 100) / 100, }); } /** * Send a leg's market entry. The leg was recorded before this — the runaway * guard has to see it while the batch is still in flight — so it is taken * back here only when the client KNOWS the order never reached the broker * (OrderNotPlacedError). Anything ambiguous, like an ack or fill timeout, * keeps the leg: the order may well have filled, and a leg counted that never * happened only costs a leg, while one forgotten that did can trade the * account twice. */ async function sendEntry( tag: string, // eslint-disable-next-line @typescript-eslint/no-explicit-any client: any, acc: { id: number; name: string }, contractName: string, contracts: number, action: 'Buy' | 'Sell', tradeDate: string, legAmount: number, ) { try { const fill = await client.sendOrder(acc.id, contractName, contracts, action, 'Market'); (runtime().filledEntries ??= new Map()).set(acc.id, tradeDate); return fill; } catch (err) { if (isOrderNotPlaced(err)) { unrecordLeg(acc.id, tradeDate, legAmount); console.warn(`${tag} ${acc.name}: entry never reached the broker — leg un-counted`); } else { console.warn(`${tag} ${acc.name}: entry outcome unknown — leg kept (it may have filled)`); } throw err; } } /** Run one account's part of a batch with the account marked in flight, so * no other batch picks it while its orders are still being placed. */ async function withAccountInFlight(accountId: number, fire: () => Promise): Promise { const inFlight = runtime().accountsInFlight; inFlight.add(accountId); try { return await fire(); } finally { inFlight.delete(accountId); } } // ── readiness ──────────────────────────────────────────────────────────────── type TradableFirm = ReturnType[number]; interface FirmClient { firm: TradableFirm; client: PlatformClient } /** * 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 * stale holds a frozen snapshot, whose positions may be long gone and which * may be missing ones opened since — it must neither trade nor gate. */ function firmClients(now = Date.now()): { ready: FirmClient[]; unready: FirmClient[] } { const clients = getClients(); const ready: FirmClient[] = []; const unready: FirmClient[] = []; for (const firm of getTradableFirms()) { const client = clients.get(firm.id); if (!client) continue; (isClientReady(client, now) ? ready : unready).push({ firm, client }); } return { ready, unready }; } function countOpenPositions(ready: FirmClient[]): number { return ready.reduce( (count, { client }) => count + client.accountList.filter((acc) => !!client.positions[acc.id]).length, 0, ); } /** * Which firms hold the tick, and which it trades without. * * A firm whose client isn't ready might hold positions nobody can see, so for * the first UNREADY_GRACE_MS the whole tick waits for it, as it always has — * right for a restart or a reconnect, which settle within a minute or two. * Past the grace period the client is presumed dead (broker maintenance, a * login that keeps failing) and the firm is quarantined: the other firms * trade without it, rather than one broken session stopping every firm on the * machine. A quarantined firm trades nothing — runTrade and copyTrade only * ever see ready clients — and rejoins on its first good sync. * * A client that has synced and found no accounts is ignored outright: it * cannot hold positions or trade, so there is nothing to wait for. */ function assessReadiness( ready: FirmClient[], unready: FirmClient[], now: number, ): { holding: TradableFirm[]; quarantined: { firm: TradableFirm; unreadyMs: number }[] } { const rt = runtime(); const holding: TradableFirm[] = []; const quarantined: { firm: TradableFirm; unreadyMs: number }[] = []; const tracked = new Set(); for (const { firm, client } of unready) { if (client.lastSyncAt > 0 && client.accountList.length === 0) continue; tracked.add(firm.id); const since = rt.unreadySince.get(firm.id) ?? now; rt.unreadySince.set(firm.id, since); const unreadyMs = now - since; if (unreadyMs < UNREADY_GRACE_MS) holding.push(firm); else quarantined.push({ firm, unreadyMs }); } // Forget firms that are ready again (or gone), and say so for any that // had been traded around. for (const id of [...rt.unreadySince.keys()]) { if (tracked.has(id)) continue; const back = ready.find((r) => r.firm.id === id); if (back && rt.quarantineLoggedAt.has(id)) { console.log(`[scheduler] ${back.firm.name} synced again — trading it with the others`); } rt.unreadySince.delete(id); rt.quarantineLoggedAt.delete(id); } return { holding, quarantined }; } // ── trade mutex ────────────────────────────────────────────────────────────── /** * One batch at a time, instance-wide. 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. * * 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 * rather than nothing trading for the rest of the process's life. The stuck * batch's accounts stay in accountsInFlight, so they are never traded twice, * and if the stuck batch does wake up it finds it no longer holds the mutex * and abandons its now-stale selection (holdsTradeMutex). * * Returns the lease to hand to releaseTradeMutex, or null to skip. */ function acquireTradeMutex(tag: string): number | null { const rt = runtime(); const now = Date.now(); if (rt.tradeInFlightSince > 0) { const heldMs = now - rt.tradeInFlightSince; if (heldMs < TRADE_WEDGED_MS) { console.log(`${tag} another 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`); } rt.tradeInFlightSince = now; return now; } function holdsTradeMutex(lease: number): boolean { return runtime().tradeInFlightSince === 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; } /** 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. */ async function legInstrument( // eslint-disable-next-line @typescript-eslint/no-explicit-any client: any, drain: boolean, batchContract: { name: string; tickSize: number }, batchPointValue: number, ): Promise<{ contract: { name: string; tickSize: number }; pointValue: number }> { if (!drain) return { contract: batchContract, pointValue: batchPointValue }; const mnq = await client.findFrontMonthContract('MNQ'); if (!mnq) throw new Error('MNQ contract not found for drain trade'); return { contract: 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; /** * 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. */ function entryCommissionFrom(before: number, after: number, contracts: number): number { 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; } // ── per-account trade evaluation (shared by runTrade / copyTrade / hasRemaining…) ── /** The ineligible reason for an account whose history isn't loaded yet — still * in play for stop-after-all, since it trades once the fetch lands. */ const HISTORY_PENDING = 'history not loaded yet — waiting for the fetch'; /** Likewise for an account whose broker hasn't rolled its cash record to the * new trade date, when this process didn't see the old day end. */ const ROLL_PENDING = "waiting for the broker to roll the account's day"; interface AccountEvaluation { eligible: boolean; reason: string; plan: DayPlan | null; isExtraDay: boolean; /** Current stage is a drain stage — the leg trades MNQ regardless of the batch symbol. */ drain: boolean; cash: { amount: number; realizedPnL: number }; dailyPnL: { date: string; pnl: number }[]; daysTraded: number; } /** * Evaluate whether an account can trade right now. Returns null when the account has no * matching config (not ours). Split-allowed accounts stay eligible mid-day until * remainingToday drops to LEG_COMPLETION_EPSILON or the leg cap is hit; extra-day and * strict-consistency (past day 1) accounts keep the legacy one-trade-per-day gate. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function evaluateAccountForTrade(client: any, acc: { id: number; name: string; active: boolean }, tradeDate: string): AccountEvaluation | null { const cfg = getAccountConfig(acc.id).accountType; if (!cfg) return null; const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 }; const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0; // A combining firm records holiday sessions with the next full trading day, // so the plan and the already-traded gates below must see the whole stretch // as one day: Sunday's booked P&L folds into Tuesday's realized, and the // day target stays anchored to the combined (clearing) trade date. `cash` // itself stays raw — the commission delta in the fire path compares live // Tradovate values. const combined = combineCmeSessions( cfg.combineCmeHolidays, client.dailyPnL[acc.id] ?? [], tradeDate, cash.realizedPnL, ); const dailyPnL = combined.dailyPnL; const daysTraded = combined.daysTraded; const base = { plan: null, isExtraDay: false, drain: false, cash, dailyPnL, daysTraded }; 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' }; // Another batch is placing this account's order right now; its position // won't show until the fill comes back, so the check above can't see it. if (runtime().accountsInFlight.has(acc.id)) return { ...base, eligible: false, reason: 'order in flight' }; // History this client hasn't loaded (a restart, a rebuild, a wipe, a new // account) or that a payout has overtaken would plan the account as a // fresh stage-1 first day: an oversized leg, or a re-opened finished cycle. if (!client.historyReady(acc.id)) return { ...base, eligible: false, reason: HISTORY_PENDING }; if (cash.pendingRoll) return { ...base, eligible: false, reason: ROLL_PENDING }; // 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. // The plain negative-realized check is what survives a restart, when the // in-memory leg counter has forgotten the morning's stop-out. if (cfg.firstDayStop || cfg.minDayStop) { if (combined.realizedPnL <= -25) { return { ...base, eligible: false, reason: 'red day booked — make-back rides tomorrow' }; } if (legsToday(acc.id, tradeDate) > 0 && combined.realizedPnL < legSumToday(acc.id, tradeDate) - 25) { return { ...base, eligible: false, reason: 'stopped out — day booked' }; } } const priorProfit = client.priorProfit?.[acc.id] ?? 0; const allFundTxns = client.fundTransactions?.[acc.id] ?? []; const effective = resolveEffectiveConfig( cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, priorProfit, allFundTxns, cfg.minDayPnL ); const equityProfit = cash.amount - cfg.accountSize; const plan = computeDayPlan( acc.id, combined.tradeDate, effective.profitTarget, effective.consistency, dailyPnL, effective.minDayPnL, effective.minTradingDays, equityProfit, combined.realizedPnL, cfg.splitEnabled, effective.drain ); if (!plan) return { ...base, eligible: false, reason: 'invalid balance' }; const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0); const isExtraDay = !effective.drain && effective.minDayPnL <= 0 && effective.minTradingDays > daysTraded && totalProfit >= effective.profitTarget; const evaluated = { plan, isExtraDay, drain: effective.drain, cash, dailyPnL, daysTraded }; if (isExtraDay || !plan.splitAllowed) { // Legacy one-and-done gate: any realized PnL today means this account is // finished — for a combining account, "today" spans the holiday stretch. if (combined.realizedPnL !== 0) { return { ...evaluated, eligible: false, reason: `already traded today (realizedPnL=${combined.realizedPnL})` }; } // And any entry of today's the broker confirmed filled, whatever its // realized P&L says (a record replaced under it, a fill not booked // yet). Only a confirmed fill: an entry that timed out or was // rejected unfilled leaves the account free to retry. if (filledEntryToday(acc.id, tradeDate)) { return { ...evaluated, eligible: false, reason: 'already traded today (entry filled)' }; } if (!isExtraDay && plan.remainingToday <= 0) { const reason = effective.drain && plan.dayTarget === 0 ? 'drain days booked — waiting for withdrawal' : `target=$${plan.dayTarget} (challenge complete)`; return { ...evaluated, eligible: false, reason }; } } else { // Split-leg mode: keep trading until the day's remaining target is used up. // The epsilon only applies once a leg has completed, so tiny day-start targets still trade. const doneThreshold = combined.realizedPnL === 0 ? 0 : LEG_COMPLETION_EPSILON; if (plan.remainingToday <= doneThreshold) { const reason = combined.realizedPnL === 0 ? `target=$${plan.dayTarget} (challenge complete)` : `day done ($${plan.remainingToday} left of $${plan.dayTarget})`; return { ...evaluated, eligible: false, reason }; } if (legsToday(acc.id, tradeDate) >= MAX_LEGS_PER_DAY) { return { ...evaluated, eligible: false, reason: `leg cap reached (${MAX_LEGS_PER_DAY} today)` }; } } return { ...evaluated, eligible: true, reason: '' }; } // ── helpers ─────────────────────────────────────────────────────────────────── // Random 1–20 minute delay added to the session open, picked once per Central // calendar day. Re-rolling on every check would move the open boundary between // polls, letting a trade slip through a moment the next check would reject. // Held in runtime() so every bundle of this module agrees on the day's roll. function openJitterMinutes(centralDay: string): number { const sessionOpenJitter = runtime().sessionOpenJitter; let jitter = sessionOpenJitter.get(centralDay); if (jitter === undefined) { jitter = 1 + Math.floor(Math.random() * 20); // 1–20 inclusive sessionOpenJitter.clear(); // only today's entry is ever needed sessionOpenJitter.set(centralDay, jitter); console.log(`[trading-hours] session open delayed ${jitter} min today`); } return jitter; } /** Today's session-open delay in minutes, for reporting to the master. */ export function todaysSessionOpenDelay(): number { const centralDay = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Chicago', }).format(new Date()); 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; } /** * 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. */ 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; } // ── core trade logic ────────────────────────────────────────────────────────── /** One account's outcome in a batch: its fill summary, or { status: 'error', reason }. */ type LegResult = { status: string; [key: string]: unknown }; type FirmTradeResults = { firm: string; results: LegResult[] }; type TradeOutcome = Extract; export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string): Promise { return (await runTradeBatch(action, symbol)).results; } /** runTrade plus what it decided, which the tick records for getSchedulerHealth. */ async function runTradeBatch( action: 'Buy' | 'Sell' | 'Auto', symbol: string, ): Promise<{ outcome: TradeOutcome; results: FirmTradeResults[] }> { const lease = acquireTradeMutex('[auto-trade]'); if (lease === null) return { outcome: 'skipped-in-flight', results: [] }; try { return await runTradeLocked(action, symbol, lease); } finally { releaseTradeMutex(lease); } } async function runTradeLocked( action: 'Buy' | 'Sell' | 'Auto', symbol: string, lease: number, ): Promise<{ outcome: TradeOutcome; results: FirmTradeResults[] }> { if (isInNoTradeWindow()) { console.log('[auto-trade] CME 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); if (openPositions > 0) { console.log(`[auto-trade] ${openPositions} position(s) still open — skipping`); return { outcome: 'positions-open', results: [] }; } // 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}`); } // 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}`); 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; }; const allEligible: EligibleItem[] = []; await Promise.all(ready.map(async ({ firm, client }) => { if (client.accountList.length === 0) return; // Skip this firm entirely if the symbol is banned for it if (firm.bannedSymbols.includes(resolvedSymbol)) { console.log(`[auto-trade] ${firm.name}: ${resolvedSymbol} is banned — skipping firm`); return; } // One firm's trouble (a contract lookup that throws, a malformed // snapshot) skips that firm, never the batch — a rejection here would // fail the Promise.all and every other firm would sit the tick out. try { const contract = await client.findFrontMonthContract(resolvedSymbol); if (!contract) return; const found: EligibleItem[] = []; for (const acc of client.accountList) { const ev = evaluateAccountForTrade(client, acc, tradeDate); if (!ev || !ev.eligible || !ev.plan) continue; found.push({ firmName: firm.name, client, acc, contract, cash: ev.cash, dailyPnL: ev.dailyPnL, daysTraded: ev.daysTraded, plan: ev.plan, isExtraDay: ev.isExtraDay, drain: ev.drain, prefix: getAccountConfig(acc.id).accountType?.prefix ?? '', }); } allEligible.push(...found); } catch (err) { console.error(`[auto-trade] ${firm.name}: skipped — ${errorMessage(err)}`); } })); if (allEligible.length === 0) { console.log('[auto-trade] 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)) { console.warn('[auto-trade] superseded while collecting accounts — abandoning this batch'); return { outcome: 'skipped-in-flight', results: [] }; } // Take only the first batch — all fired simultaneously, no rolling pool. // Remaining accounts wait for the next tick (which only fires once all positions are flat). // Like accounts trade together: a batch is drawn from a single prefix group // (largest group first), never mixed across prefixes — a group's remainder // fires as a smaller batch on a later tick instead of being padded from // another prefix. E.g. 4×FTDFY, 5×MFFUEV, 7×MFFUSF at limit 5 fires as // batches of 5, 5, 4, 2. const groups = new Map(); for (const item of allEligible) { const group = groups.get(item.prefix); if (group) group.push(item); else groups.set(item.prefix, [item]); } 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)'}`); 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!; // 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. if (item.isExtraDay) { const mnqContract = await client.findFrontMonthContract('MNQ'); if (!mnqContract) throw new Error('MNQ contract not found for extra-day trade'); const fill = await client.sendOrder(acc.id, mnqContract.name, 1, resolvedAction, 'Market'); 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)`); return { account: acc.name, firm: item.firmName, status: 'filled', contracts: 1, target: 0, grossTarget: 0, totalCommission: 0, targetPath: 'extra_day', entryPrice: fill.price, exitPrice: exitFill.price, commission: 0, }; } // 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, }; }))); // Group results by firm for the response const firmResultsMap = new Map(); for (let i = 0; i < batch.length; i++) { const firmName = batch[i].firmName; if (!firmResultsMap.has(firmName)) firmResultsMap.set(firmName, []); const r = tradeResults[i]; if (r.status === 'rejected') { console.error(`[auto-trade] ${batch[i].acc.name} (${firmName}): failed — ${errorMessage(r.reason)}`); } firmResultsMap.get(firmName)!.push( r.status === 'fulfilled' ? r.value : { status: 'error', reason: errorMessage(r.reason) } ); } return { outcome: 'fired', results: Array.from(firmResultsMap.entries()).map(([firm, results]) => ({ firm, results })), }; } // ── 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. */ export async function copyTrade() { console.log('[copy-trade] called'); // Shares runTrade'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]'); if (lease === null) return []; try { return await copyTradeLocked(lease); } finally { releaseTradeMutex(lease); } } async function copyTradeLocked(lease: number): Promise { if (isInNoTradeWindow()) { 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(); if (unready.length > 0) { console.log(`[copy-trade] not synced — leaving out: ${unready.map((u) => u.firm.name).join(', ')}`); } const maxConcurrent = maxConcurrentAccounts(); // 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. let positionContractId: number | string | null = null; let positionedCount = 0; // eslint-disable-next-line @typescript-eslint/no-explicit-any let positionedClient: any = null; const positionedPrefixes = new Set(); for (const { client } of ready) { for (const acc of client.accountList) { const pos = client.positions[acc.id]; if (!pos) continue; positionedCount++; const prefix = getAccountConfig(acc.id).accountType?.prefix; if (prefix) positionedPrefixes.add(prefix); if (!resolvedAction) { resolvedAction = pos.netPos > 0 ? 'Buy' : 'Sell'; positionContractId = pos.contractId; positionedClient = client; } } } if (!resolvedAction || positionedCount === 0) { console.log('[copy-trade] no open positions to copy from'); return []; } console.log(`[copy-trade] found ${positionedCount} open position(s), action=${resolvedAction}, contractId=${positionContractId}`); // Resolve symbol from the positioned contract's contractId — only check the client that // actually has the position. Iterating every firm × every symbol can take 30+ seconds. let resolvedSymbol: string | null = null; const instruments = getInstruments(); const enabledSymbols = instruments.filter(i => i.enabled).map(i => i.symbol); if (positionedClient) { for (const sym of enabledSymbols) { // A lookup that throws rules out that one symbol, not the copy. try { const contract = await positionedClient.findFrontMonthContract(sym); if (contract && contract.id === positionContractId) { resolvedSymbol = sym; break; } } catch (err) { console.error(`[copy-trade] ${sym}: contract lookup failed — ${errorMessage(err)}`); } } } console.log(`[copy-trade] resolved symbol: ${resolvedSymbol ?? '(none, using fallback)'}`); // Fall back to scheduler symbol or first enabled if (!resolvedSymbol) { 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 slotsAvailable = maxConcurrent - positionedCount; if (slotsAvailable <= 0) { console.log(`[copy-trade] already at max concurrent (${positionedCount}/${maxConcurrent})`); return []; } 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[] = []; await Promise.all(ready.map(async ({ firm, client }) => { if (client.accountList.length === 0) { console.log(`[copy-trade] ${firm.name}: empty account list — skip firm`); return; } if (firm.bannedSymbols.includes(resolvedSymbol!)) { console.log(`[copy-trade] ${firm.name}: ${resolvedSymbol} is banned — skip firm`); return; } // Same isolation as runTrade: one firm's throw skips that firm only. try { const contract = await client.findFrontMonthContract(resolvedSymbol!); if (!contract) { console.log(`[copy-trade] ${firm.name}: cannot resolve front-month contract for ${resolvedSymbol} — skip firm`); return; } const found: CopyItem[] = []; 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; } if (!ev.eligible || !ev.plan) { console.log(`[copy-trade] ${acc.name}: ${ev.reason} — skip`); continue; } // copyTrade has no extra-day branch — those accounts wait for the scheduler 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, drain: ev.drain, prefix: getAccountConfig(acc.id).accountType?.prefix ?? '', }); } eligible.push(...found); } catch (err) { console.error(`[copy-trade] ${firm.name}: skipped — ${errorMessage(err)}`); } })); // 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 ordered = [...eligible.filter(matchesPositioned), ...eligible.filter((i) => !matchesPositioned(i))]; const batch = ordered.slice(0, slotsAvailable); if (batch.length === 0) { console.log('[copy-trade] no eligible accounts to copy to'); return []; } // Superseded while collecting (see runTrade) — this selection is stale. if (!holdsTradeMutex(lease)) { console.warn('[copy-trade] superseded while collecting accounts — abandoning this copy'); return []; } console.log(`[copy-trade] copying ${resolvedAction} ${resolvedSymbol} to ${batch.length} account(s)`); 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 results: unknown[] = []; for (let i = 0; i < batch.length; i++) { const r = tradeResults[i]; if (r.status === 'rejected') { console.error(`[copy-trade] ${batch[i].acc.name} (${batch[i].firmName}): failed — ${errorMessage(r.reason)}`); } results.push( r.status === 'fulfilled' ? r.value : { status: 'error', reason: errorMessage(r.reason) } ); } return results; } // ── eligibility check ──────────────────────────────────────────────────────── /** Returns true if any configured account could still trade today (eligible for a next leg / extra-day, or has an open position). */ function hasRemainingConfiguredAccounts(): boolean { const { ready, unready } = firmClients(); const tradeDate = currentTradeDateCT(); // A client that isn't ready can't say whether its accounts are done — its // snapshot is missing or stale — so while one might hold any of ours, the // scheduler stays up for it to rejoin. Only a client that never synced // and knows no accounts is an unknown in full; one whose (stale) list // shows none of ours can be ruled out. // // Only while the session is open, though. Outside it the unready firm // couldn't trade anyway, and after the close its day is over. Unbounded, // one firm that never comes back (a wrong password, a TopstepX key with // no username) would keep a stop-after-all scheduler up for good, trading // 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 { client } of ready) { if (client.accountList.length === 0) continue; for (const acc of client.accountList) { const cfg = getAccountConfig(acc.id).accountType; if (!cfg) continue; // no config = not our account // Account with open position = still in play if (client.positions[acc.id]) return true; // Order being placed right now = still in play (evaluation calls it ineligible) if (runtime().accountsInFlight.has(acc.id)) return true; 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) && !isAfterSessionClose()) return true; } } return false; } // ── scheduler ───────────────────────────────────────────────────────────────── interface SchedulerState { action: 'Buy' | 'Sell' | 'Auto'; symbol: string; intervalId: ReturnType | null; lastRun: Date | null; running: boolean; stopAfterAll: boolean; } // Global singleton (survives HMR in dev via module cache) const _global = globalThis as typeof globalThis & { __autoTrader?: SchedulerState }; function getState(): SchedulerState { if (!_global.__autoTrader) { _global.__autoTrader = { action: 'Buy', symbol: 'NQ', intervalId: null, lastRun: null, running: false, stopAfterAll: false }; } return _global.__autoTrader; } export function startScheduler(action: 'Buy' | 'Sell' | 'Auto', symbol: string, stopAfterAll: boolean = false) { const state = getState(); // The settings reads and writes are the part that can throw, so they go // before anything in memory changes: a failure then leaves whatever // schedule was running as it was, never one marked running with its // interval cleared and nothing scheduled. A non-numeric setting falls back // to 60s — setInterval takes NaN as 1ms. const configuredSecs = parseInt(getSetting('tick_interval_seconds') ?? '60', 10); const intervalSecs = Math.max(5, Number.isFinite(configuredSecs) ? configuredSecs : 60); // Mirror to the settings table so a restart can pick the schedule back up. // The interval itself is in-memory only; resumeSchedulerIfPersisted() recreates it. setSetting('scheduler_running', '1'); setSetting('scheduler_action', action); setSetting('scheduler_symbol', symbol); setSetting('scheduler_stop_after_all', stopAfterAll ? '1' : '0'); // Clear any existing interval if (state.intervalId !== null) { clearInterval(state.intervalId); } state.action = action; state.symbol = symbol; state.running = true; state.stopAfterAll = stopAfterAll; const tick = async () => { if (!state.running) return; state.lastRun = new Date(); const rt = runtime(); const now = Date.now(); 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). 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 { outcome, results } = await runTradeBatch(state.action, state.symbol); rt.lastTickOutcome = outcome; const filled = results.flatMap((r) => r.results).filter((r) => r.status === 'filled').length; if (filled > 0) console.log(`[scheduler] tick: ${filled} account(s) filled`); // Auto-stop if user opted in and no configured accounts can trade anymore if (state.stopAfterAll && !hasRemainingConfiguredAccounts()) { console.log('[scheduler] all configured accounts done for today — stopping'); stopScheduler(); } } catch (err) { rt.lastTickOutcome = `error: ${errorMessage(err)}`; console.error(`[scheduler] tick error: ${errorMessage(err)}`); } }; state.intervalId = setInterval(tick, intervalSecs * 1_000); console.log(`[scheduler] started — ${action} ${symbol} every ${intervalSecs}s`); } export function stopScheduler() { const state = getState(); if (state.intervalId !== null) { clearInterval(state.intervalId); state.intervalId = null; } state.running = false; // Nothing watches readiness while stopped, so the grace clocks would be // stale by the next start — that start gives every firm a fresh grace. // Reset before the settings write, which can throw. const rt = runtime(); rt.unreadySince.clear(); rt.quarantineLoggedAt.clear(); rt.holding = []; rt.quarantined = []; rt.lastTickOutcome = 'not-running'; setSetting('scheduler_running', '0'); console.log('[scheduler] stopped'); } /** * Restart the schedule that was running before the process went down. * * No sync-wait here on purpose: tick() already holds while any client isn't * ready (for up to UNREADY_GRACE_MS each), and again while any account holds * an open position. So the worst case is a few logged no-op ticks until the * clients finish syncing. */ export function resumeSchedulerIfPersisted(): void { if (getSetting('scheduler_running') !== '1') return; const action = (getSetting('scheduler_action') ?? 'Buy') as 'Buy' | 'Sell' | 'Auto'; const symbol = getSetting('scheduler_symbol') ?? 'NQ'; const stopAfterAll = getSetting('scheduler_stop_after_all') === '1'; console.log(`[scheduler] resuming persisted schedule — ${action} ${symbol}`); startScheduler(action, symbol, stopAfterAll); } export function getSchedulerStatus() { const state = getState(); return { running: state.running, action: state.action, symbol: state.symbol, lastRun: state.lastRun, intervalSeconds: parseInt(getSetting('tick_interval_seconds') ?? '60', 10), stopAfterAll: state.stopAfterAll, sessionOpenDelayMin: todaysSessionOpenDelay(), }; } /** * 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'. */ export function getSchedulerHealth(): { lastTickAt: number; lastTickOutcome: TickOutcome; lastFireAt: number; holding: string[]; quarantined: string[]; tradeInFlightSince: number; } { const running = getState().running; const rt = runtime(); return { lastTickAt: rt.lastTickAt, lastTickOutcome: running ? rt.lastTickOutcome : 'not-running', lastFireAt: rt.lastFireAt, holding: running ? [...rt.holding] : [], quarantined: running ? [...rt.quarantined] : [], tradeInFlightSince: rt.tradeInFlightSince, }; }