Files
autofirmer-expanded/lib/auto-trade.ts
T
Brandon LiandClaude Fable 5 fc65b4340d Reconstruct TopstepX MLL floors from the EOD balance curve
TopstepX never reports the Maximum Loss Limit, but it's deterministic:
floor = min(0, eodBalanceHigh − trailingDrawdown), locking at the $0
starting balance. The EOD curve rebuilds from daily P&L + fund events,
with off-book fee drift spread across days proportionally to reported
fees (validated against a platform-reported MLL to ~$0.10). Stored in
autoLiqThresholds — negative, or +$0.01 once locked — so dead detection,
dashboards, and the ruin model's startThreshold all use the real room.
Guards move from '> 0' to '!== 0'; 0 still means no floor. canTrade=false
accounts are skipped. daily_pnl gains a fees column for the weighting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-13 13:57:06 -05:00

918 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Auto-trade scheduler
*
* When a trade is triggered (POST /api/trade), the scheduler stores the
* action + symbol and fires the same trade logic every 60 seconds to pick
* up accounts that were busy (in a position) at the time of the original
* signal but have since exited and are now eligible.
*/
import { getInstruments } from './db';
import { getClients } from './clients';
import { getTradableFirms } from './firm-catalog';
import { getAccountConfig } from './account-type-resolver';
import {
combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT,
BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY, POINT_VALUES,
FIRST_DAY_STOP, MIN_DAY_STOP_MULT,
type DayPlan,
} from './trading-logic';
import { getSetting, setSetting } from './db';
// ── helpers ──────────────────────────────────────────────────────────────────
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;
}
// 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.
const _legCounts = new Map<number, { date: string; count: number; sum: number }>();
function legsToday(accountId: number, tradeDate: string): number {
const entry = _legCounts.get(accountId);
return entry && entry.date === tradeDate ? entry.count : 0;
}
function legSumToday(accountId: number, tradeDate: string): number {
const entry = _legCounts.get(accountId);
return entry && entry.date === tradeDate ? entry.sum : 0;
}
function recordLeg(accountId: number, tradeDate: string, legAmount: number): void {
_legCounts.set(accountId, {
date: tradeDate,
count: legsToday(accountId, tradeDate) + 1,
sum: Math.round((legSumToday(accountId, tradeDate) + legAmount) * 100) / 100,
});
}
/** Obfuscation stop for the current leg, in dollars. Infinity = no stop.
* Day 1 of a cycle carries the $1,000 account stop (firstDayStop), min-day
* (small) trades a 2×-target stop (minDayStop) — and the day after a losing
* day always rides stopless, the make-back day. */
function obfuscationStopDollars(
cfg: { firstDayStop: boolean; minDayStop: boolean },
dailyPnL: { date: string; pnl: number }[],
plan: DayPlan,
tradeDate: string,
legAmount: number,
): number {
if (!cfg.firstDayStop && !cfg.minDayStop) return Infinity;
const prior = dailyPnL
.filter((d) => d.date < tradeDate && d.pnl !== 0)
.sort((a, b) => a.date.localeCompare(b.date));
if (prior.length > 0 && prior[prior.length - 1].pnl < 0) return Infinity;
let stop = Infinity;
const realizedToday = Math.round((plan.dayTarget - plan.remainingToday) * 100) / 100;
if (cfg.firstDayStop && prior.length === 0) stop = Math.max(0, FIRST_DAY_STOP + realizedToday);
if (cfg.minDayStop && plan.dayTarget <= BIG_DAY_THRESHOLD) stop = Math.min(stop, MIN_DAY_STOP_MULT * legAmount);
return stop;
}
/** The instrument a leg actually trades: drain legs go on MNQ, hardcoded
* (like the extra-day path) — tiny targets belong on the micro regardless of
* the batch symbol. */
async function legInstrument(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any,
drain: boolean,
batchContract: { name: string; tickSize: number },
batchPointValue: number,
): Promise<{ contract: { name: string; tickSize: number }; pointValue: number }> {
if (!drain) return { contract: batchContract, pointValue: batchPointValue };
const mnq = await client.findFrontMonthContract('MNQ');
if (!mnq) throw new Error('MNQ contract not found for drain trade');
return { contract: mnq, pointValue: POINT_VALUES['MNQ'] };
}
// ── per-account trade evaluation (shared by runTrade / copyTrade / hasRemaining…) ──
interface AccountEvaluation {
eligible: boolean;
reason: string;
plan: DayPlan | null;
isExtraDay: boolean;
/** Current stage is a drain stage — the leg trades MNQ regardless of the batch symbol. */
drain: boolean;
cash: { amount: number; realizedPnL: number };
dailyPnL: { date: string; pnl: number }[];
daysTraded: number;
}
/**
* Evaluate whether an account can trade right now. Returns null when the account has no
* matching config (not ours). Split-allowed accounts stay eligible mid-day until
* remainingToday drops to LEG_COMPLETION_EPSILON or the leg cap is hit; extra-day and
* strict-consistency (past day 1) accounts keep the legacy one-trade-per-day gate.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function evaluateAccountForTrade(client: any, acc: { id: number; name: string; active: boolean }, tradeDate: string): AccountEvaluation | null {
const cfg = getAccountConfig(acc.id).accountType;
if (!cfg) return null;
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0;
// A combining firm records holiday sessions with the next full trading day,
// so the plan and the already-traded gates below must see the whole stretch
// as one day: Sunday's booked P&L folds into Tuesday's realized, and the
// day target stays anchored to the combined (clearing) trade date. `cash`
// itself stays raw — the commission delta in the fire path compares live
// Tradovate values.
const combined = combineCmeSessions(
cfg.combineCmeHolidays, client.dailyPnL[acc.id] ?? [], tradeDate, cash.realizedPnL,
);
const dailyPnL = combined.dailyPnL;
const daysTraded = combined.daysTraded;
const base = { plan: null, isExtraDay: false, drain: false, cash, dailyPnL, daysTraded };
if (isAccountDead(cash.amount, autoLiqThreshold)) return { ...base, eligible: false, reason: 'dead' };
if (!acc.active) return { ...base, eligible: false, reason: 'inactive (DLL)' };
if (client.positions[acc.id]) return { ...base, eligible: false, reason: 'already in trade' };
// Obfuscation stops: a stop-out shows as realized P&L falling short of the
// legs fired today — the day is booked; the make-back ride is tomorrow.
// The plain negative-realized check is what survives a restart, when the
// in-memory leg counter has forgotten the morning's stop-out.
if (cfg.firstDayStop || cfg.minDayStop) {
if (combined.realizedPnL <= -25) {
return { ...base, eligible: false, reason: 'red day booked — make-back rides tomorrow' };
}
if (legsToday(acc.id, tradeDate) > 0
&& combined.realizedPnL < legSumToday(acc.id, tradeDate) - 25) {
return { ...base, eligible: false, reason: 'stopped out — day booked' };
}
}
const priorProfit = client.priorProfit?.[acc.id] ?? 0;
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
const effective = resolveEffectiveConfig(
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, priorProfit, allFundTxns,
cfg.minDayPnL
);
const equityProfit = cash.amount - cfg.accountSize;
const plan = computeDayPlan(
acc.id, combined.tradeDate, effective.profitTarget, effective.consistency,
dailyPnL, effective.minDayPnL, effective.minTradingDays, equityProfit, combined.realizedPnL,
cfg.splitEnabled, effective.drain
);
if (!plan) return { ...base, eligible: false, reason: 'invalid balance' };
const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0);
const isExtraDay = !effective.drain
&& effective.minDayPnL <= 0
&& effective.minTradingDays > daysTraded
&& totalProfit >= effective.profitTarget;
const evaluated = { plan, isExtraDay, drain: effective.drain, cash, dailyPnL, daysTraded };
if (isExtraDay || !plan.splitAllowed) {
// Legacy one-and-done gate: any realized PnL today means this account is
// finished — for a combining account, "today" spans the holiday stretch.
if (combined.realizedPnL !== 0) {
return { ...evaluated, eligible: false, reason: `already traded today (realizedPnL=${combined.realizedPnL})` };
}
if (!isExtraDay && plan.remainingToday <= 0) {
const reason = effective.drain && plan.dayTarget === 0
? 'drain days booked — waiting for withdrawal'
: `target=$${plan.dayTarget} (challenge complete)`;
return { ...evaluated, eligible: false, reason };
}
} else {
// Split-leg mode: keep trading until the day's remaining target is used up.
// The epsilon only applies once a leg has completed, so tiny day-start targets still trade.
const doneThreshold = combined.realizedPnL === 0 ? 0 : LEG_COMPLETION_EPSILON;
if (plan.remainingToday <= doneThreshold) {
const reason = combined.realizedPnL === 0
? `target=$${plan.dayTarget} (challenge complete)`
: `day done ($${plan.remainingToday} left of $${plan.dayTarget})`;
return { ...evaluated, eligible: false, reason };
}
if (legsToday(acc.id, tradeDate) >= MAX_LEGS_PER_DAY) {
return { ...evaluated, eligible: false, reason: `leg cap reached (${MAX_LEGS_PER_DAY} today)` };
}
}
return { ...evaluated, eligible: true, reason: '' };
}
// ── helpers ───────────────────────────────────────────────────────────────────
// Random 1–20 minute delay added to the session open, picked once per Central
// calendar day. Re-rolling on every check would move the open boundary between
// polls, letting a trade slip through a moment the next check would reject.
const sessionOpenJitter = new Map<string, number>();
function openJitterMinutes(centralDay: string): number {
let jitter = sessionOpenJitter.get(centralDay);
if (jitter === undefined) {
jitter = 1 + Math.floor(Math.random() * 20); // 1–20 inclusive
sessionOpenJitter.clear(); // only today's entry is ever needed
sessionOpenJitter.set(centralDay, jitter);
console.log(`[trading-hours] session open delayed ${jitter} min today`);
}
return jitter;
}
/** Today's session-open delay in minutes, for reporting to the master. */
export function todaysSessionOpenDelay(): number {
const centralDay = new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/Chicago',
}).format(new Date());
return openJitterMinutes(centralDay);
}
/**
* Returns true when trading is not allowed based on the trading_hours setting.
*
* "full_cme" — Sun 5:05 PM – Fri 2:55 PM Central (5 min buffer on each side)
* "equity_hours" — 8:35 AM – 2:55 PM Central, Mon–Fri (5 min buffer on each side)
*
* The open side of each window is pushed back by a random 1–20 minutes per day
* (see openJitterMinutes) so sessions never start at the exact same time.
*/
function isInNoTradeWindow(): boolean {
const now = new Date();
const centralParts = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: 'numeric',
hour12: false,
timeZone: 'America/Chicago',
}).formatToParts(now);
const hour = parseInt(centralParts.find((p) => p.type === 'hour')!.value, 10);
const minute = parseInt(centralParts.find((p) => p.type === 'minute')!.value, 10);
const day = new Intl.DateTimeFormat('en-US', {
weekday: 'short',
timeZone: 'America/Chicago',
}).format(now); // "Sun", "Mon", ... "Sat"
const timeMinutes = hour * 60 + minute; // minutes since midnight
const jitter = todaysSessionOpenDelay();
const mode = getSetting('trading_hours') ?? 'full_cme';
if (mode === 'equity_hours') {
// Equity hours: 8:30 AM – 3:00 PM Central with 5 min buffer = 8:35 AM – 2:55 PM
// Weekdays only
if (day === 'Sat' || day === 'Sun') return true;
const open = 8 * 60 + 35 + jitter; // 8:35 AM + daily jitter
const close = 14 * 60 + 55; // 2:55 PM
return timeMinutes < open || timeMinutes >= close;
}
// Full CME: Sun 5:00 PM – Fri 4:00 PM Central with 5 min buffer
// Open: 5:05 PM, Close: 2:55 PM (stop early), Daily halt: 2:55 PM – 5:05 PM
// Saturday — market closed all day
if (day === 'Sat') return true;
// Sunday — market opens at 5:05 PM Central (+ daily jitter)
const cmeOpen = 17 * 60 + 5 + jitter; // 5:05 PM + daily jitter
if (day === 'Sun') return timeMinutes < cmeOpen;
// Friday — stop trading at 2:55 PM Central
const cmeClose = 14 * 60 + 55; // 2:55 PM
if (day === 'Fri' && timeMinutes >= cmeClose) return true;
// Mon–Thu: block 2:55 PM – 5:05 PM Central (early stop + daily halt + buffer)
if (timeMinutes >= cmeClose && timeMinutes < cmeOpen) return true;
return false;
}
// ── core trade logic ──────────────────────────────────────────────────────────
export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string) {
if (isInNoTradeWindow()) {
console.log('[auto-trade] CME market closed — skipping');
return [];
}
// Block if any account still has an open position (same gate as the scheduler tick)
const allClients = getClients();
const allFirms = getTradableFirms();
const openPositions = allFirms.reduce((count, firm) => {
const c = allClients.get(firm.id);
if (!c) return count;
return count + c.accountList.filter(acc => !!c.positions[acc.id]).length;
}, 0);
if (openPositions > 0) {
console.log(`[auto-trade] ${openPositions} position(s) still open — skipping`);
return [];
}
// Resolve 'Auto' symbol once per batch so all accounts trade the same symbol
let resolvedSymbol = symbol;
if (symbol === 'Auto') {
const enabled = getInstruments().filter((i) => i.enabled).map((i) => i.symbol);
resolvedSymbol = enabled.length > 0 ? enabled[Math.floor(Math.random() * enabled.length)] : 'NQ';
console.log(`[auto-trade] random symbol resolved to: ${resolvedSymbol}`);
}
// Resolve Auto action once per batch so all accounts trade the same direction
const resolvedAction: 'Buy' | 'Sell' = action === 'Auto'
? (Math.random() < 0.5 ? 'Buy' : 'Sell')
: action;
const pointValue = POINT_VALUES[resolvedSymbol];
if (!pointValue) throw new Error(`Unknown symbol: ${resolvedSymbol}`);
const tradeDate = currentTradeDateCT();
const maxConcurrent = parseInt(getSetting('max_concurrent_accounts') ?? '5', 10);
const firms = getTradableFirms();
const clients = getClients();
// ── Phase 1: collect ALL eligible accounts across ALL firms in parallel ──
type EligibleItem = {
firmName: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any;
acc: { id: number; name: string; active: boolean };
contract: { name: string; tickSize: number };
cash: { amount: number; realizedPnL: number };
dailyPnL: { date: string; pnl: number }[];
daysTraded: number;
plan: DayPlan;
isExtraDay: boolean;
drain: boolean;
prefix: string;
};
const allEligible: EligibleItem[] = [];
await Promise.all(firms.map(async (firm) => {
const client = clients.get(firm.id);
if (!client || client.accountList.length === 0) return;
// Skip this firm entirely if the symbol is banned for it
if (firm.bannedSymbols.includes(resolvedSymbol)) {
console.log(`[auto-trade] ${firm.name}: ${resolvedSymbol} is banned — skipping firm`);
return;
}
const contract = await client.findFrontMonthContract(resolvedSymbol);
if (!contract) return;
for (const acc of client.accountList) {
const ev = evaluateAccountForTrade(client, acc, tradeDate);
if (!ev || !ev.eligible || !ev.plan) continue;
allEligible.push({
firmName: firm.name, client, acc, contract,
cash: ev.cash, dailyPnL: ev.dailyPnL, daysTraded: ev.daysTraded,
plan: ev.plan, isExtraDay: ev.isExtraDay, drain: ev.drain,
prefix: getAccountConfig(acc.id).accountType?.prefix ?? '',
});
}
}));
if (allEligible.length === 0) {
console.log('[auto-trade] no eligible accounts found');
return [];
}
// Take only the first batch — all fired simultaneously, no rolling pool.
// Remaining accounts wait for the next tick (which only fires once all positions are flat).
// Like accounts trade together: a batch is drawn from a single prefix group
// (largest group first), never mixed across prefixes — a group's remainder
// fires as a smaller batch on a later tick instead of being padded from
// another prefix. E.g. 4×FTDFY, 5×MFFUEV, 7×MFFUSF at limit 5 fires as
// batches of 5, 5, 4, 2.
const groups = new Map<string, EligibleItem[]>();
for (const item of allEligible) {
const group = groups.get(item.prefix);
if (group) group.push(item);
else groups.set(item.prefix, [item]);
}
const [batchPrefix, batchGroup] = [...groups.entries()]
.sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]))[0];
const batch = batchGroup.slice(0, maxConcurrent);
console.log(`[auto-trade] ${allEligible.length} eligible account(s) across ${groups.size} prefix group(s) — firing batch of ${batch.length} ${batchPrefix || '(no prefix)'}`);
// ── Phase 2: fire the batch simultaneously ──
const tradeResults = await Promise.allSettled(batch.map(async (item) => {
const { client, acc, contract } = item;
const cfg = getAccountConfig(acc.id).accountType!;
// Extra-day mode: profit target already met, no min day P&L, days still needed.
// Just trade 1 MNQ in and out at market immediately — P&L doesn't matter.
if (item.isExtraDay) {
const mnqContract = await client.findFrontMonthContract('MNQ');
if (!mnqContract) throw new Error('MNQ contract not found for extra-day trade');
const fill = await client.sendOrder(acc.id, mnqContract.name, 1, resolvedAction, 'Market');
const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy';
const exitFill = await client.sendOrder(acc.id, mnqContract.name, 1, exitAction, 'Market');
console.log(`[auto-trade] ${acc.name} (${item.firmName}) extra-day: ${resolvedAction} 1xMNQ @ ${fill.price} | exited @ ${exitFill.price} (market)`);
return {
account: acc.name,
firm: item.firmName,
status: 'filled',
contracts: 1,
target: 0,
grossTarget: 0,
totalCommission: 0,
targetPath: 'extra_day',
entryPrice: fill.price,
exitPrice: exitFill.price,
commission: 0,
};
}
// Trade one leg toward today's remaining target. Split-allowed big days carve a
// ≥$500 piece; everything else trades the full remaining amount in one go. The
// carve fraction is seeded per (prefix, date, leg number) so like accounts in a
// batch split their targets the same way, or close to it when remainders differ.
// An account type's min-legs setting forces at least that many carves
// on BIG days only — a day at or below the threshold still trades as
// one leg. Set per prefix, so like accounts still split alike.
const minLegs = item.plan.dayTarget > BIG_DAY_THRESHOLD ? (cfg.minLegs ?? 0) : 0;
const legAmount = item.plan.splitAllowed
? pickLegAmount(
item.plan.remainingToday,
() => groupLegFraction(item.prefix, tradeDate, legsToday(acc.id, tradeDate)),
minLegs - legsToday(acc.id, tradeDate),
)
: item.plan.remainingToday;
recordLeg(acc.id, tradeDate, legAmount);
const inst = await legInstrument(client, item.drain, contract, pointValue);
const legContract = inst.contract;
const pv = inst.pointValue;
const rawContracts = Math.max(1, Math.ceil(legAmount / 1000));
const contracts = cfg.maxPositionSize > 0 ? Math.min(rawContracts, cfg.maxPositionSize) : rawContracts;
const fill = await client.sendOrder(acc.id, legContract.name, contracts, resolvedAction, 'Market');
// Wait briefly for the cash balance WebSocket update to reflect entry commission
await new Promise(r => setTimeout(r, 1000));
const updatedCash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
// After entry, realizedPnL drops by the entry commission; the delta from the
// pre-entry value is the entry fee paid (pre-entry may be nonzero mid-day).
// Fall back to $2.50/contract if the WS hasn't updated yet (guarantees at least 1 extra tick).
const entryCommission = Math.abs(updatedCash.realizedPnL - item.cash.realizedPnL) || (2.5 * contracts);
const totalCommission = entryCommission * 2; // entry + exit round-trip
const grossTarget = legAmount + totalCommission;
const targetPoints = grossTarget / (pv * contracts);
const ticks = Math.ceil(targetPoints / legContract.tickSize);
const exitPrice = resolvedAction === 'Buy'
? fill.price + (ticks * legContract.tickSize)
: fill.price - (ticks * legContract.tickSize);
const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy';
const stopDollars = obfuscationStopDollars(cfg, item.dailyPnL, item.plan, tradeDate, legAmount);
let exitOrder: { orderId?: number };
let stopPrice: number | null = null;
if (Number.isFinite(stopDollars) && stopDollars > 0) {
const stopTicks = Math.max(1, Math.ceil(stopDollars / (pv * contracts) / legContract.tickSize));
stopPrice = resolvedAction === 'Buy'
? fill.price - (stopTicks * legContract.tickSize)
: fill.price + (stopTicks * legContract.tickSize);
try {
exitOrder = await client.placeOcoExit(acc.id, legContract.name, contracts, exitAction, exitPrice, stopPrice);
} catch (err) {
// The position is already open — never leave it exit-less. Fall
// back to the classic lone limit and shout about it.
console.error(`[auto-trade] ${acc.name}: placeoco REJECTED (${err instanceof Error ? err.message : err}) — falling back to lone limit exit, NO STOP`);
stopPrice = null;
exitOrder = await client.placeOrderNoWait(acc.id, legContract.name, contracts, exitAction, 'Limit', exitPrice);
}
} else {
exitOrder = await client.placeOrderNoWait(acc.id, legContract.name, contracts, exitAction, 'Limit', exitPrice);
}
console.log(`[auto-trade] ${acc.name} (${item.firmName}) ${resolvedAction} ${contracts}x${item.drain ? legContract.name : resolvedSymbol} @ ${fill.price} | leg $${legAmount} of day $${item.plan.dayTarget} (remaining $${item.plan.remainingToday}) [${item.plan.path}${item.drain ? ' drain' : ''}] (+$${totalCommission.toFixed(2)} comm) | exit @ ${exitPrice}${stopPrice !== null ? ` / stop @ ${stopPrice}` : ''} (orderId=${exitOrder.orderId})`);
return {
account: acc.name,
firm: item.firmName,
status: 'filled',
contracts,
target: legAmount,
dayTarget: item.plan.dayTarget,
remainingBefore: item.plan.remainingToday,
grossTarget,
totalCommission,
targetPath: item.plan.path,
entryPrice: fill.price,
exitPrice,
commission: entryCommission,
};
}));
// Group results by firm for the response
const firmResultsMap = new Map<string, unknown[]>();
for (let i = 0; i < batch.length; i++) {
const firmName = batch[i].firmName;
if (!firmResultsMap.has(firmName)) firmResultsMap.set(firmName, []);
const r = tradeResults[i];
firmResultsMap.get(firmName)!.push(
r.status === 'fulfilled'
? r.value
: { status: 'error', reason: (r.reason as any)?.message ?? String(r.reason) }
);
}
return Array.from(firmResultsMap.entries()).map(([firm, results]) => ({ firm, results }));
}
// ── copy trade ───────────────────────────────────────────────────────────────
/**
* Copy the current trade direction to up to maxConcurrent accounts.
* Finds accounts with open positions, determines direction, then fires
* orders for eligible accounts that haven't traded yet.
*/
export async function copyTrade() {
console.log('[copy-trade] called');
if (isInNoTradeWindow()) {
console.log('[copy-trade] outside trading hours — skipping');
return [];
}
const firms = getTradableFirms();
const clients = getClients();
const maxConcurrent = Math.max(1, parseInt(getSetting('max_concurrent_accounts') ?? '5', 10));
// Find all accounts with open positions to determine direction + symbol
let resolvedAction: 'Buy' | 'Sell' | null = null;
// number on Tradovate, string on TopstepX — only ever compared against
// contracts from the same client, so the union stays coherent.
let positionContractId: number | string | null = null;
let positionedCount = 0;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let positionedClient: any = null;
const positionedPrefixes = new Set<string>();
for (const firm of firms) {
const client = clients.get(firm.id);
if (!client) continue;
for (const acc of client.accountList) {
const pos = client.positions[acc.id];
if (!pos) continue;
positionedCount++;
const prefix = getAccountConfig(acc.id).accountType?.prefix;
if (prefix) positionedPrefixes.add(prefix);
if (!resolvedAction) {
resolvedAction = pos.netPos > 0 ? 'Buy' : 'Sell';
positionContractId = pos.contractId;
positionedClient = client;
}
}
}
if (!resolvedAction || positionedCount === 0) {
console.log('[copy-trade] no open positions to copy from');
return [];
}
console.log(`[copy-trade] found ${positionedCount} open position(s), action=${resolvedAction}, contractId=${positionContractId}`);
// Resolve symbol from the positioned contract's contractId — only check the client that
// actually has the position. Iterating every firm × every symbol can take 30+ seconds.
let resolvedSymbol: string | null = null;
const instruments = getInstruments();
const enabledSymbols = instruments.filter(i => i.enabled).map(i => i.symbol);
if (positionedClient) {
for (const sym of enabledSymbols) {
const contract = await positionedClient.findFrontMonthContract(sym);
if (contract && contract.id === positionContractId) {
resolvedSymbol = sym;
break;
}
}
}
console.log(`[copy-trade] resolved symbol: ${resolvedSymbol ?? '(none, using fallback)'}`);
// Fall back to scheduler symbol or first enabled
if (!resolvedSymbol) {
const state = getState();
resolvedSymbol = state.symbol !== 'Auto' ? state.symbol : (enabledSymbols[0] ?? 'NQ');
}
const pointValue = POINT_VALUES[resolvedSymbol];
if (!pointValue) {
console.log(`[copy-trade] unknown symbol ${resolvedSymbol}`);
return [];
}
const slotsAvailable = maxConcurrent - positionedCount;
if (slotsAvailable <= 0) {
console.log(`[copy-trade] already at max concurrent (${positionedCount}/${maxConcurrent})`);
return [];
}
const tradeDate = currentTradeDateCT();
// Collect eligible accounts (same logic as Phase 1 of runTrade)
type CopyItem = {
firmName: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any;
acc: { id: number; name: string; active: boolean };
contract: { name: string; tickSize: number };
cash: { amount: number; realizedPnL: number };
dailyPnL: { date: string; pnl: number }[];
plan: DayPlan;
drain: boolean;
prefix: string;
};
const eligible: CopyItem[] = [];
await Promise.all(firms.map(async (firm) => {
const client = clients.get(firm.id);
if (!client || client.accountList.length === 0) {
console.log(`[copy-trade] ${firm.name}: no client or empty account list — skip firm`);
return;
}
if (firm.bannedSymbols.includes(resolvedSymbol!)) {
console.log(`[copy-trade] ${firm.name}: ${resolvedSymbol} is banned — skip firm`);
return;
}
const contract = await client.findFrontMonthContract(resolvedSymbol!);
if (!contract) {
console.log(`[copy-trade] ${firm.name}: cannot resolve front-month contract for ${resolvedSymbol} — skip firm`);
return;
}
for (const acc of client.accountList) {
const ev = evaluateAccountForTrade(client, acc, tradeDate);
if (!ev) { console.log(`[copy-trade] ${acc.name}: no matching config prefix — skip`); continue; }
if (!ev.eligible || !ev.plan) { console.log(`[copy-trade] ${acc.name}: ${ev.reason} — skip`); continue; }
// copyTrade has no extra-day branch — those accounts wait for the scheduler
if (ev.isExtraDay) { console.log(`[copy-trade] ${acc.name}: extra-day account — skip`); continue; }
eligible.push({
firmName: firm.name, client, acc, contract, cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan,
drain: ev.drain,
prefix: getAccountConfig(acc.id).accountType?.prefix ?? '',
});
}
}));
// Fill the open slots with accounts whose prefix matches the already-positioned
// accounts before falling back to mismatched prefixes.
const matchesPositioned = (item: CopyItem) => positionedPrefixes.has(item.prefix);
const ordered = [...eligible.filter(matchesPositioned), ...eligible.filter((i) => !matchesPositioned(i))];
const batch = ordered.slice(0, slotsAvailable);
if (batch.length === 0) {
console.log('[copy-trade] no eligible accounts to copy to');
return [];
}
console.log(`[copy-trade] copying ${resolvedAction} ${resolvedSymbol} to ${batch.length} account(s)`);
// Fire orders (same as Phase 2 of runTrade)
const tradeResults = await Promise.allSettled(batch.map(async (item) => {
const { client, acc, contract } = item;
const cfg = getAccountConfig(acc.id).accountType!;
const minLegs = item.plan.dayTarget > BIG_DAY_THRESHOLD ? (cfg.minLegs ?? 0) : 0;
const legAmount = item.plan.splitAllowed
? pickLegAmount(
item.plan.remainingToday,
() => groupLegFraction(item.prefix, tradeDate, legsToday(acc.id, tradeDate)),
minLegs - legsToday(acc.id, tradeDate),
)
: item.plan.remainingToday;
recordLeg(acc.id, tradeDate, legAmount);
const inst = await legInstrument(client, item.drain, contract, pointValue);
const legContract = inst.contract;
const pv = inst.pointValue;
const rawContracts = Math.max(1, Math.ceil(legAmount / 1000));
const contracts = cfg.maxPositionSize > 0 ? Math.min(rawContracts, cfg.maxPositionSize) : rawContracts;
const fill = await client.sendOrder(acc.id, legContract.name, contracts, resolvedAction!, 'Market');
await new Promise(r => setTimeout(r, 1000));
const updatedCash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
const entryCommission = Math.abs(updatedCash.realizedPnL - item.cash.realizedPnL) || (2.5 * contracts);
const totalCommission = entryCommission * 2;
const grossTarget = legAmount + totalCommission;
const targetPoints = grossTarget / (pv * contracts);
const ticks = Math.ceil(targetPoints / legContract.tickSize);
const exitPrice = resolvedAction === 'Buy'
? fill.price + (ticks * legContract.tickSize)
: fill.price - (ticks * legContract.tickSize);
const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy';
const stopDollars = obfuscationStopDollars(cfg, item.dailyPnL, item.plan, tradeDate, legAmount);
let exitOrder: { orderId?: number };
let stopPrice: number | null = null;
if (Number.isFinite(stopDollars) && stopDollars > 0) {
const stopTicks = Math.max(1, Math.ceil(stopDollars / (pv * contracts) / legContract.tickSize));
stopPrice = resolvedAction === 'Buy'
? fill.price - (stopTicks * legContract.tickSize)
: fill.price + (stopTicks * legContract.tickSize);
try {
exitOrder = await client.placeOcoExit(acc.id, legContract.name, contracts, exitAction, exitPrice, stopPrice);
} catch (err) {
console.error(`[copy-trade] ${acc.name}: placeoco REJECTED (${err instanceof Error ? err.message : err}) — falling back to lone limit exit, NO STOP`);
stopPrice = null;
exitOrder = await client.placeOrderNoWait(acc.id, legContract.name, contracts, exitAction, 'Limit', exitPrice);
}
} else {
exitOrder = await client.placeOrderNoWait(acc.id, legContract.name, contracts, exitAction, 'Limit', exitPrice);
}
console.log(`[copy-trade] ${acc.name} (${item.firmName}) ${resolvedAction} ${contracts}x${item.drain ? legContract.name : resolvedSymbol} @ ${fill.price} | leg $${legAmount} of day $${item.plan.dayTarget} (remaining $${item.plan.remainingToday}) [${item.plan.path}${item.drain ? ' drain' : ''}] | exit @ ${exitPrice}${stopPrice !== null ? ` / stop @ ${stopPrice}` : ''} (orderId=${exitOrder.orderId})`);
return {
account: acc.name,
firm: item.firmName,
status: 'filled',
contracts,
target: legAmount,
dayTarget: item.plan.dayTarget,
remainingBefore: item.plan.remainingToday,
grossTarget,
totalCommission,
targetPath: item.plan.path,
entryPrice: fill.price,
exitPrice,
commission: entryCommission,
};
}));
const results: unknown[] = [];
for (let i = 0; i < batch.length; i++) {
const r = tradeResults[i];
results.push(
r.status === 'fulfilled'
? r.value
: { status: 'error', reason: (r.reason as any)?.message ?? String(r.reason) }
);
}
return results;
}
// ── eligibility check ────────────────────────────────────────────────────────
/** Returns true if any configured account could still trade today (eligible for a next leg / extra-day, or has an open position). */
function hasRemainingConfiguredAccounts(): boolean {
const firms = getTradableFirms();
const clients = getClients();
const tradeDate = currentTradeDateCT();
for (const firm of firms) {
const client = clients.get(firm.id);
if (!client || client.accountList.length === 0) continue;
for (const acc of client.accountList) {
const cfg = getAccountConfig(acc.id).accountType;
if (!cfg) continue; // no config = not our account
// Account with open position = still in play
if (client.positions[acc.id]) return true;
const ev = evaluateAccountForTrade(client, acc, tradeDate);
if (ev?.eligible) return true;
}
}
return false;
}
// ── scheduler ─────────────────────────────────────────────────────────────────
interface SchedulerState {
action: 'Buy' | 'Sell' | 'Auto';
symbol: string;
intervalId: ReturnType<typeof setInterval> | null;
lastRun: Date | null;
running: boolean;
stopAfterAll: boolean;
}
// Global singleton (survives HMR in dev via module cache)
const _global = globalThis as typeof globalThis & { __autoTrader?: SchedulerState };
function getState(): SchedulerState {
if (!_global.__autoTrader) {
_global.__autoTrader = { action: 'Buy', symbol: 'NQ', intervalId: null, lastRun: null, running: false, stopAfterAll: false };
}
return _global.__autoTrader;
}
export function startScheduler(action: 'Buy' | 'Sell' | 'Auto', symbol: string, stopAfterAll: boolean = false) {
const state = getState();
// Clear any existing interval
if (state.intervalId !== null) {
clearInterval(state.intervalId);
}
state.action = action;
state.symbol = symbol;
state.running = true;
state.stopAfterAll = stopAfterAll;
// Mirror to the settings table so a restart can pick the schedule back up.
// The interval itself is in-memory only; resumeSchedulerIfPersisted() recreates it.
setSetting('scheduler_running', '1');
setSetting('scheduler_action', action);
setSetting('scheduler_symbol', symbol);
setSetting('scheduler_stop_after_all', stopAfterAll ? '1' : '0');
const tick = async () => {
if (!state.running) return;
state.lastRun = new Date();
// Skip this tick until every client has completed its initial sync (positions are populated)
const clients = getClients();
const firms = getTradableFirms();
const notReady = firms.filter(f => {
const c = clients.get(f.id);
return c && !c.syncComplete;
});
if (notReady.length > 0) {
console.log(`[scheduler] waiting for sync: ${notReady.map(f => f.name).join(', ')}`);
return;
}
// Skip this tick if any account still has an open position from the previous batch
const openPositions = firms.reduce((count, firm) => {
const client = clients.get(firm.id);
if (!client) return count;
return count + client.accountList.filter(acc => !!client.positions[acc.id]).length;
}, 0);
if (openPositions > 0) {
console.log(`[scheduler] ${openPositions} position(s) still open — skipping tick`);
return;
}
try {
const results = await runTrade(state.action, state.symbol);
const filled = results.flatMap((r: any) => r.results ?? []).filter((r: any) => r.status === 'filled').length;
if (filled > 0) console.log(`[scheduler] tick: ${filled} account(s) filled`);
// Auto-stop if user opted in and no configured accounts can trade anymore
if (state.stopAfterAll && !hasRemainingConfiguredAccounts()) {
console.log('[scheduler] all configured accounts done for today — stopping');
stopScheduler();
}
} catch (err) {
console.error('[scheduler] tick error:', err);
}
};
const intervalSecs = Math.max(5, parseInt(getSetting('tick_interval_seconds') ?? '60', 10));
state.intervalId = setInterval(tick, intervalSecs * 1_000);
console.log(`[scheduler] started — ${action} ${symbol} every ${intervalSecs}s`);
}
export function stopScheduler() {
const state = getState();
if (state.intervalId !== null) {
clearInterval(state.intervalId);
state.intervalId = null;
}
state.running = false;
setSetting('scheduler_running', '0');
console.log('[scheduler] stopped');
}
/**
* Restart the schedule that was running before the process went down.
*
* No sync-wait here on purpose: tick() already skips while any client reports
* !syncComplete, and again while any account holds an open position. So the
* worst case is a few logged no-op ticks until the clients finish syncing.
*/
export function resumeSchedulerIfPersisted(): void {
if (getSetting('scheduler_running') !== '1') return;
const action = (getSetting('scheduler_action') ?? 'Buy') as 'Buy' | 'Sell' | 'Auto';
const symbol = getSetting('scheduler_symbol') ?? 'NQ';
const stopAfterAll = getSetting('scheduler_stop_after_all') === '1';
console.log(`[scheduler] resuming persisted schedule — ${action} ${symbol}`);
startScheduler(action, symbol, stopAfterAll);
}
export function getSchedulerStatus() {
const state = getState();
return {
running: state.running,
action: state.action,
symbol: state.symbol,
lastRun: state.lastRun,
intervalSeconds: parseInt(getSetting('tick_interval_seconds') ?? '60', 10),
stopAfterAll: state.stopAfterAll,
sessionOpenDelayMin: todaysSessionOpenDelay(),
};
}