738 lines
32 KiB
TypeScript
738 lines
32 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 {
|
||
computeDayPlan, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT,
|
||
LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY, POINT_VALUES,
|
||
type DayPlan,
|
||
} from './trading-logic';
|
||
import { getSetting, setSetting } from './db';
|
||
|
||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
function isAccountDead(amount: number, autoLiqThreshold: number): boolean {
|
||
return autoLiqThreshold > 0 && amount <= autoLiqThreshold;
|
||
}
|
||
|
||
// In-memory runaway guard: legs fired per account per trade date. Resets on restart,
|
||
// which is fine — day completion is derived from realized PnL, not from this counter.
|
||
const _legCounts = new Map<number, { date: string; count: number }>();
|
||
|
||
function legsToday(accountId: number, tradeDate: string): number {
|
||
const entry = _legCounts.get(accountId);
|
||
return entry && entry.date === tradeDate ? entry.count : 0;
|
||
}
|
||
|
||
function recordLeg(accountId: number, tradeDate: string): void {
|
||
_legCounts.set(accountId, { date: tradeDate, count: legsToday(accountId, tradeDate) + 1 });
|
||
}
|
||
|
||
// ── per-account trade evaluation (shared by runTrade / copyTrade / hasRemaining…) ──
|
||
|
||
interface AccountEvaluation {
|
||
eligible: boolean;
|
||
reason: string;
|
||
plan: DayPlan | null;
|
||
isExtraDay: 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;
|
||
const dailyPnL: { date: string; pnl: number }[] = client.dailyPnL[acc.id] ?? [];
|
||
const daysTraded: number = client.daysTraded[acc.id] ?? 0;
|
||
|
||
const base = { plan: null, isExtraDay: 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' };
|
||
|
||
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
|
||
);
|
||
|
||
const equityProfit = cash.amount - cfg.accountSize;
|
||
const plan = computeDayPlan(
|
||
acc.id, tradeDate, effective.profitTarget, effective.consistency,
|
||
dailyPnL, cfg.minDayPnL, effective.minTradingDays, equityProfit, cash.realizedPnL,
|
||
cfg.splitEnabled
|
||
);
|
||
if (!plan) return { ...base, eligible: false, reason: 'invalid balance' };
|
||
|
||
const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0);
|
||
const isExtraDay = cfg.minDayPnL <= 0
|
||
&& effective.minTradingDays > daysTraded
|
||
&& totalProfit >= effective.profitTarget;
|
||
|
||
const evaluated = { plan, isExtraDay, cash, dailyPnL, daysTraded };
|
||
|
||
if (isExtraDay || !plan.splitAllowed) {
|
||
// Legacy one-and-done gate: any realized PnL today means this account is finished.
|
||
if (cash.realizedPnL !== 0) {
|
||
return { ...evaluated, eligible: false, reason: `already traded today (realizedPnL=${cash.realizedPnL})` };
|
||
}
|
||
if (!isExtraDay && plan.remainingToday <= 0) {
|
||
return { ...evaluated, eligible: false, reason: `target=$${plan.dayTarget} (challenge complete)` };
|
||
}
|
||
} 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 = cash.realizedPnL === 0 ? 0 : LEG_COMPLETION_EPSILON;
|
||
if (plan.remainingToday <= doneThreshold) {
|
||
const reason = cash.realizedPnL === 0
|
||
? `target=$${plan.dayTarget} (challenge complete)`
|
||
: `day done ($${plan.remainingToday} left of $${plan.dayTarget})`;
|
||
return { ...evaluated, eligible: false, reason };
|
||
}
|
||
if (legsToday(acc.id, tradeDate) >= MAX_LEGS_PER_DAY) {
|
||
return { ...evaluated, eligible: false, reason: `leg cap reached (${MAX_LEGS_PER_DAY} today)` };
|
||
}
|
||
}
|
||
|
||
return { ...evaluated, eligible: true, reason: '' };
|
||
}
|
||
|
||
|
||
|
||
|
||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
// Random 1–20 minute delay added to the session open, picked once per Central
|
||
// calendar day. Re-rolling on every check would move the open boundary between
|
||
// polls, letting a trade slip through a moment the next check would reject.
|
||
const sessionOpenJitter = new Map<string, number>();
|
||
|
||
function openJitterMinutes(centralDay: string): number {
|
||
let jitter = sessionOpenJitter.get(centralDay);
|
||
if (jitter === undefined) {
|
||
jitter = 1 + Math.floor(Math.random() * 20); // 1–20 inclusive
|
||
sessionOpenJitter.clear(); // only today's entry is ever needed
|
||
sessionOpenJitter.set(centralDay, jitter);
|
||
console.log(`[trading-hours] session open delayed ${jitter} min today`);
|
||
}
|
||
return jitter;
|
||
}
|
||
|
||
/** Today's session-open delay in minutes, for reporting to the master. */
|
||
export function todaysSessionOpenDelay(): number {
|
||
const centralDay = new Intl.DateTimeFormat('en-CA', {
|
||
timeZone: 'America/Chicago',
|
||
}).format(new Date());
|
||
return openJitterMinutes(centralDay);
|
||
}
|
||
|
||
/**
|
||
* Returns true when trading is not allowed based on the trading_hours setting.
|
||
*
|
||
* "full_cme" — Sun 5:05 PM – Fri 2:55 PM Central (5 min buffer on each side)
|
||
* "equity_hours" — 8:35 AM – 2:55 PM Central, Mon–Fri (5 min buffer on each side)
|
||
*
|
||
* The open side of each window is pushed back by a random 1–20 minutes per day
|
||
* (see openJitterMinutes) so sessions never start at the exact same time.
|
||
*/
|
||
function isInNoTradeWindow(): boolean {
|
||
const now = new Date();
|
||
const centralParts = new Intl.DateTimeFormat('en-US', {
|
||
hour: 'numeric',
|
||
minute: 'numeric',
|
||
hour12: false,
|
||
timeZone: 'America/Chicago',
|
||
}).formatToParts(now);
|
||
const hour = parseInt(centralParts.find((p) => p.type === 'hour')!.value, 10);
|
||
const minute = parseInt(centralParts.find((p) => p.type === 'minute')!.value, 10);
|
||
const day = new Intl.DateTimeFormat('en-US', {
|
||
weekday: 'short',
|
||
timeZone: 'America/Chicago',
|
||
}).format(now); // "Sun", "Mon", ... "Sat"
|
||
const timeMinutes = hour * 60 + minute; // minutes since midnight
|
||
const jitter = todaysSessionOpenDelay();
|
||
|
||
const mode = getSetting('trading_hours') ?? 'full_cme';
|
||
|
||
if (mode === 'equity_hours') {
|
||
// Equity hours: 8:30 AM – 3:00 PM Central with 5 min buffer = 8:35 AM – 2:55 PM
|
||
// Weekdays only
|
||
if (day === 'Sat' || day === 'Sun') return true;
|
||
const open = 8 * 60 + 35 + jitter; // 8:35 AM + daily jitter
|
||
const close = 14 * 60 + 55; // 2:55 PM
|
||
return timeMinutes < open || timeMinutes >= close;
|
||
}
|
||
|
||
// Full CME: Sun 5:00 PM – Fri 4:00 PM Central with 5 min buffer
|
||
// Open: 5:05 PM, Close: 2:55 PM (stop early), Daily halt: 2:55 PM – 5:05 PM
|
||
|
||
// Saturday — market closed all day
|
||
if (day === 'Sat') return true;
|
||
|
||
// Sunday — market opens at 5:05 PM Central (+ daily jitter)
|
||
const cmeOpen = 17 * 60 + 5 + jitter; // 5:05 PM + daily jitter
|
||
if (day === 'Sun') return timeMinutes < cmeOpen;
|
||
|
||
// Friday — stop trading at 2:55 PM Central
|
||
const cmeClose = 14 * 60 + 55; // 2:55 PM
|
||
if (day === 'Fri' && timeMinutes >= cmeClose) return true;
|
||
|
||
// Mon–Thu: block 2:55 PM – 5:05 PM Central (early stop + daily halt + buffer)
|
||
if (timeMinutes >= cmeClose && timeMinutes < cmeOpen) return true;
|
||
|
||
return false;
|
||
}
|
||
|
||
// ── core trade logic ──────────────────────────────────────────────────────────
|
||
|
||
export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string) {
|
||
if (isInNoTradeWindow()) {
|
||
console.log('[auto-trade] CME market closed — skipping');
|
||
return [];
|
||
}
|
||
|
||
// Block if any account still has an open position (same gate as the scheduler tick)
|
||
const allClients = getClients();
|
||
const allFirms = getTradableFirms();
|
||
const openPositions = allFirms.reduce((count, firm) => {
|
||
const c = allClients.get(firm.id);
|
||
if (!c) return count;
|
||
return count + c.accountList.filter(acc => !!c.positions[acc.id]).length;
|
||
}, 0);
|
||
if (openPositions > 0) {
|
||
console.log(`[auto-trade] ${openPositions} position(s) still open — skipping`);
|
||
return [];
|
||
}
|
||
|
||
// Resolve 'Auto' symbol once per batch so all accounts trade the same symbol
|
||
let resolvedSymbol = symbol;
|
||
if (symbol === 'Auto') {
|
||
const enabled = getInstruments().filter((i) => i.enabled).map((i) => i.symbol);
|
||
resolvedSymbol = enabled.length > 0 ? enabled[Math.floor(Math.random() * enabled.length)] : 'NQ';
|
||
console.log(`[auto-trade] random symbol resolved to: ${resolvedSymbol}`);
|
||
}
|
||
// Resolve Auto action once per batch so all accounts trade the same direction
|
||
const resolvedAction: 'Buy' | 'Sell' = action === 'Auto'
|
||
? (Math.random() < 0.5 ? 'Buy' : 'Sell')
|
||
: action;
|
||
const pointValue = POINT_VALUES[resolvedSymbol];
|
||
if (!pointValue) throw new Error(`Unknown symbol: ${resolvedSymbol}`);
|
||
|
||
const tradeDate = currentTradeDateCT();
|
||
const maxConcurrent = parseInt(getSetting('max_concurrent_accounts') ?? '5', 10);
|
||
const firms = getTradableFirms();
|
||
const clients = getClients();
|
||
|
||
// ── Phase 1: collect ALL eligible accounts across ALL firms in parallel ──
|
||
type EligibleItem = {
|
||
firmName: string;
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
client: any;
|
||
acc: { id: number; name: string; active: boolean };
|
||
contract: { name: string; tickSize: number };
|
||
cash: { amount: number; realizedPnL: number };
|
||
dailyPnL: { date: string; pnl: number }[];
|
||
daysTraded: number;
|
||
plan: DayPlan;
|
||
isExtraDay: boolean;
|
||
};
|
||
|
||
const allEligible: EligibleItem[] = [];
|
||
|
||
await Promise.all(firms.map(async (firm) => {
|
||
const client = clients.get(firm.id);
|
||
if (!client || client.accountList.length === 0) return;
|
||
|
||
// Skip this firm entirely if the symbol is banned for it
|
||
if (firm.bannedSymbols.includes(resolvedSymbol)) {
|
||
console.log(`[auto-trade] ${firm.name}: ${resolvedSymbol} is banned — skipping firm`);
|
||
return;
|
||
}
|
||
|
||
|
||
const contract = await client.findFrontMonthContract(resolvedSymbol);
|
||
if (!contract) return;
|
||
|
||
for (const acc of client.accountList) {
|
||
const ev = evaluateAccountForTrade(client, acc, tradeDate);
|
||
if (!ev || !ev.eligible || !ev.plan) continue;
|
||
|
||
allEligible.push({
|
||
firmName: firm.name, client, acc, contract,
|
||
cash: ev.cash, dailyPnL: ev.dailyPnL, daysTraded: ev.daysTraded,
|
||
plan: ev.plan, isExtraDay: ev.isExtraDay,
|
||
});
|
||
}
|
||
}));
|
||
|
||
if (allEligible.length === 0) {
|
||
console.log('[auto-trade] no eligible accounts found');
|
||
return [];
|
||
}
|
||
|
||
// Take only the first batch — all fired simultaneously, no rolling pool.
|
||
// Remaining accounts wait for the next tick (which only fires once all positions are flat).
|
||
const batch = allEligible.slice(0, maxConcurrent);
|
||
console.log(`[auto-trade] ${allEligible.length} eligible account(s) — firing batch of ${batch.length}`);
|
||
|
||
// ── Phase 2: fire the batch simultaneously ──
|
||
const tradeResults = await Promise.allSettled(batch.map(async (item) => {
|
||
const { client, acc, contract } = item;
|
||
const cfg = getAccountConfig(acc.id).accountType!;
|
||
|
||
// Extra-day mode: profit target already met, no min day P&L, days still needed.
|
||
// Just trade 1 MNQ in and out at market immediately — P&L doesn't matter.
|
||
if (item.isExtraDay) {
|
||
const mnqContract = await client.findFrontMonthContract('MNQ');
|
||
if (!mnqContract) throw new Error('MNQ contract not found for extra-day trade');
|
||
|
||
const fill = await client.sendOrder(acc.id, mnqContract.name, 1, resolvedAction, 'Market');
|
||
const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy';
|
||
const exitFill = await client.sendOrder(acc.id, mnqContract.name, 1, exitAction, 'Market');
|
||
|
||
console.log(`[auto-trade] ${acc.name} (${item.firmName}) extra-day: ${resolvedAction} 1xMNQ @ ${fill.price} | exited @ ${exitFill.price} (market)`);
|
||
|
||
return {
|
||
account: acc.name,
|
||
firm: item.firmName,
|
||
status: 'filled',
|
||
contracts: 1,
|
||
target: 0,
|
||
grossTarget: 0,
|
||
totalCommission: 0,
|
||
targetPath: 'extra_day',
|
||
entryPrice: fill.price,
|
||
exitPrice: exitFill.price,
|
||
commission: 0,
|
||
};
|
||
}
|
||
|
||
// Trade one leg toward today's remaining target. Split-allowed big days carve a
|
||
// random ≥$800 piece; everything else trades the full remaining amount in one go.
|
||
const legAmount = item.plan.splitAllowed ? pickLegAmount(item.plan.remainingToday) : item.plan.remainingToday;
|
||
recordLeg(acc.id, tradeDate);
|
||
|
||
const rawContracts = Math.max(1, Math.ceil(legAmount / 1000));
|
||
const contracts = cfg.maxPositionSize > 0 ? Math.min(rawContracts, cfg.maxPositionSize) : rawContracts;
|
||
const fill = await client.sendOrder(acc.id, contract.name, contracts, resolvedAction, 'Market');
|
||
|
||
// Wait briefly for the cash balance WebSocket update to reflect entry commission
|
||
await new Promise(r => setTimeout(r, 1000));
|
||
const updatedCash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
|
||
// After entry, realizedPnL drops by the entry commission; the delta from the
|
||
// pre-entry value is the entry fee paid (pre-entry may be nonzero mid-day).
|
||
// Fall back to $2.50/contract if the WS hasn't updated yet (guarantees at least 1 extra tick).
|
||
const entryCommission = Math.abs(updatedCash.realizedPnL - item.cash.realizedPnL) || (2.5 * contracts);
|
||
const totalCommission = entryCommission * 2; // entry + exit round-trip
|
||
const grossTarget = legAmount + totalCommission;
|
||
|
||
const targetPoints = grossTarget / (pointValue * contracts);
|
||
const ticks = Math.ceil(targetPoints / contract.tickSize);
|
||
const exitPrice = resolvedAction === 'Buy'
|
||
? fill.price + (ticks * contract.tickSize)
|
||
: fill.price - (ticks * contract.tickSize);
|
||
|
||
const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy';
|
||
const exitOrder = await client.placeOrderNoWait(acc.id, contract.name, contracts, exitAction, 'Limit', exitPrice);
|
||
|
||
console.log(`[auto-trade] ${acc.name} (${item.firmName}) ${resolvedAction} ${contracts}x${resolvedSymbol} @ ${fill.price} | leg $${legAmount} of day $${item.plan.dayTarget} (remaining $${item.plan.remainingToday}) [${item.plan.path}] (+$${totalCommission.toFixed(2)} comm) | exit @ ${exitPrice} (orderId=${exitOrder.orderId})`);
|
||
|
||
return {
|
||
account: acc.name,
|
||
firm: item.firmName,
|
||
status: 'filled',
|
||
contracts,
|
||
target: legAmount,
|
||
dayTarget: item.plan.dayTarget,
|
||
remainingBefore: item.plan.remainingToday,
|
||
grossTarget,
|
||
totalCommission,
|
||
targetPath: item.plan.path,
|
||
entryPrice: fill.price,
|
||
exitPrice,
|
||
commission: entryCommission,
|
||
};
|
||
}));
|
||
|
||
// Group results by firm for the response
|
||
const firmResultsMap = new Map<string, unknown[]>();
|
||
for (let i = 0; i < batch.length; i++) {
|
||
const firmName = batch[i].firmName;
|
||
if (!firmResultsMap.has(firmName)) firmResultsMap.set(firmName, []);
|
||
const r = tradeResults[i];
|
||
firmResultsMap.get(firmName)!.push(
|
||
r.status === 'fulfilled'
|
||
? r.value
|
||
: { status: 'error', reason: (r.reason as any)?.message ?? String(r.reason) }
|
||
);
|
||
}
|
||
|
||
return Array.from(firmResultsMap.entries()).map(([firm, results]) => ({ firm, results }));
|
||
}
|
||
|
||
// ── copy trade ───────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Copy the current trade direction to up to maxConcurrent accounts.
|
||
* Finds accounts with open positions, determines direction, then fires
|
||
* orders for eligible accounts that haven't traded yet.
|
||
*/
|
||
export async function copyTrade() {
|
||
console.log('[copy-trade] called');
|
||
if (isInNoTradeWindow()) {
|
||
console.log('[copy-trade] outside trading hours — skipping');
|
||
return [];
|
||
}
|
||
|
||
const firms = getTradableFirms();
|
||
const clients = getClients();
|
||
const maxConcurrent = Math.max(1, parseInt(getSetting('max_concurrent_accounts') ?? '5', 10));
|
||
|
||
// Find all accounts with open positions to determine direction + symbol
|
||
let resolvedAction: 'Buy' | 'Sell' | null = null;
|
||
let positionContractId: number | null = null;
|
||
let positionedCount = 0;
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
let positionedClient: any = null;
|
||
|
||
for (const firm of firms) {
|
||
const client = clients.get(firm.id);
|
||
if (!client) continue;
|
||
for (const acc of client.accountList) {
|
||
const pos = client.positions[acc.id];
|
||
if (!pos) continue;
|
||
positionedCount++;
|
||
if (!resolvedAction) {
|
||
resolvedAction = pos.netPos > 0 ? 'Buy' : 'Sell';
|
||
positionContractId = pos.contractId;
|
||
positionedClient = client;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!resolvedAction || positionedCount === 0) {
|
||
console.log('[copy-trade] no open positions to copy from');
|
||
return [];
|
||
}
|
||
console.log(`[copy-trade] found ${positionedCount} open position(s), action=${resolvedAction}, contractId=${positionContractId}`);
|
||
|
||
// Resolve symbol from the positioned contract's contractId — only check the client that
|
||
// actually has the position. Iterating every firm × every symbol can take 30+ seconds.
|
||
let resolvedSymbol: string | null = null;
|
||
const instruments = getInstruments();
|
||
const enabledSymbols = instruments.filter(i => i.enabled).map(i => i.symbol);
|
||
|
||
if (positionedClient) {
|
||
for (const sym of enabledSymbols) {
|
||
const contract = await positionedClient.findFrontMonthContract(sym);
|
||
if (contract && contract.id === positionContractId) {
|
||
resolvedSymbol = sym;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
console.log(`[copy-trade] resolved symbol: ${resolvedSymbol ?? '(none, using fallback)'}`);
|
||
|
||
// Fall back to scheduler symbol or first enabled
|
||
if (!resolvedSymbol) {
|
||
const state = getState();
|
||
resolvedSymbol = state.symbol !== 'Auto' ? state.symbol : (enabledSymbols[0] ?? 'NQ');
|
||
}
|
||
|
||
const pointValue = POINT_VALUES[resolvedSymbol];
|
||
if (!pointValue) {
|
||
console.log(`[copy-trade] unknown symbol ${resolvedSymbol}`);
|
||
return [];
|
||
}
|
||
|
||
const slotsAvailable = maxConcurrent - positionedCount;
|
||
if (slotsAvailable <= 0) {
|
||
console.log(`[copy-trade] already at max concurrent (${positionedCount}/${maxConcurrent})`);
|
||
return [];
|
||
}
|
||
|
||
const tradeDate = currentTradeDateCT();
|
||
|
||
// Collect eligible accounts (same logic as Phase 1 of runTrade)
|
||
type CopyItem = {
|
||
firmName: string;
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
client: any;
|
||
acc: { id: number; name: string; active: boolean };
|
||
contract: { name: string; tickSize: number };
|
||
cash: { amount: number; realizedPnL: number };
|
||
plan: DayPlan;
|
||
};
|
||
const eligible: CopyItem[] = [];
|
||
|
||
await Promise.all(firms.map(async (firm) => {
|
||
const client = clients.get(firm.id);
|
||
if (!client || client.accountList.length === 0) {
|
||
console.log(`[copy-trade] ${firm.name}: no client or empty account list — skip firm`);
|
||
return;
|
||
}
|
||
if (firm.bannedSymbols.includes(resolvedSymbol!)) {
|
||
console.log(`[copy-trade] ${firm.name}: ${resolvedSymbol} is banned — skip firm`);
|
||
return;
|
||
}
|
||
|
||
const contract = await client.findFrontMonthContract(resolvedSymbol!);
|
||
if (!contract) {
|
||
console.log(`[copy-trade] ${firm.name}: cannot resolve front-month contract for ${resolvedSymbol} — skip firm`);
|
||
return;
|
||
}
|
||
|
||
for (const acc of client.accountList) {
|
||
const ev = evaluateAccountForTrade(client, acc, tradeDate);
|
||
if (!ev) { console.log(`[copy-trade] ${acc.name}: no matching config prefix — skip`); continue; }
|
||
if (!ev.eligible || !ev.plan) { console.log(`[copy-trade] ${acc.name}: ${ev.reason} — skip`); continue; }
|
||
// copyTrade has no extra-day branch — those accounts wait for the scheduler
|
||
if (ev.isExtraDay) { console.log(`[copy-trade] ${acc.name}: extra-day account — skip`); continue; }
|
||
|
||
eligible.push({ firmName: firm.name, client, acc, contract, cash: ev.cash, plan: ev.plan });
|
||
}
|
||
}));
|
||
|
||
const batch = eligible.slice(0, slotsAvailable);
|
||
if (batch.length === 0) {
|
||
console.log('[copy-trade] no eligible accounts to copy to');
|
||
return [];
|
||
}
|
||
|
||
console.log(`[copy-trade] copying ${resolvedAction} ${resolvedSymbol} to ${batch.length} account(s)`);
|
||
|
||
// Fire orders (same as Phase 2 of runTrade)
|
||
const tradeResults = await Promise.allSettled(batch.map(async (item) => {
|
||
const { client, acc, contract } = item;
|
||
const cfg = getAccountConfig(acc.id).accountType!;
|
||
|
||
const legAmount = item.plan.splitAllowed ? pickLegAmount(item.plan.remainingToday) : item.plan.remainingToday;
|
||
recordLeg(acc.id, tradeDate);
|
||
|
||
const rawContracts = Math.max(1, Math.ceil(legAmount / 1000));
|
||
const contracts = cfg.maxPositionSize > 0 ? Math.min(rawContracts, cfg.maxPositionSize) : rawContracts;
|
||
const fill = await client.sendOrder(acc.id, contract.name, contracts, resolvedAction!, 'Market');
|
||
|
||
await new Promise(r => setTimeout(r, 1000));
|
||
const updatedCash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
|
||
const entryCommission = Math.abs(updatedCash.realizedPnL - item.cash.realizedPnL) || (2.5 * contracts);
|
||
const totalCommission = entryCommission * 2;
|
||
const grossTarget = legAmount + totalCommission;
|
||
|
||
const targetPoints = grossTarget / (pointValue * contracts);
|
||
const ticks = Math.ceil(targetPoints / contract.tickSize);
|
||
const exitPrice = resolvedAction === 'Buy'
|
||
? fill.price + (ticks * contract.tickSize)
|
||
: fill.price - (ticks * contract.tickSize);
|
||
|
||
const exitAction: 'Buy' | 'Sell' = resolvedAction === 'Buy' ? 'Sell' : 'Buy';
|
||
const exitOrder = await client.placeOrderNoWait(acc.id, contract.name, contracts, exitAction, 'Limit', exitPrice);
|
||
|
||
console.log(`[copy-trade] ${acc.name} (${item.firmName}) ${resolvedAction} ${contracts}x${resolvedSymbol} @ ${fill.price} | leg $${legAmount} of day $${item.plan.dayTarget} (remaining $${item.plan.remainingToday}) [${item.plan.path}] | exit @ ${exitPrice} (orderId=${exitOrder.orderId})`);
|
||
|
||
return {
|
||
account: acc.name,
|
||
firm: item.firmName,
|
||
status: 'filled',
|
||
contracts,
|
||
target: legAmount,
|
||
dayTarget: item.plan.dayTarget,
|
||
remainingBefore: item.plan.remainingToday,
|
||
grossTarget,
|
||
totalCommission,
|
||
targetPath: item.plan.path,
|
||
entryPrice: fill.price,
|
||
exitPrice,
|
||
commission: entryCommission,
|
||
};
|
||
}));
|
||
|
||
const results: unknown[] = [];
|
||
for (let i = 0; i < batch.length; i++) {
|
||
const r = tradeResults[i];
|
||
results.push(
|
||
r.status === 'fulfilled'
|
||
? r.value
|
||
: { status: 'error', reason: (r.reason as any)?.message ?? String(r.reason) }
|
||
);
|
||
}
|
||
return results;
|
||
}
|
||
|
||
// ── eligibility check ────────────────────────────────────────────────────────
|
||
|
||
/** Returns true if any configured account could still trade today (eligible for a next leg / extra-day, or has an open position). */
|
||
function hasRemainingConfiguredAccounts(): boolean {
|
||
const firms = getTradableFirms();
|
||
const clients = getClients();
|
||
const tradeDate = currentTradeDateCT();
|
||
|
||
for (const firm of firms) {
|
||
const client = clients.get(firm.id);
|
||
if (!client || client.accountList.length === 0) continue;
|
||
|
||
for (const acc of client.accountList) {
|
||
const cfg = getAccountConfig(acc.id).accountType;
|
||
if (!cfg) continue; // no config = not our account
|
||
|
||
// Account with open position = still in play
|
||
if (client.positions[acc.id]) return true;
|
||
|
||
const ev = evaluateAccountForTrade(client, acc, tradeDate);
|
||
if (ev?.eligible) return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
// ── scheduler ─────────────────────────────────────────────────────────────────
|
||
|
||
interface SchedulerState {
|
||
action: 'Buy' | 'Sell' | 'Auto';
|
||
symbol: string;
|
||
intervalId: ReturnType<typeof setInterval> | null;
|
||
lastRun: Date | null;
|
||
running: boolean;
|
||
stopAfterAll: boolean;
|
||
}
|
||
|
||
// Global singleton (survives HMR in dev via module cache)
|
||
const _global = globalThis as typeof globalThis & { __autoTrader?: SchedulerState };
|
||
|
||
function getState(): SchedulerState {
|
||
if (!_global.__autoTrader) {
|
||
_global.__autoTrader = { action: 'Buy', symbol: 'NQ', intervalId: null, lastRun: null, running: false, stopAfterAll: false };
|
||
}
|
||
return _global.__autoTrader;
|
||
}
|
||
|
||
export function startScheduler(action: 'Buy' | 'Sell' | 'Auto', symbol: string, stopAfterAll: boolean = false) {
|
||
const state = getState();
|
||
|
||
// Clear any existing interval
|
||
if (state.intervalId !== null) {
|
||
clearInterval(state.intervalId);
|
||
}
|
||
|
||
state.action = action;
|
||
state.symbol = symbol;
|
||
state.running = true;
|
||
state.stopAfterAll = stopAfterAll;
|
||
|
||
// Mirror to the settings table so a restart can pick the schedule back up.
|
||
// The interval itself is in-memory only; resumeSchedulerIfPersisted() recreates it.
|
||
setSetting('scheduler_running', '1');
|
||
setSetting('scheduler_action', action);
|
||
setSetting('scheduler_symbol', symbol);
|
||
setSetting('scheduler_stop_after_all', stopAfterAll ? '1' : '0');
|
||
|
||
const tick = async () => {
|
||
if (!state.running) return;
|
||
state.lastRun = new Date();
|
||
|
||
// Skip this tick until every client has completed its initial sync (positions are populated)
|
||
const clients = getClients();
|
||
const firms = getTradableFirms();
|
||
const notReady = firms.filter(f => {
|
||
const c = clients.get(f.id);
|
||
return c && !c.syncComplete;
|
||
});
|
||
if (notReady.length > 0) {
|
||
console.log(`[scheduler] waiting for sync: ${notReady.map(f => f.name).join(', ')}`);
|
||
return;
|
||
}
|
||
|
||
// Skip this tick if any account still has an open position from the previous batch
|
||
const openPositions = firms.reduce((count, firm) => {
|
||
const client = clients.get(firm.id);
|
||
if (!client) return count;
|
||
return count + client.accountList.filter(acc => !!client.positions[acc.id]).length;
|
||
}, 0);
|
||
if (openPositions > 0) {
|
||
console.log(`[scheduler] ${openPositions} position(s) still open — skipping tick`);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const results = await runTrade(state.action, state.symbol);
|
||
const filled = results.flatMap((r: any) => r.results ?? []).filter((r: any) => r.status === 'filled').length;
|
||
if (filled > 0) console.log(`[scheduler] tick: ${filled} account(s) filled`);
|
||
|
||
// Auto-stop if user opted in and no configured accounts can trade anymore
|
||
if (state.stopAfterAll && !hasRemainingConfiguredAccounts()) {
|
||
console.log('[scheduler] all configured accounts done for today — stopping');
|
||
stopScheduler();
|
||
}
|
||
} catch (err) {
|
||
console.error('[scheduler] tick error:', err);
|
||
}
|
||
};
|
||
|
||
const intervalSecs = Math.max(5, parseInt(getSetting('tick_interval_seconds') ?? '60', 10));
|
||
state.intervalId = setInterval(tick, intervalSecs * 1_000);
|
||
console.log(`[scheduler] started — ${action} ${symbol} every ${intervalSecs}s`);
|
||
}
|
||
|
||
export function stopScheduler() {
|
||
const state = getState();
|
||
if (state.intervalId !== null) {
|
||
clearInterval(state.intervalId);
|
||
state.intervalId = null;
|
||
}
|
||
state.running = false;
|
||
setSetting('scheduler_running', '0');
|
||
console.log('[scheduler] stopped');
|
||
}
|
||
|
||
/**
|
||
* Restart the schedule that was running before the process went down.
|
||
*
|
||
* No sync-wait here on purpose: tick() already skips while any client reports
|
||
* !syncComplete, and again while any account holds an open position. So the
|
||
* worst case is a few logged no-op ticks until the clients finish syncing.
|
||
*/
|
||
export function resumeSchedulerIfPersisted(): void {
|
||
if (getSetting('scheduler_running') !== '1') return;
|
||
|
||
const action = (getSetting('scheduler_action') ?? 'Buy') as 'Buy' | 'Sell' | 'Auto';
|
||
const symbol = getSetting('scheduler_symbol') ?? 'NQ';
|
||
const stopAfterAll = getSetting('scheduler_stop_after_all') === '1';
|
||
|
||
console.log(`[scheduler] resuming persisted schedule — ${action} ${symbol}`);
|
||
startScheduler(action, symbol, stopAfterAll);
|
||
}
|
||
|
||
export function getSchedulerStatus() {
|
||
const state = getState();
|
||
return {
|
||
running: state.running,
|
||
action: state.action,
|
||
symbol: state.symbol,
|
||
lastRun: state.lastRun,
|
||
intervalSeconds: parseInt(getSetting('tick_interval_seconds') ?? '60', 10),
|
||
stopAfterAll: state.stopAfterAll,
|
||
sessionOpenDelayMin: todaysSessionOpenDelay(),
|
||
};
|
||
}
|