import type { TickerRecord } from "../../../../types/ticker"; import { createLocalTickerSearchCandidates, findExactTickerSearchMatch, type TickerSearchCandidate, } from "../../../../tickers/search"; import type { ResultItem } from "../../list/model"; import { canonicalExchange, parsePublicTickerKey } from "../../../../utils/exchanges"; import { isExplicitMarketSymbol } from "../../../../tickers/search/ranking"; export const QUICK_LOOK_TICKER_SEARCH_OPTIONS = { includeOptionContracts: false } as const; export function buildTickerSearchCacheKey( query: string, brokerId?: string | null, brokerInstanceId?: string | null, ): string { return [query.trim().toUpperCase(), brokerId || "", brokerInstanceId || ""].join("|"); } export function createQuickLookTickerCandidates(tickers: Iterable): TickerSearchCandidate[] { return createLocalTickerSearchCandidates(tickers, new Map(), QUICK_LOOK_TICKER_SEARCH_OPTIONS); } /** Provider type or saved asset category, whichever classified the candidate. */ function rawInstrumentType(candidate: Pick): string { return candidate.result?.brokerContract?.secType || candidate.result?.type || candidate.ticker?.metadata.assetCategory || ""; } /** * Class tag for the badge column. An unclassified instrument gets none: the * row lifts its exchange code there instead when the code is short enough. */ export function formatInstrumentBadge( candidate: Pick, ): string | undefined { switch (candidate.instrumentClass) { case "equity": return "EQ"; case "fund": return /\bET[FNP]\b/i.test(rawInstrumentType(candidate)) ? "ETF" : "FUND"; case "derivative": return "DERIV"; case "other": return undefined; } } export function normalizeCommandTickerSearchText(value: string): string { const normalized = value.trim().toUpperCase(); return isExplicitMarketSymbol(normalized) ? normalized : normalized.replace(/[^A-Z0-9]+/g, ""); } function isExactTickerResultMatch(item: ResultItem, query: string): boolean { if (item.kind !== "ticker" && item.kind !== "search") return false; return findExactTickerSearchMatch([item], query) != null || parsePublicTickerKey(item.label).symbol === query.trim().toUpperCase(); } export function mergeTickerSearchResultItems( query: string, rankedItems: ResultItem[], fallbackItems: ResultItem[], ): ResultItem[] { const merged: ResultItem[] = []; const seen = new Set(); const addItem = (item: ResultItem) => { if (item.kind === "info") return; const key = `${item.label.trim().toUpperCase()}:${(item.right || "").trim().toUpperCase()}:${item.contractKey ?? ""}`; if (seen.has(key)) return; seen.add(key); merged.push(item); }; rankedItems.forEach(addItem); fallbackItems.forEach(addItem); if (merged.length === 0) return rankedItems.length > 0 ? rankedItems : fallbackItems; return merged.map((item) => isExactTickerResultMatch(item, query) && item.category !== "Saved" ? { ...item, category: "Exact Match" } : item); } export const ROOT_INSTRUMENTS_CATEGORY = "Instruments"; /** Enough to surface the listing the user means without burying the sections below. */ const ROOT_INSTRUMENTS_LIMIT = 5; function isInstrumentItem(item: ResultItem): boolean { return item.kind === "ticker" || item.kind === "search"; } /** * Fold symbol-search rows into a plain root query's list. An exact symbol hit * is promoted ahead of everything, with distinct venues retained so a bare * symbol cannot hide another security. Non-exact matches collapse into one * Instruments section with one row per symbol. Info rows ("no matches", "search * failed") are dropped: the instruments are an extra here, never the answer. */ export function mergePlainRootTickerResults( query: string, providerItems: ResultItem[], rootItems: ResultItem[], ): ResultItem[] { const seenSymbols = new Set(); const instruments: ResultItem[] = []; const isExact = (item: ResultItem) => item.category === "Exact Match" || isExactTickerResultMatch(item, query); for (const item of providerItems) { if (!isInstrumentItem(item)) continue; const symbol = item.label.trim().toUpperCase(); const key = (isExact(item) ? `${symbol}:${canonicalExchange(item.right)}` : symbol) + (item.contractKey ? `:${item.contractKey}` : ""); if (seenSymbols.has(key)) continue; seenSymbols.add(key); instruments.push(item); if (instruments.length >= ROOT_INSTRUMENTS_LIMIT) break; } if (instruments.length === 0) return rootItems; return [ ...instruments.filter(isExact).map((item) => ({ ...item, category: "Exact Match" })), ...rootItems, ...instruments.filter((item) => !isExact(item)).map((item) => ({ ...item, category: ROOT_INSTRUMENTS_CATEGORY })), ]; }