gloomberb 0.3.7 → 0.4.0

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 (133) hide show
  1. package/README.md +19 -8
  2. package/package.json +1 -1
  3. package/src/app.tsx +29 -65
  4. package/src/cli/base-converter.ts +31 -0
  5. package/src/cli/commands/search.ts +11 -3
  6. package/src/cli/commands/ticker.ts +22 -11
  7. package/src/cli/context.ts +27 -32
  8. package/src/cli/helpers.ts +1 -4
  9. package/src/cli/index.ts +140 -123
  10. package/src/cli/registry.test.ts +226 -0
  11. package/src/cli/registry.ts +201 -0
  12. package/src/cli.test.ts +219 -0
  13. package/src/components/chart/chart-controller.ts +309 -0
  14. package/src/components/chart/chart-data.ts +5 -6
  15. package/src/components/chart/chart-pane-settings.ts +27 -0
  16. package/src/components/chart/chart-resolution.test.ts +56 -0
  17. package/src/components/chart/chart-resolution.ts +362 -0
  18. package/src/components/chart/chart-types.ts +12 -4
  19. package/src/components/chart/chart-viewport.test.ts +25 -0
  20. package/src/components/chart/chart-viewport.ts +28 -0
  21. package/src/components/chart/comparison-chart-data.ts +20 -27
  22. package/src/components/chart/comparison-stock-chart.tsx +374 -99
  23. package/src/components/chart/stock-chart-renderer-switch.test.tsx +124 -0
  24. package/src/components/chart/stock-chart.tsx +455 -106
  25. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
  26. package/src/components/command-bar/command-bar.test.tsx +650 -5
  27. package/src/components/command-bar/command-bar.tsx +359 -63
  28. package/src/components/command-bar/command-registry.ts +8 -0
  29. package/src/components/command-bar/helpers.test.ts +23 -1
  30. package/src/components/command-bar/helpers.ts +14 -5
  31. package/src/components/command-bar/pane-template-display.ts +12 -0
  32. package/src/components/command-bar/root-shortcuts.ts +2 -1
  33. package/src/components/command-bar/workflow-ops.ts +4 -2
  34. package/src/components/detail-feed-view.test.tsx +124 -49
  35. package/src/components/detail-feed-view.tsx +396 -160
  36. package/src/components/layout/floating-pane.tsx +1 -1
  37. package/src/components/layout/pane-header.tsx +26 -2
  38. package/src/components/layout/shell.tsx +62 -50
  39. package/src/components/layout/status-bar.test.tsx +66 -0
  40. package/src/components/layout/status-bar.tsx +26 -5
  41. package/src/components/onboarding/onboarding-wizard.test.tsx +65 -0
  42. package/src/components/onboarding/onboarding-wizard.tsx +5 -6
  43. package/src/components/ticker-list-table.test.tsx +83 -0
  44. package/src/components/ticker-list-table.tsx +174 -97
  45. package/src/components/ui/data-table.tsx +27 -2
  46. package/src/components/ui/index.ts +1 -0
  47. package/src/components/ui/page-stack-view.tsx +60 -0
  48. package/src/components/use-double-click-activation.ts +31 -0
  49. package/src/index.tsx +10 -10
  50. package/src/market-data/coordinator.test.ts +92 -4
  51. package/src/market-data/coordinator.ts +32 -5
  52. package/src/market-data/hooks.test.tsx +120 -0
  53. package/src/market-data/hooks.tsx +53 -16
  54. package/src/market-data/request-types.ts +4 -2
  55. package/src/market-data/selectors.ts +24 -58
  56. package/src/plugins/builtin/chat-controller.test.ts +370 -3
  57. package/src/plugins/builtin/chat-controller.ts +310 -35
  58. package/src/plugins/builtin/chat.test.tsx +118 -6
  59. package/src/plugins/builtin/chat.tsx +175 -116
  60. package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
  61. package/src/plugins/builtin/comparison-chart.tsx +21 -1
  62. package/src/plugins/builtin/news.tsx +1 -11
  63. package/src/plugins/builtin/portfolio-list/cli/commands.ts +656 -0
  64. package/src/plugins/builtin/portfolio-list/command-bar.ts +122 -0
  65. package/src/plugins/builtin/portfolio-list/header.tsx +16 -34
  66. package/src/plugins/builtin/portfolio-list/index.tsx +5 -0
  67. package/src/plugins/builtin/portfolio-list/metrics.test.ts +61 -0
  68. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  69. package/src/plugins/builtin/portfolio-list/mutations.test.ts +187 -0
  70. package/src/plugins/builtin/portfolio-list/mutations.ts +226 -0
  71. package/src/plugins/builtin/portfolio-list/pane.tsx +39 -17
  72. package/src/plugins/builtin/portfolio-list/table.tsx +11 -2
  73. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +163 -0
  74. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +81 -0
  75. package/src/plugins/builtin/sec.tsx +1 -11
  76. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +13 -2
  77. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +21 -0
  78. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +45 -10
  79. package/src/plugins/builtin/ticker-detail/pane.tsx +35 -17
  80. package/src/plugins/builtin/ticker-detail/settings.ts +19 -1
  81. package/src/plugins/builtin/ticker-detail.test.tsx +47 -0
  82. package/src/plugins/catalog.ts +65 -0
  83. package/src/plugins/ibkr/broker-adapter.ts +28 -0
  84. package/src/plugins/ibkr/gateway-service.test.ts +45 -1
  85. package/src/plugins/ibkr/gateway-service.ts +141 -7
  86. package/src/plugins/pane-manager.test.ts +1 -1
  87. package/src/plugins/plugin-runtime.tsx +27 -7
  88. package/src/plugins/prediction-markets/cli.ts +28 -0
  89. package/src/plugins/prediction-markets/controller-data.ts +11 -3
  90. package/src/plugins/prediction-markets/controller.ts +141 -121
  91. package/src/plugins/prediction-markets/index.tsx +2 -0
  92. package/src/plugins/prediction-markets/launch.ts +51 -16
  93. package/src/plugins/prediction-markets/metrics.ts +9 -5
  94. package/src/plugins/prediction-markets/pane.test.tsx +119 -57
  95. package/src/plugins/prediction-markets/pane.tsx +74 -153
  96. package/src/plugins/prediction-markets/table.tsx +6 -3
  97. package/src/plugins/prediction-markets/types.ts +0 -1
  98. package/src/sources/gloomberb-cloud.test.ts +29 -0
  99. package/src/sources/gloomberb-cloud.ts +89 -1
  100. package/src/sources/provider-router.test.ts +265 -3
  101. package/src/sources/provider-router.ts +239 -11
  102. package/src/sources/yahoo-finance.test.ts +26 -0
  103. package/src/sources/yahoo-finance.ts +54 -3
  104. package/src/state/app-context-selector.test.tsx +99 -0
  105. package/src/state/app-context.test.ts +18 -0
  106. package/src/state/app-context.tsx +170 -65
  107. package/src/state/selectors.ts +25 -0
  108. package/src/theme/colors.ts +8 -8
  109. package/src/theme/themes.test.ts +2 -0
  110. package/src/theme/themes.ts +22 -10
  111. package/src/types/broker.ts +21 -0
  112. package/src/types/config.ts +17 -1
  113. package/src/types/data-provider.ts +18 -0
  114. package/src/types/financials.ts +31 -1
  115. package/src/types/plugin.ts +70 -0
  116. package/src/utils/api-client.test.ts +57 -0
  117. package/src/utils/api-client.ts +35 -12
  118. package/src/utils/back-navigation.ts +27 -0
  119. package/src/utils/format.test.ts +14 -0
  120. package/src/utils/format.ts +3 -3
  121. package/src/utils/market-status.ts +46 -1
  122. package/src/utils/quote-freshness.ts +127 -0
  123. package/src/utils/quote-resolution.test.ts +277 -0
  124. package/src/utils/quote-resolution.ts +641 -0
  125. package/src/utils/slugify.ts +3 -0
  126. package/src/utils/timestamp.ts +25 -0
  127. package/src/version.ts +1 -1
  128. package/src/cli/commands/portfolio.ts +0 -210
  129. package/src/cli/commands/watchlist.ts +0 -210
  130. package/src/plugins/builtin/data-providers.ts +0 -16
  131. package/src/sources/exchange-rates.ts +0 -18
  132. package/src/utils/ascii-chart.ts +0 -172
  133. package/src/utils/ticker-tokenizer.test.ts +0 -48
@@ -1,4 +1,21 @@
1
1
  export type MarketState = "PRE" | "REGULAR" | "POST" | "PREPRE" | "POSTPOST" | "CLOSED";
2
+ export type SessionConfidence = "explicit" | "derived" | "unknown";
3
+ export type QuoteDataSource = "live" | "delayed" | "yahoo";
4
+
5
+ export interface QuoteFieldProvenance {
6
+ providerId: string;
7
+ dataSource?: QuoteDataSource;
8
+ }
9
+
10
+ export interface QuoteProvenance {
11
+ price?: QuoteFieldProvenance;
12
+ session?: QuoteFieldProvenance;
13
+ listing?: QuoteFieldProvenance;
14
+ routing?: QuoteFieldProvenance;
15
+ descriptive?: QuoteFieldProvenance;
16
+ fields?: Record<string, QuoteFieldProvenance>;
17
+ rejectedPriceProviders?: string[];
18
+ }
2
19
 
3
20
  export interface Quote {
4
21
  symbol: string;
@@ -16,7 +33,12 @@ export interface Quote {
16
33
  lastUpdated: number; // timestamp ms
17
34
  exchangeName?: string;
18
35
  fullExchangeName?: string;
36
+ listingExchangeName?: string;
37
+ listingExchangeFullName?: string;
38
+ routingExchangeName?: string;
39
+ routingExchangeFullName?: string;
19
40
  marketState?: MarketState;
41
+ sessionConfidence?: SessionConfidence;
20
42
  preMarketPrice?: number;
21
43
  preMarketChange?: number;
22
44
  preMarketChangePercent?: number;
@@ -31,10 +53,17 @@ export interface Quote {
31
53
  high?: number;
32
54
  low?: number;
33
55
  mark?: number;
56
+ provenance?: QuoteProvenance;
34
57
  /** Where the quote data came from: live broker feed, delayed broker feed, or a fallback like Yahoo. */
35
- dataSource?: "live" | "delayed" | "yahoo";
58
+ dataSource?: QuoteDataSource;
36
59
  }
37
60
 
61
+ export interface QuoteContribution extends Quote {
62
+ providerId: string;
63
+ }
64
+
65
+ export type QuoteContributionMap = Record<string, QuoteContribution>;
66
+
38
67
  export interface Fundamentals {
39
68
  trailingPE?: number;
40
69
  forwardPE?: number;
@@ -110,6 +139,7 @@ export interface PricePoint {
110
139
 
111
140
  export interface TickerFinancials {
112
141
  quote?: Quote;
142
+ quoteContributions?: QuoteContributionMap;
113
143
  fundamentals?: Fundamentals;
114
144
  profile?: CompanyProfile;
115
145
  annualStatements: FinancialStatement[];
@@ -169,6 +169,75 @@ export interface WizardStep {
169
169
  body?: string[];
170
170
  }
171
171
 
172
+ export interface CliHelpColumn {
173
+ header: string;
174
+ align?: "left" | "right" | "center";
175
+ width?: number;
176
+ }
177
+
178
+ export interface CliCommandHelpSection {
179
+ title: string;
180
+ columns?: CliHelpColumn[];
181
+ rows?: string[][];
182
+ lines?: string[];
183
+ }
184
+
185
+ export interface CliCommandHelp {
186
+ usage?: string[];
187
+ sections?: CliCommandHelpSection[];
188
+ }
189
+
190
+ export interface CliLaunchEnvironment {
191
+ terminalWidth: number;
192
+ terminalHeight: number;
193
+ }
194
+
195
+ export interface CliLaunchConfigResult<TLaunchState = unknown> {
196
+ config: AppConfig;
197
+ launchState?: TLaunchState;
198
+ }
199
+
200
+ export interface CliLaunchRequest<TLaunchState = unknown> {
201
+ applyConfig(config: AppConfig, env: CliLaunchEnvironment): CliLaunchConfigResult<TLaunchState>;
202
+ applySessionSnapshot?(
203
+ config: AppConfig,
204
+ snapshot: import("../state/session-persistence").AppSessionSnapshot | null,
205
+ launchState: TLaunchState | undefined,
206
+ ): import("../state/session-persistence").AppSessionSnapshot;
207
+ }
208
+
209
+ export type CliDispatchResult =
210
+ | { kind: "handled" }
211
+ | { kind: "launch-ui"; request: CliLaunchRequest }
212
+ | { kind: "unhandled" };
213
+
214
+ export interface CliCommandContext {
215
+ initConfigData(): Promise<import("../cli/types").ConfigContext>;
216
+ initMarketData(): Promise<import("../cli/types").MarketContext>;
217
+ fail(message: string, details?: string): never;
218
+ closeAndFail(
219
+ persistence: import("../data/app-persistence").AppPersistence,
220
+ message: string,
221
+ details?: string,
222
+ ): never;
223
+ output: {
224
+ cliStyles: typeof import("../utils/cli-output").cliStyles;
225
+ colorBySign: typeof import("../utils/cli-output").colorBySign;
226
+ renderSection: typeof import("../utils/cli-output").renderSection;
227
+ renderStat: typeof import("../utils/cli-output").renderStat;
228
+ renderTable: typeof import("../utils/cli-output").renderTable;
229
+ };
230
+ log: PluginLogger;
231
+ }
232
+
233
+ export interface CliCommandDef {
234
+ name: string;
235
+ aliases?: string[];
236
+ description: string;
237
+ help?: CliCommandHelp;
238
+ execute(args: string[], ctx: CliCommandContext): void | CliDispatchResult | Promise<void | CliDispatchResult>;
239
+ }
240
+
172
241
  export interface CommandDef {
173
242
  id: string;
174
243
  label: string;
@@ -340,6 +409,7 @@ export interface GloomPlugin {
340
409
  description?: string;
341
410
  toggleable?: boolean;
342
411
  order?: number;
412
+ cliCommands?: CliCommandDef[];
343
413
 
344
414
  setup?(ctx: GloomPluginContext): void | Promise<void>;
345
415
  dispose?(): void;
@@ -84,3 +84,60 @@ describe("apiClient auth cookies", () => {
84
84
  ]);
85
85
  });
86
86
  });
87
+
88
+ describe("apiClient chat timestamps", () => {
89
+ test("normalizes transcript and send-response timestamps to UTC ISO strings", async () => {
90
+ const responses = [
91
+ createResponse([{
92
+ id: "m1",
93
+ channelId: "everyone",
94
+ content: "older",
95
+ replyToId: null,
96
+ createdAt: "2026-04-08 07:28:27.625",
97
+ user: { id: "u1", username: "alice", displayName: "Alice" },
98
+ replyTo: null,
99
+ }]),
100
+ createResponse({
101
+ id: "m2",
102
+ channelId: "everyone",
103
+ content: "hello",
104
+ replyToId: null,
105
+ createdAt: "2026-04-08T07:29:27.625",
106
+ user: { id: "u1", username: "alice", displayName: "Alice" },
107
+ replyTo: null,
108
+ }),
109
+ ];
110
+
111
+ globalThis.fetch = (async () => responses.shift() as Response) as typeof fetch;
112
+
113
+ const messages = await apiClient.getMessages("everyone", { limit: 1 });
114
+ const sentMessage = await apiClient.sendMessage("everyone", "hello");
115
+
116
+ expect(messages[0]?.createdAt).toBe("2026-04-08T07:28:27.625Z");
117
+ expect(sentMessage.createdAt).toBe("2026-04-08T07:29:27.625Z");
118
+ });
119
+
120
+ test("normalizes websocket chat timestamps before notifying listeners", async () => {
121
+ const seenCreatedAts: string[] = [];
122
+ const channel = apiClient.connectChannel("everyone", (message) => {
123
+ seenCreatedAts.push(message.createdAt);
124
+ });
125
+
126
+ await (apiClient as any).handleSocketMessage(JSON.stringify({
127
+ type: "chat.message",
128
+ channelId: "everyone",
129
+ data: {
130
+ id: "m1",
131
+ channelId: "everyone",
132
+ content: "hello",
133
+ replyToId: null,
134
+ createdAt: "2026-04-08 07:28:27.625",
135
+ user: { id: "u1", username: "alice", displayName: "Alice" },
136
+ replyTo: null,
137
+ },
138
+ }));
139
+
140
+ expect(seenCreatedAts).toEqual(["2026-04-08T07:28:27.625Z"]);
141
+ channel.close();
142
+ });
143
+ });
@@ -1,6 +1,7 @@
1
1
  import type { CompanyProfile, Fundamentals, PricePoint, Quote, TickerFinancials } from "../types/financials";
2
2
  import type { InstrumentSearchResult } from "../types/instrument";
3
3
  import { debugLog } from "./debug-log";
4
+ import { normalizeTimestamp } from "./timestamp";
4
5
 
5
6
  const DEFAULT_API_URL = "https://api.gloom.sh";
6
7
  const SESSION_COOKIE_NAMES = ["__Secure-gloomberb.session_token", "gloomberb.session_token"] as const;
@@ -14,6 +15,8 @@ export interface ChatMessage {
14
15
  createdAt: string;
15
16
  user: { id: string; username: string; displayName: string };
16
17
  replyTo?: { content: string; user: { username: string } } | null;
18
+ clientStatus?: "sending" | "failed";
19
+ clientError?: string | null;
17
20
  }
18
21
 
19
22
  export interface ChatChannel {
@@ -106,6 +109,17 @@ function marketKey(symbol: string, exchange?: string): string {
106
109
  return normalizedExchangeValue ? `${normalizedSymbolValue}:${normalizedExchangeValue}` : normalizedSymbolValue;
107
110
  }
108
111
 
112
+ function normalizeChatMessage(message: ChatMessage): ChatMessage {
113
+ return {
114
+ ...message,
115
+ createdAt: normalizeTimestamp(message.createdAt),
116
+ };
117
+ }
118
+
119
+ function normalizeChatMessages(messages: ChatMessage[]): ChatMessage[] {
120
+ return messages.map((message) => normalizeChatMessage(message));
121
+ }
122
+
109
123
  type ChannelListener = (message: ChatMessage) => void;
110
124
  type QuoteListener = (target: QuoteStreamTarget, quote: CloudQuotePayload) => void;
111
125
  type SessionCookieName = (typeof SESSION_COOKIE_NAMES)[number];
@@ -404,8 +418,9 @@ class GloomApiClient {
404
418
  }
405
419
 
406
420
  if (parsed?.type === "chat.message" && typeof parsed.channelId === "string" && parsed.data) {
421
+ const message = normalizeChatMessage(parsed.data as ChatMessage);
407
422
  for (const listener of this.channelListeners.get(parsed.channelId) ?? []) {
408
- listener(parsed.data as ChatMessage);
423
+ listener(message);
409
424
  }
410
425
  return;
411
426
  }
@@ -491,23 +506,30 @@ class GloomApiClient {
491
506
  if (opts?.before) params.set("before", opts.before);
492
507
  if (opts?.limit) params.set("limit", String(opts.limit));
493
508
  const qs = params.toString();
494
- return this.request<ChatMessage[]>(`/chat/channels/${channelId}/messages${qs ? `?${qs}` : ""}`);
509
+ const messages = await this.request<ChatMessage[]>(`/chat/channels/${channelId}/messages${qs ? `?${qs}` : ""}`);
510
+ return normalizeChatMessages(messages);
495
511
  }
496
512
 
497
513
  async sendMessage(channelId: string, content: string, replyToId?: string): Promise<ChatMessage> {
498
- return this.request<ChatMessage>(`/chat/channels/${channelId}/messages`, {
514
+ const message = await this.request<ChatMessage>(`/chat/channels/${channelId}/messages`, {
499
515
  method: "POST",
500
516
  body: JSON.stringify({ content, replyToId }),
501
517
  });
518
+ return normalizeChatMessage(message);
502
519
  }
503
520
 
504
521
  connectChannel(
505
522
  channelId: string,
506
523
  onMessage: (msg: ChatMessage) => void,
507
524
  onError?: (err: string) => void,
508
- ): { send: (content: string, replyToId?: string) => void; close: () => void } {
525
+ ): { send: (content: string, replyToId?: string) => Promise<ChatMessage>; close: () => void } {
509
526
  if (!channelId) {
510
- return { send: () => {}, close: () => {} };
527
+ return {
528
+ send: async () => {
529
+ throw new Error("Channel id is required");
530
+ },
531
+ close: () => {},
532
+ };
511
533
  }
512
534
 
513
535
  const listeners = this.channelListeners.get(channelId) ?? new Set<ChannelListener>();
@@ -520,14 +542,15 @@ class GloomApiClient {
520
542
  }
521
543
 
522
544
  return {
523
- send: (content: string, replyToId?: string) => {
524
- if (this.ws?.readyState === WebSocket.OPEN) {
525
- this.sendSocketMessage({ type: "chat.send", channelId, content, replyToId });
526
- return;
527
- }
528
- void this.sendMessage(channelId, content, replyToId).then(onMessage).catch((error) => {
545
+ send: async (content: string, replyToId?: string) => {
546
+ try {
547
+ const message = await this.sendMessage(channelId, content, replyToId);
548
+ onMessage(message);
549
+ return message;
550
+ } catch (error) {
529
551
  onError?.(error instanceof Error ? error.message : String(error));
530
- });
552
+ throw error;
553
+ }
531
554
  },
532
555
  close: () => {
533
556
  const current = this.channelListeners.get(channelId);
@@ -0,0 +1,27 @@
1
+ export interface BackNavigationEventLike {
2
+ name?: string;
3
+ ctrl?: boolean;
4
+ meta?: boolean;
5
+ option?: boolean;
6
+ shift?: boolean;
7
+ }
8
+
9
+ export function isPlainBackspace(event: BackNavigationEventLike): boolean {
10
+ return event.name === "backspace"
11
+ && !event.ctrl
12
+ && !event.meta
13
+ && !event.option
14
+ && !event.shift;
15
+ }
16
+
17
+ export function isPlainEscape(event: BackNavigationEventLike): boolean {
18
+ return (event.name === "escape" || event.name === "esc")
19
+ && !event.ctrl
20
+ && !event.meta
21
+ && !event.option
22
+ && !event.shift;
23
+ }
24
+
25
+ export function isBackNavigationKey(event: BackNavigationEventLike): boolean {
26
+ return isPlainBackspace(event) || isPlainEscape(event);
27
+ }
@@ -0,0 +1,14 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { formatTimeAgo } from "./format";
3
+
4
+ describe("formatTimeAgo", () => {
5
+ test("handles UTC ISO timestamps with explicit offsets", () => {
6
+ const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString().replace("Z", "+00:00");
7
+ expect(formatTimeAgo(fiveMinutesAgo)).toBe("5m ago");
8
+ });
9
+
10
+ test("treats space-separated chat timestamps without a timezone as UTC", () => {
11
+ const fiveMinutesAgo = new Date(Date.now() - 5 * 60_000).toISOString().replace("T", " ").replace("Z", "");
12
+ expect(formatTimeAgo(fiveMinutesAgo)).toBe("5m ago");
13
+ });
14
+ });
@@ -1,3 +1,5 @@
1
+ import { toTimestampMillis } from "./timestamp";
2
+
1
3
  const currencyFormatters = new Map<string, Intl.NumberFormat>();
2
4
  const numberFormatters = new Map<number, Intl.NumberFormat>();
3
5
 
@@ -143,9 +145,7 @@ export function convertCurrency(
143
145
 
144
146
  /** Format a date/timestamp as relative time (e.g., "5m ago", "2h ago") */
145
147
  export function formatTimeAgo(date: Date | string): string {
146
- const ts = typeof date === "string"
147
- ? new Date(date.endsWith("Z") ? date : date + "Z").getTime()
148
- : date.getTime();
148
+ const ts = toTimestampMillis(date);
149
149
  const seconds = Math.floor((Date.now() - ts) / 1000);
150
150
  if (seconds < 60) return "just now";
151
151
  const minutes = Math.floor(seconds / 60);
@@ -1,4 +1,4 @@
1
- import type { MarketState, Quote } from "../types/financials";
1
+ import type { MarketState, Quote, QuoteFieldProvenance } from "../types/financials";
2
2
  import { colors, priceColor } from "../theme/colors";
3
3
  import { formatPercentRaw } from "./format";
4
4
 
@@ -30,6 +30,22 @@ export function marketStateColor(state: MarketState): string {
30
30
  }
31
31
  }
32
32
 
33
+ export function marketStateDot(state?: MarketState): string {
34
+ switch (state) {
35
+ case "REGULAR":
36
+ return "\u25CF";
37
+ case "PRE":
38
+ case "POST":
39
+ return "\u25D0";
40
+ case "CLOSED":
41
+ case "PREPRE":
42
+ case "POSTPOST":
43
+ return "\u25CB";
44
+ default:
45
+ return "\u25CC";
46
+ }
47
+ }
48
+
33
49
  /** Short exchange display name */
34
50
  export function exchangeShortName(exchangeName?: string, fullExchangeName?: string): string {
35
51
  if (!exchangeName && !fullExchangeName) return "";
@@ -57,6 +73,35 @@ export function exchangeShortName(exchangeName?: string, fullExchangeName?: stri
57
73
  return map[name] || name;
58
74
  }
59
75
 
76
+ export function quoteProviderLabel(providerId?: string): string {
77
+ switch (providerId) {
78
+ case "ibkr":
79
+ return "Broker";
80
+ case "gloomberb-cloud":
81
+ return "Cloud";
82
+ case "yahoo":
83
+ return "Yahoo";
84
+ default:
85
+ return providerId || "Unknown";
86
+ }
87
+ }
88
+
89
+ export function quoteSourceLabel(
90
+ provenance?: QuoteFieldProvenance,
91
+ kind: "price" | "session" = "price",
92
+ ): string {
93
+ if (!provenance?.providerId) return "Unknown";
94
+ if (provenance.providerId === "ibkr") {
95
+ if (kind === "session") return "Broker";
96
+ if (provenance.dataSource === "live") return "IBKR live";
97
+ if (provenance.dataSource === "delayed") return "IBKR delayed";
98
+ return "IBKR";
99
+ }
100
+ if (provenance.providerId === "gloomberb-cloud") return "Cloud";
101
+ if (provenance.providerId === "yahoo") return "Yahoo";
102
+ return provenance.providerId;
103
+ }
104
+
60
105
  /** Get extended hours price info (pre-market or after-hours) for display */
61
106
  export function getExtendedHoursInfo(quote: Quote | null | undefined): { text: string; color: string } | null {
62
107
  if (!quote) return null;
@@ -0,0 +1,127 @@
1
+ import type { MarketState, Quote } from "../types/financials";
2
+
3
+ const US_EXTENDED_HOURS_EXCHANGES = new Set(["NASDAQ", "NYSE", "AMEX", "ARCA", "BATS"]);
4
+ const EXCHANGE_TIME_ZONES: Record<string, string> = {
5
+ NASDAQ: "America/New_York",
6
+ NYSE: "America/New_York",
7
+ ARCA: "America/New_York",
8
+ AMEX: "America/New_York",
9
+ BATS: "America/New_York",
10
+ FWB: "Europe/Berlin",
11
+ FWB2: "Europe/Berlin",
12
+ XETRA: "Europe/Berlin",
13
+ SWX: "Europe/Zurich",
14
+ SFB: "Europe/Stockholm",
15
+ HKEX: "Asia/Hong_Kong",
16
+ JPX: "Asia/Tokyo",
17
+ TPEX: "Asia/Taipei",
18
+ NSE: "Asia/Kolkata",
19
+ BSE: "Asia/Kolkata",
20
+ LSE: "Europe/London",
21
+ };
22
+ const EXCHANGE_ALIASES: Record<string, string> = {
23
+ NMS: "NASDAQ",
24
+ NGM: "NASDAQ",
25
+ NCM: "NASDAQ",
26
+ NAS: "NASDAQ",
27
+ NYQ: "NYSE",
28
+ NYS: "NYSE",
29
+ PCX: "AMEX",
30
+ ASE: "AMEX",
31
+ HKG: "HKEX",
32
+ GER: "XETRA",
33
+ };
34
+
35
+ type UsSessionState = Exclude<MarketState, never>;
36
+
37
+ function normalizeExchange(exchange?: string): string {
38
+ const normalized = (exchange ?? "").trim().toUpperCase();
39
+ return EXCHANGE_ALIASES[normalized] ?? normalized;
40
+ }
41
+
42
+ function isUsExtendedHoursExchange(exchange?: string): boolean {
43
+ return US_EXTENDED_HOURS_EXCHANGES.has(normalizeExchange(exchange));
44
+ }
45
+
46
+ function exchangeLocalDate(exchange: string, timestampMs: number): string | null {
47
+ const timeZone = EXCHANGE_TIME_ZONES[normalizeExchange(exchange)];
48
+ if (!timeZone) return null;
49
+
50
+ const parts = new Intl.DateTimeFormat("en-CA", {
51
+ timeZone,
52
+ year: "numeric",
53
+ month: "2-digit",
54
+ day: "2-digit",
55
+ }).formatToParts(new Date(timestampMs));
56
+ const year = parts.find((part) => part.type === "year")?.value;
57
+ const month = parts.find((part) => part.type === "month")?.value;
58
+ const day = parts.find((part) => part.type === "day")?.value;
59
+ if (!year || !month || !day) return null;
60
+ return `${year}-${month}-${day}`;
61
+ }
62
+
63
+ function usSessionState(timestampMs: number): UsSessionState {
64
+ const parts = new Intl.DateTimeFormat("en-US", {
65
+ timeZone: "America/New_York",
66
+ weekday: "short",
67
+ hour: "2-digit",
68
+ minute: "2-digit",
69
+ hourCycle: "h23",
70
+ }).formatToParts(new Date(timestampMs));
71
+
72
+ const weekday = parts.find((part) => part.type === "weekday")?.value ?? "";
73
+ if (weekday === "Sat" || weekday === "Sun") return "CLOSED";
74
+
75
+ const hour = Number(parts.find((part) => part.type === "hour")?.value ?? "0");
76
+ const minute = Number(parts.find((part) => part.type === "minute")?.value ?? "0");
77
+ const totalMinutes = hour * 60 + minute;
78
+
79
+ if (totalMinutes < 4 * 60) return "PREPRE";
80
+ if (totalMinutes < 9 * 60 + 30) return "PRE";
81
+ if (totalMinutes < 16 * 60) return "REGULAR";
82
+ if (totalMinutes < 20 * 60) return "POST";
83
+ return "POSTPOST";
84
+ }
85
+
86
+ export function isQuoteMissingActiveSessionPrice(quote: Quote): boolean {
87
+ if ((quote.marketState === "PRE" || quote.marketState === "PREPRE") && quote.preMarketPrice == null) {
88
+ return true;
89
+ }
90
+ if ((quote.marketState === "POST" || quote.marketState === "POSTPOST") && quote.postMarketPrice == null) {
91
+ return true;
92
+ }
93
+ return false;
94
+ }
95
+
96
+ export function isQuoteStaleForCurrentSession(quote: Quote | null | undefined, now = Date.now()): boolean {
97
+ if (!quote) return false;
98
+ if (isQuoteMissingActiveSessionPrice(quote)) return true;
99
+
100
+ const exchange = normalizeExchange(quote.listingExchangeName || quote.exchangeName);
101
+ if (!exchange || !Number.isFinite(quote.lastUpdated)) return false;
102
+
103
+ const quoteDate = exchangeLocalDate(exchange, quote.lastUpdated);
104
+ const currentDate = exchangeLocalDate(exchange, now);
105
+ if (!quoteDate || !currentDate || quoteDate === currentDate) return false;
106
+
107
+ if (quote.marketState === "REGULAR") return true;
108
+
109
+ if (isUsExtendedHoursExchange(exchange)) {
110
+ const session = usSessionState(now);
111
+ return session === "PRE" || session === "REGULAR" || session === "POST";
112
+ }
113
+
114
+ return false;
115
+ }
116
+
117
+ export function hasFreshQuoteForCurrentSession(
118
+ quotes: Iterable<Quote | null | undefined>,
119
+ now = Date.now(),
120
+ ): boolean {
121
+ for (const quote of quotes) {
122
+ if (quote && !isQuoteStaleForCurrentSession(quote, now)) {
123
+ return true;
124
+ }
125
+ }
126
+ return false;
127
+ }