Instances were started by hand and updated by hand, so they drifted behind master silently. Now: PM2 supervises the dashboard and the clicker, a logon task brings them up, and a 5-minute task pulls, rebuilds and restarts when master moves. Restarting on every push is only safe because the scheduler now survives it. It was pure in-memory state (_global.__autoTrader), so any restart silently stopped automated trading with the dashboard simply showing it as off. It now mirrors running/action/symbol/stopAfterAll to the settings table, and resumeSchedulerIfPersisted() picks it back up from the getClients() bootstrap. No sync-wait was needed there: tick() already skips while a client reports !syncComplete and while any account holds a position. A failed build is never deployed — the build runs before anything restarts, so a broken push leaves the previous build serving. start-all and update-check both warm the app with a request afterwards. That is load-bearing: getClients() is lazily bootstrapped, so until something makes an HTTP request the Tradovate clients, the reporter and the resumed schedule never start. That was already true of manual restarts. Logic lives in Node so a macOS or Linux port only needs an equivalent of install-autostart.ps1. Python deps are hash-guarded, so the common path is one hash and one import with no network, and failure is non-fatal since only the clicker needs them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
720 lines
32 KiB
TypeScript
720 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 { getFirms, isSymbolBanned, getInstruments, getBannedSymbols } from './db';
|
||
import { getClients } from './clients';
|
||
import { computeDailyTarget, resolveEffectiveConfig, POINT_VALUES } from './trading-logic';
|
||
import { getSetting, setSetting } from './db';
|
||
import type { FirmConfig, AccountConfig } from '@/types';
|
||
import type { FirmWithAccounts } from './db';
|
||
|
||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
function isAccountDead(amount: number, autoLiqThreshold: number): boolean {
|
||
return autoLiqThreshold > 0 && amount <= autoLiqThreshold;
|
||
}
|
||
|
||
function getAccountConfig(name: string, firm: FirmConfig): AccountConfig | undefined {
|
||
return [...firm.accounts]
|
||
.sort((a, b) => b.prefix.length - a.prefix.length)
|
||
.find((a) => name.startsWith(a.prefix));
|
||
}
|
||
|
||
/** Map DB row (snake_case) → FirmConfig (camelCase) to fix field-name mismatch. */
|
||
function mapFirmConfig(firm: FirmWithAccounts): FirmConfig {
|
||
return {
|
||
id: firm.id,
|
||
firm: firm.name,
|
||
username: firm.username,
|
||
password: firm.password,
|
||
bannedSymbols: getBannedSymbols(firm.id),
|
||
accounts: firm.accounts.map((a) => ({
|
||
prefix: a.prefix,
|
||
profitTarget: a.profit_target,
|
||
consistency: a.consistency,
|
||
minDayPnL: a.min_day_pnl,
|
||
minTradingDays: a.min_trading_days,
|
||
accountSize: a.account_size,
|
||
maxLoss: a.max_loss,
|
||
maxPositionSize: a.max_position_size,
|
||
targetSameEquity: a.target_same_equity === 1,
|
||
withdrawalStages: (() => { try { return JSON.parse(a.withdrawal_stages ?? '[]') as { profit: number; consistency: number; minTradingDays: number }[]; } catch { return []; } })(),
|
||
})),
|
||
};
|
||
}
|
||
|
||
|
||
|
||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* 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)
|
||
*/
|
||
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 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; // 8:35 AM
|
||
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
|
||
const cmeOpen = 17 * 60 + 5; // 5:05 PM
|
||
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 = getFirms();
|
||
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 maxConcurrent = parseInt(getSetting('max_concurrent_accounts') ?? '5', 10);
|
||
const firms = getFirms();
|
||
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 };
|
||
firmConfig: FirmConfig;
|
||
cash: { amount: number; realizedPnL: number };
|
||
dailyPnL: { date: string; pnl: number }[];
|
||
daysTraded: number;
|
||
};
|
||
|
||
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 (isSymbolBanned(firm.id, resolvedSymbol)) {
|
||
console.log(`[auto-trade] ${firm.name}: ${resolvedSymbol} is banned — skipping firm`);
|
||
return;
|
||
}
|
||
|
||
const firmConfig = mapFirmConfig(firm);
|
||
|
||
const contract = await client.findFrontMonthContract(resolvedSymbol);
|
||
if (!contract) return;
|
||
|
||
for (const acc of client.accountList) {
|
||
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;
|
||
|
||
if (isAccountDead(cash.amount, autoLiqThreshold)) continue;
|
||
if (!acc.active) continue;
|
||
if (client.positions[acc.id]) continue;
|
||
|
||
const cfg = getAccountConfig(acc.name, firmConfig);
|
||
if (!cfg) continue;
|
||
|
||
const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0);
|
||
|
||
// Only trade accounts that haven't traded yet today
|
||
if (cash.realizedPnL !== 0) continue;
|
||
|
||
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
|
||
);
|
||
|
||
// Use the same target formula as the dashboard — skip if $0 (challenge complete)
|
||
const equityProfit = cash.amount - cfg.accountSize;
|
||
const target = computeDailyTarget(effective.profitTarget, effective.consistency, dailyPnL, cfg.minDayPnL, effective.minTradingDays, equityProfit);
|
||
if (!target) continue; // skip accounts without a valid balance
|
||
|
||
// Allow through if it's an MNQ extra-day trade: no min day P&L, profit done, days still needed
|
||
const isMnqExtraDay = cfg.minDayPnL <= 0
|
||
&& effective.minTradingDays > daysTraded
|
||
&& totalProfit >= effective.profitTarget;
|
||
|
||
if (target.amount <= 0 && !isMnqExtraDay) continue;
|
||
|
||
allEligible.push({ firmName: firm.name, client, acc, contract, firmConfig, cash, dailyPnL, daysTraded });
|
||
}
|
||
}));
|
||
|
||
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, firmConfig, cash, dailyPnL, daysTraded } = item;
|
||
const cfg = getAccountConfig(acc.name, firmConfig)!;
|
||
const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0);
|
||
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
|
||
);
|
||
|
||
// 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.
|
||
const isExtraDay = cfg.minDayPnL <= 0
|
||
&& effective.minTradingDays > daysTraded
|
||
&& totalProfit >= effective.profitTarget;
|
||
|
||
if (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,
|
||
};
|
||
}
|
||
|
||
const equityProfit = cash.amount - cfg.accountSize;
|
||
const target = computeDailyTarget(effective.profitTarget, effective.consistency, dailyPnL, cfg.minDayPnL, effective.minTradingDays, equityProfit);
|
||
if (!target) throw new Error(`${acc.name}: invalid balance, cannot compute target`);
|
||
|
||
const rawContracts = Math.max(1, Math.ceil(target.amount / 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 = -entryCommission (was 0 before), so abs = entry fee paid.
|
||
// 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) || (2.5 * contracts);
|
||
const totalCommission = entryCommission * 2; // entry + exit round-trip
|
||
const grossTarget = target.amount + 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} | target $${target.amount} [${target.path}] (+$${totalCommission.toFixed(2)} comm) | exit @ ${exitPrice} (orderId=${exitOrder.orderId})`);
|
||
|
||
return {
|
||
account: acc.name,
|
||
firm: item.firmName,
|
||
status: 'filled',
|
||
contracts,
|
||
target: target.amount,
|
||
grossTarget,
|
||
totalCommission,
|
||
targetPath: target.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 = getFirms();
|
||
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 [];
|
||
}
|
||
|
||
// Collect eligible accounts (same logic as Phase 1 of runTrade)
|
||
type CopyItem = {
|
||
firmName: string;
|
||
client: any;
|
||
acc: { id: number; name: string; active: boolean };
|
||
contract: { name: string; tickSize: number };
|
||
firmConfig: FirmConfig;
|
||
cash: { amount: number; realizedPnL: number };
|
||
dailyPnL: { date: string; pnl: number }[];
|
||
daysTraded: number;
|
||
};
|
||
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 (isSymbolBanned(firm.id, resolvedSymbol!)) {
|
||
console.log(`[copy-trade] ${firm.name}: ${resolvedSymbol} is banned — skip firm`);
|
||
return;
|
||
}
|
||
|
||
const firmConfig = mapFirmConfig(firm);
|
||
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) {
|
||
if (client.positions[acc.id]) { console.log(`[copy-trade] ${acc.name}: already in trade — skip`); continue; }
|
||
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
|
||
const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0;
|
||
if (isAccountDead(cash.amount, autoLiqThreshold)) { console.log(`[copy-trade] ${acc.name}: dead — skip`); continue; }
|
||
if (!acc.active) { console.log(`[copy-trade] ${acc.name}: inactive (DLL) — skip`); continue; }
|
||
const cfg = getAccountConfig(acc.name, firmConfig);
|
||
if (!cfg) { console.log(`[copy-trade] ${acc.name}: no matching config prefix — skip`); continue; }
|
||
if (cash.realizedPnL !== 0) { console.log(`[copy-trade] ${acc.name}: already traded today (realizedPnL=${cash.realizedPnL}) — skip`); continue; }
|
||
|
||
const dailyPnL: { date: string; pnl: number }[] = client.dailyPnL[acc.id] ?? [];
|
||
const daysTraded: number = client.daysTraded[acc.id] ?? 0;
|
||
const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0);
|
||
|
||
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 target = computeDailyTarget(effective.profitTarget, effective.consistency, dailyPnL, cfg.minDayPnL, effective.minTradingDays, equityProfit);
|
||
if (!target) { console.log(`[copy-trade] ${acc.name}: target=null (invalid balance) — skip`); continue; }
|
||
if (target.amount <= 0) { console.log(`[copy-trade] ${acc.name}: target=$${target.amount} (challenge complete) — skip`); continue; }
|
||
|
||
eligible.push({ firmName: firm.name, client, acc, contract, firmConfig, cash, dailyPnL, daysTraded });
|
||
}
|
||
}));
|
||
|
||
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, firmConfig, cash, dailyPnL } = item;
|
||
const cfg = getAccountConfig(acc.name, firmConfig)!;
|
||
const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0);
|
||
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 target = computeDailyTarget(effective.profitTarget, effective.consistency, dailyPnL, cfg.minDayPnL, effective.minTradingDays, equityProfit);
|
||
if (!target) throw new Error(`${acc.name}: invalid balance, cannot compute target`);
|
||
|
||
const rawContracts = Math.max(1, Math.ceil(target.amount / 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) || (2.5 * contracts);
|
||
const totalCommission = entryCommission * 2;
|
||
const grossTarget = target.amount + 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} | target $${target.amount} [${target.path}] | exit @ ${exitPrice} (orderId=${exitOrder.orderId})`);
|
||
|
||
return {
|
||
account: acc.name,
|
||
firm: item.firmName,
|
||
status: 'filled',
|
||
contracts,
|
||
target: target.amount,
|
||
grossTarget,
|
||
totalCommission,
|
||
targetPath: target.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 (not dead, not inactive, hasn't traded, target > 0 or extra-day, or has open position). */
|
||
function hasRemainingConfiguredAccounts(): boolean {
|
||
const firms = getFirms();
|
||
const clients = getClients();
|
||
|
||
for (const firm of firms) {
|
||
const client = clients.get(firm.id);
|
||
if (!client || client.accountList.length === 0) continue;
|
||
|
||
const firmConfig = mapFirmConfig(firm);
|
||
|
||
for (const acc of client.accountList) {
|
||
const cfg = getAccountConfig(acc.name, firmConfig);
|
||
if (!cfg) continue; // no config = not our account
|
||
|
||
// Account with open position = still in play
|
||
if (client.positions[acc.id]) return true;
|
||
|
||
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
|
||
const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0;
|
||
|
||
if (isAccountDead(cash.amount, autoLiqThreshold)) continue;
|
||
if (!acc.active) continue;
|
||
if (cash.realizedPnL !== 0) continue; // already traded today
|
||
|
||
const dailyPnL: { date: string; pnl: number }[] = client.dailyPnL[acc.id] ?? [];
|
||
const daysTraded: number = client.daysTraded[acc.id] ?? 0;
|
||
const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0);
|
||
|
||
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 target = computeDailyTarget(effective.profitTarget, effective.consistency, dailyPnL, cfg.minDayPnL, effective.minTradingDays, equityProfit);
|
||
if (!target) continue; // no valid balance → skip this account
|
||
const isMnqExtraDay = cfg.minDayPnL <= 0
|
||
&& effective.minTradingDays > daysTraded
|
||
&& totalProfit >= effective.profitTarget;
|
||
|
||
if (target.amount > 0 || isMnqExtraDay) 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 = getFirms();
|
||
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,
|
||
};
|
||
}
|