- DXtrade client (lib/dxtrade-class.ts): REST session login with lockout-safe backoff, 60s sync of accounts/metrics/positions/orders/transfers, history and fund transactions from cash transactions, day-start balance at the 22:00 UTC reset, CLOSE-by-positionCode exits (native OCO group, else a stop-first emulated pair), a cross-account hedge guard, stray-order reaper. - Instrument selector: the firm's symbol map lists candidate symbols per CME root; the cheapest tradable one per unit of recent hourly volatility wins (spread + 2x commission [+ financing] / sigma). - Fees: a firm's commission % of notional prices every fee off the fill; the take-profit is solved in closed form so the leg nets its target. - Hard daily loss limit per account type: every leg carries a stop keeping the firm's day inside the limit less a 10% reserve and both fees (rounded in); a green day never widens it; no room or a refused bracket flattens. - Notional sizing where the platform prices positions: target-hold volatility sizing or the symbol map's fixed notional, under a per-type notional cap. - Every-day firms trade weekends/holidays; the scheduler runs lanes (futures, one per DXtrade firm) with their own hours, flat gate, mutex and batches. - The catalog sync skips platforms this build doesn't know instead of treating them as Tradovate. One shared fireLeg replaces the duplicated batch/copy code. - npm run check: fee/stop/sizing/hours/selector checks; scripts/dxtrade-smoke.mjs confirms the API against a real login before trading. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
258 lines
14 KiB
TypeScript
258 lines
14 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 { historyFresh } from '@/lib/platform-client';
|
||
import {
|
||
combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, currentTradeDateCT,
|
||
obfuscationStopDollars, dllRoom as computeDllRoom, dllStopDollars, botStopsActive,
|
||
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);
|
||
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;
|
||
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;
|
||
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),
|
||
});
|
||
}
|
||
if (plan && (botStopsActive(cfg))) {
|
||
const obf = obfuscationStopDollars(cfg, dailyPnL, plan, combined.tradeDate, plan.remainingToday);
|
||
const hard = dllRoom !== null
|
||
? 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;
|
||
}
|
||
|
||
// 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,
|
||
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 });
|
||
}
|
||
}
|