Files
autofirmer-expanded/lib/contract-resolver.ts
T
Brandon LiandClaude Opus 5.5 7fb9ddbd96 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>
2026-11-01 18:12:21 -06:00

238 lines
9.4 KiB
TypeScript

/**
* Contract Resolver
*
* Determines the active contract for each symbol by:
* 1. Asking Yahoo Finance's continuous contract ({PRODUCT}=F) for the exchange suffix
* 2. Probing Yahoo for several near-month tickers and ranking them by volume
* 3. Looking up the highest-volume contract on Tradovate via /contract/suggest
*
* Volume is the trader-standard definition of "front month": liquidity has
* migrated to whichever contract is most actively traded on any given day.
*
* Results are cached and refreshed periodically (default: every 30 minutes).
*/
import axios from './http-client';
import { errorMessage } from './platform-client';
import { SHARED_DEMO_HOST } from './tradovate-helpers';
interface ContractInfo {
id: number;
name: string;
tickSize: number;
contractMaturityId?: number;
}
export interface ResolvedContract extends ContractInfo {
/** The other candidate contracts that were not selected */
alternative?: string;
}
const MONTH_CODES_ORDERED = ['F', 'G', 'H', 'J', 'K', 'M', 'N', 'Q', 'U', 'V', 'X', 'Z'];
const PROBE_WINDOW = 6;
// ── Cache ────────────────────────────────────────────────────────────────────
// On globalThis, not module scope: instrumentation.ts boots the process from its
// own bundle, so the boot and midnight resolves would otherwise fill a cache the
// route handlers (settings page, manual trades) never see.
const _g = globalThis as typeof globalThis & {
__contractCache?: Map<string, { contract: ResolvedContract; resolvedAt: number }>;
__contractRoots?: Map<string, string>;
};
const cache = (_g.__contractCache ??= new Map());
/** Contract id → the root it was resolved for. Unlike the cache it never
* expires: a position opened hours ago still needs its instrument's name
* (lib/trade-mix.ts). Front months are few, so it stays small. */
const roots = (_g.__contractRoots ??= new Map());
const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
export function getCachedContract(symbol: string): ResolvedContract | null {
const entry = cache.get(symbol);
if (!entry) return null;
if (Date.now() - entry.resolvedAt > CACHE_TTL_MS) {
cache.delete(symbol);
return null;
}
return entry.contract;
}
/** The root a resolved contract id belongs to, however long ago it was
* resolved; null for one this process never resolved. */
export function rootOfContract(contractId: number | string): string | null {
return roots.get(String(contractId)) ?? null;
}
export function getAllCachedContracts(): Record<string, ResolvedContract | null> {
const result: Record<string, ResolvedContract | null> = {};
for (const [symbol, entry] of cache) {
if (Date.now() - entry.resolvedAt > CACHE_TTL_MS) {
cache.delete(symbol);
result[symbol] = null;
} else {
result[symbol] = entry.contract;
}
}
return result;
}
// ── Yahoo Finance lookups ────────────────────────────────────────────────────
/** Fetch the continuous contract's exchange suffix (e.g. "NQ=F" → "CME"). */
async function getContinuousExchange(product: string): Promise<string | null> {
try {
const res = await axios.get(
`https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(product + '=F')}`,
{ params: { range: '1d', interval: '1d' } }
);
const meta = res.data?.chart?.result?.[0]?.meta;
if (!meta) return null;
return meta.exchangeName ?? 'CME';
} catch {
return null;
}
}
/**
* Probe Yahoo for the next PROBE_WINDOW month codes starting from the current
* calendar month. Returns the candidate with the highest recent volume, plus
* the earliest other candidate with non-zero volume (used as the "rolled from"
* label). Quarterly products naturally skip their unused months — Yahoo has
* no data for them.
*/
async function findHighestVolumeContract(
product: string,
exchange: string,
): Promise<{ chosen: string; runnerUp: string | null } | null> {
const now = new Date();
const startMonthIdx = now.getUTCMonth();
const startYear = now.getUTCFullYear();
const probes = await Promise.all(
Array.from({ length: PROBE_WINDOW }, (_, offset) => offset).map(async (offset) => {
const flat = startMonthIdx + offset;
const monthCode = MONTH_CODES_ORDERED[flat % 12];
const year = startYear + Math.floor(flat / 12);
const yy = (year % 100).toString().padStart(2, '0');
const ticker = `${product}${monthCode}${yy}.${exchange}`;
try {
const res = await axios.get(
`https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(ticker)}`,
{ params: { range: '1d', interval: '1d' } }
);
const result = res.data?.chart?.result?.[0];
const meta = result?.meta;
if (!meta?.shortName) return null;
const volume: number =
meta.regularMarketVolume
?? result?.indicators?.quote?.[0]?.volume?.at(-1)
?? 0;
return {
name: `${product}${monthCode}${(year % 10).toString()}`,
offset,
volume,
};
} catch {
return null;
}
})
);
const valid = probes.filter((p): p is NonNullable<typeof p> => p !== null);
if (valid.length === 0) return null;
const byVolume = [...valid].sort((a, b) => b.volume - a.volume);
const chosen = byVolume[0];
console.log(
`[contract-resolver] ${product}: volume ranking — ${byVolume.map((c) => `${c.name}=${c.volume}`).join(', ')}`
);
const earlierWithVolume = valid
.filter((p) => p.offset < chosen.offset && p.volume > 0)
.sort((a, b) => a.offset - b.offset)[0];
return {
chosen: chosen.name,
runnerUp: earlierWithVolume?.name ?? null,
};
}
// ── Main resolver ────────────────────────────────────────────────────────────
/**
* Resolve the active contract for a list of symbols.
* 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 — on
* `host`, the token's own (TradovateClient.serviceHost())
*/
export async function resolveContracts(
symbols: string[],
accessToken: string,
host: string = SHARED_DEMO_HOST,
): Promise<Record<string, ResolvedContract | null>> {
const headers = { Authorization: `Bearer ${accessToken}` };
const results: Record<string, ResolvedContract | null> = {};
await Promise.all(symbols.map(async (sym) => {
let chosenName: string | null = null;
let rolledFrom: string | null = null;
const exchange = await getContinuousExchange(sym);
if (exchange) {
const winner = await findHighestVolumeContract(sym, exchange);
if (winner) {
chosenName = winner.chosen;
rolledFrom = winner.runnerUp;
}
} else {
console.log(`[contract-resolver] ${sym}: Yahoo continuous contract lookup failed`);
}
try {
const res = await axios.get(
`https://${host}/v1/contract/suggest?t=${encodeURIComponent(chosenName ?? sym)}&l=10`,
{ headers }
);
const contracts: ContractInfo[] = res.data ?? [];
const exactMatch = chosenName ? contracts.find((c) => c.name === chosenName) : null;
const fallbackMatch = contracts.find((c) => {
if (!c.name.startsWith(sym)) return false;
return /^[A-Z]\d/.test(c.name.slice(sym.length));
});
const match = exactMatch ?? fallbackMatch;
if (!match) {
console.log(`[contract-resolver] ${sym}: no matching contract found on Tradovate`);
results[sym] = null;
return;
}
const contract: ResolvedContract = {
id: match.id,
name: match.name,
tickSize: (match as any).providerTickSize ?? 0.25,
contractMaturityId: (match as any).contractMaturityId,
alternative: rolledFrom ?? undefined,
};
results[sym] = contract;
cache.set(sym, { contract, resolvedAt: Date.now() });
roots.set(String(contract.id), sym);
if (rolledFrom) {
console.log(`[contract-resolver] ${sym}: ${rolledFrom} → ${match.name} ROLLED (by volume)`);
} else {
console.log(`[contract-resolver] ${sym}: ${match.name} resolved`);
}
} catch (err) {
// Never the whole error: an AxiosError carries the Bearer token in its config.
console.error(`[contract-resolver] ${sym}: Tradovate lookup failed — ${errorMessage(err)}`);
results[sym] = null;
}
}));
return results;
}