Files
autofirmer-expanded/lib/auto-trade.ts
T
SenofyandClaude Sonnet 4.6 d0065ab047 Refresh dailyPnL on a fixed hourly interval instead of throttling
- Replace the reactive throttle with a proactive setInterval in requestSync
  that fires fetchDaysTraded exactly once per hour per client
- Remove post-fill fetchDaysTraded calls from auto-trade.ts — no longer
  needed and were causing bursts of report API requests on simultaneous fills
- Guard against duplicate intervals if requestSync fires more than once

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 20:41:56 -05:00

350 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 } from './db';
import { getClients } from './clients';
import { computeDailyTarget, POINT_VALUES } from './trading-logic';
import { getSetting } 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,
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,
})),
};
}
// ── helpers ───────────────────────────────────────────────────────────────────
/** Returns true during the 3:00 PM 4:59 PM Central no-trade window. */
function isInNoTradeWindow(): boolean {
const hour = parseInt(
new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
hour12: false,
timeZone: 'America/Chicago',
}).format(new Date()),
10
);
return hour >= 15 && hour < 17;
}
// ── core trade logic ──────────────────────────────────────────────────────────
export async function runTrade(action: 'Buy' | 'Sell' | 'Random', symbol: string) {
if (isInNoTradeWindow()) {
console.log('[auto-trade] no-trade window active (35 PM Central) — skipping');
return [];
}
// Resolve 'Random' symbol once per batch so all accounts trade the same symbol
let resolvedSymbol = symbol;
if (symbol === 'Random') {
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 Random action once per batch so all accounts trade the same direction
const resolvedAction: 'Buy' | 'Sell' = action === 'Random'
? (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;
// Use the same target formula as the dashboard — skip if $0 (challenge complete)
const target = computeDailyTarget(cfg.profitTarget, cfg.consistency, totalProfit, dailyPnL, cfg.minDayPnL, cfg.minTradingDays);
// 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
&& cfg.minTradingDays > daysTraded
&& totalProfit >= cfg.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, dailyPnL, daysTraded } = item;
const cfg = getAccountConfig(acc.name, firmConfig)!;
const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0);
// 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
&& cfg.minTradingDays > daysTraded
&& totalProfit >= cfg.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 target = computeDailyTarget(cfg.profitTarget, cfg.consistency, totalProfit, dailyPnL, cfg.minDayPnL, cfg.minTradingDays);
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 }));
}
// ── scheduler ─────────────────────────────────────────────────────────────────
interface SchedulerState {
action: 'Buy' | 'Sell' | 'Random';
symbol: string;
intervalId: ReturnType<typeof setInterval> | null;
lastRun: Date | null;
running: 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 };
}
return _global.__autoTrader;
}
export function startScheduler(action: 'Buy' | 'Sell' | 'Random', symbol: string) {
const state = getState();
// Clear any existing interval
if (state.intervalId !== null) {
clearInterval(state.intervalId);
}
state.action = action;
state.symbol = symbol;
state.running = true;
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`);
} 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;
console.log('[scheduler] stopped');
}
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),
};
}