([]);
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) => )}
-
+
-
- {tradeLoading ? 'Starting…' : '▶ Start'}
-
+
+
+ {hasAnyPosition && (
+
+ {copyLoading ? 'Copying…' : 'Copy to Max'}
+
+ )}
+
+ {tradeLoading ? 'Starting…' : '▶ Start'}
+
+
>
)}
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 */