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
+26 -11
View File
@@ -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();