Files
autofirmer-expanded/lib/auto-trade.ts
T
Brandon LiandClaude Opus 5.5 e49640498a Tradovate: yesterday's realized P&L no longer reads as today's after the roll
Tradovate rolls each account's cash record to the new trade date on its own
schedule (18 minutes after the 17:00 CT open on 2026-09-24, or not until the
account trades), and until then the latest record still carries the ended
day's realizedPnL. Plans sized legs off it, one-and-done accounts read as
already traded, the reporter scored every account partial/loss, and an entry
across the record roll took a whole day's P&L as its "commission", parking
the take-profit out of reach.

- Cash records keep their trade date; realized P&L from an earlier trade
  date reads as 0 (judged when read, so a record stored before the roll
  counts as ended after it). Archived or older records never overwrite a
  newer one, whichever order the roll pair arrives in.
- The entry-commission delta falls back to $2.50/contract when it exceeds
  any real fee ($10/contract).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:22:43 -05:00

1394 lines
64 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Auto-trade scheduler
*
* When a trade is triggered (POST /api/trade), the scheduler stores the
* action + symbol and fires the same trade logic every 60 seconds to pick
* up accounts that were busy (in a position) at the time of the original
* signal but have since exited and are now eligible.
*/
import { getInstruments } from './db';
import { getClients } from './clients';
import { getTradableFirms } from './firm-catalog';
import { getAccountConfig } from './account-type-resolver';
import {
combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT,
BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY, POINT_VALUES,
FIRST_DAY_STOP, MIN_DAY_STOP_MULT,
type DayPlan,
} from './trading-logic';
import { getSetting, setSetting } from './db';
import { errorMessage, isClientReady, isOrderNotPlaced, type PlatformClient } from './platform-client';
// ── process-wide state ───────────────────────────────────────────────────────
/**
* Everything the batches share, held on globalThis like the scheduler state
* further down. Next can bundle this module more than once — the scheduler
* tick runs from whichever bundle booted the clients, POST /api/trade and
* /api/copy-trade from their own route bundles — and plain module-level state
* would split into one private copy per bundle: a leg recorded by the tick
* would be invisible to a copy-trade, the trade mutex would lock nothing, and
* the session-open jitter could be rolled twice in one day.
*/
interface TradeRuntime {
/** In-memory runaway guard: legs fired per account per trade date, plus
* the sum of their targets — realized P&L falling short of that sum is
* how a stop-out is detected. Resets on restart, which is fine — day
* completion is derived from realized PnL, not from this counter. */
legCounts: Map<number, { date: string; count: number; sum: number }>;
/** Today's session-open delay, keyed by Central calendar day (see openJitterMinutes). */
sessionOpenJitter: Map<string, number>;
/** 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<number>;
/** Firm id → when the tick first saw its client not ready (see UNREADY_GRACE_MS). */
unreadySince: Map<string, number>;
/** Firm id → when the tick last logged it as quarantined (rate limit). */
quarantineLoggedAt: Map<string, number>;
/** When the batch holding the trade mutex took it (0 = no batch in flight). */
tradeInFlightSince: number;
// What getSchedulerHealth reports.
lastTickAt: number;
lastTickOutcome: TickOutcome;
lastFireAt: number;
holding: string[];
quarantined: string[];
}
/** What the scheduler's last tick decided (getSchedulerHealth). */
export type TickOutcome =
| 'not-running' | 'holding-for-sync' | 'positions-open' | 'closed'
| 'no-eligible' | 'fired' | 'skipped-in-flight' | `error: ${string}`;
const _tradeGlobal = globalThis as typeof globalThis & { __autoTradeRuntime?: TradeRuntime };
function runtime(): TradeRuntime {
if (!_tradeGlobal.__autoTradeRuntime) {
_tradeGlobal.__autoTradeRuntime = {
legCounts: new Map(),
sessionOpenJitter: new Map(),
accountsInFlight: new Set(),
unreadySince: new Map(),
quarantineLoggedAt: new Map(),
tradeInFlightSince: 0,
lastTickAt: 0,
lastTickOutcome: 'not-running',
lastFireAt: 0,
holding: [],
quarantined: [],
};
}
return _tradeGlobal.__autoTradeRuntime;
}
/** How long the tick holds every firm for one whose client isn't ready. Long
* enough for a restart or a reconnect to finish syncing; past it the client
* is presumed dead and its firm is quarantined (see assessReadiness). */
const UNREADY_GRACE_MS = 10 * 60 * 1000;
/** How often a quarantined firm is re-announced in the log. */
const QUARANTINE_LOG_EVERY_MS = 5 * 60 * 1000;
/** A batch that has held the trade mutex this long is presumed wedged. */
const TRADE_WEDGED_MS = 10 * 60 * 1000;
// ── helpers ──────────────────────────────────────────────────────────────────
/** How many accounts one batch may fire, never below 1. The settings PATCH
* stores whatever the form sends, and a cleared field arrives as '' —
* parseInt('') is NaN, which slice() takes as 0: every batch would fire
* nobody while the tick reported 'fired'. Non-numeric falls back to the
* default 5, like a missing setting; the form's own floor is 1. */
function maxConcurrentAccounts(): number {
const configured = parseInt(getSetting('max_concurrent_accounts') ?? '5', 10);
return Math.max(1, Number.isFinite(configured) ? configured : 5);
}
function isAccountDead(amount: number, autoLiqThreshold: number): boolean {
// Tradovate floors are positive balances; reconstructed TopstepX MLL
// floors are negative (or +$0.01 once locked at breakeven). 0 = no floor.
return autoLiqThreshold !== 0 && amount <= autoLiqThreshold;
}
function legsToday(accountId: number, tradeDate: string): number {
const entry = runtime().legCounts.get(accountId);
return entry && entry.date === tradeDate ? entry.count : 0;
}
function legSumToday(accountId: number, tradeDate: string): number {
const entry = runtime().legCounts.get(accountId);
return entry && entry.date === tradeDate ? entry.sum : 0;
}
function recordLeg(accountId: number, tradeDate: string, legAmount: number): void {
runtime().legCounts.set(accountId, {
date: tradeDate,
count: legsToday(accountId, tradeDate) + 1,
sum: Math.round((legSumToday(accountId, tradeDate) + legAmount) * 100) / 100,
});
}
/** Take back a leg recordLeg counted for an entry that never reached the
* broker. Left standing, it would bench a stop-type account for the day —
* realized P&L "falls short" of a leg that never traded — and burn one of
* the day's MAX_LEGS_PER_DAY. */
function unrecordLeg(accountId: number, tradeDate: string, legAmount: number): void {
const legCounts = runtime().legCounts;
const entry = legCounts.get(accountId);
if (!entry || entry.date !== tradeDate || entry.count <= 0) return;
if (entry.count === 1) {
legCounts.delete(accountId);
return;
}
legCounts.set(accountId, {
date: tradeDate,
count: entry.count - 1,
sum: Math.round((entry.sum - legAmount) * 100) / 100,
});
}
/**
* Send a leg's market entry. The leg was recorded before this — the runaway
* guard has to see it while the batch is still in flight — so it is taken
* back here only when the client KNOWS the order never reached the broker
* (OrderNotPlacedError). Anything ambiguous, like an ack or fill timeout,
* keeps the leg: the order may well have filled, and a leg counted that never
* happened only costs a leg, while one forgotten that did can trade the
* account twice.
*/
async function sendEntry(
tag: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any,
acc: { id: number; name: string },
contractName: string,
contracts: number,
action: 'Buy' | 'Sell',
tradeDate: string,
legAmount: number,
) {
try {
return await client.sendOrder(acc.id, contractName, contracts, action, 'Market');
} catch (err) {
if (isOrderNotPlaced(err)) {
unrecordLeg(acc.id, tradeDate, legAmount);
console.warn(`${tag} ${acc.name}: entry never reached the broker — leg un-counted`);
} else {
console.warn(`${tag} ${acc.name}: entry outcome unknown — leg kept (it may have filled)`);
}
throw err;
}
}
/** Run one account's part of a batch with the account marked in flight, so
* no other batch picks it while its orders are still being placed. */
async function withAccountInFlight<T>(accountId: number, fire: () => Promise<T>): Promise<T> {
const inFlight = runtime().accountsInFlight;
inFlight.add(accountId);
try {
return await fire();
} finally {
inFlight.delete(accountId);
}
}
// ── readiness ────────────────────────────────────────────────────────────────
type TradableFirm = ReturnType<typeof getTradableFirms>[number];
interface FirmClient { firm: TradableFirm; client: PlatformClient }
/**
* Tradable firms that have a client, split by isClientReady. Everything that
* trades or gates works from `ready` only: a client whose sync is missing or
* stale holds a frozen snapshot, whose positions may be long gone and which
* may be missing ones opened since — it must neither trade nor gate.
*/
function firmClients(now = Date.now()): { ready: FirmClient[]; unready: FirmClient[] } {
const clients = getClients();
const ready: FirmClient[] = [];
const unready: FirmClient[] = [];
for (const firm of getTradableFirms()) {
const client = clients.get(firm.id);
if (!client) continue;
(isClientReady(client, now) ? ready : unready).push({ firm, client });
}
return { ready, unready };
}
function countOpenPositions(ready: FirmClient[]): number {
return ready.reduce(
(count, { client }) => count + client.accountList.filter((acc) => !!client.positions[acc.id]).length,
0,
);
}
/**
* Which firms hold the tick, and which it trades without.
*
* A firm whose client isn't ready might hold positions nobody can see, so for
* the first UNREADY_GRACE_MS the whole tick waits for it, as it always has —
* right for a restart or a reconnect, which settle within a minute or two.
* Past the grace period the client is presumed dead (broker maintenance, a
* login that keeps failing) and the firm is quarantined: the other firms
* trade without it, rather than one broken session stopping every firm on the
* machine. A quarantined firm trades nothing — runTrade and copyTrade only
* ever see ready clients — and rejoins on its first good sync.
*
* A client that has synced and found no accounts is ignored outright: it
* cannot hold positions or trade, so there is nothing to wait for.
*/
function assessReadiness(
ready: FirmClient[],
unready: FirmClient[],
now: number,
): { holding: TradableFirm[]; quarantined: { firm: TradableFirm; unreadyMs: number }[] } {
const rt = runtime();
const holding: TradableFirm[] = [];
const quarantined: { firm: TradableFirm; unreadyMs: number }[] = [];
const tracked = new Set<string>();
for (const { firm, client } of unready) {
if (client.lastSyncAt > 0 && client.accountList.length === 0) continue;
tracked.add(firm.id);
const since = rt.unreadySince.get(firm.id) ?? now;
rt.unreadySince.set(firm.id, since);
const unreadyMs = now - since;
if (unreadyMs < UNREADY_GRACE_MS) holding.push(firm);
else quarantined.push({ firm, unreadyMs });
}
// Forget firms that are ready again (or gone), and say so for any that
// had been traded around.
for (const id of [...rt.unreadySince.keys()]) {
if (tracked.has(id)) continue;
const back = ready.find((r) => r.firm.id === id);
if (back && rt.quarantineLoggedAt.has(id)) {
console.log(`[scheduler] ${back.firm.name} synced again — trading it with the others`);
}
rt.unreadySince.delete(id);
rt.quarantineLoggedAt.delete(id);
}
return { holding, quarantined };
}
// ── trade mutex ──────────────────────────────────────────────────────────────
/**
* One batch at a time, instance-wide. The tick, a master "trade" command,
* POST /api/trade and /api/copy-trade can all fire a batch, and two batches
* collecting at once would each see the other's accounts as still flat — the
* position gate only trips once a fill has come back over the socket.
*
* A batch that has held the mutex past TRADE_WEDGED_MS is presumed stuck on a
* broker call that will never answer, and the next one goes ahead without it
* rather than nothing trading for the rest of the process's life. The stuck
* batch's accounts stay in accountsInFlight, so they are never traded twice,
* and if the stuck batch does wake up it finds it no longer holds the mutex
* and abandons its now-stale selection (holdsTradeMutex).
*
* Returns the lease to hand to releaseTradeMutex, or null to skip.
*/
function acquireTradeMutex(tag: string): number | null {
const rt = runtime();
const now = Date.now();
if (rt.tradeInFlightSince > 0) {
const heldMs = now - rt.tradeInFlightSince;
if (heldMs < TRADE_WEDGED_MS) {
console.log(`${tag} another batch in flight for ${Math.round(heldMs / 1000)}s — skipping`);
return null;
}
console.warn(`${tag} batch in flight for ${Math.round(heldMs / 60_000)}m looks wedged — proceeding without it`);
}
rt.tradeInFlightSince = now;
return now;
}
function holdsTradeMutex(lease: number): boolean {
return runtime().tradeInFlightSince === lease;
}
/** Only the holder unlocks: a wedged batch that finally returns must not
* release the batch that took over from it. */
function releaseTradeMutex(lease: number): void {
if (holdsTradeMutex(lease)) runtime().tradeInFlightSince = 0;
}
/** Obfuscation stop for the current leg, in dollars. Infinity = no stop.
* Day 1 of a cycle carries the $1,000 account stop (firstDayStop), min-day
* (small) trades a 2×-target stop (minDayStop) — and the day after a losing
* day always rides stopless, the make-back day. */
function obfuscationStopDollars(
cfg: { firstDayStop: boolean; minDayStop: boolean },
dailyPnL: { date: string; pnl: number }[],
plan: DayPlan,
tradeDate: string,
legAmount: number,
): number {
if (!cfg.firstDayStop && !cfg.minDayStop) return Infinity;
const prior = dailyPnL
.filter((d) => d.date < tradeDate && d.pnl !== 0)
.sort((a, b) => a.date.localeCompare(b.date));
if (prior.length > 0 && prior[prior.length - 1].pnl < 0) return Infinity;
let stop = Infinity;
const realizedToday = Math.round((plan.dayTarget - plan.remainingToday) * 100) / 100;
if (cfg.firstDayStop && prior.length === 0) stop = Math.max(0, FIRST_DAY_STOP + realizedToday);
if (cfg.minDayStop && plan.dayTarget <= BIG_DAY_THRESHOLD) stop = Math.min(stop, MIN_DAY_STOP_MULT * legAmount);
return stop;
}
/** The instrument a leg actually trades: drain legs go on MNQ, hardcoded
* (like the extra-day path) — tiny targets belong on the micro regardless of
* the batch symbol. */
async function legInstrument(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any,
drain: boolean,
batchContract: { name: string; tickSize: number },
batchPointValue: number,
): Promise<{ contract: { name: string; tickSize: number }; pointValue: number }> {
if (!drain) return { contract: batchContract, pointValue: batchPointValue };
const mnq = await client.findFrontMonthContract('MNQ');
if (!mnq) throw new Error('MNQ contract not found for drain trade');
return { contract: mnq, pointValue: POINT_VALUES['MNQ'] };
}
/** An entry's fee when the balance update hasn't shown it yet — at least one
* extra tick on the exit. */
const FALLBACK_COMMISSION_PER_CONTRACT = 2.5;
/** No real entry fee comes near this per contract. */
const MAX_COMMISSION_PER_CONTRACT = 10;
/**
* The entry fee, read off the realized-P&L delta around the fill. Anything
* past a real per-contract fee is something else moving realized P&L — a
* broker record rolling to the new trade date under the entry swapped a whole
* day's P&L into the "fee", and doubled into the exit target that parked the
* take-profit out of reach — so it falls back like a missing update does.
*/
function entryCommissionFrom(before: number, after: number, contracts: number): number {
const delta = Math.abs(after - before);
if (delta > 0 && delta <= MAX_COMMISSION_PER_CONTRACT * contracts) return delta;
if (delta > 0) {
console.warn(`[auto-trade] realized P&L moved $${Math.round(delta * 100) / 100} around a ${contracts}-contract entry`
+ ` — not a fee; using $${FALLBACK_COMMISSION_PER_CONTRACT}/contract`);
}
return FALLBACK_COMMISSION_PER_CONTRACT * contracts;
}
// ── per-account trade evaluation (shared by runTrade / copyTrade / hasRemaining…) ──
/** The ineligible reason for an account whose history isn't loaded yet — still
* in play for stop-after-all, since it trades once the fetch lands. */
const HISTORY_PENDING = 'history not loaded yet — waiting for the fetch';
interface AccountEvaluation {
eligible: boolean;
reason: string;
plan: DayPlan | null;
isExtraDay: boolean;
/** Current stage is a drain stage — the leg trades MNQ regardless of the batch symbol. */
drain: boolean;
cash: { amount: number; realizedPnL: number };
dailyPnL: { date: string; pnl: number }[];
daysTraded: number;
}
/**
* Evaluate whether an account can trade right now. Returns null when the account has no
* matching config (not ours). Split-allowed accounts stay eligible mid-day until
* remainingToday drops to LEG_COMPLETION_EPSILON or the leg cap is hit; extra-day and
* strict-consistency (past day 1) accounts keep the legacy one-trade-per-day gate.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function evaluateAccountForTrade(client: any, acc: { id: number; name: string; active: boolean }, tradeDate: string): AccountEvaluation | null {
const cfg = getAccountConfig(acc.id).accountType;
if (!cfg) return null;
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0;
// A combining firm records holiday sessions with the next full trading day,
// so the plan and the already-traded gates below must see the whole stretch
// as one day: Sunday's booked P&L folds into Tuesday's realized, and the
// day target stays anchored to the combined (clearing) trade date. `cash`
// itself stays raw — the commission delta in the fire path compares live
// Tradovate values.
const combined = combineCmeSessions(
cfg.combineCmeHolidays, client.dailyPnL[acc.id] ?? [], tradeDate, cash.realizedPnL,
);
const dailyPnL = combined.dailyPnL;
const daysTraded = combined.daysTraded;
const base = { plan: null, isExtraDay: false, drain: false, cash, dailyPnL, daysTraded };
if (isAccountDead(cash.amount, autoLiqThreshold)) return { ...base, eligible: false, reason: 'dead' };
if (!acc.active) return { ...base, eligible: false, reason: 'inactive (DLL)' };
if (client.positions[acc.id]) return { ...base, eligible: false, reason: 'already in trade' };
// Another batch is placing this account's order right now; its position
// won't show until the fill comes back, so the check above can't see it.
if (runtime().accountsInFlight.has(acc.id)) return { ...base, eligible: false, reason: 'order in flight' };
// History this client hasn't loaded (a restart, a rebuild, a wipe, a new
// account) or that a payout has overtaken would plan the account as a
// fresh stage-1 first day: an oversized leg, or a re-opened finished cycle.
if (!client.historyReady(acc.id)) return { ...base, eligible: false, reason: HISTORY_PENDING };
// Obfuscation stops: a stop-out shows as realized P&L falling short of the
// legs fired today — the day is booked; the make-back ride is tomorrow.
// The plain negative-realized check is what survives a restart, when the
// in-memory leg counter has forgotten the morning's stop-out.
if (cfg.firstDayStop || cfg.minDayStop) {
if (combined.realizedPnL <= -25) {
return { ...base, eligible: false, reason: 'red day booked — make-back rides tomorrow' };
}
if (legsToday(acc.id, tradeDate) > 0
&& combined.realizedPnL < legSumToday(acc.id, tradeDate) - 25) {
return { ...base, eligible: false, reason: 'stopped out — day booked' };
}
}
const priorProfit = client.priorProfit?.[acc.id] ?? 0;
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
const effective = resolveEffectiveConfig(
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, priorProfit, allFundTxns,
cfg.minDayPnL
);
const equityProfit = cash.amount - cfg.accountSize;
const plan = computeDayPlan(
acc.id, combined.tradeDate, effective.profitTarget, effective.consistency,
dailyPnL, effective.minDayPnL, effective.minTradingDays, equityProfit, combined.realizedPnL,
cfg.splitEnabled, effective.drain
);
if (!plan) return { ...base, eligible: false, reason: 'invalid balance' };
const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0);
const isExtraDay = !effective.drain
&& effective.minDayPnL <= 0
&& effective.minTradingDays > daysTraded
&& totalProfit >= effective.profitTarget;
const evaluated = { plan, isExtraDay, drain: effective.drain, cash, dailyPnL, daysTraded };
if (isExtraDay || !plan.splitAllowed) {
// Legacy one-and-done gate: any realized PnL today means this account is
// finished — for a combining account, "today" spans the holiday stretch.
if (combined.realizedPnL !== 0) {
return { ...evaluated, eligible: false, reason: `already traded today (realizedPnL=${combined.realizedPnL})` };
}
if (!isExtraDay && plan.remainingToday <= 0) {
const reason = effective.drain && plan.dayTarget === 0
? 'drain days booked — waiting for withdrawal'
: `target=$${plan.dayTarget} (challenge complete)`;
return { ...evaluated, eligible: false, reason };
}
} else {
// Split-leg mode: keep trading until the day's remaining target is used up.
// The epsilon only applies once a leg has completed, so tiny day-start targets still trade.
const doneThreshold = combined.realizedPnL === 0 ? 0 : LEG_COMPLETION_EPSILON;
if (plan.remainingToday <= doneThreshold) {
const reason = combined.realizedPnL === 0
? `target=$${plan.dayTarget} (challenge complete)`
: `day done ($${plan.remainingToday} left of $${plan.dayTarget})`;
return { ...evaluated, eligible: false, reason };
}
if (legsToday(acc.id, tradeDate) >= MAX_LEGS_PER_DAY) {
return { ...evaluated, eligible: false, reason: `leg cap reached (${MAX_LEGS_PER_DAY} today)` };
}
}
return { ...evaluated, eligible: true, reason: '' };
}
// ── helpers ───────────────────────────────────────────────────────────────────
// Random 1–20 minute delay added to the session open, picked once per Central
// calendar day. Re-rolling on every check would move the open boundary between
// polls, letting a trade slip through a moment the next check would reject.
// Held in runtime() so every bundle of this module agrees on the day's roll.
function openJitterMinutes(centralDay: string): number {
const sessionOpenJitter = runtime().sessionOpenJitter;
let jitter = sessionOpenJitter.get(centralDay);
if (jitter === undefined) {
jitter = 1 + Math.floor(Math.random() * 20); // 1–20 inclusive
sessionOpenJitter.clear(); // only today's entry is ever needed
sessionOpenJitter.set(centralDay, jitter);
console.log(`[trading-hours] session open delayed ${jitter} min today`);
}
return jitter;
}
/** Today's session-open delay in minutes, for reporting to the master. */
export function todaysSessionOpenDelay(): number {
const centralDay = new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/Chicago',
}).format(new Date());
return openJitterMinutes(centralDay);
}
/**
* Returns true when trading is not allowed based on the trading_hours setting.
*
* "full_cme" — Sun 5:05 PM – Fri 2:55 PM Central (5 min buffer on each side)
* "equity_hours" — 8:35 AM – 2:55 PM Central, Mon–Fri (5 min buffer on each side)
*
* The open side of each window is pushed back by a random 1–20 minutes per day
* (see openJitterMinutes) so sessions never start at the exact same time.
*/
function isInNoTradeWindow(): boolean {
const now = new Date();
const centralParts = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: 'numeric',
hour12: false,
timeZone: 'America/Chicago',
}).formatToParts(now);
const hour = parseInt(centralParts.find((p) => p.type === 'hour')!.value, 10);
const minute = parseInt(centralParts.find((p) => p.type === 'minute')!.value, 10);
const day = new Intl.DateTimeFormat('en-US', {
weekday: 'short',
timeZone: 'America/Chicago',
}).format(now); // "Sun", "Mon", ... "Sat"
const timeMinutes = hour * 60 + minute; // minutes since midnight
const jitter = todaysSessionOpenDelay();
const mode = getSetting('trading_hours') ?? 'full_cme';
if (mode === 'equity_hours') {
// Equity hours: 8:30 AM – 3:00 PM Central with 5 min buffer = 8:35 AM – 2:55 PM
// Weekdays only
if (day === 'Sat' || day === 'Sun') return true;
const open = 8 * 60 + 35 + jitter; // 8:35 AM + daily jitter
const close = 14 * 60 + 55; // 2:55 PM
return timeMinutes < open || timeMinutes >= close;
}
// Full CME: Sun 5:00 PM – Fri 4:00 PM Central with 5 min buffer
// Open: 5:05 PM, Close: 2:55 PM (stop early), Daily halt: 2:55 PM – 5:05 PM
// Saturday — market closed all day
if (day === 'Sat') return true;
// Sunday — market opens at 5:05 PM Central (+ daily jitter)
const cmeOpen = 17 * 60 + 5 + jitter; // 5:05 PM + daily jitter
if (day === 'Sun') return timeMinutes < cmeOpen;
// Friday — stop trading at 2:55 PM Central
const cmeClose = 14 * 60 + 55; // 2:55 PM
if (day === 'Fri' && timeMinutes >= cmeClose) return true;
// Mon–Thu: block 2:55 PM – 5:05 PM Central (early stop + daily halt + buffer)
if (timeMinutes >= cmeClose && timeMinutes < cmeOpen) return true;
return false;
}
// ── core trade logic ──────────────────────────────────────────────────────────
/** One account's outcome in a batch: its fill summary, or { status: 'error', reason }. */
type LegResult = { status: string; [key: string]: unknown };
type FirmTradeResults = { firm: string; results: LegResult[] };
type TradeOutcome = Extract<TickOutcome, 'closed' | 'positions-open' | 'no-eligible' | 'fired' | 'skipped-in-flight'>;
export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string): Promise<FirmTradeResults[]> {
return (await runTradeBatch(action, symbol)).results;
}
/** runTrade plus what it decided, which the tick records for getSchedulerHealth. */
async function runTradeBatch(
action: 'Buy' | 'Sell' | 'Auto',
symbol: string,
): Promise<{ outcome: TradeOutcome; results: FirmTradeResults[] }> {
const lease = acquireTradeMutex('[auto-trade]');
if (lease === null) return { outcome: 'skipped-in-flight', results: [] };
try {
return await runTradeLocked(action, symbol, lease);
} finally {
releaseTradeMutex(lease);
}
}
async function runTradeLocked(
action: 'Buy' | 'Sell' | 'Auto',
symbol: string,
lease: number,
): Promise<{ outcome: TradeOutcome; results: FirmTradeResults[] }> {
if (isInNoTradeWindow()) {
console.log('[auto-trade] CME market closed — skipping');
return { outcome: 'closed', results: [] };
}
// Block if any account still has an open position (same gate as the scheduler tick).
// Ready clients only, like everything below: a stale snapshot must neither trade nor gate.
const { ready } = firmClients();
const openPositions = countOpenPositions(ready);
if (openPositions > 0) {
console.log(`[auto-trade] ${openPositions} position(s) still open — skipping`);
return { outcome: 'positions-open', results: [] };
}
// Resolve 'Auto' symbol once per batch so all accounts trade the same symbol
let resolvedSymbol = symbol;
if (symbol === 'Auto') {
const enabled = getInstruments().filter((i) => i.enabled).map((i) => i.symbol);
resolvedSymbol = enabled.length > 0 ? enabled[Math.floor(Math.random() * enabled.length)] : 'NQ';
console.log(`[auto-trade] random symbol resolved to: ${resolvedSymbol}`);
}
// Resolve Auto action once per batch so all accounts trade the same direction
const resolvedAction: 'Buy' | 'Sell' = action === 'Auto'
? (Math.random() < 0.5 ? 'Buy' : 'Sell')
: action;
const pointValue = POINT_VALUES[resolvedSymbol];
if (!pointValue) throw new Error(`Unknown symbol: ${resolvedSymbol}`);
const tradeDate = currentTradeDateCT();
const maxConcurrent = maxConcurrentAccounts();
// ── Phase 1: collect ALL eligible accounts across ALL firms in parallel ──
type EligibleItem = {
firmName: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any;
acc: { id: number; name: string; active: boolean };
contract: { name: string; tickSize: number };
cash: { amount: number; realizedPnL: number };
dailyPnL: { date: string; pnl: number }[];
daysTraded: number;
plan: DayPlan;
isExtraDay: boolean;
drain: boolean;
prefix: string;
};
const allEligible: EligibleItem[] = [];
await Promise.all(ready.map(async ({ firm, client }) => {
if (client.accountList.length === 0) return;
// Skip this firm entirely if the symbol is banned for it
if (firm.bannedSymbols.includes(resolvedSymbol)) {
console.log(`[auto-trade] ${firm.name}: ${resolvedSymbol} is banned — skipping firm`);
return;
}
// One firm's trouble (a contract lookup that throws, a malformed
// snapshot) skips that firm, never the batch — a rejection here would
// fail the Promise.all and every other firm would sit the tick out.
try {
const contract = await client.findFrontMonthContract(resolvedSymbol);
if (!contract) return;
const found: EligibleItem[] = [];
for (const acc of client.accountList) {
const ev = evaluateAccountForTrade(client, acc, tradeDate);
if (!ev || !ev.eligible || !ev.plan) continue;
found.push({
firmName: firm.name, client, acc, contract,
cash: ev.cash, dailyPnL: ev.dailyPnL, daysTraded: ev.daysTraded,
plan: ev.plan, isExtraDay: ev.isExtraDay, drain: ev.drain,
prefix: getAccountConfig(acc.id).accountType?.prefix ?? '',
});
}
allEligible.push(...found);
} catch (err) {
console.error(`[auto-trade] ${firm.name}: skipped — ${errorMessage(err)}`);
}
}));
if (allEligible.length === 0) {
console.log('[auto-trade] no eligible accounts found');
return { outcome: 'no-eligible', results: [] };
}
// Collecting took so long that another batch presumed this one wedged and
// took the mutex. That batch traded on fresher numbers than these, so
// firing now could trade an account twice.
if (!holdsTradeMutex(lease)) {
console.warn('[auto-trade] superseded while collecting accounts — abandoning this batch');
return { outcome: 'skipped-in-flight', results: [] };
}
// Take only the first batch — all fired simultaneously, no rolling pool.
// Remaining accounts wait for the next tick (which only fires once all positions are flat).
// Like accounts trade together: a batch is drawn from a single prefix group
// (largest group first), never mixed across prefixes — a group's remainder
// fires as a smaller batch on a later tick instead of being padded from
// another prefix. E.g. 4×FTDFY, 5×MFFUEV, 7×MFFUSF at limit 5 fires as
// batches of 5, 5, 4, 2.
const groups = new Map<string, EligibleItem[]>();
for (const item of allEligible) {
const group = groups.get(item.prefix);
if (group) group.push(item);
else groups.set(item.prefix, [item]);
}
const [batchPrefix, batchGroup] = [...groups.entries()]
.sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]))[0];
const batch = batchGroup.slice(0, maxConcurrent);
console.log(`[auto-trade] ${allEligible.length} eligible account(s) across ${groups.size} prefix group(s) — firing batch of ${batch.length} ${batchPrefix || '(no prefix)'}`);
runtime().lastFireAt = Date.now();
// ── Phase 2: fire the batch simultaneously ──
// Each account is marked in flight before anything is placed and released
// once its orders are done, so no overlapping batch can pick it meanwhile.
const tradeResults = await Promise.allSettled(batch.map((item) => withAccountInFlight(item.acc.id, async () => {
const { client, acc, contract } = item;
const cfg = getAccountConfig(acc.id).accountType!;
// Extra-day mode: profit target already met, no min day P&L, days still needed.
// Just trade 1 MNQ in and out at market immediately — P&L doesn't matter.
if (item.isExtraDay) {
const mnqContract = await client.findFrontMonthContract('MNQ');
if (!mnqContract) throw new Error('MNQ contract not found for extra-day trade');
const fill = await client.sendOrder(acc.id, mnqContract.name, 1, resolvedAction, 'Market');
const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy';
const exitFill = await client.sendOrder(acc.id, mnqContract.name, 1, exitAction, 'Market');
console.log(`[auto-trade] ${acc.name} (${item.firmName}) extra-day: ${resolvedAction} 1xMNQ @ ${fill.price} | exited @ ${exitFill.price} (market)`);
return {
account: acc.name,
firm: item.firmName,
status: 'filled',
contracts: 1,
target: 0,
grossTarget: 0,
totalCommission: 0,
targetPath: 'extra_day',
entryPrice: fill.price,
exitPrice: exitFill.price,
commission: 0,
};
}
// Trade one leg toward today's remaining target. Split-allowed big days carve a
// ≥$500 piece; everything else trades the full remaining amount in one go. The
// carve fraction is seeded per (prefix, date, leg number) so like accounts in a
// batch split their targets the same way, or close to it when remainders differ.
// An account type's min-legs setting forces at least that many carves
// on BIG days only — a day at or below the threshold still trades as
// one leg. Set per prefix, so like accounts still split alike.
const minLegs = item.plan.dayTarget > BIG_DAY_THRESHOLD ? (cfg.minLegs ?? 0) : 0;
const legAmount = item.plan.splitAllowed
? pickLegAmount(
item.plan.remainingToday,
() => groupLegFraction(item.prefix, tradeDate, legsToday(acc.id, tradeDate)),
minLegs - legsToday(acc.id, tradeDate),
)
: item.plan.remainingToday;
// Counted before anything is sent so the runaway guard sees it while
// the batch is in flight; taken back only if the entry never reached
// the broker (here, or in sendEntry).
recordLeg(acc.id, tradeDate, legAmount);
const inst = await legInstrument(client, item.drain, contract, pointValue)
.catch((err: unknown) => { unrecordLeg(acc.id, tradeDate, legAmount); throw err; });
const legContract = inst.contract;
const pv = inst.pointValue;
const rawContracts = Math.max(1, Math.ceil(legAmount / 1000));
const contracts = cfg.maxPositionSize > 0 ? Math.min(rawContracts, cfg.maxPositionSize) : rawContracts;
const fill = await sendEntry('[auto-trade]', client, acc, legContract.name, contracts, resolvedAction, tradeDate, legAmount);
// Wait briefly for the cash balance WebSocket update to reflect entry commission
await new Promise(r => setTimeout(r, 1000));
const updatedCash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
// After entry, realizedPnL drops by the entry commission; the delta from the
// pre-entry value is the entry fee paid (pre-entry may be nonzero mid-day).
// Fall back to $2.50/contract if the WS hasn't updated yet (guarantees at least 1 extra tick).
const entryCommission = entryCommissionFrom(item.cash.realizedPnL, updatedCash.realizedPnL, contracts);
const totalCommission = entryCommission * 2; // entry + exit round-trip
const grossTarget = legAmount + totalCommission;
const targetPoints = grossTarget / (pv * contracts);
const ticks = Math.ceil(targetPoints / legContract.tickSize);
const exitPrice = resolvedAction === 'Buy'
? fill.price + (ticks * legContract.tickSize)
: fill.price - (ticks * legContract.tickSize);
const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy';
const stopDollars = obfuscationStopDollars(cfg, item.dailyPnL, item.plan, tradeDate, legAmount);
let exitOrder: { orderId?: number };
let stopPrice: number | null = null;
if (Number.isFinite(stopDollars) && stopDollars > 0) {
const stopTicks = Math.max(1, Math.ceil(stopDollars / (pv * contracts) / legContract.tickSize));
stopPrice = resolvedAction === 'Buy'
? fill.price - (stopTicks * legContract.tickSize)
: fill.price + (stopTicks * legContract.tickSize);
try {
exitOrder = await client.placeOcoExit(acc.id, legContract.name, contracts, exitAction, exitPrice, stopPrice);
} catch (err) {
// The position is already open — never leave it exit-less when
// the pair never went in (refused, or not sent): fall back to
// the classic lone limit and shout about it. An unknown outcome
// (the ack timed out, the connection went after the send) is
// different: the pair may be working, and a lone limit on top
// doubles the exit — both filling flips the account into an
// opposite position nothing manages. Place nothing more then.
if (!isOrderNotPlaced(err)) {
console.error(`[auto-trade] ${acc.name}: placeoco outcome UNKNOWN (${errorMessage(err)}) — NOT placing a second exit; check this position's exits at the broker`);
throw new Error(`entry filled @ ${fill.price} but the exit's outcome is unknown — ${errorMessage(err)}`);
}
console.error(`[auto-trade] ${acc.name}: placeoco REJECTED (${errorMessage(err)}) — falling back to lone limit exit, NO STOP`);
stopPrice = null;
exitOrder = await client.placeOrderNoWait(acc.id, legContract.name, contracts, exitAction, 'Limit', exitPrice);
}
} else {
exitOrder = await client.placeOrderNoWait(acc.id, legContract.name, contracts, exitAction, 'Limit', exitPrice);
}
console.log(`[auto-trade] ${acc.name} (${item.firmName}) ${resolvedAction} ${contracts}x${item.drain ? legContract.name : resolvedSymbol} @ ${fill.price} | leg $${legAmount} of day $${item.plan.dayTarget} (remaining $${item.plan.remainingToday}) [${item.plan.path}${item.drain ? ' drain' : ''}] (+$${totalCommission.toFixed(2)} comm) | exit @ ${exitPrice}${stopPrice !== null ? ` / stop @ ${stopPrice}` : ''} (orderId=${exitOrder.orderId})`);
return {
account: acc.name,
firm: item.firmName,
status: 'filled',
contracts,
target: legAmount,
dayTarget: item.plan.dayTarget,
remainingBefore: item.plan.remainingToday,
grossTarget,
totalCommission,
targetPath: item.plan.path,
entryPrice: fill.price,
exitPrice,
commission: entryCommission,
};
})));
// Group results by firm for the response
const firmResultsMap = new Map<string, LegResult[]>();
for (let i = 0; i < batch.length; i++) {
const firmName = batch[i].firmName;
if (!firmResultsMap.has(firmName)) firmResultsMap.set(firmName, []);
const r = tradeResults[i];
if (r.status === 'rejected') {
console.error(`[auto-trade] ${batch[i].acc.name} (${firmName}): failed — ${errorMessage(r.reason)}`);
}
firmResultsMap.get(firmName)!.push(
r.status === 'fulfilled'
? r.value
: { status: 'error', reason: errorMessage(r.reason) }
);
}
return {
outcome: 'fired',
results: Array.from(firmResultsMap.entries()).map(([firm, results]) => ({ firm, results })),
};
}
// ── copy trade ───────────────────────────────────────────────────────────────
/**
* Copy the current trade direction to up to maxConcurrent accounts.
* Finds accounts with open positions, determines direction, then fires
* orders for eligible accounts that haven't traded yet.
*/
export async function copyTrade() {
console.log('[copy-trade] called');
// Shares runTrade's mutex: a copy landing mid-batch would see the batch's
// accounts as still flat and could fill past max_concurrent_accounts.
const lease = acquireTradeMutex('[copy-trade]');
if (lease === null) return [];
try {
return await copyTradeLocked(lease);
} finally {
releaseTradeMutex(lease);
}
}
async function copyTradeLocked(lease: number): Promise<unknown[]> {
if (isInNoTradeWindow()) {
console.log('[copy-trade] outside trading hours — skipping');
return [];
}
// Ready clients only, as in runTrade: a stale snapshot must neither be
// copied from, counted against the slots, nor traded.
const { ready, unready } = firmClients();
if (unready.length > 0) {
console.log(`[copy-trade] not synced — leaving out: ${unready.map((u) => u.firm.name).join(', ')}`);
}
const maxConcurrent = maxConcurrentAccounts();
// Find all accounts with open positions to determine direction + symbol
let resolvedAction: 'Buy' | 'Sell' | null = null;
// number on Tradovate, string on TopstepX — only ever compared against
// contracts from the same client, so the union stays coherent.
let positionContractId: number | string | null = null;
let positionedCount = 0;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let positionedClient: any = null;
const positionedPrefixes = new Set<string>();
for (const { client } of ready) {
for (const acc of client.accountList) {
const pos = client.positions[acc.id];
if (!pos) continue;
positionedCount++;
const prefix = getAccountConfig(acc.id).accountType?.prefix;
if (prefix) positionedPrefixes.add(prefix);
if (!resolvedAction) {
resolvedAction = pos.netPos > 0 ? 'Buy' : 'Sell';
positionContractId = pos.contractId;
positionedClient = client;
}
}
}
if (!resolvedAction || positionedCount === 0) {
console.log('[copy-trade] no open positions to copy from');
return [];
}
console.log(`[copy-trade] found ${positionedCount} open position(s), action=${resolvedAction}, contractId=${positionContractId}`);
// Resolve symbol from the positioned contract's contractId — only check the client that
// actually has the position. Iterating every firm × every symbol can take 30+ seconds.
let resolvedSymbol: string | null = null;
const instruments = getInstruments();
const enabledSymbols = instruments.filter(i => i.enabled).map(i => i.symbol);
if (positionedClient) {
for (const sym of enabledSymbols) {
// A lookup that throws rules out that one symbol, not the copy.
try {
const contract = await positionedClient.findFrontMonthContract(sym);
if (contract && contract.id === positionContractId) {
resolvedSymbol = sym;
break;
}
} catch (err) {
console.error(`[copy-trade] ${sym}: contract lookup failed — ${errorMessage(err)}`);
}
}
}
console.log(`[copy-trade] resolved symbol: ${resolvedSymbol ?? '(none, using fallback)'}`);
// Fall back to scheduler symbol or first enabled
if (!resolvedSymbol) {
const state = getState();
resolvedSymbol = state.symbol !== 'Auto' ? state.symbol : (enabledSymbols[0] ?? 'NQ');
}
const pointValue = POINT_VALUES[resolvedSymbol];
if (!pointValue) {
console.log(`[copy-trade] unknown symbol ${resolvedSymbol}`);
return [];
}
const slotsAvailable = maxConcurrent - positionedCount;
if (slotsAvailable <= 0) {
console.log(`[copy-trade] already at max concurrent (${positionedCount}/${maxConcurrent})`);
return [];
}
const tradeDate = currentTradeDateCT();
// Collect eligible accounts (same logic as Phase 1 of runTrade)
type CopyItem = {
firmName: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any;
acc: { id: number; name: string; active: boolean };
contract: { name: string; tickSize: number };
cash: { amount: number; realizedPnL: number };
dailyPnL: { date: string; pnl: number }[];
plan: DayPlan;
drain: boolean;
prefix: string;
};
const eligible: CopyItem[] = [];
await Promise.all(ready.map(async ({ firm, client }) => {
if (client.accountList.length === 0) {
console.log(`[copy-trade] ${firm.name}: empty account list — skip firm`);
return;
}
if (firm.bannedSymbols.includes(resolvedSymbol!)) {
console.log(`[copy-trade] ${firm.name}: ${resolvedSymbol} is banned — skip firm`);
return;
}
// Same isolation as runTrade: one firm's throw skips that firm only.
try {
const contract = await client.findFrontMonthContract(resolvedSymbol!);
if (!contract) {
console.log(`[copy-trade] ${firm.name}: cannot resolve front-month contract for ${resolvedSymbol} — skip firm`);
return;
}
const found: CopyItem[] = [];
for (const acc of client.accountList) {
const ev = evaluateAccountForTrade(client, acc, tradeDate);
if (!ev) { console.log(`[copy-trade] ${acc.name}: no matching config prefix — skip`); continue; }
if (!ev.eligible || !ev.plan) { console.log(`[copy-trade] ${acc.name}: ${ev.reason} — skip`); continue; }
// copyTrade has no extra-day branch — those accounts wait for the scheduler
if (ev.isExtraDay) { console.log(`[copy-trade] ${acc.name}: extra-day account — skip`); continue; }
found.push({
firmName: firm.name, client, acc, contract, cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan,
drain: ev.drain,
prefix: getAccountConfig(acc.id).accountType?.prefix ?? '',
});
}
eligible.push(...found);
} catch (err) {
console.error(`[copy-trade] ${firm.name}: skipped — ${errorMessage(err)}`);
}
}));
// Fill the open slots with accounts whose prefix matches the already-positioned
// accounts before falling back to mismatched prefixes.
const matchesPositioned = (item: CopyItem) => positionedPrefixes.has(item.prefix);
const ordered = [...eligible.filter(matchesPositioned), ...eligible.filter((i) => !matchesPositioned(i))];
const batch = ordered.slice(0, slotsAvailable);
if (batch.length === 0) {
console.log('[copy-trade] no eligible accounts to copy to');
return [];
}
// Superseded while collecting (see runTrade) — this selection is stale.
if (!holdsTradeMutex(lease)) {
console.warn('[copy-trade] superseded while collecting accounts — abandoning this copy');
return [];
}
console.log(`[copy-trade] copying ${resolvedAction} ${resolvedSymbol} to ${batch.length} account(s)`);
runtime().lastFireAt = Date.now();
// Fire orders (same as Phase 2 of runTrade, accounts marked in flight the same way)
const tradeResults = await Promise.allSettled(batch.map((item) => withAccountInFlight(item.acc.id, async () => {
const { client, acc, contract } = item;
const cfg = getAccountConfig(acc.id).accountType!;
const minLegs = item.plan.dayTarget > BIG_DAY_THRESHOLD ? (cfg.minLegs ?? 0) : 0;
const legAmount = item.plan.splitAllowed
? pickLegAmount(
item.plan.remainingToday,
() => groupLegFraction(item.prefix, tradeDate, legsToday(acc.id, tradeDate)),
minLegs - legsToday(acc.id, tradeDate),
)
: item.plan.remainingToday;
// Counted before sending, taken back only if the entry never reached the broker (see runTrade).
recordLeg(acc.id, tradeDate, legAmount);
const inst = await legInstrument(client, item.drain, contract, pointValue)
.catch((err: unknown) => { unrecordLeg(acc.id, tradeDate, legAmount); throw err; });
const legContract = inst.contract;
const pv = inst.pointValue;
const rawContracts = Math.max(1, Math.ceil(legAmount / 1000));
const contracts = cfg.maxPositionSize > 0 ? Math.min(rawContracts, cfg.maxPositionSize) : rawContracts;
const fill = await sendEntry('[copy-trade]', client, acc, legContract.name, contracts, resolvedAction!, tradeDate, legAmount);
await new Promise(r => setTimeout(r, 1000));
const updatedCash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
const entryCommission = entryCommissionFrom(item.cash.realizedPnL, updatedCash.realizedPnL, contracts);
const totalCommission = entryCommission * 2;
const grossTarget = legAmount + totalCommission;
const targetPoints = grossTarget / (pv * contracts);
const ticks = Math.ceil(targetPoints / legContract.tickSize);
const exitPrice = resolvedAction === 'Buy'
? fill.price + (ticks * legContract.tickSize)
: fill.price - (ticks * legContract.tickSize);
const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy';
const stopDollars = obfuscationStopDollars(cfg, item.dailyPnL, item.plan, tradeDate, legAmount);
let exitOrder: { orderId?: number };
let stopPrice: number | null = null;
if (Number.isFinite(stopDollars) && stopDollars > 0) {
const stopTicks = Math.max(1, Math.ceil(stopDollars / (pv * contracts) / legContract.tickSize));
stopPrice = resolvedAction === 'Buy'
? fill.price - (stopTicks * legContract.tickSize)
: fill.price + (stopTicks * legContract.tickSize);
try {
exitOrder = await client.placeOcoExit(acc.id, legContract.name, contracts, exitAction, exitPrice, stopPrice);
} catch (err) {
// Lone-limit fallback only for a pair that never went in (see runTrade).
if (!isOrderNotPlaced(err)) {
console.error(`[copy-trade] ${acc.name}: placeoco outcome UNKNOWN (${errorMessage(err)}) — NOT placing a second exit; check this position's exits at the broker`);
throw new Error(`entry filled @ ${fill.price} but the exit's outcome is unknown — ${errorMessage(err)}`);
}
console.error(`[copy-trade] ${acc.name}: placeoco REJECTED (${errorMessage(err)}) — falling back to lone limit exit, NO STOP`);
stopPrice = null;
exitOrder = await client.placeOrderNoWait(acc.id, legContract.name, contracts, exitAction, 'Limit', exitPrice);
}
} else {
exitOrder = await client.placeOrderNoWait(acc.id, legContract.name, contracts, exitAction, 'Limit', exitPrice);
}
console.log(`[copy-trade] ${acc.name} (${item.firmName}) ${resolvedAction} ${contracts}x${item.drain ? legContract.name : resolvedSymbol} @ ${fill.price} | leg $${legAmount} of day $${item.plan.dayTarget} (remaining $${item.plan.remainingToday}) [${item.plan.path}${item.drain ? ' drain' : ''}] | exit @ ${exitPrice}${stopPrice !== null ? ` / stop @ ${stopPrice}` : ''} (orderId=${exitOrder.orderId})`);
return {
account: acc.name,
firm: item.firmName,
status: 'filled',
contracts,
target: legAmount,
dayTarget: item.plan.dayTarget,
remainingBefore: item.plan.remainingToday,
grossTarget,
totalCommission,
targetPath: item.plan.path,
entryPrice: fill.price,
exitPrice,
commission: entryCommission,
};
})));
const results: unknown[] = [];
for (let i = 0; i < batch.length; i++) {
const r = tradeResults[i];
if (r.status === 'rejected') {
console.error(`[copy-trade] ${batch[i].acc.name} (${batch[i].firmName}): failed — ${errorMessage(r.reason)}`);
}
results.push(
r.status === 'fulfilled'
? r.value
: { status: 'error', reason: errorMessage(r.reason) }
);
}
return results;
}
// ── eligibility check ────────────────────────────────────────────────────────
/** Returns true if any configured account could still trade today (eligible for a next leg / extra-day, or has an open position). */
function hasRemainingConfiguredAccounts(): boolean {
const { ready, unready } = firmClients();
const tradeDate = currentTradeDateCT();
// A client that isn't ready can't say whether its accounts are done — its
// snapshot is missing or stale — so while one might hold any of ours, the
// scheduler stays up for it to rejoin. Only a client that never synced
// and knows no accounts is an unknown in full; one whose (stale) list
// shows none of ours can be ruled out.
//
// Only while the session is open, though. Outside it the unready firm
// couldn't trade anyway, and after the close its day is over. Unbounded,
// one firm that never comes back (a wrong password, a TopstepX key with
// no username) would keep a stop-after-all scheduler up for good, trading
// the ready firms every session after — exactly what the operator opted
// out of. (Before an open with every ready account already done, this
// stops rather than wait on the unready firm: the safe side.)
if (!isInNoTradeWindow()) {
for (const { client } of unready) {
if (client.lastSyncAt === 0 && client.accountList.length === 0) return true;
if (client.accountList.some((acc) => getAccountConfig(acc.id).accountType)) return true;
}
}
for (const { client } of ready) {
if (client.accountList.length === 0) continue;
for (const acc of client.accountList) {
const cfg = getAccountConfig(acc.id).accountType;
if (!cfg) continue; // no config = not our account
// Account with open position = still in play
if (client.positions[acc.id]) return true;
// Order being placed right now = still in play (evaluation calls it ineligible)
if (runtime().accountsInFlight.has(acc.id)) return true;
const ev = evaluateAccountForTrade(client, acc, tradeDate);
if (ev?.eligible) return true;
// Waiting on its history, not done: it may still owe today's trade.
// Only while the session is open, like the unready firms above.
if (ev?.reason === HISTORY_PENDING && !isInNoTradeWindow()) return true;
}
}
return false;
}
// ── scheduler ─────────────────────────────────────────────────────────────────
interface SchedulerState {
action: 'Buy' | 'Sell' | 'Auto';
symbol: string;
intervalId: ReturnType<typeof setInterval> | null;
lastRun: Date | null;
running: boolean;
stopAfterAll: boolean;
}
// Global singleton (survives HMR in dev via module cache)
const _global = globalThis as typeof globalThis & { __autoTrader?: SchedulerState };
function getState(): SchedulerState {
if (!_global.__autoTrader) {
_global.__autoTrader = { action: 'Buy', symbol: 'NQ', intervalId: null, lastRun: null, running: false, stopAfterAll: false };
}
return _global.__autoTrader;
}
export function startScheduler(action: 'Buy' | 'Sell' | 'Auto', symbol: string, stopAfterAll: boolean = false) {
const state = getState();
// The settings reads and writes are the part that can throw, so they go
// before anything in memory changes: a failure then leaves whatever
// schedule was running as it was, never one marked running with its
// interval cleared and nothing scheduled. A non-numeric setting falls back
// to 60s — setInterval takes NaN as 1ms.
const configuredSecs = parseInt(getSetting('tick_interval_seconds') ?? '60', 10);
const intervalSecs = Math.max(5, Number.isFinite(configuredSecs) ? configuredSecs : 60);
// Mirror to the settings table so a restart can pick the schedule back up.
// The interval itself is in-memory only; resumeSchedulerIfPersisted() recreates it.
setSetting('scheduler_running', '1');
setSetting('scheduler_action', action);
setSetting('scheduler_symbol', symbol);
setSetting('scheduler_stop_after_all', stopAfterAll ? '1' : '0');
// Clear any existing interval
if (state.intervalId !== null) {
clearInterval(state.intervalId);
}
state.action = action;
state.symbol = symbol;
state.running = true;
state.stopAfterAll = stopAfterAll;
const tick = async () => {
if (!state.running) return;
state.lastRun = new Date();
const rt = runtime();
const now = Date.now();
rt.lastTickAt = now;
try {
// Hold the tick while any firm's client is still inside its sync
// grace period (positions may not be populated yet); trade around
// firms that have been out past it (see assessReadiness).
const { ready, unready } = firmClients(now);
const { holding, quarantined } = assessReadiness(ready, unready, now);
rt.holding = holding.map((f) => f.name);
rt.quarantined = quarantined.map((q) => q.firm.name);
if (holding.length > 0) {
rt.lastTickOutcome = 'holding-for-sync';
console.log(`[scheduler] waiting for sync: ${rt.holding.join(', ')}`);
return;
}
for (const { firm, unreadyMs } of quarantined) {
if (now - (rt.quarantineLoggedAt.get(firm.id) ?? 0) < QUARANTINE_LOG_EVERY_MS) continue;
rt.quarantineLoggedAt.set(firm.id, now);
console.warn(`[scheduler] ${firm.name} not synced for ${Math.round(unreadyMs / 60_000)}m — trading the other firms without it`);
}
// Skip this tick if any account still has an open position from the
// previous batch — one batch fires only when every (ready) account is flat.
const openPositions = countOpenPositions(ready);
if (openPositions > 0) {
rt.lastTickOutcome = 'positions-open';
console.log(`[scheduler] ${openPositions} position(s) still open — skipping tick`);
return;
}
const { outcome, results } = await runTradeBatch(state.action, state.symbol);
rt.lastTickOutcome = outcome;
const filled = results.flatMap((r) => r.results).filter((r) => r.status === 'filled').length;
if (filled > 0) console.log(`[scheduler] tick: ${filled} account(s) filled`);
// Auto-stop if user opted in and no configured accounts can trade anymore
if (state.stopAfterAll && !hasRemainingConfiguredAccounts()) {
console.log('[scheduler] all configured accounts done for today — stopping');
stopScheduler();
}
} catch (err) {
rt.lastTickOutcome = `error: ${errorMessage(err)}`;
console.error(`[scheduler] tick error: ${errorMessage(err)}`);
}
};
state.intervalId = setInterval(tick, intervalSecs * 1_000);
console.log(`[scheduler] started — ${action} ${symbol} every ${intervalSecs}s`);
}
export function stopScheduler() {
const state = getState();
if (state.intervalId !== null) {
clearInterval(state.intervalId);
state.intervalId = null;
}
state.running = false;
// Nothing watches readiness while stopped, so the grace clocks would be
// stale by the next start — that start gives every firm a fresh grace.
// Reset before the settings write, which can throw.
const rt = runtime();
rt.unreadySince.clear();
rt.quarantineLoggedAt.clear();
rt.holding = [];
rt.quarantined = [];
rt.lastTickOutcome = 'not-running';
setSetting('scheduler_running', '0');
console.log('[scheduler] stopped');
}
/**
* Restart the schedule that was running before the process went down.
*
* No sync-wait here on purpose: tick() already holds while any client isn't
* ready (for up to UNREADY_GRACE_MS each), and again while any account holds
* an open position. So the worst case is a few logged no-op ticks until the
* clients finish syncing.
*/
export function resumeSchedulerIfPersisted(): void {
if (getSetting('scheduler_running') !== '1') return;
const action = (getSetting('scheduler_action') ?? 'Buy') as 'Buy' | 'Sell' | 'Auto';
const symbol = getSetting('scheduler_symbol') ?? 'NQ';
const stopAfterAll = getSetting('scheduler_stop_after_all') === '1';
console.log(`[scheduler] resuming persisted schedule — ${action} ${symbol}`);
startScheduler(action, symbol, stopAfterAll);
}
export function getSchedulerStatus() {
const state = getState();
return {
running: state.running,
action: state.action,
symbol: state.symbol,
lastRun: state.lastRun,
intervalSeconds: parseInt(getSetting('tick_interval_seconds') ?? '60', 10),
stopAfterAll: state.stopAfterAll,
sessionOpenDelayMin: todaysSessionOpenDelay(),
};
}
/**
* Why the scheduler is or isn't trading, for health checks — separate from
* getSchedulerStatus, whose shape is what the master receives. `holding` are
* the firms the last tick waited on (inside their sync grace period);
* `quarantined` the ones past it, traded around. Timestamps are epoch ms,
* 0 = never; lastFireAt counts batches from any caller, not just the tick.
* Until the first tick after a start, the outcome still reads 'not-running'.
*/
export function getSchedulerHealth(): {
lastTickAt: number;
lastTickOutcome: TickOutcome;
lastFireAt: number;
holding: string[];
quarantined: string[];
tradeInFlightSince: number;
} {
const running = getState().running;
const rt = runtime();
return {
lastTickAt: rt.lastTickAt,
lastTickOutcome: running ? rt.lastTickOutcome : 'not-running',
lastFireAt: rt.lastFireAt,
holding: running ? [...rt.holding] : [],
quarantined: running ? [...rt.quarantined] : [],
tradeInFlightSince: rt.tradeInFlightSince,
};
}