Every machine sized a like account in a like state to the same position as every other person's, so two people's positions could match to the unit. Each person now draws a size factor in [0.85, 1.15] per trade date, seeded by the master's uuid for them; all of one person's accounts share it. The factor moves the hold a DXtrade leg is sized for (hold / factor², since size goes as 1/√hold), so the instrument pick, the dashboard's next leg and the Firms page's reach test all see the hold the size was made for. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
326 lines
19 KiB
TypeScript
326 lines
19 KiB
TypeScript
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, tradeDate: combined.tradeDate,
|
||
})[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 });
|
||
}
|
||
}
|