/** * Auto-trade scheduler * * When a trade is triggered (POST /api/trade), the scheduler stores the * action + symbol and fires the same trade logic every 60 seconds to pick * up accounts that were busy (in a position) at the time of the original * signal but have since exited and are now eligible. */ import { getInstruments } from './db'; import { getClients } from './clients'; import { getTradableFirms } from './firm-catalog'; import { getAccountConfig } from './account-type-resolver'; import { computeDayPlan, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY, POINT_VALUES, type DayPlan, } from './trading-logic'; import { getSetting, setSetting } from './db'; // ── helpers ────────────────────────────────────────────────────────────────── function isAccountDead(amount: number, autoLiqThreshold: number): boolean { return autoLiqThreshold > 0 && amount <= autoLiqThreshold; } // In-memory runaway guard: legs fired per account per trade date. Resets on restart, // which is fine — day completion is derived from realized PnL, not from this counter. const _legCounts = new Map(); function legsToday(accountId: number, tradeDate: string): number { const entry = _legCounts.get(accountId); return entry && entry.date === tradeDate ? entry.count : 0; } function recordLeg(accountId: number, tradeDate: string): void { _legCounts.set(accountId, { date: tradeDate, count: legsToday(accountId, tradeDate) + 1 }); } // ── per-account trade evaluation (shared by runTrade / copyTrade / hasRemaining…) ── interface AccountEvaluation { eligible: boolean; reason: string; plan: DayPlan | null; isExtraDay: boolean; cash: { amount: number; realizedPnL: number }; dailyPnL: { date: string; pnl: number }[]; daysTraded: number; } /** * Evaluate whether an account can trade right now. Returns null when the account has no * matching config (not ours). Split-allowed accounts stay eligible mid-day until * remainingToday drops to LEG_COMPLETION_EPSILON or the leg cap is hit; extra-day and * strict-consistency (past day 1) accounts keep the legacy one-trade-per-day gate. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function evaluateAccountForTrade(client: any, acc: { id: number; name: string; active: boolean }, tradeDate: string): AccountEvaluation | null { const cfg = getAccountConfig(acc.id).accountType; if (!cfg) return null; const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 }; const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0; const dailyPnL: { date: string; pnl: number }[] = client.dailyPnL[acc.id] ?? []; const daysTraded: number = client.daysTraded[acc.id] ?? 0; const base = { plan: null, isExtraDay: false, cash, dailyPnL, daysTraded }; if (isAccountDead(cash.amount, autoLiqThreshold)) return { ...base, eligible: false, reason: 'dead' }; if (!acc.active) return { ...base, eligible: false, reason: 'inactive (DLL)' }; if (client.positions[acc.id]) return { ...base, eligible: false, reason: 'already in trade' }; 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 ); const equityProfit = cash.amount - cfg.accountSize; const plan = computeDayPlan( acc.id, tradeDate, effective.profitTarget, effective.consistency, dailyPnL, cfg.minDayPnL, effective.minTradingDays, equityProfit, cash.realizedPnL, cfg.splitEnabled ); if (!plan) return { ...base, eligible: false, reason: 'invalid balance' }; const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0); const isExtraDay = cfg.minDayPnL <= 0 && effective.minTradingDays > daysTraded && totalProfit >= effective.profitTarget; const evaluated = { plan, isExtraDay, cash, dailyPnL, daysTraded }; if (isExtraDay || !plan.splitAllowed) { // Legacy one-and-done gate: any realized PnL today means this account is finished. if (cash.realizedPnL !== 0) { return { ...evaluated, eligible: false, reason: `already traded today (realizedPnL=${cash.realizedPnL})` }; } if (!isExtraDay && plan.remainingToday <= 0) { return { ...evaluated, eligible: false, reason: `target=$${plan.dayTarget} (challenge complete)` }; } } 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 = cash.realizedPnL === 0 ? 0 : LEG_COMPLETION_EPSILON; if (plan.remainingToday <= doneThreshold) { const reason = cash.realizedPnL === 0 ? `target=$${plan.dayTarget} (challenge complete)` : `day done ($${plan.remainingToday} left of $${plan.dayTarget})`; return { ...evaluated, eligible: false, reason }; } if (legsToday(acc.id, tradeDate) >= MAX_LEGS_PER_DAY) { return { ...evaluated, eligible: false, reason: `leg cap reached (${MAX_LEGS_PER_DAY} today)` }; } } return { ...evaluated, eligible: true, reason: '' }; } // ── helpers ─────────────────────────────────────────────────────────────────── // Random 1–20 minute delay added to the session open, picked once per Central // calendar day. Re-rolling on every check would move the open boundary between // polls, letting a trade slip through a moment the next check would reject. const sessionOpenJitter = new Map(); function openJitterMinutes(centralDay: string): number { let jitter = sessionOpenJitter.get(centralDay); if (jitter === undefined) { jitter = 1 + Math.floor(Math.random() * 20); // 1–20 inclusive sessionOpenJitter.clear(); // only today's entry is ever needed sessionOpenJitter.set(centralDay, jitter); console.log(`[trading-hours] session open delayed ${jitter} min today`); } return jitter; } /** Today's session-open delay in minutes, for reporting to the master. */ export function todaysSessionOpenDelay(): number { const centralDay = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/Chicago', }).format(new Date()); return openJitterMinutes(centralDay); } /** * Returns true when trading is not allowed based on the trading_hours setting. * * "full_cme" — Sun 5:05 PM – Fri 2:55 PM Central (5 min buffer on each side) * "equity_hours" — 8:35 AM – 2:55 PM Central, Mon–Fri (5 min buffer on each side) * * The open side of each window is pushed back by a random 1–20 minutes per day * (see openJitterMinutes) so sessions never start at the exact same time. */ function isInNoTradeWindow(): boolean { const now = new Date(); const centralParts = new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: 'numeric', hour12: false, timeZone: 'America/Chicago', }).formatToParts(now); const hour = parseInt(centralParts.find((p) => p.type === 'hour')!.value, 10); const minute = parseInt(centralParts.find((p) => p.type === 'minute')!.value, 10); const day = new Intl.DateTimeFormat('en-US', { weekday: 'short', timeZone: 'America/Chicago', }).format(now); // "Sun", "Mon", ... "Sat" const timeMinutes = hour * 60 + minute; // minutes since midnight const jitter = todaysSessionOpenDelay(); const mode = getSetting('trading_hours') ?? 'full_cme'; if (mode === 'equity_hours') { // Equity hours: 8:30 AM – 3:00 PM Central with 5 min buffer = 8:35 AM – 2:55 PM // Weekdays only if (day === 'Sat' || day === 'Sun') return true; const open = 8 * 60 + 35 + jitter; // 8:35 AM + daily jitter const close = 14 * 60 + 55; // 2:55 PM return timeMinutes < open || timeMinutes >= close; } // Full CME: Sun 5:00 PM – Fri 4:00 PM Central with 5 min buffer // Open: 5:05 PM, Close: 2:55 PM (stop early), Daily halt: 2:55 PM – 5:05 PM // Saturday — market closed all day if (day === 'Sat') return true; // Sunday — market opens at 5:05 PM Central (+ daily jitter) const cmeOpen = 17 * 60 + 5 + jitter; // 5:05 PM + daily jitter if (day === 'Sun') return timeMinutes < cmeOpen; // Friday — stop trading at 2:55 PM Central const cmeClose = 14 * 60 + 55; // 2:55 PM if (day === 'Fri' && timeMinutes >= cmeClose) return true; // Mon–Thu: block 2:55 PM – 5:05 PM Central (early stop + daily halt + buffer) if (timeMinutes >= cmeClose && timeMinutes < cmeOpen) return true; return false; } // ── core trade logic ────────────────────────────────────────────────────────── export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string) { if (isInNoTradeWindow()) { console.log('[auto-trade] CME market closed — skipping'); return []; } // Block if any account still has an open position (same gate as the scheduler tick) const allClients = getClients(); const allFirms = getTradableFirms(); const openPositions = allFirms.reduce((count, firm) => { const c = allClients.get(firm.id); if (!c) return count; return count + c.accountList.filter(acc => !!c.positions[acc.id]).length; }, 0); if (openPositions > 0) { console.log(`[auto-trade] ${openPositions} position(s) still open — skipping`); return []; } // Resolve 'Auto' symbol once per batch so all accounts trade the same symbol let resolvedSymbol = symbol; if (symbol === 'Auto') { const enabled = getInstruments().filter((i) => i.enabled).map((i) => i.symbol); resolvedSymbol = enabled.length > 0 ? enabled[Math.floor(Math.random() * enabled.length)] : 'NQ'; console.log(`[auto-trade] random symbol resolved to: ${resolvedSymbol}`); } // Resolve Auto action once per batch so all accounts trade the same direction const resolvedAction: 'Buy' | 'Sell' = action === 'Auto' ? (Math.random() < 0.5 ? 'Buy' : 'Sell') : action; const pointValue = POINT_VALUES[resolvedSymbol]; if (!pointValue) throw new Error(`Unknown symbol: ${resolvedSymbol}`); const tradeDate = currentTradeDateCT(); const maxConcurrent = parseInt(getSetting('max_concurrent_accounts') ?? '5', 10); const firms = getTradableFirms(); const clients = getClients(); // ── Phase 1: collect ALL eligible accounts across ALL firms in parallel ── type EligibleItem = { firmName: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any client: any; acc: { id: number; name: string; active: boolean }; contract: { name: string; tickSize: number }; cash: { amount: number; realizedPnL: number }; dailyPnL: { date: string; pnl: number }[]; daysTraded: number; plan: DayPlan; isExtraDay: boolean; }; const allEligible: EligibleItem[] = []; await Promise.all(firms.map(async (firm) => { const client = clients.get(firm.id); if (!client || client.accountList.length === 0) return; // Skip this firm entirely if the symbol is banned for it if (firm.bannedSymbols.includes(resolvedSymbol)) { console.log(`[auto-trade] ${firm.name}: ${resolvedSymbol} is banned — skipping firm`); return; } const contract = await client.findFrontMonthContract(resolvedSymbol); if (!contract) return; for (const acc of client.accountList) { const ev = evaluateAccountForTrade(client, acc, tradeDate); if (!ev || !ev.eligible || !ev.plan) continue; allEligible.push({ firmName: firm.name, client, acc, contract, cash: ev.cash, dailyPnL: ev.dailyPnL, daysTraded: ev.daysTraded, plan: ev.plan, isExtraDay: ev.isExtraDay, }); } })); if (allEligible.length === 0) { console.log('[auto-trade] no eligible accounts found'); return []; } // 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). const batch = allEligible.slice(0, maxConcurrent); console.log(`[auto-trade] ${allEligible.length} eligible account(s) — firing batch of ${batch.length}`); // ── Phase 2: fire the batch simultaneously ── const tradeResults = await Promise.allSettled(batch.map(async (item) => { const { client, acc, contract } = item; const cfg = getAccountConfig(acc.id).accountType!; // Extra-day mode: profit target already met, no min day P&L, days still needed. // Just trade 1 MNQ in and out at market immediately — P&L doesn't matter. if (item.isExtraDay) { const mnqContract = await client.findFrontMonthContract('MNQ'); if (!mnqContract) throw new Error('MNQ contract not found for extra-day trade'); const fill = await client.sendOrder(acc.id, mnqContract.name, 1, resolvedAction, 'Market'); const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy'; const exitFill = await client.sendOrder(acc.id, mnqContract.name, 1, exitAction, 'Market'); console.log(`[auto-trade] ${acc.name} (${item.firmName}) extra-day: ${resolvedAction} 1xMNQ @ ${fill.price} | exited @ ${exitFill.price} (market)`); return { account: acc.name, firm: item.firmName, status: 'filled', contracts: 1, target: 0, grossTarget: 0, totalCommission: 0, targetPath: 'extra_day', entryPrice: fill.price, exitPrice: exitFill.price, commission: 0, }; } // Trade one leg toward today's remaining target. Split-allowed big days carve a // random ≥$800 piece; everything else trades the full remaining amount in one go. const legAmount = item.plan.splitAllowed ? pickLegAmount(item.plan.remainingToday) : item.plan.remainingToday; recordLeg(acc.id, tradeDate); const rawContracts = Math.max(1, Math.ceil(legAmount / 1000)); const contracts = cfg.maxPositionSize > 0 ? Math.min(rawContracts, cfg.maxPositionSize) : rawContracts; const fill = await client.sendOrder(acc.id, contract.name, contracts, resolvedAction, 'Market'); // Wait briefly for the cash balance WebSocket update to reflect entry commission await new Promise(r => setTimeout(r, 1000)); const updatedCash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 }; // After entry, realizedPnL drops by the entry commission; the delta from the // pre-entry value is the entry fee paid (pre-entry may be nonzero mid-day). // Fall back to $2.50/contract if the WS hasn't updated yet (guarantees at least 1 extra tick). const entryCommission = Math.abs(updatedCash.realizedPnL - item.cash.realizedPnL) || (2.5 * contracts); const totalCommission = entryCommission * 2; // entry + exit round-trip const grossTarget = legAmount + totalCommission; const targetPoints = grossTarget / (pointValue * contracts); const ticks = Math.ceil(targetPoints / contract.tickSize); const exitPrice = resolvedAction === 'Buy' ? fill.price + (ticks * contract.tickSize) : fill.price - (ticks * contract.tickSize); const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy'; const exitOrder = await client.placeOrderNoWait(acc.id, contract.name, contracts, exitAction, 'Limit', exitPrice); console.log(`[auto-trade] ${acc.name} (${item.firmName}) ${resolvedAction} ${contracts}x${resolvedSymbol} @ ${fill.price} | leg $${legAmount} of day $${item.plan.dayTarget} (remaining $${item.plan.remainingToday}) [${item.plan.path}] (+$${totalCommission.toFixed(2)} comm) | exit @ ${exitPrice} (orderId=${exitOrder.orderId})`); return { account: acc.name, firm: item.firmName, status: 'filled', contracts, target: legAmount, dayTarget: item.plan.dayTarget, remainingBefore: item.plan.remainingToday, grossTarget, totalCommission, targetPath: item.plan.path, entryPrice: fill.price, exitPrice, commission: entryCommission, }; })); // Group results by firm for the response const firmResultsMap = new Map(); for (let i = 0; i < batch.length; i++) { const firmName = batch[i].firmName; if (!firmResultsMap.has(firmName)) firmResultsMap.set(firmName, []); const r = tradeResults[i]; firmResultsMap.get(firmName)!.push( r.status === 'fulfilled' ? r.value : { status: 'error', reason: (r.reason as any)?.message ?? String(r.reason) } ); } return Array.from(firmResultsMap.entries()).map(([firm, results]) => ({ firm, results })); } // ── copy trade ─────────────────────────────────────────────────────────────── /** * Copy the current trade direction to up to maxConcurrent accounts. * Finds accounts with open positions, determines direction, then fires * orders for eligible accounts that haven't traded yet. */ export async function copyTrade() { console.log('[copy-trade] called'); if (isInNoTradeWindow()) { console.log('[copy-trade] outside trading hours — skipping'); return []; } const firms = getTradableFirms(); const clients = getClients(); const maxConcurrent = Math.max(1, parseInt(getSetting('max_concurrent_accounts') ?? '5', 10)); // Find all accounts with open positions to determine direction + symbol let resolvedAction: 'Buy' | 'Sell' | null = null; let positionContractId: number | null = null; let positionedCount = 0; // eslint-disable-next-line @typescript-eslint/no-explicit-any let positionedClient: any = null; for (const firm of firms) { const client = clients.get(firm.id); if (!client) continue; for (const acc of client.accountList) { const pos = client.positions[acc.id]; if (!pos) continue; positionedCount++; if (!resolvedAction) { resolvedAction = pos.netPos > 0 ? 'Buy' : 'Sell'; positionContractId = pos.contractId; positionedClient = client; } } } if (!resolvedAction || positionedCount === 0) { console.log('[copy-trade] no open positions to copy from'); return []; } console.log(`[copy-trade] found ${positionedCount} open position(s), action=${resolvedAction}, contractId=${positionContractId}`); // Resolve symbol from the positioned contract's contractId — only check the client that // actually has the position. Iterating every firm × every symbol can take 30+ seconds. let resolvedSymbol: string | null = null; const instruments = getInstruments(); const enabledSymbols = instruments.filter(i => i.enabled).map(i => i.symbol); if (positionedClient) { for (const sym of enabledSymbols) { const contract = await positionedClient.findFrontMonthContract(sym); if (contract && contract.id === positionContractId) { resolvedSymbol = sym; break; } } } console.log(`[copy-trade] resolved symbol: ${resolvedSymbol ?? '(none, using fallback)'}`); // Fall back to scheduler symbol or first enabled if (!resolvedSymbol) { const state = getState(); resolvedSymbol = state.symbol !== 'Auto' ? state.symbol : (enabledSymbols[0] ?? 'NQ'); } const pointValue = POINT_VALUES[resolvedSymbol]; if (!pointValue) { console.log(`[copy-trade] unknown symbol ${resolvedSymbol}`); return []; } const slotsAvailable = maxConcurrent - positionedCount; if (slotsAvailable <= 0) { console.log(`[copy-trade] already at max concurrent (${positionedCount}/${maxConcurrent})`); return []; } const tradeDate = currentTradeDateCT(); // Collect eligible accounts (same logic as Phase 1 of runTrade) type CopyItem = { firmName: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any client: any; acc: { id: number; name: string; active: boolean }; contract: { name: string; tickSize: number }; cash: { amount: number; realizedPnL: number }; plan: DayPlan; }; const eligible: CopyItem[] = []; await Promise.all(firms.map(async (firm) => { const client = clients.get(firm.id); if (!client || client.accountList.length === 0) { console.log(`[copy-trade] ${firm.name}: no client or empty account list — skip firm`); return; } if (firm.bannedSymbols.includes(resolvedSymbol!)) { console.log(`[copy-trade] ${firm.name}: ${resolvedSymbol} is banned — skip firm`); return; } const contract = await client.findFrontMonthContract(resolvedSymbol!); if (!contract) { console.log(`[copy-trade] ${firm.name}: cannot resolve front-month contract for ${resolvedSymbol} — skip firm`); return; } 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; } eligible.push({ firmName: firm.name, client, acc, contract, cash: ev.cash, plan: ev.plan }); } })); const batch = eligible.slice(0, slotsAvailable); if (batch.length === 0) { console.log('[copy-trade] no eligible accounts to copy to'); return []; } console.log(`[copy-trade] copying ${resolvedAction} ${resolvedSymbol} to ${batch.length} account(s)`); // Fire orders (same as Phase 2 of runTrade) const tradeResults = await Promise.allSettled(batch.map(async (item) => { const { client, acc, contract } = item; const cfg = getAccountConfig(acc.id).accountType!; const legAmount = item.plan.splitAllowed ? pickLegAmount(item.plan.remainingToday) : item.plan.remainingToday; recordLeg(acc.id, tradeDate); const rawContracts = Math.max(1, Math.ceil(legAmount / 1000)); const contracts = cfg.maxPositionSize > 0 ? Math.min(rawContracts, cfg.maxPositionSize) : rawContracts; const fill = await client.sendOrder(acc.id, contract.name, contracts, resolvedAction!, 'Market'); await new Promise(r => setTimeout(r, 1000)); const updatedCash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 }; const entryCommission = Math.abs(updatedCash.realizedPnL - item.cash.realizedPnL) || (2.5 * contracts); const totalCommission = entryCommission * 2; const grossTarget = legAmount + totalCommission; const targetPoints = grossTarget / (pointValue * contracts); const ticks = Math.ceil(targetPoints / contract.tickSize); const exitPrice = resolvedAction === 'Buy' ? fill.price + (ticks * contract.tickSize) : fill.price - (ticks * contract.tickSize); const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy'; const exitOrder = await client.placeOrderNoWait(acc.id, contract.name, contracts, exitAction, 'Limit', exitPrice); console.log(`[copy-trade] ${acc.name} (${item.firmName}) ${resolvedAction} ${contracts}x${resolvedSymbol} @ ${fill.price} | leg $${legAmount} of day $${item.plan.dayTarget} (remaining $${item.plan.remainingToday}) [${item.plan.path}] | exit @ ${exitPrice} (orderId=${exitOrder.orderId})`); return { account: acc.name, firm: item.firmName, status: 'filled', contracts, target: legAmount, dayTarget: item.plan.dayTarget, remainingBefore: item.plan.remainingToday, grossTarget, totalCommission, targetPath: item.plan.path, entryPrice: fill.price, exitPrice, commission: entryCommission, }; })); const results: unknown[] = []; for (let i = 0; i < batch.length; i++) { const r = tradeResults[i]; results.push( r.status === 'fulfilled' ? r.value : { status: 'error', reason: (r.reason as any)?.message ?? String(r.reason) } ); } return results; } // ── eligibility check ──────────────────────────────────────────────────────── /** Returns true if any configured account could still trade today (eligible for a next leg / extra-day, or has an open position). */ function hasRemainingConfiguredAccounts(): boolean { const firms = getTradableFirms(); const clients = getClients(); const tradeDate = currentTradeDateCT(); for (const firm of firms) { const client = clients.get(firm.id); if (!client || 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; const ev = evaluateAccountForTrade(client, acc, tradeDate); if (ev?.eligible) return true; } } return false; } // ── scheduler ───────────────────────────────────────────────────────────────── interface SchedulerState { action: 'Buy' | 'Sell' | 'Auto'; symbol: string; intervalId: ReturnType | null; lastRun: Date | null; running: boolean; stopAfterAll: boolean; } // Global singleton (survives HMR in dev via module cache) const _global = globalThis as typeof globalThis & { __autoTrader?: SchedulerState }; function getState(): SchedulerState { if (!_global.__autoTrader) { _global.__autoTrader = { action: 'Buy', symbol: 'NQ', intervalId: null, lastRun: null, running: false, stopAfterAll: false }; } return _global.__autoTrader; } export function startScheduler(action: 'Buy' | 'Sell' | 'Auto', symbol: string, stopAfterAll: boolean = false) { const state = getState(); // Clear any existing interval if (state.intervalId !== null) { clearInterval(state.intervalId); } state.action = action; state.symbol = symbol; state.running = true; state.stopAfterAll = stopAfterAll; // 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'); const tick = async () => { if (!state.running) return; state.lastRun = new Date(); // Skip this tick until every client has completed its initial sync (positions are populated) const clients = getClients(); const firms = getTradableFirms(); const notReady = firms.filter(f => { const c = clients.get(f.id); return c && !c.syncComplete; }); if (notReady.length > 0) { console.log(`[scheduler] waiting for sync: ${notReady.map(f => f.name).join(', ')}`); return; } // Skip this tick if any account still has an open position from the previous batch const openPositions = firms.reduce((count, firm) => { const client = clients.get(firm.id); if (!client) return count; return count + client.accountList.filter(acc => !!client.positions[acc.id]).length; }, 0); if (openPositions > 0) { console.log(`[scheduler] ${openPositions} position(s) still open — skipping tick`); return; } try { const results = await runTrade(state.action, state.symbol); const filled = results.flatMap((r: any) => r.results ?? []).filter((r: any) => r.status === 'filled').length; if (filled > 0) console.log(`[scheduler] tick: ${filled} account(s) filled`); // Auto-stop if user opted in and no configured accounts can trade anymore if (state.stopAfterAll && !hasRemainingConfiguredAccounts()) { console.log('[scheduler] all configured accounts done for today — stopping'); stopScheduler(); } } catch (err) { console.error('[scheduler] tick error:', err); } }; const intervalSecs = Math.max(5, parseInt(getSetting('tick_interval_seconds') ?? '60', 10)); state.intervalId = setInterval(tick, intervalSecs * 1_000); console.log(`[scheduler] started — ${action} ${symbol} every ${intervalSecs}s`); } export function stopScheduler() { const state = getState(); if (state.intervalId !== null) { clearInterval(state.intervalId); state.intervalId = null; } state.running = false; 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 skips while any client reports * !syncComplete, and again while any account holds an open position. So the * worst case is a few logged no-op ticks until the clients finish syncing. */ export function resumeSchedulerIfPersisted(): void { if (getSetting('scheduler_running') !== '1') return; const action = (getSetting('scheduler_action') ?? 'Buy') as 'Buy' | 'Sell' | 'Auto'; const symbol = getSetting('scheduler_symbol') ?? 'NQ'; const stopAfterAll = getSetting('scheduler_stop_after_all') === '1'; console.log(`[scheduler] resuming persisted schedule — ${action} ${symbol}`); startScheduler(action, symbol, stopAfterAll); } export function getSchedulerStatus() { const state = getState(); return { running: state.running, action: state.action, symbol: state.symbol, lastRun: state.lastRun, intervalSeconds: parseInt(getSetting('tick_interval_seconds') ?? '60', 10), stopAfterAll: state.stopAfterAll, sessionOpenDelayMin: todaysSessionOpenDelay(), }; }