Files
autofirmer-expanded/lib/auto-trade.ts
T
Brandon LiandClaude Opus 5.5 29eaf64a6b Vest: a day that lost to the bot's limit stops, even with Vest room left
The daily-loss room on Vest was min(the stage's limit, balance − Vest's
floor), which ignored the day's losses against the bot's own limit. With
the type's $1,229 under Vest's 3% ($1,500), five 50k evals that had
lost $1,111.65 today still saw $388 of room and traded again. The room
is now min(limit + the day's realized loss, balance − floor).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:55:54 -05:00

2298 lines
113 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 { getClients } from './clients';
import { getCachedFirm, getTradableFirms } from './firm-catalog';
import { ensureRulesEvidence, getAccountConfig } from './account-type-resolver';
import {
combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT,
obfuscationStopDollars, takeProfitPlan, onTick, percentFee, dllRoom, dllStopDollars, dllViable, stopTicks,
expectedSwapToWin, planLegSize, maxLossRoom, breachDistance,
isNoTradeTime, isPastSessionClose,
BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY,
FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP,
CME_EVENING_OPEN_MIN, foldReason, withinFirmCaps,
fundRowsKey, markFinishedCycle, unbookedPayoutDrop, machineSide, stageInputsFor, floorRoom,
type DayPlan, type FinishedCycleMark,
} from './trading-logic';
import { getSetting, setSetting, loadAccountMeta, saveAccountMeta } from './db';
import type { LaneTick, TickReason } from './master-protocol';
import { isNotionalPlatform } from './master-protocol';
import { POINT_VALUES, housekeepingRoot, HOUSEKEEPING_ROOT, MAX_HOLD_HOURS, futuresContracts } from './instrument-specs';
import { firmInstruments, drawFuturesInstrument, spreadBlock, type LegShape } from './instruments';
import { recordEntry } from './trade-mix';
import { sizingHoldHours } from './person-size';
import { errorMessage, isClientReady, isOrderNotPlaced, type PlatformClient, type PlatformContract, type PickContext } from './platform-client';
// ── process-wide state ───────────────────────────────────────────────────────
/**
* Everything the batches share, held on globalThis like the scheduler state
* further down. Next can bundle this module more than once — the scheduler
* tick runs from whichever bundle booted the clients, POST /api/trade and
* /api/copy-trade from their own route bundles — and plain module-level state
* would split into one private copy per bundle: a leg recorded by the tick
* would be invisible to a copy-trade, the trade mutex would lock nothing, and
* the session-open jitter could be rolled twice in one day.
*/
interface TradeRuntime {
/** In-memory runaway guard: legs fired per account per trade date, plus
* the sum of their targets — realized P&L falling short of that sum is
* how a stop-out is detected. Resets on restart, which is fine — day
* completion is derived from realized PnL, not from this counter. */
legCounts: Map<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>;
/** The side a lane's batch (or manual leg) chose with nothing held on the
* machine, while it places it: another lane deciding meanwhile takes the
* same side (see sideForEntry). Only counts while its lease holds. */
sideReservation?: { side: 'Buy' | 'Sell'; lane: string; lease: number };
/** Account id → when its last payout claim failed (runClaims backoff). */
claimFailedAt?: Map<number, number>;
/** Accounts whose exit the broker refused — a bracket while a hard daily
* loss limit was on, or a lone take-profit: the position was flattened
* and the account sits out the trade date rather than bleed a round trip
* per leg. */
exitRefused?: Map<number, string>;
// What getSchedulerHealth reports.
lastTickAt: number;
lastTickOutcome: TickOutcome;
/** The last tick's outcome per lane. */
laneOutcomes?: Map<string, TickOutcome>;
/** The last tick per lane as the master sees it: what happened and why
* accounts sat it out (getSchedulerStatus). */
laneTicks?: Map<string, LaneTick>;
lastFireAt: number;
holding: string[];
quarantined: string[];
}
/** What the scheduler's last tick decided (getSchedulerHealth). */
export type TickOutcome =
| 'not-running' | 'holding-for-sync' | 'positions-open' | 'closed'
| 'no-eligible' | 'fired' | 'skipped-in-flight' | `error: ${string}`;
const _tradeGlobal = globalThis as typeof globalThis & { __autoTradeRuntime?: TradeRuntime };
function runtime(): TradeRuntime {
if (!_tradeGlobal.__autoTradeRuntime) {
_tradeGlobal.__autoTradeRuntime = {
legCounts: new Map(),
sessionOpenJitter: new Map(),
accountsInFlight: new Set(),
unreadySince: new Map(),
quarantineLoggedAt: new Map(),
lastTickAt: 0,
lastTickOutcome: 'not-running',
lastFireAt: 0,
holding: [],
quarantined: [],
};
}
return _tradeGlobal.__autoTradeRuntime;
}
/** How long the tick holds every firm for one whose client isn't ready. Long
* enough for a restart or a reconnect to finish syncing; past it the client
* is presumed dead and its firm is quarantined (see assessReadiness). */
const UNREADY_GRACE_MS = 10 * 60 * 1000;
/** How often a quarantined firm is re-announced in the log. */
const QUARANTINE_LOG_EVERY_MS = 5 * 60 * 1000;
/** A batch that has held the trade mutex this long is presumed wedged. */
const TRADE_WEDGED_MS = 10 * 60 * 1000;
// ── helpers ──────────────────────────────────────────────────────────────────
/** How many accounts one batch may fire, never below 1. The settings PATCH
* stores whatever the form sends, and a cleared field arrives as '' —
* parseInt('') is NaN, which slice() takes as 0: every batch would fire
* nobody while the tick reported 'fired'. Non-numeric falls back to the
* default 5, like a missing setting; the form's own floor is 1. */
function maxConcurrentAccounts(): number {
const configured = parseInt(getSetting('max_concurrent_accounts') ?? '5', 10);
return Math.max(1, Number.isFinite(configured) ? configured : 5);
}
function isAccountDead(amount: number, autoLiqThreshold: number): boolean {
// Tradovate floors are positive balances; reconstructed TopstepX MLL
// floors are negative (or +$0.01 once locked at breakeven). 0 = no floor.
return autoLiqThreshold !== 0 && amount <= autoLiqThreshold;
}
function legsToday(accountId: number, tradeDate: string): number {
const entry = runtime().legCounts.get(accountId);
return entry && entry.date === tradeDate ? entry.count : 0;
}
function legSumToday(accountId: number, tradeDate: string): number {
const entry = runtime().legCounts.get(accountId);
return entry && entry.date === tradeDate ? entry.sum : 0;
}
/**
* Why an obfuscation stop has booked the day, or null when it hasn't. A
* stop-out shows as realized P&L falling short of the legs fired today — the
* day is booked; the make-back ride is tomorrow. The plain negative-realized
* check is what survives a restart, when the in-memory leg counter has
* forgotten the morning's stop-out. The dashboard asks too: a booked day has
* no next leg, so no next stop to show.
*/
export function obfuscationDayBooked(
cfg: { firstDayStop: boolean; minDayStop: boolean },
accountId: number,
tradeDate: string,
realizedPnL: number,
): string | null {
if (!cfg.firstDayStop && !cfg.minDayStop) return null;
if (realizedPnL <= -25) return 'red day booked — make-back rides tomorrow';
if (legsToday(accountId, tradeDate) > 0 && realizedPnL < legSumToday(accountId, tradeDate) - 25) {
return 'stopped out — day booked';
}
return null;
}
interface EntryOutcome {
tradeDate: string;
state: 'filled' | 'unknown';
/** When the entry went out, and the realized P&L just before it. */
at: number;
realizedBefore: number;
legAmount: number;
/** When its outcome was declared unknown (the send's error). */
unknownAt?: number;
}
/** An unknown entry counts as never filled only once a good sync that
* STARTED at least this long after it was declared unknown shows the account
* flat, with realized P&L unchanged and no order still working — a market
* order that could fill has done so by then. */
const UNKNOWN_ENTRY_SETTLE_MS = 90_000;
function entryOutcomeToday(accountId: number, tradeDate: string): EntryOutcome | null {
const outcome = runtime().entryOutcomes?.get(accountId);
return outcome && outcome.tradeDate === tradeDate ? outcome : null;
}
/**
* An entry whose outcome is unknown holds the account — it may be in a
* position no exit protects, and a retry would stack another on it — until
* broker state shows it never filled; then the leg is taken back and the
* account is free again. True while it still holds.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function unknownEntryHolds(client: any, accountId: number, tradeDate: string): boolean {
const outcome = entryOutcomeToday(accountId, tradeDate);
if (!outcome || outcome.state !== 'unknown') return false;
const cash = client.accountCashBalances[accountId];
// Nothing is judged before a good sync that started after the window:
// until then a position read can predate the fill (or have failed) while
// the trades read after it already show the P&L it booked.
const syncedFrom = client.lastSyncStartedAt ?? client.lastSyncAt;
if (syncedFrom < (outcome.unknownAt ?? outcome.at) + UNKNOWN_ENTRY_SETTLE_MS || cash === undefined) return true;
if (client.hasWorkingOrders?.(accountId)) return true;
// The broker shows the fill (the position, or the P&L it booked): it
// filled after all. From here it's an ordinary filled entry — the
// position gate holds it while open, one-and-done is done for the day.
if (client.positions[accountId] || Math.abs(cash.realizedPnL - outcome.realizedBefore) >= 0.005) {
outcome.state = 'filled';
console.log(`[auto-trade] account ${accountId}: the entry of unknown outcome filled after all`);
return false;
}
runtime().entryOutcomes?.delete(accountId);
unrecordLeg(accountId, tradeDate, outcome.legAmount);
console.log(`[auto-trade] account ${accountId}: the entry of unknown outcome never filled — free to trade again`);
return false;
}
function recordLeg(accountId: number, tradeDate: string, legAmount: number): void {
runtime().legCounts.set(accountId, {
date: tradeDate,
count: legsToday(accountId, tradeDate) + 1,
sum: Math.round((legSumToday(accountId, tradeDate) + legAmount) * 100) / 100,
});
}
/** Take back a leg recordLeg counted for an entry that never reached the
* broker. Left standing, it would bench a stop-type account for the day —
* realized P&L "falls short" of a leg that never traded — and burn one of
* the day's MAX_LEGS_PER_DAY. */
function unrecordLeg(accountId: number, tradeDate: string, legAmount: number): void {
const legCounts = runtime().legCounts;
const entry = legCounts.get(accountId);
if (!entry || entry.date !== tradeDate || entry.count <= 0) return;
if (entry.count === 1) {
legCounts.delete(accountId);
return;
}
legCounts.set(accountId, {
date: tradeDate,
count: entry.count - 1,
sum: Math.round((entry.sum - legAmount) * 100) / 100,
});
}
/**
* Send a leg's market entry. The leg was recorded before this — the runaway
* guard has to see it while the batch is still in flight — so it is taken
* back here only when the client KNOWS the order never reached the broker
* (OrderNotPlacedError). Anything ambiguous, like an ack or fill timeout,
* keeps the leg: the order may well have filled, and a leg counted that never
* happened only costs a leg, while one forgotten that did can trade the
* account twice.
*/
async function sendEntry(
tag: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any,
acc: { id: number; name: string },
contractName: string,
contracts: number,
action: 'Buy' | 'Sell',
tradeDate: string,
legAmount: number,
) {
const outcomes = (runtime().entryOutcomes ??= new Map());
const at = Date.now();
const realizedBefore = client.accountCashBalances[acc.id]?.realizedPnL ?? 0;
try {
const fill = await client.sendOrder(acc.id, contractName, contracts, action, 'Market');
outcomes.set(acc.id, { tradeDate, state: 'filled', at, realizedBefore, legAmount });
return fill;
} catch (err) {
if (isOrderNotPlaced(err)) {
unrecordLeg(acc.id, tradeDate, legAmount);
console.warn(`${tag} ${acc.name}: entry never reached the broker — leg un-counted`);
} else {
outcomes.set(acc.id, { tradeDate, state: 'unknown', at, realizedBefore, legAmount, unknownAt: Date.now() });
console.warn(`${tag} ${acc.name}: entry outcome unknown — leg kept and the account held until the broker shows it`);
}
throw err;
}
}
/** Run one account's part of a batch with the account marked in flight, so
* no other batch picks it while its orders are still being placed. */
async function withAccountInFlight<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 or Vest firm is a lane of
* its own.
*/
export function laneOf(firm: { id: string; platform: string }): string {
return isNotionalPlatform(firm.platform) ? firm.id : FUTURES_LANE;
}
const FUTURES_LANE = 'futures';
function inLane<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);
const r = runtime().sideReservation;
if (r && r.lane === lane && r.lease === lease) runtime().sideReservation = undefined;
}
/**
* The side a new entry in `lane` must take (machineSide): the side of every
* position on this machine in any lane, else the side another lane's batch is
* placing right now. One person's machine must never be long in one firm and
* short in another, whatever the instruments.
*/
function sideForEntry(lane: string): 'Buy' | 'Sell' | 'mixed' | null {
const net = firmClients().ready.flatMap(({ client }) => client.accountList
.map((a) => (client.positions[a.id]?.netPos ?? 0) as number)
.filter((n) => n !== 0));
const r = runtime().sideReservation;
const reserved = r && r.lane !== lane && holdsTradeMutex(r.lease, r.lane) ? r.side : null;
return machineSide(net, reserved);
}
/** Why `action` can't be entered in `lane` without a hedge, or null. */
function hedgeBlock(side: ReturnType<typeof sideForEntry>, action: 'Buy' | 'Sell' | 'Auto'): string | null {
if (side === 'mixed') return 'this machine holds long and short positions — no side to join until one of them closes';
if (side && action !== 'Auto' && action !== side) {
return `this machine is ${side === 'Buy' ? 'long' : 'short'} elsewhere — a ${action} would hedge it`;
}
return null;
}
/** What a firm's tiny legs (drain, extra-day) trade: a futures firm the
* micro of what it lists (MNQ when NQ is listed, as always — see
* housekeepingRoot); a DXtrade or Vest firm its current pick. */
function housekeepingFor(firm: { platform: string; instruments?: string[]; bannedSymbols?: string[]; symbolMap?: Record<string, { symbol: string }[]> }): string {
if (isNotionalPlatform(firm.platform)) return '*';
return housekeepingRoot(firmInstruments(firm)) ?? HOUSEKEEPING_ROOT;
}
/** The instrument a leg actually trades: drain legs go on the firm's
* housekeeping micro — tiny targets belong on a micro whatever the batch
* symbol. */
async function legInstrument(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any,
firm: TradableFirm,
drain: boolean,
batchContract: LegContract,
batchPointValue: number,
): Promise<{ contract: LegContract; pointValue: number }> {
if (!drain) return { contract: batchContract, pointValue: batchPointValue };
const root = housekeepingFor(firm);
const micro: LegContract | null = await client.findFrontMonthContract(root);
if (!micro) throw new Error(`${root} contract not found for drain trade`);
const pointValue = micro.pointValue ?? POINT_VALUES[root];
if (!pointValue) throw new Error(`no point value for ${root}`);
return { contract: micro, pointValue };
}
/** Which listed root a held contract is — each candidate looked up on the
* client holding it. null when none matches. */
async function heldRoot(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any,
contractId: number | string,
roots: readonly string[],
): Promise<string | null> {
for (const root of roots) {
try {
const contract = await client.findFrontMonthContract(root);
if (contract && String(contract.id) === String(contractId)) return root;
} catch (err) {
console.error(`[auto-trade] ${root}: contract lookup failed — ${errorMessage(err)}`);
}
}
return null;
}
/** A leg's shape for the draw's hold estimate: its dollars, its stop and
* the account type's max contracts. */
function legShapeFor(item: SizingItem, tradeDate: string): LegShape {
const cfg = getAccountConfig(item.acc.id).accountType!;
const leg = legAmountFor(item, tradeDate);
const obf = obfuscationStopDollars(cfg, item.dailyPnL, item.plan, tradeDate, leg);
const dll = item.dll;
const hard = Number.isFinite(item.dllRoom) && dll > 0
? dllStopDollars({ room: item.dllRoom, dailyLossLimit: dll, entryFee: FALLBACK_COMMISSION_PER_CONTRACT, commissionPct: 0 })
: Infinity;
return {
leg,
stopDollars: Math.min(Number.isFinite(obf) && obf > 0 ? obf : Infinity, hard),
maxPositionSize: cfg.maxPositionSize,
limits: item.firm.instrumentLimits,
};
}
/** What the fire path needs of a contract — PlatformContract minus the id. */
type LegContract = Omit<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 limit at the account's current stage (0 = none). */
dll: number;
}
/**
* The hard daily-loss check: the room left today, or why there is none to
* trade with. Room is Infinity when the account type has no limit.
*/
function dailyLossGate(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any,
accountId: number,
/** The limit at the account's current stage (0 = none). */
dll: number,
cfg: { maxPositionSize: number; firmId: string },
cash: { amount: number; realizedPnL: number },
combinedRealized: number,
remainingToday: number,
singleContract: boolean,
): { room: number } | { reason: string } {
if (!(dll > 0)) return { room: Infinity };
// A platform that states its own daily-loss floor (Vest) decides the
// room against it, transfers and all; the rest measure the day's P&L.
const floor: number | null | undefined = client.dailyLossFloor?.(accountId);
if (floor === null) return { reason: DLL_BASE_PENDING };
let room: number;
if (typeof floor === 'number') {
room = floorRoom({
dailyLossLimit: dll, closedBalance: cash.amount, floor,
realizedToday: Math.min(cash.realizedPnL, combinedRealized),
});
} else {
const dayStart: number | null | undefined = client.dayStartBalance?.(accountId);
if (dayStart === null) return { reason: DLL_BASE_PENDING };
room = dllRoom({
dailyLossLimit: dll,
closedBalance: cash.amount,
dayStartBalance: dayStart,
// The broker's day, raw — and on a combining firm the worse of it
// and the combined figure, whichever the firm turns out to use.
realizedToday: Math.min(cash.realizedPnL, combinedRealized),
});
}
// Measured-fee firms: a typical round trip on the contracts this leg
// could use. Percent-fee firms size by notional, which isn't known
// here — fireLeg re-checks with the real size before any entry.
const contractsBound = singleContract ? 1
: Math.min(Math.max(1, Math.ceil(remainingToday / 1000)), cfg.maxPositionSize > 0 ? cfg.maxPositionSize : Infinity);
const percentFees = (getCachedFirm(cfg.firmId)?.commissionPct ?? 0) > 0;
const feeEstimate = percentFees ? 0 : 2 * FALLBACK_COMMISSION_PER_CONTRACT * contractsBound;
if (!dllViable(room, dll, feeEstimate)) {
return { reason: `daily loss limit: $${Math.round(room * 100) / 100} of $${dll} left — day booked` };
}
return { room };
}
const FINISHED_CYCLE_META = 'finished_cycle';
/** Accounts held by finishedCycleDrop and already logged — one line per hold. */
const payoutHoldsLogged = new Set<number>();
/**
* Marks the books while the account's cycle is finished (day target 0, not
* an extra day); once a plan wants to trade again, the dollars day-start
* equity has fallen below that mark with no fund row booked since — null
* when it hasn't (see unbookedPayoutDrop). Kept in account_meta, so a
* restart between the finish and the payout still holds; a wipe clears it.
*
* 2026-10-06: two Topstep Express accounts sat finished, waiting for their
* payouts. When the payouts landed, the plan read them as lost profit and
* fired a full 3×NQ stage-1 leg 28 seconds later, before the withdrawal was
* booked — on the day that should have been a drain day. Both were
* liquidated against the $0 floor the payout left.
*/
function finishedCycleDrop(
acc: { id: number; name: string },
plan: DayPlan,
isExtraDay: boolean,
funds: string,
dayStartEquity: number,
bookedPnL: number,
): number | null {
if (!isExtraDay && plan.dayTarget <= 0) {
const mark = JSON.stringify(markFinishedCycle(funds, dayStartEquity, bookedPnL));
if (loadAccountMeta(acc.id, FINISHED_CYCLE_META) !== mark) saveAccountMeta(acc.id, FINISHED_CYCLE_META, mark);
payoutHoldsLogged.delete(acc.id);
return null;
}
let mark: FinishedCycleMark | null = null;
try {
mark = JSON.parse(loadAccountMeta(acc.id, FINISHED_CYCLE_META) ?? 'null') as FinishedCycleMark | null;
} catch {
mark = null;
}
const drop = unbookedPayoutDrop(mark, funds, dayStartEquity, bookedPnL);
if (drop === null) {
payoutHoldsLogged.delete(acc.id);
} else if (!payoutHoldsLogged.has(acc.id)) {
payoutHoldsLogged.add(acc.id);
console.warn(`[auto-trade] ${acc.name}: balance fell $${drop} below its books after its cycle finished, with no fund transaction booked — held until the payout is booked`);
}
return drop;
}
/**
* Evaluate whether an account can trade right now. Returns null when the account has no
* matching config (not ours). Split-allowed accounts stay eligible mid-day until
* remainingToday drops to LEG_COMPLETION_EPSILON or the leg cap is hit; extra-day and
* strict-consistency (past day 1) accounts keep the legacy one-trade-per-day gate.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function evaluateAccountForTrade(client: any, acc: { id: number; name: string; active: boolean }, tradeDate: string): AccountEvaluation | null {
// Which rule set the account trades on hangs on its rules date: bring it
// down to the broker's evidence before reading the rules, so an account
// whose history landed since the last sync never trades a leg on the
// date this machine first saw it.
ensureRulesEvidence(client, acc.id);
const cfg = getAccountConfig(acc.id).accountType;
if (!cfg) return null;
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0;
// A combining firm records holiday sessions with the next full trading day,
// so the plan and the already-traded gates below must see the whole stretch
// as one day: Sunday's booked P&L folds into Tuesday's realized, and the
// day target stays anchored to the combined (clearing) trade date. `cash`
// itself stays raw — the commission delta in the fire path compares live
// Tradovate values.
const combined = combineCmeSessions(
cfg.combineCmeHolidays, client.dailyPnL[acc.id] ?? [], tradeDate, cash.realizedPnL,
);
const dailyPnL = combined.dailyPnL;
const daysTraded = combined.daysTraded;
const base = { plan: null, isExtraDay: false, drain: false, cash, dailyPnL, daysTraded, dllRoom: Infinity, dll: 0 };
if (isAccountDead(cash.amount, autoLiqThreshold)) return { ...base, eligible: false, reason: 'dead' };
if (!acc.active) return { ...base, eligible: false, reason: 'inactive (DLL)' };
if (client.positions[acc.id]) return { ...base, eligible: false, reason: 'already in trade' };
// Another batch is placing this account's order right now; its position
// won't show until the fill comes back, so the check above can't see it.
if (runtime().accountsInFlight.has(acc.id)) return { ...base, eligible: false, reason: 'order in flight' };
// History this client hasn't loaded (a restart, a rebuild, a wipe, a new
// account) or that a payout has overtaken would plan the account as a
// fresh stage-1 first day: an oversized leg, or a re-opened finished cycle.
if (!client.historyReady(acc.id)) return { ...base, eligible: false, reason: HISTORY_PENDING };
// A balance the books can't explain yet — a payout that platform books
// only later — would plan as lost profit to re-earn.
const unbooked: number | null = client.unbookedBalanceChange?.(acc.id) ?? null;
if (unbooked !== null) {
return { ...base, eligible: false, reason: `balance $${unbooked} off its books — waiting for a sync to book it` };
}
if (cash.pendingRoll) return { ...base, eligible: false, reason: ROLL_PENDING };
if (unknownEntryHolds(client, acc.id, tradeDate)) return { ...base, eligible: false, reason: ENTRY_UNKNOWN };
if (runtime().exitRefused?.get(acc.id) === tradeDate) {
return { ...base, eligible: false, reason: 'exit refused — flattened, benched for the day' };
}
const booked = obfuscationDayBooked(cfg, acc.id, tradeDate, combined.realizedPnL);
if (booked) return { ...base, eligible: false, reason: booked };
const priorProfit = client.priorProfit?.[acc.id] ?? 0;
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
const effective = resolveEffectiveConfig(
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, priorProfit, allFundTxns,
cfg.minDayPnL, stageInputsFor(client, acc.id, cfg),
);
const equityProfit = cash.amount - cfg.accountSize;
const plan = computeDayPlan(
acc.id, combined.tradeDate, effective.profitTarget, effective.consistency,
dailyPnL, effective.minDayPnL, effective.minTradingDays, equityProfit, combined.realizedPnL,
cfg.splitEnabled, effective.drain
);
if (!plan) return { ...base, eligible: false, reason: 'invalid balance' };
const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0);
const isExtraDay = !effective.drain
&& effective.minDayPnL <= 0
&& effective.minTradingDays > daysTraded
&& totalProfit >= effective.profitTarget;
const evaluated = { plan, isExtraDay, drain: effective.drain, cash, dailyPnL, daysTraded, dllRoom: Infinity, dll: effective.dailyLossLimit };
// A finished cycle that a balance drop reopens, with no fill or fund row
// to explain it, is a payout not booked yet: hold it until the booking
// moves the account to its next stage (a drain day, say).
const bookedPnL = priorProfit + dailyPnL
.filter((d) => d.date !== combined.tradeDate)
.reduce((sum, d) => sum + d.pnl, 0);
const payoutDrop = finishedCycleDrop(
acc, plan, isExtraDay, fundRowsKey(allFundTxns), equityProfit - combined.realizedPnL, bookedPnL,
);
if (payoutDrop !== null) {
return { ...evaluated, eligible: false, reason: `balance $${payoutDrop} below its books since the cycle finished — waiting for the payout to be booked` };
}
if (isExtraDay || !plan.splitAllowed) {
// Legacy one-and-done gate: any realized PnL today means this account is
// finished — for a combining account, "today" spans the holiday stretch.
if (combined.realizedPnL !== 0) {
return { ...evaluated, eligible: false, reason: `already traded today (realizedPnL=${combined.realizedPnL})` };
}
// And any entry of today's the broker confirmed filled, whatever its
// realized P&L says (a record replaced under it, a fill not booked
// yet). One of unknown outcome is held above until settled.
if (entryOutcomeToday(acc.id, tradeDate)?.state === 'filled') {
return { ...evaluated, eligible: false, reason: 'already traded today (entry filled)' };
}
if (!isExtraDay && plan.remainingToday <= 0) {
const reason = effective.drain && plan.dayTarget === 0
? 'drain days booked — waiting for withdrawal'
: `target=$${plan.dayTarget} (challenge complete)`;
return { ...evaluated, eligible: false, reason };
}
} else {
// Split-leg mode: keep trading until the day's remaining target is used up.
// The epsilon only applies once a leg has completed, so tiny day-start targets still trade.
const doneThreshold = combined.realizedPnL === 0 ? 0 : LEG_COMPLETION_EPSILON;
if (plan.remainingToday <= doneThreshold) {
const reason = combined.realizedPnL === 0
? `target=$${plan.dayTarget} (challenge complete)`
: `day done ($${plan.remainingToday} left of $${plan.dayTarget})`;
return { ...evaluated, eligible: false, reason };
}
if (legsToday(acc.id, tradeDate) >= MAX_LEGS_PER_DAY) {
return { ...evaluated, eligible: false, reason: `leg cap reached (${MAX_LEGS_PER_DAY} today)` };
}
}
// Hard daily loss limit: checked last, so the more telling reasons above
// (day done, challenge complete, leg cap) win. A daily-loss stop-out
// leaves about the reserve plus fees, which is below what a trade needs —
// so a stop-out benches the account for the rest of the day by itself.
const gate = dailyLossGate(client, acc.id, effective.dailyLossLimit, cfg, cash, combined.realizedPnL, plan.remainingToday, isExtraDay || effective.drain);
if ('reason' in gate) return { ...evaluated, eligible: false, reason: gate.reason };
if (Number.isFinite(gate.room)) return { ...evaluated, eligible: true, reason: '', dllRoom: gate.room };
return { ...evaluated, eligible: true, reason: '' };
}
// ── helpers ───────────────────────────────────────────────────────────────────
/** The longest random delay added to the session open, in minutes. */
const MAX_OPEN_DELAY_MIN = 180;
// Random 0–180 minute delay added to the session open, picked once per Central
// calendar day. Re-rolling on every check would move the open boundary between
// polls, letting a trade slip through a moment the next check would reject.
// Held in runtime() so every bundle of this module agrees on the day's roll,
// and saved as a setting so a restart during the wait (an update, a crash)
// keeps the day's delay instead of rolling a new one.
function openJitterMinutes(centralDay: string): number {
const sessionOpenJitter = runtime().sessionOpenJitter;
let jitter = sessionOpenJitter.get(centralDay);
if (jitter === undefined) {
const [savedDay, savedMin] = (getSetting('session_open_delay') ?? '').split('|');
const saved = savedDay === centralDay ? Number(savedMin) : NaN;
if (Number.isInteger(saved) && saved >= 0 && saved <= MAX_OPEN_DELAY_MIN) {
jitter = saved;
} else {
jitter = Math.floor(Math.random() * (MAX_OPEN_DELAY_MIN + 1)); // 0–180 inclusive
setSetting('session_open_delay', `${centralDay}|${jitter}`);
console.log(`[trading-hours] session open delayed ${jitter} min today`);
}
sessionOpenJitter.clear(); // only today's entry is ever needed
sessionOpenJitter.set(centralDay, jitter);
}
return jitter;
}
/** Today's session-open delay in minutes, for reporting to the master. */
export function todaysSessionOpenDelay(): number {
const centralDay = new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/Chicago',
}).format(new Date());
return openJitterMinutes(centralDay);
}
/** The trade date's session has closed and the next hasn't opened: weekdays
* 2:55–5:00 PM CT for CME firms, the pre-roll guard for an every-day firm.
* The rest of the no-trade window — the roll to the open, and weekends — is
* waiting on a session to come. */
function isAfterSessionClose(firm: { tradesEveryDay?: boolean }, now: Date = new Date()): boolean {
return isPastSessionClose(now, firm.tradesEveryDay === true);
}
/**
* Whether the firm takes no new trades right now (see isNoTradeTime). CME
* firms follow the trading_hours setting — "full_cme" or "equity_hours" — with
* the open pushed back by the day's 0–180 minute delay (openJitterMinutes), so
* sessions never start at the exact same time. Every-day firms ignore the
* setting and only stand down around the daily roll.
*/
function isInNoTradeWindow(firm: { tradesEveryDay?: boolean }, now: Date = new Date()): boolean {
return isNoTradeTime(now, {
everyDay: firm.tradesEveryDay === true,
mode: getSetting('trading_hours') ?? 'full_cme',
jitterMin: todaysSessionOpenDelay(),
});
}
// ── core trade logic ──────────────────────────────────────────────────────────
/** One account's outcome in a batch: its fill summary, or { status: 'error', reason }. */
type LegResult = { status: string; [key: string]: unknown };
type FirmTradeResults = { firm: string; results: LegResult[] };
type TradeOutcome = Extract<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;
}
/** What a lane's batch decided: its outcome, what it traded, and for the
* master a line on what happened and why accounts sat it out. */
type BatchOutcome = { outcome: TradeOutcome; results: FirmTradeResults[]; detail?: string; reasons?: TickReason[] };
/** Accounts that sat a batch out, by reason. Dollar amounts are folded so
* like reasons group ("day done ($12 left of $300)" and "($40 left of $500)"). */
type ReasonTally = Map<string, { count: number; accounts: string[] }>;
function tally(reasons: ReasonTally, reason: string, who: string): void {
const key = foldReason(reason);
const entry = reasons.get(key) ?? { count: 0, accounts: [] };
entry.count++;
if (entry.accounts.length < 3) entry.accounts.push(who);
reasons.set(key, entry);
}
function reasonList(reasons: ReasonTally): TickReason[] {
return [...reasons.entries()]
.map(([reason, { count, accounts }]) => ({ reason, count, accounts }))
.sort((a, b) => b.count - a.count)
.slice(0, 8);
}
const fmtCentralMinutes = (m: number): string => {
const h = Math.floor(m / 60) % 24;
return `${h % 12 || 12}:${String(m % 60).padStart(2, '0')} ${h < 12 ? 'AM' : 'PM'} CT`;
};
/** Why a lane is closed, for the master: when today's session opens. */
function closedDetail(lane: string): string {
if (lane !== FUTURES_LANE) return 'standing down around the daily roll';
const delay = todaysSessionOpenDelay();
const open = getSetting('trading_hours') === 'equity_hours' ? 8 * 60 + 35 + delay : CME_EVENING_OPEN_MIN + delay;
return `outside trading hours — today's open is ${fmtCentralMinutes(open)}`;
}
/** One lane's batch plus what it decided, which the tick records for getSchedulerHealth. */
async function runTradeBatch(
action: 'Buy' | 'Sell' | 'Auto',
symbol: string,
lane: string,
): Promise<BatchOutcome> {
const lease = acquireTradeMutex('[auto-trade]', lane);
if (lease === null) return { outcome: 'skipped-in-flight', results: [], detail: 'another batch is still in flight' };
try {
return await runTradeLocked(action, symbol, lease, lane);
} finally {
releaseTradeMutex(lease, lane);
}
}
/** An eligible account and everything fireLeg needs to trade it. */
interface LegItem {
firmName: string;
firm: TradableFirm;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any;
acc: { id: number; name: string; active: boolean };
contract: LegContract;
/** Dollars per 1.0 of price per contract of the batch contract. */
pv: number;
cash: { amount: number; realizedPnL: number };
dailyPnL: { date: string; pnl: number }[];
plan: DayPlan;
drain: boolean;
prefix: string;
/** Hard daily-loss room (Infinity = no limit). */
dllRoom: number;
/** The hard daily loss limit at the account's current stage (0 = none). */
dll: number;
}
/** What sizing a leg needs before its instrument is known. */
type SizingItem = Omit<LegItem, 'contract' | 'pv'>;
async function runTradeLocked(
action: 'Buy' | 'Sell' | 'Auto',
symbol: string,
lease: number,
lane: string,
): Promise<BatchOutcome> {
const laneFirms = getTradableFirms().filter((f) => laneOf(f) === lane);
if (laneFirms.every((f) => isInNoTradeWindow(f))) {
console.log(`[auto-trade] ${laneName(lane)}: market closed — skipping`);
return { outcome: 'closed', results: [], detail: closedDetail(lane) };
}
// Block if any account in the lane still has an open position (same gate
// as the scheduler tick). Ready clients only, like everything below: a
// stale snapshot must neither trade nor gate.
const laneReady = inLane(firmClients().ready, lane);
const openPositions = countOpenPositions(laneReady);
if (openPositions > 0) {
console.log(`[auto-trade] ${laneName(lane)}: ${openPositions} position(s) still open — skipping`);
return { outcome: 'positions-open', results: [], detail: `${openPositions} position(s) still open` };
}
const open = laneReady.filter(({ firm }) => !isInNoTradeWindow(firm));
// One direction per batch, and per machine: every account trades the side
// of whatever this machine already holds in any lane (another firm, a hand
// trade), so nothing the batch opens hedges the person against themselves.
// Free only when nothing is held or being placed; the choice is then held
// while the batch places it, so a lane deciding meanwhile follows it.
const side = sideForEntry(lane);
const blocked = hedgeBlock(side, action);
if (blocked) {
console.log(`[auto-trade] ${laneName(lane)}: ${blocked} — skipping`);
return { outcome: 'positions-open', results: [], detail: blocked };
}
const resolvedAction: 'Buy' | 'Sell' = side === 'Buy' || side === 'Sell'
? side
: action === 'Auto' ? (Math.random() < 0.5 ? 'Buy' : 'Sell') : action;
if (!side) runtime().sideReservation = { side: resolvedAction, lane, lease };
const futuresLane = lane === FUTURES_LANE;
// An explicit symbol (the scheduler's, or a manual Trade) is the
// operator's pick; 'Auto' is drawn per batch from the batch firm's list.
const explicit = futuresLane && symbol !== 'Auto' ? symbol : null;
const tradeDate = currentTradeDateCT();
const maxConcurrent = maxConcurrentAccounts();
// ── Phase 1: the eligible accounts across the lane's firms ──
// No instrument yet: it's chosen for the batch's firm, from that firm's
// list, once the batch is known.
type Candidate = SizingItem & { daysTraded: number; isExtraDay: boolean };
type EligibleItem = LegItem & { daysTraded: number; isExtraDay: boolean };
const allEligible: Candidate[] = [];
// Why accounts sat this batch out (accounts with no type aren't ours),
// and what stopped the batch itself — for the master.
const reasons: ReasonTally = new Map();
const notes = new Set<string>();
for (const { firm, client } of open) {
if (client.accountList.length === 0) continue;
const listed = firmInstruments(firm);
if (listed.length === 0) {
console.log(`[auto-trade] ${firm.name}: no instruments listed — skipping firm`);
tally(reasons, 'its firm lists no instruments', firm.name);
continue;
}
if (explicit && !listed.includes(explicit)) {
console.log(`[auto-trade] ${firm.name}: ${explicit} isn't on its instrument list — skipping firm`);
tally(reasons, `${explicit} isn't on its firm's list`, firm.name);
continue;
}
// One firm's trouble (a malformed snapshot) skips that firm, never
// the batch.
try {
for (const acc of client.accountList) {
const ev = evaluateAccountForTrade(client, acc, tradeDate);
if (!ev) continue;
if (!ev.eligible || !ev.plan) {
tally(reasons, ev.reason || 'no day plan', acc.name);
continue;
}
allEligible.push({
firmName: firm.name, firm, client, acc,
cash: ev.cash, dailyPnL: ev.dailyPnL, daysTraded: ev.daysTraded,
plan: ev.plan, isExtraDay: ev.isExtraDay, drain: ev.drain,
prefix: getAccountConfig(acc.id).accountType?.prefix ?? '',
dllRoom: ev.dllRoom, dll: ev.dll,
});
}
} catch (err) {
console.error(`[auto-trade] ${firm.name}: skipped — ${errorMessage(err)}`);
tally(reasons, `firm skipped — ${errorMessage(err)}`, firm.name);
}
}
if (allEligible.length === 0) {
console.log(`[auto-trade] ${laneName(lane)}: no eligible accounts found`);
return { outcome: 'no-eligible', results: [], detail: 'no eligible accounts', reasons: reasonList(reasons) };
}
// Take only the first batch — all fired simultaneously, no rolling pool.
// Remaining accounts wait for the next tick (which only fires once all positions are flat).
// Like accounts trade together: a batch is drawn from a single prefix group
// (largest group first), never mixed across prefixes — a group's remainder
// fires as a smaller batch on a later tick instead of being padded from
// another prefix. E.g. 4×FTDFY, 5×MFFUEV, 7×MFFUSF at limit 5 fires as
// batches of 5, 5, 4, 2. A firm's own cap on accounts at once (the
// master's Firms page) trims its share first: 6 Topstep accounts capped
// at 3 fire as 3, 3. A group that can't trade anything right now
// (nothing listed fits, no contract resolves) yields to the next.
const groups = new Map<string, Candidate[]>();
for (const item of allEligible) {
const group = groups.get(item.prefix);
if (group) group.push(item);
else groups.set(item.prefix, [item]);
}
const ordered = [...groups.entries()].sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]));
let batch: EligibleItem[] = [];
let batchPrefix = '';
let resolvedSymbol = '';
for (const [prefix, members] of ordered) {
// The lane is flat (see above), so no firm holds anything yet.
const candidates = withinFirmCaps(members).slice(0, maxConcurrent);
const lead = candidates.find((it) => !it.isExtraDay && !it.drain);
const firms = [...new Map(candidates.map((it) => [it.firm.id, it])).values()];
if (futuresLane) {
// The group's firms must all list the root: their lists' overlap,
// or — if that's empty — the lead firm's accounts alone.
let pool = candidates;
let allowed = firms.map((it) => firmInstruments(it.firm)).reduce((a, b) => a.filter((r) => b.includes(r)));
if (allowed.length === 0) {
const leadFirm = (lead ?? candidates[0]).firm.id;
pool = candidates.filter((it) => it.firm.id === leadFirm);
allowed = firmInstruments(pool[0].firm);
}
const shape = lead ? legShapeFor(lead, tradeDate) : null;
const tried: string[] = [];
for (let attempt = 0; attempt < 3 && batch.length === 0; attempt++) {
const root = explicit ?? await drawFuturesInstrument(allowed.filter((r) => !tried.includes(r)), shape);
if (!root) {
notes.add(`nothing on ${prefix || 'its'} list fits right now (spread or ${MAX_HOLD_HOURS}h hold cap)`);
break;
}
tried.push(root);
// Paying more than the master's limit to cross the book:
// not this tick (the draw left it out already; an explicit
// symbol waits for the spread to come back in).
const wide = spreadBlock(root);
if (wide) {
console.log(`[auto-trade] ${wide} — not trading it this tick`);
notes.add(wide);
if (explicit) break;
continue;
}
const resolved: EligibleItem[] = [];
for (const { firm, client } of [...new Map(pool.map((it) => [it.firm.id, it])).values()]) {
try {
const contract = await client.findFrontMonthContract(root);
const pv = contract?.pointValue ?? POINT_VALUES[root];
if (!contract || !pv) {
console.log(`[auto-trade] ${firm.name}: no ${root} contract — leaving its accounts out`);
notes.add(`${firm.name}: no ${root} contract`);
continue;
}
resolved.push(...pool.filter((it) => it.firm.id === firm.id).map((it) => ({ ...it, contract, pv })));
} catch (err) {
console.error(`[auto-trade] ${firm.name}: ${root} contract lookup failed — ${errorMessage(err)}`);
notes.add(`${firm.name}: ${root} contract lookup failed`);
}
}
if (resolved.length > 0) {
batch = resolved;
resolvedSymbol = root;
}
if (explicit) break;
}
} else {
// A DXtrade lane: one firm, its list ranked for the group's leg.
const { client, firm } = candidates[0];
try {
const contract = await client.findFrontMonthContract('*', pickContextFor(candidates.filter((it) => !it.isExtraDay && !it.drain), tradeDate));
const pv = contract?.pointValue;
if (contract && pv) {
batch = candidates.map((it) => ({ ...it, contract, pv }));
resolvedSymbol = String(contract.id);
} else {
console.log(`[auto-trade] ${firm.name}: nothing on its list can trade right now`);
notes.add(`${firm.name}: nothing on its list can trade right now`);
}
} catch (err) {
console.error(`[auto-trade] ${firm.name}: instrument pick failed — ${errorMessage(err)}`);
notes.add(`${firm.name}: instrument pick failed — ${errorMessage(err)}`);
}
}
if (batch.length > 0) {
batchPrefix = prefix;
break;
}
}
if (batch.length === 0) {
console.log(`[auto-trade] ${laneName(lane)}: nothing tradable for any eligible group`);
return {
outcome: 'no-eligible', results: [],
detail: `${allEligible.length} eligible, nothing tradable${notes.size ? ` — ${[...notes].join('; ')}` : ''}`,
reasons: reasonList(reasons),
};
}
// Choosing took so long that another batch presumed this one wedged and
// took the mutex. That batch traded on fresher numbers than these, so
// firing now could trade an account twice.
if (!holdsTradeMutex(lease, lane)) {
console.warn('[auto-trade] superseded while collecting accounts — abandoning this batch');
return { outcome: 'skipped-in-flight', results: [], detail: 'superseded by another batch while choosing' };
}
console.log(`[auto-trade] ${laneName(lane)}: ${allEligible.length} eligible account(s) across ${groups.size} prefix group(s) — firing batch of ${batch.length} ${batchPrefix || '(no prefix)'} on ${resolvedSymbol}`);
runtime().lastFireAt = Date.now();
// ── Phase 2: fire the batch simultaneously ──
// Each account is marked in flight before anything is placed and released
// once its orders are done, so no overlapping batch can pick it meanwhile.
const tradeResults = await Promise.allSettled(batch.map((item) => withAccountInFlight(item.acc.id, async () => {
const { client, acc } = item;
// Extra-day mode: profit target already met, no min day P&L, days still needed.
// Just trade 1 micro (MNQ for a firm listing NQ) in and out at market
// immediately — P&L doesn't matter.
if (item.isExtraDay) {
const microRoot = housekeepingFor(item.firm);
const wide = spreadBlock(microRoot);
if (wide) throw new Error(`${wide} — no extra-day trade`);
const mnqContract = await client.findFrontMonthContract(microRoot);
if (!mnqContract) throw new Error(`${microRoot} contract not found for extra-day trade`);
const fill = await client.sendOrder(acc.id, mnqContract.name, 1, resolvedAction, 'Market');
recordEntry(item.firmName, microRoot, resolvedAction, 1, mnqContract.id);
const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy';
const exitFill = await client.sendOrder(acc.id, mnqContract.name, 1, exitAction, 'Market');
console.log(`[auto-trade] ${acc.name} (${item.firmName}) extra-day: ${resolvedAction} 1x${mnqContract.name} @ ${fill.price} | exited @ ${exitFill.price} (market)`);
return {
account: acc.name,
firm: item.firmName,
status: 'filled',
contracts: 1,
target: 0,
grossTarget: 0,
totalCommission: 0,
targetPath: 'extra_day',
entryPrice: fill.price,
exitPrice: exitFill.price,
commission: 0,
};
}
return fireLeg('[auto-trade]', item, resolvedAction, resolvedSymbol, tradeDate);
})));
// Group results by firm for the response
const firmResultsMap = new Map<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) }
);
}
const all = [...firmResultsMap.values()].flat();
const filledCount = all.filter((r) => r.status === 'filled').length;
const failed = all.filter((r) => r.status === 'error');
// Accounts left for a later batch aren't benched: the next tick fires them.
for (const item of allEligible) {
if (!batch.some((b) => b.acc.id === item.acc.id)) tally(reasons, 'eligible — waits for a later batch', item.acc.name);
}
return {
outcome: 'fired',
results: Array.from(firmResultsMap.entries()).map(([firm, results]) => ({ firm, results })),
detail: `fired ${batch.length} × ${batchPrefix || '(no prefix)'} on ${resolvedSymbol}: ${filledCount} filled`
+ (failed.length ? `, ${failed.length} failed (${String(failed[0].reason ?? '').slice(0, 120)})` : ''),
reasons: reasonList(reasons),
};
}
const round2 = (n: number) => Math.round(n * 100) / 100;
/**
* Close a just-filled position at market because it must not stay open — its
* daily-loss stop has no room, or the broker refused its exit bracket — and
* report the leg as failed. A flatten that fails too is the worst case: a
* position with no stop, so it is shouted about.
*/
async function flattenAndFail(
tag: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any,
acc: { id: number; name: string },
contractName: string,
contracts: number,
exitAction: 'Buy' | 'Sell',
entryPrice: number,
why: string,
): Promise<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}`);
}
/**
* The take-profit of a leg with no stop. The position is already open, so a
* refusal must not leave it riding with no exit at all — on 2026-10-02 two GC
* shorts did, far past their target. Refused: flatten and bench the account
* for the day, like a refused bracket. Outcome unknown: it may be working, and
* a second exit on top could flip the account, so nothing more is placed.
*/
async function placeLoneExit(
tag: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any,
acc: { id: number; name: string },
contractName: string,
contracts: number,
exitAction: 'Buy' | 'Sell',
exitPrice: number,
entryPrice: number,
tradeDate: string,
): Promise<{ orderId?: number }> {
try {
return await client.placeOrderNoWait(acc.id, contractName, contracts, exitAction, 'Limit', exitPrice);
} catch (err) {
if (!isOrderNotPlaced(err)) {
console.error(`${tag} ${acc.name}: take-profit outcome UNKNOWN (${errorMessage(err)}) — NOT placing a second exit; check this position's exits at the broker`);
throw new Error(`entry filled @ ${entryPrice} but the take-profit's outcome is unknown — ${errorMessage(err)}`);
}
(runtime().exitRefused ??= new Map()).set(acc.id, tradeDate);
console.error(`${tag} ${acc.name}: take-profit @ ${exitPrice} REJECTED (${errorMessage(err)}) — flattening`);
return flattenAndFail(tag, client, acc, contractName, contracts, exitAction, entryPrice,
`take-profit @ ${exitPrice} refused (${errorMessage(err)})`);
}
}
/**
* The leg an account trades next. Split-allowed big days carve a ≥$500
* piece; everything else trades the full remaining amount in one go. The
* carve fraction is seeded per (prefix, date, leg number) so like accounts in
* a batch split their targets the same way, or close to it when remainders
* differ. An account type's min-legs setting forces at least that many
* carves on BIG days only — a day at or below the threshold still trades as
* one leg. Set per prefix, so like accounts still split alike.
*/
function legAmountFor(item: SizingItem, tradeDate: string): number {
const cfg = getAccountConfig(item.acc.id).accountType!;
const minLegs = item.plan.dayTarget > BIG_DAY_THRESHOLD ? (cfg.minLegs ?? 0) : 0;
return item.plan.splitAllowed
? pickLegAmount(
item.plan.remainingToday,
() => groupLegFraction(item.prefix, tradeDate, legsToday(item.acc.id, tradeDate)),
minLegs - legsToday(item.acc.id, tradeDate),
)
: item.plan.remainingToday;
}
/** How this account's leg would size on `contract` (see planLegSize), with
* the account's live funds, its max-drawdown floor and its stops. */
function sizeLeg(item: SizingItem, contract: LegContract, legAmount: number, tradeDate: string) {
const cfg = getAccountConfig(item.acc.id).accountType!;
const live = item.client.accountCashBalances[item.acc.id] ?? item.cash;
const pct = contract.commissionPct ?? item.firm.commissionPct ?? 0;
const floorRoom = maxLossRoom({
balance: live.amount, accountSize: cfg.accountSize, maxLoss: cfg.maxLoss ?? 0,
autoLiqThreshold: item.client.autoLiqThresholds?.[item.acc.id],
});
const obfuscation = obfuscationStopDollars(cfg, item.dailyPnL, item.plan, tradeDate, legAmount);
const plan = planLegSize({
leg: legAmount,
contract,
commissionPct: pct,
targetHoldHours: sizingHoldHours(item.firm, tradeDate),
financingPctPerDay: item.firm.financingPctPerDay ?? 0,
dailyLossLimit: item.dll,
dllRoom: item.dllRoom,
maxLossRoom: floorRoom,
obfuscationDollars: obfuscation,
availableFunds: live.availableFunds ?? live.amount,
});
return { ...plan, pct, obfuscation, maxLossRoom: floorRoom };
}
/**
* The batch's leg, for a platform that chooses between instruments: the
* first account of the firm's largest prefix group — the group the batch
* will fire — sized on each candidate.
*/
function pickContextFor(items: SizingItem[], tradeDate: string): PickContext | undefined {
const groups = new Map<string, SizingItem[]>();
for (const it of items) groups.set(it.prefix, [...(groups.get(it.prefix) ?? []), it]);
const lead = [...groups.values()].sort((a, b) => b.length - a.length)[0]?.[0];
if (!lead) return undefined;
const legAmount = legAmountFor(lead, tradeDate);
return {
targetHoldHours: sizingHoldHours(lead.firm, tradeDate),
evaluate: (contract) => {
const { sizing, cost } = sizeLeg(lead, contract, legAmount, tradeDate);
return sizing && cost !== null ? { notional: sizing.notional, holdHours: sizing.predictedHoldHours, cost } : null;
},
};
}
/**
* Trade one leg toward the account's remaining target for today — shared by
* the batch and copy-trade, so every fee, stop and sizing rule lives once.
*
* Size: futures trade one contract per $1,000 of leg; a platform that prices
* positions (stepNotional) sizes by notional instead — whatever makes the
* leg's expected hold the firm's target as this person sizes it today
* (sizingHoldHours), from the instrument's recent volatility — under the
* account type's notional cap and the instrument's margin rate (the firm's
* leverage).
*
* Fees: a percent-of-notional firm's fee is computed off the fill; otherwise
* it is read off the realized-P&L change a second after it. A firm that
* charges overnight swap has the swap a winning leg is expected to pay added
* to its take-profit.
*
* Stop: the tighter of the obfuscation stop and the hard daily-loss stop.
* With a daily loss limit, no position is left without one — no room for a
* tick, or a refused bracket, flattens it instead.
*/
async function fireLeg(
tag: string,
item: LegItem,
action: 'Buy' | 'Sell',
symbolLabel: string,
tradeDate: string,
): Promise<LegResult> {
const { client, acc } = item;
const cfg = getAccountConfig(acc.id).accountType!;
const legAmount = legAmountFor(item, tradeDate);
// Counted before anything is sent so the runaway guard sees it while
// the batch is in flight; taken back only if the entry never reached
// the broker (here, or in sendEntry).
recordLeg(acc.id, tradeDate, legAmount);
const dll = item.dll;
const dllOn = Number.isFinite(item.dllRoom) && dll > 0;
// Everything before the entry goes out: a failure here never reached the
// broker, so the leg is taken back.
let legContract: LegContract;
let pv: number;
let pct: number;
let contracts: number;
let plan: ReturnType<typeof sizeLeg>;
try {
const inst = await legInstrument(client, item.firm, item.drain, item.contract, item.pv);
legContract = inst.contract;
pv = inst.pointValue;
// A futures entry never pays more than the master's limit to cross
// the book — the last word for every path (batch, copy, manual, drain).
if (!((legContract.stepNotional ?? 0) > 0)) {
const wide = spreadBlock(item.drain ? housekeepingFor(item.firm) : symbolLabel);
if (wide) throw new Error(`${wide} — not entering`);
}
plan = sizeLeg(item, legContract, legAmount, tradeDate);
pct = plan.pct;
const stepNotional = legContract.stepNotional ?? 0;
if (stepNotional > 0) {
if (!plan.sizing) {
throw new Error(`no position size for ${legContract.name} — its recent volatility is unknown`);
}
contracts = Math.max(1, Math.round(plan.sizing.notional / stepNotional));
if (plan.cap > 0) contracts = Math.min(contracts, Math.max(1, Math.floor(plan.cap / stepNotional)));
if ((legContract.maxContracts ?? 0) > 0) contracts = Math.min(contracts, legContract.maxContracts!);
contracts = Math.max(contracts, legContract.minContracts ?? 1);
// The real size is known now: re-check the daily-loss room with it.
if (dllOn && !plan.lastStand) {
const estimate = plan.walkAt(contracts * stepNotional);
if (estimate < DLL_MIN_STOP) {
throw new Error(`daily loss limit: $${round2(item.dllRoom)} room leaves a $${round2(estimate)} stop at this size — not trading`);
}
}
} else {
// One per $1,000, capped by the account type's max contracts and
// the firm's limit on this instrument.
const legRoot = item.drain ? housekeepingFor(item.firm) : symbolLabel;
contracts = futuresContracts(legAmount, cfg.maxPositionSize, item.firm.instrumentLimits?.[legRoot] ?? 0);
}
} catch (err) {
unrecordLeg(acc.id, tradeDate, legAmount);
throw err;
}
const { sizing, obfuscation } = plan;
const fill = await sendEntry(tag, client, acc, legContract.name, contracts, action, tradeDate, legAmount);
// For the master's trade mix: a futures leg is its root (the drain
// micro on a drain day), a DXtrade one its symbol.
const dx = (legContract.stepNotional ?? 0) > 0;
recordEntry(
item.firmName,
dx ? String(legContract.name).split('|')[0] : (item.drain ? housekeepingFor(item.firm) : symbolLabel),
action,
dx ? contracts * (Number(String(legContract.name).split('|')[1]) || 1) : contracts,
(legContract as { id?: unknown }).id,
);
const exitAction: 'Buy' | 'Sell' = action === 'Buy' ? 'Sell' : 'Buy';
// The entry fee. Percent firms: straight off the fill. Otherwise wait
// briefly for the cash balance update: realizedPnL drops by the entry
// commission, and the delta from the pre-entry value is the fee (the
// pre-entry value may be nonzero mid-day). The take-profit falls back to
// $2.50/contract if the update hasn't landed (at least one extra tick).
let entryFee: number;
let measured: number | null = null;
if (pct > 0) {
entryFee = percentFee(pct, fill.price, pv, contracts);
} else {
await new Promise(r => setTimeout(r, 1000));
const updatedCash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
measured = entryCommissionMeasured(item.cash.realizedPnL, updatedCash.realizedPnL, contracts);
entryFee = measured ?? FALLBACK_COMMISSION_PER_CONTRACT * contracts;
}
// The stop. On a measured-fee firm whose fee didn't show, the daily-loss
// stop reserves the worst per-contract fee, not the $2.50 fallback. A
// last stand carries no daily-loss stop: a stop-out would leave the
// account nothing to trade on, so the firm's own breach is the far side.
const dllFee = pct > 0 ? entryFee : (measured ?? MAX_COMMISSION_PER_CONTRACT * contracts);
const notional = fill.price * pv * contracts;
const dllDollars = dllOn && !plan.lastStand
? dllStopDollars({ room: item.dllRoom, dailyLossLimit: dll, entryFee: dllFee, commissionPct: pct, notional })
: Infinity;
const ticks = stopTicks({ obfuscationDollars: obfuscation, dllDollars, pointValue: pv, contracts, tickSize: legContract.tickSize });
if (ticks === 0) {
await flattenAndFail(tag, client, acc, legContract.name, contracts, exitAction, fill.price,
`daily-loss room ($${round2(item.dllRoom)}) doesn't cover a one-tick stop`);
}
// The take-profit clears the leg, both fees and — where the firm charges
// overnight swap — what a winning leg is expected to pay in it.
const orderStop = ticks !== null ? ticks * legContract.tickSize * pv * contracts : Infinity;
const swapReserve = expectedSwapToWin({
distance: legAmount + 2 * entryFee,
stopDollars: plan.lastStand
? Math.min(orderStop, breachDistance({ dllRoom: item.dllRoom, maxLossRoom: plan.maxLossRoom, entryFee }))
: orderStop,
notional,
volatilityPerHour: legContract.volatilityPerHour,
financingPctPerDay: item.firm.financingPctPerDay ?? 0,
});
const tp = takeProfitPlan({
side: action, entry: fill.price, leg: legAmount + swapReserve, pointValue: pv, contracts,
tickSize: legContract.tickSize, commissionPct: pct, entryCommission: entryFee,
});
let exitOrder: { orderId?: number };
let stopPrice: number | null = null;
if (ticks !== null) {
// On the grid toward the entry: the stop's distance is a hard limit.
stopPrice = action === 'Buy'
? onTick(fill.price - (ticks * legContract.tickSize), legContract.tickSize, 'up')
: onTick(fill.price + (ticks * legContract.tickSize), legContract.tickSize, 'down');
try {
exitOrder = await client.placeOcoExit(acc.id, legContract.name, contracts, exitAction, tp.exitPrice, stopPrice);
} catch (err) {
// An unknown outcome (the ack timed out, the connection went after
// the send) means the pair may be working, and a second exit on top
// doubles it — both filling flips the account into an opposite
// position nothing manages. Place nothing more then.
if (!isOrderNotPlaced(err)) {
console.error(`${tag} ${acc.name}: placeoco outcome UNKNOWN (${errorMessage(err)}) — NOT placing a second exit; check this position's exits at the broker`);
throw new Error(`entry filled @ ${fill.price} but the exit's outcome is unknown — ${errorMessage(err)}`);
}
// The pair never went in. With a hard daily loss limit the position
// must not ride without its stop: flatten and sit out the day.
if (dllOn) {
(runtime().exitRefused ??= new Map()).set(acc.id, tradeDate);
console.error(`${tag} ${acc.name}: placeoco REJECTED (${errorMessage(err)}) with a daily loss limit — flattening`);
return flattenAndFail(tag, client, acc, legContract.name, contracts, exitAction, fill.price,
`exit bracket refused (${errorMessage(err)})`);
}
// No limit: the position is already open — never leave it exit-less.
console.error(`${tag} ${acc.name}: placeoco REJECTED (${errorMessage(err)}) — falling back to lone limit exit, NO STOP`);
stopPrice = null;
exitOrder = await placeLoneExit(tag, client, acc, legContract.name, contracts, exitAction, tp.exitPrice, fill.price, tradeDate);
}
} else {
exitOrder = await placeLoneExit(tag, client, acc, legContract.name, contracts, exitAction, tp.exitPrice, fill.price, tradeDate);
}
const instrumentLabel = legContract.stepNotional || item.drain ? legContract.name : symbolLabel;
const sizeNote = sizing
? ` | notional $${Math.round(contracts * (legContract.stepNotional ?? 0))} (hold ~${sizing.predictedHoldHours.toFixed(1)}h`
+ `${sizing.winProbability !== null ? `, win ${Math.round(sizing.winProbability * 100)}%` : ''})`
: '';
const stopNote = (stopPrice !== null
? ` / stop @ ${stopPrice}`
+ (dllOn && !plan.lastStand ? ` (dll room $${round2(item.dllRoom)} → $${round2(dllDollars)}${Number.isFinite(obfuscation) && obfuscation > 0 ? `, obf $${round2(obfuscation)}` : ''})` : '')
: '')
+ (plan.lastStand ? ` | LAST STAND — no daily-loss stop: $${round2(plan.maxLossRoom ?? 0)} to the max-drawdown floor, $${round2(item.dllRoom)} of the day's` : '');
console.log(`${tag} ${acc.name} (${item.firmName}) ${action} ${contracts}x${instrumentLabel} @ ${fill.price} | leg $${legAmount} of day $${item.plan.dayTarget} (remaining $${item.plan.remainingToday}) [${item.plan.path}${item.drain ? ' drain' : ''}] (+$${tp.totalCommission.toFixed(2)} comm${swapReserve > 0 ? `, +$${swapReserve.toFixed(2)} expected swap` : ''})${sizeNote} | exit @ ${tp.exitPrice}${stopNote} (orderId=${exitOrder.orderId})`);
return {
account: acc.name,
firm: item.firmName,
status: 'filled',
contracts,
target: legAmount,
dayTarget: item.plan.dayTarget,
remainingBefore: item.plan.remainingToday,
grossTarget: tp.grossTarget,
totalCommission: tp.totalCommission,
targetPath: item.plan.path,
entryPrice: fill.price,
exitPrice: tp.exitPrice,
stopPrice,
commission: entryFee,
};
}
// ── copy trade ───────────────────────────────────────────────────────────────
/**
* Copy the current trade direction to up to maxConcurrent accounts (and no
* more of a firm's than its cap on accounts at once).
* Finds accounts with open positions, determines direction, then fires
* orders for eligible accounts that haven't traded yet — within the lane of
* the first open position found, never across lanes.
*/
export async function copyTrade() {
console.log('[copy-trade] called');
const positioned = firmClients().ready
.find(({ client }) => client.accountList.some((acc) => !!client.positions[acc.id]));
if (!positioned) {
console.log('[copy-trade] no open positions to copy from');
return [];
}
const lane = laneOf(positioned.firm);
// Shares the batch's mutex: a copy landing mid-batch would see the batch's
// accounts as still flat and could fill past max_concurrent_accounts.
const lease = acquireTradeMutex('[copy-trade]', lane);
if (lease === null) return [];
try {
return await copyTradeLocked(lease, lane);
} finally {
releaseTradeMutex(lease, lane);
}
}
async function copyTradeLocked(lease: number, lane: string): Promise<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>();
// Open positions by firm, against each firm's cap on accounts at once.
const heldByFirm = new Map<string, number>();
for (const { firm, client } of ready) {
for (const acc of client.accountList) {
const pos = client.positions[acc.id];
if (!pos) continue;
positionedCount++;
heldByFirm.set(firm.id, (heldByFirm.get(firm.id) ?? 0) + 1);
const prefix = getAccountConfig(acc.id).accountType?.prefix;
if (prefix) positionedPrefixes.add(prefix);
if (!resolvedAction) {
resolvedAction = pos.netPos > 0 ? 'Buy' : 'Sell';
positionContractId = pos.contractId;
positionedClient = client;
}
}
}
const futuresLane = lane === FUTURES_LANE;
if (!resolvedAction || positionedCount === 0) {
console.log('[copy-trade] no open positions to copy from');
return [];
}
console.log(`[copy-trade] found ${positionedCount} open position(s), action=${resolvedAction}, contractId=${positionContractId}`);
// Which instrument is held — looked up only on the client that holds it
// (iterating every firm × every symbol can take 30+ seconds), among the
// roots the lane's firms list plus their drain micros. A DXtrade client
// answers '*' with the symbol it holds. Unidentified means no copy: a
// guess could put the direction on a different instrument.
let resolvedSymbol: string | null = null;
if (positionedClient && positionContractId !== null) {
const roots = futuresLane
? [...new Set(ready.flatMap(({ firm }) => [...firmInstruments(firm), housekeepingFor(firm)]))]
: ['*'];
resolvedSymbol = await heldRoot(positionedClient, positionContractId, roots);
}
if (!resolvedSymbol) {
console.log(`[copy-trade] can't tell which instrument contract ${positionContractId} is — not copying`);
return [];
}
console.log(`[copy-trade] resolved symbol: ${resolvedSymbol}`);
const basePointValue: number | undefined = POINT_VALUES[resolvedSymbol];
const slotsAvailable = maxConcurrent - positionedCount;
if (slotsAvailable <= 0) {
console.log(`[copy-trade] already at max concurrent (${positionedCount}/${maxConcurrent})`);
return [];
}
const tradeDate = currentTradeDateCT();
// Collect eligible accounts (same logic as Phase 1 of runTrade)
const eligible: LegItem[] = [];
await Promise.all(ready.filter(({ firm }) => !isInNoTradeWindow(firm)).map(async ({ firm, client }) => {
if (client.accountList.length === 0) {
console.log(`[copy-trade] ${firm.name}: empty account list — skip firm`);
return;
}
if (futuresLane && !firmInstruments(firm).includes(resolvedSymbol!)) {
console.log(`[copy-trade] ${firm.name}: ${resolvedSymbol} isn't on its instrument list — skip firm`);
return;
}
// Same isolation as runTrade: one firm's throw skips that firm only.
try {
const contract = await client.findFrontMonthContract(resolvedSymbol!);
if (!contract) {
console.log(`[copy-trade] ${firm.name}: cannot resolve front-month contract for ${resolvedSymbol} — skip firm`);
return;
}
const pv = contract.pointValue ?? basePointValue;
if (!pv) {
console.log(`[copy-trade] ${firm.name}: no point value for ${resolvedSymbol} — skip firm`);
return;
}
const found: LegItem[] = [];
for (const acc of client.accountList) {
const ev = evaluateAccountForTrade(client, acc, tradeDate);
if (!ev) { console.log(`[copy-trade] ${acc.name}: no matching config prefix — skip`); continue; }
if (!ev.eligible || !ev.plan) { console.log(`[copy-trade] ${acc.name}: ${ev.reason} — skip`); continue; }
// copyTrade has no extra-day branch — those accounts wait for the scheduler
if (ev.isExtraDay) { console.log(`[copy-trade] ${acc.name}: extra-day account — skip`); continue; }
found.push({
firmName: firm.name, firm, client, acc, contract, pv, cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan,
drain: ev.drain,
prefix: getAccountConfig(acc.id).accountType?.prefix ?? '',
dllRoom: ev.dllRoom, dll: ev.dll,
});
}
eligible.push(...found);
} catch (err) {
console.error(`[copy-trade] ${firm.name}: skipped — ${errorMessage(err)}`);
}
}));
// Fill the open slots with accounts whose prefix matches the already-positioned
// accounts before falling back to mismatched prefixes.
const matchesPositioned = (item: LegItem) => positionedPrefixes.has(item.prefix);
const ordered = [...eligible.filter(matchesPositioned), ...eligible.filter((i) => !matchesPositioned(i))];
const batch = withinFirmCaps(ordered, heldByFirm).slice(0, slotsAvailable);
if (batch.length === 0) {
console.log('[copy-trade] no eligible accounts to copy to');
return [];
}
// Superseded while collecting (see runTrade) — this selection is stale.
if (!holdsTradeMutex(lease, lane)) {
console.warn('[copy-trade] superseded while collecting accounts — abandoning this copy');
return [];
}
console.log(`[copy-trade] copying ${resolvedAction} ${resolvedSymbol} to ${batch.length} account(s)`);
runtime().lastFireAt = Date.now();
// Fire orders (same as Phase 2 of runTrade, accounts marked in flight the same way)
const tradeResults = await Promise.allSettled(batch.map((item) => withAccountInFlight(
item.acc.id,
() => fireLeg('[copy-trade]', item, resolvedAction!, resolvedSymbol!, tradeDate),
)));
const results: unknown[] = [];
for (let i = 0; i < batch.length; i++) {
const r = tradeResults[i];
if (r.status === 'rejected') {
console.error(`[copy-trade] ${batch[i].acc.name} (${batch[i].firmName}): failed — ${errorMessage(r.reason)}`);
}
results.push(
r.status === 'fulfilled'
? r.value
: { status: 'error', reason: errorMessage(r.reason) }
);
}
return results;
}
// ── manual leg ───────────────────────────────────────────────────────────────
/**
* Fire one leg on one account now, through the same path the scheduler uses
* — sizing, take-profit, the daily-loss stop, the exit bracket — for someone
* testing an account by hand (POST /api/accounts/[id]/fire-leg).
*
* The one rule it overrides is "one trade per day" for unsplit accounts: an
* account that already booked something today may take another leg toward
* what's left of its target. Everything else still applies — flat, history
* loaded, the daily-loss room, the firm's trading hours, no batch in flight
* in its lane — and a lane with positions open is only joined on the same
* instrument and side.
*/
export async function fireManualLeg(accountId: number, action: 'Buy' | 'Sell' | 'Auto'): Promise<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`);
// The lane's lock before anything is read, as a batch takes it: a batch
// that fired between this leg's checks and its entry would leave it
// trading on numbers from before — a second leg, or the other side of
// the position the batch just opened.
const lane = laneOf(firm);
const lease = acquireTradeMutex('[manual-leg]', lane);
if (lease === null) throw new Error('another batch is in flight in this lane');
try {
const tradeDate = currentTradeDateCT();
const ev = evaluateAccountForTrade(client, acc, tradeDate);
if (!ev || !ev.plan) throw new Error(ev?.reason || 'account has no account type');
if (!ev.eligible && !ev.reason.startsWith('already traded today')) throw new Error(ev.reason);
if (ev.isExtraDay) throw new Error('extra-day accounts only take the scheduled in-and-out');
if (ev.plan.remainingToday <= LEG_COMPLETION_EPSILON) throw new Error(`day target met ($${ev.plan.remainingToday} left)`);
const cfg = getAccountConfig(acc.id).accountType!;
// The once-a-day return comes before the daily-loss check: run it here.
let room = ev.dllRoom;
if (!ev.eligible) {
const gate = dailyLossGate(client, acc.id, ev.dll, cfg, ev.cash, ev.cash.realizedPnL, ev.plan.remainingToday, ev.drain);
if ('reason' in gate) throw new Error(gate.reason);
room = gate.room;
}
// A lane with positions open may only be joined on the same instrument
// and side — what a batch of like accounts would have done — never the
// other side (that is hedging).
const held = inLane(firmClients().ready, lane).flatMap(({ client: c }) => c.accountList
.map((a) => c.positions[a.id])
.filter((p): p is NonNullable<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`);
}
// …nor against what the machine holds in any other lane.
const side = sideForEntry(lane);
const blocked = hedgeBlock(side, heldSide ?? action);
if (blocked) throw new Error(blocked);
const resolvedAction: 'Buy' | 'Sell' = heldSide
?? (side === 'Buy' || side === 'Sell' ? side : action === 'Auto' ? (Math.random() < 0.5 ? 'Buy' : 'Sell') : action);
if (!side) runtime().sideReservation = { side: resolvedAction, lane, lease };
const sizingItem: SizingItem = {
firmName: firm.name, firm, client, acc,
cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan, drain: ev.drain,
prefix: cfg.prefix, dllRoom: room, dll: ev.dll,
};
// The instrument: a DXtrade or Vest firm ranks its list; a futures
// firm joins the held root, else trades an explicit scheduler symbol
// it lists, else draws from its list.
let symbol = '*';
if (!isNotionalPlatform(firm.platform)) {
const listed = firmInstruments(firm);
const state = getState();
const found = held.length > 0 ? await heldRoot(client, held[0].contractId, [...listed, housekeepingFor(firm)]) : null;
const drawn = found ?? (state.symbol !== 'Auto' && listed.includes(state.symbol)
? state.symbol
: await drawFuturesInstrument(listed, legShapeFor(sizingItem, tradeDate)));
if (!drawn) throw new Error(held.length > 0 ? `can't tell which listed instrument the lane holds` : 'nothing on the firm\'s list fits right now');
symbol = drawn;
}
const contract = await client.findFrontMonthContract(symbol, ev.drain ? undefined : pickContextFor([sizingItem], tradeDate));
if (!contract) throw new Error(`no instrument for ${symbol}`);
if (held.length > 0 && String(contract.id) !== [...heldContracts][0]) {
throw new Error(`the ${laneName(lane)} lane holds ${[...heldContracts][0]}, not ${contract.id} — not adding to it`);
}
const pv = contract.pointValue ?? POINT_VALUES[symbol];
if (!pv) throw new Error(`no point value for ${symbol}`);
// The lookups above took long enough for a batch to presume this one
// wedged and take the lane: it traded on fresher numbers.
if (!holdsTradeMutex(lease, lane)) throw new Error('superseded by a batch while choosing — not firing');
const item: LegItem = { ...sizingItem, contract, pv };
console.log(`[manual-leg] ${acc.name} (${firm.name}): firing one leg by hand`);
runtime().lastFireAt = Date.now();
return await withAccountInFlight(acc.id, () => fireLeg('[manual-leg]', item, resolvedAction, symbol, tradeDate));
} finally {
releaseTradeMutex(lease, lane);
}
}
// ── payout claims ────────────────────────────────────────────────────────────
/** A failed claim is retried after this long. */
const CLAIM_RETRY_MS = 5 * 60_000;
/** How much an account should claim now, or null: a payoutable account whose
* platform takes payouts by API, flat and settled, that has reached its
* stage's target — all of its profit above the starting balance. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function claimDue(client: any, acc: { id: number; active: boolean }, now: number): number | null {
const cfg = getAccountConfig(acc.id).accountType;
if (!cfg || cfg.payoutable !== true || !acc.active) return null;
if (client.positions[acc.id] || client.hasWorkingOrders?.(acc.id) || runtime().accountsInFlight.has(acc.id)) return null;
if (!client.historyReady?.(acc.id)) return null;
if (now - (runtime().claimFailedAt?.get(acc.id) ?? 0) < CLAIM_RETRY_MS) return null;
const cash = client.accountCashBalances[acc.id];
if (!cash) return null;
const effective = resolveEffectiveConfig(
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages,
client.priorProfit?.[acc.id] ?? 0, client.fundTransactions?.[acc.id] ?? [], cfg.minDayPnL,
stageInputsFor(client, acc.id, cfg),
);
if (effective.drain || !(effective.profitTarget > 0)) return null;
const profit = cash.amount - cfg.accountSize;
return profit >= effective.profitTarget - LEG_COMPLETION_EPSILON ? profit : null;
}
/**
* Claim the profit of every payoutable account in the lane that reached its
* stage's target, where the platform takes payouts by API (Vest's Claim
* Profit). The account then starts its next stage the same day: its leg
* counts and entry outcome for the day are cleared, and a type whose stages
* reset each day trades its next stage on the room the claim freed. Under the
* lane's mutex, so no batch reads an account mid-claim. Off with the
* auto_claim setting at 'off'.
*/
async function runClaims(lane: string, ready: FirmClient[]): Promise<void> {
if (getSetting('auto_claim') === 'off') return;
const items = inLane(ready, lane).filter(({ client }) => typeof (client as { claimProfit?: unknown }).claimProfit === 'function');
if (items.length === 0) return;
const now = Date.now();
const due = items.flatMap(({ firm, client }) => client.accountList.flatMap((acc) => {
const amount = claimDue(client, acc, now);
return amount === null ? [] : [{ firm, client, acc, amount }];
}));
if (due.length === 0) return;
const lease = acquireTradeMutex('[auto-claim]', lane);
if (lease === null) return;
try {
const tradeDate = currentTradeDateCT();
for (const { firm, client, acc, amount } of due) {
if (!holdsTradeMutex(lease, lane)) return;
if (claimDue(client, acc, Date.now()) === null) continue; // settled since
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { claimed, received } = await (client as any).claimProfit(acc.id, amount);
runtime().legCounts.delete(acc.id);
runtime().entryOutcomes?.delete(acc.id);
runtime().claimFailedAt?.delete(acc.id);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const stage = 1 + ((client as any).sameDayClaims?.(acc.id) ?? 0);
console.log(`[auto-claim] ${firm.name} ${acc.name}: claimed $${claimed}${received !== null ? ` (you get $${received})` : ''}`
+ ` — trading stage ${stage} next (${tradeDate})`);
} catch (err) {
(runtime().claimFailedAt ??= new Map()).set(acc.id, Date.now());
console.error(`[auto-claim] ${firm.name} ${acc.name}: claim of $${Math.floor(amount * 100) / 100} failed — ${errorMessage(err)}; retrying in ${CLAIM_RETRY_MS / 60_000}m`);
}
}
} finally {
releaseTradeMutex(lease, lane);
}
}
// ── 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: 'Auto', intervalId: null, lastRun: null, running: false, stopAfterAll: false };
}
return _global.__autoTrader;
}
export function startScheduler(action: 'Buy' | 'Sell' | 'Auto', symbol: string, stopAfterAll: boolean = false) {
const state = getState();
// The settings reads and writes are the part that can throw, so they go
// before anything in memory changes: a failure then leaves whatever
// schedule was running as it was, never one marked running with its
// interval cleared and nothing scheduled. A non-numeric setting falls back
// to 60s — setInterval takes NaN as 1ms.
const configuredSecs = parseInt(getSetting('tick_interval_seconds') ?? '60', 10);
const intervalSecs = Math.max(5, Number.isFinite(configuredSecs) ? configuredSecs : 60);
// Mirror to the settings table so a restart can pick the schedule back up.
// The interval itself is in-memory only; resumeSchedulerIfPersisted() recreates it.
setSetting('scheduler_running', '1');
setSetting('scheduler_action', action);
setSetting('scheduler_symbol', symbol);
setSetting('scheduler_stop_after_all', stopAfterAll ? '1' : '0');
// Clear any existing interval
if (state.intervalId !== null) {
clearInterval(state.intervalId);
}
state.action = action;
state.symbol = symbol;
state.running = true;
state.stopAfterAll = stopAfterAll;
const tick = async () => {
if (!state.running) return;
state.lastRun = new Date();
const rt = runtime();
const now = Date.now();
rt.lastTickAt = now;
try {
// Hold a lane while any of its firms' clients is still inside its
// sync grace period (positions may not be populated yet); trade
// around firms that have been out past it (see assessReadiness).
// Lanes hold, gate and fire independently.
const { ready, unready } = firmClients(now);
const { holding, quarantined } = assessReadiness(ready, unready, now);
rt.holding = holding.map((f) => f.name);
rt.quarantined = quarantined.map((q) => q.firm.name);
for (const { firm, unreadyMs } of quarantined) {
if (now - (rt.quarantineLoggedAt.get(firm.id) ?? 0) < QUARANTINE_LOG_EVERY_MS) continue;
rt.quarantineLoggedAt.set(firm.id, now);
console.warn(`[scheduler] ${firm.name} not synced for ${Math.round(unreadyMs / 60_000)}m — trading the other firms without it`);
}
const outcomes = new Map<string, TickOutcome>();
const ticks = new Map<string, LaneTick>();
const record = (lane: string, outcome: TickOutcome, detail: string, reasons?: TickReason[]) => {
outcomes.set(lane, outcome);
ticks.set(lane, {
lane: laneName(lane),
at: new Date(now).toISOString(),
outcome,
detail: detail.slice(0, 300),
...(reasons && reasons.length > 0 ? { reasons } : {}),
});
};
let filled = 0;
for (const lane of tradableLanes()) {
try {
const laneHolding = holding.filter((f) => laneOf(f) === lane);
if (laneHolding.length > 0) {
const names = laneHolding.map((f) => f.name).join(', ');
record(lane, 'holding-for-sync', `waiting for sync: ${names}`);
console.log(`[scheduler] ${laneName(lane)}: waiting for sync: ${names}`);
continue;
}
// Payouts first, account by account — a claim doesn't wait
// for the rest of the lane to go flat.
await runClaims(lane, ready);
// Skip this lane if any of its accounts still has an open
// position from its previous batch — a batch fires only
// when every (ready) account in the lane is flat.
const openPositions = countOpenPositions(inLane(ready, lane));
if (openPositions > 0) {
record(lane, 'positions-open', `${openPositions} position(s) still open`);
console.log(`[scheduler] ${laneName(lane)}: ${openPositions} position(s) still open — skipping tick`);
continue;
}
const { outcome, results, detail, reasons } = await runTradeBatch(state.action, state.symbol, lane);
record(lane, outcome, detail ?? outcome, reasons);
filled += results.flatMap((r) => r.results).filter((r) => r.status === 'filled').length;
} catch (err) {
record(lane, `error: ${errorMessage(err)}`, errorMessage(err));
console.error(`[scheduler] ${laneName(lane)}: tick error: ${errorMessage(err)}`);
}
}
rt.laneOutcomes = outcomes;
rt.laneTicks = ticks;
rt.lastTickOutcome = overallOutcome([...outcomes.values()]);
if (filled > 0) console.log(`[scheduler] tick: ${filled} account(s) filled`);
// Auto-stop if user opted in and no configured accounts can trade anymore
if (state.stopAfterAll && !hasRemainingConfiguredAccounts()) {
console.log('[scheduler] all configured accounts done for today — stopping');
stopScheduler();
}
} catch (err) {
rt.lastTickOutcome = `error: ${errorMessage(err)}`;
console.error(`[scheduler] tick error: ${errorMessage(err)}`);
}
};
state.intervalId = setInterval(tick, intervalSecs * 1_000);
console.log(`[scheduler] started — ${action} ${symbol} every ${intervalSecs}s`);
}
/** One outcome for the whole tick, for the master and /api/health: the most
* telling lane's (a fill beats an error beats waiting beats nothing to do). */
function overallOutcome(outcomes: TickOutcome[]): TickOutcome {
if (outcomes.length === 0) return 'no-eligible';
const rank = (o: TickOutcome): number => {
if (o === 'fired') return 0;
if (o.startsWith('error')) return 1;
return ['skipped-in-flight', 'positions-open', 'holding-for-sync', 'no-eligible', 'closed', 'not-running']
.indexOf(o) + 2;
};
return [...outcomes].sort((a, b) => rank(a) - rank(b))[0];
}
export function stopScheduler() {
const state = getState();
if (state.intervalId !== null) {
clearInterval(state.intervalId);
state.intervalId = null;
}
state.running = false;
// Nothing watches readiness while stopped, so the grace clocks would be
// stale by the next start — that start gives every firm a fresh grace.
// Reset before the settings write, which can throw.
const rt = runtime();
rt.unreadySince.clear();
rt.quarantineLoggedAt.clear();
rt.holding = [];
rt.quarantined = [];
rt.lastTickOutcome = 'not-running';
rt.laneOutcomes = new Map();
setSetting('scheduler_running', '0');
console.log('[scheduler] stopped');
}
/**
* Restart the schedule that was running before the process went down.
*
* No sync-wait here on purpose: tick() already holds while any client isn't
* ready (for up to UNREADY_GRACE_MS each), and again while any account holds
* an open position. So the worst case is a few logged no-op ticks until the
* clients finish syncing.
*/
export function resumeSchedulerIfPersisted(): void {
if (getSetting('scheduler_running') !== '1') return;
const action = (getSetting('scheduler_action') ?? 'Buy') as 'Buy' | 'Sell' | 'Auto';
const symbol = getSetting('scheduler_symbol') ?? 'Auto';
const stopAfterAll = getSetting('scheduler_stop_after_all') === '1';
console.log(`[scheduler] resuming persisted schedule — ${action} ${symbol}`);
startScheduler(action, symbol, stopAfterAll);
}
export function getSchedulerStatus() {
const state = getState();
return {
running: state.running,
action: state.action,
symbol: state.symbol,
lastRun: state.lastRun,
intervalSeconds: parseInt(getSetting('tick_interval_seconds') ?? '60', 10),
stopAfterAll: state.stopAfterAll,
sessionOpenDelayMin: todaysSessionOpenDelay(),
// A stopped scheduler keeps its last ticks, each with its time.
lastTicks: [...(runtime().laneTicks?.values() ?? [])],
};
}
/**
* Why the scheduler is or isn't trading, for health checks — separate from
* getSchedulerStatus, whose shape is what the master receives. `holding` are
* the firms the last tick waited on (inside their sync grace period);
* `quarantined` the ones past it, traded around. `lanes` is the last tick's
* outcome per lane (see laneOf), keyed by lane name. Timestamps are epoch ms,
* 0 = never; lastFireAt counts batches from any caller, not just the tick;
* tradeInFlightSince is the oldest lane batch still in flight. Until the
* first tick after a start, the outcome still reads 'not-running'.
*/
export function getSchedulerHealth(): {
lastTickAt: number;
lastTickOutcome: TickOutcome;
lastFireAt: number;
holding: string[];
quarantined: string[];
tradeInFlightSince: number;
lanes: Record<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]))
: {},
};
}