/** * 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 { getClients } from './clients'; import { getCachedFirm, getTradableFirms } from './firm-catalog'; import { ensureRulesEvidence, getAccountConfig } from './account-type-resolver'; import { combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT, obfuscationStopDollars, takeProfitPlan, onTick, percentFee, dllRoom, dllStopDollars, dllViable, stopTicks, expectedSwapToWin, planLegSize, maxLossRoom, breachDistance, isNoTradeTime, isPastSessionClose, BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY, FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP, CME_EVENING_OPEN_MIN, foldReason, withinFirmCaps, fundRowsKey, markFinishedCycle, unbookedPayoutDrop, machineSide, stageInputsFor, floorRoom, type DayPlan, type FinishedCycleMark, } from './trading-logic'; import { getSetting, setSetting, loadAccountMeta, saveAccountMeta } from './db'; import type { LaneTick, TickReason } from './master-protocol'; import { isNotionalPlatform } from './master-protocol'; import { POINT_VALUES, housekeepingRoot, HOUSEKEEPING_ROOT, MAX_HOLD_HOURS, futuresContracts } from './instrument-specs'; import { firmInstruments, drawFuturesInstrument, spreadBlock, type LegShape } from './instruments'; import { recordEntry } from './trade-mix'; import { sizingHoldHours } from './person-size'; import { errorMessage, isClientReady, isOrderNotPlaced, type PlatformClient, type PlatformContract, type PickContext } 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, how today's last entry ended: 'filled' (confirmed), or * 'unknown' (an ack or fill that never came back — it may have filled, * with no exit placed). An entry that never reached the broker leaves * nothing here. */ entryOutcomes?: 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; /** Lane → when the batch holding that lane's trade mutex took it (absent * = no batch in flight there). See laneOf. */ tradeInFlight?: Map; /** The side a lane's batch (or manual leg) chose with nothing held on the * machine, while it places it: another lane deciding meanwhile takes the * same side (see sideForEntry). Only counts while its lease holds. */ sideReservation?: { side: 'Buy' | 'Sell'; lane: string; lease: number }; /** Account id → when its last payout claim failed (runClaims backoff). */ claimFailedAt?: Map; /** Accounts whose exit the broker refused — a bracket while a hard daily * loss limit was on, or a lone take-profit: 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; /** The last tick per lane as the master sees it: what happened and why * accounts sat it out (getSchedulerStatus). */ laneTicks?: Map; 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(), 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; } /** * Why an obfuscation stop has booked the day, or null when it hasn't. 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. The dashboard asks too: a booked day has * no next leg, so no next stop to show. */ export function obfuscationDayBooked( cfg: { firstDayStop: boolean; minDayStop: boolean }, accountId: number, tradeDate: string, realizedPnL: number, ): string | null { if (!cfg.firstDayStop && !cfg.minDayStop) return null; if (realizedPnL <= -25) return 'red day booked — make-back rides tomorrow'; if (legsToday(accountId, tradeDate) > 0 && realizedPnL < legSumToday(accountId, tradeDate) - 25) { return 'stopped out — day booked'; } return null; } interface EntryOutcome { tradeDate: string; state: 'filled' | 'unknown'; /** When the entry went out, and the realized P&L just before it. */ at: number; realizedBefore: number; legAmount: number; /** When its outcome was declared unknown (the send's error). */ unknownAt?: number; } /** An unknown entry counts as never filled only once a good sync that * STARTED at least this long after it was declared unknown shows the account * flat, with realized P&L unchanged and no order still working — a market * order that could fill has done so by then. */ const UNKNOWN_ENTRY_SETTLE_MS = 90_000; function entryOutcomeToday(accountId: number, tradeDate: string): EntryOutcome | null { const outcome = runtime().entryOutcomes?.get(accountId); return outcome && outcome.tradeDate === tradeDate ? outcome : null; } /** * An entry whose outcome is unknown holds the account — it may be in a * position no exit protects, and a retry would stack another on it — until * broker state shows it never filled; then the leg is taken back and the * account is free again. True while it still holds. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function unknownEntryHolds(client: any, accountId: number, tradeDate: string): boolean { const outcome = entryOutcomeToday(accountId, tradeDate); if (!outcome || outcome.state !== 'unknown') return false; const cash = client.accountCashBalances[accountId]; // Nothing is judged before a good sync that started after the window: // until then a position read can predate the fill (or have failed) while // the trades read after it already show the P&L it booked. const syncedFrom = client.lastSyncStartedAt ?? client.lastSyncAt; if (syncedFrom < (outcome.unknownAt ?? outcome.at) + UNKNOWN_ENTRY_SETTLE_MS || cash === undefined) return true; if (client.hasWorkingOrders?.(accountId)) return true; // The broker shows the fill (the position, or the P&L it booked): it // filled after all. From here it's an ordinary filled entry — the // position gate holds it while open, one-and-done is done for the day. if (client.positions[accountId] || Math.abs(cash.realizedPnL - outcome.realizedBefore) >= 0.005) { outcome.state = 'filled'; console.log(`[auto-trade] account ${accountId}: the entry of unknown outcome filled after all`); return false; } runtime().entryOutcomes?.delete(accountId); unrecordLeg(accountId, tradeDate, outcome.legAmount); console.log(`[auto-trade] account ${accountId}: the entry of unknown outcome never filled — free to trade again`); return false; } 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, ) { const outcomes = (runtime().entryOutcomes ??= new Map()); const at = Date.now(); const realizedBefore = client.accountCashBalances[acc.id]?.realizedPnL ?? 0; try { const fill = await client.sendOrder(acc.id, contractName, contracts, action, 'Market'); outcomes.set(acc.id, { tradeDate, state: 'filled', at, realizedBefore, legAmount }); 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 { outcomes.set(acc.id, { tradeDate, state: 'unknown', at, realizedBefore, legAmount, unknownAt: Date.now() }); console.warn(`${tag} ${acc.name}: entry outcome unknown — leg kept and the account held until the broker shows it`); } 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 } // ── 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 or Vest firm is a lane of * its own. */ export function laneOf(firm: { id: string; platform: string }): string { return isNotionalPlatform(firm.platform) ? 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 * 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 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. 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 * 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, lane: string): number | null { const inFlight = (runtime().tradeInFlight ??= new Map()); const now = Date.now(); const since = inFlight.get(lane) ?? 0; if (since > 0) { const heldMs = now - since; if (heldMs < TRADE_WEDGED_MS) { console.log(`${tag} another ${laneName(lane)} batch in flight for ${Math.round(heldMs / 1000)}s — skipping`); return null; } console.warn(`${tag} ${laneName(lane)} batch in flight for ${Math.round(heldMs / 60_000)}m looks wedged — proceeding without it`); } // 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, 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, lane: string): void { if (holdsTradeMutex(lease, lane)) runtime().tradeInFlight?.delete(lane); const r = runtime().sideReservation; if (r && r.lane === lane && r.lease === lease) runtime().sideReservation = undefined; } /** * The side a new entry in `lane` must take (machineSide): the side of every * position on this machine in any lane, else the side another lane's batch is * placing right now. One person's machine must never be long in one firm and * short in another, whatever the instruments. */ function sideForEntry(lane: string): 'Buy' | 'Sell' | 'mixed' | null { const net = firmClients().ready.flatMap(({ client }) => client.accountList .map((a) => (client.positions[a.id]?.netPos ?? 0) as number) .filter((n) => n !== 0)); const r = runtime().sideReservation; const reserved = r && r.lane !== lane && holdsTradeMutex(r.lease, r.lane) ? r.side : null; return machineSide(net, reserved); } /** Why `action` can't be entered in `lane` without a hedge, or null. */ function hedgeBlock(side: ReturnType, action: 'Buy' | 'Sell' | 'Auto'): string | null { if (side === 'mixed') return 'this machine holds long and short positions — no side to join until one of them closes'; if (side && action !== 'Auto' && action !== side) { return `this machine is ${side === 'Buy' ? 'long' : 'short'} elsewhere — a ${action} would hedge it`; } return null; } /** What a firm's tiny legs (drain, extra-day) trade: a futures firm the * micro of what it lists (MNQ when NQ is listed, as always — see * housekeepingRoot); a DXtrade or Vest firm its current pick. */ function housekeepingFor(firm: { platform: string; instruments?: string[]; bannedSymbols?: string[]; symbolMap?: Record }): string { if (isNotionalPlatform(firm.platform)) return '*'; return housekeepingRoot(firmInstruments(firm)) ?? HOUSEKEEPING_ROOT; } /** The instrument a leg actually trades: drain legs go on the firm's * housekeeping micro — tiny targets belong on a micro whatever the batch * symbol. */ async function legInstrument( // eslint-disable-next-line @typescript-eslint/no-explicit-any client: any, firm: TradableFirm, drain: boolean, batchContract: LegContract, batchPointValue: number, ): Promise<{ contract: LegContract; pointValue: number }> { if (!drain) return { contract: batchContract, pointValue: batchPointValue }; const root = housekeepingFor(firm); const micro: LegContract | null = await client.findFrontMonthContract(root); if (!micro) throw new Error(`${root} contract not found for drain trade`); const pointValue = micro.pointValue ?? POINT_VALUES[root]; if (!pointValue) throw new Error(`no point value for ${root}`); return { contract: micro, pointValue }; } /** Which listed root a held contract is — each candidate looked up on the * client holding it. null when none matches. */ async function heldRoot( // eslint-disable-next-line @typescript-eslint/no-explicit-any client: any, contractId: number | string, roots: readonly string[], ): Promise { for (const root of roots) { try { const contract = await client.findFrontMonthContract(root); if (contract && String(contract.id) === String(contractId)) return root; } catch (err) { console.error(`[auto-trade] ${root}: contract lookup failed — ${errorMessage(err)}`); } } return null; } /** A leg's shape for the draw's hold estimate: its dollars, its stop and * the account type's max contracts. */ function legShapeFor(item: SizingItem, tradeDate: string): LegShape { const cfg = getAccountConfig(item.acc.id).accountType!; const leg = legAmountFor(item, tradeDate); const obf = obfuscationStopDollars(cfg, item.dailyPnL, item.plan, tradeDate, leg); const dll = item.dll; const hard = Number.isFinite(item.dllRoom) && dll > 0 ? dllStopDollars({ room: item.dllRoom, dailyLossLimit: dll, entryFee: FALLBACK_COMMISSION_PER_CONTRACT, commissionPct: 0 }) : Infinity; return { leg, stopDollars: Math.min(Number.isFinite(obf) && obf > 0 ? obf : Infinity, hard), maxPositionSize: cfg.maxPositionSize, limits: item.firm.instrumentLimits, }; } /** 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. Null when there is no usable reading — no update * yet, or that. */ 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 null; } // ── 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"; /** 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; 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; /** What the firm's day may still lose before a hard daily-loss breach * (Infinity = no limit). */ dllRoom: number; /** The hard daily loss limit at the account's current stage (0 = none). */ dll: number; } /** * The hard daily-loss check: the room left today, or why there is none to * trade with. Room is Infinity when the account type has no limit. */ function dailyLossGate( // eslint-disable-next-line @typescript-eslint/no-explicit-any client: any, accountId: number, /** The limit at the account's current stage (0 = none). */ dll: number, cfg: { maxPositionSize: number; firmId: string }, cash: { amount: number; realizedPnL: number }, combinedRealized: number, remainingToday: number, singleContract: boolean, ): { room: number } | { reason: string } { if (!(dll > 0)) return { room: Infinity }; // A platform that states its own daily-loss floor (Vest) decides the // room against it, transfers and all; the rest measure the day's P&L. const floor: number | null | undefined = client.dailyLossFloor?.(accountId); if (floor === null) return { reason: DLL_BASE_PENDING }; let room: number; if (typeof floor === 'number') { room = floorRoom({ dailyLossLimit: dll, closedBalance: cash.amount, floor, realizedToday: Math.min(cash.realizedPnL, combinedRealized), }); } else { const dayStart: number | null | undefined = client.dayStartBalance?.(accountId); if (dayStart === null) return { reason: DLL_BASE_PENDING }; 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, combinedRealized), }); } // 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 = singleContract ? 1 : Math.min(Math.max(1, Math.ceil(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 { reason: `daily loss limit: $${Math.round(room * 100) / 100} of $${dll} left — day booked` }; } return { room }; } const FINISHED_CYCLE_META = 'finished_cycle'; /** Accounts held by finishedCycleDrop and already logged — one line per hold. */ const payoutHoldsLogged = new Set(); /** * Marks the books while the account's cycle is finished (day target 0, not * an extra day); once a plan wants to trade again, the dollars day-start * equity has fallen below that mark with no fund row booked since — null * when it hasn't (see unbookedPayoutDrop). Kept in account_meta, so a * restart between the finish and the payout still holds; a wipe clears it. * * 2026-10-06: two Topstep Express accounts sat finished, waiting for their * payouts. When the payouts landed, the plan read them as lost profit and * fired a full 3×NQ stage-1 leg 28 seconds later, before the withdrawal was * booked — on the day that should have been a drain day. Both were * liquidated against the $0 floor the payout left. */ function finishedCycleDrop( acc: { id: number; name: string }, plan: DayPlan, isExtraDay: boolean, funds: string, dayStartEquity: number, bookedPnL: number, ): number | null { if (!isExtraDay && plan.dayTarget <= 0) { const mark = JSON.stringify(markFinishedCycle(funds, dayStartEquity, bookedPnL)); if (loadAccountMeta(acc.id, FINISHED_CYCLE_META) !== mark) saveAccountMeta(acc.id, FINISHED_CYCLE_META, mark); payoutHoldsLogged.delete(acc.id); return null; } let mark: FinishedCycleMark | null = null; try { mark = JSON.parse(loadAccountMeta(acc.id, FINISHED_CYCLE_META) ?? 'null') as FinishedCycleMark | null; } catch { mark = null; } const drop = unbookedPayoutDrop(mark, funds, dayStartEquity, bookedPnL); if (drop === null) { payoutHoldsLogged.delete(acc.id); } else if (!payoutHoldsLogged.has(acc.id)) { payoutHoldsLogged.add(acc.id); console.warn(`[auto-trade] ${acc.name}: balance fell $${drop} below its books after its cycle finished, with no fund transaction booked — held until the payout is booked`); } return drop; } /** * 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 { // Which rule set the account trades on hangs on its rules date: bring it // down to the broker's evidence before reading the rules, so an account // whose history landed since the last sync never trades a leg on the // date this machine first saw it. ensureRulesEvidence(client, acc.id); 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, dllRoom: Infinity, dll: 0 }; 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 }; // A balance the books can't explain yet — a payout that platform books // only later — would plan as lost profit to re-earn. const unbooked: number | null = client.unbookedBalanceChange?.(acc.id) ?? null; if (unbooked !== null) { return { ...base, eligible: false, reason: `balance $${unbooked} off its books — waiting for a sync to book it` }; } 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 refused — flattened, benched for the day' }; } const booked = obfuscationDayBooked(cfg, acc.id, tradeDate, combined.realizedPnL); if (booked) return { ...base, eligible: false, reason: 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, stageInputsFor(client, acc.id, cfg), ); 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, dllRoom: Infinity, dll: effective.dailyLossLimit }; // A finished cycle that a balance drop reopens, with no fill or fund row // to explain it, is a payout not booked yet: hold it until the booking // moves the account to its next stage (a drain day, say). const bookedPnL = priorProfit + dailyPnL .filter((d) => d.date !== combined.tradeDate) .reduce((sum, d) => sum + d.pnl, 0); const payoutDrop = finishedCycleDrop( acc, plan, isExtraDay, fundRowsKey(allFundTxns), equityProfit - combined.realizedPnL, bookedPnL, ); if (payoutDrop !== null) { return { ...evaluated, eligible: false, reason: `balance $${payoutDrop} below its books since the cycle finished — waiting for the payout to be booked` }; } 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). One of unknown outcome is held above until settled. if (entryOutcomeToday(acc.id, tradeDate)?.state === 'filled') { 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)` }; } } // 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 gate = dailyLossGate(client, acc.id, effective.dailyLossLimit, cfg, cash, combined.realizedPnL, plan.remainingToday, isExtraDay || effective.drain); if ('reason' in gate) return { ...evaluated, eligible: false, reason: gate.reason }; if (Number.isFinite(gate.room)) return { ...evaluated, eligible: true, reason: '', dllRoom: gate.room }; return { ...evaluated, eligible: true, reason: '' }; } // ── helpers ─────────────────────────────────────────────────────────────────── /** The longest random delay added to the session open, in minutes. */ const MAX_OPEN_DELAY_MIN = 180; // Random 0–180 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, // and saved as a setting so a restart during the wait (an update, a crash) // keeps the day's delay instead of rolling a new one. function openJitterMinutes(centralDay: string): number { const sessionOpenJitter = runtime().sessionOpenJitter; let jitter = sessionOpenJitter.get(centralDay); if (jitter === undefined) { const [savedDay, savedMin] = (getSetting('session_open_delay') ?? '').split('|'); const saved = savedDay === centralDay ? Number(savedMin) : NaN; if (Number.isInteger(saved) && saved >= 0 && saved <= MAX_OPEN_DELAY_MIN) { jitter = saved; } else { jitter = Math.floor(Math.random() * (MAX_OPEN_DELAY_MIN + 1)); // 0–180 inclusive setSetting('session_open_delay', `${centralDay}|${jitter}`); console.log(`[trading-hours] session open delayed ${jitter} min today`); } sessionOpenJitter.clear(); // only today's entry is ever needed sessionOpenJitter.set(centralDay, jitter); } 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); } /** 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); } /** * 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 0–180 minute delay (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(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 ────────────────────────────────────────────────────────── /** 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; /** One batch in every lane — a master "trade" command, POST /api/trade. */ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string): Promise { const results: FirmTradeResults[] = []; for (const lane of tradableLanes()) { results.push(...(await runTradeBatch(action, symbol, lane)).results); } return results; } /** What a lane's batch decided: its outcome, what it traded, and for the * master a line on what happened and why accounts sat it out. */ type BatchOutcome = { outcome: TradeOutcome; results: FirmTradeResults[]; detail?: string; reasons?: TickReason[] }; /** Accounts that sat a batch out, by reason. Dollar amounts are folded so * like reasons group ("day done ($12 left of $300)" and "($40 left of $500)"). */ type ReasonTally = Map; function tally(reasons: ReasonTally, reason: string, who: string): void { const key = foldReason(reason); const entry = reasons.get(key) ?? { count: 0, accounts: [] }; entry.count++; if (entry.accounts.length < 3) entry.accounts.push(who); reasons.set(key, entry); } function reasonList(reasons: ReasonTally): TickReason[] { return [...reasons.entries()] .map(([reason, { count, accounts }]) => ({ reason, count, accounts })) .sort((a, b) => b.count - a.count) .slice(0, 8); } const fmtCentralMinutes = (m: number): string => { const h = Math.floor(m / 60) % 24; return `${h % 12 || 12}:${String(m % 60).padStart(2, '0')} ${h < 12 ? 'AM' : 'PM'} CT`; }; /** Why a lane is closed, for the master: when today's session opens. */ function closedDetail(lane: string): string { if (lane !== FUTURES_LANE) return 'standing down around the daily roll'; const delay = todaysSessionOpenDelay(); const open = getSetting('trading_hours') === 'equity_hours' ? 8 * 60 + 35 + delay : CME_EVENING_OPEN_MIN + delay; return `outside trading hours — today's open is ${fmtCentralMinutes(open)}`; } /** 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 { const lease = acquireTradeMutex('[auto-trade]', lane); if (lease === null) return { outcome: 'skipped-in-flight', results: [], detail: 'another batch is still in flight' }; try { return await runTradeLocked(action, symbol, lease, lane); } finally { 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; /** The hard daily loss limit at the account's current stage (0 = none). */ dll: number; } /** What sizing a leg needs before its instrument is known. */ type SizingItem = Omit; async function runTradeLocked( action: 'Buy' | 'Sell' | 'Auto', symbol: string, lease: number, lane: string, ): Promise { 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: [], detail: closedDetail(lane) }; } // 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] ${laneName(lane)}: ${openPositions} position(s) still open — skipping`); return { outcome: 'positions-open', results: [], detail: `${openPositions} position(s) still open` }; } const open = laneReady.filter(({ firm }) => !isInNoTradeWindow(firm)); // One direction per batch, and per machine: every account trades the side // of whatever this machine already holds in any lane (another firm, a hand // trade), so nothing the batch opens hedges the person against themselves. // Free only when nothing is held or being placed; the choice is then held // while the batch places it, so a lane deciding meanwhile follows it. const side = sideForEntry(lane); const blocked = hedgeBlock(side, action); if (blocked) { console.log(`[auto-trade] ${laneName(lane)}: ${blocked} — skipping`); return { outcome: 'positions-open', results: [], detail: blocked }; } const resolvedAction: 'Buy' | 'Sell' = side === 'Buy' || side === 'Sell' ? side : action === 'Auto' ? (Math.random() < 0.5 ? 'Buy' : 'Sell') : action; if (!side) runtime().sideReservation = { side: resolvedAction, lane, lease }; const futuresLane = lane === FUTURES_LANE; // An explicit symbol (the scheduler's, or a manual Trade) is the // operator's pick; 'Auto' is drawn per batch from the batch firm's list. const explicit = futuresLane && symbol !== 'Auto' ? symbol : null; const tradeDate = currentTradeDateCT(); const maxConcurrent = maxConcurrentAccounts(); // ── Phase 1: the eligible accounts across the lane's firms ── // No instrument yet: it's chosen for the batch's firm, from that firm's // list, once the batch is known. type Candidate = SizingItem & { daysTraded: number; isExtraDay: boolean }; type EligibleItem = LegItem & { daysTraded: number; isExtraDay: boolean }; const allEligible: Candidate[] = []; // Why accounts sat this batch out (accounts with no type aren't ours), // and what stopped the batch itself — for the master. const reasons: ReasonTally = new Map(); const notes = new Set(); for (const { firm, client } of open) { if (client.accountList.length === 0) continue; const listed = firmInstruments(firm); if (listed.length === 0) { console.log(`[auto-trade] ${firm.name}: no instruments listed — skipping firm`); tally(reasons, 'its firm lists no instruments', firm.name); continue; } if (explicit && !listed.includes(explicit)) { console.log(`[auto-trade] ${firm.name}: ${explicit} isn't on its instrument list — skipping firm`); tally(reasons, `${explicit} isn't on its firm's list`, firm.name); continue; } // One firm's trouble (a malformed snapshot) skips that firm, never // the batch. try { for (const acc of client.accountList) { const ev = evaluateAccountForTrade(client, acc, tradeDate); if (!ev) continue; if (!ev.eligible || !ev.plan) { tally(reasons, ev.reason || 'no day plan', acc.name); continue; } allEligible.push({ firmName: firm.name, firm, client, acc, 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, dll: ev.dll, }); } } catch (err) { console.error(`[auto-trade] ${firm.name}: skipped — ${errorMessage(err)}`); tally(reasons, `firm skipped — ${errorMessage(err)}`, firm.name); } } if (allEligible.length === 0) { console.log(`[auto-trade] ${laneName(lane)}: no eligible accounts found`); return { outcome: 'no-eligible', results: [], detail: 'no eligible accounts', reasons: reasonList(reasons) }; } // 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. A firm's own cap on accounts at once (the // master's Firms page) trims its share first: 6 Topstep accounts capped // at 3 fire as 3, 3. A group that can't trade anything right now // (nothing listed fits, no contract resolves) yields to the next. 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 ordered = [...groups.entries()].sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0])); let batch: EligibleItem[] = []; let batchPrefix = ''; let resolvedSymbol = ''; for (const [prefix, members] of ordered) { // The lane is flat (see above), so no firm holds anything yet. const candidates = withinFirmCaps(members).slice(0, maxConcurrent); const lead = candidates.find((it) => !it.isExtraDay && !it.drain); const firms = [...new Map(candidates.map((it) => [it.firm.id, it])).values()]; if (futuresLane) { // The group's firms must all list the root: their lists' overlap, // or — if that's empty — the lead firm's accounts alone. let pool = candidates; let allowed = firms.map((it) => firmInstruments(it.firm)).reduce((a, b) => a.filter((r) => b.includes(r))); if (allowed.length === 0) { const leadFirm = (lead ?? candidates[0]).firm.id; pool = candidates.filter((it) => it.firm.id === leadFirm); allowed = firmInstruments(pool[0].firm); } const shape = lead ? legShapeFor(lead, tradeDate) : null; const tried: string[] = []; for (let attempt = 0; attempt < 3 && batch.length === 0; attempt++) { const root = explicit ?? await drawFuturesInstrument(allowed.filter((r) => !tried.includes(r)), shape); if (!root) { notes.add(`nothing on ${prefix || 'its'} list fits right now (spread or ${MAX_HOLD_HOURS}h hold cap)`); break; } tried.push(root); // Paying more than the master's limit to cross the book: // not this tick (the draw left it out already; an explicit // symbol waits for the spread to come back in). const wide = spreadBlock(root); if (wide) { console.log(`[auto-trade] ${wide} — not trading it this tick`); notes.add(wide); if (explicit) break; continue; } const resolved: EligibleItem[] = []; for (const { firm, client } of [...new Map(pool.map((it) => [it.firm.id, it])).values()]) { try { const contract = await client.findFrontMonthContract(root); const pv = contract?.pointValue ?? POINT_VALUES[root]; if (!contract || !pv) { console.log(`[auto-trade] ${firm.name}: no ${root} contract — leaving its accounts out`); notes.add(`${firm.name}: no ${root} contract`); continue; } resolved.push(...pool.filter((it) => it.firm.id === firm.id).map((it) => ({ ...it, contract, pv }))); } catch (err) { console.error(`[auto-trade] ${firm.name}: ${root} contract lookup failed — ${errorMessage(err)}`); notes.add(`${firm.name}: ${root} contract lookup failed`); } } if (resolved.length > 0) { batch = resolved; resolvedSymbol = root; } if (explicit) break; } } else { // A DXtrade lane: one firm, its list ranked for the group's leg. const { client, firm } = candidates[0]; try { const contract = await client.findFrontMonthContract('*', pickContextFor(candidates.filter((it) => !it.isExtraDay && !it.drain), tradeDate)); const pv = contract?.pointValue; if (contract && pv) { batch = candidates.map((it) => ({ ...it, contract, pv })); resolvedSymbol = String(contract.id); } else { console.log(`[auto-trade] ${firm.name}: nothing on its list can trade right now`); notes.add(`${firm.name}: nothing on its list can trade right now`); } } catch (err) { console.error(`[auto-trade] ${firm.name}: instrument pick failed — ${errorMessage(err)}`); notes.add(`${firm.name}: instrument pick failed — ${errorMessage(err)}`); } } if (batch.length > 0) { batchPrefix = prefix; break; } } if (batch.length === 0) { console.log(`[auto-trade] ${laneName(lane)}: nothing tradable for any eligible group`); return { outcome: 'no-eligible', results: [], detail: `${allEligible.length} eligible, nothing tradable${notes.size ? ` — ${[...notes].join('; ')}` : ''}`, reasons: reasonList(reasons), }; } // Choosing 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, lane)) { console.warn('[auto-trade] superseded while collecting accounts — abandoning this batch'); return { outcome: 'skipped-in-flight', results: [], detail: 'superseded by another batch while choosing' }; } 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)'} on ${resolvedSymbol}`); 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 } = item; // Extra-day mode: profit target already met, no min day P&L, days still needed. // Just trade 1 micro (MNQ for a firm listing NQ) in and out at market // immediately — P&L doesn't matter. if (item.isExtraDay) { const microRoot = housekeepingFor(item.firm); const wide = spreadBlock(microRoot); if (wide) throw new Error(`${wide} — no extra-day trade`); const mnqContract = await client.findFrontMonthContract(microRoot); if (!mnqContract) throw new Error(`${microRoot} contract not found for extra-day trade`); const fill = await client.sendOrder(acc.id, mnqContract.name, 1, resolvedAction, 'Market'); recordEntry(item.firmName, microRoot, resolvedAction, 1, mnqContract.id); 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} 1x${mnqContract.name} @ ${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, }; } return fireLeg('[auto-trade]', item, resolvedAction, resolvedSymbol, tradeDate); }))); // 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) } ); } const all = [...firmResultsMap.values()].flat(); const filledCount = all.filter((r) => r.status === 'filled').length; const failed = all.filter((r) => r.status === 'error'); // Accounts left for a later batch aren't benched: the next tick fires them. for (const item of allEligible) { if (!batch.some((b) => b.acc.id === item.acc.id)) tally(reasons, 'eligible — waits for a later batch', item.acc.name); } return { outcome: 'fired', results: Array.from(firmResultsMap.entries()).map(([firm, results]) => ({ firm, results })), detail: `fired ${batch.length} × ${batchPrefix || '(no prefix)'} on ${resolvedSymbol}: ${filledCount} filled` + (failed.length ? `, ${failed.length} failed (${String(failed[0].reason ?? '').slice(0, 120)})` : ''), reasons: reasonList(reasons), }; } 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}`); } /** * The take-profit of a leg with no stop. The position is already open, so a * refusal must not leave it riding with no exit at all — on 2026-10-02 two GC * shorts did, far past their target. Refused: flatten and bench the account * for the day, like a refused bracket. Outcome unknown: it may be working, and * a second exit on top could flip the account, so nothing more is placed. */ async function placeLoneExit( 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', exitPrice: number, entryPrice: number, tradeDate: string, ): Promise<{ orderId?: number }> { try { return await client.placeOrderNoWait(acc.id, contractName, contracts, exitAction, 'Limit', exitPrice); } catch (err) { if (!isOrderNotPlaced(err)) { console.error(`${tag} ${acc.name}: take-profit outcome UNKNOWN (${errorMessage(err)}) — NOT placing a second exit; check this position's exits at the broker`); throw new Error(`entry filled @ ${entryPrice} but the take-profit's outcome is unknown — ${errorMessage(err)}`); } (runtime().exitRefused ??= new Map()).set(acc.id, tradeDate); console.error(`${tag} ${acc.name}: take-profit @ ${exitPrice} REJECTED (${errorMessage(err)}) — flattening`); return flattenAndFail(tag, client, acc, contractName, contracts, exitAction, entryPrice, `take-profit @ ${exitPrice} refused (${errorMessage(err)})`); } } /** * The leg an account trades next. 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. */ function legAmountFor(item: SizingItem, tradeDate: string): number { const cfg = getAccountConfig(item.acc.id).accountType!; const minLegs = item.plan.dayTarget > BIG_DAY_THRESHOLD ? (cfg.minLegs ?? 0) : 0; return item.plan.splitAllowed ? pickLegAmount( item.plan.remainingToday, () => groupLegFraction(item.prefix, tradeDate, legsToday(item.acc.id, tradeDate)), minLegs - legsToday(item.acc.id, tradeDate), ) : item.plan.remainingToday; } /** How this account's leg would size on `contract` (see planLegSize), with * the account's live funds, its max-drawdown floor and its stops. */ function sizeLeg(item: SizingItem, contract: LegContract, legAmount: number, tradeDate: string) { const cfg = getAccountConfig(item.acc.id).accountType!; const live = item.client.accountCashBalances[item.acc.id] ?? item.cash; const pct = contract.commissionPct ?? item.firm.commissionPct ?? 0; const floorRoom = maxLossRoom({ balance: live.amount, accountSize: cfg.accountSize, maxLoss: cfg.maxLoss ?? 0, autoLiqThreshold: item.client.autoLiqThresholds?.[item.acc.id], }); const obfuscation = obfuscationStopDollars(cfg, item.dailyPnL, item.plan, tradeDate, legAmount); const plan = planLegSize({ leg: legAmount, contract, commissionPct: pct, targetHoldHours: sizingHoldHours(item.firm, tradeDate), financingPctPerDay: item.firm.financingPctPerDay ?? 0, dailyLossLimit: item.dll, dllRoom: item.dllRoom, maxLossRoom: floorRoom, obfuscationDollars: obfuscation, availableFunds: live.availableFunds ?? live.amount, }); return { ...plan, pct, obfuscation, maxLossRoom: floorRoom }; } /** * The batch's leg, for a platform that chooses between instruments: the * first account of the firm's largest prefix group — the group the batch * will fire — sized on each candidate. */ function pickContextFor(items: SizingItem[], tradeDate: string): PickContext | undefined { const groups = new Map(); for (const it of items) groups.set(it.prefix, [...(groups.get(it.prefix) ?? []), it]); const lead = [...groups.values()].sort((a, b) => b.length - a.length)[0]?.[0]; if (!lead) return undefined; const legAmount = legAmountFor(lead, tradeDate); return { targetHoldHours: sizingHoldHours(lead.firm, tradeDate), evaluate: (contract) => { const { sizing, cost } = sizeLeg(lead, contract, legAmount, tradeDate); return sizing && cost !== null ? { notional: sizing.notional, holdHours: sizing.predictedHoldHours, cost } : null; }, }; } /** * 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 — whatever makes the * leg's expected hold the firm's target as this person sizes it today * (sizingHoldHours), from the instrument's recent volatility — under the * account type's notional cap and the instrument's margin rate (the firm's * leverage). * * 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. A firm that * charges overnight swap has the swap a winning leg is expected to pay added * to its take-profit. * * 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 } = item; const cfg = getAccountConfig(acc.id).accountType!; const legAmount = legAmountFor(item, tradeDate); // 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 = item.dll; const dllOn = Number.isFinite(item.dllRoom) && dll > 0; // 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 plan: ReturnType; try { const inst = await legInstrument(client, item.firm, item.drain, item.contract, item.pv); legContract = inst.contract; pv = inst.pointValue; // A futures entry never pays more than the master's limit to cross // the book — the last word for every path (batch, copy, manual, drain). if (!((legContract.stepNotional ?? 0) > 0)) { const wide = spreadBlock(item.drain ? housekeepingFor(item.firm) : symbolLabel); if (wide) throw new Error(`${wide} — not entering`); } plan = sizeLeg(item, legContract, legAmount, tradeDate); pct = plan.pct; const stepNotional = legContract.stepNotional ?? 0; if (stepNotional > 0) { if (!plan.sizing) { throw new Error(`no position size for ${legContract.name} — its recent volatility is unknown`); } contracts = Math.max(1, Math.round(plan.sizing.notional / stepNotional)); if (plan.cap > 0) contracts = Math.min(contracts, Math.max(1, Math.floor(plan.cap / 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 && !plan.lastStand) { const estimate = plan.walkAt(contracts * stepNotional); 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 { // One per $1,000, capped by the account type's max contracts and // the firm's limit on this instrument. const legRoot = item.drain ? housekeepingFor(item.firm) : symbolLabel; contracts = futuresContracts(legAmount, cfg.maxPositionSize, item.firm.instrumentLimits?.[legRoot] ?? 0); } } catch (err) { unrecordLeg(acc.id, tradeDate, legAmount); throw err; } const { sizing, obfuscation } = plan; const fill = await sendEntry(tag, client, acc, legContract.name, contracts, action, tradeDate, legAmount); // For the master's trade mix: a futures leg is its root (the drain // micro on a drain day), a DXtrade one its symbol. const dx = (legContract.stepNotional ?? 0) > 0; recordEntry( item.firmName, dx ? String(legContract.name).split('|')[0] : (item.drain ? housekeepingFor(item.firm) : symbolLabel), action, dx ? contracts * (Number(String(legContract.name).split('|')[1]) || 1) : contracts, (legContract as { id?: unknown }).id, ); 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; } // 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. A // last stand carries no daily-loss stop: a stop-out would leave the // account nothing to trade on, so the firm's own breach is the far side. const dllFee = pct > 0 ? entryFee : (measured ?? MAX_COMMISSION_PER_CONTRACT * contracts); const notional = fill.price * pv * contracts; const dllDollars = dllOn && !plan.lastStand ? dllStopDollars({ room: item.dllRoom, dailyLossLimit: dll, entryFee: dllFee, commissionPct: pct, notional }) : 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`); } // The take-profit clears the leg, both fees and — where the firm charges // overnight swap — what a winning leg is expected to pay in it. const orderStop = ticks !== null ? ticks * legContract.tickSize * pv * contracts : Infinity; const swapReserve = expectedSwapToWin({ distance: legAmount + 2 * entryFee, stopDollars: plan.lastStand ? Math.min(orderStop, breachDistance({ dllRoom: item.dllRoom, maxLossRoom: plan.maxLossRoom, entryFee })) : orderStop, notional, volatilityPerHour: legContract.volatilityPerHour, financingPctPerDay: item.firm.financingPctPerDay ?? 0, }); const tp = takeProfitPlan({ side: action, entry: fill.price, leg: legAmount + swapReserve, pointValue: pv, contracts, tickSize: legContract.tickSize, commissionPct: pct, entryCommission: entryFee, }); let exitOrder: { orderId?: number }; let stopPrice: number | null = null; if (ticks !== null) { // On the grid toward the entry: the stop's distance is a hard limit. stopPrice = action === 'Buy' ? onTick(fill.price - (ticks * legContract.tickSize), legContract.tickSize, 'up') : onTick(fill.price + (ticks * legContract.tickSize), legContract.tickSize, 'down'); 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 placeLoneExit(tag, client, acc, legContract.name, contracts, exitAction, tp.exitPrice, fill.price, tradeDate); } } else { exitOrder = await placeLoneExit(tag, client, acc, legContract.name, contracts, exitAction, tp.exitPrice, fill.price, tradeDate); } const instrumentLabel = legContract.stepNotional || item.drain ? legContract.name : symbolLabel; const sizeNote = sizing ? ` | notional $${Math.round(contracts * (legContract.stepNotional ?? 0))} (hold ~${sizing.predictedHoldHours.toFixed(1)}h` + `${sizing.winProbability !== null ? `, win ${Math.round(sizing.winProbability * 100)}%` : ''})` : ''; const stopNote = (stopPrice !== null ? ` / stop @ ${stopPrice}` + (dllOn && !plan.lastStand ? ` (dll room $${round2(item.dllRoom)} → $${round2(dllDollars)}${Number.isFinite(obfuscation) && obfuscation > 0 ? `, obf $${round2(obfuscation)}` : ''})` : '') : '') + (plan.lastStand ? ` | LAST STAND — no daily-loss stop: $${round2(plan.maxLossRoom ?? 0)} to the max-drawdown floor, $${round2(item.dllRoom)} of the day's` : ''); 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${swapReserve > 0 ? `, +$${swapReserve.toFixed(2)} expected swap` : ''})${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 (and no * more of a firm's than its cap on accounts at once). * Finds accounts with open positions, determines direction, then fires * 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'); 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]', lane); if (lease === null) return []; try { return await copyTradeLocked(lease, lane); } finally { releaseTradeMutex(lease, lane); } } 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 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(', ')}`); } 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/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 let positionedClient: any = null; const positionedPrefixes = new Set(); // Open positions by firm, against each firm's cap on accounts at once. const heldByFirm = new Map(); for (const { firm, client } of ready) { for (const acc of client.accountList) { const pos = client.positions[acc.id]; if (!pos) continue; positionedCount++; heldByFirm.set(firm.id, (heldByFirm.get(firm.id) ?? 0) + 1); 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; } } } const futuresLane = lane === FUTURES_LANE; 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}`); // Which instrument is held — looked up only on the client that holds it // (iterating every firm × every symbol can take 30+ seconds), among the // roots the lane's firms list plus their drain micros. A DXtrade client // answers '*' with the symbol it holds. Unidentified means no copy: a // guess could put the direction on a different instrument. let resolvedSymbol: string | null = null; if (positionedClient && positionContractId !== null) { const roots = futuresLane ? [...new Set(ready.flatMap(({ firm }) => [...firmInstruments(firm), housekeepingFor(firm)]))] : ['*']; resolvedSymbol = await heldRoot(positionedClient, positionContractId, roots); } if (!resolvedSymbol) { console.log(`[copy-trade] can't tell which instrument contract ${positionContractId} is — not copying`); return []; } console.log(`[copy-trade] resolved symbol: ${resolvedSymbol}`); const basePointValue: number | undefined = POINT_VALUES[resolvedSymbol]; 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) const eligible: LegItem[] = []; 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; } if (futuresLane && !firmInstruments(firm).includes(resolvedSymbol!)) { console.log(`[copy-trade] ${firm.name}: ${resolvedSymbol} isn't on its instrument list — 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 pv = contract.pointValue ?? basePointValue; if (!pv) { console.log(`[copy-trade] ${firm.name}: no point value for ${resolvedSymbol} — skip firm`); return; } 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; } 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, 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, dll: ev.dll, }); } 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: LegItem) => positionedPrefixes.has(item.prefix); const ordered = [...eligible.filter(matchesPositioned), ...eligible.filter((i) => !matchesPositioned(i))]; const batch = withinFirmCaps(ordered, heldByFirm).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, lane)) { 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, () => fireLeg('[copy-trade]', item, resolvedAction!, resolvedSymbol!, tradeDate), ))); 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; } // ── manual leg ─────────────────────────────────────────────────────────────── /** * Fire one leg on one account now, through the same path the scheduler uses * — sizing, take-profit, the daily-loss stop, the exit bracket — for someone * testing an account by hand (POST /api/accounts/[id]/fire-leg). * * The one rule it overrides is "one trade per day" for unsplit accounts: an * account that already booked something today may take another leg toward * what's left of its target. Everything else still applies — flat, history * loaded, the daily-loss room, the firm's trading hours, no batch in flight * in its lane — and a lane with positions open is only joined on the same * instrument and side. */ export async function fireManualLeg(accountId: number, action: 'Buy' | 'Sell' | 'Auto'): Promise { const found = firmClients().ready.find(({ client }) => client.accountList.some((a) => a.id === accountId)); if (!found) throw new Error('account not found on a synced client'); const { firm, client } = found; const acc = client.accountList.find((a) => a.id === accountId)!; if (isInNoTradeWindow(firm)) throw new Error(`${firm.name} is outside its trading hours`); // The lane's lock before anything is read, as a batch takes it: a batch // that fired between this leg's checks and its entry would leave it // trading on numbers from before — a second leg, or the other side of // the position the batch just opened. const lane = laneOf(firm); const lease = acquireTradeMutex('[manual-leg]', lane); if (lease === null) throw new Error('another batch is in flight in this lane'); try { const tradeDate = currentTradeDateCT(); const ev = evaluateAccountForTrade(client, acc, tradeDate); if (!ev || !ev.plan) throw new Error(ev?.reason || 'account has no account type'); if (!ev.eligible && !ev.reason.startsWith('already traded today')) throw new Error(ev.reason); if (ev.isExtraDay) throw new Error('extra-day accounts only take the scheduled in-and-out'); if (ev.plan.remainingToday <= LEG_COMPLETION_EPSILON) throw new Error(`day target met ($${ev.plan.remainingToday} left)`); const cfg = getAccountConfig(acc.id).accountType!; // The once-a-day return comes before the daily-loss check: run it here. let room = ev.dllRoom; if (!ev.eligible) { const gate = dailyLossGate(client, acc.id, ev.dll, cfg, ev.cash, ev.cash.realizedPnL, ev.plan.remainingToday, ev.drain); if ('reason' in gate) throw new Error(gate.reason); room = gate.room; } // A lane with positions open may only be joined on the same instrument // and side — what a batch of like accounts would have done — never the // other side (that is hedging). const held = inLane(firmClients().ready, lane).flatMap(({ client: c }) => c.accountList .map((a) => c.positions[a.id]) .filter((p): p is NonNullable => !!p)); const heldSides = new Set(held.map((p) => (p.netPos > 0 ? 'Buy' : 'Sell'))); const heldContracts = new Set(held.map((p) => String(p.contractId))); if (heldSides.size > 1 || heldContracts.size > 1) throw new Error(`the ${laneName(lane)} lane holds mixed positions — not adding to it`); const heldSide: 'Buy' | 'Sell' | null = held.length > 0 ? (held[0].netPos > 0 ? 'Buy' : 'Sell') : null; if (heldSide && action !== 'Auto' && action !== heldSide) { throw new Error(`the ${laneName(lane)} lane is ${heldSide === 'Buy' ? 'long' : 'short'} — a ${action} would hedge it`); } // …nor against what the machine holds in any other lane. const side = sideForEntry(lane); const blocked = hedgeBlock(side, heldSide ?? action); if (blocked) throw new Error(blocked); const resolvedAction: 'Buy' | 'Sell' = heldSide ?? (side === 'Buy' || side === 'Sell' ? side : action === 'Auto' ? (Math.random() < 0.5 ? 'Buy' : 'Sell') : action); if (!side) runtime().sideReservation = { side: resolvedAction, lane, lease }; const sizingItem: SizingItem = { firmName: firm.name, firm, client, acc, cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan, drain: ev.drain, prefix: cfg.prefix, dllRoom: room, dll: ev.dll, }; // The instrument: a DXtrade or Vest firm ranks its list; a futures // firm joins the held root, else trades an explicit scheduler symbol // it lists, else draws from its list. let symbol = '*'; if (!isNotionalPlatform(firm.platform)) { const listed = firmInstruments(firm); const state = getState(); const found = held.length > 0 ? await heldRoot(client, held[0].contractId, [...listed, housekeepingFor(firm)]) : null; const drawn = found ?? (state.symbol !== 'Auto' && listed.includes(state.symbol) ? state.symbol : await drawFuturesInstrument(listed, legShapeFor(sizingItem, tradeDate))); if (!drawn) throw new Error(held.length > 0 ? `can't tell which listed instrument the lane holds` : 'nothing on the firm\'s list fits right now'); symbol = drawn; } const contract = await client.findFrontMonthContract(symbol, ev.drain ? undefined : pickContextFor([sizingItem], tradeDate)); if (!contract) throw new Error(`no instrument for ${symbol}`); if (held.length > 0 && String(contract.id) !== [...heldContracts][0]) { throw new Error(`the ${laneName(lane)} lane holds ${[...heldContracts][0]}, not ${contract.id} — not adding to it`); } const pv = contract.pointValue ?? POINT_VALUES[symbol]; if (!pv) throw new Error(`no point value for ${symbol}`); // The lookups above took long enough for a batch to presume this one // wedged and take the lane: it traded on fresher numbers. if (!holdsTradeMutex(lease, lane)) throw new Error('superseded by a batch while choosing — not firing'); const item: LegItem = { ...sizingItem, contract, pv }; console.log(`[manual-leg] ${acc.name} (${firm.name}): firing one leg by hand`); runtime().lastFireAt = Date.now(); return await withAccountInFlight(acc.id, () => fireLeg('[manual-leg]', item, resolvedAction, symbol, tradeDate)); } finally { releaseTradeMutex(lease, lane); } } // ── payout claims ──────────────────────────────────────────────────────────── /** A failed claim is retried after this long. */ const CLAIM_RETRY_MS = 5 * 60_000; /** How much an account should claim now, or null: a payoutable account whose * platform takes payouts by API, flat and settled, that has reached its * stage's target — all of its profit above the starting balance. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function claimDue(client: any, acc: { id: number; active: boolean }, now: number): number | null { const cfg = getAccountConfig(acc.id).accountType; if (!cfg || cfg.payoutable !== true || !acc.active) return null; if (client.positions[acc.id] || client.hasWorkingOrders?.(acc.id) || runtime().accountsInFlight.has(acc.id)) return null; if (!client.historyReady?.(acc.id)) return null; if (now - (runtime().claimFailedAt?.get(acc.id) ?? 0) < CLAIM_RETRY_MS) return null; const cash = client.accountCashBalances[acc.id]; if (!cash) return null; const effective = resolveEffectiveConfig( cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, client.priorProfit?.[acc.id] ?? 0, client.fundTransactions?.[acc.id] ?? [], cfg.minDayPnL, stageInputsFor(client, acc.id, cfg), ); if (effective.drain || !(effective.profitTarget > 0)) return null; const profit = cash.amount - cfg.accountSize; return profit >= effective.profitTarget - LEG_COMPLETION_EPSILON ? profit : null; } /** * Claim the profit of every payoutable account in the lane that reached its * stage's target, where the platform takes payouts by API (Vest's Claim * Profit). The account then starts its next stage the same day: its leg * counts and entry outcome for the day are cleared, and a type whose stages * reset each day trades its next stage on the room the claim freed. Under the * lane's mutex, so no batch reads an account mid-claim. Off with the * auto_claim setting at 'off'. */ async function runClaims(lane: string, ready: FirmClient[]): Promise { if (getSetting('auto_claim') === 'off') return; const items = inLane(ready, lane).filter(({ client }) => typeof (client as { claimProfit?: unknown }).claimProfit === 'function'); if (items.length === 0) return; const now = Date.now(); const due = items.flatMap(({ firm, client }) => client.accountList.flatMap((acc) => { const amount = claimDue(client, acc, now); return amount === null ? [] : [{ firm, client, acc, amount }]; })); if (due.length === 0) return; const lease = acquireTradeMutex('[auto-claim]', lane); if (lease === null) return; try { const tradeDate = currentTradeDateCT(); for (const { firm, client, acc, amount } of due) { if (!holdsTradeMutex(lease, lane)) return; if (claimDue(client, acc, Date.now()) === null) continue; // settled since try { // eslint-disable-next-line @typescript-eslint/no-explicit-any const { claimed, received } = await (client as any).claimProfit(acc.id, amount); afterClaim('[auto-claim]', firm, client, acc, claimed, received, tradeDate); } catch (err) { (runtime().claimFailedAt ??= new Map()).set(acc.id, Date.now()); console.error(`[auto-claim] ${firm.name} ${acc.name}: claim of $${Math.floor(amount * 100) / 100} failed — ${errorMessage(err)}; retrying in ${CLAIM_RETRY_MS / 60_000}m`); } } } finally { releaseTradeMutex(lease, lane); } } /** A claim went through: the account starts its next stage — the day's leg * count and entry outcome are cleared, as if it hadn't traded today. */ function afterClaim( tag: string, firm: TradableFirm, client: PlatformClient, acc: { id: number; name: string }, claimed: number, received: number | null, tradeDate: string, ): number { runtime().legCounts.delete(acc.id); runtime().entryOutcomes?.delete(acc.id); runtime().claimFailedAt?.delete(acc.id); const stage = 1 + (client.sameDayClaims?.(acc.id) ?? 0); console.log(`${tag} ${firm.name} ${acc.name}: claimed $${claimed}${received !== null ? ` (you get $${received})` : ''}` + ` — trading stage ${stage} next (${tradeDate})`); return stage; } /** A Claim now the account can't take as asked — said back to the person * as is (the claim never reached the platform). */ export class ClaimRefusedError extends Error {} /** Whether an account can be claimed by hand right now, and how much: its * whole profit above the account size, whatever its stage's target. */ function manualClaim(accountId: number): | { firm: TradableFirm; client: PlatformClient; acc: { id: number; name: string }; amount: number; target: number | null } | { reason: string; unsupported?: true } { const { ready, unready } = firmClients(); const holds = ({ client }: FirmClient) => client.accountList.some((a) => a.id === accountId); const item = ready.find(holds); if (!item) { const stale = unready.find(holds); if (stale && typeof stale.client.claimProfit !== 'function') return { reason: `${stale.firm.name} takes no claims through its API`, unsupported: true }; return { reason: stale ? `${stale.firm.name} hasn't synced recently — wait for its next sync` : 'the account is not on a connected firm' }; } const { firm, client } = item; if (typeof client.claimProfit !== 'function') return { reason: `${firm.name} takes no claims through its API`, unsupported: true }; const acc = client.accountList.find((a) => a.id === accountId)!; const cfg = getAccountConfig(acc.id).accountType; if (!cfg) return { reason: 'no account type is assigned' }; if (cfg.payoutable !== true) return { reason: `${cfg.label} doesn't take payouts`, unsupported: true }; if (!acc.active) return { reason: `${firm.name} shows the account inactive` }; if (client.positions[acc.id] || client.hasWorkingOrders?.(acc.id) || runtime().accountsInFlight.has(acc.id)) { return { reason: 'a position or order is open — the account must be flat' }; } if (!client.historyReady(acc.id)) return { reason: 'its history is still loading' }; const cash = client.accountCashBalances[acc.id]; if (!cash) return { reason: 'its balance has not been read yet' }; const profit = Math.floor((cash.amount - cfg.accountSize) * 100) / 100; if (!(profit >= 1)) { return { reason: `$${Math.abs(profit).toFixed(2)} ${profit < 0 ? 'below' : 'above'} its $${cfg.accountSize.toLocaleString()} start — nothing to claim (the least is $1)` }; } const effective = resolveEffectiveConfig( cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, client.priorProfit?.[acc.id] ?? 0, client.fundTransactions?.[acc.id] ?? [], cfg.minDayPnL, stageInputsFor(client, acc.id, cfg), ); return { firm, client, acc, amount: profit, target: effective.profitTarget > 0 ? effective.profitTarget : null }; } /** The dashboard's view of manualClaim. */ export function claimableNow(accountId: number): | { claimable: number; target: number | null; firm: string } | { claimable: null; reason: string; unsupported: boolean } { const c = manualClaim(accountId); if ('reason' in c) return { claimable: null, reason: c.reason, unsupported: c.unsupported === true }; return { claimable: c.amount, target: c.target, firm: c.firm.name }; } /** * Claim now, from the dashboard: the person pressed it, so the stage's * target doesn't matter — any profit from $1 up to all of it above the * account size. The same checks as an automatic claim, made again under the * lane's mutex (no batch reads the account mid-claim), and the same * bookkeeping after: the account trades its next stage. */ export async function claimAccountNow(accountId: number, amount: number): Promise<{ claimed: number; received: number | null; stage: number }> { const first = manualClaim(accountId); if ('reason' in first) throw new ClaimRefusedError(first.reason); const lane = laneOf(first.firm); const lease = acquireTradeMutex('[claim-now]', lane); if (lease === null) throw new ClaimRefusedError(`the bot is placing a trade on ${first.firm.name} right now — try again in a minute`); try { const c = manualClaim(accountId); if ('reason' in c) throw new ClaimRefusedError(c.reason); if (!(amount >= 1)) throw new ClaimRefusedError(`$${amount} is below the $1 minimum claim`); if (amount > c.amount + 0.005) throw new ClaimRefusedError(`only $${c.amount.toFixed(2)} is claimable now`); const { claimed, received } = await c.client.claimProfit!(c.acc.id, amount); const stage = afterClaim('[claim-now]', c.firm, c.client, c.acc, claimed, received, currentTradeDateCT()); return { claimed, received, stage }; } finally { releaseTradeMutex(lease, lane); } } // ── 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.) 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 { firm, 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 (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; } } 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: 'Auto', 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 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); 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`); } const outcomes = new Map(); const ticks = new Map(); const record = (lane: string, outcome: TickOutcome, detail: string, reasons?: TickReason[]) => { outcomes.set(lane, outcome); ticks.set(lane, { lane: laneName(lane), at: new Date(now).toISOString(), outcome, detail: detail.slice(0, 300), ...(reasons && reasons.length > 0 ? { reasons } : {}), }); }; let filled = 0; for (const lane of tradableLanes()) { try { const laneHolding = holding.filter((f) => laneOf(f) === lane); if (laneHolding.length > 0) { const names = laneHolding.map((f) => f.name).join(', '); record(lane, 'holding-for-sync', `waiting for sync: ${names}`); console.log(`[scheduler] ${laneName(lane)}: waiting for sync: ${names}`); continue; } // Payouts first, account by account — a claim doesn't wait // for the rest of the lane to go flat. await runClaims(lane, ready); // 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) { record(lane, 'positions-open', `${openPositions} position(s) still open`); console.log(`[scheduler] ${laneName(lane)}: ${openPositions} position(s) still open — skipping tick`); continue; } const { outcome, results, detail, reasons } = await runTradeBatch(state.action, state.symbol, lane); record(lane, outcome, detail ?? outcome, reasons); filled += results.flatMap((r) => r.results).filter((r) => r.status === 'filled').length; } catch (err) { record(lane, `error: ${errorMessage(err)}`, errorMessage(err)); console.error(`[scheduler] ${laneName(lane)}: tick error: ${errorMessage(err)}`); } } rt.laneOutcomes = outcomes; rt.laneTicks = ticks; 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 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`); } /** 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) { 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'; rt.laneOutcomes = new Map(); 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') ?? 'Auto'; 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(), // A stopped scheduler keeps its last ticks, each with its time. lastTicks: [...(runtime().laneTicks?.values() ?? [])], }; } /** * 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. `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; lastTickOutcome: TickOutcome; lastFireAt: number; 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: inFlight.length > 0 ? Math.min(...inFlight) : 0, lanes: running ? Object.fromEntries([...(rt.laneOutcomes ?? new Map())].map(([lane, o]) => [laneName(lane), o])) : {}, }; }