Files
autofirmer-expanded/lib/auto-trade.ts
T

1714 lines
82 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 { getCachedFirm, getTradableFirms } from './firm-catalog';
import { getAccountConfig } from './account-type-resolver';
import {
combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT,
obfuscationStopDollars, takeProfitPlan, percentFee, dllRoom, dllStopDollars, dllViable, stopTicks, sizeNotional,
DEFAULT_TARGET_HOLD_HOURS,
isNoTradeTime, isPastSessionClose,
BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY, POINT_VALUES,
FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP,
type DayPlan,
} from './trading-logic';
import { getSetting, setSetting } from './db';
import { errorMessage, isClientReady, isOrderNotPlaced, type PlatformClient, type PlatformContract } 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 }>;
/** 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<number, EntryOutcome>;
/** 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>;
/** Lane → when the batch holding that lane's trade mutex took it (absent
* = no batch in flight there). See laneOf. */
tradeInFlight?: Map<string, number>;
/** Accounts whose exit bracket the broker refused while a hard daily loss
* limit was on: the position was flattened and the account sits out the
* trade date rather than bleed a round trip per leg. */
exitRefused?: Map<number, string>;
// What getSchedulerHealth reports.
lastTickAt: number;
lastTickOutcome: TickOutcome;
/** The last tick's outcome per lane. */
laneOutcomes?: Map<string, 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(),
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;
}
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<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 }
// ── lanes ────────────────────────────────────────────────────────────────────
/**
* Which lane a firm trades in. Lanes are independent: each has its own
* readiness hold, trading hours, all-flat gate, trade mutex and batch, so a
* multi-hour crypto position never holds up the futures open and a stuck
* futures position never freezes a 24/7 firm's weekend. Every CME-futures
* firm (Tradovate, TopstepX) shares the one 'futures' lane — exactly how all
* firms traded before lanes existed; each DXTrade firm is a lane of its own.
*/
export function laneOf(firm: { id: string; platform: string }): string {
return firm.platform === 'dxtrade' ? firm.id : FUTURES_LANE;
}
const FUTURES_LANE = 'futures';
function inLane<T extends { firm: TradableFirm }>(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<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 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);
}
/** The instrument a leg actually trades: drain legs go on MNQ (whatever the
* platform maps MNQ to), hardcoded like the extra-day path — tiny targets
* belong on the micro regardless of the batch symbol. */
async function legInstrument(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any,
drain: boolean,
batchContract: LegContract,
batchPointValue: number,
): Promise<{ contract: LegContract; pointValue: number }> {
if (!drain) return { contract: batchContract, pointValue: batchPointValue };
const mnq: LegContract | null = await client.findFrontMonthContract('MNQ');
if (!mnq) throw new Error('MNQ contract not found for drain trade');
return { contract: mnq, pointValue: mnq.pointValue ?? POINT_VALUES['MNQ'] };
}
/** What the fire path needs of a contract — PlatformContract minus the id. */
type LegContract = Omit<PlatformContract, 'id'>;
/**
* 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 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,
cfg: { dailyLossLimit?: number; maxPositionSize: number; firmId: string },
cash: { amount: number; realizedPnL: number },
combinedRealized: number,
remainingToday: number,
singleContract: boolean,
): { room: number } | { reason: string } {
const dll = cfg.dailyLossLimit ?? 0;
if (!(dll > 0)) return { room: Infinity };
const dayStart: number | null | undefined = client.dayStartBalance?.(accountId);
if (dayStart === null) return { reason: DLL_BASE_PENDING };
const room = dllRoom({
dailyLossLimit: dll,
closedBalance: cash.amount,
dayStartBalance: dayStart,
// The broker's day, raw — and on a combining firm the worse of it
// and the combined figure, whichever the firm turns out to use.
realizedToday: Math.min(cash.realizedPnL, 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 };
}
/**
* 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, dllRoom: Infinity };
if (isAccountDead(cash.amount, autoLiqThreshold)) return { ...base, eligible: false, reason: 'dead' };
if (!acc.active) return { ...base, eligible: false, reason: 'inactive (DLL)' };
if (client.positions[acc.id]) return { ...base, eligible: false, reason: 'already in trade' };
// Another batch is placing this account's order right now; its position
// won't show until the fill comes back, so the check above can't see it.
if (runtime().accountsInFlight.has(acc.id)) return { ...base, eligible: false, reason: 'order in flight' };
// History this client hasn't loaded (a restart, a rebuild, a wipe, a new
// account) or that a payout has overtaken would plan the account as a
// fresh stage-1 first day: an oversized leg, or a re-opened finished cycle.
if (!client.historyReady(acc.id)) return { ...base, eligible: false, reason: HISTORY_PENDING };
if (cash.pendingRoll) return { ...base, eligible: false, reason: ROLL_PENDING };
if (unknownEntryHolds(client, acc.id, tradeDate)) return { ...base, eligible: false, reason: ENTRY_UNKNOWN };
if (runtime().exitRefused?.get(acc.id) === tradeDate) {
return { ...base, eligible: false, reason: 'exit bracket refused — flattened, benched for the day' };
}
// Obfuscation stops: a stop-out shows as realized P&L falling short of the
// legs fired today — the day is booked; the make-back ride is tomorrow.
// 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, dllRoom: Infinity };
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, 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 ───────────────────────────────────────────────────────────────────
// 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);
}
/** 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 1–20 minute jitter (openJitterMinutes), so
* sessions never start at the exact same time. Every-day firms ignore the
* setting and only stand down around the daily roll.
*/
function isInNoTradeWindow(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<TickOutcome, 'closed' | 'positions-open' | 'no-eligible' | 'fired' | 'skipped-in-flight'>;
/** One batch in every lane — a master "trade" command, POST /api/trade. */
export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string): Promise<FirmTradeResults[]> {
const results: FirmTradeResults[] = [];
for (const lane of tradableLanes()) {
results.push(...(await runTradeBatch(action, symbol, lane)).results);
}
return results;
}
/** One lane's batch plus what it decided, which the tick records for getSchedulerHealth. */
async function runTradeBatch(
action: 'Buy' | 'Sell' | 'Auto',
symbol: string,
lane: string,
): Promise<{ outcome: TradeOutcome; results: FirmTradeResults[] }> {
const lease = acquireTradeMutex('[auto-trade]', lane);
if (lease === null) return { outcome: 'skipped-in-flight', results: [] };
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;
}
async function runTradeLocked(
action: 'Buy' | 'Sell' | 'Auto',
symbol: string,
lease: number,
lane: string,
): Promise<{ outcome: TradeOutcome; results: FirmTradeResults[] }> {
const laneFirms = getTradableFirms().filter((f) => laneOf(f) === lane);
if (laneFirms.every((f) => isInNoTradeWindow(f))) {
console.log(`[auto-trade] ${laneName(lane)}: market closed — skipping`);
return { outcome: 'closed', results: [] };
}
// Block if any account 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: [] };
}
const open = laneReady.filter(({ firm }) => !isInNoTradeWindow(firm));
// Resolve 'Auto' symbol once per batch so all accounts trade the same symbol
let resolvedSymbol = symbol;
if (symbol === 'Auto') {
const enabled = getInstruments().filter((i) => i.enabled).map((i) => i.symbol);
resolvedSymbol = enabled.length > 0 ? enabled[Math.floor(Math.random() * enabled.length)] : 'NQ';
console.log(`[auto-trade] ${laneName(lane)}: random symbol resolved to: ${resolvedSymbol}`);
}
// Resolve Auto action once per batch so all accounts trade the same direction
const resolvedAction: 'Buy' | 'Sell' = action === 'Auto'
? (Math.random() < 0.5 ? 'Buy' : 'Sell')
: action;
// The CME product's point value. A platform that maps the root to its own
// instrument (DXTrade) supplies the contract's point value instead.
const basePointValue: number | undefined = POINT_VALUES[resolvedSymbol];
const tradeDate = currentTradeDateCT();
const maxConcurrent = maxConcurrentAccounts();
// ── Phase 1: collect ALL eligible accounts across the lane's firms in parallel ──
type EligibleItem = LegItem & { daysTraded: number; isExtraDay: boolean };
const allEligible: EligibleItem[] = [];
await Promise.all(open.map(async ({ firm, client }) => {
if (client.accountList.length === 0) return;
// Skip this firm entirely if the symbol is banned for it
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 pv = contract.pointValue ?? basePointValue;
if (!pv) {
console.log(`[auto-trade] ${firm.name}: no point value for ${resolvedSymbol} — skipping firm`);
return;
}
const found: EligibleItem[] = [];
for (const acc of client.accountList) {
const ev = evaluateAccountForTrade(client, acc, tradeDate);
if (!ev || !ev.eligible || !ev.plan) continue;
found.push({
firmName: firm.name, firm, client, acc, contract, pv,
cash: ev.cash, dailyPnL: ev.dailyPnL, daysTraded: ev.daysTraded,
plan: ev.plan, isExtraDay: ev.isExtraDay, drain: ev.drain,
prefix: getAccountConfig(acc.id).accountType?.prefix ?? '',
dllRoom: ev.dllRoom,
});
}
allEligible.push(...found);
} catch (err) {
console.error(`[auto-trade] ${firm.name}: skipped — ${errorMessage(err)}`);
}
}));
if (allEligible.length === 0) {
console.log(`[auto-trade] ${laneName(lane)}: no eligible accounts found`);
return { outcome: 'no-eligible', results: [] };
}
// Collecting took so long that another batch presumed this one wedged and
// took the mutex. That batch traded on fresher numbers than these, so
// firing now could trade an account twice.
if (!holdsTradeMutex(lease, lane)) {
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] ${laneName(lane)}: ${allEligible.length} eligible account(s) across ${groups.size} prefix group(s) — firing batch of ${batch.length} ${batchPrefix || '(no prefix)'}`);
runtime().lastFireAt = Date.now();
// ── Phase 2: fire the batch simultaneously ──
// Each account is marked in flight before anything is placed and released
// once its orders are done, so no overlapping batch can pick it meanwhile.
const tradeResults = await Promise.allSettled(batch.map((item) => withAccountInFlight(item.acc.id, async () => {
const { client, acc } = item;
// Extra-day mode: profit target already met, no min day P&L, days still needed.
// Just trade 1 MNQ in and out at market immediately — P&L doesn't matter.
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} 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<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 })),
};
}
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<never> {
try {
const exit = await client.sendOrder(acc.id, contractName, contracts, exitAction, 'Market');
console.warn(`${tag} ${acc.name}: flattened @ ${exit.price} — ${why}`);
} catch (err) {
console.error(`${tag} ${acc.name}: FLATTEN FAILED (${errorMessage(err)}) — the position may be open WITHOUT A STOP; close it at the broker (${why})`);
throw new Error(`entry filled @ ${entryPrice}; ${why}; flatten failed — ${errorMessage(err)}`);
}
throw new Error(`entry filled @ ${entryPrice}; flattened — ${why}`);
}
/**
* Trade one leg toward the account's remaining target for today — shared by
* the batch and copy-trade, so every fee, stop and sizing rule lives once.
*
* Size: futures trade one contract per $1,000 of leg; a platform that prices
* positions (stepNotional) sizes by notional instead — whatever makes the
* leg's expected hold the firm's target, from the instrument's recent
* volatility — under the account type's notional cap.
*
* Fees: a percent-of-notional firm's fee is computed off the fill; otherwise
* it is read off the realized-P&L change a second after it.
*
* Stop: the tighter of the obfuscation stop and the hard daily-loss stop.
* With a daily loss limit, no position is left without one — no room for a
* tick, or a refused bracket, flattens it instead.
*/
async function fireLeg(
tag: string,
item: LegItem,
action: 'Buy' | 'Sell',
symbolLabel: string,
tradeDate: string,
): Promise<LegResult> {
const { client, acc, firm } = item;
const cfg = getAccountConfig(acc.id).accountType!;
// Trade one leg toward today's remaining target. Split-allowed big days carve a
// ≥$500 piece; everything else trades the full remaining amount in one go. The
// carve fraction is seeded per (prefix, date, leg number) so like accounts in a
// batch split their targets the same way, or close to it when remainders differ.
// An account type's min-legs setting forces at least that many carves
// on BIG days only — a day at or below the threshold still trades as
// one leg. Set per prefix, so like accounts still split alike.
const minLegs = item.plan.dayTarget > BIG_DAY_THRESHOLD ? (cfg.minLegs ?? 0) : 0;
const legAmount = item.plan.splitAllowed
? pickLegAmount(
item.plan.remainingToday,
() => groupLegFraction(item.prefix, tradeDate, legsToday(acc.id, tradeDate)),
minLegs - legsToday(acc.id, tradeDate),
)
: item.plan.remainingToday;
// Counted before anything is sent so the runaway guard sees it while
// the batch is in flight; taken back only if the entry never reached
// the broker (here, or in sendEntry).
recordLeg(acc.id, tradeDate, legAmount);
const dll = cfg.dailyLossLimit ?? 0;
const dllOn = Number.isFinite(item.dllRoom) && dll > 0;
const obfuscation = obfuscationStopDollars(cfg, item.dailyPnL, item.plan, tradeDate, legAmount);
// Everything before the entry goes out: a failure here never reached the
// broker, so the leg is taken back.
let legContract: LegContract;
let pv: number;
let pct: number;
let contracts: number;
let sizing: ReturnType<typeof sizeNotional> = null;
try {
const inst = await legInstrument(client, item.drain, item.contract, item.pv);
legContract = inst.contract;
pv = inst.pointValue;
pct = legContract.commissionPct ?? firm.commissionPct ?? 0;
const stepNotional = legContract.stepNotional ?? 0;
if (stepNotional > 0) {
const obfStop = Number.isFinite(obfuscation) && obfuscation > 0 ? obfuscation : Infinity;
const stopDollarsAt = (notional: number) => Math.min(
obfStop,
dllOn ? dllStopDollars({ room: item.dllRoom, dailyLossLimit: dll, entryFee: (pct / 100) * notional, commissionPct: pct }) : Infinity,
);
sizing = sizeNotional({
leg: legAmount,
stopDollarsAt,
volatilityPerHour: legContract.volatilityPerHour,
targetHoldHours: firm.targetHoldHours || DEFAULT_TARGET_HOLD_HOURS,
commissionPct: pct,
spreadFraction: legContract.spreadFraction,
financingPctPerDay: firm.financingPctPerDay ?? 0,
maxNotional: cfg.maxNotional ?? 0,
});
if (!sizing) {
throw new Error(`no position size for ${legContract.name} — its recent volatility is unknown`);
}
contracts = Math.max(1, Math.round(sizing.notional / stepNotional));
const maxNotional = cfg.maxNotional ?? 0;
if (maxNotional > 0) contracts = Math.min(contracts, Math.max(1, Math.floor(maxNotional / stepNotional)));
if ((legContract.maxContracts ?? 0) > 0) contracts = Math.min(contracts, legContract.maxContracts!);
contracts = Math.max(contracts, legContract.minContracts ?? 1);
// The real size is known now: re-check the daily-loss room with it.
if (dllOn) {
const estimate = dllStopDollars({
room: item.dllRoom, dailyLossLimit: dll, entryFee: (pct / 100) * contracts * stepNotional, commissionPct: pct,
});
if (estimate < DLL_MIN_STOP) {
throw new Error(`daily loss limit: $${round2(item.dllRoom)} room leaves a $${round2(estimate)} stop at this size — not trading`);
}
}
} else {
const rawContracts = Math.max(1, Math.ceil(legAmount / 1000));
contracts = cfg.maxPositionSize > 0 ? Math.min(rawContracts, cfg.maxPositionSize) : rawContracts;
}
} catch (err) {
unrecordLeg(acc.id, tradeDate, legAmount);
throw err;
}
const fill = await sendEntry(tag, client, acc, legContract.name, contracts, action, tradeDate, legAmount);
const exitAction: 'Buy' | 'Sell' = action === 'Buy' ? 'Sell' : 'Buy';
// The entry fee. Percent firms: straight off the fill. Otherwise wait
// briefly for the cash balance update: realizedPnL drops by the entry
// commission, and the delta from the pre-entry value is the fee (the
// pre-entry value may be nonzero mid-day). The take-profit falls back to
// $2.50/contract if the update hasn't landed (at least one extra tick).
let entryFee: number;
let measured: number | null = null;
if (pct > 0) {
entryFee = percentFee(pct, fill.price, pv, contracts);
} else {
await new Promise(r => setTimeout(r, 1000));
const updatedCash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
measured = entryCommissionMeasured(item.cash.realizedPnL, updatedCash.realizedPnL, contracts);
entryFee = measured ?? FALLBACK_COMMISSION_PER_CONTRACT * contracts;
}
const tp = takeProfitPlan({
side: action, entry: fill.price, leg: legAmount, pointValue: pv, contracts,
tickSize: legContract.tickSize, commissionPct: pct, entryCommission: entryFee,
});
// The stop. On a measured-fee firm whose fee didn't show, the daily-loss
// stop reserves the worst per-contract fee, not the $2.50 fallback.
const dllFee = pct > 0 ? entryFee : (measured ?? MAX_COMMISSION_PER_CONTRACT * contracts);
const dllDollars = dllOn ? dllStopDollars({ room: item.dllRoom, dailyLossLimit: dll, entryFee: dllFee, commissionPct: pct }) : Infinity;
const ticks = stopTicks({ obfuscationDollars: obfuscation, dllDollars, pointValue: pv, contracts, tickSize: legContract.tickSize });
if (ticks === 0) {
await flattenAndFail(tag, client, acc, legContract.name, contracts, exitAction, fill.price,
`daily-loss room ($${round2(item.dllRoom)}) doesn't cover a one-tick stop`);
}
let exitOrder: { orderId?: number };
let stopPrice: number | null = null;
if (ticks !== null) {
stopPrice = action === 'Buy'
? fill.price - (ticks * legContract.tickSize)
: fill.price + (ticks * legContract.tickSize);
try {
exitOrder = await client.placeOcoExit(acc.id, legContract.name, contracts, exitAction, tp.exitPrice, stopPrice);
} catch (err) {
// An unknown outcome (the ack timed out, the connection went after
// the send) means the pair may be working, and a second exit on top
// doubles it — both filling flips the account into an opposite
// position nothing manages. Place nothing more then.
if (!isOrderNotPlaced(err)) {
console.error(`${tag} ${acc.name}: placeoco outcome UNKNOWN (${errorMessage(err)}) — NOT placing a second exit; check this position's exits at the broker`);
throw new Error(`entry filled @ ${fill.price} but the exit's outcome is unknown — ${errorMessage(err)}`);
}
// The pair never went in. With a hard daily loss limit the position
// must not ride without its stop: flatten and sit out the day.
if (dllOn) {
(runtime().exitRefused ??= new Map()).set(acc.id, tradeDate);
console.error(`${tag} ${acc.name}: placeoco REJECTED (${errorMessage(err)}) with a daily loss limit — flattening`);
return flattenAndFail(tag, client, acc, legContract.name, contracts, exitAction, fill.price,
`exit bracket refused (${errorMessage(err)})`);
}
// No limit: the position is already open — never leave it exit-less.
console.error(`${tag} ${acc.name}: placeoco REJECTED (${errorMessage(err)}) — falling back to lone limit exit, NO STOP`);
stopPrice = null;
exitOrder = await client.placeOrderNoWait(acc.id, legContract.name, contracts, exitAction, 'Limit', tp.exitPrice);
}
} else {
exitOrder = await client.placeOrderNoWait(acc.id, legContract.name, contracts, exitAction, 'Limit', tp.exitPrice);
}
const instrumentLabel = legContract.stepNotional || item.drain ? legContract.name : symbolLabel;
const sizeNote = sizing
? ` | notional $${Math.round(contracts * (legContract.stepNotional ?? 0))} (hold ~${sizing.predictedHoldHours.toFixed(1)}h`
+ `${sizing.winProbability !== null ? `, win ${Math.round(sizing.winProbability * 100)}%` : ''})`
: '';
const stopNote = stopPrice !== null
? ` / stop @ ${stopPrice}`
+ (dllOn ? ` (dll room $${round2(item.dllRoom)} → $${round2(dllDollars)}${Number.isFinite(obfuscation) && obfuscation > 0 ? `, obf $${round2(obfuscation)}` : ''})` : '')
: '';
console.log(`${tag} ${acc.name} (${item.firmName}) ${action} ${contracts}x${instrumentLabel} @ ${fill.price} | leg $${legAmount} of day $${item.plan.dayTarget} (remaining $${item.plan.remainingToday}) [${item.plan.path}${item.drain ? ' drain' : ''}] (+$${tp.totalCommission.toFixed(2)} comm)${sizeNote} | exit @ ${tp.exitPrice}${stopNote} (orderId=${exitOrder.orderId})`);
return {
account: acc.name,
firm: item.firmName,
status: 'filled',
contracts,
target: legAmount,
dayTarget: item.plan.dayTarget,
remainingBefore: item.plan.remainingToday,
grossTarget: tp.grossTarget,
totalCommission: tp.totalCommission,
targetPath: item.plan.path,
entryPrice: fill.price,
exitPrice: tp.exitPrice,
stopPrice,
commission: entryFee,
};
}
// ── copy trade ───────────────────────────────────────────────────────────────
/**
* Copy the current trade direction to up to maxConcurrent accounts.
* Finds accounts with open positions, determines direction, then fires
* orders for eligible accounts that haven't traded yet — 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<unknown[]> {
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<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 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 (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 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,
});
}
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 = 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, 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<LegResult> {
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`);
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, 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 lane = laneOf(firm);
const held = inLane(firmClients().ready, lane).flatMap(({ client: c }) => c.accountList
.map((a) => c.positions[a.id])
.filter((p): p is NonNullable<typeof p> => !!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`);
}
const state = getState();
const symbol = state.symbol !== 'Auto' ? state.symbol : 'NQ';
const resolvedAction: 'Buy' | 'Sell' = heldSide ?? (action === 'Auto' ? (Math.random() < 0.5 ? 'Buy' : 'Sell') : action);
const contract = await client.findFrontMonthContract(symbol);
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}`);
const lease = acquireTradeMutex('[manual-leg]', lane);
if (lease === null) throw new Error('another batch is in flight in this lane');
try {
const item: LegItem = {
firmName: firm.name, firm, client, acc, contract, pv,
cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan, drain: ev.drain,
prefix: cfg.prefix, dllRoom: room,
};
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);
}
}
// ── 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<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 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<string, TickOutcome>();
let filled = 0;
for (const lane of tradableLanes()) {
try {
const laneHolding = holding.filter((f) => laneOf(f) === lane);
if (laneHolding.length > 0) {
outcomes.set(lane, 'holding-for-sync');
console.log(`[scheduler] ${laneName(lane)}: waiting for sync: ${laneHolding.map((f) => f.name).join(', ')}`);
continue;
}
// Skip this lane if any of its accounts still has an open
// position from its previous batch — a batch fires only
// when every (ready) account in the lane is flat.
const openPositions = countOpenPositions(inLane(ready, lane));
if (openPositions > 0) {
outcomes.set(lane, 'positions-open');
console.log(`[scheduler] ${laneName(lane)}: ${openPositions} position(s) still open — skipping tick`);
continue;
}
const { outcome, results } = await runTradeBatch(state.action, state.symbol, lane);
outcomes.set(lane, outcome);
filled += results.flatMap((r) => r.results).filter((r) => r.status === 'filled').length;
} catch (err) {
outcomes.set(lane, `error: ${errorMessage(err)}`);
console.error(`[scheduler] ${laneName(lane)}: tick error: ${errorMessage(err)}`);
}
}
rt.laneOutcomes = outcomes;
rt.lastTickOutcome = overallOutcome([...outcomes.values()]);
if (filled > 0) console.log(`[scheduler] tick: ${filled} account(s) filled`);
// Auto-stop if user opted in and no configured accounts can trade anymore
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') ?? '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. `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<string, TickOutcome>;
} {
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]))
: {},
};
}