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.
- package/LICENSE +1 -1
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/package.json +3 -5
- package/src/cli/entry.ts +11 -1
- package/src/cli/native-smoke.ts +5 -0
- package/src/components/command-bar/routes/root/provider-search.ts +7 -3
- package/src/components/command-bar/routes/ticker-search/results.test.ts +34 -0
- package/src/components/command-bar/routes/ticker-search/results.ts +4 -6
- package/src/components/command-bar/routes/ticker-search/route.ts +2 -2
- package/src/components/command-bar/routing/list-state.ts +2 -1
- package/src/components/command-bar/surface/index.test.tsx +65 -0
- package/src/components/command-bar/view-model.test.ts +24 -0
- package/src/components/command-bar/view-model.ts +2 -1
- package/src/plugins/builtin/econ/fred-cache.test.ts +8 -1
- package/src/plugins/builtin/help/index.tsx +1 -1
- package/src/plugins/ibkr/gateway/account-loaders.ts +1 -1
- package/src/renderers/electrobun/bun/application-menu/index.ts +1 -1
- package/src/renderers/opentui/dialog-host.tsx +279 -80
- package/src/renderers/opentui/test-utils.tsx +9 -6
- package/src/renderers/opentui/toast-host.tsx +136 -7
- package/src/renderers/opentui/ui-host.tsx +19 -3
- package/src/tickers/search/index.test.ts +284 -2
- package/src/tickers/search/index.ts +55 -18
- package/src/tickers/search/ranking.ts +143 -18
- package/src/tickers/search/types.ts +1 -0
- package/src/updater/github-releases.test.ts +3 -3
- package/src/updater/github-releases.ts +1 -1
|
@@ -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(
|
|
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(
|
|
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:
|
|
62
|
-
exchangeLabel
|
|
63
|
-
primaryExchangeLabel
|
|
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 =
|
|
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
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
111
|
-
+ scoreExchangePreference(intent, item)
|
|
112
|
-
|
|
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 ?
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
}
|