gloomberb 0.7.2 → 0.7.4

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.
Files changed (167) hide show
  1. package/README.md +79 -132
  2. package/package.json +1 -1
  3. package/src/app.tsx +73 -67
  4. package/src/components/chart/chart-data.ts +7 -2
  5. package/src/components/chart/chart-pane-settings.ts +7 -4
  6. package/src/components/chart/chart-renderer.ts +158 -47
  7. package/src/components/chart/chart-types.ts +20 -2
  8. package/src/components/chart/chart.test.ts +179 -13
  9. package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
  10. package/src/components/chart/comparison-chart-renderer.ts +50 -11
  11. package/src/components/chart/comparison-stock-chart.tsx +129 -129
  12. package/src/components/chart/market-session.ts +187 -0
  13. package/src/components/chart/native/chart-rasterizer.ts +108 -19
  14. package/src/components/chart/price-axis-labels.tsx +25 -9
  15. package/src/components/chart/stock-chart-renderer-switch.test.tsx +97 -1
  16. package/src/components/chart/stock-chart.tsx +95 -49
  17. package/src/components/chart/time-axis-label.tsx +56 -2
  18. package/src/components/command-bar/command-bar.test.tsx +446 -120
  19. package/src/components/command-bar/command-bar.tsx +535 -384
  20. package/src/components/command-bar/command-registry.ts +16 -6
  21. package/src/components/command-bar/list-model.ts +8 -4
  22. package/src/components/command-bar/root-shortcuts.ts +4 -5
  23. package/src/components/command-bar/view-model.test.ts +21 -0
  24. package/src/components/command-bar/view-model.ts +17 -4
  25. package/src/components/command-bar/workflow-ops.ts +11 -32
  26. package/src/components/data-table-view.test.tsx +24 -1
  27. package/src/components/data-table-view.tsx +3 -2
  28. package/src/components/feed-data-table-stack-view.tsx +3 -2
  29. package/src/components/layout/detached-pane-shell.tsx +2 -2
  30. package/src/components/layout/floating-pane.tsx +9 -2
  31. package/src/components/layout/pane-footer.tsx +1 -1
  32. package/src/components/layout/pane-header.tsx +4 -2
  33. package/src/components/layout/pane-sizing.ts +12 -0
  34. package/src/components/layout/pane.tsx +24 -2
  35. package/src/components/layout/shell.tsx +24 -10
  36. package/src/components/layout/status-bar.test.tsx +4 -1
  37. package/src/components/layout/status-bar.tsx +4 -24
  38. package/src/components/markdown-text.tsx +53 -8
  39. package/src/components/onboarding/onboarding-wizard.tsx +7 -6
  40. package/src/components/pane-settings-dialog.tsx +9 -6
  41. package/src/components/pane-template-wizard.tsx +3 -2
  42. package/src/components/price-selector-dialog.tsx +3 -2
  43. package/src/components/price-sparkline-view.tsx +463 -0
  44. package/src/components/price-sparkline.ts +46 -0
  45. package/src/components/table-view-shared.tsx +21 -3
  46. package/src/components/ticker-list-table-view.tsx +3 -2
  47. package/src/components/ticker-list-table.tsx +15 -66
  48. package/src/components/toggle-list.tsx +3 -3
  49. package/src/components/ui/choice-dialog.tsx +3 -2
  50. package/src/components/ui/multi-select-dialog.tsx +3 -2
  51. package/src/components/ui/native-select.tsx +3 -3
  52. package/src/core/state/app-state.ts +210 -80
  53. package/src/core/state/session-persistence.ts +25 -0
  54. package/src/data/config-store-node.ts +113 -25
  55. package/src/data/config-store.test.ts +65 -3
  56. package/src/data/plugin-state-store.ts +39 -13
  57. package/src/data/sqlite-cache.test.ts +13 -0
  58. package/src/market-data/coordinator-subscriptions.test.ts +102 -18
  59. package/src/market-data/coordinator.test.ts +122 -2
  60. package/src/market-data/coordinator.ts +200 -56
  61. package/src/market-data/hooks.test.tsx +31 -0
  62. package/src/market-data/hooks.tsx +1 -0
  63. package/src/news/aggregator.test.ts +41 -0
  64. package/src/news/aggregator.ts +61 -9
  65. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
  66. package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
  67. package/src/plugins/builtin/ai/settings.ts +3 -0
  68. package/src/plugins/builtin/broker-manager/index.tsx +4 -3
  69. package/src/plugins/builtin/chat-controller.test.ts +398 -17
  70. package/src/plugins/builtin/chat-controller.ts +263 -48
  71. package/src/plugins/builtin/chat.test.tsx +312 -6
  72. package/src/plugins/builtin/chat.tsx +245 -66
  73. package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
  74. package/src/plugins/builtin/correlation/colors.test.ts +39 -0
  75. package/src/plugins/builtin/correlation/colors.ts +87 -0
  76. package/src/plugins/builtin/correlation/index.test.tsx +151 -0
  77. package/src/plugins/builtin/correlation/index.tsx +92 -21
  78. package/src/plugins/builtin/debug.tsx +4 -3
  79. package/src/plugins/builtin/econ/index.tsx +3 -2
  80. package/src/plugins/builtin/help.test.tsx +2 -2
  81. package/src/plugins/builtin/holders/index.tsx +25 -13
  82. package/src/plugins/builtin/layout-manager.tsx +1 -7
  83. package/src/plugins/builtin/market-movers/index.tsx +72 -40
  84. package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
  85. package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
  86. package/src/plugins/builtin/news-wire/index.tsx +1 -3
  87. package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
  88. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
  89. package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
  90. package/src/plugins/builtin/options.tsx +9 -17
  91. package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
  92. package/src/plugins/builtin/portfolio-list/metrics.ts +13 -2
  93. package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
  94. package/src/plugins/builtin/portfolio-list/pane.tsx +139 -23
  95. package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
  96. package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
  97. package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
  98. package/src/plugins/builtin/portfolio-list.test.tsx +311 -9
  99. package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
  100. package/src/plugins/builtin/sectors/index.tsx +275 -43
  101. package/src/plugins/builtin/sectors/sector-data.ts +41 -1
  102. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
  103. package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
  104. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
  105. package/src/plugins/builtin/ticker-detail/pane.tsx +18 -7
  106. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +445 -63
  107. package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
  108. package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
  109. package/src/plugins/builtin/world-indices/index.tsx +58 -19
  110. package/src/plugins/ibkr/trading-pane.tsx +3 -2
  111. package/src/plugins/plugin-runtime.tsx +2 -2
  112. package/src/plugins/prediction-markets/settings.ts +4 -0
  113. package/src/plugins/registry.test.ts +55 -0
  114. package/src/plugins/registry.ts +11 -4
  115. package/src/plugins/ticker-navigation.test.ts +35 -5
  116. package/src/plugins/ticker-navigation.ts +16 -22
  117. package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
  118. package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
  119. package/src/renderers/electrobun/bun/application-menu.ts +6 -1
  120. package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
  121. package/src/renderers/electrobun/bun/index.ts +52 -13
  122. package/src/renderers/electrobun/view/app-services.ts +51 -9
  123. package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
  124. package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
  125. package/src/renderers/electrobun/view/main.tsx +0 -16
  126. package/src/renderers/electrobun/view/styles.css +23 -21
  127. package/src/renderers/electrobun/view/ui-host.tsx +6 -4
  128. package/src/renderers/opentui/host.tsx +2 -1
  129. package/src/renderers/opentui/start.tsx +1 -1
  130. package/src/renderers/opentui/test-utils.tsx +6 -5
  131. package/src/sources/gloomberb-cloud.test.ts +55 -2
  132. package/src/sources/gloomberb-cloud.ts +180 -29
  133. package/src/sources/provider-router.test.ts +567 -10
  134. package/src/sources/provider-router.ts +323 -93
  135. package/src/sources/yahoo-finance.test.ts +37 -0
  136. package/src/sources/yahoo-finance.ts +48 -2
  137. package/src/state/app-bootstrap.test.ts +50 -23
  138. package/src/state/app-bootstrap.ts +29 -13
  139. package/src/state/app-context.test.ts +113 -0
  140. package/src/state/app-context.tsx +8 -4
  141. package/src/state/session-persistence.test.ts +5 -3
  142. package/src/state/use-inline-tickers.ts +3 -0
  143. package/src/state/use-quote-streaming.ts +11 -0
  144. package/src/theme/colors.ts +13 -14
  145. package/src/types/config.ts +9 -4
  146. package/src/types/data-provider.ts +5 -1
  147. package/src/types/financials.ts +1 -0
  148. package/src/types/plugin.ts +8 -1
  149. package/src/ui/context-menu.tsx +4 -4
  150. package/src/utils/api-client.test.ts +248 -0
  151. package/src/utils/api-client.ts +178 -26
  152. package/src/utils/exchanges.test.ts +16 -0
  153. package/src/utils/exchanges.ts +33 -0
  154. package/src/utils/fuzzy-search.ts +12 -1
  155. package/src/utils/keyboard.ts +31 -0
  156. package/src/utils/options.ts +1 -1
  157. package/src/utils/price-history.test.ts +28 -1
  158. package/src/utils/price-history.ts +16 -0
  159. package/src/utils/quote-freshness.test.ts +150 -0
  160. package/src/utils/quote-freshness.ts +42 -1
  161. package/src/utils/quote-resolution.ts +2 -0
  162. package/src/utils/quote-time.test.ts +34 -0
  163. package/src/utils/quote-time.ts +18 -5
  164. package/src/utils/ticker-search.test.ts +2 -2
  165. package/src/utils/ticker-search.ts +48 -1
  166. package/src/version.ts +1 -1
  167. package/src/plugins/builtin/news-wire/digest-store.ts +0 -44
@@ -0,0 +1,16 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { CANONICAL_EXCHANGE_ALIASES, EXCHANGE_TIME_ZONES } from "./exchanges";
3
+
4
+ describe("exchange metadata", () => {
5
+ test("has a valid timezone for every canonical exchange", () => {
6
+ const canonicalExchanges = [...new Set(Object.values(CANONICAL_EXCHANGE_ALIASES))].sort();
7
+ const missing = canonicalExchanges.filter((exchange) => !EXCHANGE_TIME_ZONES[exchange]);
8
+
9
+ expect(missing).toEqual([]);
10
+ for (const exchange of canonicalExchanges) {
11
+ expect(() => new Intl.DateTimeFormat("en-US", {
12
+ timeZone: EXCHANGE_TIME_ZONES[exchange],
13
+ })).not.toThrow();
14
+ }
15
+ });
16
+ });
@@ -31,6 +31,7 @@ export const CANONICAL_EXCHANGE_ALIASES: Record<string, string> = {
31
31
  TSX: "TSX",
32
32
  XTSE: "TSX",
33
33
  TSXV: "TSXV",
34
+ VAN: "TSXV",
34
35
  CN: "CSE",
35
36
  CNSX: "CSE",
36
37
  CSE: "CSE",
@@ -118,6 +119,7 @@ export const CANONICAL_EXCHANGE_ALIASES: Record<string, string> = {
118
119
  XJSE: "JSE",
119
120
  TLV: "TASE",
120
121
  XTAE: "TASE",
122
+ CCC: "CCC",
121
123
  };
122
124
 
123
125
  export const PUBLIC_EXCHANGE_ALIASES: Record<string, string> = {
@@ -128,6 +130,8 @@ export const PUBLIC_EXCHANGE_ALIASES: Record<string, string> = {
128
130
  HKEX: "XHKG",
129
131
  LSE: "XLON",
130
132
  TSX: "XTSE",
133
+ TSXV: "XTSX",
134
+ CSE: "XCNQ",
131
135
  ASX: "XASX",
132
136
  NZX: "XNZE",
133
137
  SWX: "XSWX",
@@ -172,6 +176,21 @@ export const EXCHANGE_TIME_ZONES: Record<string, string> = {
172
176
  SFB: "Europe/Stockholm",
173
177
  HKEX: "Asia/Hong_Kong",
174
178
  JPX: "Asia/Tokyo",
179
+ TSX: "America/Toronto",
180
+ TSXV: "America/Toronto",
181
+ CSE: "America/Toronto",
182
+ EPA: "Europe/Paris",
183
+ AMS: "Europe/Amsterdam",
184
+ BRU: "Europe/Brussels",
185
+ LIS: "Europe/Lisbon",
186
+ BIT: "Europe/Rome",
187
+ HEL: "Europe/Helsinki",
188
+ CPH: "Europe/Copenhagen",
189
+ OSL: "Europe/Oslo",
190
+ ICEX: "Atlantic/Reykjavik",
191
+ WSE: "Europe/Warsaw",
192
+ PSE: "Europe/Prague",
193
+ VIE: "Europe/Vienna",
175
194
  TPEX: "Asia/Taipei",
176
195
  TWSE: "Asia/Taipei",
177
196
  NSE: "Asia/Kolkata",
@@ -181,6 +200,15 @@ export const EXCHANGE_TIME_ZONES: Record<string, string> = {
181
200
  SGX: "Asia/Singapore",
182
201
  KRX: "Asia/Seoul",
183
202
  KOSDAQ: "Asia/Seoul",
203
+ NZX: "Pacific/Auckland",
204
+ SSE: "Asia/Shanghai",
205
+ SZSE: "Asia/Shanghai",
206
+ BMV: "America/Mexico_City",
207
+ B3: "America/Sao_Paulo",
208
+ BYMA: "America/Argentina/Buenos_Aires",
209
+ JSE: "Africa/Johannesburg",
210
+ TASE: "Asia/Jerusalem",
211
+ CCC: "UTC",
184
212
  };
185
213
 
186
214
  export function normalizeSymbol(value: string): string {
@@ -196,6 +224,11 @@ export function canonicalExchange(value?: string): string {
196
224
  return CANONICAL_EXCHANGE_ALIASES[normalized] ?? normalized;
197
225
  }
198
226
 
227
+ export function resolveExchangeTimeZone(value?: string): string | null {
228
+ const canonical = canonicalExchange(value);
229
+ return canonical ? EXCHANGE_TIME_ZONES[canonical] ?? null : null;
230
+ }
231
+
199
232
  export function publicExchange(value?: string): string {
200
233
  const canonical = canonicalExchange(value);
201
234
  return PUBLIC_EXCHANGE_ALIASES[canonical] ?? canonical;
@@ -5,7 +5,18 @@ export function fuzzyMatch(query: string, target: string): { match: boolean; sco
5
5
  const t = target.toLowerCase();
6
6
 
7
7
  if (q.length === 0) return { match: true, score: 0 };
8
- if (t.startsWith(q)) return { match: true, score: 1000 - q.length }; // exact prefix match
8
+ if (t === q) return { match: true, score: 2500 - q.length };
9
+
10
+ const tokens = t.split(/\s+/).filter(Boolean);
11
+ const exactTokenIndex = tokens.findIndex((token) => token === q);
12
+ if (exactTokenIndex >= 0) return { match: true, score: 2200 - q.length - exactTokenIndex };
13
+
14
+ const prefixTokenIndex = tokens.findIndex((token) => token.startsWith(q));
15
+ if (prefixTokenIndex >= 0) {
16
+ const token = tokens[prefixTokenIndex]!;
17
+ return { match: true, score: 1000 - q.length - prefixTokenIndex - (token.length - q.length) };
18
+ }
19
+
9
20
  if (t.includes(q)) return { match: true, score: 500 - q.length }; // substring match
10
21
 
11
22
  let qi = 0;
@@ -0,0 +1,31 @@
1
+ export interface KeyboardModifierEventLike {
2
+ ctrl?: boolean;
3
+ meta?: boolean;
4
+ super?: boolean;
5
+ alt?: boolean;
6
+ option?: boolean;
7
+ shift?: boolean;
8
+ name?: string;
9
+ }
10
+
11
+ export function hasKeyboardModifier(event: KeyboardModifierEventLike): boolean {
12
+ return !!(
13
+ event.ctrl
14
+ || event.meta
15
+ || event.super
16
+ || event.alt
17
+ || event.option
18
+ || event.shift
19
+ );
20
+ }
21
+
22
+ export function isPlainKeyboardEvent(event: KeyboardModifierEventLike): boolean {
23
+ return !hasKeyboardModifier(event);
24
+ }
25
+
26
+ export function isPlainKey(
27
+ event: KeyboardModifierEventLike,
28
+ ...names: string[]
29
+ ): boolean {
30
+ return isPlainKeyboardEvent(event) && names.includes(event.name ?? "");
31
+ }
@@ -23,7 +23,7 @@ export function parseOptionSymbol(symbol: string): { underlying: string; expTs:
23
23
  const year = 2000 + parseInt(yy!, 10);
24
24
  const month = parseInt(mm!, 10) - 1;
25
25
  const day = parseInt(dd!, 10);
26
- const expTs = Math.floor(new Date(year, month, day).getTime() / 1000);
26
+ const expTs = Math.floor(Date.UTC(year, month, day) / 1000);
27
27
  return { underlying: underlying!, expTs, strike, side: side as "C" | "P" };
28
28
  }
29
29
 
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { normalizePriceHistory } from "./price-history";
2
+ import { isPriceHistoryStaleForCurrentWindow, normalizePriceHistory } from "./price-history";
3
3
 
4
4
  describe("normalizePriceHistory", () => {
5
5
  test("drops poisoned cached history when every timestamp collapses to the same value", () => {
@@ -22,4 +22,31 @@ describe("normalizePriceHistory", () => {
22
22
 
23
23
  expect(history.map((point) => point.close)).toEqual([101, 102, 103]);
24
24
  });
25
+
26
+ test("drops zero-price bars instead of treating missing upstream prices as real data", () => {
27
+ const history = normalizePriceHistory([
28
+ { date: new Date("2026-05-13T13:30:00Z"), close: 64 },
29
+ { date: new Date("2026-05-13T13:45:00Z"), close: 0 },
30
+ { date: new Date("2026-05-13T14:00:00Z"), close: Number.NaN },
31
+ { date: new Date("2026-05-13T14:15:00Z"), close: 65 },
32
+ ]);
33
+
34
+ expect(history.map((point) => point.close)).toEqual([64, 65]);
35
+ });
36
+
37
+ test("detects intraday history that is old even when the cache record is fresh", () => {
38
+ expect(
39
+ isPriceHistoryStaleForCurrentWindow(
40
+ [{ date: new Date("2026-04-17T20:00:00Z"), close: 67 }],
41
+ Date.parse("2026-05-13T23:00:00Z"),
42
+ ),
43
+ ).toBe(true);
44
+
45
+ expect(
46
+ isPriceHistoryStaleForCurrentWindow(
47
+ [{ date: new Date("2026-05-13T22:45:00Z"), close: 67 }],
48
+ Date.parse("2026-05-13T23:00:00Z"),
49
+ ),
50
+ ).toBe(false);
51
+ });
25
52
  });
@@ -1,5 +1,7 @@
1
1
  import type { PricePoint, TickerFinancials } from "../types/financials";
2
2
 
3
+ const MAX_CURRENT_INTRADAY_HISTORY_LAG_MS = 18 * 60 * 60 * 1000;
4
+
3
5
  function getPointTimestamp(point: PricePoint): number {
4
6
  const value = point.date as Date | string | number | null | undefined;
5
7
  if (value instanceof Date) return value.getTime();
@@ -7,6 +9,10 @@ function getPointTimestamp(point: PricePoint): number {
7
9
  return new Date(value).getTime();
8
10
  }
9
11
 
12
+ function hasValidClose(point: PricePoint): boolean {
13
+ return Number.isFinite(point.close) && point.close > 0;
14
+ }
15
+
10
16
  function comparePricePointsByDate(left: PricePoint, right: PricePoint): number {
11
17
  const leftTime = getPointTimestamp(left);
12
18
  const rightTime = getPointTimestamp(right);
@@ -31,6 +37,7 @@ export function normalizePriceHistory(points: PricePoint[]): PricePoint[] {
31
37
  for (const point of points) {
32
38
  const time = getPointTimestamp(point);
33
39
  if (!Number.isFinite(time)) continue;
40
+ if (!hasValidClose(point)) continue;
34
41
 
35
42
  if (firstTimestamp === null) {
36
43
  firstTimestamp = time;
@@ -55,6 +62,15 @@ export function normalizePriceHistory(points: PricePoint[]): PricePoint[] {
55
62
  return validPoints.length === points.length ? points : validPoints;
56
63
  }
57
64
 
65
+ export function isPriceHistoryStaleForCurrentWindow(points: PricePoint[], now = Date.now()): boolean {
66
+ const normalized = normalizePriceHistory(points);
67
+ const latest = normalized.at(-1);
68
+ if (!latest) return false;
69
+
70
+ const latestTime = getPointTimestamp(latest);
71
+ return Number.isFinite(latestTime) && now - latestTime > MAX_CURRENT_INTRADAY_HISTORY_LAG_MS;
72
+ }
73
+
58
74
  export function normalizeTickerFinancialsPriceHistory(financials: TickerFinancials): TickerFinancials {
59
75
  const priceHistory = normalizePriceHistory(financials.priceHistory ?? []);
60
76
  return priceHistory === financials.priceHistory
@@ -0,0 +1,150 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Quote } from "../types/financials";
3
+ import { isQuoteStaleForCurrentSession } from "./quote-freshness";
4
+
5
+ function quote(overrides: Partial<Quote>): Quote {
6
+ return {
7
+ symbol: "2337",
8
+ price: 168,
9
+ currency: "TWD",
10
+ change: 0,
11
+ changePercent: 0,
12
+ lastUpdated: Date.parse("2026-05-08T06:00:00Z"),
13
+ listingExchangeName: "TWSE",
14
+ exchangeName: "TWSE",
15
+ marketState: "CLOSED",
16
+ dataSource: "delayed",
17
+ ...overrides,
18
+ };
19
+ }
20
+
21
+ describe("quote freshness", () => {
22
+ test("treats multi-business-day-old non-US closed quotes as stale", () => {
23
+ expect(
24
+ isQuoteStaleForCurrentSession(
25
+ quote({}),
26
+ Date.parse("2026-05-13T21:00:00Z"),
27
+ ),
28
+ ).toBe(true);
29
+ });
30
+
31
+ test("treats stale Paris and Toronto quotes as refreshable", () => {
32
+ const now = Date.parse("2026-05-13T21:00:00Z");
33
+
34
+ expect(
35
+ isQuoteStaleForCurrentSession(
36
+ quote({
37
+ symbol: "ALRIB",
38
+ currency: "EUR",
39
+ lastUpdated: Date.parse("2026-05-08T15:30:00Z"),
40
+ listingExchangeName: "PAR",
41
+ exchangeName: "PAR",
42
+ }),
43
+ now,
44
+ ),
45
+ ).toBe(true);
46
+
47
+ expect(
48
+ isQuoteStaleForCurrentSession(
49
+ quote({
50
+ symbol: "HPS-A.TO",
51
+ currency: "CAD",
52
+ lastUpdated: Date.parse("2026-05-08T20:00:00Z"),
53
+ listingExchangeName: "TOR",
54
+ exchangeName: "TOR",
55
+ }),
56
+ now,
57
+ ),
58
+ ).toBe(true);
59
+ });
60
+
61
+ test("treats old crypto quotes as stale on the 24/7 venue", () => {
62
+ expect(
63
+ isQuoteStaleForCurrentSession(
64
+ quote({
65
+ symbol: "BTC-USD",
66
+ currency: "USD",
67
+ lastUpdated: Date.parse("2026-05-09T15:29:00Z"),
68
+ listingExchangeName: "CCC",
69
+ exchangeName: "CCC",
70
+ marketState: "REGULAR",
71
+ }),
72
+ Date.parse("2026-05-13T21:00:00Z"),
73
+ ),
74
+ ).toBe(true);
75
+ });
76
+
77
+ test("allows the previous local business day before the market reopens", () => {
78
+ expect(
79
+ isQuoteStaleForCurrentSession(
80
+ quote({
81
+ symbol: "1211",
82
+ currency: "HKD",
83
+ lastUpdated: Date.parse("2026-05-13T08:00:00Z"),
84
+ listingExchangeName: "SEHK",
85
+ exchangeName: "SEHK",
86
+ }),
87
+ Date.parse("2026-05-13T21:00:00Z"),
88
+ ),
89
+ ).toBe(false);
90
+ });
91
+
92
+ test("rejects previous-day weekday quotes that are older than a normal overnight close", () => {
93
+ expect(
94
+ isQuoteStaleForCurrentSession(
95
+ quote({
96
+ symbol: "285A.T",
97
+ currency: "JPY",
98
+ lastUpdated: Date.parse("2026-05-12T21:00:00Z"),
99
+ listingExchangeName: "JPX",
100
+ exchangeName: "JPX",
101
+ }),
102
+ Date.parse("2026-05-13T21:00:00Z"),
103
+ ),
104
+ ).toBe(true);
105
+
106
+ expect(
107
+ isQuoteStaleForCurrentSession(
108
+ quote({
109
+ symbol: "285A.T",
110
+ currency: "JPY",
111
+ lastUpdated: Date.parse("2026-05-13T06:24:00Z"),
112
+ listingExchangeName: "JPX",
113
+ exchangeName: "JPX",
114
+ }),
115
+ Date.parse("2026-05-13T21:00:00Z"),
116
+ ),
117
+ ).toBe(false);
118
+ });
119
+
120
+ test("allows Friday closes before Monday reopen", () => {
121
+ expect(
122
+ isQuoteStaleForCurrentSession(
123
+ quote({
124
+ symbol: "285A.T",
125
+ currency: "JPY",
126
+ lastUpdated: Date.parse("2026-05-08T06:24:00Z"),
127
+ listingExchangeName: "JPX",
128
+ exchangeName: "JPX",
129
+ }),
130
+ Date.parse("2026-05-10T21:00:00Z"),
131
+ ),
132
+ ).toBe(false);
133
+ });
134
+
135
+ test("treats prior-date regular-session quotes as stale immediately", () => {
136
+ expect(
137
+ isQuoteStaleForCurrentSession(
138
+ quote({
139
+ symbol: "HY9H",
140
+ currency: "EUR",
141
+ lastUpdated: Date.parse("2026-04-07T19:55:00Z"),
142
+ listingExchangeName: "FWB2",
143
+ exchangeName: "FWB2",
144
+ marketState: "REGULAR",
145
+ }),
146
+ Date.parse("2026-04-08T10:49:00Z"),
147
+ ),
148
+ ).toBe(true);
149
+ });
150
+ });
@@ -3,6 +3,8 @@ import { canonicalExchange, EXCHANGE_TIME_ZONES } from "./exchanges";
3
3
 
4
4
  const US_EXTENDED_HOURS_EXCHANGES = new Set(["NASDAQ", "NYSE", "AMEX", "ARCA", "BATS"]);
5
5
  const exchangeLocalDateFormatters = new Map<string, Intl.DateTimeFormat>();
6
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
7
+ const OVERNIGHT_CLOSE_MAX_AGE_MS = 20 * 60 * 60 * 1000;
6
8
  const usSessionFormatter = new Intl.DateTimeFormat("en-US", {
7
9
  timeZone: "America/New_York",
8
10
  weekday: "short",
@@ -43,6 +45,38 @@ function exchangeLocalDate(exchange: string, timestampMs: number): string | null
43
45
  return `${year}-${month}-${day}`;
44
46
  }
45
47
 
48
+ function isoLocalDateToUtcDay(date: string): number | null {
49
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
50
+ if (!match) return null;
51
+
52
+ const year = Number(match[1]);
53
+ const month = Number(match[2]);
54
+ const day = Number(match[3]);
55
+ if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null;
56
+
57
+ return Math.floor(Date.UTC(year, month - 1, day) / MS_PER_DAY);
58
+ }
59
+
60
+ function localWeekdaysBetween(earlierDate: string, laterDate: string): number {
61
+ const earlierDay = isoLocalDateToUtcDay(earlierDate);
62
+ const laterDay = isoLocalDateToUtcDay(laterDate);
63
+ if (earlierDay == null || laterDay == null || laterDay <= earlierDay) return 0;
64
+
65
+ let weekdays = 0;
66
+ for (let day = earlierDay + 1; day <= laterDay; day += 1) {
67
+ const weekday = new Date(day * MS_PER_DAY).getUTCDay();
68
+ if (weekday !== 0 && weekday !== 6) {
69
+ weekdays += 1;
70
+ }
71
+ }
72
+ return weekdays;
73
+ }
74
+
75
+ function localWeekday(date: string): number | null {
76
+ const day = isoLocalDateToUtcDay(date);
77
+ return day == null ? null : new Date(day * MS_PER_DAY).getUTCDay();
78
+ }
79
+
46
80
  function usSessionState(timestampMs: number): UsSessionState {
47
81
  const parts = usSessionFormatter.formatToParts(new Date(timestampMs));
48
82
 
@@ -85,9 +119,16 @@ export function isQuoteStaleForCurrentSession(quote: Quote | null | undefined, n
85
119
 
86
120
  if (isUsExtendedHoursExchange(exchange)) {
87
121
  const session = usSessionState(now);
88
- return session === "PRE" || session === "REGULAR" || session === "POST";
122
+ if (session === "PRE" || session === "REGULAR" || session === "POST") {
123
+ return true;
124
+ }
89
125
  }
90
126
 
127
+ const weekdaysBehind = localWeekdaysBetween(quoteDate, currentDate);
128
+ if (weekdaysBehind > 1) return true;
129
+ if (weekdaysBehind === 1 && now - quote.lastUpdated > OVERNIGHT_CLOSE_MAX_AGE_MS) {
130
+ return localWeekday(currentDate) !== 1;
131
+ }
91
132
  return false;
92
133
  }
93
134
 
@@ -30,6 +30,7 @@ const PRICE_FIELD_KEYS = [
30
30
  "low",
31
31
  "mark",
32
32
  "lastUpdated",
33
+ "receivedAt",
33
34
  ] as const;
34
35
 
35
36
  const SESSION_FIELD_KEYS = [
@@ -516,6 +517,7 @@ export function resolveCanonicalQuote(
516
517
  volume: resolved.volume as Quote["volume"],
517
518
  name: resolved.name as Quote["name"],
518
519
  lastUpdated: Number(resolved.lastUpdated ?? priceProvider?.lastUpdated ?? sessionProvider?.lastUpdated ?? now),
520
+ receivedAt: resolved.receivedAt as Quote["receivedAt"],
519
521
  exchangeName: resolved.exchangeName as Quote["exchangeName"],
520
522
  fullExchangeName: resolved.fullExchangeName as Quote["fullExchangeName"],
521
523
  listingExchangeName: resolved.listingExchangeName as Quote["listingExchangeName"],
@@ -0,0 +1,34 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { formatQuoteAge, formatQuoteAgeWithSource, getMostRecentQuoteUpdate } from "./quote-time";
3
+
4
+ describe("quote-time", () => {
5
+ test("formats sub-second quote age in milliseconds", () => {
6
+ const now = 1_700_000_030_000;
7
+
8
+ expect(formatQuoteAge(now - 999, now)).toBe("999ms");
9
+ expect(formatQuoteAge(now, now)).toBe("0ms");
10
+ expect(formatQuoteAgeWithSource({
11
+ lastUpdated: now - 100,
12
+ dataSource: "delayed",
13
+ }, now)).toBe("◷100ms");
14
+ });
15
+
16
+ test("uses stream receipt time for displayed quote age when available", () => {
17
+ const now = 1_700_000_030_000;
18
+ const quote = {
19
+ lastUpdated: 1_700_000_000_000,
20
+ receivedAt: 1_700_000_029_000,
21
+ };
22
+
23
+ expect(formatQuoteAgeWithSource(quote, now)).toBe("1s");
24
+ expect(getMostRecentQuoteUpdate([quote], now)).toBe(1_700_000_029_000);
25
+ });
26
+
27
+ test("keeps delayed source marker while using receipt time for age", () => {
28
+ expect(formatQuoteAgeWithSource({
29
+ lastUpdated: 1_700_000_000_000,
30
+ receivedAt: 1_700_000_029_000,
31
+ dataSource: "delayed",
32
+ }, 1_700_000_030_000)).toBe("◷1s");
33
+ });
34
+ });
@@ -11,20 +11,33 @@ export function formatQuoteAge(lastUpdated: number | undefined, now = Date.now()
11
11
  const clamped = clampQuoteTimestamp(lastUpdated, now);
12
12
  if (clamped == null) return "—";
13
13
 
14
- const ageSeconds = Math.max(0, (now - clamped) / 1000);
14
+ const ageMs = Math.max(0, now - clamped);
15
+ if (ageMs < 1000) return `${Math.floor(ageMs)}ms`;
16
+
17
+ const ageSeconds = ageMs / 1000;
15
18
  if (ageSeconds < 60) return `${Math.floor(ageSeconds)}s`;
16
19
  if (ageSeconds < 3600) return `${Math.floor(ageSeconds / 60)}m`;
17
20
  if (ageSeconds < 86400) return `${Math.floor(ageSeconds / 3600)}h`;
18
21
  return `${Math.floor(ageSeconds / 86400)}d`;
19
22
  }
20
23
 
24
+ export function resolveQuoteAgeTimestamp(
25
+ quote: Pick<Quote, "lastUpdated" | "receivedAt"> | null | undefined,
26
+ now = Date.now(),
27
+ ): number | null {
28
+ if (!quote) return null;
29
+ const receivedAt = clampQuoteTimestamp(quote.receivedAt, now);
30
+ return receivedAt ?? clampQuoteTimestamp(quote.lastUpdated, now);
31
+ }
32
+
21
33
  export function formatQuoteAgeWithSource(
22
- quote: Pick<Quote, "lastUpdated" | "dataSource"> | null | undefined,
34
+ quote: Pick<Quote, "lastUpdated" | "receivedAt" | "dataSource"> | null | undefined,
23
35
  now = Date.now(),
24
36
  ): string {
25
37
  if (!quote) return "—";
26
38
 
27
- const age = formatQuoteAge(quote.lastUpdated, now);
39
+ const timestamp = resolveQuoteAgeTimestamp(quote, now);
40
+ const age = timestamp == null ? "—" : formatQuoteAge(timestamp, now);
28
41
  if (age === "—") return age;
29
42
 
30
43
  const prefix = quote.dataSource === "delayed" ? "◷" : "";
@@ -32,12 +45,12 @@ export function formatQuoteAgeWithSource(
32
45
  }
33
46
 
34
47
  export function getMostRecentQuoteUpdate(
35
- quotes: Iterable<Pick<Quote, "lastUpdated"> | null | undefined>,
48
+ quotes: Iterable<Pick<Quote, "lastUpdated" | "receivedAt"> | null | undefined>,
36
49
  now = Date.now(),
37
50
  ): number | null {
38
51
  let latest: number | null = null;
39
52
  for (const quote of quotes) {
40
- const timestamp = clampQuoteTimestamp(quote?.lastUpdated, now);
53
+ const timestamp = resolveQuoteAgeTimestamp(quote, now);
41
54
  if (timestamp != null && (latest == null || timestamp > latest)) {
42
55
  latest = timestamp;
43
56
  }
@@ -82,7 +82,7 @@ describe("ticker-search utilities", () => {
82
82
  ]),
83
83
  });
84
84
 
85
- expect(results.map((item) => item.id)).toEqual(["goto:AAPL", "search:APP"]);
85
+ expect(results.map((item) => item.id)).toEqual(["goto:AAPL", "search:APP:NASDAQ:TEST"]);
86
86
  expect(findExactTickerSearchMatch(results, "AAPL")?.id).toBe("goto:AAPL");
87
87
  });
88
88
 
@@ -97,7 +97,7 @@ describe("ticker-search utilities", () => {
97
97
  ]),
98
98
  });
99
99
 
100
- expect(results.map((item) => item.id)).toEqual(["goto:AAPL", "search:APLE"]);
100
+ expect(results.map((item) => item.id)).toEqual(["goto:AAPL", "search:APLE:NASDAQ:TEST"]);
101
101
  expect(results[0]).toMatchObject({
102
102
  id: "goto:AAPL",
103
103
  detail: "Apple Inc",
@@ -140,7 +140,7 @@ export function createProviderTickerSearchCandidates(
140
140
  const symbol = getSearchResultSymbol(result);
141
141
  const saved = localTickers.has(symbol);
142
142
  return [{
143
- id: `search:${result.symbol}`,
143
+ id: buildProviderCandidateId(result, symbol),
144
144
  label: symbol,
145
145
  symbol,
146
146
  detail: [result.name, result.brokerLabel, result.type].filter(Boolean).join(" | "),
@@ -357,6 +357,7 @@ export function rankTickerSearchItems<T extends Pick<TickerSearchRankableItem, "
357
357
  item,
358
358
  index,
359
359
  normalizedSymbol: normalizeSearchText(item.symbol || item.label),
360
+ symbolMatchRank: scoreSymbolMatchRank(intent, item),
360
361
  textScore,
361
362
  score: textScore + priorityScore + (textScore > 0 && saved ? 900 : 0),
362
363
  };
@@ -375,6 +376,7 @@ export function rankTickerSearchItems<T extends Pick<TickerSearchRankableItem, "
375
376
  return !matchedLocalSymbols.has(normalizedSymbol);
376
377
  })
377
378
  .sort((a, b) => {
379
+ if (b.symbolMatchRank !== a.symbolMatchRank) return b.symbolMatchRank - a.symbolMatchRank;
378
380
  if (b.score !== a.score) return b.score - a.score;
379
381
  const aSaved = isSavedSearchItem(a.item);
380
382
  const bSaved = isSavedSearchItem(b.item);
@@ -463,6 +465,16 @@ function buildProviderSearchResultKey(result: InstrumentSearchResult): string {
463
465
  ].join("|");
464
466
  }
465
467
 
468
+ function buildProviderCandidateId(result: InstrumentSearchResult, symbol: string): string {
469
+ return [
470
+ "search",
471
+ normalizeTickerSymbol(symbol),
472
+ normalizeSearchText(result.exchange || result.primaryExchange || result.type || ""),
473
+ normalizeSearchText(result.currency || ""),
474
+ normalizeSearchText(result.providerId || ""),
475
+ ].filter(Boolean).join(":");
476
+ }
477
+
466
478
  function getProviderHintRichness(result: InstrumentSearchResult): number {
467
479
  let score = 0;
468
480
  if (result.name) score += Math.min(120, result.name.length);
@@ -728,6 +740,41 @@ function scoreSearchAlias(intent: SearchQueryIntent, alias: string): number {
728
740
  return score;
729
741
  }
730
742
 
743
+ function scoreSymbolMatchRank(
744
+ intent: SearchQueryIntent,
745
+ item: Pick<TickerSearchRankableItem, "label"> & Partial<TickerSearchRankableItem>,
746
+ ): number {
747
+ if (!intent.normalizedQuery && !intent.compactQuery) return 0;
748
+ const displaySymbol = normalizeSearchText(item.symbol || item.label);
749
+ const compactDisplaySymbol = compactSearchText(item.symbol || item.label);
750
+
751
+ if (
752
+ displaySymbol === intent.normalizedQuery
753
+ || (intent.compactQuery && compactDisplaySymbol === intent.compactQuery)
754
+ ) {
755
+ return 3;
756
+ }
757
+
758
+ const aliases = getItemSearchAliases(item);
759
+ if (aliases.some((alias) => {
760
+ const normalizedAlias = normalizeSearchText(alias);
761
+ const compactAlias = compactSearchText(alias);
762
+ return normalizedAlias === intent.normalizedQuery
763
+ || (intent.compactQuery && compactAlias === intent.compactQuery);
764
+ })) {
765
+ return 2;
766
+ }
767
+
768
+ if (
769
+ displaySymbol.startsWith(intent.normalizedQuery)
770
+ || (intent.compactQuery && compactDisplaySymbol.startsWith(intent.compactQuery))
771
+ ) {
772
+ return 1;
773
+ }
774
+
775
+ return 0;
776
+ }
777
+
731
778
  function scoreAssetPreference(intent: SearchQueryIntent, instrumentClass?: TickerSearchInstrumentClass): number {
732
779
  const itemClass = instrumentClass || "other";
733
780
  if (!intent.assetPreference) {
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.7.2";
1
+ export const VERSION = "0.7.4";