diff --git a/app/api/copy-trade/route.ts b/app/api/copy-trade/route.ts new file mode 100644 index 0000000..1c4a581 --- /dev/null +++ b/app/api/copy-trade/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from 'next/server'; +import { copyTrade } from '@/lib/auto-trade'; + +export async function POST() { + try { + const results = await copyTrade(); + return NextResponse.json(results); + } catch (err: any) { + console.error('[POST /api/copy-trade]', err); + return NextResponse.json({ error: err?.message ?? 'Copy trade failed' }, { status: 500 }); + } +} diff --git a/app/api/state/route.ts b/app/api/state/route.ts index 954e6e9..14309cc 100644 --- a/app/api/state/route.ts +++ b/app/api/state/route.ts @@ -66,7 +66,9 @@ export async function GET() { amount: cash.amount, realizedPnL: cash.realizedPnL, daysTraded, - hasPosition: !!client.positions[acc.id], + positionDirection: client.positions[acc.id] + ? (client.positions[acc.id].netPos > 0 ? 'long' as const : 'short' as const) + : null, autoLiqThreshold, totalProfit, targetHit, diff --git a/app/api/trade/route.ts b/app/api/trade/route.ts index c4e7d11..f08d6cd 100644 --- a/app/api/trade/route.ts +++ b/app/api/trade/route.ts @@ -4,14 +4,14 @@ import { POINT_VALUES } from '@/lib/trading-logic'; export async function POST(req: NextRequest) { try { - const body = await req.json() as { action: 'Buy' | 'Sell' | 'Random'; symbol: string }; - const { action, symbol } = body; + const body = await req.json() as { action: 'Buy' | 'Sell' | 'Auto'; symbol: string; stopAfterAll?: boolean }; + const { action, symbol, stopAfterAll } = body; if (!action || !symbol) { return NextResponse.json({ error: 'Missing required fields: action, symbol' }, { status: 400 }); } - if (symbol !== 'Random' && !POINT_VALUES[symbol]) { + if (symbol !== 'Auto' && !POINT_VALUES[symbol]) { return NextResponse.json({ error: `Unknown symbol: ${symbol}` }, { status: 400 }); } @@ -19,7 +19,7 @@ export async function POST(req: NextRequest) { const results = await runTrade(action, symbol); // (Re)start the scheduler with this action + symbol - startScheduler(action, symbol); + startScheduler(action, symbol, stopAfterAll ?? false); return NextResponse.json(results); } catch (err: any) { diff --git a/app/page.tsx b/app/page.tsx index 8eb9835..d0d1fd5 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -11,7 +11,7 @@ type SortDir = 'asc' | 'desc'; function statusRank(account: AccountState): number { if (isAccountDead(account)) return 0; if (!account.active) return 1; - if (!account.hasPosition) { + if (!account.positionDirection) { if (account.targetHit) return 4; // Target Hit — most accomplished return 2; // Flat } @@ -147,8 +147,8 @@ function AccountRow({ account, firm, hideDead, privacy }: { account: AccountStat ? Dead : !account.active ? Hit DLL - : account.hasPosition - ? In Trade + : account.positionDirection + ? {account.positionDirection === 'long' ? 'Long' : 'Short'} : account.targetHit ? Target Hit : Flat} @@ -258,10 +258,11 @@ function FirmRows({ state, firm, deleteMode, selected, onToggle, hideDead, priva interface SchedulerStatus { running: boolean; - action: 'Buy' | 'Sell' | 'Random'; + action: 'Buy' | 'Sell' | 'Auto'; symbol: string; lastRun: string | null; intervalSeconds: number; + stopAfterAll: boolean; } export default function Home() { @@ -276,12 +277,13 @@ export default function Home() { const [sortDir, setSortDir] = useState('asc'); // Trade controls - const [scheduler, setScheduler] = useState({ running: false, action: 'Buy', symbol: 'NQ', lastRun: null, intervalSeconds: 60 }); + const [scheduler, setScheduler] = useState({ running: false, action: 'Buy', symbol: 'NQ', lastRun: null, intervalSeconds: 60, stopAfterAll: false }); const [enabledSymbols, setEnabledSymbols] = useState([]); const [tradeSymbol, setTradeSymbol] = useState(''); - const [tradeAction, setTradeAction] = useState<'Buy' | 'Sell' | 'Random'>('Buy'); + const [tradeAction, setTradeAction] = useState<'Buy' | 'Sell' | 'Auto'>('Buy'); const [tickInterval, setTickInterval] = useState('60'); const [tradeLoading, setTradeLoading] = useState(false); + const [stopAfterAll, setStopAfterAll] = useState(false); const handleSort = (col: SortKey) => { if (sortKey === col) { @@ -355,7 +357,7 @@ export default function Home() { await fetch('/api/trade', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ action: tradeAction, symbol: tradeSymbol }), + body: JSON.stringify({ action: tradeAction, symbol: tradeSymbol, stopAfterAll }), }); await fetchScheduler(); } finally { @@ -368,6 +370,17 @@ export default function Home() { setScheduler((s) => ({ ...s, running: false, lastRun: null })); }; + const hasAnyPosition = firms.some(f => f.accounts.some(a => a.positionDirection !== null)); + const [copyLoading, setCopyLoading] = useState(false); + const handleCopy = async () => { + setCopyLoading(true); + try { + await fetch('/api/copy-trade', { method: 'POST' }); + } finally { + setCopyLoading(false); + } + }; + // Count dead accounts across all firms for the toggle button label const deadCount = firms.reduce((total, firmState) => { const firmCfg = config.find((c) => c.firm === firmState.firm); @@ -476,12 +489,23 @@ export default function Home() { · last run {new Date(scheduler.lastRun).toLocaleTimeString()} )} - +
+ {hasAnyPosition && ( + + )} + +
) : ( <> @@ -494,7 +518,7 @@ export default function Home() { className="rounded-lg border border-slate-200 bg-slate-50 px-2.5 py-1.5 text-sm font-mono text-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500" > {enabledSymbols.map((s) => )} - +
@@ -538,13 +562,33 @@ export default function Home() { s
- + +
+ {hasAnyPosition && ( + + )} + +
)} diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index ca3ff31..9fe8f72 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -107,20 +107,20 @@ function isInNoTradeWindow(): boolean { // ── core trade logic ────────────────────────────────────────────────────────── -export async function runTrade(action: 'Buy' | 'Sell' | 'Random', symbol: string) { +export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string) { if (isInNoTradeWindow()) { console.log('[auto-trade] CME market closed — skipping'); return []; } - // Resolve 'Random' symbol once per batch so all accounts trade the same symbol + // Resolve 'Auto' symbol once per batch so all accounts trade the same symbol let resolvedSymbol = symbol; - if (symbol === 'Random') { + 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 Random action once per batch so all accounts trade the same direction - const resolvedAction: 'Buy' | 'Sell' = action === 'Random' + const resolvedAction: 'Buy' | 'Sell' = action === 'Auto' ? (Math.random() < 0.5 ? 'Buy' : 'Sell') : action; const pointValue = POINT_VALUES[resolvedSymbol]; @@ -307,14 +307,245 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Random', symbol: string 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() { + 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 resolvedSymbol: string | null = null; + let positionedCount = 0; + + 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'; + } + // Determine the symbol from the scheduler state (positions only have contractId) + if (!resolvedSymbol) { + const state = getState(); + resolvedSymbol = state.symbol === 'Auto' ? null : state.symbol; + } + } + } + + if (!resolvedAction || positionedCount === 0) { + console.log('[copy-trade] no open positions to copy from'); + return []; + } + + // Fall back to enabled instruments if symbol unknown + if (!resolvedSymbol) { + const instruments = getInstruments(); + const enabled = instruments.filter(i => i.enabled).map(i => i.symbol); + resolvedSymbol = enabled[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; + 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) return; + if (isSymbolBanned(firm.id, resolvedSymbol!)) return; + + const firmConfig = mapFirmConfig(firm); + const contract = await client.findFrontMonthContract(resolvedSymbol!); + if (!contract) return; + + for (const acc of client.accountList) { + if (client.positions[acc.id]) continue; // already in a trade + 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; + const cfg = getAccountConfig(acc.name, firmConfig); + if (!cfg) 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 target = computeDailyTarget(effective.profitTarget, effective.consistency, totalProfit, dailyPnL, cfg.minDayPnL, effective.minTradingDays); + if (target.amount <= 0) continue; + + eligible.push({ firmName: firm.name, client, acc, contract, firmConfig, 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, 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 target = computeDailyTarget(effective.profitTarget, effective.consistency, totalProfit, dailyPnL, cfg.minDayPnL, effective.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'); + + 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 target = computeDailyTarget(effective.profitTarget, effective.consistency, totalProfit, dailyPnL, cfg.minDayPnL, effective.minTradingDays); + 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' | 'Random'; + action: 'Buy' | 'Sell' | 'Auto'; symbol: string; intervalId: ReturnType | null; lastRun: Date | null; running: boolean; + stopAfterAll: boolean; } // Global singleton (survives HMR in dev via module cache) @@ -322,12 +553,12 @@ const _global = globalThis as typeof globalThis & { __autoTrader?: SchedulerStat function getState(): SchedulerState { if (!_global.__autoTrader) { - _global.__autoTrader = { action: 'Buy', symbol: 'NQ', intervalId: null, lastRun: null, running: false }; + _global.__autoTrader = { action: 'Buy', symbol: 'NQ', intervalId: null, lastRun: null, running: false, stopAfterAll: false }; } return _global.__autoTrader; } -export function startScheduler(action: 'Buy' | 'Sell' | 'Random', symbol: string) { +export function startScheduler(action: 'Buy' | 'Sell' | 'Auto', symbol: string, stopAfterAll: boolean = false) { const state = getState(); // Clear any existing interval @@ -338,6 +569,7 @@ export function startScheduler(action: 'Buy' | 'Sell' | 'Random', symbol: string state.action = action; state.symbol = symbol; state.running = true; + state.stopAfterAll = stopAfterAll; const tick = async () => { if (!state.running) return; @@ -370,6 +602,12 @@ export function startScheduler(action: 'Buy' | 'Sell' | 'Random', symbol: string 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); } @@ -398,5 +636,6 @@ export function getSchedulerStatus() { symbol: state.symbol, lastRun: state.lastRun, intervalSeconds: parseInt(getSetting('tick_interval_seconds') ?? '60', 10), + stopAfterAll: state.stopAfterAll, }; } diff --git a/lib/trading-logic.ts b/lib/trading-logic.ts index 3d8a415..7a3afd7 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -94,10 +94,11 @@ export function computeDailyTarget( let path: 'first_day' | 'normal_day' | 'reduced_day'; if (daysTraded === 0) { - baseAmount = profitTarget * consistency; + // 0% or 100% consistency = no constraint; let min-day reservation drive the target + baseAmount = (consistency === 0 || consistency >= 1) ? 0 : profitTarget * consistency; path = 'first_day'; - } else if (consistency === 0) { - // 0% consistency means no consistency rule to satisfy — base amount is always $0. + } else if (consistency === 0 || consistency >= 1) { + // No consistency rule to satisfy — base amount is $0. // The min-day reservation block below handles any mandatory-day targeting. baseAmount = 0; path = 'reduced_day'; @@ -140,5 +141,10 @@ export function computeDailyTarget( return { amount: Math.round(amount * 100) / 100, path }; } + // When no consistency constraint and no min-day reservation applied, target the full remaining profit + if (baseAmount <= 0 && (consistency === 0 || consistency >= 1)) { + baseAmount = Math.max(0, profitTarget - totalProfit); + } + return { amount: Math.round(baseAmount * 100) / 100, path }; } diff --git a/types.ts b/types.ts index e3f9be5..a44c5bd 100644 --- a/types.ts +++ b/types.ts @@ -27,7 +27,7 @@ export interface AccountState { amount: number; realizedPnL: number; daysTraded: number; - hasPosition: boolean; + positionDirection: 'long' | 'short' | null; /** Balance floor from Tradovate's auto-liquidation profile (0 = not set) */ autoLiqThreshold: number; /** Sum of all historical daily P&L entries */