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:
co-authored by
Claude Opus 5.5
parent
10da0ca83e
commit
7fb9ddbd96
@@ -4,6 +4,7 @@ import { getTradableFirms } from '@/lib/firm-catalog';
|
|||||||
import { getAccountConfig } from '@/lib/account-type-resolver';
|
import { getAccountConfig } from '@/lib/account-type-resolver';
|
||||||
import { combineCmeSessions, computeDayPlan, resolveEffectiveConfig, currentTradeDateCT } from '@/lib/trading-logic';
|
import { combineCmeSessions, computeDayPlan, resolveEffectiveConfig, currentTradeDateCT } from '@/lib/trading-logic';
|
||||||
import axios from '@/lib/http-client';
|
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
|
* 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 });
|
return NextResponse.json({ error: 'no token for firmId ' + firmId }, { status: 400 });
|
||||||
}
|
}
|
||||||
const headers = { Authorization: `Bearer ${client.accessInfo.accessToken}` };
|
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
|
// Full report cycle
|
||||||
if (payload.action === 'report') {
|
if (payload.action === 'report') {
|
||||||
const { account, startDate, endDate, name: reportName = 'Fills' } = payload;
|
const { account, startDate, endDate, name: reportName = 'Fills' } = payload;
|
||||||
try {
|
try {
|
||||||
let reportData = (await axios.post(
|
let reportData = (await axios.post(
|
||||||
'https://rpt-demo.tradovateapi.com/v1/reports/requestreport',
|
`https://${reportingHost}/v1/reports/requestreport`,
|
||||||
{
|
{
|
||||||
name: reportName, representationType: 'json', timezone: -300,
|
name: reportName, representationType: 'json', timezone: -300,
|
||||||
params: [
|
params: [
|
||||||
@@ -178,7 +182,7 @@ export async function PATCH(req: Request) {
|
|||||||
const wait: number = Math.max(1, reportData['p-time'] ?? 1);
|
const wait: number = Math.max(1, reportData['p-time'] ?? 1);
|
||||||
await new Promise((r) => setTimeout(r, wait * 1000));
|
await new Promise((r) => setTimeout(r, wait * 1000));
|
||||||
reportData = (await axios.get(
|
reportData = (await axios.get(
|
||||||
'https://rpt-demo.tradovateapi.com/v1/reports/getreport',
|
`https://${reportingHost}/v1/reports/getreport`,
|
||||||
{ params: { 'p-ticket': ticket }, headers }
|
{ params: { 'p-ticket': ticket }, headers }
|
||||||
)).data;
|
)).data;
|
||||||
attempts++;
|
attempts++;
|
||||||
@@ -207,7 +211,7 @@ export async function PATCH(req: Request) {
|
|||||||
|
|
||||||
// Generic proxy
|
// Generic proxy
|
||||||
const { path, url: rawUrl, method = 'GET', body: reqBody } = payload;
|
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 {
|
try {
|
||||||
const res = method === 'POST'
|
const res = method === 'POST'
|
||||||
? await axios.post(url, reqBody, { headers })
|
? await axios.post(url, reqBody, { headers })
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getClients } from '@/lib/clients';
|
import { getClients } from '@/lib/clients';
|
||||||
import axios from '@/lib/http-client';
|
import axios from '@/lib/http-client';
|
||||||
|
import { SHARED_DEMO_HOST } from '@/lib/tradovate-helpers';
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
@@ -17,7 +18,7 @@ export async function GET(
|
|||||||
if (!accessToken) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
if (!accessToken) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
|
||||||
|
|
||||||
const res = await axios.get(
|
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}` } }
|
{ headers: { Authorization: `Bearer ${accessToken}` } }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getClients } from '@/lib/clients';
|
import { contractLookupToken } from '@/lib/clients';
|
||||||
import { futuresRootsHere } from '@/lib/instruments';
|
import { futuresRootsHere } from '@/lib/instruments';
|
||||||
import { resolveContracts, getAllCachedContracts } from '@/lib/contract-resolver';
|
import { resolveContracts, getAllCachedContracts } from '@/lib/contract-resolver';
|
||||||
|
|
||||||
@@ -19,14 +19,8 @@ export async function GET() {
|
|||||||
*/
|
*/
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const clients = getClients();
|
const lookup = contractLookupToken();
|
||||||
// Find first client with a valid token
|
if (!lookup) {
|
||||||
let accessToken: string | null = null;
|
|
||||||
for (const [, c] of clients) {
|
|
||||||
const token = (c as any).accessInfo?.accessToken;
|
|
||||||
if (token) { accessToken = token; break; }
|
|
||||||
}
|
|
||||||
if (!accessToken) {
|
|
||||||
return NextResponse.json({ error: 'No authenticated client available' }, { status: 503 });
|
return NextResponse.json({ error: 'No authenticated client available' }, { status: 503 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +31,7 @@ export async function POST(req: NextRequest) {
|
|||||||
? requestedSymbols
|
? requestedSymbols
|
||||||
: futuresRootsHere();
|
: futuresRootsHere();
|
||||||
|
|
||||||
const resolved = await resolveContracts(symbols, accessToken);
|
const resolved = await resolveContracts(symbols, lookup.token, lookup.host);
|
||||||
return NextResponse.json(resolved);
|
return NextResponse.json(resolved);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[POST /api/instruments/contracts]', err);
|
console.error('[POST /api/instruments/contracts]', err);
|
||||||
|
|||||||
+29
-16
@@ -9,6 +9,7 @@ import { firmInstruments, isFuturesFirm, prewarmMarket } from './instruments';
|
|||||||
import { housekeepingRoot } from './instrument-specs';
|
import { housekeepingRoot } from './instrument-specs';
|
||||||
import { resolveFirmAssignments } from './account-type-resolver';
|
import { resolveFirmAssignments } from './account-type-resolver';
|
||||||
import { resolveContracts } from './contract-resolver';
|
import { resolveContracts } from './contract-resolver';
|
||||||
|
import { SHARED_DEMO_HOST } from './tradovate-helpers';
|
||||||
import { startReporter, sendReport } from './reporter';
|
import { startReporter, sendReport } from './reporter';
|
||||||
import { startBackupSync } from './backup-sync';
|
import { startBackupSync } from './backup-sync';
|
||||||
import { applyBrokerHttpDefaults, applyBrokerProxy } from './outbound-proxy';
|
import { applyBrokerHttpDefaults, applyBrokerProxy } from './outbound-proxy';
|
||||||
@@ -280,27 +281,39 @@ function resolveRootsHere(): string[] {
|
|||||||
return [...roots];
|
return [...roots];
|
||||||
}
|
}
|
||||||
|
|
||||||
function triggerContractResolve(): void {
|
/**
|
||||||
// A midnight run supersedes a pending retry; either way only one chain.
|
* The Tradovate token the contract resolver confirms ids with (the
|
||||||
if (g.__contractResolveRetryTimer) clearTimeout(g.__contractResolveRetryTimer);
|
* Yahoo-driven resolver checks them against Tradovate; TopstepX clients
|
||||||
g.__contractResolveRetryTimer = undefined;
|
* resolve their own contracts but still reuse the resolver's front-month
|
||||||
|
* names), and the host it belongs on.
|
||||||
const map = ensureMap();
|
* A token alone isn't enough: a client whose session died keeps its expired
|
||||||
// Find first client with a valid access token (the Yahoo-driven resolver
|
* one, and resolving with that fails with nothing left to retry. The cache
|
||||||
// confirms contract ids against Tradovate; TopstepX clients resolve their
|
* serves every firm, so a client on the shared hosts is preferred; one on its
|
||||||
// own contracts but still reuse the resolver's front-month names).
|
* organization's own (Lucid's) only stands in.
|
||||||
// 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.
|
export function contractLookupToken(): { token: string; host: string } | null {
|
||||||
let accessToken: string | null = null;
|
let pick: { token: string; host: string } | null = null;
|
||||||
for (const [, c] of map) {
|
for (const [, c] of ensureMap()) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const token = (c as any).accessInfo?.accessToken;
|
const token = (c as any).accessInfo?.accessToken;
|
||||||
if (!token) continue;
|
if (!token) continue;
|
||||||
let authenticated = true;
|
let authenticated = true;
|
||||||
try { authenticated = c.health().authenticated; } catch { /* no health: trust the token */ }
|
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;
|
const retries = (g.__contractResolveRetries ?? 0) + 1;
|
||||||
if (retries > CONTRACT_RESOLVE_MAX_RETRIES) {
|
if (retries > CONTRACT_RESOLVE_MAX_RETRIES) {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -325,7 +338,7 @@ function triggerContractResolve(): void {
|
|||||||
const symbols = resolveRootsHere();
|
const symbols = resolveRootsHere();
|
||||||
g.__contractResolvedRoots = new Set(symbols);
|
g.__contractResolvedRoots = new Set(symbols);
|
||||||
console.log(`[contract-resolver] Resolving ${symbols.length} instruments...`);
|
console.log(`[contract-resolver] Resolving ${symbols.length} instruments...`);
|
||||||
resolveContracts(symbols, accessToken)
|
resolveContracts(symbols, lookup.token, lookup.host)
|
||||||
.then((results) => {
|
.then((results) => {
|
||||||
const rolled = Object.entries(results).filter(([, v]) => v?.alternative);
|
const rolled = Object.entries(results).filter(([, v]) => v?.alternative);
|
||||||
console.log(`[contract-resolver] Done — ${rolled.length} contract(s) rolled via price match`);
|
console.log(`[contract-resolver] Done — ${rolled.length} contract(s) rolled via price match`);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
import axios from './http-client';
|
import axios from './http-client';
|
||||||
import { errorMessage } from './platform-client';
|
import { errorMessage } from './platform-client';
|
||||||
|
import { SHARED_DEMO_HOST } from './tradovate-helpers';
|
||||||
|
|
||||||
interface ContractInfo {
|
interface ContractInfo {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -163,11 +164,13 @@ async function findHighestVolumeContract(
|
|||||||
* For each symbol:
|
* For each symbol:
|
||||||
* 1. Fetch {PRODUCT}=F from Yahoo to learn the exchange suffix
|
* 1. Fetch {PRODUCT}=F from Yahoo to learn the exchange suffix
|
||||||
* 2. Probe near-month Yahoo tickers and pick the highest-volume contract
|
* 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(
|
export async function resolveContracts(
|
||||||
symbols: string[],
|
symbols: string[],
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
|
host: string = SHARED_DEMO_HOST,
|
||||||
): Promise<Record<string, ResolvedContract | null>> {
|
): Promise<Record<string, ResolvedContract | null>> {
|
||||||
const headers = { Authorization: `Bearer ${accessToken}` };
|
const headers = { Authorization: `Bearer ${accessToken}` };
|
||||||
const results: Record<string, ResolvedContract | null> = {};
|
const results: Record<string, ResolvedContract | null> = {};
|
||||||
@@ -189,7 +192,7 @@ export async function resolveContracts(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await axios.get(
|
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 }
|
{ headers }
|
||||||
);
|
);
|
||||||
const contracts: ContractInfo[] = res.data ?? [];
|
const contracts: ContractInfo[] = res.data ?? [];
|
||||||
|
|||||||
+26
-11
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import axios from './http-client';
|
import axios from './http-client';
|
||||||
import type { AccountItem, AuthLoginResponse, Contract } from './tradovate-helpers';
|
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 { currentTradeDateCT } from './trading-logic';
|
||||||
import { POINT_VALUES } from './instrument-specs';
|
import { POINT_VALUES } from './instrument-specs';
|
||||||
import { getCachedContract, resolveContracts } from './contract-resolver';
|
import { getCachedContract, resolveContracts } from './contract-resolver';
|
||||||
@@ -516,7 +516,9 @@ export class TradovateClient {
|
|||||||
this.lastLoginError = null;
|
this.lastLoginError = null;
|
||||||
this.consecutiveLoginFailures = 0;
|
this.consecutiveLoginFailures = 0;
|
||||||
this.needsLogin = false;
|
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();
|
this.openSocket();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -615,7 +617,7 @@ export class TradovateClient {
|
|||||||
const randomnumber = Math.random().toString(36).substring(2, 15);
|
const randomnumber = Math.random().toString(36).substring(2, 15);
|
||||||
// Broker endpoint — dialled through the operator's proxy when one
|
// Broker endpoint — dialled through the operator's proxy when one
|
||||||
// is configured (lib/outbound-proxy.ts).
|
// 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.ws = ws;
|
||||||
this.socketOpenedAt = Date.now();
|
this.socketOpenedAt = Date.now();
|
||||||
this.authorizeSent = false;
|
this.authorizeSent = false;
|
||||||
@@ -1266,7 +1268,7 @@ export class TradovateClient {
|
|||||||
if (this.intentionalDisconnect) throw new Error('client disconnected');
|
if (this.intentionalDisconnect) throw new Error('client disconnected');
|
||||||
try {
|
try {
|
||||||
let reportData = (await axios.post(
|
let reportData = (await axios.post(
|
||||||
'https://rpt-demo.tradovateapi.com/v1/reports/requestreport',
|
`https://${this.reportingHost()}/v1/reports/requestreport`,
|
||||||
{
|
{
|
||||||
name, representationType: 'json', timezone: -300,
|
name, representationType: 'json', timezone: -300,
|
||||||
params: [
|
params: [
|
||||||
@@ -1286,7 +1288,7 @@ export class TradovateClient {
|
|||||||
await new Promise((r) => setTimeout(r, pTime * 1000));
|
await new Promise((r) => setTimeout(r, pTime * 1000));
|
||||||
if (this.intentionalDisconnect) throw new Error('client disconnected');
|
if (this.intentionalDisconnect) throw new Error('client disconnected');
|
||||||
reportData = (await axios.get(
|
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 }
|
{ params: { 'p-ticket': pTicket }, headers: authHeaders, timeout: REPORT_TIMEOUT_MS }
|
||||||
)).data;
|
)).data;
|
||||||
pollAttempts++;
|
pollAttempts++;
|
||||||
@@ -1909,8 +1911,10 @@ export class TradovateClient {
|
|||||||
if (!body?.accessToken) {
|
if (!body?.accessToken) {
|
||||||
throw new Error(body?.errorText ? String(body.errorText) : 'renewal answered without an access token');
|
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.
|
// Merge, so a renewal answer without expirationTime can't leave it
|
||||||
this.accessInfo = { ...this.accessInfo, ...body } as AuthLoginResponse;
|
// 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();
|
this.noteTokenExpiry();
|
||||||
console.log(`${this.tag} renewed access token — expires ${this.accessInfo.expirationTime}`);
|
console.log(`${this.tag} renewed access token — expires ${this.accessInfo.expirationTime}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1930,7 +1934,7 @@ export class TradovateClient {
|
|||||||
return [];
|
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: {
|
headers: {
|
||||||
Authorization: `Bearer ${this.accessInfo.accessToken}`,
|
Authorization: `Bearer ${this.accessInfo.accessToken}`,
|
||||||
},
|
},
|
||||||
@@ -1954,7 +1958,7 @@ export class TradovateClient {
|
|||||||
// Under a deadline: its lookups (Yahoo, then Tradovate) must not
|
// Under a deadline: its lookups (Yahoo, then Tradovate) must not
|
||||||
// hold every firm's trade. A late answer still fills the cache.
|
// hold every firm's trade. A late answer still fills the cache.
|
||||||
const resolved = await withDeadline(
|
const resolved = await withDeadline(
|
||||||
resolveContracts([productName], this.accessInfo.accessToken),
|
resolveContracts([productName], this.accessInfo.accessToken, this.serviceHost()),
|
||||||
CONTRACT_RESOLVE_DEADLINE_MS,
|
CONTRACT_RESOLVE_DEADLINE_MS,
|
||||||
'contract resolve',
|
'contract resolve',
|
||||||
);
|
);
|
||||||
@@ -1969,7 +1973,7 @@ export class TradovateClient {
|
|||||||
try {
|
try {
|
||||||
// Last resort: fall back to Tradovate's ordered suggestions.
|
// Last resort: fall back to Tradovate's ordered suggestions.
|
||||||
const res = await axios.get(
|
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 }
|
{ headers: { Authorization: `Bearer ${this.accessInfo.accessToken}` }, timeout: HTTP_TIMEOUT_MS }
|
||||||
);
|
);
|
||||||
const contracts: Array<{ id: number; name: string; status: string; providerTickSize: number }> = res.data ?? [];
|
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);
|
.filter((id): id is number => id !== undefined);
|
||||||
|
|
||||||
const res = await axios.post(
|
const res = await axios.post(
|
||||||
'https://demo.tradovateapi.com/v1/contract/getproductfeeparams',
|
`https://${this.serviceHost()}/v1/contract/getproductfeeparams`,
|
||||||
{ productIds },
|
{ productIds },
|
||||||
{ headers: { Authorization: `Bearer ${this.accessInfo.accessToken}` }, timeout: HTTP_TIMEOUT_MS }
|
{ 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;
|
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. */
|
/** Liveness snapshot for /api/health and the registry supervisor. No tokens. */
|
||||||
public health(): ClientHealth {
|
public health(): ClientHealth {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|||||||
@@ -14,6 +14,42 @@ export interface AuthLoginResponse {
|
|||||||
requiredNonProCertification: boolean;
|
requiredNonProCertification: boolean;
|
||||||
outdatedSentimentPolicy: boolean;
|
outdatedSentimentPolicy: boolean;
|
||||||
orgName: string;
|
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 {
|
export interface AccountItem {
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"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": {
|
"dependencies": {
|
||||||
"@microsoft/signalr": "^10.0.11",
|
"@microsoft/signalr": "^10.0.11",
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user