This box no longer decides what firms exist. It caches the master's catalog
and stores one thing of its own: the Tradovate login for each firm. Creating
firms locally is gone — app/add, POST /api/firms, the account-config routes
and the banned-symbol route with it.
The cache is the point, not an optimisation. The scheduler needs every
account's target and size on each 60s tick, so fetching them per tick would
stop trading with positions open the first time the master rebooted. It is
one JSON blob in settings, replaced only by a complete valid response, and a
response with zero firms is refused outright: a misconfigured FIRMS_DB serves
{"firms":[]} as a healthy 200, and mirroring that would idle the whole fleet
at once.
Accounts bind to an account type by prefix AND size, the size taken from the
opening funding transaction. That does not arrive with the first sync, so an
account sits Unassigned and the resolver tries again next sync rather than
guessing. A type picked by hand is never overwritten by an auto pass. This
replaces the longest-prefix helper that had been copy-pasted into five files,
which could not tell two types with the same prefix apart at all.
firm_credentials and account_assignments deliberately have no foreign key to
the cached catalog. foreign_keys is ON, so a cascade would erase the Tradovate
passwords the moment a firm was removed on the master — routine, not
exceptional. They survive it, and are re-keyed by name if the master's database
is ever rebuilt with fresh ids.
The legacy firms/account_configs/firm_banned_symbols tables are left on disk,
unreferenced. firms is the only table that ever held plaintext credentials and
dropping it would make a rollback unrecoverable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
124 lines
6.8 KiB
TypeScript
124 lines
6.8 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 { computeDailyTarget, resolveEffectiveConfig } from '@/lib/trading-logic';
|
|
|
|
export async function GET() {
|
|
try {
|
|
const firms = getCachedCatalog();
|
|
const clients = getClients();
|
|
|
|
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 };
|
|
const dailyPnL: { date: string; pnl: number }[] = client.dailyPnL[acc.id] ?? [];
|
|
const totalProfit = dailyPnL.reduce((sum, d) => sum + d.pnl, 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);
|
|
// 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.minDayPnL > 0
|
|
? dailyPnL.filter((d) => d.pnl >= cfg.minDayPnL).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 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 effectiveProfitTarget: number | null = null;
|
|
if (cfg && !isDead) {
|
|
const effective = resolveEffectiveConfig(
|
|
cfg.profitTarget,
|
|
cfg.consistency,
|
|
cfg.minTradingDays,
|
|
cfg.targetSameEquity,
|
|
cfg.withdrawalStages,
|
|
priorProfit,
|
|
allFundTxns
|
|
);
|
|
const equityProfit = cash.amount - cfg.accountSize;
|
|
const target = computeDailyTarget(effective.profitTarget, effective.consistency, dailyPnL, cfg.minDayPnL, 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.minDayPnL === 0 ? dailyPnL : dailyPnL.filter((d) => d.pnl >= cfg.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);
|
|
// 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,
|
|
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 });
|
|
}
|
|
}
|