gloomberb 0.2.0 → 0.3.1

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 (310) hide show
  1. package/README.md +20 -19
  2. package/bin/gloomberb +3 -0
  3. package/package.json +5 -4
  4. package/patches/@opentui%2Fcore@0.1.90.patch +356 -0
  5. package/src/app.tsx +1330 -0
  6. package/src/brokers/sync-broker-instance.test.ts +100 -0
  7. package/src/brokers/sync-broker-instance.ts +341 -0
  8. package/src/cli/commands/plugins.ts +173 -0
  9. package/src/cli/commands/portfolio.ts +210 -0
  10. package/src/cli/commands/search.ts +120 -0
  11. package/src/cli/commands/ticker.ts +375 -0
  12. package/src/cli/commands/watchlist.ts +210 -0
  13. package/src/cli/context.ts +62 -0
  14. package/src/cli/errors.ts +13 -0
  15. package/src/cli/helpers.ts +85 -0
  16. package/src/cli/index.ts +143 -0
  17. package/src/cli/ticker-resolution.ts +39 -0
  18. package/src/cli/types.ts +15 -0
  19. package/src/cli.test.ts +361 -0
  20. package/src/components/chart/chart-data.ts +239 -0
  21. package/src/components/chart/chart-renderer.ts +1199 -0
  22. package/src/components/chart/chart-types.ts +82 -0
  23. package/src/components/chart/chart-viewport.ts +21 -0
  24. package/src/components/chart/chart.test.ts +439 -0
  25. package/src/components/chart/comparison-chart-data.test.ts +109 -0
  26. package/src/components/chart/comparison-chart-data.ts +316 -0
  27. package/src/components/chart/comparison-chart-renderer.test.ts +84 -0
  28. package/src/components/chart/comparison-chart-renderer.ts +354 -0
  29. package/src/components/chart/comparison-stock-chart.tsx +1486 -0
  30. package/src/components/chart/cursor-motion.ts +48 -0
  31. package/src/components/chart/native/chart-rasterizer.ts +732 -0
  32. package/src/components/chart/native/kitty-adapter.ts +12 -0
  33. package/src/components/chart/native/kitty-manager.ts +142 -0
  34. package/src/components/chart/native/kitty-protocol.ts +110 -0
  35. package/src/components/chart/native/kitty-support.ts +71 -0
  36. package/src/components/chart/native/kitty.test.ts +620 -0
  37. package/src/components/chart/native/renderer-selection.ts +24 -0
  38. package/src/components/chart/native/surface-manager.ts +225 -0
  39. package/src/components/chart/native/surface-sync.ts +34 -0
  40. package/src/components/chart/stock-chart-renderer-switch.test.tsx +165 -0
  41. package/src/components/chart/stock-chart.test.ts +107 -0
  42. package/src/components/chart/stock-chart.tsx +1556 -0
  43. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +137 -0
  44. package/src/components/command-bar/command-bar.test.tsx +1524 -0
  45. package/src/components/command-bar/command-bar.tsx +4823 -0
  46. package/src/components/command-bar/command-registry.ts +261 -0
  47. package/src/components/command-bar/helpers.test.ts +224 -0
  48. package/src/components/command-bar/helpers.ts +371 -0
  49. package/src/components/command-bar/root-shortcuts.ts +158 -0
  50. package/src/components/command-bar/view-model.test.ts +152 -0
  51. package/src/components/command-bar/view-model.ts +185 -0
  52. package/src/components/command-bar/workflow-ops.test.ts +70 -0
  53. package/src/components/command-bar/workflow-ops.ts +433 -0
  54. package/src/components/command-bar/workflow-types.ts +127 -0
  55. package/src/components/detail-feed-view.test.tsx +105 -0
  56. package/src/components/detail-feed-view.tsx +295 -0
  57. package/src/components/index.ts +32 -0
  58. package/src/components/layout/floating-pane.tsx +49 -0
  59. package/src/components/layout/header.tsx +129 -0
  60. package/src/components/layout/pane-header.tsx +44 -0
  61. package/src/components/layout/pane.tsx +49 -0
  62. package/src/components/layout/pixel-mouse-mode.test.ts +122 -0
  63. package/src/components/layout/pixel-mouse.test.ts +40 -0
  64. package/src/components/layout/shell.test.tsx +625 -0
  65. package/src/components/layout/shell.tsx +1200 -0
  66. package/src/components/layout/status-bar.test.tsx +173 -0
  67. package/src/components/layout/status-bar.tsx +138 -0
  68. package/src/components/markdown-editor.tsx +134 -0
  69. package/src/components/onboarding/onboarding-wizard.test.tsx +184 -0
  70. package/src/components/onboarding/onboarding-wizard.tsx +1365 -0
  71. package/src/components/pane-settings-dialog.tsx +468 -0
  72. package/src/components/pane-template-wizard.test.tsx +52 -0
  73. package/src/components/pane-template-wizard.tsx +199 -0
  74. package/src/components/price-selector-dialog.tsx +195 -0
  75. package/src/components/spinner.tsx +2 -0
  76. package/src/components/tab-bar.tsx +17 -0
  77. package/src/components/ticker-badge-text.test.tsx +160 -0
  78. package/src/components/ticker-badge-text.tsx +58 -0
  79. package/src/components/ticker-badge.tsx +88 -0
  80. package/src/components/ticker-list-table.tsx +188 -0
  81. package/src/components/toggle-list.tsx +74 -0
  82. package/src/components/ui/button.tsx +77 -0
  83. package/src/components/ui/data-table.tsx +210 -0
  84. package/src/components/ui/external-link.tsx +22 -0
  85. package/src/components/ui/fields.tsx +187 -0
  86. package/src/components/ui/frame.tsx +73 -0
  87. package/src/components/ui/index.ts +53 -0
  88. package/src/components/ui/list-view.tsx +166 -0
  89. package/src/components/ui/loading.tsx +70 -0
  90. package/src/components/ui/status.tsx +83 -0
  91. package/src/components/ui/tabs.tsx +47 -0
  92. package/src/components/ui/toggle.tsx +164 -0
  93. package/src/components/ui/ui.test.tsx +134 -0
  94. package/src/data/app-persistence.ts +25 -0
  95. package/src/data/config-store.test.ts +310 -0
  96. package/src/data/config-store.ts +469 -0
  97. package/src/data/plugin-state-store.ts +102 -0
  98. package/src/data/resource-store.ts +323 -0
  99. package/src/data/session-store.ts +52 -0
  100. package/src/data/sqlite-cache.test.ts +103 -0
  101. package/src/data/sqlite-database.ts +62 -0
  102. package/src/data/sqlite-json.ts +19 -0
  103. package/src/data/ticker-repository.ts +45 -0
  104. package/src/data/ticker-store.ts +46 -0
  105. package/src/index.tsx +67 -0
  106. package/src/market-data/coordinator.test.ts +369 -0
  107. package/src/market-data/coordinator.ts +580 -0
  108. package/src/market-data/hooks.tsx +234 -0
  109. package/src/market-data/query-store.ts +28 -0
  110. package/src/market-data/request-types.test.ts +64 -0
  111. package/src/market-data/request-types.ts +70 -0
  112. package/src/market-data/result-types.ts +56 -0
  113. package/src/market-data/selectors.ts +166 -0
  114. package/src/market-data/trace.ts +7 -0
  115. package/src/pane-settings.test.ts +71 -0
  116. package/src/pane-settings.ts +73 -0
  117. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +338 -0
  118. package/src/plugins/builtin/ai/gloomberb-cli.ts +70 -0
  119. package/src/plugins/builtin/ai/index.test.tsx +139 -0
  120. package/src/plugins/builtin/ai/index.tsx +86 -0
  121. package/src/plugins/builtin/ai/providers.ts +99 -0
  122. package/src/plugins/builtin/ai/runner.ts +82 -0
  123. package/src/plugins/builtin/ai/screener-contract.test.ts +117 -0
  124. package/src/plugins/builtin/ai/screener-contract.ts +174 -0
  125. package/src/plugins/builtin/ai/screener-pane.test.tsx +365 -0
  126. package/src/plugins/builtin/ai/screener-pane.tsx +1161 -0
  127. package/src/plugins/builtin/ai/settings.ts +79 -0
  128. package/src/plugins/builtin/ai/ticker-context.ts +64 -0
  129. package/src/plugins/builtin/ai/utils.ts +71 -0
  130. package/src/plugins/builtin/ask-ai.test.tsx +258 -0
  131. package/src/plugins/builtin/ask-ai.tsx +9 -0
  132. package/src/plugins/builtin/chat-controller.test.ts +217 -0
  133. package/src/plugins/builtin/chat-controller.ts +340 -0
  134. package/src/plugins/builtin/chat.test.tsx +451 -0
  135. package/src/plugins/builtin/chat.tsx +620 -0
  136. package/src/plugins/builtin/comparison-chart.test.tsx +277 -0
  137. package/src/plugins/builtin/comparison-chart.tsx +169 -0
  138. package/src/plugins/builtin/data-providers.ts +16 -0
  139. package/src/plugins/builtin/debug.tsx +351 -0
  140. package/src/plugins/builtin/help.test.tsx +110 -0
  141. package/src/plugins/builtin/help.tsx +281 -0
  142. package/src/plugins/builtin/layout-manager.tsx +264 -0
  143. package/src/plugins/builtin/manual-entry.tsx +25 -0
  144. package/src/plugins/builtin/news.tsx +143 -0
  145. package/src/plugins/builtin/notes-files.ts +58 -0
  146. package/src/plugins/builtin/notes.tsx +396 -0
  147. package/src/plugins/builtin/options-availability.test.tsx +179 -0
  148. package/src/plugins/builtin/options-availability.ts +158 -0
  149. package/src/plugins/builtin/options.tsx +364 -0
  150. package/src/plugins/builtin/portfolio-list/header.tsx +227 -0
  151. package/src/plugins/builtin/portfolio-list/index.tsx +99 -0
  152. package/src/plugins/builtin/portfolio-list/metrics.ts +436 -0
  153. package/src/plugins/builtin/portfolio-list/pane.tsx +533 -0
  154. package/src/plugins/builtin/portfolio-list/settings.ts +280 -0
  155. package/src/plugins/builtin/portfolio-list/summary.tsx +270 -0
  156. package/src/plugins/builtin/portfolio-list/table.tsx +64 -0
  157. package/src/plugins/builtin/portfolio-list.test.ts +74 -0
  158. package/src/plugins/builtin/portfolio-list.test.tsx +1164 -0
  159. package/src/plugins/builtin/portfolio-metrics.test.ts +153 -0
  160. package/src/plugins/builtin/quote-monitor.test.tsx +114 -0
  161. package/src/plugins/builtin/sec.tsx +250 -0
  162. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +43 -0
  163. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +390 -0
  164. package/src/plugins/builtin/ticker-detail/index.tsx +80 -0
  165. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +242 -0
  166. package/src/plugins/builtin/ticker-detail/pane.tsx +183 -0
  167. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +83 -0
  168. package/src/plugins/builtin/ticker-detail/settings.ts +132 -0
  169. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +193 -0
  170. package/src/plugins/builtin/ticker-detail.test.tsx +578 -0
  171. package/src/plugins/builtin/yahoo.ts +18 -0
  172. package/src/plugins/event-bus.ts +37 -0
  173. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +35 -0
  174. package/src/plugins/ibkr/account-cache.test.ts +101 -0
  175. package/src/plugins/ibkr/account-cache.ts +137 -0
  176. package/src/plugins/ibkr/broker-adapter.ts +168 -0
  177. package/src/plugins/ibkr/config.test.ts +68 -0
  178. package/src/plugins/ibkr/config.ts +262 -0
  179. package/src/plugins/ibkr/dialogs.tsx +83 -0
  180. package/src/plugins/ibkr/flex.test.ts +107 -0
  181. package/src/plugins/ibkr/flex.ts +267 -0
  182. package/src/plugins/ibkr/fundamental-parser.ts +149 -0
  183. package/src/plugins/ibkr/gateway-helpers.ts +28 -0
  184. package/src/plugins/ibkr/gateway-selection.ts +51 -0
  185. package/src/plugins/ibkr/gateway-service.test.ts +450 -0
  186. package/src/plugins/ibkr/gateway-service.ts +2228 -0
  187. package/src/plugins/ibkr/index.test.tsx +183 -0
  188. package/src/plugins/ibkr/index.tsx +153 -0
  189. package/src/plugins/ibkr/instance-selection.test.ts +121 -0
  190. package/src/plugins/ibkr/instance-selection.ts +36 -0
  191. package/src/plugins/ibkr/price-normalization.test.ts +38 -0
  192. package/src/plugins/ibkr/price-normalization.ts +59 -0
  193. package/src/plugins/ibkr/trade-badge.tsx +27 -0
  194. package/src/plugins/ibkr/trade-tab.test.tsx +225 -0
  195. package/src/plugins/ibkr/trade-tab.tsx +1050 -0
  196. package/src/plugins/ibkr/trade-utils.test.ts +175 -0
  197. package/src/plugins/ibkr/trade-utils.ts +149 -0
  198. package/src/plugins/ibkr/trading-pane.tsx +438 -0
  199. package/src/plugins/ibkr/trading-state.test.ts +109 -0
  200. package/src/plugins/ibkr/trading-state.ts +339 -0
  201. package/src/plugins/loader.ts +71 -0
  202. package/src/plugins/pane-manager.test.ts +103 -0
  203. package/src/plugins/pane-manager.ts +974 -0
  204. package/src/plugins/plugin-persistence.ts +85 -0
  205. package/src/plugins/plugin-runtime.test.tsx +185 -0
  206. package/src/plugins/plugin-runtime.tsx +175 -0
  207. package/src/plugins/prediction-markets/cache.ts +114 -0
  208. package/src/plugins/prediction-markets/categories.ts +223 -0
  209. package/src/plugins/prediction-markets/chart.tsx +512 -0
  210. package/src/plugins/prediction-markets/columns.ts +133 -0
  211. package/src/plugins/prediction-markets/controller-data.ts +614 -0
  212. package/src/plugins/prediction-markets/controller.ts +725 -0
  213. package/src/plugins/prediction-markets/detail/book.tsx +118 -0
  214. package/src/plugins/prediction-markets/detail/outcomes.tsx +102 -0
  215. package/src/plugins/prediction-markets/detail/overview.tsx +73 -0
  216. package/src/plugins/prediction-markets/detail/pane.tsx +308 -0
  217. package/src/plugins/prediction-markets/detail/rules.tsx +16 -0
  218. package/src/plugins/prediction-markets/detail/shared.tsx +29 -0
  219. package/src/plugins/prediction-markets/detail/trades.tsx +30 -0
  220. package/src/plugins/prediction-markets/detail.test.tsx +47 -0
  221. package/src/plugins/prediction-markets/index.tsx +102 -0
  222. package/src/plugins/prediction-markets/keyboard.ts +64 -0
  223. package/src/plugins/prediction-markets/launch.test.ts +126 -0
  224. package/src/plugins/prediction-markets/launch.ts +240 -0
  225. package/src/plugins/prediction-markets/metrics.ts +311 -0
  226. package/src/plugins/prediction-markets/navigation.ts +90 -0
  227. package/src/plugins/prediction-markets/outcome-order.ts +19 -0
  228. package/src/plugins/prediction-markets/pane.test.tsx +425 -0
  229. package/src/plugins/prediction-markets/pane.tsx +294 -0
  230. package/src/plugins/prediction-markets/plugin.test.tsx +570 -0
  231. package/src/plugins/prediction-markets/rows.ts +229 -0
  232. package/src/plugins/prediction-markets/services/fetch.ts +97 -0
  233. package/src/plugins/prediction-markets/services/kalshi-adapter.ts +555 -0
  234. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +660 -0
  235. package/src/plugins/prediction-markets/services/polymarket-ws.ts +108 -0
  236. package/src/plugins/prediction-markets/settings.ts +164 -0
  237. package/src/plugins/prediction-markets/status.ts +24 -0
  238. package/src/plugins/prediction-markets/table.tsx +84 -0
  239. package/src/plugins/prediction-markets/test-helpers.tsx +636 -0
  240. package/src/plugins/prediction-markets/types.ts +186 -0
  241. package/src/plugins/registry.ts +633 -0
  242. package/src/sources/exchange-rates.ts +18 -0
  243. package/src/sources/gloomberb-cloud.test.ts +199 -0
  244. package/src/sources/gloomberb-cloud.ts +326 -0
  245. package/src/sources/provider-errors.ts +19 -0
  246. package/src/sources/provider-router.test.ts +1476 -0
  247. package/src/sources/provider-router.ts +1405 -0
  248. package/src/sources/sec-edgar.test.ts +286 -0
  249. package/src/sources/sec-edgar.ts +526 -0
  250. package/src/sources/yahoo-finance.test.ts +14 -0
  251. package/src/sources/yahoo-finance.ts +1017 -0
  252. package/src/state/app-activity.test.ts +41 -0
  253. package/src/state/app-activity.ts +106 -0
  254. package/src/state/app-bootstrap.test.ts +471 -0
  255. package/src/state/app-bootstrap.ts +308 -0
  256. package/src/state/app-context.test.ts +406 -0
  257. package/src/state/app-context.test.tsx +132 -0
  258. package/src/state/app-context.tsx +969 -0
  259. package/src/state/selectors.ts +67 -0
  260. package/src/state/session-persistence.test.ts +128 -0
  261. package/src/state/session-persistence.ts +161 -0
  262. package/src/state/ticker-refresh-queue.test.ts +46 -0
  263. package/src/state/ticker-refresh-queue.ts +40 -0
  264. package/src/state/use-inline-tickers.ts +174 -0
  265. package/src/state/use-quote-streaming.test.ts +37 -0
  266. package/src/state/use-quote-streaming.test.tsx +70 -0
  267. package/src/state/use-quote-streaming.ts +80 -0
  268. package/src/state/use-session-snapshot.ts +84 -0
  269. package/src/test-support/data-provider.ts +22 -0
  270. package/src/theme/color-utils.ts +67 -0
  271. package/src/theme/colors.ts +128 -0
  272. package/src/theme/themes.test.ts +45 -0
  273. package/src/theme/themes.ts +491 -0
  274. package/src/types/broker.ts +105 -0
  275. package/src/types/config.ts +434 -0
  276. package/src/types/data-provider.ts +81 -0
  277. package/src/types/financials.ts +142 -0
  278. package/src/types/instrument.ts +29 -0
  279. package/src/types/persistence.ts +21 -0
  280. package/src/types/plugin.ts +354 -0
  281. package/src/types/ticker.ts +58 -0
  282. package/src/types/trading.ts +85 -0
  283. package/src/updater.test.ts +162 -0
  284. package/src/updater.ts +247 -0
  285. package/src/utils/api-client.ts +666 -0
  286. package/src/utils/ascii-chart.ts +172 -0
  287. package/src/utils/broker-instances.ts +47 -0
  288. package/src/utils/cli-output.ts +83 -0
  289. package/src/utils/currency-units.test.ts +49 -0
  290. package/src/utils/currency-units.ts +82 -0
  291. package/src/utils/debug-log.ts +133 -0
  292. package/src/utils/exchange-rate-map.ts +8 -0
  293. package/src/utils/format.ts +158 -0
  294. package/src/utils/fuzzy-search.ts +34 -0
  295. package/src/utils/market-status.ts +95 -0
  296. package/src/utils/options.ts +86 -0
  297. package/src/utils/price-history.ts +39 -0
  298. package/src/utils/quote-time.test.ts +22 -0
  299. package/src/utils/quote-time.ts +50 -0
  300. package/src/utils/sec.test.ts +55 -0
  301. package/src/utils/sec.ts +45 -0
  302. package/src/utils/ticker-list.test.ts +22 -0
  303. package/src/utils/ticker-list.ts +35 -0
  304. package/src/utils/ticker-metadata.ts +51 -0
  305. package/src/utils/ticker-search.test.ts +227 -0
  306. package/src/utils/ticker-search.ts +764 -0
  307. package/src/utils/ticker-tokenizer.test.ts +48 -0
  308. package/src/utils/ticker-tokenizer.ts +72 -0
  309. package/src/version.ts +1 -0
  310. package/install.mjs +0 -68
@@ -0,0 +1,666 @@
1
+ import type { CompanyProfile, Fundamentals, PricePoint, Quote, TickerFinancials } from "../types/financials";
2
+ import type { InstrumentSearchResult } from "../types/instrument";
3
+ import { debugLog } from "./debug-log";
4
+
5
+ const DEFAULT_API_URL = "https://api.gloom.sh";
6
+ const cloudApiLog = debugLog.createLogger("cloud-api");
7
+
8
+ export interface ChatMessage {
9
+ id: string;
10
+ channelId: string;
11
+ content: string;
12
+ replyToId: string | null;
13
+ createdAt: string;
14
+ user: { id: string; username: string; displayName: string };
15
+ replyTo?: { content: string; user: { username: string } } | null;
16
+ }
17
+
18
+ export interface ChatChannel {
19
+ id: string;
20
+ name: string;
21
+ description: string | null;
22
+ created_at: string;
23
+ }
24
+
25
+ export interface AuthUser {
26
+ id: string;
27
+ name: string;
28
+ email: string;
29
+ username: string | null;
30
+ emailVerified: boolean;
31
+ image: string | null;
32
+ createdAt: string;
33
+ updatedAt: string;
34
+ }
35
+
36
+ export type PersistedAuthUser = Pick<AuthUser, "id" | "emailVerified"> & Partial<AuthUser>;
37
+
38
+ export interface CloudQuotePayload extends Quote {
39
+ providerId: "gloomberb-cloud";
40
+ dataSource: "live" | "delayed";
41
+ }
42
+
43
+ export interface CloudCompanyProfile extends CompanyProfile {}
44
+
45
+ export interface CloudFundamentals extends Fundamentals {}
46
+
47
+ export interface CloudPricePointPayload {
48
+ date: string;
49
+ open?: number;
50
+ high?: number;
51
+ low?: number;
52
+ close: number;
53
+ volume?: number;
54
+ }
55
+
56
+ export type CloudMarketStatus =
57
+ | "success"
58
+ | "partial"
59
+ | "empty"
60
+ | "unsupported"
61
+ | "retryable_error"
62
+ | "fatal_error";
63
+
64
+ export interface CloudMarketResponse<T> {
65
+ status: CloudMarketStatus;
66
+ data: T | null;
67
+ reasonCode?: string;
68
+ asOf?: string;
69
+ staleAt?: string;
70
+ providerMeta?: {
71
+ upstream?: string;
72
+ normalizedSymbol?: string;
73
+ normalizedExchange?: string;
74
+ latencyMs?: number;
75
+ range?: string;
76
+ granularity?: string;
77
+ timezone?: string;
78
+ currency?: string;
79
+ barCount?: number;
80
+ };
81
+ }
82
+
83
+ export interface CloudVerificationResponse {
84
+ sent: boolean;
85
+ email?: string;
86
+ alreadyVerified?: boolean;
87
+ }
88
+
89
+ export interface QuoteStreamTarget {
90
+ symbol: string;
91
+ exchange?: string;
92
+ }
93
+
94
+ function normalizeSymbol(value: string): string {
95
+ return value.trim().toUpperCase();
96
+ }
97
+
98
+ function normalizeExchange(value?: string): string {
99
+ return (value ?? "").trim().toUpperCase();
100
+ }
101
+
102
+ function marketKey(symbol: string, exchange?: string): string {
103
+ const normalizedSymbolValue = normalizeSymbol(symbol);
104
+ const normalizedExchangeValue = normalizeExchange(exchange);
105
+ return normalizedExchangeValue ? `${normalizedSymbolValue}:${normalizedExchangeValue}` : normalizedSymbolValue;
106
+ }
107
+
108
+ type ChannelListener = (message: ChatMessage) => void;
109
+ type QuoteListener = (target: QuoteStreamTarget, quote: CloudQuotePayload) => void;
110
+
111
+ class ApiRequestError extends Error {
112
+ constructor(message: string, readonly status?: number) {
113
+ super(message);
114
+ this.name = "ApiRequestError";
115
+ }
116
+ }
117
+
118
+ class GloomApiClient {
119
+ private sessionToken: string | null = null;
120
+ private websocketToken: string | null = null;
121
+ private currentUser: AuthUser | null = null;
122
+ private readonly baseUrl: string;
123
+
124
+ private ws: WebSocket | null = null;
125
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
126
+ private reconnectDelayMs = 1000;
127
+
128
+ private readonly channelListeners = new Map<string, Set<ChannelListener>>();
129
+ private readonly quoteListeners = new Map<string, Set<QuoteListener>>();
130
+ private readonly quoteTargets = new Map<string, QuoteStreamTarget>();
131
+
132
+ constructor() {
133
+ this.baseUrl = process.env.GLOOMBERB_API_URL ?? DEFAULT_API_URL;
134
+ }
135
+
136
+ getSessionToken(): string | null {
137
+ return this.sessionToken;
138
+ }
139
+
140
+ getWebSocketToken(): string | null {
141
+ return this.websocketToken;
142
+ }
143
+
144
+ setSessionToken(token: string | null): void {
145
+ this.sessionToken = token;
146
+ if (!token) {
147
+ this.websocketToken = null;
148
+ this.currentUser = null;
149
+ this.teardownSocket();
150
+ }
151
+ }
152
+
153
+ setWebSocketToken(token: string | null): void {
154
+ this.websocketToken = token;
155
+ if (!token) {
156
+ this.teardownSocket();
157
+ }
158
+ }
159
+
160
+ getCurrentUser(): AuthUser | null {
161
+ return this.currentUser;
162
+ }
163
+
164
+ restoreCachedUser(user: PersistedAuthUser | null): void {
165
+ if (!this.sessionToken || !user?.id) {
166
+ this.setCurrentUser(null);
167
+ return;
168
+ }
169
+ this.setCurrentUser({
170
+ id: user.id,
171
+ name: typeof user.name === "string" && user.name.length > 0
172
+ ? user.name
173
+ : user.username ?? "User",
174
+ email: typeof user.email === "string" ? user.email : "",
175
+ username: typeof user.username === "string" ? user.username : null,
176
+ emailVerified: user.emailVerified === true,
177
+ image: typeof user.image === "string" ? user.image : null,
178
+ createdAt: typeof user.createdAt === "string" ? user.createdAt : "",
179
+ updatedAt: typeof user.updatedAt === "string" ? user.updatedAt : "",
180
+ });
181
+ }
182
+
183
+ isVerified(): boolean {
184
+ return !!this.sessionToken && !!this.currentUser?.emailVerified;
185
+ }
186
+
187
+ private setCurrentUser(user: AuthUser | null): void {
188
+ this.currentUser = user;
189
+ if (!this.shouldKeepSocketOpen()) {
190
+ this.teardownSocket();
191
+ return;
192
+ }
193
+ this.ensureSocket();
194
+ }
195
+
196
+ private extractSessionCookie(res: Response): void {
197
+ const setCookie = res.headers.getSetCookie?.() ?? [];
198
+ for (const cookie of setCookie) {
199
+ const match = cookie.match(/gloomberb\.session_token=([^;]+)/);
200
+ if (match) {
201
+ this.sessionToken = match[1] ?? null;
202
+ }
203
+ }
204
+ }
205
+
206
+ private async request<T>(path: string, options?: RequestInit): Promise<T> {
207
+ const headers = new Headers(options?.headers);
208
+ if (!headers.has("Content-Type") && options?.method && options.method !== "GET") {
209
+ headers.set("Content-Type", "application/json");
210
+ }
211
+ if (this.sessionToken) {
212
+ headers.set("Cookie", `gloomberb.session_token=${this.sessionToken}`);
213
+ }
214
+ headers.set("Origin", this.baseUrl);
215
+
216
+ const res = await fetch(`${this.baseUrl}${path}`, {
217
+ ...options,
218
+ headers,
219
+ credentials: "include",
220
+ });
221
+
222
+ this.extractSessionCookie(res);
223
+
224
+ if (!res.ok) {
225
+ const body = await res.text();
226
+ let msg: string;
227
+ try {
228
+ msg = JSON.parse(body).message ?? body;
229
+ } catch {
230
+ msg = body;
231
+ }
232
+ throw new ApiRequestError(msg, res.status);
233
+ }
234
+
235
+ const text = await res.text();
236
+ if (!text) return undefined as T;
237
+ const parsed = JSON.parse(text) as T & { token?: string };
238
+ if (typeof parsed?.token === "string" && parsed.token.length > 0) {
239
+ this.websocketToken = parsed.token;
240
+ }
241
+ return parsed as T;
242
+ }
243
+
244
+ private getWebSocketBaseUrl(): string {
245
+ const wsProtocol = this.baseUrl.startsWith("https") ? "wss" : "ws";
246
+ return this.baseUrl.replace(/^https?/, wsProtocol);
247
+ }
248
+
249
+ private getSocketAuthToken(): string | null {
250
+ return this.websocketToken || this.sessionToken;
251
+ }
252
+
253
+ private shouldKeepSocketOpen(): boolean {
254
+ return !!this.getSocketAuthToken()
255
+ && !!this.currentUser?.emailVerified
256
+ && (this.channelListeners.size > 0 || this.quoteTargets.size > 0);
257
+ }
258
+
259
+ private ensureSocket(): void {
260
+ if (!this.shouldKeepSocketOpen() || this.ws || this.reconnectTimer) return;
261
+
262
+ const socketToken = this.getSocketAuthToken();
263
+ if (!socketToken) return;
264
+ const url = `${this.getWebSocketBaseUrl()}/cloud/ws?token=${encodeURIComponent(socketToken)}`;
265
+ cloudApiLog.info("open websocket", {
266
+ hasToken: !!socketToken,
267
+ tokenSource: this.websocketToken ? "websocket" : "session",
268
+ quoteTargets: this.quoteTargets.size,
269
+ channelTargets: this.channelListeners.size,
270
+ });
271
+ const ws = new WebSocket(url);
272
+ this.ws = ws;
273
+
274
+ ws.onopen = () => {
275
+ if (this.ws !== ws) return;
276
+ cloudApiLog.info("websocket open");
277
+ this.reconnectDelayMs = 1000;
278
+ this.flushSubscriptions();
279
+ };
280
+
281
+ ws.onmessage = (event) => {
282
+ void this.handleSocketMessage(String(event.data));
283
+ };
284
+
285
+ ws.onclose = () => {
286
+ if (this.ws === ws) {
287
+ this.ws = null;
288
+ }
289
+ cloudApiLog.warn("websocket closed", { quoteTargets: this.quoteTargets.size, channelTargets: this.channelListeners.size });
290
+ if (!this.shouldKeepSocketOpen()) return;
291
+ this.scheduleReconnect();
292
+ };
293
+
294
+ ws.onerror = () => {
295
+ // reconnect is handled by onclose
296
+ };
297
+ }
298
+
299
+ private teardownSocket(): void {
300
+ if (this.reconnectTimer) {
301
+ clearTimeout(this.reconnectTimer);
302
+ this.reconnectTimer = null;
303
+ }
304
+ const ws = this.ws;
305
+ this.ws = null;
306
+ if (ws) {
307
+ cloudApiLog.info("teardown websocket");
308
+ }
309
+ try {
310
+ ws?.close();
311
+ } catch {
312
+ // ignore closed sockets
313
+ }
314
+ }
315
+
316
+ private scheduleReconnect(): void {
317
+ if (this.reconnectTimer || !this.shouldKeepSocketOpen()) return;
318
+ const delay = this.reconnectDelayMs;
319
+ this.reconnectTimer = setTimeout(() => {
320
+ this.reconnectTimer = null;
321
+ this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, 10_000);
322
+ this.ensureSocket();
323
+ }, delay);
324
+ }
325
+
326
+ private sendSocketMessage(payload: unknown): void {
327
+ if (this.ws?.readyState !== WebSocket.OPEN) return;
328
+ if (payload && typeof payload === "object" && "type" in (payload as Record<string, unknown>)) {
329
+ const type = (payload as Record<string, unknown>).type;
330
+ if (type === "market.subscribe" || type === "market.unsubscribe" || type === "chat.subscribe") {
331
+ cloudApiLog.info("send websocket message", payload);
332
+ }
333
+ }
334
+ this.ws.send(JSON.stringify(payload));
335
+ }
336
+
337
+ private flushSubscriptions(): void {
338
+ if (this.ws?.readyState !== WebSocket.OPEN) return;
339
+
340
+ for (const channelId of this.channelListeners.keys()) {
341
+ this.sendSocketMessage({ type: "chat.subscribe", channelId });
342
+ }
343
+
344
+ if (this.quoteTargets.size > 0) {
345
+ this.sendSocketMessage({
346
+ type: "market.subscribe",
347
+ symbols: [...this.quoteTargets.values()].map((target) => ({
348
+ symbol: target.symbol,
349
+ exchange: target.exchange ?? "",
350
+ })),
351
+ });
352
+ }
353
+ }
354
+
355
+ private async handleSocketMessage(raw: string): Promise<void> {
356
+ let parsed: any;
357
+ try {
358
+ parsed = JSON.parse(raw);
359
+ } catch {
360
+ return;
361
+ }
362
+
363
+ if (parsed?.type === "ready" && parsed.user) {
364
+ cloudApiLog.info("websocket ready", { emailVerified: parsed.user.emailVerified === true });
365
+ this.currentUser = {
366
+ ...(this.currentUser ?? {}),
367
+ ...parsed.user,
368
+ };
369
+ return;
370
+ }
371
+
372
+ if (parsed?.type === "auth.unverified") {
373
+ cloudApiLog.warn("websocket marked unverified");
374
+ if (this.currentUser) {
375
+ this.currentUser = { ...this.currentUser, emailVerified: false };
376
+ }
377
+ this.teardownSocket();
378
+ return;
379
+ }
380
+
381
+ if (parsed?.type === "chat.message" && typeof parsed.channelId === "string" && parsed.data) {
382
+ for (const listener of this.channelListeners.get(parsed.channelId) ?? []) {
383
+ listener(parsed.data as ChatMessage);
384
+ }
385
+ return;
386
+ }
387
+
388
+ if (parsed?.type === "market.quote" && parsed.quote && typeof parsed.symbol === "string") {
389
+ const key = marketKey(parsed.symbol, parsed.exchange);
390
+ const target = this.quoteTargets.get(key) ?? {
391
+ symbol: normalizeSymbol(parsed.symbol),
392
+ exchange: normalizeExchange(parsed.exchange),
393
+ };
394
+ for (const listener of this.quoteListeners.get(key) ?? []) {
395
+ listener(target, parsed.quote as CloudQuotePayload);
396
+ }
397
+ }
398
+ }
399
+
400
+ async ensureVerifiedSession(): Promise<AuthUser | null> {
401
+ if (!this.sessionToken) return null;
402
+ if (!this.currentUser) {
403
+ await this.getSession();
404
+ }
405
+ return this.currentUser?.emailVerified ? this.currentUser : null;
406
+ }
407
+
408
+ async signUp(email: string, username: string, name: string, password: string): Promise<AuthUser> {
409
+ const result = await this.request<{ user: AuthUser }>("/api/auth/api/auth/sign-up/email", {
410
+ method: "POST",
411
+ body: JSON.stringify({ email, username, name, password }),
412
+ });
413
+ this.setCurrentUser(result.user);
414
+ return result.user;
415
+ }
416
+
417
+ async signIn(email: string, password: string): Promise<AuthUser> {
418
+ const result = await this.request<{ user: AuthUser }>("/api/auth/api/auth/sign-in/email", {
419
+ method: "POST",
420
+ body: JSON.stringify({ email, password }),
421
+ });
422
+ this.setCurrentUser(result.user);
423
+ return result.user;
424
+ }
425
+
426
+ async signOut(): Promise<void> {
427
+ await this.request("/api/auth/api/auth/sign-out", { method: "POST" });
428
+ this.sessionToken = null;
429
+ this.setCurrentUser(null);
430
+ }
431
+
432
+ async getSession(): Promise<AuthUser | null> {
433
+ try {
434
+ const result = await this.request<{ user: AuthUser }>("/api/auth/api/auth/get-session", {
435
+ method: "GET",
436
+ });
437
+ const user = result?.user ?? null;
438
+ this.setCurrentUser(user);
439
+ return user;
440
+ } catch (error) {
441
+ if (!(error instanceof ApiRequestError) || (error.status !== 401 && error.status !== 403)) {
442
+ throw error;
443
+ }
444
+ this.setCurrentUser(null);
445
+ return null;
446
+ }
447
+ }
448
+
449
+ async sendVerification(): Promise<CloudVerificationResponse> {
450
+ return this.request<CloudVerificationResponse>("/cloud/auth/send-verification", {
451
+ method: "POST",
452
+ body: JSON.stringify({}),
453
+ });
454
+ }
455
+
456
+ async getChannels(): Promise<ChatChannel[]> {
457
+ return this.request<ChatChannel[]>("/chat/channels");
458
+ }
459
+
460
+ async getMessages(
461
+ channelId: string,
462
+ opts?: { after?: string; before?: string; limit?: number },
463
+ ): Promise<ChatMessage[]> {
464
+ const params = new URLSearchParams();
465
+ if (opts?.after) params.set("after", opts.after);
466
+ if (opts?.before) params.set("before", opts.before);
467
+ if (opts?.limit) params.set("limit", String(opts.limit));
468
+ const qs = params.toString();
469
+ return this.request<ChatMessage[]>(`/chat/channels/${channelId}/messages${qs ? `?${qs}` : ""}`);
470
+ }
471
+
472
+ async sendMessage(channelId: string, content: string, replyToId?: string): Promise<ChatMessage> {
473
+ return this.request<ChatMessage>(`/chat/channels/${channelId}/messages`, {
474
+ method: "POST",
475
+ body: JSON.stringify({ content, replyToId }),
476
+ });
477
+ }
478
+
479
+ connectChannel(
480
+ channelId: string,
481
+ onMessage: (msg: ChatMessage) => void,
482
+ onError?: (err: string) => void,
483
+ ): { send: (content: string, replyToId?: string) => void; close: () => void } {
484
+ if (!channelId) {
485
+ return { send: () => {}, close: () => {} };
486
+ }
487
+
488
+ const listeners = this.channelListeners.get(channelId) ?? new Set<ChannelListener>();
489
+ const firstListener = listeners.size === 0;
490
+ listeners.add(onMessage);
491
+ this.channelListeners.set(channelId, listeners);
492
+ this.ensureSocket();
493
+ if (firstListener) {
494
+ this.sendSocketMessage({ type: "chat.subscribe", channelId });
495
+ }
496
+
497
+ return {
498
+ send: (content: string, replyToId?: string) => {
499
+ if (this.ws?.readyState === WebSocket.OPEN) {
500
+ this.sendSocketMessage({ type: "chat.send", channelId, content, replyToId });
501
+ return;
502
+ }
503
+ void this.sendMessage(channelId, content, replyToId).then(onMessage).catch((error) => {
504
+ onError?.(error instanceof Error ? error.message : String(error));
505
+ });
506
+ },
507
+ close: () => {
508
+ const current = this.channelListeners.get(channelId);
509
+ if (!current) return;
510
+ current.delete(onMessage);
511
+ if (current.size === 0) {
512
+ this.channelListeners.delete(channelId);
513
+ }
514
+ if (!this.shouldKeepSocketOpen()) {
515
+ this.teardownSocket();
516
+ }
517
+ },
518
+ };
519
+ }
520
+
521
+ subscribeQuotes(
522
+ targets: QuoteStreamTarget[],
523
+ onQuote: (target: QuoteStreamTarget, quote: CloudQuotePayload) => void,
524
+ ): () => void {
525
+ const uniqueTargets = [...new Map(
526
+ targets
527
+ .filter((target) => typeof target.symbol === "string" && target.symbol.trim().length > 0)
528
+ .map((target) => {
529
+ const normalized = {
530
+ symbol: normalizeSymbol(target.symbol),
531
+ exchange: normalizeExchange(target.exchange),
532
+ } satisfies QuoteStreamTarget;
533
+ return [marketKey(normalized.symbol, normalized.exchange), normalized] as const;
534
+ }),
535
+ ).values()];
536
+
537
+ const newSubscriptions: QuoteStreamTarget[] = [];
538
+ for (const target of uniqueTargets) {
539
+ const key = marketKey(target.symbol, target.exchange);
540
+ const listeners = this.quoteListeners.get(key) ?? new Set<QuoteListener>();
541
+ if (listeners.size === 0) {
542
+ newSubscriptions.push(target);
543
+ }
544
+ listeners.add(onQuote);
545
+ this.quoteListeners.set(key, listeners);
546
+ this.quoteTargets.set(key, target);
547
+ }
548
+
549
+ this.ensureSocket();
550
+ if (newSubscriptions.length > 0) {
551
+ cloudApiLog.info("register quote listeners", {
552
+ count: newSubscriptions.length,
553
+ symbols: newSubscriptions.map((target) => marketKey(target.symbol, target.exchange)),
554
+ });
555
+ this.sendSocketMessage({
556
+ type: "market.subscribe",
557
+ symbols: newSubscriptions.map((target) => ({
558
+ symbol: target.symbol,
559
+ exchange: target.exchange ?? "",
560
+ })),
561
+ });
562
+ }
563
+
564
+ return () => {
565
+ const removedTargets: QuoteStreamTarget[] = [];
566
+
567
+ for (const target of uniqueTargets) {
568
+ const key = marketKey(target.symbol, target.exchange);
569
+ const listeners = this.quoteListeners.get(key);
570
+ if (!listeners) continue;
571
+ listeners.delete(onQuote);
572
+ if (listeners.size === 0) {
573
+ this.quoteListeners.delete(key);
574
+ const storedTarget = this.quoteTargets.get(key);
575
+ if (storedTarget) {
576
+ removedTargets.push(storedTarget);
577
+ }
578
+ this.quoteTargets.delete(key);
579
+ }
580
+ }
581
+
582
+ if (removedTargets.length > 0) {
583
+ cloudApiLog.info("remove quote listeners", {
584
+ count: removedTargets.length,
585
+ symbols: removedTargets.map((target) => marketKey(target.symbol, target.exchange)),
586
+ });
587
+ this.sendSocketMessage({
588
+ type: "market.unsubscribe",
589
+ symbols: removedTargets.map((target) => ({
590
+ symbol: target.symbol,
591
+ exchange: target.exchange ?? "",
592
+ })),
593
+ });
594
+ }
595
+
596
+ if (!this.shouldKeepSocketOpen()) {
597
+ this.teardownSocket();
598
+ }
599
+ };
600
+ }
601
+
602
+ async searchInstruments(query: string, limit = 10): Promise<InstrumentSearchResult[]> {
603
+ const params = new URLSearchParams({
604
+ q: query,
605
+ limit: String(limit),
606
+ });
607
+ const response = await this.request<CloudMarketResponse<InstrumentSearchResult[]>>(`/market/search?${params.toString()}`);
608
+ return response.data ?? [];
609
+ }
610
+
611
+ async getCloudQuote(symbol: string, exchange?: string): Promise<CloudMarketResponse<CloudQuotePayload>> {
612
+ const params = new URLSearchParams({ symbol });
613
+ if (exchange) params.set("exchange", exchange);
614
+ return this.request<CloudMarketResponse<CloudQuotePayload>>(`/market/quote?${params.toString()}`);
615
+ }
616
+
617
+ async getCloudProfile(symbol: string, exchange?: string): Promise<CloudMarketResponse<CloudCompanyProfile>> {
618
+ const params = new URLSearchParams({ symbol });
619
+ if (exchange) params.set("exchange", exchange);
620
+ return this.request<CloudMarketResponse<CloudCompanyProfile>>(`/market/profile?${params.toString()}`);
621
+ }
622
+
623
+ async getCloudFundamentals(symbol: string, exchange?: string): Promise<CloudMarketResponse<CloudFundamentals>> {
624
+ const params = new URLSearchParams({ symbol });
625
+ if (exchange) params.set("exchange", exchange);
626
+ return this.request<CloudMarketResponse<CloudFundamentals>>(`/market/fundamentals?${params.toString()}`);
627
+ }
628
+
629
+ async getCloudStatements(
630
+ symbol: string,
631
+ exchange?: string,
632
+ period: "annual" | "quarterly" | "both" = "both",
633
+ ): Promise<CloudMarketResponse<Pick<TickerFinancials, "annualStatements" | "quarterlyStatements">>> {
634
+ const params = new URLSearchParams({ symbol, period });
635
+ if (exchange) params.set("exchange", exchange);
636
+ return this.request<CloudMarketResponse<Pick<TickerFinancials, "annualStatements" | "quarterlyStatements">>>(`/market/statements?${params.toString()}`);
637
+ }
638
+
639
+ async getCloudHistory(
640
+ symbol: string,
641
+ exchange: string,
642
+ params: {
643
+ interval?: string;
644
+ outputsize?: number;
645
+ startDate?: string;
646
+ endDate?: string;
647
+ rangeKey?: string;
648
+ } = {},
649
+ ): Promise<CloudMarketResponse<CloudPricePointPayload[]>> {
650
+ const search = new URLSearchParams({ symbol, exchange });
651
+ if (params.interval) search.set("interval", params.interval);
652
+ if (params.outputsize != null) search.set("outputsize", String(params.outputsize));
653
+ if (params.startDate) search.set("startDate", params.startDate);
654
+ if (params.endDate) search.set("endDate", params.endDate);
655
+ if (params.rangeKey) search.set("rangeKey", params.rangeKey);
656
+ return this.request<CloudMarketResponse<CloudPricePointPayload[]>>(`/market/history?${search.toString()}`);
657
+ }
658
+
659
+ async getCloudExchangeRate(fromCurrency: string): Promise<CloudMarketResponse<{ rate: number }>> {
660
+ const params = new URLSearchParams({ fromCurrency });
661
+ return this.request<CloudMarketResponse<{ rate: number }>>(`/market/exchange-rate?${params.toString()}`);
662
+ }
663
+ }
664
+
665
+ export const apiClient = new GloomApiClient();
666
+ export type { PricePoint };