On Vest, a claim lowers the daily-loss floor by the amount claimed. Winning $1,500 and claiming it leaves $3,000 of room the same day, the full drawdown. The bot now uses that. - Payout claims: a scheduler step (runClaims, under the lane mutex, before the flat gate) claims every payoutable account that reached its stage's target, via a platform that takes payouts by API. The claim is all profit above the starting balance. It then clears the account's legs and entry outcome for the day so it can trade its next stage. Failures retry after 5 minutes. The auto_claim setting 'off' disables it. - VestClient.claimProfit: POST /v3/capital/withdraw to the login's main account (account_type 1 on /v3/accounts). The payout is booked at once: today's fund row and the cycle cut, with today's P&L counted from the post-claim balance. The history read later rebooks it from Vest's WITHDRAW row, and the books check counts a claim until that row shows. - Same-day stages: an account type can pick its stage by the payouts since the firm's last daily reset (stagesSameDay, sameDayClaims). A stage can carry its own daily loss limit. - That limit replaces the type's everywhere room, stops and last stands are worked out: the batch, the manual leg, copy trade, the dashboard and the report. - Room from the platform's own floor: dailyLossFloor (Vest) makes the room min(stage limit, balance − Vest's floor). A same-day claim that lowers the floor therefore frees room, and the room is never more than Vest allows. VestClient.dayStartBalance is gone. - Before the 8 PM ET reset: from 7:55 PM ET, each open Vest position on an account with a daily limit is fitted to the room the reset will leave (resetSafeStop). Its stop comes in via PUT/POST stop-loss, or the position closes when there is no room. - Checks for same-day stages, the stage limit, floorRoom, the post-claim plan, the next reset across both time changes, and resetSafeStop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
322 lines
18 KiB
TypeScript
322 lines
18 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, dayLossRoom } from '@/lib/leg-estimate';
|
||
import { obfuscationDayBooked } from '@/lib/auto-trade';
|
||
import type { AccountState } from '@/types';
|
||
import {
|
||
combineCmeSessions, computeDayPlan, cycleCompletion, resolveEffectiveConfig, currentTradeDateCT,
|
||
obfuscationStopDollars, dllStopDollars, botStopsActive, stageInputsFor,
|
||
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),
|
||
// or of today's payouts for a type whose stages reset each day.
|
||
const stage = 1 + (cfg?.stagesSameDay
|
||
? (client.sameDayClaims?.(acc.id) ?? 0)
|
||
: 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,
|
||
stageInputsFor(client, acc.id, cfg),
|
||
) : 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 = effective?.dailyLossLimit ?? 0;
|
||
dllRoom = dayLossRoom(client, acc.id, dll, cash, 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: { ...cfg, dailyLossLimit: dll }, 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 });
|
||
}
|
||
}
|