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