Lucid's users moved to their own Tradovate hosts (demo-api.lucid.ninjatrader.com, seen 2026-11-01). The login still answers on live.tradovateapi.com, but the shared demo socket drops a Lucid session right after its authorize (close 1006, no status), so every Lucid firm sat at "Connecting…" on every machine. The login answer now names the hosts (apiHosts, orgName "LucidTrading"), and the web trader follows them. A Tradovate client now opens its socket and makes its account, contract, fee and report calls on the hosts its own login names; a login without apiHosts, or with a value that isn't a plain host name, keeps demo.tradovateapi.com and rpt-demo.tradovateapi.com. A renewal answer without apiHosts keeps the login's. The shared contract cache still resolves with a client on the shared hosts when there is one; a client on its organization's own only stands in. Login, renewal and the quote feed are unchanged: Lucid's live and md hosts are the shared ones. The debug routes follow the same hosts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
258 lines
12 KiB
TypeScript
258 lines
12 KiB
TypeScript
import { NextResponse } from 'next/server';
|
||
import { getClients, resetClients } from '@/lib/clients';
|
||
import { getTradableFirms } from '@/lib/firm-catalog';
|
||
import { getAccountConfig } from '@/lib/account-type-resolver';
|
||
import { combineCmeSessions, computeDayPlan, resolveEffectiveConfig, currentTradeDateCT } from '@/lib/trading-logic';
|
||
import axios from '@/lib/http-client';
|
||
import { SHARED_DEMO_HOST, SHARED_DEMO_REPORTING_HOST } from '@/lib/tradovate-helpers';
|
||
|
||
/**
|
||
* Per-account snapshot of everything computeDayPlan sees, plus the book-balance
|
||
* check the state route warns on: day-start equity (equityProfit − realizedPnL)
|
||
* must equal priorProfit + sum(prior days). A large `drift` means a session is
|
||
* counted in both the prior-day list and realizedPnL — the daily-target inflation
|
||
* bug. Filter with ?account=<substring> (case-insensitive). Read-only.
|
||
*/
|
||
function dayPlanDiag(client: any, acc: { id: number; name: string }, tradeDate: string) {
|
||
const cfg = getAccountConfig(acc.id).accountType;
|
||
const cash = client.accountCashBalances[acc.id] ?? { amount: 0, realizedPnL: 0 };
|
||
if (!cfg) return { tradeDate, note: 'no account type assigned', cash, rawDailyPnL: client.dailyPnL[acc.id] ?? [] };
|
||
|
||
const priorProfit = client.priorProfit?.[acc.id] ?? 0;
|
||
const allFundTxns = client.fundTransactions?.[acc.id] ?? [];
|
||
const combined = combineCmeSessions(cfg.combineCmeHolidays === true, client.dailyPnL[acc.id] ?? [], tradeDate, cash.realizedPnL);
|
||
const effective = resolveEffectiveConfig(cfg.profitTarget, cfg.consistency, cfg.minTradingDays, cfg.targetSameEquity, cfg.withdrawalStages, priorProfit, allFundTxns, cfg.minDayPnL);
|
||
const equityProfit = cash.amount - cfg.accountSize;
|
||
const plan = computeDayPlan(acc.id, combined.tradeDate, effective.profitTarget, effective.consistency, combined.dailyPnL, effective.minDayPnL, effective.minTradingDays, equityProfit, combined.realizedPnL, cfg.splitEnabled, effective.drain);
|
||
|
||
const priorDaysSum = combined.dailyPnL.filter((d) => d.date !== combined.tradeDate).reduce((s, d) => s + d.pnl, 0);
|
||
const dayStartEquity = Math.round((equityProfit - combined.realizedPnL) * 100) / 100;
|
||
const expectedDayStart = Math.round((priorProfit + priorDaysSum) * 100) / 100;
|
||
|
||
return {
|
||
tradeDate: combined.tradeDate,
|
||
drift: Math.round((dayStartEquity - expectedDayStart) * 100) / 100,
|
||
dayStartEquity,
|
||
expectedDayStart,
|
||
equityProfit,
|
||
realizedPnL: combined.realizedPnL,
|
||
priorProfit,
|
||
priorDaysSum: Math.round(priorDaysSum * 100) / 100,
|
||
dayTarget: plan?.dayTarget ?? null,
|
||
remainingToday: plan?.remainingToday ?? null,
|
||
path: plan?.path ?? null,
|
||
drain: effective.drain,
|
||
consistency: effective.consistency,
|
||
hasTodayRow: combined.dailyPnL.some((d) => d.date === combined.tradeDate),
|
||
combineCmeHolidays: cfg.combineCmeHolidays === true,
|
||
combinedDailyPnL: combined.dailyPnL,
|
||
rawDailyPnL: client.dailyPnL[acc.id] ?? [],
|
||
};
|
||
}
|
||
|
||
export async function GET(req: Request) {
|
||
try {
|
||
const clients = getClients();
|
||
const firms = getTradableFirms();
|
||
const tradeDate = currentTradeDateCT();
|
||
const nameFilter = (new URL(req.url).searchParams.get('account') ?? '').toLowerCase();
|
||
|
||
const debug = firms.map((firm) => {
|
||
const client = clients.get(firm.id);
|
||
if (!client) return { firmId: firm.id, firmName: firm.name, status: 'no_client' };
|
||
|
||
return {
|
||
firmId: firm.id,
|
||
firmName: firm.name,
|
||
status: 'connected',
|
||
accountCount: client.accountList.length,
|
||
fetchDaysComplete: (client as any).fetchDaysComplete ?? 'n/a (old instance)',
|
||
lastFetchErrors: (client as any).lastFetchErrors ?? {},
|
||
lastFetchRaw: (client as any).lastFetchRaw ?? {},
|
||
accounts: client.accountList
|
||
.filter((acc) => !nameFilter || acc.name.toLowerCase().includes(nameFilter))
|
||
.map((acc) => ({
|
||
id: acc.id,
|
||
name: acc.name,
|
||
cash: client.accountCashBalances[acc.id] ?? null,
|
||
daysTraded: client.daysTraded[acc.id] ?? '(not set)',
|
||
dailyPnLEntries: (client.dailyPnL[acc.id] ?? []).length,
|
||
dailyPnL: client.dailyPnL[acc.id] ?? [],
|
||
dayPlan: dayPlanDiag(client, acc, tradeDate),
|
||
})),
|
||
recentEntityEvents: client.recentEntityEvents.slice(-10).map((e) => ({
|
||
ts: new Date(e.ts).toISOString(),
|
||
entityType: e.entityType,
|
||
eventType: e.eventType,
|
||
entity: e.entity,
|
||
})),
|
||
};
|
||
});
|
||
|
||
return NextResponse.json({ ok: true, ts: new Date().toISOString(), firms: debug });
|
||
} catch (err) {
|
||
console.error('[GET /api/debug]', err);
|
||
return NextResponse.json({ error: String(err) }, { status: 500 });
|
||
}
|
||
}
|
||
|
||
/** POST /api/debug — manually trigger fetchDaysTraded on all clients and return results */
|
||
export async function POST() {
|
||
try {
|
||
const clients = getClients();
|
||
const firms = getTradableFirms();
|
||
|
||
const results = await Promise.all(
|
||
firms.map(async (firm) => {
|
||
const client = clients.get(firm.id);
|
||
if (!client || client.accountList.length === 0) {
|
||
return { firmId: firm.id, firmName: firm.name, status: 'skipped' };
|
||
}
|
||
|
||
try {
|
||
await (client as any).fetchDaysTraded();
|
||
} catch (err) {
|
||
return { firmId: firm.id, firmName: firm.name, status: 'error', error: String(err) };
|
||
}
|
||
|
||
return {
|
||
firmId: firm.id,
|
||
firmName: firm.name,
|
||
status: 'done',
|
||
fetchDaysComplete: (client as any).fetchDaysComplete ?? 'n/a',
|
||
errors: (client as any).lastFetchErrors ?? {},
|
||
rawSamples: (client as any).lastFetchRaw ?? {},
|
||
daysTraded: client.daysTraded,
|
||
dailyPnLCounts: Object.fromEntries(
|
||
Object.entries(client.dailyPnL).map(([k, v]) => [k, (v as any[]).length])
|
||
),
|
||
};
|
||
})
|
||
);
|
||
|
||
return NextResponse.json({ ok: true, ts: new Date().toISOString(), results });
|
||
} catch (err) {
|
||
console.error('[POST /api/debug]', err);
|
||
return NextResponse.json({ error: String(err) }, { status: 500 });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* PATCH /api/debug — probe Tradovate APIs using a firm's access token.
|
||
*
|
||
* Generic proxy: { firmId, path?, url?, method?, body? }
|
||
* Full report run: { firmId, action: 'report', account, startDate, endDate }
|
||
* → does requestreport + getreport polling in one server-side call, returns raw data string
|
||
*/
|
||
export async function PATCH(req: Request) {
|
||
try {
|
||
const payload = await req.json();
|
||
const { firmId } = payload;
|
||
const client = getClients().get(firmId) as any;
|
||
if (!client?.accessInfo?.accessToken) {
|
||
return NextResponse.json({ error: 'no token for firmId ' + firmId }, { status: 400 });
|
||
}
|
||
const headers = { Authorization: `Bearer ${client.accessInfo.accessToken}` };
|
||
// The firm's own hosts when its login named them (Lucid's).
|
||
const serviceHost: string = client.serviceHost?.() ?? SHARED_DEMO_HOST;
|
||
const reportingHost: string = client.reportingHost?.() ?? SHARED_DEMO_REPORTING_HOST;
|
||
|
||
// Full report cycle
|
||
if (payload.action === 'report') {
|
||
const { account, startDate, endDate, name: reportName = 'Fills' } = payload;
|
||
try {
|
||
let reportData = (await axios.post(
|
||
`https://${reportingHost}/v1/reports/requestreport`,
|
||
{
|
||
name: reportName, representationType: 'json', timezone: -300,
|
||
params: [
|
||
{ name: 'startDate', value: startDate },
|
||
{ name: 'endDate', value: endDate },
|
||
{ name: 'startTime', value: '00:00:00' },
|
||
{ name: 'endTime', value: '00:00:00' },
|
||
...(account ? [{ name: 'account', value: account }] : []),
|
||
],
|
||
},
|
||
{ headers }
|
||
)).data;
|
||
|
||
let attempts = 0;
|
||
while (reportData?.['p-ticket'] && attempts < 30) {
|
||
const ticket: string = reportData['p-ticket'];
|
||
const wait: number = Math.max(1, reportData['p-time'] ?? 1);
|
||
await new Promise((r) => setTimeout(r, wait * 1000));
|
||
reportData = (await axios.get(
|
||
`https://${reportingHost}/v1/reports/getreport`,
|
||
{ params: { 'p-ticket': ticket }, headers }
|
||
)).data;
|
||
attempts++;
|
||
}
|
||
|
||
const raw: string = typeof reportData?.data === 'string' ? reportData.data : JSON.stringify(reportData);
|
||
// Parse to count fills and unique tradeDates
|
||
let fillCount = 0;
|
||
let uniqueDates: string[] = [];
|
||
try {
|
||
const fixed = raw.replace(/"_tradeDate":\s*(\d{4}-\d{2}-\d{2})/g, '"_tradeDate": "$1"');
|
||
const parsed: any[] = JSON.parse(fixed);
|
||
fillCount = parsed.length;
|
||
uniqueDates = [...new Set(parsed.map((f) => f._tradeDate as string))].sort();
|
||
} catch { /* not parseable yet */ }
|
||
return NextResponse.json({ ok: true, attempts, rawLen: raw.length, fillCount, uniqueDates, preview: raw.slice(0, 400) });
|
||
} catch (err: any) {
|
||
return NextResponse.json({
|
||
ok: false,
|
||
status: err?.response?.status,
|
||
data: err?.response?.data,
|
||
message: err?.message,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Generic proxy
|
||
const { path, url: rawUrl, method = 'GET', body: reqBody } = payload;
|
||
const url = rawUrl ?? `https://${serviceHost}/v1/${path}`;
|
||
try {
|
||
const res = method === 'POST'
|
||
? await axios.post(url, reqBody, { headers })
|
||
: await axios.get(url, { headers });
|
||
return NextResponse.json({ ok: true, status: res.status, data: res.data });
|
||
} catch (err: any) {
|
||
return NextResponse.json({
|
||
ok: false,
|
||
status: err?.response?.status,
|
||
data: err?.response?.data,
|
||
message: err?.message,
|
||
});
|
||
}
|
||
} catch (err) {
|
||
return NextResponse.json({ error: String(err) }, { status: 500 });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* DELETE /api/debug — force-reinitialize all Tradovate clients with fresh instances
|
||
* Clears the global pool so getClients() recreates everything from DB on next call.
|
||
*/
|
||
export async function DELETE() {
|
||
try {
|
||
const g = global as any;
|
||
const oldCount = (g.__tradovateClients as Map<number, any> | undefined)?.size ?? 0;
|
||
|
||
// Disconnect all existing WebSocket connections before clearing
|
||
resetClients();
|
||
|
||
// Re-initialize immediately with fresh instances
|
||
getClients();
|
||
|
||
return NextResponse.json({
|
||
ok: true,
|
||
message: `Disconnected ${oldCount} old client(s) — fresh instances initializing`,
|
||
ts: new Date().toISOString(),
|
||
});
|
||
} catch (err) {
|
||
console.error('[DELETE /api/debug]', err);
|
||
return NextResponse.json({ error: String(err) }, { status: 500 });
|
||
}
|
||
}
|