diff --git a/app/api/debug/route.ts b/app/api/debug/route.ts index 8944cf5..609c301 100644 --- a/app/api/debug/route.ts +++ b/app/api/debug/route.ts @@ -4,6 +4,7 @@ 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 @@ -152,13 +153,16 @@ export async function PATCH(req: Request) { 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://rpt-demo.tradovateapi.com/v1/reports/requestreport', + `https://${reportingHost}/v1/reports/requestreport`, { name: reportName, representationType: 'json', timezone: -300, params: [ @@ -178,7 +182,7 @@ export async function PATCH(req: Request) { const wait: number = Math.max(1, reportData['p-time'] ?? 1); await new Promise((r) => setTimeout(r, wait * 1000)); reportData = (await axios.get( - 'https://rpt-demo.tradovateapi.com/v1/reports/getreport', + `https://${reportingHost}/v1/reports/getreport`, { params: { 'p-ticket': ticket }, headers } )).data; attempts++; @@ -207,7 +211,7 @@ export async function PATCH(req: Request) { // Generic proxy const { path, url: rawUrl, method = 'GET', body: reqBody } = payload; - const url = rawUrl ?? `https://demo.tradovateapi.com/v1/${path}`; + const url = rawUrl ?? `https://${serviceHost}/v1/${path}`; try { const res = method === 'POST' ? await axios.post(url, reqBody, { headers }) diff --git a/app/api/firms/[id]/debug-contracts/route.ts b/app/api/firms/[id]/debug-contracts/route.ts index 4e0c6b6..35145da 100644 --- a/app/api/firms/[id]/debug-contracts/route.ts +++ b/app/api/firms/[id]/debug-contracts/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { getClients } from '@/lib/clients'; import axios from '@/lib/http-client'; +import { SHARED_DEMO_HOST } from '@/lib/tradovate-helpers'; export async function GET( req: NextRequest, @@ -17,7 +18,7 @@ export async function GET( if (!accessToken) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 }); const res = await axios.get( - `https://demo.tradovateapi.com/v1/contract/suggest?t=${encodeURIComponent(symbol)}&l=20`, + `https://${client.serviceHost?.() ?? SHARED_DEMO_HOST}/v1/contract/suggest?t=${encodeURIComponent(symbol)}&l=20`, { headers: { Authorization: `Bearer ${accessToken}` } } ); diff --git a/app/api/instruments/contracts/route.ts b/app/api/instruments/contracts/route.ts index 9d8284e..430ba28 100644 --- a/app/api/instruments/contracts/route.ts +++ b/app/api/instruments/contracts/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { getClients } from '@/lib/clients'; +import { contractLookupToken } from '@/lib/clients'; import { futuresRootsHere } from '@/lib/instruments'; import { resolveContracts, getAllCachedContracts } from '@/lib/contract-resolver'; @@ -19,14 +19,8 @@ export async function GET() { */ export async function POST(req: NextRequest) { try { - const clients = getClients(); - // Find first client with a valid token - let accessToken: string | null = null; - for (const [, c] of clients) { - const token = (c as any).accessInfo?.accessToken; - if (token) { accessToken = token; break; } - } - if (!accessToken) { + const lookup = contractLookupToken(); + if (!lookup) { return NextResponse.json({ error: 'No authenticated client available' }, { status: 503 }); } @@ -37,7 +31,7 @@ export async function POST(req: NextRequest) { ? requestedSymbols : futuresRootsHere(); - const resolved = await resolveContracts(symbols, accessToken); + const resolved = await resolveContracts(symbols, lookup.token, lookup.host); return NextResponse.json(resolved); } catch (err: any) { console.error('[POST /api/instruments/contracts]', err); diff --git a/lib/clients.ts b/lib/clients.ts index 57bf65d..08ff7e2 100644 --- a/lib/clients.ts +++ b/lib/clients.ts @@ -9,6 +9,7 @@ import { firmInstruments, isFuturesFirm, prewarmMarket } from './instruments'; import { housekeepingRoot } from './instrument-specs'; import { resolveFirmAssignments } from './account-type-resolver'; import { resolveContracts } from './contract-resolver'; +import { SHARED_DEMO_HOST } from './tradovate-helpers'; import { startReporter, sendReport } from './reporter'; import { startBackupSync } from './backup-sync'; import { applyBrokerHttpDefaults, applyBrokerProxy } from './outbound-proxy'; @@ -280,27 +281,39 @@ function resolveRootsHere(): string[] { return [...roots]; } -function triggerContractResolve(): void { - // A midnight run supersedes a pending retry; either way only one chain. - if (g.__contractResolveRetryTimer) clearTimeout(g.__contractResolveRetryTimer); - g.__contractResolveRetryTimer = undefined; - - const map = ensureMap(); - // Find first client with a valid access token (the Yahoo-driven resolver - // confirms contract ids against Tradovate; TopstepX clients resolve their - // own contracts but still reuse the resolver's front-month names). - // A token alone isn't enough: a client whose session died keeps its - // expired one, and resolving with that fails with nothing left to retry. - let accessToken: string | null = null; - for (const [, c] of map) { +/** + * The Tradovate token the contract resolver confirms ids with (the + * Yahoo-driven resolver checks them against Tradovate; TopstepX clients + * resolve their own contracts but still reuse the resolver's front-month + * names), and the host it belongs on. + * A token alone isn't enough: a client whose session died keeps its expired + * one, and resolving with that fails with nothing left to retry. The cache + * serves every firm, so a client on the shared hosts is preferred; one on its + * organization's own (Lucid's) only stands in. + */ +export function contractLookupToken(): { token: string; host: string } | null { + let pick: { token: string; host: string } | null = null; + for (const [, c] of ensureMap()) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const token = (c as any).accessInfo?.accessToken; if (!token) continue; let authenticated = true; try { authenticated = c.health().authenticated; } catch { /* no health: trust the token */ } - if (authenticated) { accessToken = token; break; } + if (!authenticated) continue; + const host = c instanceof TradovateClient ? c.serviceHost() : SHARED_DEMO_HOST; + if (!pick || host === SHARED_DEMO_HOST) pick = { token, host }; + if (host === SHARED_DEMO_HOST) break; } - if (!accessToken) { + return pick; +} + +function triggerContractResolve(): void { + // A midnight run supersedes a pending retry; either way only one chain. + if (g.__contractResolveRetryTimer) clearTimeout(g.__contractResolveRetryTimer); + g.__contractResolveRetryTimer = undefined; + + const lookup = contractLookupToken(); + if (!lookup) { const retries = (g.__contractResolveRetries ?? 0) + 1; if (retries > CONTRACT_RESOLVE_MAX_RETRIES) { console.log( @@ -325,7 +338,7 @@ function triggerContractResolve(): void { const symbols = resolveRootsHere(); g.__contractResolvedRoots = new Set(symbols); console.log(`[contract-resolver] Resolving ${symbols.length} instruments...`); - resolveContracts(symbols, accessToken) + resolveContracts(symbols, lookup.token, lookup.host) .then((results) => { const rolled = Object.entries(results).filter(([, v]) => v?.alternative); console.log(`[contract-resolver] Done — ${rolled.length} contract(s) rolled via price match`); diff --git a/lib/contract-resolver.ts b/lib/contract-resolver.ts index 29804d2..1af6a94 100644 --- a/lib/contract-resolver.ts +++ b/lib/contract-resolver.ts @@ -14,6 +14,7 @@ import axios from './http-client'; import { errorMessage } from './platform-client'; +import { SHARED_DEMO_HOST } from './tradovate-helpers'; interface ContractInfo { id: number; @@ -163,11 +164,13 @@ async function findHighestVolumeContract( * For each symbol: * 1. Fetch {PRODUCT}=F from Yahoo to learn the exchange suffix * 2. Probe near-month Yahoo tickers and pick the highest-volume contract - * 3. Look up the chosen name on Tradovate to get the contract ID + * 3. Look up the chosen name on Tradovate to get the contract ID — on + * `host`, the token's own (TradovateClient.serviceHost()) */ export async function resolveContracts( symbols: string[], accessToken: string, + host: string = SHARED_DEMO_HOST, ): Promise> { const headers = { Authorization: `Bearer ${accessToken}` }; const results: Record = {}; @@ -189,7 +192,7 @@ export async function resolveContracts( try { const res = await axios.get( - `https://demo.tradovateapi.com/v1/contract/suggest?t=${encodeURIComponent(chosenName ?? sym)}&l=10`, + `https://${host}/v1/contract/suggest?t=${encodeURIComponent(chosenName ?? sym)}&l=10`, { headers } ); const contracts: ContractInfo[] = res.data ?? []; diff --git a/lib/tradovate-class.ts b/lib/tradovate-class.ts index bff475b..8fd991f 100644 --- a/lib/tradovate-class.ts +++ b/lib/tradovate-class.ts @@ -2,7 +2,7 @@ import axios from './http-client'; import type { AccountItem, AuthLoginResponse, Contract } from './tradovate-helpers'; -import { computeSec, randomUUIDV4 } from './tradovate-helpers'; +import { SHARED_DEMO_HOST, computeSec, demoHosts, randomUUIDV4 } from './tradovate-helpers'; import { currentTradeDateCT } from './trading-logic'; import { POINT_VALUES } from './instrument-specs'; import { getCachedContract, resolveContracts } from './contract-resolver'; @@ -516,7 +516,9 @@ export class TradovateClient { this.lastLoginError = null; this.consecutiveLoginFailures = 0; this.needsLogin = false; - console.log(`${this.tag} logged in — token expires ${outcome.info.expirationTime}`); + const host = this.serviceHost(); + const where = host === SHARED_DEMO_HOST ? '' : ` (${outcome.info.orgName || 'organization'} on ${host})`; + console.log(`${this.tag} logged in — token expires ${outcome.info.expirationTime}${where}`); } this.openSocket(); } catch (err) { @@ -615,7 +617,7 @@ export class TradovateClient { const randomnumber = Math.random().toString(36).substring(2, 15); // Broker endpoint — dialled through the operator's proxy when one // is configured (lib/outbound-proxy.ts). - const ws = brokerWebSocket(`wss://demo.tradovateapi.com/v1/websocket?r=${randomnumber}`); + const ws = brokerWebSocket(`wss://${this.serviceHost()}/v1/websocket?r=${randomnumber}`); this.ws = ws; this.socketOpenedAt = Date.now(); this.authorizeSent = false; @@ -1266,7 +1268,7 @@ export class TradovateClient { if (this.intentionalDisconnect) throw new Error('client disconnected'); try { let reportData = (await axios.post( - 'https://rpt-demo.tradovateapi.com/v1/reports/requestreport', + `https://${this.reportingHost()}/v1/reports/requestreport`, { name, representationType: 'json', timezone: -300, params: [ @@ -1286,7 +1288,7 @@ export class TradovateClient { await new Promise((r) => setTimeout(r, pTime * 1000)); if (this.intentionalDisconnect) throw new Error('client disconnected'); reportData = (await axios.get( - 'https://rpt-demo.tradovateapi.com/v1/reports/getreport', + `https://${this.reportingHost()}/v1/reports/getreport`, { params: { 'p-ticket': pTicket }, headers: authHeaders, timeout: REPORT_TIMEOUT_MS } )).data; pollAttempts++; @@ -1909,8 +1911,10 @@ export class TradovateClient { if (!body?.accessToken) { throw new Error(body?.errorText ? String(body.errorText) : 'renewal answered without an access token'); } - // Merge, so a renewal answer without expirationTime can't leave it undefined. - this.accessInfo = { ...this.accessInfo, ...body } as AuthLoginResponse; + // Merge, so a renewal answer without expirationTime can't leave it + // undefined — nor one without apiHosts move the session to the + // shared hosts. + this.accessInfo = { ...this.accessInfo, ...body, apiHosts: body.apiHosts ?? this.accessInfo.apiHosts } as AuthLoginResponse; this.noteTokenExpiry(); console.log(`${this.tag} renewed access token — expires ${this.accessInfo.expirationTime}`); } catch (err) { @@ -1930,7 +1934,7 @@ export class TradovateClient { return []; } - const res = await axios.get('https://demo.tradovateapi.com/v1/account/list', { + const res = await axios.get(`https://${this.serviceHost()}/v1/account/list`, { headers: { Authorization: `Bearer ${this.accessInfo.accessToken}`, }, @@ -1954,7 +1958,7 @@ export class TradovateClient { // Under a deadline: its lookups (Yahoo, then Tradovate) must not // hold every firm's trade. A late answer still fills the cache. const resolved = await withDeadline( - resolveContracts([productName], this.accessInfo.accessToken), + resolveContracts([productName], this.accessInfo.accessToken, this.serviceHost()), CONTRACT_RESOLVE_DEADLINE_MS, 'contract resolve', ); @@ -1969,7 +1973,7 @@ export class TradovateClient { try { // Last resort: fall back to Tradovate's ordered suggestions. const res = await axios.get( - `https://demo.tradovateapi.com/v1/contract/suggest?t=${encodeURIComponent(productName)}&l=20`, + `https://${this.serviceHost()}/v1/contract/suggest?t=${encodeURIComponent(productName)}&l=20`, { headers: { Authorization: `Bearer ${this.accessInfo.accessToken}` }, timeout: HTTP_TIMEOUT_MS } ); const contracts: Array<{ id: number; name: string; status: string; providerTickSize: number }> = res.data ?? []; @@ -1990,7 +1994,7 @@ export class TradovateClient { .filter((id): id is number => id !== undefined); const res = await axios.post( - 'https://demo.tradovateapi.com/v1/contract/getproductfeeparams', + `https://${this.serviceHost()}/v1/contract/getproductfeeparams`, { productIds }, { headers: { Authorization: `Bearer ${this.accessInfo.accessToken}` }, timeout: HTTP_TIMEOUT_MS } ); @@ -2373,6 +2377,17 @@ export class TradovateClient { return expiresAt === 0 || expiresAt > Date.now() ? info.mdAccessToken : null; } + /** Host for this login's REST calls and trading socket — its + * organization's own when the login named one (demoHosts()). */ + public serviceHost(): string { + return demoHosts(this.accessInfo?.apiHosts).service; + } + + /** Host for this login's reports. */ + public reportingHost(): string { + return demoHosts(this.accessInfo?.apiHosts).reporting; + } + /** Liveness snapshot for /api/health and the registry supervisor. No tokens. */ public health(): ClientHealth { const now = Date.now(); diff --git a/lib/tradovate-helpers.ts b/lib/tradovate-helpers.ts index fca055a..dc5c625 100644 --- a/lib/tradovate-helpers.ts +++ b/lib/tradovate-helpers.ts @@ -14,6 +14,42 @@ export interface AuthLoginResponse { requiredNonProCertification: boolean; outdatedSentimentPolicy: boolean; orgName: string; + /** Where this user's organization is served — see demoHosts(). */ + apiHosts?: TradovateApiHosts | null; +} + +/** The hosts a login answer names, one per environment and service. */ +export interface TradovateApiHosts { + live?: string; + demo?: string; + mdLive?: string; + mdDemo?: string; + reportingLive?: string; + reportingDemo?: string; + replay?: string; +} + +export const SHARED_DEMO_HOST = 'demo.tradovateapi.com'; +export const SHARED_DEMO_REPORTING_HOST = 'rpt-demo.tradovateapi.com'; + +const HOST_NAME = /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i; + +/** + * The hosts a login's demo-environment calls go to: REST and the trading + * socket (service), and reports. Tradovate serves some organizations from + * their own hosts and says so in the login answer's apiHosts — Lucid moved to + * demo-api.lucid.ninjatrader.com (seen 2026-11-01), and the shared demo + * socket drops a Lucid session right after its authorize. A login without + * apiHosts, or with a value that isn't a plain host name, keeps the shared + * hosts. + */ +export function demoHosts(apiHosts: TradovateApiHosts | null | undefined): { service: string; reporting: string } { + const pick = (value: unknown, fallback: string) => + typeof value === 'string' && HOST_NAME.test(value) ? value.toLowerCase() : fallback; + return { + service: pick(apiHosts?.demo, SHARED_DEMO_HOST), + reporting: pick(apiHosts?.reportingDemo, SHARED_DEMO_REPORTING_HOST), + }; } export interface AccountItem { diff --git a/package.json b/package.json index 61ec044..ac44a57 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "build": "next build", "start": "next start", "lint": "eslint", - "check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs scripts/check-instruments.mjs scripts/check-account-status.mjs scripts/check-kept-rules.mjs scripts/check-request-user-agent.mjs scripts/check-vest.mjs scripts/check-vest-client.mjs scripts/check-trades.mjs scripts/check-catalog.mjs scripts/check-extension-vest.mjs" + "check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs scripts/check-instruments.mjs scripts/check-account-status.mjs scripts/check-kept-rules.mjs scripts/check-request-user-agent.mjs scripts/check-vest.mjs scripts/check-vest-client.mjs scripts/check-trades.mjs scripts/check-catalog.mjs scripts/check-extension-vest.mjs scripts/check-tradovate-hosts.mjs" }, "dependencies": { "@microsoft/signalr": "^10.0.11", diff --git a/scripts/check-tradovate-hosts.mjs b/scripts/check-tradovate-hosts.mjs new file mode 100644 index 0000000..9b86f96 --- /dev/null +++ b/scripts/check-tradovate-hosts.mjs @@ -0,0 +1,41 @@ +// Which hosts a Tradovate login's calls go to. Pure — never contacts a broker. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { demoHosts, SHARED_DEMO_HOST, SHARED_DEMO_REPORTING_HOST } from '../lib/tradovate-helpers.ts'; + +const SHARED = { service: SHARED_DEMO_HOST, reporting: SHARED_DEMO_REPORTING_HOST }; + +test('a login without apiHosts keeps the shared hosts', () => { + assert.deepEqual(demoHosts(undefined), SHARED); + assert.deepEqual(demoHosts(null), SHARED); + assert.deepEqual(demoHosts({}), SHARED); +}); + +test("an organization's own hosts are followed (Lucid, 2026-11-01)", () => { + const lucid = { + live: 'live.tradovateapi.com', + demo: 'demo-api.lucid.ninjatrader.com', + mdLive: 'md.tradovateapi.com', + mdDemo: 'md-demo-api.lucid.ninjatrader.com', + reportingLive: 'rpt-live.tradovateapi.com', + reportingDemo: 'rpt-demo-api.lucid.ninjatrader.com', + replay: 'replay.tradovateapi.com', + }; + assert.deepEqual(demoHosts(lucid), { + service: 'demo-api.lucid.ninjatrader.com', + reporting: 'rpt-demo-api.lucid.ninjatrader.com', + }); +}); + +test('each host falls back on its own', () => { + assert.deepEqual(demoHosts({ demo: 'demo-api.lucid.ninjatrader.com' }), { + service: 'demo-api.lucid.ninjatrader.com', + reporting: SHARED_DEMO_REPORTING_HOST, + }); +}); + +test("a value that isn't a plain host name is ignored", () => { + for (const bad of ['', 'localhost', 'evil.com/path', 'https://demo.example.com', 'a.b:443', 'x .y', 42, null]) { + assert.deepEqual(demoHosts({ demo: bad, reportingDemo: bad }), SHARED, String(bad)); + } +});