When the master marks an account type splitEnabled=false, big days trade as one unsplit leg (the legacy one-and-done behavior); min-day bumps are unaffected. Missing field — older master or pre-toggle cached catalog — defaults to enabled. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
132 lines
7.3 KiB
TypeScript
132 lines
7.3 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 { 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 };
|
|
const dailyPnL: { date: string; pnl: number }[] = client.dailyPnL[acc.id] ?? [];
|
|
const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 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);
|
|
// 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
|
|
: (client.daysTraded[acc.id] ?? 0);
|
|
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, tradeDate, effective.profitTarget, effective.consistency,
|
|
dailyPnL, cfg.minDayPnL, effective.minTradingDays, equityProfit, cash.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);
|
|
// 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(cash.realizedPnL) > 0 && client.daysTraded[acc.id] <= effective.minTradingDays) ||
|
|
(cash.realizedPnL > 0 && plan.remainingToday <= LEG_COMPLETION_EPSILON);
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: acc.id,
|
|
name: acc.name,
|
|
active: acc.active,
|
|
amount: cash.amount,
|
|
realizedPnL: cash.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: client.fullDailyPnL?.[acc.id] ?? loadDailyPnL(acc.id),
|
|
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 });
|
|
}
|
|
}
|