Reverts062ec07and5eb0ea7: every firm's broker session stays logged in around the clock again, renewing its token on the fixed timer as before. The tree is back to 6dd2528's. A session_rest row left in settings is unused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2154 lines
105 KiB
TypeScript
2154 lines
105 KiB
TypeScript
/**
|
||
* 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,
|
||
type DayPlan, type FinishedCycleMark,
|
||
} from './trading-logic';
|
||
import { getSetting, setSetting, loadAccountMeta, saveAccountMeta } from './db';
|
||
import type { LaneTick, TickReason } 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>;
|
||
/** 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 firm is a lane of its own.
|
||
*/
|
||
export function laneOf(firm: { id: string; platform: string }): string {
|
||
return firm.platform === 'dxtrade' ? firm.id : FUTURES_LANE;
|
||
}
|
||
const FUTURES_LANE = 'futures';
|
||
|
||
function inLane<T extends { firm: TradableFirm }>(items: T[], lane: string): T[] {
|
||
return items.filter((i) => laneOf(i.firm) === lane);
|
||
}
|
||
|
||
/** The lanes of every tradable firm, futures first. */
|
||
function tradableLanes(): string[] {
|
||
const lanes = new Set(getTradableFirms().map((f) => laneOf(f)));
|
||
return [...lanes].sort((a, b) => (a === FUTURES_LANE ? -1 : b === FUTURES_LANE ? 1 : a.localeCompare(b)));
|
||
}
|
||
|
||
function laneName(lane: string): string {
|
||
if (lane === FUTURES_LANE) return FUTURES_LANE;
|
||
return getTradableFirms().find((f) => f.id === lane)?.name ?? lane;
|
||
}
|
||
|
||
/**
|
||
* Tradable firms that have a client, split by isClientReady. Everything that
|
||
* trades or gates works from `ready` only: a client whose sync is missing or
|
||
* stale holds a frozen snapshot, whose positions may be long gone and which
|
||
* may be missing ones opened since — it must neither trade nor gate.
|
||
*/
|
||
function firmClients(now = Date.now()): { ready: FirmClient[]; unready: FirmClient[] } {
|
||
const clients = getClients();
|
||
const ready: FirmClient[] = [];
|
||
const unready: FirmClient[] = [];
|
||
for (const firm of getTradableFirms()) {
|
||
const client = clients.get(firm.id);
|
||
if (!client) continue;
|
||
(isClientReady(client, now) ? ready : unready).push({ firm, client });
|
||
}
|
||
return { ready, unready };
|
||
}
|
||
|
||
function countOpenPositions(ready: FirmClient[]): number {
|
||
return ready.reduce(
|
||
(count, { client }) => count + client.accountList.filter((acc) => !!client.positions[acc.id]).length,
|
||
0,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Which firms hold the tick, and which it trades without.
|
||
*
|
||
* A firm whose client isn't ready might hold positions nobody can see, so for
|
||
* the first UNREADY_GRACE_MS the whole tick waits for it, as it always has —
|
||
* right for a restart or a reconnect, which settle within a minute or two.
|
||
* Past the grace period the client is presumed dead (broker maintenance, a
|
||
* login that keeps failing) and the firm is quarantined: the other firms
|
||
* trade without it, rather than one broken session stopping every firm on the
|
||
* machine. A quarantined firm trades nothing — runTrade and copyTrade only
|
||
* ever see ready clients — and rejoins on its first good sync.
|
||
*
|
||
* A client that has synced and found no accounts is ignored outright: it
|
||
* cannot hold positions or trade, so there is nothing to wait for.
|
||
*/
|
||
function assessReadiness(
|
||
ready: FirmClient[],
|
||
unready: FirmClient[],
|
||
now: number,
|
||
): { holding: TradableFirm[]; quarantined: { firm: TradableFirm; unreadyMs: number }[] } {
|
||
const rt = runtime();
|
||
const holding: TradableFirm[] = [];
|
||
const quarantined: { firm: TradableFirm; unreadyMs: number }[] = [];
|
||
const tracked = new Set<string>();
|
||
|
||
for (const { firm, client } of unready) {
|
||
if (client.lastSyncAt > 0 && client.accountList.length === 0) continue;
|
||
tracked.add(firm.id);
|
||
const since = rt.unreadySince.get(firm.id) ?? now;
|
||
rt.unreadySince.set(firm.id, since);
|
||
const unreadyMs = now - since;
|
||
if (unreadyMs < UNREADY_GRACE_MS) holding.push(firm);
|
||
else quarantined.push({ firm, unreadyMs });
|
||
}
|
||
|
||
// Forget firms that are ready again (or gone), and say so for any that
|
||
// had been traded around.
|
||
for (const id of [...rt.unreadySince.keys()]) {
|
||
if (tracked.has(id)) continue;
|
||
const back = ready.find((r) => r.firm.id === id);
|
||
if (back && rt.quarantineLoggedAt.has(id)) {
|
||
console.log(`[scheduler] ${back.firm.name} synced again — trading it with the others`);
|
||
}
|
||
rt.unreadySince.delete(id);
|
||
rt.quarantineLoggedAt.delete(id);
|
||
}
|
||
|
||
return { holding, quarantined };
|
||
}
|
||
|
||
// ── trade mutex ──────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* One batch at a time per lane. The tick, a master "trade" command,
|
||
* POST /api/trade and /api/copy-trade can all fire a batch, and two batches
|
||
* collecting at once would each see the other's accounts as still flat — the
|
||
* position gate only trips once a fill has come back over the socket. Lanes
|
||
* never share accounts, so each has its own mutex.
|
||
*
|
||
* A batch that has held the mutex past TRADE_WEDGED_MS is presumed stuck on a
|
||
* broker call that will never answer, and the next one goes ahead without it
|
||
* rather than nothing trading for the rest of the process's life. The stuck
|
||
* batch's accounts stay in accountsInFlight, so they are never traded twice,
|
||
* and if the stuck batch does wake up it finds it no longer holds the mutex
|
||
* and abandons its now-stale selection (holdsTradeMutex).
|
||
*
|
||
* Returns the lease to hand to releaseTradeMutex, or null to skip.
|
||
*/
|
||
function acquireTradeMutex(tag: string, lane: string): number | null {
|
||
const inFlight = (runtime().tradeInFlight ??= new Map());
|
||
const now = Date.now();
|
||
const since = inFlight.get(lane) ?? 0;
|
||
if (since > 0) {
|
||
const heldMs = now - since;
|
||
if (heldMs < TRADE_WEDGED_MS) {
|
||
console.log(`${tag} another ${laneName(lane)} batch in flight for ${Math.round(heldMs / 1000)}s — skipping`);
|
||
return null;
|
||
}
|
||
console.warn(`${tag} ${laneName(lane)} batch in flight for ${Math.round(heldMs / 60_000)}m looks wedged — proceeding without it`);
|
||
}
|
||
// Leases must differ even when two lanes lock in the same millisecond.
|
||
const lease = Math.max(now, since + 1);
|
||
inFlight.set(lane, lease);
|
||
return lease;
|
||
}
|
||
|
||
function holdsTradeMutex(lease: number, lane: string): boolean {
|
||
return runtime().tradeInFlight?.get(lane) === lease;
|
||
}
|
||
|
||
/** Only the holder unlocks: a wedged batch that finally returns must not
|
||
* release the batch that took over from it. */
|
||
function releaseTradeMutex(lease: number, lane: string): void {
|
||
if (holdsTradeMutex(lease, lane)) runtime().tradeInFlight?.delete(lane);
|
||
}
|
||
|
||
/** 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 firm its current pick. */
|
||
function housekeepingFor(firm: { platform: string; instruments?: string[]; bannedSymbols?: string[]; symbolMap?: Record<string, { symbol: string }[]> }): string {
|
||
if (firm.platform === 'dxtrade') 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 = cfg.dailyLossLimit ?? 0;
|
||
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 check: the room left today, or why there is none to
|
||
* trade with. Room is Infinity when the account type has no limit.
|
||
*/
|
||
function dailyLossGate(
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
client: any,
|
||
accountId: number,
|
||
cfg: { dailyLossLimit?: number; maxPositionSize: number; firmId: string },
|
||
cash: { amount: number; realizedPnL: number },
|
||
combinedRealized: number,
|
||
remainingToday: number,
|
||
singleContract: boolean,
|
||
): { room: number } | { reason: string } {
|
||
const dll = cfg.dailyLossLimit ?? 0;
|
||
if (!(dll > 0)) return { room: Infinity };
|
||
const dayStart: number | null | undefined = client.dayStartBalance?.(accountId);
|
||
if (dayStart === null) return { reason: DLL_BASE_PENDING };
|
||
const room = dllRoom({
|
||
dailyLossLimit: dll,
|
||
closedBalance: cash.amount,
|
||
dayStartBalance: dayStart,
|
||
// The broker's day, raw — and on a combining firm the worse of it
|
||
// and the combined figure, whichever the firm turns out to use.
|
||
realizedToday: Math.min(cash.realizedPnL, combinedRealized),
|
||
});
|
||
// Measured-fee firms: a typical round trip on the contracts this leg
|
||
// could use. Percent-fee firms size by notional, which isn't known
|
||
// here — fireLeg re-checks with the real size before any entry.
|
||
const contractsBound = singleContract ? 1
|
||
: Math.min(Math.max(1, Math.ceil(remainingToday / 1000)), cfg.maxPositionSize > 0 ? cfg.maxPositionSize : Infinity);
|
||
const percentFees = (getCachedFirm(cfg.firmId)?.commissionPct ?? 0) > 0;
|
||
const feeEstimate = percentFees ? 0 : 2 * FALLBACK_COMMISSION_PER_CONTRACT * contractsBound;
|
||
if (!dllViable(room, dll, feeEstimate)) {
|
||
return { reason: `daily loss limit: $${Math.round(room * 100) / 100} of $${dll} left — day booked` };
|
||
}
|
||
return { room };
|
||
}
|
||
|
||
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 };
|
||
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
|
||
);
|
||
|
||
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 };
|
||
|
||
// 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, 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;
|
||
}
|
||
|
||
/** 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: every account trades the same side.
|
||
const resolvedAction: 'Buy' | 'Sell' = action === 'Auto'
|
||
? (Math.random() < 0.5 ? 'Buy' : 'Sell')
|
||
: action;
|
||
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,
|
||
});
|
||
}
|
||
} 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: cfg.dailyLossLimit ?? 0,
|
||
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 = cfg.dailyLossLimit ?? 0;
|
||
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,
|
||
});
|
||
}
|
||
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, 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`);
|
||
}
|
||
const resolvedAction: 'Buy' | 'Sell' = heldSide ?? (action === 'Auto' ? (Math.random() < 0.5 ? 'Buy' : 'Sell') : action);
|
||
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,
|
||
};
|
||
// The instrument: a DXtrade 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 (firm.platform !== 'dxtrade') {
|
||
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);
|
||
}
|
||
}
|
||
|
||
// ── 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;
|
||
}
|
||
// 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]))
|
||
: {},
|
||
};
|
||
}
|