gloomberb 0.10.4 → 0.10.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,12 +8,17 @@ import {
8
8
  createLocalTickerSearchCandidates,
9
9
  findExactTickerSearchMatch,
10
10
  normalizeTickerInput,
11
+ rankTickerSearchItems,
11
12
  resolveTickerSearch,
12
13
  searchTickerCandidates,
13
14
  upsertTickerFromSearchResult,
14
15
  } from "./index";
15
16
 
16
- function makeTicker(symbol: string, name = symbol): TickerRecord {
17
+ function makeTicker(
18
+ symbol: string,
19
+ name = symbol,
20
+ overrides: Partial<TickerRecord["metadata"]> = {},
21
+ ): TickerRecord {
17
22
  return {
18
23
  metadata: {
19
24
  ticker: symbol,
@@ -25,17 +30,23 @@ function makeTicker(symbol: string, name = symbol): TickerRecord {
25
30
  positions: [],
26
31
  custom: {},
27
32
  tags: [],
33
+ ...overrides,
28
34
  },
29
35
  };
30
36
  }
31
37
 
32
- function makeSearchResult(symbol: string, name = symbol): InstrumentSearchResult {
38
+ function makeSearchResult(
39
+ symbol: string,
40
+ name = symbol,
41
+ overrides: Partial<InstrumentSearchResult> = {},
42
+ ): InstrumentSearchResult {
33
43
  return {
34
44
  providerId: "test",
35
45
  symbol,
36
46
  name,
37
47
  exchange: "NASDAQ",
38
48
  type: "EQUITY",
49
+ ...overrides,
39
50
  };
40
51
  }
41
52
 
@@ -106,6 +117,277 @@ describe("ticker-search utilities", () => {
106
117
  expect(findExactTickerSearchMatch(results, "AAPL")?.id).toBe("goto:AAPL");
107
118
  });
108
119
 
120
+ test("enriches duplicate provider symbols from the saved listing's exchange", () => {
121
+ const results = buildTickerSearchCandidates({
122
+ query: "Apple NASDAQ",
123
+ tickers: new Map<string, TickerRecord>([
124
+ ["APC", makeTicker("APC", "Apple Inc.", { exchange: "XETRA", assetCategory: "STK" })],
125
+ ["AAPL", makeTicker("AAPL", "Apple Inc.", { exchange: "NASDAQ", assetCategory: "STK" })],
126
+ ]),
127
+ providerResults: [
128
+ makeSearchResult("APC", "Apple Inc.", { exchange: "XETRA" }),
129
+ makeSearchResult("AAPL", "Apple Inc. CEDEAR", {
130
+ exchange: "BYMA",
131
+ primaryExchange: "BYMA",
132
+ currency: "ARS",
133
+ }),
134
+ makeSearchResult("AAPL", "Apple Inc.", {
135
+ exchange: "SMART",
136
+ brokerContract: {
137
+ brokerId: "test",
138
+ symbol: "AAPL",
139
+ exchange: "NASDAQ",
140
+ },
141
+ }),
142
+ ],
143
+ });
144
+
145
+ expect(results[0]).toMatchObject({
146
+ id: "goto:AAPL",
147
+ right: "NASDAQ",
148
+ exchangeLabel: "NASDAQ",
149
+ });
150
+ });
151
+
152
+ test("does not apply a conflicting provider primary exchange to a saved listing", () => {
153
+ const results = buildTickerSearchCandidates({
154
+ query: "Apple BUE",
155
+ tickers: new Map<string, TickerRecord>([[
156
+ "AAPL",
157
+ makeTicker("AAPL", "Apple Inc.", { exchange: "NASDAQ", assetCategory: "STK" }),
158
+ ]]),
159
+ providerResults: [
160
+ makeSearchResult("AAPL", "Apple Inc.", {
161
+ exchange: "SMART",
162
+ primaryExchange: "BUE",
163
+ }),
164
+ makeSearchResult("AAPLC.BA", "Apple Inc.", { exchange: "BUE" }),
165
+ ],
166
+ });
167
+
168
+ expect(results[0]?.symbol).toBe("AAPLC.BA");
169
+ expect(results.find((item) => item.symbol === "AAPL")?.primaryExchangeLabel).toBeUndefined();
170
+ });
171
+
172
+ test("uses provider ordering to prefer the canonical saved listing for company-name queries", () => {
173
+ const tickers = new Map<string, TickerRecord>([
174
+ ["APC", makeTicker("APC", "Apple Inc.", {
175
+ exchange: "XETRA",
176
+ currency: "EUR",
177
+ assetCategory: "STK",
178
+ })],
179
+ ["AAPL", makeTicker("AAPL", "Apple Inc.", {
180
+ exchange: "NASDAQ",
181
+ currency: "USD",
182
+ assetCategory: "STK",
183
+ })],
184
+ ]);
185
+
186
+ const results = buildTickerSearchCandidates({
187
+ query: "Apple",
188
+ tickers,
189
+ providerResults: [
190
+ makeSearchResult("AAPL", "Apple Inc.", {
191
+ exchange: "NASDAQ",
192
+ primaryExchange: "NASDAQ",
193
+ currency: "USD",
194
+ }),
195
+ makeSearchResult("APC", "Apple Inc.", {
196
+ exchange: "XETRA",
197
+ primaryExchange: "XETRA",
198
+ currency: "EUR",
199
+ }),
200
+ ],
201
+ });
202
+
203
+ expect(results.slice(0, 2).map((item) => item.id)).toEqual([
204
+ "goto:AAPL",
205
+ "goto:APC",
206
+ ]);
207
+ expect(results.slice(0, 2).map((item) => item.category)).toEqual([
208
+ "Saved",
209
+ "Saved",
210
+ ]);
211
+ });
212
+
213
+ test("keeps explicit symbol, exchange, and asset-class intent ahead of provider ordering", () => {
214
+ const tickers = new Map<string, TickerRecord>([
215
+ ["APC", makeTicker("APC", "Apple Inc.", {
216
+ exchange: "XETRA",
217
+ currency: "EUR",
218
+ assetCategory: "STK",
219
+ })],
220
+ ["AAPL", makeTicker("AAPL", "Apple Inc.", {
221
+ exchange: "NASDAQ",
222
+ currency: "USD",
223
+ assetCategory: "STK",
224
+ })],
225
+ ]);
226
+ const providerResults = [
227
+ makeSearchResult("AAPL", "Apple Inc.", { exchange: "NASDAQ" }),
228
+ makeSearchResult("APC", "Apple Inc.", { exchange: "XETRA", currency: "EUR" }),
229
+ makeSearchResult("APLY", "Yield Strategy Linked to Apple ETF", { exchange: "NYSE Arca", type: "ETF" }),
230
+ makeSearchResult("APP", "AppLovin Corporation", { exchange: "NASDAQ" }),
231
+ ];
232
+ const firstSymbolFor = (query: string) => buildTickerSearchCandidates({
233
+ query,
234
+ tickers,
235
+ providerResults,
236
+ })[0]?.symbol;
237
+
238
+ expect(firstSymbolFor("APC")).toBe("APC");
239
+ expect(firstSymbolFor("APP")).toBe("APP");
240
+ expect(firstSymbolFor("Apple XETRA")).toBe("APC");
241
+ expect(firstSymbolFor("Apple NASDAQ")).toBe("AAPL");
242
+ expect(firstSymbolFor("Apple ETF")).toBe("APLY");
243
+ });
244
+
245
+ test("uses provider ordering without assuming the canonical listing is on a US exchange", () => {
246
+ const results = buildTickerSearchCandidates({
247
+ query: "Toyota",
248
+ tickers: new Map<string, TickerRecord>([[
249
+ "TM",
250
+ makeTicker("TM", "Toyota Motor Corporation", {
251
+ exchange: "NYSE",
252
+ currency: "USD",
253
+ assetCategory: "STK",
254
+ }),
255
+ ]]),
256
+ providerResults: [
257
+ makeSearchResult("7203", "Toyota Motor Corp", {
258
+ exchange: "Tokyo Stock Exchange",
259
+ primaryExchange: "Tokyo Stock Exchange",
260
+ currency: "JPY",
261
+ }),
262
+ makeSearchResult("TM", "Toyota Motor Corporation", {
263
+ exchange: "NYSE",
264
+ primaryExchange: "NYSE",
265
+ currency: "USD",
266
+ }),
267
+ makeSearchResult("TOYOTA80.BK", "TOYOTA80 DR", {
268
+ exchange: "SET",
269
+ currency: "THB",
270
+ }),
271
+ ],
272
+ });
273
+
274
+ expect(results.slice(0, 2).map((item) => [item.symbol, item.category])).toEqual([
275
+ ["7203", "Primary Listing"],
276
+ ["TM", "Saved"],
277
+ ]);
278
+ });
279
+
280
+ test("does not let provider order or an accidental symbol prefix overwhelm a closer company match", () => {
281
+ const results = buildTickerSearchCandidates({
282
+ query: "Apple",
283
+ tickers: new Map<string, TickerRecord>(),
284
+ providerResults: [
285
+ makeSearchResult("APLE", "Apple Hospitality REIT, Inc.", { exchange: "NYSE" }),
286
+ makeSearchResult("AAPL", "Apple Inc.", { exchange: "NASDAQ" }),
287
+ makeSearchResult("APPLE80.BK", "APPLE80 DR", { exchange: "SET", currency: "THB" }),
288
+ ],
289
+ });
290
+
291
+ expect(results[0]?.symbol).toBe("AAPL");
292
+ expect(results.some((item) => item.symbol === "APPLE80.BK")).toBe(true);
293
+ });
294
+
295
+ test("normalizes legal suffixes in literal company-name queries", () => {
296
+ const cases = [
297
+ {
298
+ query: "Apple Inc",
299
+ canonical: makeSearchResult("AAPL", "Apple Inc.", { exchange: "NASDAQ" }),
300
+ alternate: makeSearchResult("AAPL-ADR", "Apple Inc. ADR", { exchange: "OTC" }),
301
+ },
302
+ {
303
+ query: "Toyota Motor Corporation",
304
+ canonical: makeSearchResult("TM", "Toyota Motor Corporation", { exchange: "NYSE" }),
305
+ alternate: makeSearchResult("TM-ADR", "Toyota Motor Corporation ADR", { exchange: "OTC" }),
306
+ },
307
+ ];
308
+
309
+ for (const { query, canonical, alternate } of cases) {
310
+ const results = buildTickerSearchCandidates({
311
+ query,
312
+ tickers: new Map<string, TickerRecord>(),
313
+ providerResults: [alternate, canonical],
314
+ });
315
+
316
+ expect(results[0]?.symbol).toBe(canonical.symbol);
317
+ }
318
+ });
319
+
320
+ test("keeps same-issuer provider ordering transitive across ambiguous company matches", () => {
321
+ const candidates = [
322
+ {
323
+ id: "big-apple-primary",
324
+ label: "ZZZ",
325
+ symbol: "ZZZ",
326
+ detail: "Big Apple",
327
+ right: "NYSE",
328
+ category: "Other Listings",
329
+ kind: "search" as const,
330
+ instrumentClass: "equity" as const,
331
+ providerRank: 1,
332
+ },
333
+ {
334
+ id: "big-apple-alternate",
335
+ label: "APPLEB",
336
+ symbol: "APPLEB",
337
+ detail: "Big Apple",
338
+ right: "NASDAQ",
339
+ category: "Other Listings",
340
+ kind: "search" as const,
341
+ instrumentClass: "equity" as const,
342
+ providerRank: 2,
343
+ },
344
+ {
345
+ id: "unrelated-apple",
346
+ label: "XAPL",
347
+ symbol: "XAPL",
348
+ detail: "X Apple",
349
+ right: "NYSE",
350
+ category: "Other Listings",
351
+ kind: "search" as const,
352
+ instrumentClass: "equity" as const,
353
+ providerRank: 0,
354
+ },
355
+ ];
356
+
357
+ const expected = ["ZZZ", "APPLEB", "XAPL"];
358
+ expect(rankTickerSearchItems(candidates, "Apple").map((item) => item.symbol)).toEqual(expected);
359
+ expect(rankTickerSearchItems([...candidates].reverse(), "Apple").map((item) => item.symbol)).toEqual(expected);
360
+ });
361
+
362
+ test("normalizes punctuated legal suffixes before applying same-issuer provider order", () => {
363
+ const results = buildTickerSearchCandidates({
364
+ query: "Acme",
365
+ tickers: new Map<string, TickerRecord>(),
366
+ providerResults: [
367
+ makeSearchResult("AMS.MC", "Acme S.A.", { exchange: "BME" }),
368
+ makeSearchResult("ACM", "Acme SA", { exchange: "NYSE" }),
369
+ ],
370
+ });
371
+
372
+ expect(results.slice(0, 2).map((item) => item.symbol)).toEqual(["AMS.MC", "ACM"]);
373
+ });
374
+
375
+ test("keeps saved funds in the saved category", () => {
376
+ const results = buildTickerSearchCandidates({
377
+ query: "Vanguard",
378
+ tickers: new Map<string, TickerRecord>([[
379
+ "VTI",
380
+ makeTicker("VTI", "Vanguard Total Stock Market ETF", {
381
+ exchange: "NYSE Arca",
382
+ assetCategory: "ETF",
383
+ }),
384
+ ]]),
385
+ providerResults: [],
386
+ });
387
+
388
+ expect(results[0]).toMatchObject({ symbol: "VTI", category: "Saved" });
389
+ });
390
+
109
391
  test("preserves exchange-qualified symbols and groups provider listings intuitively", () => {
110
392
  const results = buildTickerSearchCandidates({
111
393
  query: "apple",
@@ -1,6 +1,7 @@
1
1
  import type { SearchRequestContext, DataProvider } from "../../types/data-provider";
2
2
  import type { InstrumentSearchResult } from "../../types/instrument";
3
3
  import type { TickerRecord } from "../../types/ticker";
4
+ import { canonicalExchange } from "../../utils/exchanges";
4
5
  import { parseOptionSymbol } from "../../utils/options";
5
6
  import {
6
7
  buildSymbolAliases,
@@ -36,6 +37,7 @@ const SHARE_CLASS_SUFFIXES = new Set(["A", "B", "C", "D", "K"]);
36
37
 
37
38
  interface TickerSearchCandidateOptions {
38
39
  includeOptionContracts?: boolean;
40
+ providerRanks?: ReadonlyMap<string, number>;
39
41
  }
40
42
 
41
43
  export function normalizeTickerInput(activeTicker: string | null, arg?: string): string | null {
@@ -53,14 +55,21 @@ export function createLocalTickerSearchCandidates(
53
55
  if (options.includeOptionContracts === false && isOptionTickerRecord(ticker)) return [];
54
56
  const symbol = normalizeTickerSymbol(ticker.metadata.ticker);
55
57
  const hint = providerHints.get(symbol);
58
+ const exchangeLabel = ticker.metadata.exchange || hint?.exchange || hint?.primaryExchange;
59
+ const savedExchange = canonicalExchange(ticker.metadata.exchange);
60
+ const hintPrimaryExchange = canonicalExchange(hint?.primaryExchange);
61
+ const primaryExchangeLabel = !savedExchange || !hintPrimaryExchange || savedExchange === hintPrimaryExchange
62
+ ? hint?.primaryExchange
63
+ : undefined;
56
64
  return [{
57
65
  id: `goto:${ticker.metadata.ticker}`,
58
66
  label: ticker.metadata.ticker,
59
67
  symbol,
60
68
  detail: resolveLocalSearchName(ticker, hint),
61
- right: hint?.exchange || ticker.metadata.exchange,
62
- exchangeLabel: hint?.exchange || ticker.metadata.exchange,
63
- primaryExchangeLabel: hint?.primaryExchange,
69
+ right: exchangeLabel,
70
+ exchangeLabel,
71
+ primaryExchangeLabel,
72
+ providerRank: options.providerRanks?.get(symbol),
64
73
  category: "Saved",
65
74
  kind: "ticker",
66
75
  saved: true,
@@ -77,7 +86,7 @@ function createProviderTickerSearchCandidates(
77
86
  localTickers: ReadonlyMap<string, TickerRecord>,
78
87
  options: TickerSearchCandidateOptions = {},
79
88
  ): TickerSearchCandidate[] {
80
- return searchResults.flatMap((result) => {
89
+ return searchResults.flatMap((result, providerRank) => {
81
90
  if (options.includeOptionContracts === false && isOptionSearchResult(result)) return [];
82
91
  const symbol = getSearchResultSymbol(result);
83
92
  const saved = localTickers.has(symbol);
@@ -89,6 +98,7 @@ function createProviderTickerSearchCandidates(
89
98
  right: result.exchange || result.primaryExchange || result.type || undefined,
90
99
  exchangeLabel: result.exchange,
91
100
  primaryExchangeLabel: result.primaryExchange,
101
+ providerRank,
92
102
  category: saved ? "Saved" : "Other Listings",
93
103
  kind: "search",
94
104
  saved,
@@ -141,13 +151,16 @@ export function buildTickerSearchCandidates({
141
151
  totalLimit?: number;
142
152
  includeOptionContracts?: boolean;
143
153
  }): TickerSearchCandidate[] {
144
- const candidateOptions = { includeOptionContracts };
145
154
  const filteredProviderResults = includeOptionContracts
146
155
  ? providerResults
147
156
  : providerResults.filter((result) => !isOptionSearchResult(result));
148
- const providerHints = buildProviderHintMap(filteredProviderResults);
157
+ const providerHints = buildProviderHints(filteredProviderResults, tickers);
158
+ const candidateOptions = {
159
+ includeOptionContracts,
160
+ providerRanks: providerHints.ranks,
161
+ };
149
162
  const localItems = rankTickerSearchItems(
150
- createLocalTickerSearchCandidates(tickers.values(), providerHints, candidateOptions),
163
+ createLocalTickerSearchCandidates(tickers.values(), providerHints.results, candidateOptions),
151
164
  query,
152
165
  );
153
166
  const providerItems = createProviderTickerSearchCandidates(filteredProviderResults, tickers, candidateOptions);
@@ -192,16 +205,28 @@ export async function resolveTickerSearch({
192
205
  };
193
206
  }
194
207
 
195
- function buildProviderHintMap(searchResults: InstrumentSearchResult[]): Map<string, InstrumentSearchResult> {
196
- const hints = new Map<string, InstrumentSearchResult>();
197
- for (const result of searchResults) {
208
+ function buildProviderHints(
209
+ searchResults: InstrumentSearchResult[],
210
+ localTickers: ReadonlyMap<string, TickerRecord>,
211
+ ): {
212
+ results: Map<string, InstrumentSearchResult>;
213
+ ranks: Map<string, number>;
214
+ } {
215
+ const results = new Map<string, InstrumentSearchResult>();
216
+ const ranks = new Map<string, number>();
217
+ for (const [rank, result] of searchResults.entries()) {
198
218
  const symbol = getSearchResultSymbol(result);
199
- const existing = hints.get(symbol);
200
- if (!existing || getProviderHintRichness(result) > getProviderHintRichness(existing)) {
201
- hints.set(symbol, result);
219
+ if (!ranks.has(symbol)) ranks.set(symbol, rank);
220
+ const existing = results.get(symbol);
221
+ const preferredExchange = localTickers.get(symbol)?.metadata.exchange;
222
+ if (
223
+ !existing
224
+ || getProviderHintScore(result, preferredExchange) > getProviderHintScore(existing, preferredExchange)
225
+ ) {
226
+ results.set(symbol, result);
202
227
  }
203
228
  }
204
- return hints;
229
+ return { results, ranks };
205
230
  }
206
231
 
207
232
  async function searchProviderResults(
@@ -284,14 +309,26 @@ function getProviderHintRichness(result: InstrumentSearchResult): number {
284
309
  return score;
285
310
  }
286
311
 
312
+ function getProviderHintScore(result: InstrumentSearchResult, preferredExchange?: string): number {
313
+ const canonicalPreferredExchange = canonicalExchange(preferredExchange);
314
+ const matchesPreferredExchange = canonicalPreferredExchange
315
+ && [
316
+ result.exchange,
317
+ result.primaryExchange,
318
+ result.brokerContract?.exchange,
319
+ result.brokerContract?.primaryExchange,
320
+ ].some((exchange) => canonicalExchange(exchange) === canonicalPreferredExchange);
321
+ return getProviderHintRichness(result) + (matchesPreferredExchange ? 10_000 : 0);
322
+ }
323
+
287
324
  function assignTickerSearchCategories<T extends TickerSearchCandidate>(items: T[]): T[] {
288
- const hasSavedListing = items.some((item) =>
289
- item.saved && item.instrumentClass !== "fund" && item.instrumentClass !== "derivative"
290
- );
291
- let assignedPrimaryListing = hasSavedListing;
325
+ let assignedPrimaryListing = false;
292
326
 
293
327
  return items.map((item) => {
294
328
  if (item.saved || item.kind === "ticker") {
329
+ if (item.instrumentClass !== "fund" && item.instrumentClass !== "derivative") {
330
+ assignedPrimaryListing = true;
331
+ }
295
332
  return { ...item, category: "Saved" };
296
333
  }
297
334
  if (item.instrumentClass === "fund" || item.instrumentClass === "derivative") {
@@ -6,6 +6,23 @@ import type {
6
6
  const FUND_TYPES = new Set(["ETF", "ETN", "ETP", "FUND", "MUTUALFUND", "CEF", "CLOSEDEND"]);
7
7
  const DERIVATIVE_TYPES = new Set(["OPT", "OPTION", "OPTIONS", "FUT", "FUTURE", "FUTURES", "WARRANT", "WARRANTS", "RIGHT", "RIGHTS"]);
8
8
  const EQUITY_TYPES = new Set(["STK", "STOCK", "EQUITY", "COMMONSTOCK", "COMMON STOCK", "ADR", "ORDINARYSHARES", "ORDINARY SHARES"]);
9
+ const COMPANY_NAME_SUFFIXES = new Set([
10
+ "AG",
11
+ "CO",
12
+ "COMPANY",
13
+ "CORP",
14
+ "CORPORATION",
15
+ "INC",
16
+ "INCORPORATED",
17
+ "LTD",
18
+ "LIMITED",
19
+ "NV",
20
+ "PLC",
21
+ "R",
22
+ "REGISTERED",
23
+ "SA",
24
+ "SE",
25
+ ]);
9
26
 
10
27
  const EXCHANGE_HINT_ALIASES: Record<string, string[]> = {
11
28
  NASDAQ: ["NASDAQ", "NMS"],
@@ -46,10 +63,13 @@ const ASSET_HINT_MAP: Record<string, TickerSearchInstrumentClass> = {
46
63
  FUTURES: "derivative",
47
64
  };
48
65
 
66
+ const SAVED_MATCH_BONUS = 900;
67
+
49
68
  interface SearchQueryIntent {
50
69
  normalizedQuery: string;
51
70
  compactQuery: string;
52
71
  companyQuery: string;
72
+ companyQueryKey: string;
53
73
  exchangeHints: string[];
54
74
  assetPreference: TickerSearchInstrumentClass | null;
55
75
  }
@@ -82,6 +102,7 @@ export function rankTickerSearchItems<T extends Pick<TickerSearchRankableItem, "
82
102
  const normalizedLabel = normalizeSearchText(item.label);
83
103
  const normalizedDetail = normalizeSearchText(item.detail);
84
104
  const normalizedRight = normalizeSearchText(item.right || "");
105
+ const companyNameKey = getCompanyNameKey(item.detail);
85
106
  const textQueries = [intent.normalizedQuery, intent.companyQuery].filter(Boolean);
86
107
  const labelScore = maxScoreForQueries(textQueries, normalizedLabel, {
87
108
  exact: 24_000,
@@ -107,16 +128,23 @@ export function rankTickerSearchItems<T extends Pick<TickerSearchRankableItem, "
107
128
  .reduce((best, alias) => Math.max(best, scoreSearchAlias(intent, alias)), 0);
108
129
  const textScore = labelScore + detailScore + aliasScore;
109
130
  const saved = isSavedSearchItem(item);
110
- const priorityScore = scoreAssetPreference(intent, item.instrumentClass)
111
- + scoreExchangePreference(intent, item)
112
- + scoreListingPriority(item);
131
+ const explicitIntentScore = scoreAssetPreference(intent, item.instrumentClass)
132
+ + scoreExchangePreference(intent, item);
133
+ const priorityScore = explicitIntentScore + scoreListingPriority(item);
134
+ const companyMatchRank = scoreCompanyNameMatch(intent, companyNameKey);
113
135
  return {
114
136
  item,
115
137
  index,
138
+ companyMatchRank,
139
+ companyNameKey,
140
+ issuerGroupKey: companyMatchRank > 0 && item.instrumentClass === "equity"
141
+ ? companyNameKey
142
+ : null,
143
+ explicitIntentScore,
116
144
  normalizedSymbol: normalizeSearchText(item.symbol || item.label),
117
145
  symbolMatchRank: scoreSymbolMatchRank(intent, item),
118
146
  textScore,
119
- score: textScore + priorityScore + (textScore > 0 && saved ? 900 : 0),
147
+ score: textScore + priorityScore + (textScore > 0 && saved ? SAVED_MATCH_BONUS : 0),
120
148
  };
121
149
  });
122
150
 
@@ -126,21 +154,83 @@ export function rankTickerSearchItems<T extends Pick<TickerSearchRankableItem, "
126
154
  .map(({ normalizedSymbol }) => normalizedSymbol),
127
155
  );
128
156
 
129
- const filtered = ranked
130
- .filter(({ item, normalizedSymbol, textScore }) => {
131
- if (textScore <= 0) return false;
132
- if (item.kind !== "search") return true;
133
- return !matchedLocalSymbols.has(normalizedSymbol);
134
- })
135
- .sort((a, b) => {
136
- if (b.symbolMatchRank !== a.symbolMatchRank) return b.symbolMatchRank - a.symbolMatchRank;
137
- if (b.score !== a.score) return b.score - a.score;
138
- const aSaved = isSavedSearchItem(a.item);
139
- const bSaved = isSavedSearchItem(b.item);
140
- if (aSaved !== bSaved) return aSaved ? -1 : 1;
141
- if (a.item.label.length !== b.item.label.length) return a.item.label.length - b.item.label.length;
142
- return a.index - b.index;
157
+ const filtered = ranked.filter(({ item, normalizedSymbol, textScore }) => {
158
+ if (textScore <= 0) return false;
159
+ if (item.kind !== "search") return true;
160
+ return !matchedLocalSymbols.has(normalizedSymbol);
161
+ });
162
+
163
+ type RankedEntry = (typeof ranked)[number];
164
+ const compareFallbackEntries = (a: RankedEntry, b: RankedEntry): number => {
165
+ if (b.symbolMatchRank !== a.symbolMatchRank) return b.symbolMatchRank - a.symbolMatchRank;
166
+ if (b.score !== a.score) return b.score - a.score;
167
+ const aSaved = isSavedSearchItem(a.item);
168
+ const bSaved = isSavedSearchItem(b.item);
169
+ if (aSaved !== bSaved) return aSaved ? -1 : 1;
170
+ if (a.item.label.length !== b.item.label.length) return a.item.label.length - b.item.label.length;
171
+ return a.index - b.index;
172
+ };
173
+
174
+ const hasExplicitHint = intent.exchangeHints.length > 0 || intent.assetPreference != null;
175
+ const bucketGroups = new Map<string, Map<string, RankedEntry[]>>();
176
+ for (const entry of filtered) {
177
+ const exactSymbolRank = entry.symbolMatchRank >= 2 ? entry.symbolMatchRank : 0;
178
+ const bucketKey = [
179
+ exactSymbolRank,
180
+ hasExplicitHint ? entry.explicitIntentScore : 0,
181
+ entry.companyMatchRank,
182
+ ].join(":");
183
+ const groupKey = entry.issuerGroupKey
184
+ ? `issuer:${entry.issuerGroupKey}`
185
+ : `item:${entry.index}`;
186
+ const groups = bucketGroups.get(bucketKey) ?? new Map<string, RankedEntry[]>();
187
+ const group = groups.get(groupKey) ?? [];
188
+ group.push(entry);
189
+ groups.set(groupKey, group);
190
+ bucketGroups.set(bucketKey, groups);
191
+ }
192
+
193
+ const groupOrderByIndex = new Map<number, number>();
194
+ for (const groups of bucketGroups.values()) {
195
+ const orderedGroups = [...groups.values()]
196
+ .map((entries) => ({
197
+ entries,
198
+ representative: entries.reduce((best, entry) =>
199
+ compareFallbackEntries(entry, best) < 0 ? entry : best
200
+ ),
201
+ }))
202
+ .sort((a, b) => compareFallbackEntries(a.representative, b.representative));
203
+ orderedGroups.forEach(({ entries }, order) => {
204
+ entries.forEach((entry) => groupOrderByIndex.set(entry.index, order));
143
205
  });
206
+ }
207
+
208
+ filtered.sort((a, b) => {
209
+ // Precedence is exact symbol, explicit query hints, whole-word company
210
+ // relevance, issuer relevance, provider order within that issuer, partial
211
+ // symbol, saved relevance, then stable source order.
212
+ const aExactSymbolRank = a.symbolMatchRank >= 2 ? a.symbolMatchRank : 0;
213
+ const bExactSymbolRank = b.symbolMatchRank >= 2 ? b.symbolMatchRank : 0;
214
+ if (bExactSymbolRank !== aExactSymbolRank) return bExactSymbolRank - aExactSymbolRank;
215
+ if (hasExplicitHint && b.explicitIntentScore !== a.explicitIntentScore) {
216
+ return b.explicitIntentScore - a.explicitIntentScore;
217
+ }
218
+ if (b.companyMatchRank !== a.companyMatchRank) {
219
+ return b.companyMatchRank - a.companyMatchRank;
220
+ }
221
+ const aGroupOrder = groupOrderByIndex.get(a.index) ?? a.index;
222
+ const bGroupOrder = groupOrderByIndex.get(b.index) ?? b.index;
223
+ if (aGroupOrder !== bGroupOrder) return aGroupOrder - bGroupOrder;
224
+ if (
225
+ a.issuerGroupKey
226
+ && a.issuerGroupKey === b.issuerGroupKey
227
+ ) {
228
+ const aProviderRank = a.item.providerRank ?? Number.POSITIVE_INFINITY;
229
+ const bProviderRank = b.item.providerRank ?? Number.POSITIVE_INFINITY;
230
+ if (aProviderRank !== bProviderRank) return aProviderRank - bProviderRank;
231
+ }
232
+ return compareFallbackEntries(a, b);
233
+ });
144
234
 
145
235
  const deduped: T[] = [];
146
236
  const seen = new Set<string>();
@@ -216,6 +306,7 @@ function analyzeSearchQuery(query: string): SearchQueryIntent {
216
306
  normalizedQuery,
217
307
  compactQuery,
218
308
  companyQuery,
309
+ companyQueryKey: normalizeCompanyName(companyQuery),
219
310
  exchangeHints,
220
311
  assetPreference,
221
312
  };
@@ -356,6 +447,40 @@ function scoreListingPriority(item: Pick<TickerSearchRankableItem, "label"> & Pa
356
447
  return score;
357
448
  }
358
449
 
450
+ function getCompanyNameKey(detail: string): string {
451
+ return normalizeCompanyName(detail.split("|")[0] || "");
452
+ }
453
+
454
+ function normalizeCompanyName(name: string): string {
455
+ const tokens = normalizeSearchText(name).split(" ").filter(Boolean);
456
+ while (tokens.length > 1) {
457
+ if (COMPANY_NAME_SUFFIXES.has(tokens.at(-1)!)) {
458
+ tokens.pop();
459
+ continue;
460
+ }
461
+
462
+ const punctuatedSuffix = [...COMPANY_NAME_SUFFIXES].find((suffix) =>
463
+ suffix.length > 1
464
+ && tokens.length > suffix.length
465
+ && tokens.slice(-suffix.length).every((token, index) =>
466
+ token.length === 1 && token === suffix[index]
467
+ )
468
+ );
469
+ if (!punctuatedSuffix) break;
470
+ tokens.splice(-punctuatedSuffix.length);
471
+ }
472
+ return tokens.join(" ");
473
+ }
474
+
475
+ function scoreCompanyNameMatch(intent: SearchQueryIntent, companyName: string): number {
476
+ const query = intent.companyQueryKey;
477
+ if (!query || !companyName) return 0;
478
+ if (companyName === query) return 3;
479
+ if (companyName.startsWith(`${query} `)) return 2;
480
+ if (companyName.includes(` ${query} `) || companyName.endsWith(` ${query}`)) return 1;
481
+ return 0;
482
+ }
483
+
359
484
  function isSavedSearchItem(item: Pick<TickerSearchRankableItem, "kind" | "category"> & Partial<TickerSearchRankableItem>): boolean {
360
485
  return item.saved === true || item.kind === "ticker" || item.category === "Saved" || item.category === "Open";
361
486
  }