- DB migration: ALTER TABLE account_configs ADD COLUMN max_position_size INTEGER NOT NULL DEFAULT 0 - Added max_position_size to AccountConfigRow, createAccountConfig, updateAccountConfig in lib/db.ts - Added maxPositionSize to AccountConfig type in types.ts (0 = no limit) - GET /api/firms/[id] now returns maxPositionSize per account - POST /api/firms/[id]/accounts and PUT /api/account-configs/[id] accept maxPositionSize - Firm settings page: new Max Contracts column (blank = no limit) - auto-trade: contracts capped at maxPositionSize when > 0 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
274 lines
11 KiB
TypeScript
274 lines
11 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 } 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,
|
|
})),
|
|
};
|
|
}
|
|
|
|
|
|
|
|
// ── core trade logic ──────────────────────────────────────────────────────────
|
|
|
|
export async function runTrade(action: 'Buy' | 'Sell', symbol: string) {
|
|
const pointValue = POINT_VALUES[symbol];
|
|
if (!pointValue) throw new Error(`Unknown symbol: ${symbol}`);
|
|
|
|
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;
|
|
|
|
const firmConfig = mapFirmConfig(firm);
|
|
|
|
const contract = await client.findFrontMonthContract(symbol);
|
|
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);
|
|
if (target.amount <= 0) 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 target = computeDailyTarget(cfg.profitTarget, cfg.consistency, totalProfit, dailyPnL);
|
|
|
|
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, action, '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 = action === 'Buy'
|
|
? fill.price + (ticks * contract.tickSize)
|
|
: fill.price - (ticks * contract.tickSize);
|
|
|
|
const exitAction: 'Buy' | 'Sell' = action === 'Buy' ? 'Sell' : 'Buy';
|
|
const exitOrder = await client.placeOrderNoWait(acc.id, contract.name, contracts, exitAction, 'Limit', exitPrice);
|
|
|
|
console.log(`[auto-trade] ${acc.name} (${item.firmName}) ${action} ${contracts}x${symbol} @ ${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';
|
|
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', 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);
|
|
}
|
|
};
|
|
|
|
state.intervalId = setInterval(tick, 60_000);
|
|
console.log(`[scheduler] started — ${action} ${symbol} every 60s`);
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|