Files
autofirmer-expanded/app/api/state/route.ts
T
Brandon LiandClaude Fable 5 50a5eb4713 Add TopstepX platform support, drain withdrawal stages, and perfect-path calendar projection
- TopstepXClient (ProjectX Gateway): API-key auth, SignalR user hub,
  Trade/search daily P&L, emulated OCO exits, books-invariant
  withdrawal detection for $0-start accounts
- Platform-aware client pool, credentials (api_key column), and
  credential form; $0-size account types match by prefix alone
- Drain stages: min-day ($5-$15 randomized) MNQ legs for the
  configured days, then wait for the 50% withdrawal; stage pills
  show 'Stage N – Drain'
- Account calendar projects the perfect path: future days book their
  planned targets, simulated 50% withdrawals, payout marker

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-12 16:25:52 -05:00

229 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
BIG_DAY_THRESHOLD, FIRST_DAY_STOP, LEG_COMPLETION_EPSILON, MIN_DAY_STOP_MULT,
} 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);
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;
// 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;
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;
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 under the obfuscation
// options, mirroring obfuscationStopDollars in auto-trade:
// day 1 keeps the day's net floor at −$1,000, min days stop
// at 2× the target, and the day after a loss rides stopless.
if (plan && (cfg.firstDayStop || cfg.minDayStop)) {
const prior = dailyPnL
.filter((d) => d.date < combined.tradeDate && d.pnl !== 0)
.sort((a, b) => a.date.localeCompare(b.date));
const recoveryDay = prior.length > 0 && prior[prior.length - 1].pnl < 0;
if (!recoveryDay) {
let stop = Infinity;
if (cfg.firstDayStop && prior.length === 0) {
stop = Math.max(0, Math.round((FIRST_DAY_STOP + combined.realizedPnL) * 100) / 100);
}
if (cfg.minDayStop && plan.dayTarget <= BIG_DAY_THRESHOLD) {
stop = Math.min(stop, MIN_DAY_STOP_MULT * plan.dayTarget);
}
if (Number.isFinite(stop) && stop > 0) nextStop = Math.round(stop * 100) / 100;
}
}
// 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);
}
}
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,
// A stop-enabled account whose day booked red via its own
// stop — the personal DLL. Firm DLL lockouts show via
// !active instead.
personalDll: !!cfg && (cfg.firstDayStop || cfg.minDayStop)
&& 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,
stage,
// Current stage is a drain stage — dashboards label it "Stage N – Drain".
stageDrain: effective?.drain ?? false,
dailyTarget,
nextStop,
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 });
}
}