import type { SearchRequestContext, DataProvider } from "../../types/data-provider"; import type { InstrumentSearchResult } from "../../types/instrument"; import type { TickerRecord } from "../../types/ticker"; import { canonicalExchange, parsePublicTickerKey, publicTickerKey } from "../../utils/exchanges"; import { tickerHasYahooSuffix } from "../../sources/yahoo-finance/symbols"; import { parseOptionSymbol } from "../../utils/options"; import { resolveCurrencyUnit } from "../../utils/currency-units"; import { searchContractKey, searchInstrumentKey } from "./identity"; import { tickerInstrumentLabel } from "../instrument-label"; import { buildSymbolAliases, classifyInstrumentKind, findExactTickerSearchMatch, getForexQuoteCurrency, isExplicitMarketSymbol, isCryptoInstrumentType, normalizeSearchText, normalizeTickerSymbol, rankTickerSearchItems, } from "./ranking"; import type { ResolvedTickerSearch, TickerSearchCandidate, } from "./types"; import { getSearchResultSymbol, isLowQualityTickerName, } from "./result"; export type { ResolvedTickerSearch, TickerSearchCandidate, TickerSearchInstrumentClass, } from "./types"; export { findExactTickerSearchMatch, rankTickerSearchItems, } from "./ranking"; export { upsertTickerFromSearchResult } from "./upsert"; export class AmbiguousTickerError extends Error { constructor(readonly query: string, readonly listings: readonly string[], readonly listingNames: Readonly> = {}) { super(`Multiple listings match ${query}. Choose an exchange in search or use ${listings.slice(0, 3).join(", ")}.`); this.name = "AmbiguousTickerError"; } } export class AmbiguousContractError extends AmbiguousTickerError { constructor(query: string, contracts: readonly string[]) { super(query, contracts); this.name = "AmbiguousContractError"; this.message = `Multiple contracts match ${query}. Choose a contract in search.`; } } const OPTION_TYPES = new Set(["OPT", "OPTION", "OPTIONS"]); const SHARE_CLASS_SUFFIXES = new Set(["A", "B", "C", "D", "K"]); interface TickerSearchCandidateOptions { includeOptionContracts?: boolean; providerRanks?: ReadonlyMap; providerPopularity?: ReadonlyMap; } export function normalizeTickerInput(activeTicker: string | null, arg?: string): string | null { const explicitTicker = arg?.trim().toUpperCase(); if (explicitTicker) return explicitTicker; return activeTicker; } export function createLocalTickerSearchCandidates( tickers: Iterable, providerHints: ReadonlyMap = new Map(), options: TickerSearchCandidateOptions = {}, ): TickerSearchCandidate[] { return Array.from(tickers).flatMap((ticker) => { const symbol = normalizeTickerSymbol(ticker.metadata.ticker); const savedExchange = canonicalExchange(ticker.metadata.exchange); // The same symbol on another venue can be another security (MSFT's BYMA // receipt, NVO on Warsaw); it must not type or route the saved listing. const venueHint = providerHints.get(symbol); const hint = venueHint && savedExchange && isOtherVenue(venueHint, savedExchange) ? undefined : venueHint; const exchangeLabel = ticker.metadata.exchange || hint?.exchange || hint?.primaryExchange; const hintPrimaryExchange = canonicalExchange(hint?.primaryExchange); const primaryExchangeLabel = !savedExchange || !hintPrimaryExchange || savedExchange === hintPrimaryExchange ? hint?.primaryExchange : undefined; const contractResults = (ticker.metadata.broker_contracts ?? []) .filter((brokerContract) => searchContractKey({ brokerContract, type: ticker.metadata.assetCategory || "" })) .map((brokerContract): InstrumentSearchResult => ({ providerId: brokerContract.brokerId, brokerInstanceId: brokerContract.brokerInstanceId, symbol: ticker.metadata.ticker, name: tickerInstrumentLabel(ticker.metadata.ticker, brokerContract), exchange: brokerContract.exchange || "", primaryExchange: brokerContract.primaryExchange, currency: brokerContract.currency, type: brokerContract.secType || ticker.metadata.assetCategory || "", brokerContract, })); if (!contractResults.length && options.includeOptionContracts === false && isOptionTickerRecord(ticker)) return []; return (contractResults.length ? contractResults : [hint]) .filter((result) => options.includeOptionContracts !== false || !result || !isOptionSearchResult(result)) .map((result) => { const contractKey = result ? searchContractKey(result) : null; const instrumentType = contractKey ? result!.type : hint?.brokerContract?.secType || hint?.type || ticker.metadata.assetCategory; const venue = contractKey ? result!.exchange : exchangeLabel; return { id: `goto:${ticker.metadata.ticker}`, label: ticker.metadata.ticker, symbol, detail: contractKey ? result!.name : resolveLocalSearchName(ticker, hint), right: venue, exchangeLabel: venue, primaryExchangeLabel: contractKey ? result!.primaryExchange : primaryExchangeLabel, providerRank: options.providerRanks?.get(symbol), popularity: options.providerPopularity?.get(symbol), category: "Saved", kind: "ticker", saved: true, instrumentClass: classifyInstrumentKind(instrumentType), instrumentType, searchAliases: buildSymbolAliases(symbol), ticker, result, ...(contractKey ? { contractKey, id: `goto:${ticker.metadata.ticker}:${contractKey}` } : {}), } as TickerSearchCandidate; }); }); } function createProviderTickerSearchCandidates( searchResults: InstrumentSearchResult[], localTickers: ReadonlyMap, options: TickerSearchCandidateOptions = {}, ): TickerSearchCandidate[] { return searchResults.flatMap((result, providerRank) => { if (options.includeOptionContracts === false && isOptionSearchResult(result)) return []; const symbol = getSearchResultSymbol(result); const currency = result.currency || getForexQuoteCurrency(symbol); if (currency && !result.currency) result = { ...result, currency }; const listingKey = publicTickerKey(symbol, listingExchange(result)); const savedTicker = localTickers.get(listingKey) ?? localTickers.get(symbol); const saved = !!savedTicker && publicTickerKey(savedTicker.metadata.ticker, savedTicker.metadata.exchange) === listingKey; return [{ id: buildProviderCandidateId(result, symbol), contractKey: searchContractKey(result), label: symbol, symbol, detail: [result.name, result.brokerLabel, result.type].filter(Boolean).join(" | "), right: result.exchange || result.primaryExchange || result.type || undefined, exchangeLabel: result.exchange, primaryExchangeLabel: result.primaryExchange, providerRank, popularity: searchResultPopularity(result), category: saved ? "Saved" : "Other Listings", kind: "search", saved, instrumentClass: classifyInstrumentKind(result.brokerContract?.secType || result.type), instrumentType: result.brokerContract?.secType || result.type, searchAliases: buildSearchResultAliases(result), result, }]; }); } export async function searchTickerCandidates({ query, tickers, dataProvider, searchContext, localLimit = 6, totalLimit = 8, includeOptionContracts = true, onPartial, }: { query: string; tickers: ReadonlyMap; dataProvider: DataProvider; searchContext?: SearchRequestContext; localLimit?: number; totalLimit?: number; includeOptionContracts?: boolean; /** Called when a slower, richer source improves results already returned. */ onPartial?: (candidates: TickerSearchCandidate[]) => void; }): Promise { const assemble = (providerResults: InstrumentSearchResult[]) => buildTickerSearchCandidates({ query, tickers, providerResults, localLimit, totalLimit, includeOptionContracts, }); return assemble(await searchProviderResults( dataProvider, query, searchContext, onPartial ? (results) => onPartial(assemble(results)) : undefined, )); } export function buildTickerSearchCandidates({ query, tickers, providerResults, localLimit = 6, totalLimit = 8, includeOptionContracts = true, }: { query: string; tickers: ReadonlyMap; providerResults: InstrumentSearchResult[]; localLimit?: number; totalLimit?: number; includeOptionContracts?: boolean; }): TickerSearchCandidate[] { const filteredProviderResults = includeOptionContracts ? providerResults : providerResults.filter((result) => !isOptionSearchResult(result)); const providerHints = buildProviderHints(filteredProviderResults, tickers); const candidateOptions = { includeOptionContracts, providerRanks: providerHints.ranks, providerPopularity: providerHints.popularity, }; const localItems = rankTickerSearchItems( createLocalTickerSearchCandidates(tickers.values(), providerHints.results, candidateOptions), query, ); const providerItems = createProviderTickerSearchCandidates(filteredProviderResults, tickers, candidateOptions); const ranked = rankTickerSearchItems([...localItems, ...providerItems], query); return limitTickerSearchCandidates(assignTickerSearchCategories(ranked), totalLimit, localLimit); } export async function resolveTickerSearch({ query, activeTicker, tickers, dataProvider, searchContext, }: { query?: string; activeTicker: string | null; tickers: ReadonlyMap; dataProvider: DataProvider; searchContext?: SearchRequestContext; }): Promise { const symbol = normalizeTickerInput(activeTicker, query); if (!symbol) return null; const local = tickers.get(symbol) ?? findExactTickerSearchMatch(createLocalTickerSearchCandidates(tickers.values()), symbol)?.ticker ?? null; if (local) { const contracts = new Map((local.metadata.broker_contracts ?? []).flatMap((brokerContract) => { const key = searchContractKey({ brokerContract, type: local.metadata.assetCategory || "" }); return key ? [[key, brokerContract] as const] : []; })); if (contracts.size > 1) throw new AmbiguousContractError(symbol, [...contracts.values()].map((contract) => tickerInstrumentLabel(local.metadata.ticker, contract))); return { kind: "local", symbol: local.metadata.ticker, ticker: local }; } const providerItems = createProviderTickerSearchCandidates( await searchProviderResults(dataProvider, symbol, searchContext), tickers, ); const literalMatches = providerItems.filter((item) => normalizeTickerSymbol(item.symbol) === symbol); const matches = literalMatches.length ? literalMatches : providerItems.filter((item) => findExactTickerSearchMatch([item], symbol)); let exactMatch = matches[0]; if (!exactMatch?.result) return null; const contracts = new Set(matches.map((item) => item.contractKey).filter(Boolean)); if (contracts.size > 1 || (contracts.size && matches.some((item) => !item.contractKey))) { throw new AmbiguousContractError(symbol, matches.map((item) => tickerInstrumentLabel(item.symbol, item.result?.brokerContract))); } const listings = new Set(matches.map((item) => publicTickerKey(item.symbol, listingExchange(item.result!)))); if (listings.size > 1 && !parsePublicTickerKey(symbol).exchange && !tickerHasYahooSuffix(symbol)) { // Search order is relevance, not a canonical listing identifier. Align bare // symbols with the quote source only when it supplies the exact identity. let verified: TickerSearchCandidate[] = []; try { const quote = await dataProvider.getQuote(symbol, "", { brokerId: searchContext?.brokerId, brokerInstanceId: searchContext?.brokerInstanceId, }); const exchange = canonicalExchange(quote.listingExchangeName || quote.exchangeName); const currency = resolveCurrencyUnit(quote.currency).currency; if (exchange && currency && Number.isFinite(quote.price) && quote.price !== 0 && Number.isFinite(quote.lastUpdated) && quote.lastUpdated > 0) { verified = matches.filter((item) => { const result = item.result!; const candidateCurrency = resolveCurrencyUnit(result.currency).currency; return canonicalExchange(listingExchange(result)) === exchange && (!candidateCurrency || candidateCurrency === currency) && findExactTickerSearchMatch([{ label: quote.symbol, right: exchange, instrumentType: quote.instrumentType }], item.symbol) && (!isCryptoInstrumentType(result.type) || quote.price > 0); }).map((item) => ({ ...item, result: { ...item.result!, currency } })); } } catch { // An unavailable quote cannot establish the default listing. } if (new Set(verified.map((item) => publicTickerKey(item.symbol, listingExchange(item.result!)))).size !== 1) { throw new AmbiguousTickerError(symbol, [...listings], Object.fromEntries(matches.map((item) => [ publicTickerKey(item.symbol, listingExchange(item.result!)), item.result!.name, ]))); } exactMatch = verified[0]!; } return { kind: "provider", symbol: exactMatch.symbol, result: exactMatch.result!, }; } function listingExchange(result: InstrumentSearchResult): string { return result.exchange === "SMART" ? result.primaryExchange || result.exchange : result.exchange || result.primaryExchange || ""; } function buildProviderHints( searchResults: InstrumentSearchResult[], localTickers: ReadonlyMap, ): { results: Map; ranks: Map; popularity: Map; } { const results = new Map(); const ranks = new Map(); const popularity = new Map(); for (const [rank, result] of searchResults.entries()) { const symbol = getSearchResultSymbol(result); if (!ranks.has(symbol)) ranks.set(symbol, rank); const score = searchResultPopularity(result); if (score != null) popularity.set(symbol, Math.max(score, popularity.get(symbol) ?? score)); const existing = results.get(symbol); const preferredExchange = localTickers.get(symbol)?.metadata.exchange; if ( !existing || getProviderHintScore(result, preferredExchange) > getProviderHintScore(existing, preferredExchange) ) { results.set(symbol, result); } } return { results, ranks, popularity }; } /** Older servers and other providers send no popularity; ignore anything else malformed. */ function searchResultPopularity(result: InstrumentSearchResult): number | undefined { return typeof result.popularity === "number" && Number.isFinite(result.popularity) ? result.popularity : undefined; } async function searchProviderResults( dataProvider: DataProvider, query: string, searchContext?: SearchRequestContext, onPartial?: (results: InstrumentSearchResult[]) => void, ): Promise { // A Map rather than a list plus a seen set, because a later source can send // back a richer version of a symbol already recorded. Overwriting a key keeps // its original position, so an upgrade does not reorder the list. const byKey = new Map(); const add = (results: InstrumentSearchResult[], upgrade = false) => { for (const result of results) { const key = buildProviderSearchResultKey(result); if (!upgrade && byKey.has(key)) continue; byKey.set(key, result); } }; // The variants are independent lookups of the same words, so they run // together. Awaited in turn they multiplied every per-source timeout by the // number of spellings tried. await Promise.all(buildProviderSearchQueries(query).map(async (searchQuery) => { try { const results = await dataProvider.search(searchQuery, { ...searchContext, ...(onPartial ? { onPartial: (upgraded: InstrumentSearchResult[]) => { add(upgraded, true); onPartial([...byKey.values()]); }, } : {}), }); add(results); } catch { // One spelling failing must not lose the others. } })); // Catalogues can omit exact market symbols or return a crypto pair from a // different venue. Verify the requested quote before accepting an alias. const requested = parsePublicTickerKey(normalizeTickerSymbol(query)); const possibleCryptoPair = /^[A-Z0-9]{1,15}-[A-Z]{3,5}$/.test(requested.symbol); if ((isExplicitMarketSymbol(query) || possibleCryptoPair) && !findExactTickerSearchMatch([...byKey.values()].map((result) => ({ label: getSearchResultSymbol(result), instrumentType: result.type, right: result.exchange, })), query)) { const { symbol, exchange } = requested; const marketType = /=F$/.test(symbol) ? "FUTURE" : /^(?:[A-Z]{3}(?:\/[A-Z]{3}|(?:[A-Z]{3})?=X))$/.test(symbol) ? "CURRENCY" : /^\^[A-Z0-9.-]+$/.test(symbol) ? "INDEX" : null; if (marketType || possibleCryptoPair) { try { const quote = await dataProvider.getQuote(symbol, exchange); const type = isCryptoInstrumentType(quote.instrumentType) ? quote.instrumentType : marketType; if (Number.isFinite(quote.price) && quote.price !== 0 && Number.isFinite(quote.lastUpdated) && quote.lastUpdated > 0 && quote.currency?.trim() && type && (!isCryptoInstrumentType(type) || quote.price > 0) && (!exchange || canonicalExchange(quote.listingExchangeName || quote.exchangeName) === exchange) && (possibleCryptoPair ? normalizeTickerSymbol(quote.symbol) === symbol : findExactTickerSearchMatch([{ label: quote.symbol }], symbol))) { add([{ providerId: quote.providerId || dataProvider.id, symbol, name: quote.name || symbol, exchange: exchange || quote.listingExchangeName || quote.exchangeName || "", currency: quote.currency, type, }]); } } catch { // An unverified symbol stays unresolved; never substitute a fuzzy equity. } } } return [...byKey.values()]; } function buildProviderSearchQueries(query: string): string[] { const trimmedQuery = query.trim(); if (!trimmedQuery) return []; const qualified = parsePublicTickerKey(trimmedQuery); if (qualified.exchange) return [trimmedQuery.toUpperCase(), qualified.symbol]; if (tickerHasYahooSuffix(trimmedQuery.toUpperCase())) return [trimmedQuery.toUpperCase()]; const symbolLike = /^[A-Za-z0-9.^=\-/]+$/.test(trimmedQuery); const queries = new Set(); if (!symbolLike) queries.add(trimmedQuery); for (const alias of buildSymbolAliases(trimmedQuery)) queries.add(alias); for (const alias of buildCompactShareClassAliases(trimmedQuery)) queries.add(alias); queries.add(trimmedQuery); return [...queries].slice(0, 4); } function buildCompactShareClassAliases(query: string): string[] { const normalized = normalizeTickerSymbol(query); if (!/^[A-Z]{4,5}$/.test(normalized)) return []; const shareClass = normalized.slice(-1); if (!SHARE_CLASS_SUFFIXES.has(shareClass)) return []; const base = normalized.slice(0, -1); if (base.length < 2) return []; return [`${base}-${shareClass}`, `${base}.${shareClass}`]; } function buildProviderSearchResultKey(result: InstrumentSearchResult): string { return searchInstrumentKey(result); } function buildProviderCandidateId(result: InstrumentSearchResult, symbol: string): string { return [ "search", normalizeTickerSymbol(symbol), normalizeSearchText(result.exchange || result.primaryExchange || result.type || ""), normalizeSearchText(result.currency || ""), normalizeSearchText(result.providerId || ""), searchContractKey(result), ].filter(Boolean).join(":"); } function getProviderHintRichness(result: InstrumentSearchResult): number { let score = 0; if (result.name) score += Math.min(120, result.name.length); if (result.exchange) score += 60; if (result.primaryExchange) score += 40; if (result.currency) score += 20; return score; } function resultVenues(result: InstrumentSearchResult): string[] { return [ result.exchange, result.primaryExchange, result.brokerContract?.exchange, result.brokerContract?.primaryExchange, ].map((exchange) => canonicalExchange(exchange)).filter(Boolean); } /** A result that names a listing venue, none of them the saved one. Broker * routing (SMART) names no venue. */ function isOtherVenue(result: InstrumentSearchResult, savedExchange: string): boolean { if (savedExchange === "SMART") return false; const venues = resultVenues(result).filter((exchange) => exchange !== "SMART"); return venues.length > 0 && !venues.includes(savedExchange); } function getProviderHintScore(result: InstrumentSearchResult, preferredExchange?: string): number { const canonicalPreferredExchange = canonicalExchange(preferredExchange); const matchesPreferredExchange = !!canonicalPreferredExchange && resultVenues(result).includes(canonicalPreferredExchange); return getProviderHintRichness(result) + (matchesPreferredExchange ? 10_000 : 0); } function assignTickerSearchCategories(items: T[]): T[] { let assignedPrimaryListing = false; return items.map((item) => { if (item.saved || item.kind === "ticker") { if (item.instrumentClass !== "fund" && item.instrumentClass !== "derivative") { assignedPrimaryListing = true; } return { ...item, category: "Saved" }; } if (item.instrumentClass === "fund" || item.instrumentClass === "derivative") { return { ...item, category: "Funds & Derivatives" }; } if (!assignedPrimaryListing) { assignedPrimaryListing = true; return { ...item, category: "Primary Listing" }; } return { ...item, category: "Other Listings" }; }) as T[]; } function limitTickerSearchCandidates( items: T[], totalLimit: number, savedLimit: number, ): T[] { const limited: T[] = []; let savedCount = 0; for (const item of items) { if (limited.length >= totalLimit) break; if (item.category === "Saved") { if (savedCount >= savedLimit) continue; savedCount += 1; } limited.push(item); } return limited; } function resolveLocalSearchName(ticker: TickerRecord, hint?: InstrumentSearchResult): string { if (hint?.name && isLowQualityTickerName(ticker.metadata.name, ticker.metadata.ticker)) { return hint.name; } return ticker.metadata.name; } function isOptionTickerRecord(ticker: TickerRecord): boolean { return isOptionType(ticker.metadata.assetCategory) || parseOptionSymbol(ticker.metadata.ticker) != null || (ticker.metadata.broker_contracts ?? []).some(isOptionBrokerContract); } function isOptionSearchResult(result: InstrumentSearchResult): boolean { return isOptionType(result.type) || parseOptionSymbol(result.symbol) != null || isOptionBrokerContract(result.brokerContract); } function isOptionBrokerContract(contract: InstrumentSearchResult["brokerContract"]): boolean { if (!contract) return false; return isOptionType(contract.secType) || parseOptionSymbol(contract.localSymbol || "") != null || contract.right === "C" || contract.right === "P" || contract.strike != null; } function isOptionType(rawType?: string): boolean { return OPTION_TYPES.has(normalizeSearchText(rawType || "")); } function buildSearchResultAliases(result: InstrumentSearchResult): string[] { const aliases = new Set(buildSymbolAliases(result.symbol)); const resolvedSymbol = getSearchResultSymbol(result); for (const alias of buildSymbolAliases(resolvedSymbol)) aliases.add(alias); if (result.brokerContract?.symbol) { for (const alias of buildSymbolAliases(result.brokerContract.symbol)) aliases.add(alias); } return [...aliases]; }