diff --git a/app/api/state/route.ts b/app/api/state/route.ts index 216dc61..9c9e275 100644 --- a/app/api/state/route.ts +++ b/app/api/state/route.ts @@ -3,12 +3,13 @@ import { loadDailyPnL } from '@/lib/db'; import { getClients } from '@/lib/clients'; import { getCachedCatalog } from '@/lib/firm-catalog'; import { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/account-type-resolver'; -import { computeDailyTarget, resolveEffectiveConfig } from '@/lib/trading-logic'; +import { computeDayPlan, resolveEffectiveConfig, currentTradeDateCT, LEG_COMPLETION_EPSILON } from '@/lib/trading-logic'; export async function GET() { try { const firms = getCachedCatalog(); const clients = getClients(); + const tradeDate = currentTradeDateCT(); const state = firms.map((f) => { const client = clients.get(f.id); @@ -53,6 +54,7 @@ export async function GET() { : false; let targetHit = false; let dailyTarget: { amount: number; path: 'first_day' | 'normal_day' | 'reduced_day' } | null = null; + let remainingToday: number | null = null; let effectiveProfitTarget: number | null = null; if (cfg && !isDead) { const effective = resolveEffectiveConfig( @@ -65,8 +67,12 @@ export async function GET() { allFundTxns ); const equityProfit = cash.amount - cfg.accountSize; - const target = computeDailyTarget(effective.profitTarget, effective.consistency, dailyPnL, cfg.minDayPnL, effective.minTradingDays, equityProfit); - dailyTarget = target; + const plan = computeDayPlan( + acc.id, tradeDate, effective.profitTarget, effective.consistency, + dailyPnL, cfg.minDayPnL, effective.minTradingDays, equityProfit, cash.realizedPnL + ); + dailyTarget = plan ? { amount: plan.dayTarget, path: plan.path } : null; + remainingToday = plan?.remainingToday ?? null; // Effective profit target = max(stage target, consistency realTarget). // If a big day forces the consistency rule, the account must reach maxDay/consistency @@ -77,11 +83,11 @@ export async function GET() { ? maxDay / effective.consistency : 0; effectiveProfitTarget = Math.max(effective.profitTarget, consistencyReal); // Condition 1: profit target already exceeded (target=0), still need days → any activity counts - // Condition 2: target > 0 → must have made at least the computed daily target - if (target) { + // Condition 2: target > 0 → all legs done (day's remaining target used up) + if (plan) { targetHit = - (target.amount === 0 && Math.abs(cash.realizedPnL) > 0 && client.daysTraded[acc.id] <= effective.minTradingDays) || - (cash.realizedPnL >= target.amount); + (plan.dayTarget === 0 && Math.abs(cash.realizedPnL) > 0 && client.daysTraded[acc.id] <= effective.minTradingDays) || + (cash.realizedPnL > 0 && plan.remainingToday <= LEG_COMPLETION_EPSILON); } } @@ -100,6 +106,7 @@ export async function GET() { targetHit, stage, dailyTarget, + remainingToday, effectiveProfitTarget, dailyPnL, fullDailyPnL: client.fullDailyPnL?.[acc.id] ?? loadDailyPnL(acc.id), diff --git a/lib/auto-trade.ts b/lib/auto-trade.ts index 13b5492..fd73530 100644 --- a/lib/auto-trade.ts +++ b/lib/auto-trade.ts @@ -11,7 +11,11 @@ import { getInstruments } from './db'; import { getClients } from './clients'; import { getTradableFirms } from './firm-catalog'; import { getAccountConfig } from './account-type-resolver'; -import { computeDailyTarget, resolveEffectiveConfig, POINT_VALUES } from './trading-logic'; +import { + computeDayPlan, pickLegAmount, resolveEffectiveConfig, currentTradeDateCT, + LEG_COMPLETION_EPSILON, MAX_LEGS_PER_DAY, POINT_VALUES, + type DayPlan, +} from './trading-logic'; import { getSetting, setSetting } from './db'; // ── helpers ────────────────────────────────────────────────────────────────── @@ -20,6 +24,98 @@ function isAccountDead(amount: number, autoLiqThreshold: number): boolean { return autoLiqThreshold > 0 && amount <= autoLiqThreshold; } +// In-memory runaway guard: legs fired per account per trade date. Resets on restart, +// which is fine — day completion is derived from realized PnL, not from this counter. +const _legCounts = new Map(); + +function legsToday(accountId: number, tradeDate: string): number { + const entry = _legCounts.get(accountId); + return entry && entry.date === tradeDate ? entry.count : 0; +} + +function recordLeg(accountId: number, tradeDate: string): void { + _legCounts.set(accountId, { date: tradeDate, count: legsToday(accountId, tradeDate) + 1 }); +} + +// ── per-account trade evaluation (shared by runTrade / copyTrade / hasRemaining…) ── + +interface AccountEvaluation { + eligible: boolean; + reason: string; + plan: DayPlan | null; + isExtraDay: boolean; + cash: { amount: number; realizedPnL: number }; + dailyPnL: { date: string; pnl: number }[]; + daysTraded: number; +} + +/** + * Evaluate whether an account can trade right now. Returns null when the account has no + * matching config (not ours). Split-allowed accounts stay eligible mid-day until + * remainingToday drops to LEG_COMPLETION_EPSILON or the leg cap is hit; extra-day and + * strict-consistency (past day 1) accounts keep the legacy one-trade-per-day gate. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function evaluateAccountForTrade(client: any, acc: { id: number; name: string; active: boolean }, tradeDate: string): AccountEvaluation | null { + const cfg = getAccountConfig(acc.id).accountType; + if (!cfg) return null; + + 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; + + const base = { plan: null, isExtraDay: false, cash, dailyPnL, daysTraded }; + if (isAccountDead(cash.amount, autoLiqThreshold)) return { ...base, eligible: false, reason: 'dead' }; + if (!acc.active) return { ...base, eligible: false, reason: 'inactive (DLL)' }; + if (client.positions[acc.id]) return { ...base, eligible: false, reason: 'already in trade' }; + + 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 plan = computeDayPlan( + acc.id, tradeDate, effective.profitTarget, effective.consistency, + dailyPnL, cfg.minDayPnL, effective.minTradingDays, equityProfit, cash.realizedPnL + ); + if (!plan) return { ...base, eligible: false, reason: 'invalid balance' }; + + const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0); + const isExtraDay = cfg.minDayPnL <= 0 + && effective.minTradingDays > daysTraded + && totalProfit >= effective.profitTarget; + + const evaluated = { plan, isExtraDay, cash, dailyPnL, daysTraded }; + + if (isExtraDay || !plan.splitAllowed) { + // Legacy one-and-done gate: any realized PnL today means this account is finished. + if (cash.realizedPnL !== 0) { + return { ...evaluated, eligible: false, reason: `already traded today (realizedPnL=${cash.realizedPnL})` }; + } + if (!isExtraDay && plan.remainingToday <= 0) { + return { ...evaluated, eligible: false, reason: `target=$${plan.dayTarget} (challenge complete)` }; + } + } else { + // Split-leg mode: keep trading until the day's remaining target is used up. + // The epsilon only applies once a leg has completed, so tiny day-start targets still trade. + const doneThreshold = cash.realizedPnL === 0 ? 0 : LEG_COMPLETION_EPSILON; + if (plan.remainingToday <= doneThreshold) { + const reason = cash.realizedPnL === 0 + ? `target=$${plan.dayTarget} (challenge complete)` + : `day done ($${plan.remainingToday} left of $${plan.dayTarget})`; + return { ...evaluated, eligible: false, reason }; + } + if (legsToday(acc.id, tradeDate) >= MAX_LEGS_PER_DAY) { + return { ...evaluated, eligible: false, reason: `leg cap reached (${MAX_LEGS_PER_DAY} today)` }; + } + } + + return { ...evaluated, eligible: true, reason: '' }; +} + @@ -113,6 +209,7 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string) const pointValue = POINT_VALUES[resolvedSymbol]; if (!pointValue) throw new Error(`Unknown symbol: ${resolvedSymbol}`); + const tradeDate = currentTradeDateCT(); const maxConcurrent = parseInt(getSetting('max_concurrent_accounts') ?? '5', 10); const firms = getTradableFirms(); const clients = getClients(); @@ -127,6 +224,8 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string) cash: { amount: number; realizedPnL: number }; dailyPnL: { date: string; pnl: number }[]; daysTraded: number; + plan: DayPlan; + isExtraDay: boolean; }; const allEligible: EligibleItem[] = []; @@ -146,42 +245,14 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string) 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; + const ev = evaluateAccountForTrade(client, acc, tradeDate); + if (!ev || !ev.eligible || !ev.plan) continue; - if (isAccountDead(cash.amount, autoLiqThreshold)) continue; - if (!acc.active) continue; - if (client.positions[acc.id]) continue; - - const cfg = getAccountConfig(acc.id).accountType; - 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, cash, dailyPnL, daysTraded }); + allEligible.push({ + firmName: firm.name, client, acc, contract, + cash: ev.cash, dailyPnL: ev.dailyPnL, daysTraded: ev.daysTraded, + plan: ev.plan, isExtraDay: ev.isExtraDay, + }); } })); @@ -197,22 +268,12 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string) // ── Phase 2: fire the batch simultaneously ── const tradeResults = await Promise.allSettled(batch.map(async (item) => { - const { client, acc, contract, cash, dailyPnL, daysTraded } = item; + const { client, acc, contract } = item; const cfg = getAccountConfig(acc.id).accountType!; - 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) { + if (item.isExtraDay) { const mnqContract = await client.findFrontMonthContract('MNQ'); if (!mnqContract) throw new Error('MNQ contract not found for extra-day trade'); @@ -237,22 +298,24 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string) }; } - 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`); + // Trade one leg toward today's remaining target. Split-allowed big days carve a + // random ≥$800 piece; everything else trades the full remaining amount in one go. + const legAmount = item.plan.splitAllowed ? pickLegAmount(item.plan.remainingToday) : item.plan.remainingToday; + recordLeg(acc.id, tradeDate); - const rawContracts = Math.max(1, Math.ceil(target.amount / 1000)); + const rawContracts = Math.max(1, Math.ceil(legAmount / 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. + // After entry, realizedPnL drops by the entry commission; the delta from the + // pre-entry value is the entry fee paid (pre-entry may be nonzero mid-day). // 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 entryCommission = Math.abs(updatedCash.realizedPnL - item.cash.realizedPnL) || (2.5 * contracts); const totalCommission = entryCommission * 2; // entry + exit round-trip - const grossTarget = target.amount + totalCommission; + const grossTarget = legAmount + totalCommission; const targetPoints = grossTarget / (pointValue * contracts); const ticks = Math.ceil(targetPoints / contract.tickSize); @@ -263,17 +326,19 @@ export async function runTrade(action: 'Buy' | 'Sell' | 'Auto', symbol: string) 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})`); + console.log(`[auto-trade] ${acc.name} (${item.firmName}) ${resolvedAction} ${contracts}x${resolvedSymbol} @ ${fill.price} | leg $${legAmount} of day $${item.plan.dayTarget} (remaining $${item.plan.remainingToday}) [${item.plan.path}] (+$${totalCommission.toFixed(2)} comm) | exit @ ${exitPrice} (orderId=${exitOrder.orderId})`); return { account: acc.name, firm: item.firmName, status: 'filled', contracts, - target: target.amount, + target: legAmount, + dayTarget: item.plan.dayTarget, + remainingBefore: item.plan.remainingToday, grossTarget, totalCommission, - targetPath: target.path, + targetPath: item.plan.path, entryPrice: fill.price, exitPrice, commission: entryCommission, @@ -377,15 +442,17 @@ export async function copyTrade() { return []; } + const tradeDate = currentTradeDateCT(); + // Collect eligible accounts (same logic as Phase 1 of runTrade) type CopyItem = { 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 }; cash: { amount: number; realizedPnL: number }; - dailyPnL: { date: string; pnl: number }[]; - daysTraded: number; + plan: DayPlan; }; const eligible: CopyItem[] = []; @@ -407,31 +474,13 @@ export async function copyTrade() { } 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.id).accountType; - 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 ev = evaluateAccountForTrade(client, acc, tradeDate); + if (!ev) { console.log(`[copy-trade] ${acc.name}: no matching config prefix — skip`); continue; } + if (!ev.eligible || !ev.plan) { console.log(`[copy-trade] ${acc.name}: ${ev.reason} — skip`); continue; } + // copyTrade has no extra-day branch — those accounts wait for the scheduler + if (ev.isExtraDay) { console.log(`[copy-trade] ${acc.name}: extra-day account — 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, cash, dailyPnL, daysTraded }); + eligible.push({ firmName: firm.name, client, acc, contract, cash: ev.cash, plan: ev.plan }); } })); @@ -445,28 +494,21 @@ export async function copyTrade() { // Fire orders (same as Phase 2 of runTrade) const tradeResults = await Promise.allSettled(batch.map(async (item) => { - const { client, acc, contract, cash, dailyPnL } = item; + const { client, acc, contract } = item; const cfg = getAccountConfig(acc.id).accountType!; - 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 legAmount = item.plan.splitAllowed ? pickLegAmount(item.plan.remainingToday) : item.plan.remainingToday; + recordLeg(acc.id, tradeDate); - const rawContracts = Math.max(1, Math.ceil(target.amount / 1000)); + const rawContracts = Math.max(1, Math.ceil(legAmount / 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 entryCommission = Math.abs(updatedCash.realizedPnL - item.cash.realizedPnL) || (2.5 * contracts); const totalCommission = entryCommission * 2; - const grossTarget = target.amount + totalCommission; + const grossTarget = legAmount + totalCommission; const targetPoints = grossTarget / (pointValue * contracts); const ticks = Math.ceil(targetPoints / contract.tickSize); @@ -477,17 +519,19 @@ export async function copyTrade() { 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})`); + console.log(`[copy-trade] ${acc.name} (${item.firmName}) ${resolvedAction} ${contracts}x${resolvedSymbol} @ ${fill.price} | leg $${legAmount} of day $${item.plan.dayTarget} (remaining $${item.plan.remainingToday}) [${item.plan.path}] | exit @ ${exitPrice} (orderId=${exitOrder.orderId})`); return { account: acc.name, firm: item.firmName, status: 'filled', contracts, - target: target.amount, + target: legAmount, + dayTarget: item.plan.dayTarget, + remainingBefore: item.plan.remainingToday, grossTarget, totalCommission, - targetPath: target.path, + targetPath: item.plan.path, entryPrice: fill.price, exitPrice, commission: entryCommission, @@ -508,16 +552,16 @@ export async function copyTrade() { // ── 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). */ +/** Returns true if any configured account could still trade today (eligible for a next leg / extra-day, or has an open position). */ function hasRemainingConfiguredAccounts(): boolean { const firms = getTradableFirms(); const clients = getClients(); + const tradeDate = currentTradeDateCT(); for (const firm of firms) { const client = clients.get(firm.id); if (!client || client.accountList.length === 0) continue; - for (const acc of client.accountList) { const cfg = getAccountConfig(acc.id).accountType; if (!cfg) continue; // no config = not our account @@ -525,31 +569,8 @@ function hasRemainingConfiguredAccounts(): boolean { // 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; + const ev = evaluateAccountForTrade(client, acc, tradeDate); + if (ev?.eligible) return true; } } diff --git a/lib/reporter.ts b/lib/reporter.ts index 0553cf8..9d330f5 100644 --- a/lib/reporter.ts +++ b/lib/reporter.ts @@ -17,7 +17,7 @@ import { getClients } from './clients'; import { getTradableFirms } from './firm-catalog'; import { getAccountConfig } from './account-type-resolver'; import { getSchedulerStatus } from './auto-trade'; -import { computeDailyTarget } from './trading-logic'; +import { computeDayPlan, currentTradeDateCT, LEG_COMPLETION_EPSILON } from './trading-logic'; import type { AccountTypeStats } from './master-protocol'; function collectFirmStats() { @@ -83,10 +83,13 @@ function collectFirmStats() { if (cfg) { const dailyPnL = client.dailyPnL[acc.id] ?? []; const equityProfit = cash.amount - cfg.accountSize; - const target = computeDailyTarget(cfg.profitTarget, cfg.consistency, dailyPnL, cfg.minDayPnL, cfg.minTradingDays, equityProfit); - const targetHit = !target ? false : - ((target.amount === 0 && Math.abs(cash.realizedPnL) > 0 && (client.daysTraded[acc.id] ?? 0) <= cfg.minTradingDays) || - (cash.realizedPnL >= target.amount)); + const plan = computeDayPlan( + acc.id, currentTradeDateCT(), cfg.profitTarget, cfg.consistency, + dailyPnL, cfg.minDayPnL, cfg.minTradingDays, equityProfit, cash.realizedPnL + ); + const targetHit = !plan ? false : + ((plan.dayTarget === 0 && Math.abs(cash.realizedPnL) > 0 && (client.daysTraded[acc.id] ?? 0) <= cfg.minTradingDays) || + (cash.realizedPnL > 0 && plan.remainingToday <= LEG_COMPLETION_EPSILON)); if (targetHit || cash.realizedPnL !== 0) { accountsTraded++; diff --git a/lib/trading-logic.ts b/lib/trading-logic.ts index fd0e8d9..78c37a3 100644 --- a/lib/trading-logic.ts +++ b/lib/trading-logic.ts @@ -48,6 +48,63 @@ export function resolveEffectiveProfitTarget( return resolveEffectiveConfig(profitTarget, 0, 0, targetSameEquity, withdrawalStages, priorProfit, fundTransactions).profitTarget; } +// ── randomized targets / split legs ────────────────────────────────────────── + +/** Split a day's target into multiple legs when it exceeds this. */ +export const BIG_DAY_THRESHOLD = 1700; +/** Every split leg must be at least this. */ +export const MIN_LEG = 800; +/** After at least one trade, a residual at or below this counts as "day done". */ +export const LEG_COMPLETION_EPSILON = 25; +/** Safety valve: never fire more than this many legs per account per trade date. */ +export const MAX_LEGS_PER_DAY = 8; + +const round2 = (n: number) => Math.round(n * 100) / 100; + +/** + * Deterministic random bump in {0, 5, 10, 15, 20, 25} for an (account, trade date) pair. + * Seeded (FNV-1a) rather than Math.random() so the dashboard, reporter, and trade path — + * which each compute the target independently — always agree on the same number. + */ +export function dailyBump(accountId: number, tradeDate: string): number { + const key = `${accountId}|${tradeDate}`; + let hash = 0x811c9dc5; + for (let i = 0; i < key.length; i++) { + hash ^= key.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return (hash % 6) * 5; +} + +/** + * Current Tradovate trade date as YYYY-MM-DD: the Chicago calendar date, rolled to the + * next day once the evening session opens (≥ 5:00 PM CT) — matching when Tradovate + * resets realizedPnL. + */ +export function currentTradeDateCT(now: Date = new Date()): string { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: 'America/Chicago', + year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', hour12: false, + }).formatToParts(now); + const get = (t: string) => parts.find((p) => p.type === t)?.value ?? '0'; + let date = Date.UTC(Number(get('year')), Number(get('month')) - 1, Number(get('day'))); + if (Number(get('hour')) % 24 >= 17) date += 24 * 60 * 60 * 1000; + return new Date(date).toISOString().slice(0, 10); +} + +/** + * Pick the next leg to trade toward today's remaining target. + * remaining ≤ BIG_DAY_THRESHOLD → trade it all in one final leg; otherwise carve a random + * $5-rounded piece from [MIN_LEG, remaining − MIN_LEG] so the remainder also stays ≥ MIN_LEG. + */ +export function pickLegAmount(remainingToday: number, rand: () => number = Math.random): number { + if (remainingToday <= BIG_DAY_THRESHOLD) return round2(remainingToday); + const lo = MIN_LEG; + const hi = remainingToday - MIN_LEG; + const leg = Math.round((lo + rand() * (hi - lo)) / 5) * 5; + return Math.min(Math.max(leg, lo), hi); +} + /** Dollar-per-point value for common futures products. */ export const POINT_VALUES: { [symbol: string]: number } = { NQ: 20, MNQ: 2, ES: 50, MES: 5, @@ -83,7 +140,8 @@ export function computeDailyTarget( dailyPnL: { date: string; pnl: number }[], minDayPnL: number, // 0 = no minimum per day minTradingDays: number, // 0 = no minimum trading days - equityProfit: number | null | undefined // amount − accountSize. Null/undefined → skip (return null). 0 is valid. + equityProfit: number | null | undefined, // amount − accountSize. Null/undefined → skip (return null). 0 is valid. + bump: number = 0 // random extra target. No consistency rule: applied when the min-day floor binds. Strict consistency: first trade ever only. ): { amount: number; path: 'first_day' | 'normal_day' | 'reduced_day' } | null { // Don't run if we don't have a real account balance to compute against — 0 is valid. if (typeof equityProfit !== 'number' || !Number.isFinite(equityProfit)) { @@ -109,10 +167,14 @@ export function computeDailyTarget( const remaining = Math.max(equityShortfall, consistencyShortfall); + const noConsistencyRule = consistency === 0 || consistency >= 1; + // 1. Both target and consistency already satisfied — coast on min-day if mandatory days remain. if (remaining <= 0) { const needsMoreDays = minTradingDays > daysTraded; - return { amount: needsMoreDays ? effectiveMinDay : 0, path: 'reduced_day' }; + let coastAmount = needsMoreDays ? effectiveMinDay : 0; + if (coastAmount > 0 && noConsistencyRule) coastAmount += bump; + return { amount: coastAmount, path: 'reduced_day' }; } // 2. Min-day reservation: future mandatory days each reserve minDayPnL. @@ -137,7 +199,7 @@ export function computeDailyTarget( let consistencyCap: number; let path: 'first_day' | 'normal_day' | 'reduced_day'; - if (consistency === 0 || consistency >= 1) { + if (noConsistencyRule) { consistencyCap = Infinity; path = daysTraded === 0 ? 'first_day' : 'reduced_day'; } else { @@ -157,7 +219,70 @@ export function computeDailyTarget( // 4. Combine caps and apply min-day floor when mandatory days remain. const raw = Math.min(consistencyCap, cappedByFuture); const mustFloor = minTradingDays > daysTraded; - const amount = mustFloor ? Math.max(effectiveMinDay, raw) : Math.max(0, raw); + let amount = mustFloor ? Math.max(effectiveMinDay, raw) : Math.max(0, raw); + + // 5. Random bump. Without a consistency rule, bump the days where the min-day floor is + // what set the target (big days get their randomness from leg splitting instead). + // With a strict consistency rule, only the first trade ever gets a bump — a slight + // overshoot there just raises consistencyShortfall, which later days self-correct. + if (bump > 0) { + if (noConsistencyRule) { + if (mustFloor && raw <= effectiveMinDay && effectiveMinDay > 0) amount = effectiveMinDay + bump; + } else if (daysTraded === 0) { + amount += bump; + } + } return { amount: Math.round(amount * 100) / 100, path }; } + +export interface DayPlan { + /** Full target for this trade date (bump included) — a fixed anchor all day. */ + dayTarget: number; + /** dayTarget minus what's already realized today; what's left to trade. */ + remainingToday: number; + path: 'first_day' | 'normal_day' | 'reduced_day'; + /** False for strict-consistency accounts past their first trade ever — single unsplit trade. */ + splitAllowed: boolean; +} + +/** + * Compute today's full target and what's still left of it, as of right now. + * + * Unlike calling computeDailyTarget directly, this anchors the day: today's PnL row (which + * the hourly Cash History refresh may write mid-day) is excluded, and day-start equity is + * reconstructed as equityProfit − realizedPnL. So the same (account, trade date) always + * yields the same dayTarget no matter when or where it's computed — the dashboard, the + * reporter, and the trade path agree, and completed legs only move remainingToday. + */ +export function computeDayPlan( + accountId: number, + tradeDate: string, + profitTarget: number, + consistency: number, + dailyPnL: { date: string; pnl: number }[], + minDayPnL: number, + minTradingDays: number, + equityProfit: number | null | undefined, + realizedPnL: number +): DayPlan | null { + if (typeof equityProfit !== 'number' || !Number.isFinite(equityProfit)) return null; + + const priorDays = dailyPnL.filter((d) => d.date !== tradeDate); + const dayStartEquity = equityProfit - realizedPnL; + const target = computeDailyTarget( + profitTarget, consistency, priorDays, minDayPnL, minTradingDays, + dayStartEquity, dailyBump(accountId, tradeDate) + ); + if (!target) return null; + + const qualifyingDays = minDayPnL === 0 ? priorDays : priorDays.filter((d) => d.pnl >= minDayPnL); + const splitAllowed = consistency === 0 || consistency >= 1 || qualifyingDays.length === 0; + + return { + dayTarget: target.amount, + remainingToday: round2(target.amount - realizedPnL), + path: target.path, + splitAllowed, + }; +}