import { NextResponse } from 'next/server'; import { listAssignments, loadDailyPnL } from '@/lib/db'; import { getClients } from '@/lib/clients'; import { getAccountTypeById, getCachedCatalog } from '@/lib/firm-catalog'; import { ruleDiffs } from '@/lib/kept-rules'; import { getAccountConfig, getInferredSize, matchAccountType } from '@/lib/account-type-resolver'; import { historyFresh } from '@/lib/platform-client'; import { rankNextLeg } from '@/lib/leg-estimate'; import { obfuscationDayBooked } from '@/lib/auto-trade'; import type { AccountState } from '@/types'; import { combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, currentTradeDateCT, obfuscationStopDollars, dllRoom as computeDllRoom, dllStopDollars, botStopsActive, maxLossRoom, lastStand as isLastStand, 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); // connected == the Tradovate sync has come back, NOT "has accounts". // A firm with no accounts is a real, settled state and must not be // reported the same way as one that is still authenticating. const connected = !!client && client.syncComplete; if (!client || client.accountList.length === 0) { return { firmId: f.id, firm: f.name, connected, accounts: [] }; } // Accounts whose rules date was set by hand — one read per firm // rather than one per account. const rulesDateByHand = new Set(listAssignments(f.id) .filter((row) => row.rules_date_manual === 1) .map((row) => row.account_id)); const accounts = client.accountList.map((acc) => { const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 }; // One lookup against the assignment table — no prefix matching // here any more. An unassigned account gets no targets, which // is also what stops it being traded. const resolved = getAccountConfig(acc.id); const cfg = resolved.accountType; const inferredAccountSize = getInferredSize(acc.id, client); // A combining firm records holiday sessions with the next full // trading day — everything below reads the account through that // view (the identity view when the account type doesn't combine). const combined = combineCmeSessions( cfg?.combineCmeHolidays === true, client.dailyPnL[acc.id] ?? [], tradeDate, cash.realizedPnL, ); const dailyPnL = combined.dailyPnL; const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 0); const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0; // 0 = no floor; negative floors are reconstructed TopstepX MLLs. const isDead = autoLiqThreshold !== 0 && cash.amount <= autoLiqThreshold; const allFundTxns = client.fundTransactions?.[acc.id] ?? []; const priorProfit = client.priorProfit?.[acc.id] ?? 0; // Stage = 1 + number of withdrawals (negative fund transactions) const stage = 1 + allFundTxns.filter((f) => f.amount < 0).length; // Stage-resolved config: a drain stage swaps in its own min-day // (default $5) and no profit target. const effective = cfg ? resolveEffectiveConfig( cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, priorProfit, allFundTxns, cfg.minDayPnL ) : null; // Days traded counts only days that hit minDayPnL (when set) — that's what // the firm requires toward the min trading day rule. const daysTraded: number = effective && effective.minDayPnL > 0 ? dailyPnL.filter((d) => d.pnl >= effective.minDayPnL).length : combined.daysTraded; // Hide initial funding (amount === accountSize) from display const fundingAmount = cfg?.accountSize ?? inferredAccountSize; const displayFundTxns = fundingAmount !== null ? allFundTxns.filter((f) => f.amount !== fundingAmount) : allFundTxns; // Surfaced so the dashboard can flag an account whose type was // picked out of several equally valid matches. const ambiguous = cfg ? matchAccountType(acc.name, inferredAccountSize, f.accountTypes).ambiguous : false; // An account on one of its type's old rule sets: cfg already // carries those rules; say which set, and how it differs from // the type's current rules. const live = resolved.keptRules && resolved.accountTypeId ? getAccountTypeById(resolved.accountTypeId) : undefined; const keptRules: AccountState['keptRules'] = resolved.keptRules && cfg && live ? { id: resolved.keptRules.id, openedAt: resolved.keptRules.openedAt, closedAt: resolved.keptRules.closedAt, differs: ruleDiffs(cfg, live), } : null; 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; let nextStop: number | null = null; let dllRoom: number | null = null; let holdEstimate: AccountState['holdEstimate'] = null; let lastStandNext = false; if (cfg && effective && !isDead) { const equityProfit = cash.amount - cfg.accountSize; const plan = computeDayPlan( acc.id, combined.tradeDate, effective.profitTarget, effective.consistency, dailyPnL, effective.minDayPnL, effective.minTradingDays, equityProfit, combined.realizedPnL, cfg.splitEnabled, effective.drain ); dailyTarget = plan ? { amount: plan.dayTarget, path: plan.path } : null; remainingToday = plan?.remainingToday ?? null; // The stop the next trade would carry, from the same helpers // the fire path uses: the obfuscation stop (day 1 keeps the // day's net floor at −$1,000, min days stop at 2× the leg, // the day after a loss rides stopless) against the hard // daily-loss stop, before fees. A min day trades its whole // remaining target as one leg. const dll = cfg.dailyLossLimit ?? 0; if (dll > 0) { const dayStart = client.dayStartBalance?.(acc.id); dllRoom = dayStart === null ? null : computeDllRoom({ dailyLossLimit: dll, closedBalance: cash.amount, dayStartBalance: dayStart, realizedToday: Math.min(cash.realizedPnL, combined.realizedPnL), }); } // The max-drawdown floor, and whether the next leg is a last // stand — no daily-loss stop — as fireLeg decides it. const floorRoom = maxLossRoom({ balance: cash.amount, accountSize: cfg.accountSize, maxLoss: cfg.maxLoss ?? 0, autoLiqThreshold: client.autoLiqThresholds?.[acc.id], }); // A day an obfuscation stop booked has no next leg — the // make-back rides tomorrow, stopless — so no next stop, last // stand or hold to show. Its remaining target has grown by // the loss: a $150 min day stopped at −$306 would read // "stop $912" for a leg that never fires. const dayBooked = obfuscationDayBooked(cfg, acc.id, tradeDate, combined.realizedPnL) !== null; lastStandNext = !dayBooked && dllRoom !== null && isLastStand({ dllRoom, dailyLossLimit: dll, maxLossRoom: floorRoom }); if (plan && !dayBooked && botStopsActive(cfg)) { const obf = obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday); const hard = dllRoom !== null && !lastStandNext ? dllStopDollars({ room: dllRoom, dailyLossLimit: dll, entryFee: 0, commissionPct: 0 }) : Infinity; const stop = Math.min(Number.isFinite(obf) && obf > 0 ? obf : Infinity, hard); if (Number.isFinite(stop) && stop > 0) nextStop = Math.round(stop * 100) / 100; } // DXtrade: the next leg sized on every surveyed instrument as // fireLeg would, and the one a batch would pick. if (plan && !dayBooked && plan.remainingToday > 0) { const best = rankNextLeg({ client, accountId: acc.id, cfg, firm: f, leg: plan.remainingToday, dllRoom, obfuscationDollars: obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday), cash, })[0]; if (best) { holdEstimate = { hours: best.holdHours, symbol: best.symbol, notional: best.notional, multiplier: best.multiplier, volatilityPerDay: best.volatilityPerDay, winProbability: best.winProbability, winProbabilityNoFees: best.winProbabilityNoFees, swapReserve: best.swapReserve, leverage: best.leverage, cost: best.cost, }; } } // Effective profit target = max(stage target, consistency realTarget). // If a big day forces the consistency rule, the account must reach maxDay/consistency // in total trading profit for the stage, not just profitTarget. const qualifying = effective.minDayPnL === 0 ? dailyPnL : dailyPnL.filter((d) => d.pnl >= effective.minDayPnL); const maxDay = qualifying.length > 0 ? Math.max(...qualifying.map((d) => d.pnl)) : 0; const consistencyReal = (effective.consistency > 0 && effective.consistency < 1 && maxDay > 0) ? maxDay / effective.consistency : 0; effectiveProfitTarget = Math.max(effective.profitTarget, consistencyReal); // ── Double-count diagnostic ─────────────────────────────── // computeDayPlan reconstructs day-start equity as // equityProfit − realizedPnL, then treats every dailyPnL row // NOT dated today as a prior day. Those two views must agree: // day-start equity should equal priorProfit + sum(prior days). // When a session's cached row is dated something other than // today's tradeDate, that session lands in BOTH the prior-day // list and realizedPnL — day-start equity reads too low while // the prior sum stays high, and the daily target inflates to // base + consistency×todayPnL (the $1,687.39 symptom). Fires // only when the books disagree by > $50, logging the exact // state needed to diagnose the trigger. Remove once resolved. const priorDaysSum = dailyPnL .filter((d) => d.date !== combined.tradeDate) .reduce((s, d) => s + d.pnl, 0); const dayStartEquity = equityProfit - combined.realizedPnL; const expectedDayStart = priorProfit + priorDaysSum; const drift = Math.round((dayStartEquity - expectedDayStart) * 100) / 100; if (Math.abs(drift) > 50) { console.warn('[day-plan][equity-drift] ' + JSON.stringify({ account: acc.name, id: acc.id, serverNow: new Date().toISOString(), tradeDate: combined.tradeDate, drift, dayStartEquity, expectedDayStart, equityProfit, realizedPnL: combined.realizedPnL, priorProfit, priorDaysSum, dayTarget: plan?.dayTarget ?? null, remainingToday: plan?.remainingToday ?? null, consistency: effective.consistency, effectiveProfitTarget, hasTodayRow: dailyPnL.some((d) => d.date === combined.tradeDate), combineCmeHolidays: cfg.combineCmeHolidays === true, combinedDailyPnL: dailyPnL, rawDailyPnL: client.dailyPnL[acc.id] ?? [], })); } // Condition 1: profit target already exceeded (target=0), still need days → any activity counts // Condition 2: target > 0 → all legs done (day's remaining target used up) if (plan) { targetHit = (plan.dayTarget === 0 && Math.abs(combined.realizedPnL) > 0 && combined.daysTraded <= effective.minTradingDays) || (combined.realizedPnL > 0 && plan.remainingToday <= LEG_COMPLETION_EPSILON); } } // Today finished the cycle — the reporter's check and guards, // so this pill and the master's purple count agree. let completed = false; const historyLoaded = client.dailyPnL[acc.id] !== undefined && client.fundTransactions?.[acc.id] !== undefined; if (cfg && effective && historyLoaded && historyFresh(client, acc) && !client.positions[acc.id]) { completed = cycleCompletion({ accountId: acc.id, rawTradeDate: tradeDate, cycleDailyPnL: client.dailyPnL[acc.id], rawRealizedPnL: cash.realizedPnL, combineCmeHolidays: cfg.combineCmeHolidays, tradesEveryDay: cfg.tradesEveryDay === true, effective, equityProfit: cash.amount - cfg.accountSize, splitEnabled: cfg.splitEnabled, }) === 'today'; } return { id: acc.id, name: acc.name, active: acc.active, amount: cash.amount, // The combined day's P&L, not just the current session's — // on the Tuesday after a holiday this includes Sun/Mon. realizedPnL: combined.realizedPnL, daysTraded, // An account the bot puts stops on whose day booked red via // its own stop — the personal DLL. Firm DLL lockouts show // via !active instead. personalDll: !!cfg && botStopsActive(cfg) && acc.active && !client.positions[acc.id] && combined.realizedPnL <= -25, positionDirection: client.positions[acc.id] ? (client.positions[acc.id].netPos > 0 ? 'long' as const : 'short' as const) : null, autoLiqThreshold, totalProfit, targetHit, completed, stage, // Current stage is a drain stage — dashboards label it "Stage N – Drain". stageDrain: effective?.drain ?? false, dailyTarget, nextStop, dllRoom, holdEstimate, lastStand: lastStandNext, remainingToday, effectiveProfitTarget, dailyPnL, fullDailyPnL: combineCmeSessions( cfg?.combineCmeHolidays === true, client.fullDailyPnL?.[acc.id] ?? loadDailyPnL(acc.id), tradeDate, 0, ).dailyPnL, fundTransactions: displayFundTxns, accountTypeId: resolved.accountTypeId, accountTypeLabel: cfg?.label ?? null, assignmentSource: resolved.source, accountType: cfg, inferredAccountSize, accountTypeAmbiguous: ambiguous, rulesDate: resolved.rulesDate, rulesDateManual: rulesDateByHand.has(acc.id), keptRules, }; }); return { firmId: f.id, firm: f.name, connected, accounts }; }); return NextResponse.json(state); } catch (err) { console.error('[GET /api/state]', err); return NextResponse.json({ error: 'Failed to fetch state' }, { status: 500 }); } }