- A batch sizes its leg on every mapped candidate, each at the leverage the broker states for it, and trades the cheapest (spread, commissions, expected swap) that reaches the target hold — else the quickest. The dashboard ranks the same way from a half-hourly candidate survey. - Last stand: when a daily-loss stop-out would leave the account within 15% of that loss above its max-drawdown floor (the account type's static max loss, or the broker's liquidation floor), the leg carries no daily-loss stop and is sized against the firm's own breach. - The daily-loss reserve is 10% of the limit or 0.1% of the position, whichever is more, since a stop's slippage grows with the position. - The account type's max notional is gone: the broker's margin rates cap each symbol. planLegSize holds the one sizing rule fireLeg, the pick and the dashboard share. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1780 lines
85 KiB
TypeScript
1780 lines
85 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 { getCachedFirm, getTradableFirms } from './firm-catalog';
|
||
import { getAccountConfig } from './account-type-resolver';
|
||
import {
|
||
combineCmeSessions, computeDayPlan, groupLegFraction, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT,
|
||
obfuscationStopDollars, takeProfitPlan, percentFee, dllRoom, dllStopDollars, dllViable, stopTicks,
|
||
expectedSwapToWin, planLegSize, maxLossRoom, breachDistance,
|
||
DEFAULT_TARGET_HOLD_HOURS,
|
||
isNoTradeTime, isPastSessionClose,
|
||
BIG_DAY_THRESHOLD, LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY, POINT_VALUES,
|
||
FALLBACK_COMMISSION_PER_CONTRACT, MAX_COMMISSION_PER_CONTRACT, DLL_MIN_STOP,
|
||
type DayPlan,
|
||
} from './trading-logic';
|
||
import { getSetting, setSetting } from './db';
|
||
import { errorMessage, isClientReady, isOrderNotPlaced, type PlatformClient, type PlatformContract, 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 bracket the broker refused while a hard daily loss
|
||
* limit was on: the position was flattened and the account sits out the
|
||
* trade date rather than bleed a round trip per leg. */
|
||
exitRefused?: Map<number, string>;
|
||
// What getSchedulerHealth reports.
|
||
lastTickAt: number;
|
||
lastTickOutcome: TickOutcome;
|
||
/** The last tick's outcome per lane. */
|
||
laneOutcomes?: Map<string, TickOutcome>;
|
||
lastFireAt: number;
|
||
holding: string[];
|
||
quarantined: string[];
|
||
}
|
||
|
||
/** What the scheduler's last tick decided (getSchedulerHealth). */
|
||
export type TickOutcome =
|
||
| 'not-running' | 'holding-for-sync' | 'positions-open' | 'closed'
|
||
| 'no-eligible' | 'fired' | 'skipped-in-flight' | `error: ${string}`;
|
||
|
||
const _tradeGlobal = globalThis as typeof globalThis & { __autoTradeRuntime?: TradeRuntime };
|
||
|
||
function runtime(): TradeRuntime {
|
||
if (!_tradeGlobal.__autoTradeRuntime) {
|
||
_tradeGlobal.__autoTradeRuntime = {
|
||
legCounts: new Map(),
|
||
sessionOpenJitter: new Map(),
|
||
accountsInFlight: new Set(),
|
||
unreadySince: new Map(),
|
||
quarantineLoggedAt: new Map(),
|
||
lastTickAt: 0,
|
||
lastTickOutcome: 'not-running',
|
||
lastFireAt: 0,
|
||
holding: [],
|
||
quarantined: [],
|
||
};
|
||
}
|
||
return _tradeGlobal.__autoTradeRuntime;
|
||
}
|
||
|
||
/** How long the tick holds every firm for one whose client isn't ready. Long
|
||
* enough for a restart or a reconnect to finish syncing; past it the client
|
||
* is presumed dead and its firm is quarantined (see assessReadiness). */
|
||
const UNREADY_GRACE_MS = 10 * 60 * 1000;
|
||
/** How often a quarantined firm is re-announced in the log. */
|
||
const QUARANTINE_LOG_EVERY_MS = 5 * 60 * 1000;
|
||
/** A batch that has held the trade mutex this long is presumed wedged. */
|
||
const TRADE_WEDGED_MS = 10 * 60 * 1000;
|
||
|
||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
/** How many accounts one batch may fire, never below 1. The settings PATCH
|
||
* stores whatever the form sends, and a cleared field arrives as '' —
|
||
* parseInt('') is NaN, which slice() takes as 0: every batch would fire
|
||
* nobody while the tick reported 'fired'. Non-numeric falls back to the
|
||
* default 5, like a missing setting; the form's own floor is 1. */
|
||
function maxConcurrentAccounts(): number {
|
||
const configured = parseInt(getSetting('max_concurrent_accounts') ?? '5', 10);
|
||
return Math.max(1, Number.isFinite(configured) ? configured : 5);
|
||
}
|
||
|
||
function isAccountDead(amount: number, autoLiqThreshold: number): boolean {
|
||
// Tradovate floors are positive balances; reconstructed TopstepX MLL
|
||
// floors are negative (or +$0.01 once locked at breakeven). 0 = no floor.
|
||
return autoLiqThreshold !== 0 && amount <= autoLiqThreshold;
|
||
}
|
||
|
||
function legsToday(accountId: number, tradeDate: string): number {
|
||
const entry = runtime().legCounts.get(accountId);
|
||
return entry && entry.date === tradeDate ? entry.count : 0;
|
||
}
|
||
|
||
function legSumToday(accountId: number, tradeDate: string): number {
|
||
const entry = runtime().legCounts.get(accountId);
|
||
return entry && entry.date === tradeDate ? entry.sum : 0;
|
||
}
|
||
|
||
interface EntryOutcome {
|
||
tradeDate: string;
|
||
state: 'filled' | 'unknown';
|
||
/** When the entry went out, and the realized P&L just before it. */
|
||
at: number;
|
||
realizedBefore: number;
|
||
legAmount: number;
|
||
/** When its outcome was declared unknown (the send's error). */
|
||
unknownAt?: number;
|
||
}
|
||
|
||
/** An unknown entry counts as never filled only once a good sync that
|
||
* STARTED at least this long after it was declared unknown shows the account
|
||
* flat, with realized P&L unchanged and no order still working — a market
|
||
* order that could fill has done so by then. */
|
||
const UNKNOWN_ENTRY_SETTLE_MS = 90_000;
|
||
|
||
function entryOutcomeToday(accountId: number, tradeDate: string): EntryOutcome | null {
|
||
const outcome = runtime().entryOutcomes?.get(accountId);
|
||
return outcome && outcome.tradeDate === tradeDate ? outcome : null;
|
||
}
|
||
|
||
/**
|
||
* An entry whose outcome is unknown holds the account — it may be in a
|
||
* position no exit protects, and a retry would stack another on it — until
|
||
* broker state shows it never filled; then the leg is taken back and the
|
||
* account is free again. True while it still holds.
|
||
*/
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
function unknownEntryHolds(client: any, accountId: number, tradeDate: string): boolean {
|
||
const outcome = entryOutcomeToday(accountId, tradeDate);
|
||
if (!outcome || outcome.state !== 'unknown') return false;
|
||
const cash = client.accountCashBalances[accountId];
|
||
// Nothing is judged before a good sync that started after the window:
|
||
// until then a position read can predate the fill (or have failed) while
|
||
// the trades read after it already show the P&L it booked.
|
||
const syncedFrom = client.lastSyncStartedAt ?? client.lastSyncAt;
|
||
if (syncedFrom < (outcome.unknownAt ?? outcome.at) + UNKNOWN_ENTRY_SETTLE_MS || cash === undefined) return true;
|
||
if (client.hasWorkingOrders?.(accountId)) return true;
|
||
// The broker shows the fill (the position, or the P&L it booked): it
|
||
// filled after all. From here it's an ordinary filled entry — the
|
||
// position gate holds it while open, one-and-done is done for the day.
|
||
if (client.positions[accountId] || Math.abs(cash.realizedPnL - outcome.realizedBefore) >= 0.005) {
|
||
outcome.state = 'filled';
|
||
console.log(`[auto-trade] account ${accountId}: the entry of unknown outcome filled after all`);
|
||
return false;
|
||
}
|
||
runtime().entryOutcomes?.delete(accountId);
|
||
unrecordLeg(accountId, tradeDate, outcome.legAmount);
|
||
console.log(`[auto-trade] account ${accountId}: the entry of unknown outcome never filled — free to trade again`);
|
||
return false;
|
||
}
|
||
|
||
function recordLeg(accountId: number, tradeDate: string, legAmount: number): void {
|
||
runtime().legCounts.set(accountId, {
|
||
date: tradeDate,
|
||
count: legsToday(accountId, tradeDate) + 1,
|
||
sum: Math.round((legSumToday(accountId, tradeDate) + legAmount) * 100) / 100,
|
||
});
|
||
}
|
||
|
||
/** Take back a leg recordLeg counted for an entry that never reached the
|
||
* broker. Left standing, it would bench a stop-type account for the day —
|
||
* realized P&L "falls short" of a leg that never traded — and burn one of
|
||
* the day's MAX_LEGS_PER_DAY. */
|
||
function unrecordLeg(accountId: number, tradeDate: string, legAmount: number): void {
|
||
const legCounts = runtime().legCounts;
|
||
const entry = legCounts.get(accountId);
|
||
if (!entry || entry.date !== tradeDate || entry.count <= 0) return;
|
||
if (entry.count === 1) {
|
||
legCounts.delete(accountId);
|
||
return;
|
||
}
|
||
legCounts.set(accountId, {
|
||
date: tradeDate,
|
||
count: entry.count - 1,
|
||
sum: Math.round((entry.sum - legAmount) * 100) / 100,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Send a leg's market entry. The leg was recorded before this — the runaway
|
||
* guard has to see it while the batch is still in flight — so it is taken
|
||
* back here only when the client KNOWS the order never reached the broker
|
||
* (OrderNotPlacedError). Anything ambiguous, like an ack or fill timeout,
|
||
* keeps the leg: the order may well have filled, and a leg counted that never
|
||
* happened only costs a leg, while one forgotten that did can trade the
|
||
* account twice.
|
||
*/
|
||
async function sendEntry(
|
||
tag: string,
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
client: any,
|
||
acc: { id: number; name: string },
|
||
contractName: string,
|
||
contracts: number,
|
||
action: 'Buy' | 'Sell',
|
||
tradeDate: string,
|
||
legAmount: number,
|
||
) {
|
||
const outcomes = (runtime().entryOutcomes ??= new Map());
|
||
const at = Date.now();
|
||
const realizedBefore = client.accountCashBalances[acc.id]?.realizedPnL ?? 0;
|
||
try {
|
||
const fill = await client.sendOrder(acc.id, contractName, contracts, action, 'Market');
|
||
outcomes.set(acc.id, { tradeDate, state: 'filled', at, realizedBefore, legAmount });
|
||
return fill;
|
||
} catch (err) {
|
||
if (isOrderNotPlaced(err)) {
|
||
unrecordLeg(acc.id, tradeDate, legAmount);
|
||
console.warn(`${tag} ${acc.name}: entry never reached the broker — leg un-counted`);
|
||
} else {
|
||
outcomes.set(acc.id, { tradeDate, state: 'unknown', at, realizedBefore, legAmount, unknownAt: Date.now() });
|
||
console.warn(`${tag} ${acc.name}: entry outcome unknown — leg kept and the account held until the broker shows it`);
|
||
}
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
/** Run one account's part of a batch with the account marked in flight, so
|
||
* no other batch picks it while its orders are still being placed. */
|
||
async function withAccountInFlight<T>(accountId: number, fire: () => Promise<T>): Promise<T> {
|
||
const inFlight = runtime().accountsInFlight;
|
||
inFlight.add(accountId);
|
||
try {
|
||
return await fire();
|
||
} finally {
|
||
inFlight.delete(accountId);
|
||
}
|
||
}
|
||
|
||
// ── readiness ────────────────────────────────────────────────────────────────
|
||
|
||
type TradableFirm = ReturnType<typeof getTradableFirms>[number];
|
||
interface FirmClient { firm: TradableFirm; client: PlatformClient }
|
||
|
||
// ── lanes ────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Which lane a firm trades in. Lanes are independent: each has its own
|
||
* readiness hold, trading hours, all-flat gate, trade mutex and batch, so a
|
||
* multi-hour crypto position never holds up the futures open and a stuck
|
||
* futures position never freezes a 24/7 firm's weekend. Every CME-futures
|
||
* firm (Tradovate, TopstepX) shares the one 'futures' lane — exactly how all
|
||
* firms traded before lanes existed; each DXTrade firm is a lane of its own.
|
||
*/
|
||
export function laneOf(firm: { id: string; platform: string }): string {
|
||
return firm.platform === 'dxtrade' ? firm.id : FUTURES_LANE;
|
||
}
|
||
const FUTURES_LANE = 'futures';
|
||
|
||
function inLane<T extends { firm: TradableFirm }>(items: T[], lane: string): T[] {
|
||
return items.filter((i) => laneOf(i.firm) === lane);
|
||
}
|
||
|
||
/** The lanes of every tradable firm, futures first. */
|
||
function tradableLanes(): string[] {
|
||
const lanes = new Set(getTradableFirms().map((f) => laneOf(f)));
|
||
return [...lanes].sort((a, b) => (a === FUTURES_LANE ? -1 : b === FUTURES_LANE ? 1 : a.localeCompare(b)));
|
||
}
|
||
|
||
function laneName(lane: string): string {
|
||
if (lane === FUTURES_LANE) return FUTURES_LANE;
|
||
return getTradableFirms().find((f) => f.id === lane)?.name ?? lane;
|
||
}
|
||
|
||
/**
|
||
* Tradable firms that have a client, split by isClientReady. Everything that
|
||
* trades or gates works from `ready` only: a client whose sync is missing or
|
||
* stale holds a frozen snapshot, whose positions may be long gone and which
|
||
* may be missing ones opened since — it must neither trade nor gate.
|
||
*/
|
||
function firmClients(now = Date.now()): { ready: FirmClient[]; unready: FirmClient[] } {
|
||
const clients = getClients();
|
||
const ready: FirmClient[] = [];
|
||
const unready: FirmClient[] = [];
|
||
for (const firm of getTradableFirms()) {
|
||
const client = clients.get(firm.id);
|
||
if (!client) continue;
|
||
(isClientReady(client, now) ? ready : unready).push({ firm, client });
|
||
}
|
||
return { ready, unready };
|
||
}
|
||
|
||
function countOpenPositions(ready: FirmClient[]): number {
|
||
return ready.reduce(
|
||
(count, { client }) => count + client.accountList.filter((acc) => !!client.positions[acc.id]).length,
|
||
0,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Which firms hold the tick, and which it trades without.
|
||
*
|
||
* A firm whose client isn't ready might hold positions nobody can see, so for
|
||
* the first UNREADY_GRACE_MS the whole tick waits for it, as it always has —
|
||
* right for a restart or a reconnect, which settle within a minute or two.
|
||
* Past the grace period the client is presumed dead (broker maintenance, a
|
||
* login that keeps failing) and the firm is quarantined: the other firms
|
||
* trade without it, rather than one broken session stopping every firm on the
|
||
* machine. A quarantined firm trades nothing — runTrade and copyTrade only
|
||
* ever see ready clients — and rejoins on its first good sync.
|
||
*
|
||
* A client that has synced and found no accounts is ignored outright: it
|
||
* cannot hold positions or trade, so there is nothing to wait for.
|
||
*/
|
||
function assessReadiness(
|
||
ready: FirmClient[],
|
||
unready: FirmClient[],
|
||
now: number,
|
||
): { holding: TradableFirm[]; quarantined: { firm: TradableFirm; unreadyMs: number }[] } {
|
||
const rt = runtime();
|
||
const holding: TradableFirm[] = [];
|
||
const quarantined: { firm: TradableFirm; unreadyMs: number }[] = [];
|
||
const tracked = new Set<string>();
|
||
|
||
for (const { firm, client } of unready) {
|
||
if (client.lastSyncAt > 0 && client.accountList.length === 0) continue;
|
||
tracked.add(firm.id);
|
||
const since = rt.unreadySince.get(firm.id) ?? now;
|
||
rt.unreadySince.set(firm.id, since);
|
||
const unreadyMs = now - since;
|
||
if (unreadyMs < UNREADY_GRACE_MS) holding.push(firm);
|
||
else quarantined.push({ firm, unreadyMs });
|
||
}
|
||
|
||
// Forget firms that are ready again (or gone), and say so for any that
|
||
// had been traded around.
|
||
for (const id of [...rt.unreadySince.keys()]) {
|
||
if (tracked.has(id)) continue;
|
||
const back = ready.find((r) => r.firm.id === id);
|
||
if (back && rt.quarantineLoggedAt.has(id)) {
|
||
console.log(`[scheduler] ${back.firm.name} synced again — trading it with the others`);
|
||
}
|
||
rt.unreadySince.delete(id);
|
||
rt.quarantineLoggedAt.delete(id);
|
||
}
|
||
|
||
return { holding, quarantined };
|
||
}
|
||
|
||
// ── trade mutex ──────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* One batch at a time per lane. The tick, a master "trade" command,
|
||
* POST /api/trade and /api/copy-trade can all fire a batch, and two batches
|
||
* collecting at once would each see the other's accounts as still flat — the
|
||
* position gate only trips once a fill has come back over the socket. Lanes
|
||
* never share accounts, so each has its own mutex.
|
||
*
|
||
* A batch that has held the mutex past TRADE_WEDGED_MS is presumed stuck on a
|
||
* broker call that will never answer, and the next one goes ahead without it
|
||
* rather than nothing trading for the rest of the process's life. The stuck
|
||
* batch's accounts stay in accountsInFlight, so they are never traded twice,
|
||
* and if the stuck batch does wake up it finds it no longer holds the mutex
|
||
* and abandons its now-stale selection (holdsTradeMutex).
|
||
*
|
||
* Returns the lease to hand to releaseTradeMutex, or null to skip.
|
||
*/
|
||
function acquireTradeMutex(tag: string, lane: string): number | null {
|
||
const inFlight = (runtime().tradeInFlight ??= new Map());
|
||
const now = Date.now();
|
||
const since = inFlight.get(lane) ?? 0;
|
||
if (since > 0) {
|
||
const heldMs = now - since;
|
||
if (heldMs < TRADE_WEDGED_MS) {
|
||
console.log(`${tag} another ${laneName(lane)} batch in flight for ${Math.round(heldMs / 1000)}s — skipping`);
|
||
return null;
|
||
}
|
||
console.warn(`${tag} ${laneName(lane)} batch in flight for ${Math.round(heldMs / 60_000)}m looks wedged — proceeding without it`);
|
||
}
|
||
// Leases must differ even when two lanes lock in the same millisecond.
|
||
const lease = Math.max(now, since + 1);
|
||
inFlight.set(lane, lease);
|
||
return lease;
|
||
}
|
||
|
||
function holdsTradeMutex(lease: number, lane: string): boolean {
|
||
return runtime().tradeInFlight?.get(lane) === lease;
|
||
}
|
||
|
||
/** Only the holder unlocks: a wedged batch that finally returns must not
|
||
* release the batch that took over from it. */
|
||
function releaseTradeMutex(lease: number, lane: string): void {
|
||
if (holdsTradeMutex(lease, lane)) runtime().tradeInFlight?.delete(lane);
|
||
}
|
||
|
||
/** The instrument a leg actually trades: drain legs go on MNQ (whatever the
|
||
* platform maps MNQ to), hardcoded like the extra-day path — tiny targets
|
||
* belong on the micro regardless of the batch symbol. */
|
||
async function legInstrument(
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
client: any,
|
||
drain: boolean,
|
||
batchContract: LegContract,
|
||
batchPointValue: number,
|
||
): Promise<{ contract: LegContract; pointValue: number }> {
|
||
if (!drain) return { contract: batchContract, pointValue: batchPointValue };
|
||
const mnq: LegContract | null = await client.findFrontMonthContract('MNQ');
|
||
if (!mnq) throw new Error('MNQ contract not found for drain trade');
|
||
return { contract: mnq, pointValue: mnq.pointValue ?? POINT_VALUES['MNQ'] };
|
||
}
|
||
|
||
/** What the fire path needs of a contract — PlatformContract minus the id. */
|
||
type LegContract = Omit<PlatformContract, 'id'>;
|
||
|
||
/**
|
||
* The entry fee, read off the realized-P&L delta around the fill. Anything
|
||
* past a real per-contract fee is something else moving realized P&L — a
|
||
* broker record rolling to the new trade date under the entry swapped a whole
|
||
* day's P&L into the "fee", and doubled into the exit target that parked the
|
||
* take-profit out of reach. Null when there is no usable reading — no update
|
||
* yet, or that.
|
||
*/
|
||
function entryCommissionMeasured(before: number, after: number, contracts: number): number | null {
|
||
const delta = Math.abs(after - before);
|
||
if (delta > 0 && delta <= MAX_COMMISSION_PER_CONTRACT * contracts) return delta;
|
||
if (delta > 0) {
|
||
console.warn(`[auto-trade] realized P&L moved $${Math.round(delta * 100) / 100} around a ${contracts}-contract entry`
|
||
+ ` — not a fee; using $${FALLBACK_COMMISSION_PER_CONTRACT}/contract`);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ── per-account trade evaluation (shared by runTrade / copyTrade / hasRemaining…) ──
|
||
|
||
/** The ineligible reason for an account whose history isn't loaded yet — still
|
||
* in play for stop-after-all, since it trades once the fetch lands. */
|
||
const HISTORY_PENDING = 'history not loaded yet — waiting for the fetch';
|
||
/** Likewise for an account whose broker hasn't rolled its cash record to the
|
||
* new trade date, when this process didn't see the old day end. */
|
||
const ROLL_PENDING = "waiting for the broker to roll the account's day";
|
||
/** An entry whose outcome is unknown, waiting for the broker to show it. */
|
||
const ENTRY_UNKNOWN = 'entry outcome unknown — waiting for the broker to show it';
|
||
/** A hard daily loss limit is set but the platform can't tell yet where the
|
||
* firm's day started — still in play, like a pending history. */
|
||
const DLL_BASE_PENDING = "daily loss limit: waiting for the day's starting balance";
|
||
|
||
/** Reasons that mean "not yet", not "done for the day" — stop-after-all keeps
|
||
* the scheduler up for these until the session closes. */
|
||
const PENDING_REASONS = new Set([HISTORY_PENDING, ROLL_PENDING, ENTRY_UNKNOWN, DLL_BASE_PENDING]);
|
||
|
||
interface AccountEvaluation {
|
||
eligible: boolean;
|
||
reason: string;
|
||
plan: DayPlan | null;
|
||
isExtraDay: boolean;
|
||
/** Current stage is a drain stage — the leg trades MNQ regardless of the batch symbol. */
|
||
drain: boolean;
|
||
cash: { amount: number; realizedPnL: number };
|
||
dailyPnL: { date: string; pnl: number }[];
|
||
daysTraded: number;
|
||
/** What the firm's day may still lose before a hard daily-loss breach
|
||
* (Infinity = no limit). */
|
||
dllRoom: number;
|
||
}
|
||
|
||
/**
|
||
* The hard daily-loss check: the room left today, or why there is none to
|
||
* trade with. Room is Infinity when the account type has no limit.
|
||
*/
|
||
function dailyLossGate(
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
client: any,
|
||
accountId: number,
|
||
cfg: { dailyLossLimit?: number; maxPositionSize: number; firmId: string },
|
||
cash: { amount: number; realizedPnL: number },
|
||
combinedRealized: number,
|
||
remainingToday: number,
|
||
singleContract: boolean,
|
||
): { room: number } | { reason: string } {
|
||
const dll = cfg.dailyLossLimit ?? 0;
|
||
if (!(dll > 0)) return { room: Infinity };
|
||
const dayStart: number | null | undefined = client.dayStartBalance?.(accountId);
|
||
if (dayStart === null) return { reason: DLL_BASE_PENDING };
|
||
const room = dllRoom({
|
||
dailyLossLimit: dll,
|
||
closedBalance: cash.amount,
|
||
dayStartBalance: dayStart,
|
||
// The broker's day, raw — and on a combining firm the worse of it
|
||
// and the combined figure, whichever the firm turns out to use.
|
||
realizedToday: Math.min(cash.realizedPnL, combinedRealized),
|
||
});
|
||
// Measured-fee firms: a typical round trip on the contracts this leg
|
||
// could use. Percent-fee firms size by notional, which isn't known
|
||
// here — fireLeg re-checks with the real size before any entry.
|
||
const contractsBound = singleContract ? 1
|
||
: Math.min(Math.max(1, Math.ceil(remainingToday / 1000)), cfg.maxPositionSize > 0 ? cfg.maxPositionSize : Infinity);
|
||
const percentFees = (getCachedFirm(cfg.firmId)?.commissionPct ?? 0) > 0;
|
||
const feeEstimate = percentFees ? 0 : 2 * FALLBACK_COMMISSION_PER_CONTRACT * contractsBound;
|
||
if (!dllViable(room, dll, feeEstimate)) {
|
||
return { reason: `daily loss limit: $${Math.round(room * 100) / 100} of $${dll} left — day booked` };
|
||
}
|
||
return { room };
|
||
}
|
||
|
||
/**
|
||
* Evaluate whether an account can trade right now. Returns null when the account has no
|
||
* matching config (not ours). Split-allowed accounts stay eligible mid-day until
|
||
* remainingToday drops to LEG_COMPLETION_EPSILON or the leg cap is hit; extra-day and
|
||
* strict-consistency (past day 1) accounts keep the legacy one-trade-per-day gate.
|
||
*/
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
function evaluateAccountForTrade(client: any, acc: { id: number; name: string; active: boolean }, tradeDate: string): AccountEvaluation | null {
|
||
const cfg = getAccountConfig(acc.id).accountType;
|
||
if (!cfg) return null;
|
||
|
||
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
|
||
const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0;
|
||
|
||
// A combining firm records holiday sessions with the next full trading day,
|
||
// so the plan and the already-traded gates below must see the whole stretch
|
||
// as one day: Sunday's booked P&L folds into Tuesday's realized, and the
|
||
// day target stays anchored to the combined (clearing) trade date. `cash`
|
||
// itself stays raw — the commission delta in the fire path compares live
|
||
// Tradovate values.
|
||
const combined = combineCmeSessions(
|
||
cfg.combineCmeHolidays, client.dailyPnL[acc.id] ?? [], tradeDate, cash.realizedPnL,
|
||
);
|
||
const dailyPnL = combined.dailyPnL;
|
||
const daysTraded = combined.daysTraded;
|
||
|
||
const base = { plan: null, isExtraDay: false, drain: false, cash, dailyPnL, daysTraded, dllRoom: Infinity };
|
||
if (isAccountDead(cash.amount, autoLiqThreshold)) return { ...base, eligible: false, reason: 'dead' };
|
||
if (!acc.active) return { ...base, eligible: false, reason: 'inactive (DLL)' };
|
||
if (client.positions[acc.id]) return { ...base, eligible: false, reason: 'already in trade' };
|
||
// Another batch is placing this account's order right now; its position
|
||
// won't show until the fill comes back, so the check above can't see it.
|
||
if (runtime().accountsInFlight.has(acc.id)) return { ...base, eligible: false, reason: 'order in flight' };
|
||
// History this client hasn't loaded (a restart, a rebuild, a wipe, a new
|
||
// account) or that a payout has overtaken would plan the account as a
|
||
// fresh stage-1 first day: an oversized leg, or a re-opened finished cycle.
|
||
if (!client.historyReady(acc.id)) return { ...base, eligible: false, reason: HISTORY_PENDING };
|
||
if (cash.pendingRoll) return { ...base, eligible: false, reason: ROLL_PENDING };
|
||
if (unknownEntryHolds(client, acc.id, tradeDate)) return { ...base, eligible: false, reason: ENTRY_UNKNOWN };
|
||
if (runtime().exitRefused?.get(acc.id) === tradeDate) {
|
||
return { ...base, eligible: false, reason: 'exit bracket refused — flattened, benched for the day' };
|
||
}
|
||
|
||
// Obfuscation stops: a stop-out shows as realized P&L falling short of the
|
||
// legs fired today — the day is booked; the make-back ride is tomorrow.
|
||
// The plain negative-realized check is what survives a restart, when the
|
||
// in-memory leg counter has forgotten the morning's stop-out.
|
||
if (cfg.firstDayStop || cfg.minDayStop) {
|
||
if (combined.realizedPnL <= -25) {
|
||
return { ...base, eligible: false, reason: 'red day booked — make-back rides tomorrow' };
|
||
}
|
||
if (legsToday(acc.id, tradeDate) > 0
|
||
&& combined.realizedPnL < legSumToday(acc.id, tradeDate) - 25) {
|
||
return { ...base, eligible: false, reason: 'stopped out — day booked' };
|
||
}
|
||
}
|
||
|
||
const priorProfit = client.priorProfit?.[acc.id] ?? 0;
|
||
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
|
||
const effective = resolveEffectiveConfig(
|
||
cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, priorProfit, allFundTxns,
|
||
cfg.minDayPnL
|
||
);
|
||
|
||
const equityProfit = cash.amount - cfg.accountSize;
|
||
const plan = computeDayPlan(
|
||
acc.id, combined.tradeDate, effective.profitTarget, effective.consistency,
|
||
dailyPnL, effective.minDayPnL, effective.minTradingDays, equityProfit, combined.realizedPnL,
|
||
cfg.splitEnabled, effective.drain
|
||
);
|
||
if (!plan) return { ...base, eligible: false, reason: 'invalid balance' };
|
||
|
||
const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0);
|
||
const isExtraDay = !effective.drain
|
||
&& effective.minDayPnL <= 0
|
||
&& effective.minTradingDays > daysTraded
|
||
&& totalProfit >= effective.profitTarget;
|
||
|
||
const evaluated = { plan, isExtraDay, drain: effective.drain, cash, dailyPnL, daysTraded, dllRoom: Infinity };
|
||
|
||
if (isExtraDay || !plan.splitAllowed) {
|
||
// Legacy one-and-done gate: any realized PnL today means this account is
|
||
// finished — for a combining account, "today" spans the holiday stretch.
|
||
if (combined.realizedPnL !== 0) {
|
||
return { ...evaluated, eligible: false, reason: `already traded today (realizedPnL=${combined.realizedPnL})` };
|
||
}
|
||
// And any entry of today's the broker confirmed filled, whatever its
|
||
// realized P&L says (a record replaced under it, a fill not booked
|
||
// yet). One of unknown outcome is held above until settled.
|
||
if (entryOutcomeToday(acc.id, tradeDate)?.state === 'filled') {
|
||
return { ...evaluated, eligible: false, reason: 'already traded today (entry filled)' };
|
||
}
|
||
if (!isExtraDay && plan.remainingToday <= 0) {
|
||
const reason = effective.drain && plan.dayTarget === 0
|
||
? 'drain days booked — waiting for withdrawal'
|
||
: `target=$${plan.dayTarget} (challenge complete)`;
|
||
return { ...evaluated, eligible: false, reason };
|
||
}
|
||
} else {
|
||
// Split-leg mode: keep trading until the day's remaining target is used up.
|
||
// The epsilon only applies once a leg has completed, so tiny day-start targets still trade.
|
||
const doneThreshold = combined.realizedPnL === 0 ? 0 : LEG_COMPLETION_EPSILON;
|
||
if (plan.remainingToday <= doneThreshold) {
|
||
const reason = combined.realizedPnL === 0
|
||
? `target=$${plan.dayTarget} (challenge complete)`
|
||
: `day done ($${plan.remainingToday} left of $${plan.dayTarget})`;
|
||
return { ...evaluated, eligible: false, reason };
|
||
}
|
||
if (legsToday(acc.id, tradeDate) >= MAX_LEGS_PER_DAY) {
|
||
return { ...evaluated, eligible: false, reason: `leg cap reached (${MAX_LEGS_PER_DAY} today)` };
|
||
}
|
||
}
|
||
|
||
// Hard daily loss limit: checked last, so the more telling reasons above
|
||
// (day done, challenge complete, leg cap) win. A daily-loss stop-out
|
||
// leaves about the reserve plus fees, which is below what a trade needs —
|
||
// so a stop-out benches the account for the rest of the day by itself.
|
||
const gate = dailyLossGate(client, acc.id, cfg, cash, combined.realizedPnL, plan.remainingToday, isExtraDay || effective.drain);
|
||
if ('reason' in gate) return { ...evaluated, eligible: false, reason: gate.reason };
|
||
if (Number.isFinite(gate.room)) return { ...evaluated, eligible: true, reason: '', dllRoom: gate.room };
|
||
|
||
return { ...evaluated, eligible: true, reason: '' };
|
||
}
|
||
|
||
|
||
|
||
|
||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
// Random 1–20 minute delay added to the session open, picked once per Central
|
||
// calendar day. Re-rolling on every check would move the open boundary between
|
||
// polls, letting a trade slip through a moment the next check would reject.
|
||
// Held in runtime() so every bundle of this module agrees on the day's roll.
|
||
function openJitterMinutes(centralDay: string): number {
|
||
const sessionOpenJitter = runtime().sessionOpenJitter;
|
||
let jitter = sessionOpenJitter.get(centralDay);
|
||
if (jitter === undefined) {
|
||
jitter = 1 + Math.floor(Math.random() * 20); // 1–20 inclusive
|
||
sessionOpenJitter.clear(); // only today's entry is ever needed
|
||
sessionOpenJitter.set(centralDay, jitter);
|
||
console.log(`[trading-hours] session open delayed ${jitter} min today`);
|
||
}
|
||
return jitter;
|
||
}
|
||
|
||
/** Today's session-open delay in minutes, for reporting to the master. */
|
||
export function todaysSessionOpenDelay(): number {
|
||
const centralDay = new Intl.DateTimeFormat('en-CA', {
|
||
timeZone: 'America/Chicago',
|
||
}).format(new Date());
|
||
return openJitterMinutes(centralDay);
|
||
}
|
||
|
||
/** The trade date's session has closed and the next hasn't opened: weekdays
|
||
* 2:55–5:00 PM CT for CME firms, the pre-roll guard for an every-day firm.
|
||
* The rest of the no-trade window — the roll to the open, and weekends — is
|
||
* waiting on a session to come. */
|
||
function isAfterSessionClose(firm: { tradesEveryDay?: boolean }, now: Date = new Date()): boolean {
|
||
return isPastSessionClose(now, firm.tradesEveryDay === true);
|
||
}
|
||
|
||
/**
|
||
* Whether the firm takes no new trades right now (see isNoTradeTime). CME
|
||
* firms follow the trading_hours setting — "full_cme" or "equity_hours" — with
|
||
* the open pushed back by the day's 1–20 minute jitter (openJitterMinutes), so
|
||
* sessions never start at the exact same time. Every-day firms ignore the
|
||
* setting and only stand down around the daily roll.
|
||
*/
|
||
function isInNoTradeWindow(firm: { tradesEveryDay?: boolean }, now: Date = new Date()): boolean {
|
||
return isNoTradeTime(now, {
|
||
everyDay: firm.tradesEveryDay === true,
|
||
mode: getSetting('trading_hours') ?? 'full_cme',
|
||
jitterMin: todaysSessionOpenDelay(),
|
||
});
|
||
}
|
||
|
||
// ── core trade logic ──────────────────────────────────────────────────────────
|
||
|
||
/** One account's outcome in a batch: its fill summary, or { status: 'error', reason }. */
|
||
type LegResult = { status: string; [key: string]: unknown };
|
||
type FirmTradeResults = { firm: string; results: LegResult[] };
|
||
type TradeOutcome = Extract<TickOutcome, 'closed' | 'positions-open' | 'no-eligible' | 'fired' | 'skipped-in-flight'>;
|
||
|
||
/** One batch in every lane — a master "trade" command, POST /api/trade. */
|
||
export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string): Promise<FirmTradeResults[]> {
|
||
const results: FirmTradeResults[] = [];
|
||
for (const lane of tradableLanes()) {
|
||
results.push(...(await runTradeBatch(action, symbol, lane)).results);
|
||
}
|
||
return results;
|
||
}
|
||
|
||
/** One lane's batch plus what it decided, which the tick records for getSchedulerHealth. */
|
||
async function runTradeBatch(
|
||
action: 'Buy' | 'Sell' | 'Auto',
|
||
symbol: string,
|
||
lane: string,
|
||
): Promise<{ outcome: TradeOutcome; results: FirmTradeResults[] }> {
|
||
const lease = acquireTradeMutex('[auto-trade]', lane);
|
||
if (lease === null) return { outcome: 'skipped-in-flight', results: [] };
|
||
try {
|
||
return await runTradeLocked(action, symbol, lease, lane);
|
||
} finally {
|
||
releaseTradeMutex(lease, lane);
|
||
}
|
||
}
|
||
|
||
/** An eligible account and everything fireLeg needs to trade it. */
|
||
interface LegItem {
|
||
firmName: string;
|
||
firm: TradableFirm;
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
client: any;
|
||
acc: { id: number; name: string; active: boolean };
|
||
contract: LegContract;
|
||
/** Dollars per 1.0 of price per contract of the batch contract. */
|
||
pv: number;
|
||
cash: { amount: number; realizedPnL: number };
|
||
dailyPnL: { date: string; pnl: number }[];
|
||
plan: DayPlan;
|
||
drain: boolean;
|
||
prefix: string;
|
||
/** Hard daily-loss room (Infinity = no limit). */
|
||
dllRoom: number;
|
||
}
|
||
|
||
/** 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<{ outcome: TradeOutcome; results: FirmTradeResults[] }> {
|
||
const laneFirms = getTradableFirms().filter((f) => laneOf(f) === lane);
|
||
if (laneFirms.every((f) => isInNoTradeWindow(f))) {
|
||
console.log(`[auto-trade] ${laneName(lane)}: market closed — skipping`);
|
||
return { outcome: 'closed', results: [] };
|
||
}
|
||
|
||
// Block if any account in the lane still has an open position (same gate
|
||
// as the scheduler tick). Ready clients only, like everything below: a
|
||
// stale snapshot must neither trade nor gate.
|
||
const laneReady = inLane(firmClients().ready, lane);
|
||
const openPositions = countOpenPositions(laneReady);
|
||
if (openPositions > 0) {
|
||
console.log(`[auto-trade] ${laneName(lane)}: ${openPositions} position(s) still open — skipping`);
|
||
return { outcome: 'positions-open', results: [] };
|
||
}
|
||
const open = laneReady.filter(({ firm }) => !isInNoTradeWindow(firm));
|
||
|
||
// Resolve 'Auto' symbol once per batch so all accounts trade the same symbol
|
||
let resolvedSymbol = symbol;
|
||
if (symbol === 'Auto') {
|
||
const enabled = getInstruments().filter((i) => i.enabled).map((i) => i.symbol);
|
||
resolvedSymbol = enabled.length > 0 ? enabled[Math.floor(Math.random() * enabled.length)] : 'NQ';
|
||
console.log(`[auto-trade] ${laneName(lane)}: random symbol resolved to: ${resolvedSymbol}`);
|
||
}
|
||
// Resolve Auto action once per batch so all accounts trade the same direction
|
||
const resolvedAction: 'Buy' | 'Sell' = action === 'Auto'
|
||
? (Math.random() < 0.5 ? 'Buy' : 'Sell')
|
||
: action;
|
||
// The CME product's point value. A platform that maps the root to its own
|
||
// instrument (DXTrade) supplies the contract's point value instead.
|
||
const basePointValue: number | undefined = POINT_VALUES[resolvedSymbol];
|
||
|
||
const tradeDate = currentTradeDateCT();
|
||
const maxConcurrent = maxConcurrentAccounts();
|
||
|
||
// ── Phase 1: collect ALL eligible accounts across the lane's firms in parallel ──
|
||
type EligibleItem = LegItem & { daysTraded: number; isExtraDay: boolean };
|
||
|
||
const allEligible: EligibleItem[] = [];
|
||
|
||
await Promise.all(open.map(async ({ firm, client }) => {
|
||
if (client.accountList.length === 0) return;
|
||
|
||
// Skip this firm entirely if the symbol is banned for it
|
||
if (firm.bannedSymbols.includes(resolvedSymbol)) {
|
||
console.log(`[auto-trade] ${firm.name}: ${resolvedSymbol} is banned — skipping firm`);
|
||
return;
|
||
}
|
||
|
||
// One firm's trouble (a contract lookup that throws, a malformed
|
||
// snapshot) skips that firm, never the batch — a rejection here would
|
||
// fail the Promise.all and every other firm would sit the tick out.
|
||
try {
|
||
// The accounts first: a platform that chooses between
|
||
// instruments ranks them by how the batch's leg would size.
|
||
const evaluated: (SizingItem & { daysTraded: number; isExtraDay: boolean })[] = [];
|
||
for (const acc of client.accountList) {
|
||
const ev = evaluateAccountForTrade(client, acc, tradeDate);
|
||
if (!ev || !ev.eligible || !ev.plan) continue;
|
||
evaluated.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,
|
||
});
|
||
}
|
||
if (evaluated.length === 0) return;
|
||
|
||
const contract = await client.findFrontMonthContract(
|
||
resolvedSymbol, pickContextFor(evaluated.filter((it) => !it.isExtraDay && !it.drain), tradeDate));
|
||
if (!contract) return;
|
||
const pv = contract.pointValue ?? basePointValue;
|
||
if (!pv) {
|
||
console.log(`[auto-trade] ${firm.name}: no point value for ${resolvedSymbol} — skipping firm`);
|
||
return;
|
||
}
|
||
const found: EligibleItem[] = evaluated.map((it) => ({ ...it, contract, pv }));
|
||
allEligible.push(...found);
|
||
} catch (err) {
|
||
console.error(`[auto-trade] ${firm.name}: skipped — ${errorMessage(err)}`);
|
||
}
|
||
}));
|
||
|
||
if (allEligible.length === 0) {
|
||
console.log(`[auto-trade] ${laneName(lane)}: no eligible accounts found`);
|
||
return { outcome: 'no-eligible', results: [] };
|
||
}
|
||
|
||
// Collecting took so long that another batch presumed this one wedged and
|
||
// took the mutex. That batch traded on fresher numbers than these, so
|
||
// firing now could trade an account twice.
|
||
if (!holdsTradeMutex(lease, lane)) {
|
||
console.warn('[auto-trade] superseded while collecting accounts — abandoning this batch');
|
||
return { outcome: 'skipped-in-flight', results: [] };
|
||
}
|
||
|
||
// Take only the first batch — all fired simultaneously, no rolling pool.
|
||
// Remaining accounts wait for the next tick (which only fires once all positions are flat).
|
||
// Like accounts trade together: a batch is drawn from a single prefix group
|
||
// (largest group first), never mixed across prefixes — a group's remainder
|
||
// fires as a smaller batch on a later tick instead of being padded from
|
||
// another prefix. E.g. 4×FTDFY, 5×MFFUEV, 7×MFFUSF at limit 5 fires as
|
||
// batches of 5, 5, 4, 2.
|
||
const groups = new Map<string, EligibleItem[]>();
|
||
for (const item of allEligible) {
|
||
const group = groups.get(item.prefix);
|
||
if (group) group.push(item);
|
||
else groups.set(item.prefix, [item]);
|
||
}
|
||
const [batchPrefix, batchGroup] = [...groups.entries()]
|
||
.sort((a, b) => b[1].length - a[1].length || a[0].localeCompare(b[0]))[0];
|
||
const batch = batchGroup.slice(0, maxConcurrent);
|
||
console.log(`[auto-trade] ${laneName(lane)}: ${allEligible.length} eligible account(s) across ${groups.size} prefix group(s) — firing batch of ${batch.length} ${batchPrefix || '(no prefix)'}`);
|
||
runtime().lastFireAt = Date.now();
|
||
|
||
// ── Phase 2: fire the batch simultaneously ──
|
||
// Each account is marked in flight before anything is placed and released
|
||
// once its orders are done, so no overlapping batch can pick it meanwhile.
|
||
const tradeResults = await Promise.allSettled(batch.map((item) => withAccountInFlight(item.acc.id, async () => {
|
||
const { client, acc } = item;
|
||
|
||
// Extra-day mode: profit target already met, no min day P&L, days still needed.
|
||
// Just trade 1 MNQ in and out at market immediately — P&L doesn't matter.
|
||
if (item.isExtraDay) {
|
||
const mnqContract = await client.findFrontMonthContract('MNQ');
|
||
if (!mnqContract) throw new Error('MNQ contract not found for extra-day trade');
|
||
|
||
const fill = await client.sendOrder(acc.id, mnqContract.name, 1, resolvedAction, 'Market');
|
||
const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy';
|
||
const exitFill = await client.sendOrder(acc.id, mnqContract.name, 1, exitAction, 'Market');
|
||
|
||
console.log(`[auto-trade] ${acc.name} (${item.firmName}) extra-day: ${resolvedAction} 1x${mnqContract.name} @ ${fill.price} | exited @ ${exitFill.price} (market)`);
|
||
|
||
return {
|
||
account: acc.name,
|
||
firm: item.firmName,
|
||
status: 'filled',
|
||
contracts: 1,
|
||
target: 0,
|
||
grossTarget: 0,
|
||
totalCommission: 0,
|
||
targetPath: 'extra_day',
|
||
entryPrice: fill.price,
|
||
exitPrice: exitFill.price,
|
||
commission: 0,
|
||
};
|
||
}
|
||
|
||
return fireLeg('[auto-trade]', item, resolvedAction, resolvedSymbol, tradeDate);
|
||
})));
|
||
|
||
// Group results by firm for the response
|
||
const firmResultsMap = new Map<string, LegResult[]>();
|
||
for (let i = 0; i < batch.length; i++) {
|
||
const firmName = batch[i].firmName;
|
||
if (!firmResultsMap.has(firmName)) firmResultsMap.set(firmName, []);
|
||
const r = tradeResults[i];
|
||
if (r.status === 'rejected') {
|
||
console.error(`[auto-trade] ${batch[i].acc.name} (${firmName}): failed — ${errorMessage(r.reason)}`);
|
||
}
|
||
firmResultsMap.get(firmName)!.push(
|
||
r.status === 'fulfilled'
|
||
? r.value
|
||
: { status: 'error', reason: errorMessage(r.reason) }
|
||
);
|
||
}
|
||
|
||
return {
|
||
outcome: 'fired',
|
||
results: Array.from(firmResultsMap.entries()).map(([firm, results]) => ({ firm, results })),
|
||
};
|
||
}
|
||
|
||
const round2 = (n: number) => Math.round(n * 100) / 100;
|
||
|
||
/**
|
||
* Close a just-filled position at market because it must not stay open — its
|
||
* daily-loss stop has no room, or the broker refused its exit bracket — and
|
||
* report the leg as failed. A flatten that fails too is the worst case: a
|
||
* position with no stop, so it is shouted about.
|
||
*/
|
||
async function flattenAndFail(
|
||
tag: string,
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
client: any,
|
||
acc: { id: number; name: string },
|
||
contractName: string,
|
||
contracts: number,
|
||
exitAction: 'Buy' | 'Sell',
|
||
entryPrice: number,
|
||
why: string,
|
||
): Promise<never> {
|
||
try {
|
||
const exit = await client.sendOrder(acc.id, contractName, contracts, exitAction, 'Market');
|
||
console.warn(`${tag} ${acc.name}: flattened @ ${exit.price} — ${why}`);
|
||
} catch (err) {
|
||
console.error(`${tag} ${acc.name}: FLATTEN FAILED (${errorMessage(err)}) — the position may be open WITHOUT A STOP; close it at the broker (${why})`);
|
||
throw new Error(`entry filled @ ${entryPrice}; ${why}; flatten failed — ${errorMessage(err)}`);
|
||
}
|
||
throw new Error(`entry filled @ ${entryPrice}; flattened — ${why}`);
|
||
}
|
||
|
||
/**
|
||
* 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: item.firm.targetHoldHours || DEFAULT_TARGET_HOLD_HOURS,
|
||
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: lead.firm.targetHoldHours || DEFAULT_TARGET_HOLD_HOURS,
|
||
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, 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.drain, item.contract, item.pv);
|
||
legContract = inst.contract;
|
||
pv = inst.pointValue;
|
||
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 {
|
||
const rawContracts = Math.max(1, Math.ceil(legAmount / 1000));
|
||
contracts = cfg.maxPositionSize > 0 ? Math.min(rawContracts, cfg.maxPositionSize) : rawContracts;
|
||
}
|
||
} catch (err) {
|
||
unrecordLeg(acc.id, tradeDate, legAmount);
|
||
throw err;
|
||
}
|
||
const { sizing, obfuscation } = plan;
|
||
|
||
const fill = await sendEntry(tag, client, acc, legContract.name, contracts, action, tradeDate, legAmount);
|
||
const exitAction: 'Buy' | 'Sell' = action === 'Buy' ? 'Sell' : 'Buy';
|
||
|
||
// The entry fee. Percent firms: straight off the fill. Otherwise wait
|
||
// briefly for the cash balance update: realizedPnL drops by the entry
|
||
// commission, and the delta from the pre-entry value is the fee (the
|
||
// pre-entry value may be nonzero mid-day). The take-profit falls back to
|
||
// $2.50/contract if the update hasn't landed (at least one extra tick).
|
||
let entryFee: number;
|
||
let measured: number | null = null;
|
||
if (pct > 0) {
|
||
entryFee = percentFee(pct, fill.price, pv, contracts);
|
||
} else {
|
||
await new Promise(r => setTimeout(r, 1000));
|
||
const updatedCash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
|
||
measured = entryCommissionMeasured(item.cash.realizedPnL, updatedCash.realizedPnL, contracts);
|
||
entryFee = measured ?? FALLBACK_COMMISSION_PER_CONTRACT * contracts;
|
||
}
|
||
// 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) {
|
||
stopPrice = action === 'Buy'
|
||
? fill.price - (ticks * legContract.tickSize)
|
||
: fill.price + (ticks * legContract.tickSize);
|
||
try {
|
||
exitOrder = await client.placeOcoExit(acc.id, legContract.name, contracts, exitAction, tp.exitPrice, stopPrice);
|
||
} catch (err) {
|
||
// An unknown outcome (the ack timed out, the connection went after
|
||
// the send) means the pair may be working, and a second exit on top
|
||
// doubles it — both filling flips the account into an opposite
|
||
// position nothing manages. Place nothing more then.
|
||
if (!isOrderNotPlaced(err)) {
|
||
console.error(`${tag} ${acc.name}: placeoco outcome UNKNOWN (${errorMessage(err)}) — NOT placing a second exit; check this position's exits at the broker`);
|
||
throw new Error(`entry filled @ ${fill.price} but the exit's outcome is unknown — ${errorMessage(err)}`);
|
||
}
|
||
// The pair never went in. With a hard daily loss limit the position
|
||
// must not ride without its stop: flatten and sit out the day.
|
||
if (dllOn) {
|
||
(runtime().exitRefused ??= new Map()).set(acc.id, tradeDate);
|
||
console.error(`${tag} ${acc.name}: placeoco REJECTED (${errorMessage(err)}) with a daily loss limit — flattening`);
|
||
return flattenAndFail(tag, client, acc, legContract.name, contracts, exitAction, fill.price,
|
||
`exit bracket refused (${errorMessage(err)})`);
|
||
}
|
||
// No limit: the position is already open — never leave it exit-less.
|
||
console.error(`${tag} ${acc.name}: placeoco REJECTED (${errorMessage(err)}) — falling back to lone limit exit, NO STOP`);
|
||
stopPrice = null;
|
||
exitOrder = await client.placeOrderNoWait(acc.id, legContract.name, contracts, exitAction, 'Limit', tp.exitPrice);
|
||
}
|
||
} else {
|
||
exitOrder = await client.placeOrderNoWait(acc.id, legContract.name, contracts, exitAction, 'Limit', tp.exitPrice);
|
||
}
|
||
|
||
const instrumentLabel = legContract.stepNotional || item.drain ? legContract.name : symbolLabel;
|
||
const sizeNote = sizing
|
||
? ` | notional $${Math.round(contracts * (legContract.stepNotional ?? 0))} (hold ~${sizing.predictedHoldHours.toFixed(1)}h`
|
||
+ `${sizing.winProbability !== null ? `, win ${Math.round(sizing.winProbability * 100)}%` : ''})`
|
||
: '';
|
||
const stopNote = (stopPrice !== null
|
||
? ` / stop @ ${stopPrice}`
|
||
+ (dllOn && !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.
|
||
* Finds accounts with open positions, determines direction, then fires
|
||
* orders for eligible accounts that haven't traded yet — within the lane of
|
||
* the first open position found, never across lanes.
|
||
*/
|
||
export async function copyTrade() {
|
||
console.log('[copy-trade] called');
|
||
const positioned = firmClients().ready
|
||
.find(({ client }) => client.accountList.some((acc) => !!client.positions[acc.id]));
|
||
if (!positioned) {
|
||
console.log('[copy-trade] no open positions to copy from');
|
||
return [];
|
||
}
|
||
const lane = laneOf(positioned.firm);
|
||
// Shares the batch's mutex: a copy landing mid-batch would see the batch's
|
||
// accounts as still flat and could fill past max_concurrent_accounts.
|
||
const lease = acquireTradeMutex('[copy-trade]', lane);
|
||
if (lease === null) return [];
|
||
try {
|
||
return await copyTradeLocked(lease, lane);
|
||
} finally {
|
||
releaseTradeMutex(lease, lane);
|
||
}
|
||
}
|
||
|
||
async function copyTradeLocked(lease: number, lane: string): Promise<unknown[]> {
|
||
const laneFirms = getTradableFirms().filter((f) => laneOf(f) === lane);
|
||
if (laneFirms.every((f) => isInNoTradeWindow(f))) {
|
||
console.log('[copy-trade] outside trading hours — skipping');
|
||
return [];
|
||
}
|
||
|
||
// Ready clients only, as in runTrade: a stale snapshot must neither be
|
||
// copied from, counted against the slots, nor traded.
|
||
const all = firmClients();
|
||
const ready = inLane(all.ready, lane);
|
||
const unready = inLane(all.unready, lane);
|
||
if (unready.length > 0) {
|
||
console.log(`[copy-trade] not synced — leaving out: ${unready.map((u) => u.firm.name).join(', ')}`);
|
||
}
|
||
const maxConcurrent = maxConcurrentAccounts();
|
||
|
||
// Find all accounts with open positions to determine direction + symbol
|
||
let resolvedAction: 'Buy' | 'Sell' | null = null;
|
||
// number on Tradovate, string on TopstepX/DXTrade — only ever compared
|
||
// against contracts from the same client, so the union stays coherent.
|
||
let positionContractId: number | string | null = null;
|
||
let positionedCount = 0;
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
let positionedClient: any = null;
|
||
const positionedPrefixes = new Set<string>();
|
||
|
||
for (const { client } of ready) {
|
||
for (const acc of client.accountList) {
|
||
const pos = client.positions[acc.id];
|
||
if (!pos) continue;
|
||
positionedCount++;
|
||
const prefix = getAccountConfig(acc.id).accountType?.prefix;
|
||
if (prefix) positionedPrefixes.add(prefix);
|
||
if (!resolvedAction) {
|
||
resolvedAction = pos.netPos > 0 ? 'Buy' : 'Sell';
|
||
positionContractId = pos.contractId;
|
||
positionedClient = client;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!resolvedAction || positionedCount === 0) {
|
||
console.log('[copy-trade] no open positions to copy from');
|
||
return [];
|
||
}
|
||
console.log(`[copy-trade] found ${positionedCount} open position(s), action=${resolvedAction}, contractId=${positionContractId}`);
|
||
|
||
// Resolve symbol from the positioned contract's contractId — only check the client that
|
||
// actually has the position. Iterating every firm × every symbol can take 30+ seconds.
|
||
let resolvedSymbol: string | null = null;
|
||
const instruments = getInstruments();
|
||
const enabledSymbols = instruments.filter(i => i.enabled).map(i => i.symbol);
|
||
|
||
if (positionedClient) {
|
||
for (const sym of enabledSymbols) {
|
||
// A lookup that throws rules out that one symbol, not the copy.
|
||
try {
|
||
const contract = await positionedClient.findFrontMonthContract(sym);
|
||
if (contract && contract.id === positionContractId) {
|
||
resolvedSymbol = sym;
|
||
break;
|
||
}
|
||
} catch (err) {
|
||
console.error(`[copy-trade] ${sym}: contract lookup failed — ${errorMessage(err)}`);
|
||
}
|
||
}
|
||
}
|
||
console.log(`[copy-trade] resolved symbol: ${resolvedSymbol ?? '(none, using fallback)'}`);
|
||
|
||
// Fall back to scheduler symbol or first enabled
|
||
if (!resolvedSymbol) {
|
||
const state = getState();
|
||
resolvedSymbol = state.symbol !== 'Auto' ? state.symbol : (enabledSymbols[0] ?? 'NQ');
|
||
}
|
||
const basePointValue: number | undefined = POINT_VALUES[resolvedSymbol];
|
||
|
||
const slotsAvailable = maxConcurrent - positionedCount;
|
||
if (slotsAvailable <= 0) {
|
||
console.log(`[copy-trade] already at max concurrent (${positionedCount}/${maxConcurrent})`);
|
||
return [];
|
||
}
|
||
|
||
const tradeDate = currentTradeDateCT();
|
||
|
||
// Collect eligible accounts (same logic as Phase 1 of runTrade)
|
||
const eligible: LegItem[] = [];
|
||
|
||
await Promise.all(ready.filter(({ firm }) => !isInNoTradeWindow(firm)).map(async ({ firm, client }) => {
|
||
if (client.accountList.length === 0) {
|
||
console.log(`[copy-trade] ${firm.name}: empty account list — skip firm`);
|
||
return;
|
||
}
|
||
if (firm.bannedSymbols.includes(resolvedSymbol!)) {
|
||
console.log(`[copy-trade] ${firm.name}: ${resolvedSymbol} is banned — skip firm`);
|
||
return;
|
||
}
|
||
|
||
// Same isolation as runTrade: one firm's throw skips that firm only.
|
||
try {
|
||
const contract = await client.findFrontMonthContract(resolvedSymbol!);
|
||
if (!contract) {
|
||
console.log(`[copy-trade] ${firm.name}: cannot resolve front-month contract for ${resolvedSymbol} — skip firm`);
|
||
return;
|
||
}
|
||
const pv = contract.pointValue ?? basePointValue;
|
||
if (!pv) {
|
||
console.log(`[copy-trade] ${firm.name}: no point value for ${resolvedSymbol} — skip firm`);
|
||
return;
|
||
}
|
||
|
||
const found: LegItem[] = [];
|
||
for (const acc of client.accountList) {
|
||
const ev = evaluateAccountForTrade(client, acc, tradeDate);
|
||
if (!ev) { console.log(`[copy-trade] ${acc.name}: no matching config prefix — skip`); continue; }
|
||
if (!ev.eligible || !ev.plan) { console.log(`[copy-trade] ${acc.name}: ${ev.reason} — skip`); continue; }
|
||
// copyTrade has no extra-day branch — those accounts wait for the scheduler
|
||
if (ev.isExtraDay) { console.log(`[copy-trade] ${acc.name}: extra-day account — skip`); continue; }
|
||
|
||
found.push({
|
||
firmName: firm.name, firm, client, acc, contract, pv, cash: ev.cash, dailyPnL: ev.dailyPnL, plan: ev.plan,
|
||
drain: ev.drain,
|
||
prefix: getAccountConfig(acc.id).accountType?.prefix ?? '',
|
||
dllRoom: ev.dllRoom,
|
||
});
|
||
}
|
||
eligible.push(...found);
|
||
} catch (err) {
|
||
console.error(`[copy-trade] ${firm.name}: skipped — ${errorMessage(err)}`);
|
||
}
|
||
}));
|
||
|
||
// Fill the open slots with accounts whose prefix matches the already-positioned
|
||
// accounts before falling back to mismatched prefixes.
|
||
const matchesPositioned = (item: LegItem) => positionedPrefixes.has(item.prefix);
|
||
const ordered = [...eligible.filter(matchesPositioned), ...eligible.filter((i) => !matchesPositioned(i))];
|
||
const batch = ordered.slice(0, slotsAvailable);
|
||
if (batch.length === 0) {
|
||
console.log('[copy-trade] no eligible accounts to copy to');
|
||
return [];
|
||
}
|
||
|
||
// Superseded while collecting (see runTrade) — this selection is stale.
|
||
if (!holdsTradeMutex(lease, lane)) {
|
||
console.warn('[copy-trade] superseded while collecting accounts — abandoning this copy');
|
||
return [];
|
||
}
|
||
|
||
console.log(`[copy-trade] copying ${resolvedAction} ${resolvedSymbol} to ${batch.length} account(s)`);
|
||
runtime().lastFireAt = Date.now();
|
||
|
||
// Fire orders (same as Phase 2 of runTrade, accounts marked in flight the same way)
|
||
const tradeResults = await Promise.allSettled(batch.map((item) => withAccountInFlight(
|
||
item.acc.id,
|
||
() => fireLeg('[copy-trade]', item, resolvedAction!, resolvedSymbol!, tradeDate),
|
||
)));
|
||
|
||
const results: unknown[] = [];
|
||
for (let i = 0; i < batch.length; i++) {
|
||
const r = tradeResults[i];
|
||
if (r.status === 'rejected') {
|
||
console.error(`[copy-trade] ${batch[i].acc.name} (${batch[i].firmName}): failed — ${errorMessage(r.reason)}`);
|
||
}
|
||
results.push(
|
||
r.status === 'fulfilled'
|
||
? r.value
|
||
: { status: 'error', reason: errorMessage(r.reason) }
|
||
);
|
||
}
|
||
return results;
|
||
}
|
||
|
||
// ── manual leg ───────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Fire one leg on one account now, through the same path the scheduler uses
|
||
* — sizing, take-profit, the daily-loss stop, the exit bracket — for someone
|
||
* testing an account by hand (POST /api/accounts/[id]/fire-leg).
|
||
*
|
||
* The one rule it overrides is "one trade per day" for unsplit accounts: an
|
||
* account that already booked something today may take another leg toward
|
||
* what's left of its target. Everything else still applies — flat, history
|
||
* loaded, the daily-loss room, the firm's trading hours, no batch in flight
|
||
* in its lane — and a lane with positions open is only joined on the same
|
||
* instrument and side.
|
||
*/
|
||
export async function fireManualLeg(accountId: number, action: 'Buy' | 'Sell' | 'Auto'): Promise<LegResult> {
|
||
const found = firmClients().ready.find(({ client }) => client.accountList.some((a) => a.id === accountId));
|
||
if (!found) throw new Error('account not found on a synced client');
|
||
const { firm, client } = found;
|
||
const acc = client.accountList.find((a) => a.id === accountId)!;
|
||
if (isInNoTradeWindow(firm)) throw new Error(`${firm.name} is outside its trading hours`);
|
||
|
||
const tradeDate = currentTradeDateCT();
|
||
const ev = evaluateAccountForTrade(client, acc, tradeDate);
|
||
if (!ev || !ev.plan) throw new Error(ev?.reason || 'account has no account type');
|
||
if (!ev.eligible && !ev.reason.startsWith('already traded today')) throw new Error(ev.reason);
|
||
if (ev.isExtraDay) throw new Error('extra-day accounts only take the scheduled in-and-out');
|
||
if (ev.plan.remainingToday <= LEG_COMPLETION_EPSILON) throw new Error(`day target met ($${ev.plan.remainingToday} left)`);
|
||
const cfg = getAccountConfig(acc.id).accountType!;
|
||
// The once-a-day return comes before the daily-loss check: run it here.
|
||
let room = ev.dllRoom;
|
||
if (!ev.eligible) {
|
||
const gate = dailyLossGate(client, acc.id, cfg, ev.cash, ev.cash.realizedPnL, ev.plan.remainingToday, ev.drain);
|
||
if ('reason' in gate) throw new Error(gate.reason);
|
||
room = gate.room;
|
||
}
|
||
|
||
// A lane with positions open may only be joined on the same instrument
|
||
// and side — what a batch of like accounts would have done — never the
|
||
// other side (that is hedging).
|
||
const lane = laneOf(firm);
|
||
const held = inLane(firmClients().ready, lane).flatMap(({ client: c }) => c.accountList
|
||
.map((a) => c.positions[a.id])
|
||
.filter((p): p is NonNullable<typeof p> => !!p));
|
||
const heldSides = new Set(held.map((p) => (p.netPos > 0 ? 'Buy' : 'Sell')));
|
||
const heldContracts = new Set(held.map((p) => String(p.contractId)));
|
||
if (heldSides.size > 1 || heldContracts.size > 1) throw new Error(`the ${laneName(lane)} lane holds mixed positions — not adding to it`);
|
||
const heldSide: 'Buy' | 'Sell' | null = held.length > 0 ? (held[0].netPos > 0 ? 'Buy' : 'Sell') : null;
|
||
if (heldSide && action !== 'Auto' && action !== heldSide) {
|
||
throw new Error(`the ${laneName(lane)} lane is ${heldSide === 'Buy' ? 'long' : 'short'} — a ${action} would hedge it`);
|
||
}
|
||
const state = getState();
|
||
const symbol = state.symbol !== 'Auto' ? state.symbol : 'NQ';
|
||
const resolvedAction: 'Buy' | 'Sell' = heldSide ?? (action === 'Auto' ? (Math.random() < 0.5 ? 'Buy' : 'Sell') : action);
|
||
const 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,
|
||
};
|
||
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}`);
|
||
|
||
const lease = acquireTradeMutex('[manual-leg]', lane);
|
||
if (lease === null) throw new Error('another batch is in flight in this lane');
|
||
try {
|
||
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: 'NQ', intervalId: null, lastRun: null, running: false, stopAfterAll: false };
|
||
}
|
||
return _global.__autoTrader;
|
||
}
|
||
|
||
export function startScheduler(action: 'Buy' | 'Sell' | 'Auto', symbol: string, stopAfterAll: boolean = false) {
|
||
const state = getState();
|
||
|
||
// The settings reads and writes are the part that can throw, so they go
|
||
// before anything in memory changes: a failure then leaves whatever
|
||
// schedule was running as it was, never one marked running with its
|
||
// interval cleared and nothing scheduled. A non-numeric setting falls back
|
||
// to 60s — setInterval takes NaN as 1ms.
|
||
const configuredSecs = parseInt(getSetting('tick_interval_seconds') ?? '60', 10);
|
||
const intervalSecs = Math.max(5, Number.isFinite(configuredSecs) ? configuredSecs : 60);
|
||
|
||
// Mirror to the settings table so a restart can pick the schedule back up.
|
||
// The interval itself is in-memory only; resumeSchedulerIfPersisted() recreates it.
|
||
setSetting('scheduler_running', '1');
|
||
setSetting('scheduler_action', action);
|
||
setSetting('scheduler_symbol', symbol);
|
||
setSetting('scheduler_stop_after_all', stopAfterAll ? '1' : '0');
|
||
|
||
// Clear any existing interval
|
||
if (state.intervalId !== null) {
|
||
clearInterval(state.intervalId);
|
||
}
|
||
|
||
state.action = action;
|
||
state.symbol = symbol;
|
||
state.running = true;
|
||
state.stopAfterAll = stopAfterAll;
|
||
|
||
const tick = async () => {
|
||
if (!state.running) return;
|
||
state.lastRun = new Date();
|
||
const rt = runtime();
|
||
const now = Date.now();
|
||
rt.lastTickAt = now;
|
||
|
||
try {
|
||
// Hold a lane while any of its firms' clients is still inside its
|
||
// sync grace period (positions may not be populated yet); trade
|
||
// around firms that have been out past it (see assessReadiness).
|
||
// Lanes hold, gate and fire independently.
|
||
const { ready, unready } = firmClients(now);
|
||
const { holding, quarantined } = assessReadiness(ready, unready, now);
|
||
rt.holding = holding.map((f) => f.name);
|
||
rt.quarantined = quarantined.map((q) => q.firm.name);
|
||
for (const { firm, unreadyMs } of quarantined) {
|
||
if (now - (rt.quarantineLoggedAt.get(firm.id) ?? 0) < QUARANTINE_LOG_EVERY_MS) continue;
|
||
rt.quarantineLoggedAt.set(firm.id, now);
|
||
console.warn(`[scheduler] ${firm.name} not synced for ${Math.round(unreadyMs / 60_000)}m — trading the other firms without it`);
|
||
}
|
||
|
||
const outcomes = new Map<string, TickOutcome>();
|
||
let filled = 0;
|
||
for (const lane of tradableLanes()) {
|
||
try {
|
||
const laneHolding = holding.filter((f) => laneOf(f) === lane);
|
||
if (laneHolding.length > 0) {
|
||
outcomes.set(lane, 'holding-for-sync');
|
||
console.log(`[scheduler] ${laneName(lane)}: waiting for sync: ${laneHolding.map((f) => f.name).join(', ')}`);
|
||
continue;
|
||
}
|
||
// Skip this lane if any of its accounts still has an open
|
||
// position from its previous batch — a batch fires only
|
||
// when every (ready) account in the lane is flat.
|
||
const openPositions = countOpenPositions(inLane(ready, lane));
|
||
if (openPositions > 0) {
|
||
outcomes.set(lane, 'positions-open');
|
||
console.log(`[scheduler] ${laneName(lane)}: ${openPositions} position(s) still open — skipping tick`);
|
||
continue;
|
||
}
|
||
const { outcome, results } = await runTradeBatch(state.action, state.symbol, lane);
|
||
outcomes.set(lane, outcome);
|
||
filled += results.flatMap((r) => r.results).filter((r) => r.status === 'filled').length;
|
||
} catch (err) {
|
||
outcomes.set(lane, `error: ${errorMessage(err)}`);
|
||
console.error(`[scheduler] ${laneName(lane)}: tick error: ${errorMessage(err)}`);
|
||
}
|
||
}
|
||
rt.laneOutcomes = outcomes;
|
||
rt.lastTickOutcome = overallOutcome([...outcomes.values()]);
|
||
if (filled > 0) console.log(`[scheduler] tick: ${filled} account(s) filled`);
|
||
|
||
// Auto-stop if user opted in and no configured accounts can trade anymore
|
||
if (state.stopAfterAll && !hasRemainingConfiguredAccounts()) {
|
||
console.log('[scheduler] all configured accounts done for today — stopping');
|
||
stopScheduler();
|
||
}
|
||
} catch (err) {
|
||
rt.lastTickOutcome = `error: ${errorMessage(err)}`;
|
||
console.error(`[scheduler] tick error: ${errorMessage(err)}`);
|
||
}
|
||
};
|
||
|
||
state.intervalId = setInterval(tick, intervalSecs * 1_000);
|
||
console.log(`[scheduler] started — ${action} ${symbol} every ${intervalSecs}s`);
|
||
}
|
||
|
||
/** One outcome for the whole tick, for the master and /api/health: the most
|
||
* telling lane's (a fill beats an error beats waiting beats nothing to do). */
|
||
function overallOutcome(outcomes: TickOutcome[]): TickOutcome {
|
||
if (outcomes.length === 0) return 'no-eligible';
|
||
const rank = (o: TickOutcome): number => {
|
||
if (o === 'fired') return 0;
|
||
if (o.startsWith('error')) return 1;
|
||
return ['skipped-in-flight', 'positions-open', 'holding-for-sync', 'no-eligible', 'closed', 'not-running']
|
||
.indexOf(o) + 2;
|
||
};
|
||
return [...outcomes].sort((a, b) => rank(a) - rank(b))[0];
|
||
}
|
||
|
||
export function stopScheduler() {
|
||
const state = getState();
|
||
if (state.intervalId !== null) {
|
||
clearInterval(state.intervalId);
|
||
state.intervalId = null;
|
||
}
|
||
state.running = false;
|
||
|
||
// Nothing watches readiness while stopped, so the grace clocks would be
|
||
// stale by the next start — that start gives every firm a fresh grace.
|
||
// Reset before the settings write, which can throw.
|
||
const rt = runtime();
|
||
rt.unreadySince.clear();
|
||
rt.quarantineLoggedAt.clear();
|
||
rt.holding = [];
|
||
rt.quarantined = [];
|
||
rt.lastTickOutcome = 'not-running';
|
||
rt.laneOutcomes = new Map();
|
||
|
||
setSetting('scheduler_running', '0');
|
||
console.log('[scheduler] stopped');
|
||
}
|
||
|
||
/**
|
||
* Restart the schedule that was running before the process went down.
|
||
*
|
||
* No sync-wait here on purpose: tick() already holds while any client isn't
|
||
* ready (for up to UNREADY_GRACE_MS each), and again while any account holds
|
||
* an open position. So the worst case is a few logged no-op ticks until the
|
||
* clients finish syncing.
|
||
*/
|
||
export function resumeSchedulerIfPersisted(): void {
|
||
if (getSetting('scheduler_running') !== '1') return;
|
||
|
||
const action = (getSetting('scheduler_action') ?? 'Buy') as 'Buy' | 'Sell' | 'Auto';
|
||
const symbol = getSetting('scheduler_symbol') ?? 'NQ';
|
||
const stopAfterAll = getSetting('scheduler_stop_after_all') === '1';
|
||
|
||
console.log(`[scheduler] resuming persisted schedule — ${action} ${symbol}`);
|
||
startScheduler(action, symbol, stopAfterAll);
|
||
}
|
||
|
||
export function getSchedulerStatus() {
|
||
const state = getState();
|
||
return {
|
||
running: state.running,
|
||
action: state.action,
|
||
symbol: state.symbol,
|
||
lastRun: state.lastRun,
|
||
intervalSeconds: parseInt(getSetting('tick_interval_seconds') ?? '60', 10),
|
||
stopAfterAll: state.stopAfterAll,
|
||
sessionOpenDelayMin: todaysSessionOpenDelay(),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Why the scheduler is or isn't trading, for health checks — separate from
|
||
* getSchedulerStatus, whose shape is what the master receives. `holding` are
|
||
* the firms the last tick waited on (inside their sync grace period);
|
||
* `quarantined` the ones past it, traded around. `lanes` is the last tick's
|
||
* outcome per lane (see laneOf), keyed by lane name. Timestamps are epoch ms,
|
||
* 0 = never; lastFireAt counts batches from any caller, not just the tick;
|
||
* tradeInFlightSince is the oldest lane batch still in flight. Until the
|
||
* first tick after a start, the outcome still reads 'not-running'.
|
||
*/
|
||
export function getSchedulerHealth(): {
|
||
lastTickAt: number;
|
||
lastTickOutcome: TickOutcome;
|
||
lastFireAt: number;
|
||
holding: string[];
|
||
quarantined: string[];
|
||
tradeInFlightSince: number;
|
||
lanes: Record<string, TickOutcome>;
|
||
} {
|
||
const running = getState().running;
|
||
const rt = runtime();
|
||
const inFlight = [...(rt.tradeInFlight?.values() ?? [])];
|
||
return {
|
||
lastTickAt: rt.lastTickAt,
|
||
lastTickOutcome: running ? rt.lastTickOutcome : 'not-running',
|
||
lastFireAt: rt.lastFireAt,
|
||
holding: running ? [...rt.holding] : [],
|
||
quarantined: running ? [...rt.quarantined] : [],
|
||
tradeInFlightSince: inFlight.length > 0 ? Math.min(...inFlight) : 0,
|
||
lanes: running
|
||
? Object.fromEntries([...(rt.laneOutcomes ?? new Map())].map(([lane, o]) => [laneName(lane), o]))
|
||
: {},
|
||
};
|
||
}
|