`npm run build` failed on a clean checkout, so nothing on master could be built for production. `npm run dev` does not hard-fail on type errors, which is why it went unnoticed. - state route returned client.perContractFees, which has never existed on TradovateClient on any branch; nothing consumed it - mapFirmConfig omitted bannedSymbols. Type gap only: the trade path calls isSymbolBanned() against the DB directly, so bans were always enforced - initClient's sync callback was sync where the constructor wants () => Promise<void> - accessInfo and ws are assigned during async connect/auth, never in the constructor, so they take definite-assignment assertions - the socket payload's inline entityType union had drifted five members behind the indirect-callback union above it, making the 'position' and 'cashBalance' branches unreachable to the compiler. Both now share a TradovateEntityType alias. Type-only: those handlers ran fine at runtime Behaviour is unchanged throughout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
110 lines
5.9 KiB
TypeScript
110 lines
5.9 KiB
TypeScript
import { NextResponse } from 'next/server';
|
|
import { getFirms, loadDailyPnL } from '@/lib/db';
|
|
import { getClients } from '@/lib/clients';
|
|
import { computeDailyTarget, resolveEffectiveConfig } from '@/lib/trading-logic';
|
|
import type { AccountConfigRow } from '@/lib/db';
|
|
|
|
function getAccountConfig(name: string, accounts: AccountConfigRow[]): AccountConfigRow | undefined {
|
|
return [...accounts]
|
|
.sort((a, b) => b.prefix.length - a.prefix.length)
|
|
.find((a) => name.startsWith(a.prefix));
|
|
}
|
|
|
|
export async function GET() {
|
|
try {
|
|
const firms = getFirms();
|
|
const clients = getClients();
|
|
|
|
const state = firms.map((f) => {
|
|
const client = clients.get(f.id);
|
|
if (!client || client.accountList.length === 0) {
|
|
return { firm: f.name, connected: false, 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);
|
|
|
|
// Compute daily target and targetHit in one place — the single source of truth.
|
|
const cfg = getAccountConfig(acc.name, f.accounts);
|
|
// 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.min_day_pnl > 0
|
|
? dailyPnL.filter((d) => d.pnl >= cfg.min_day_pnl).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 displayFundTxns = cfg
|
|
? allFundTxns.filter((f) => f.amount !== cfg.account_size)
|
|
: allFundTxns;
|
|
let targetHit = false;
|
|
let dailyTarget: { amount: number; path: 'first_day' | 'normal_day' | 'reduced_day' } | null = null;
|
|
let effectiveProfitTarget: number | null = null;
|
|
if (cfg && !isDead) {
|
|
const withdrawalStages: { profit: number; consistency: number; minTradingDays: number }[] = (() => { try { return JSON.parse(cfg.withdrawal_stages ?? '[]'); } catch { return []; } })();
|
|
const effective = resolveEffectiveConfig(
|
|
cfg.profit_target,
|
|
cfg.consistency,
|
|
cfg.min_trading_days,
|
|
cfg.target_same_equity === 1,
|
|
withdrawalStages,
|
|
priorProfit,
|
|
allFundTxns
|
|
);
|
|
const equityProfit = cash.amount - cfg.account_size;
|
|
const target = computeDailyTarget(effective.profitTarget, effective.consistency, dailyPnL, cfg.min_day_pnl, effective.minTradingDays, equityProfit);
|
|
dailyTarget = target;
|
|
|
|
// 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.min_day_pnl === 0 ? dailyPnL : dailyPnL.filter((d) => d.pnl >= cfg.min_day_pnl);
|
|
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 → must have made at least the computed daily target
|
|
if (target) {
|
|
targetHit =
|
|
(target.amount === 0 && Math.abs(cash.realizedPnL) > 0 && client.daysTraded[acc.id] <= effective.minTradingDays) ||
|
|
(cash.realizedPnL >= target.amount);
|
|
}
|
|
}
|
|
|
|
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,
|
|
effectiveProfitTarget,
|
|
dailyPnL,
|
|
fullDailyPnL: client.fullDailyPnL?.[acc.id] ?? loadDailyPnL(acc.id),
|
|
fundTransactions: displayFundTxns,
|
|
};
|
|
});
|
|
return { firm: f.name, connected: true, accounts };
|
|
});
|
|
|
|
return NextResponse.json(state);
|
|
} catch (err) {
|
|
console.error('[GET /api/state]', err);
|
|
return NextResponse.json({ error: 'Failed to fetch state' }, { status: 500 });
|
|
}
|
|
}
|