Tradovate: each login's calls go to the hosts its organization is served from

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>
This commit is contained in:
Brandon Li
2026-11-01 18:12:21 -06:00
co-authored by Claude Opus 5.5
parent 10da0ca83e
commit 7fb9ddbd96
9 changed files with 151 additions and 44 deletions
+29 -16
View File
@@ -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`);