The daily target sometimes inflates to base + consistency×todayPnL (e.g. 1200 + 0.4×1218.48 = 1687.39), which happens when a session's cached daily-P&L row is dated something other than the current tradeDate: it then lands in BOTH the prior-day list and realizedPnL, so day-start equity reads too low while the prior-day sum stays high. Detect it with a book-balance invariant that is coincidence-proof (it does not care whether prior and today P&L happen to be equal): the reconstructed day-start equity (equityProfit − realizedPnL) must equal priorProfit + sum(prior days). The state route now logs the full account state whenever those disagree by more than $50, and GET /api/debug reports the same per-account breakdown on demand (?account=<substring>) so the exact inputs can be captured the moment a number looks wrong. Read-only; no behavior change. Remove once the trigger is fixed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
192 lines
11 KiB
TypeScript
192 lines
11 KiB
TypeScript
import { NextResponse } from 'next/server';
|
||
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 { combineCmeSessions, 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);
|
||
// 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: [] };
|
||
}
|
||
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);
|
||
// 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 = cfg && cfg.minDayPnL > 0
|
||
? dailyPnL.filter((d) => d.pnl >= cfg.minDayPnL).length
|
||
: combined.daysTraded;
|
||
const autoLiqThreshold = client.autoLiqThresholds[acc.id] ?? 0;
|
||
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;
|
||
// 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;
|
||
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(
|
||
cfg.profitTarget,
|
||
cfg.consistency,
|
||
cfg.minTradingDays,
|
||
cfg.targetSameEquity,
|
||
cfg.withdrawalStages,
|
||
priorProfit,
|
||
allFundTxns
|
||
);
|
||
const equityProfit = cash.amount - cfg.accountSize;
|
||
const plan = computeDayPlan(
|
||
acc.id, combined.tradeDate, effective.profitTarget, effective.consistency,
|
||
dailyPnL, cfg.minDayPnL, effective.minTradingDays, equityProfit, combined.realizedPnL,
|
||
cfg.splitEnabled
|
||
);
|
||
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
|
||
// in total trading profit for the stage, not just profitTarget.
|
||
const qualifying = cfg.minDayPnL === 0 ? dailyPnL : dailyPnL.filter((d) => d.pnl >= cfg.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);
|
||
}
|
||
}
|
||
|
||
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,
|
||
positionDirection: client.positions[acc.id]
|
||
? (client.positions[acc.id].netPos > 0 ? 'long' as const : 'short' as const)
|
||
: null,
|
||
autoLiqThreshold,
|
||
totalProfit,
|
||
targetHit,
|
||
stage,
|
||
dailyTarget,
|
||
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,
|
||
};
|
||
});
|
||
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 });
|
||
}
|
||
}
|