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,1164 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, rmSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { act, useReducer } from "react";
6
+ import { testRender } from "@opentui/react/test-utils";
7
+ import { AppPersistence } from "../../data/app-persistence";
8
+ import { AppContext, appReducer, createInitialState, PaneInstanceProvider, type AppAction } from "../../state/app-context";
9
+ import { ProviderRouter } from "../../sources/provider-router";
10
+ import { cloneLayout, createDefaultConfig, type AppConfig, type BrokerInstanceConfig } from "../../types/config";
11
+ import type { DataProvider } from "../../types/data-provider";
12
+ import type { Quote } from "../../types/financials";
13
+ import type { TickerRecord } from "../../types/ticker";
14
+ import { MarketDataCoordinator, setSharedMarketDataCoordinator } from "../../market-data/coordinator";
15
+ import { setSharedRegistryForTests } from "../registry";
16
+ import { ibkrGatewayManager } from "../ibkr/gateway-service";
17
+ import { portfolioListPlugin } from "./portfolio-list";
18
+
19
+ const TEST_PANE_ID = "portfolio-list:test";
20
+
21
+ let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
22
+ let harnessDispatch: React.Dispatch<AppAction> | null = null;
23
+ let sharedCoordinator: MarketDataCoordinator | null = null;
24
+ let harnessState: ReturnType<typeof createInitialState> | null = null;
25
+ const tempPaths: string[] = [];
26
+
27
+ const PortfolioPane = portfolioListPlugin.panes![0]!.component as (props: {
28
+ paneId: string;
29
+ paneType: string;
30
+ focused: boolean;
31
+ width: number;
32
+ height: number;
33
+ }) => JSX.Element;
34
+
35
+ function createBrokerInstance(connectionMode: "gateway" | "flex", id = `ibkr-${connectionMode}`): BrokerInstanceConfig {
36
+ return {
37
+ id,
38
+ brokerType: "ibkr",
39
+ label: connectionMode === "gateway" ? "Gateway" : "Flex",
40
+ connectionMode,
41
+ config: connectionMode === "gateway"
42
+ ? { connectionMode, gateway: { host: "127.0.0.1", port: 4002, clientId: 1 } }
43
+ : { connectionMode, flex: { token: "token", queryId: "query" } },
44
+ enabled: true,
45
+ };
46
+ }
47
+
48
+ function createTempDbPath(name: string): string {
49
+ const path = join(tmpdir(), `gloomberb-portfolio-list-${name}-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
50
+ tempPaths.push(path);
51
+ return path;
52
+ }
53
+
54
+ function makeTicker(overrides: Partial<TickerRecord["metadata"]> = {}): TickerRecord {
55
+ return {
56
+ metadata: {
57
+ ticker: "AAPL",
58
+ exchange: "NASDAQ",
59
+ currency: "USD",
60
+ name: "Apple",
61
+ portfolios: ["broker:ibkr-flex:DU12345", "broker:ibkr-live:DU12345"],
62
+ watchlists: [],
63
+ positions: [
64
+ {
65
+ portfolio: "broker:ibkr-flex:DU12345",
66
+ shares: 10,
67
+ avgCost: 100,
68
+ currency: "USD",
69
+ broker: "ibkr",
70
+ brokerInstanceId: "ibkr-flex",
71
+ brokerAccountId: "DU12345",
72
+ },
73
+ {
74
+ portfolio: "broker:ibkr-live:DU12345",
75
+ shares: 10,
76
+ avgCost: 100,
77
+ currency: "USD",
78
+ broker: "ibkr",
79
+ brokerInstanceId: "ibkr-live",
80
+ brokerAccountId: "DU12345",
81
+ },
82
+ ],
83
+ custom: {},
84
+ tags: [],
85
+ ...overrides,
86
+ },
87
+ };
88
+ }
89
+
90
+ function makeQuote(overrides: Partial<Quote> = {}): Quote {
91
+ return {
92
+ symbol: "AAPL",
93
+ price: 125,
94
+ bid: 124.95,
95
+ ask: 125.05,
96
+ bidSize: 100,
97
+ askSize: 200,
98
+ currency: "USD",
99
+ change: 5,
100
+ changePercent: 4.17,
101
+ previousClose: 120,
102
+ name: "Apple",
103
+ lastUpdated: Date.now(),
104
+ marketState: "REGULAR",
105
+ ...overrides,
106
+ };
107
+ }
108
+
109
+ function createPortfolioConfig(portfolioId: string, brokerInstances: BrokerInstanceConfig[] = []): AppConfig {
110
+ const config = createDefaultConfig("/tmp/gloomberb-portfolio-list");
111
+ const layout = {
112
+ dockRoot: { kind: "pane" as const, instanceId: TEST_PANE_ID },
113
+ instances: [{
114
+ instanceId: TEST_PANE_ID,
115
+ paneId: "portfolio-list",
116
+ binding: { kind: "none" as const },
117
+ params: { collectionId: portfolioId },
118
+ }],
119
+ floating: [],
120
+ };
121
+
122
+ return {
123
+ ...config,
124
+ brokerInstances,
125
+ portfolios: [
126
+ ...config.portfolios,
127
+ {
128
+ id: portfolioId,
129
+ name: portfolioId.includes("flex") ? "Flex DU12345" : "Live DU12345",
130
+ currency: "USD",
131
+ brokerId: "ibkr",
132
+ brokerInstanceId: portfolioId.includes("flex") ? "ibkr-flex" : "ibkr-live",
133
+ brokerAccountId: "DU12345",
134
+ },
135
+ ],
136
+ layout,
137
+ layouts: [{ name: "Default", layout: cloneLayout(layout) }],
138
+ };
139
+ }
140
+
141
+ function createPortfolioConfigWithColumns(
142
+ portfolioId: string,
143
+ columnIds: string[],
144
+ brokerInstances: BrokerInstanceConfig[] = [],
145
+ ): AppConfig {
146
+ const config = createPortfolioConfig(portfolioId, brokerInstances);
147
+ const instance = config.layout.instances.find((entry) => entry.instanceId === TEST_PANE_ID);
148
+ if (instance) {
149
+ instance.settings = {
150
+ ...(instance.settings ?? {}),
151
+ columnIds,
152
+ };
153
+ }
154
+ return config;
155
+ }
156
+ function createPortfolioState(
157
+ config: AppConfig,
158
+ collectionId: string,
159
+ expanded = false,
160
+ {
161
+ ticker = makeTicker(),
162
+ quote = makeQuote(),
163
+ exchangeRates,
164
+ }: {
165
+ ticker?: TickerRecord;
166
+ quote?: Quote;
167
+ exchangeRates?: Map<string, number>;
168
+ } = {},
169
+ ) {
170
+ const state = createInitialState(config);
171
+ state.focusedPaneId = TEST_PANE_ID;
172
+ state.paneState[TEST_PANE_ID] = {
173
+ collectionId,
174
+ cursorSymbol: "AAPL",
175
+ cashDrawerExpanded: expanded,
176
+ };
177
+ state.tickers = new Map([["AAPL", ticker]]);
178
+ state.financials = new Map([["AAPL", { annualStatements: [], quarterlyStatements: [], priceHistory: [], quote }]]);
179
+ if (exchangeRates) {
180
+ state.exchangeRates = exchangeRates;
181
+ }
182
+ return state;
183
+ }
184
+
185
+ function PortfolioHarness({
186
+ config,
187
+ collectionId,
188
+ expanded = false,
189
+ brokerAccounts = {},
190
+ ticker,
191
+ quote,
192
+ exchangeRates,
193
+ stateMutator,
194
+ }: {
195
+ config: AppConfig;
196
+ collectionId: string;
197
+ expanded?: boolean;
198
+ brokerAccounts?: ReturnType<typeof createInitialState>["brokerAccounts"];
199
+ ticker?: TickerRecord;
200
+ quote?: Quote;
201
+ exchangeRates?: Map<string, number>;
202
+ stateMutator?: (state: ReturnType<typeof createInitialState>) => void;
203
+ }) {
204
+ const initialState = createPortfolioState(config, collectionId, expanded, {
205
+ ticker,
206
+ quote,
207
+ exchangeRates,
208
+ });
209
+ initialState.brokerAccounts = brokerAccounts;
210
+ stateMutator?.(initialState);
211
+ const [state, dispatch] = useReducer(appReducer, initialState);
212
+ harnessDispatch = dispatch;
213
+ harnessState = state;
214
+
215
+ return (
216
+ <AppContext value={{ state, dispatch }}>
217
+ <PaneInstanceProvider paneId={TEST_PANE_ID}>
218
+ <PortfolioPane
219
+ paneId={TEST_PANE_ID}
220
+ paneType="portfolio-list"
221
+ focused
222
+ width={100}
223
+ height={24}
224
+ />
225
+ </PaneInstanceProvider>
226
+ </AppContext>
227
+ );
228
+ }
229
+
230
+ async function flushFrame() {
231
+ await act(async () => {
232
+ await testSetup!.renderOnce();
233
+ });
234
+ }
235
+
236
+ afterEach(async () => {
237
+ if (testSetup) {
238
+ testSetup.renderer.destroy();
239
+ testSetup = undefined;
240
+ }
241
+ harnessDispatch = null;
242
+ sharedCoordinator = null;
243
+ setSharedMarketDataCoordinator(null);
244
+ harnessState = null;
245
+ setSharedRegistryForTests(undefined);
246
+ await ibkrGatewayManager.removeInstance("ibkr-live");
247
+ for (const path of tempPaths.splice(0)) {
248
+ if (existsSync(path)) rmSync(path, { force: true });
249
+ }
250
+ });
251
+
252
+ describe("PortfolioListPane cash and margin UI", () => {
253
+ test("defaults new portfolio panes to floating", () => {
254
+ const paneDef = portfolioListPlugin.panes?.find((entry) => entry.id === "portfolio-list");
255
+
256
+ expect(paneDef?.defaultMode).toBe("floating");
257
+ });
258
+
259
+ test("keeps non-broker portfolios unchanged", async () => {
260
+ const config = createDefaultConfig("/tmp/gloomberb-portfolio-list");
261
+ const layout = {
262
+ columns: [{ width: "100%" }],
263
+ instances: [{
264
+ instanceId: TEST_PANE_ID,
265
+ paneId: "portfolio-list",
266
+ binding: { kind: "none" as const },
267
+ params: { collectionId: "main" },
268
+ }],
269
+ docked: [{ instanceId: TEST_PANE_ID, columnIndex: 0 }],
270
+ floating: [],
271
+ };
272
+ const nextConfig = { ...config, layout, layouts: [{ name: "Default", layout: cloneLayout(layout) }] };
273
+
274
+ testSetup = await testRender(
275
+ <PortfolioHarness config={nextConfig} collectionId="main" />,
276
+ { width: 100, height: 24 },
277
+ );
278
+
279
+ await flushFrame();
280
+
281
+ const frame = testSetup.captureCharFrame();
282
+ expect(frame).not.toContain("Cash & Margin");
283
+ });
284
+
285
+ test("keeps native price and avg cost while converting market value and pnl to base currency", async () => {
286
+ const portfolioId = "broker:ibkr-flex:DU12345";
287
+ const config = createPortfolioConfig(portfolioId, [createBrokerInstance("flex")]);
288
+
289
+ testSetup = await testRender(
290
+ <PortfolioHarness
291
+ config={config}
292
+ collectionId={portfolioId}
293
+ ticker={makeTicker({
294
+ currency: "EUR",
295
+ positions: [
296
+ {
297
+ portfolio: "broker:ibkr-flex:DU12345",
298
+ shares: 10,
299
+ avgCost: 100,
300
+ currency: "EUR",
301
+ broker: "ibkr",
302
+ brokerInstanceId: "ibkr-flex",
303
+ brokerAccountId: "DU12345",
304
+ },
305
+ ],
306
+ })}
307
+ quote={makeQuote({
308
+ price: 125,
309
+ currency: "EUR",
310
+ change: 5,
311
+ changePercent: 4.17,
312
+ })}
313
+ exchangeRates={new Map([["USD", 1], ["EUR", 1.1]])}
314
+ />,
315
+ { width: 100, height: 24 },
316
+ );
317
+
318
+ await flushFrame();
319
+
320
+ const frame = testSetup.captureCharFrame();
321
+ expect(frame).toMatch(/AAPL\s+125\s+\+4\.17%/);
322
+ expect(frame).toContain("100");
323
+ expect(frame).toContain("1.4k");
324
+ expect(frame).toContain("+275");
325
+ expect(frame).not.toContain("€100.00");
326
+ expect(frame).not.toContain("$137.50");
327
+ });
328
+
329
+ test("shows broker market value and pnl before snapshot warmup", async () => {
330
+ const portfolioId = "broker:ibkr-flex:DU12345";
331
+ const config = createPortfolioConfigWithColumns(
332
+ portfolioId,
333
+ ["ticker", "price", "mkt_value", "pnl", "pnl_pct"],
334
+ [createBrokerInstance("flex")],
335
+ );
336
+
337
+ testSetup = await testRender(
338
+ <PortfolioHarness
339
+ config={config}
340
+ collectionId={portfolioId}
341
+ stateMutator={(state) => {
342
+ state.tickers = new Map([["AAPL", makeTicker({
343
+ portfolios: [portfolioId],
344
+ positions: [{
345
+ portfolio: portfolioId,
346
+ shares: 10,
347
+ avgCost: 100,
348
+ currency: "USD",
349
+ broker: "ibkr",
350
+ brokerInstanceId: "ibkr-flex",
351
+ brokerAccountId: "DU12345",
352
+ markPrice: 125,
353
+ marketValue: 1250,
354
+ unrealizedPnl: 250,
355
+ }],
356
+ })]]);
357
+ state.financials = new Map();
358
+ state.paneState[TEST_PANE_ID] = {
359
+ collectionId: portfolioId,
360
+ cursorSymbol: "AAPL",
361
+ cashDrawerExpanded: false,
362
+ };
363
+ }}
364
+ />,
365
+ { width: 100, height: 12 },
366
+ );
367
+
368
+ await flushFrame();
369
+
370
+ const frame = testSetup.captureCharFrame();
371
+ expect(frame).toContain("Val 1.3k");
372
+ expect(frame).toContain("125");
373
+ expect(frame).toContain("+250");
374
+ expect(frame).toContain("25.00%");
375
+ });
376
+
377
+ test("keeps option avg cost on premium scale and multiplies quoted value by contract size", async () => {
378
+ const portfolioId = "broker:ibkr-flex:DU12345";
379
+ const optionTicker = "SPY 260619C00500000";
380
+ const config = createPortfolioConfigWithColumns(
381
+ portfolioId,
382
+ ["ticker", "price", "avg_cost", "cost_basis", "mkt_value", "pnl"],
383
+ [createBrokerInstance("flex")],
384
+ );
385
+
386
+ testSetup = await testRender(
387
+ <PortfolioHarness
388
+ config={config}
389
+ collectionId={portfolioId}
390
+ stateMutator={(state) => {
391
+ state.tickers = new Map([[
392
+ optionTicker,
393
+ makeTicker({
394
+ ticker: optionTicker,
395
+ name: "SPY Jun19'26 500 Call",
396
+ assetCategory: "OPT",
397
+ portfolios: [portfolioId],
398
+ positions: [{
399
+ portfolio: portfolioId,
400
+ shares: 2,
401
+ avgCost: 4.25,
402
+ currency: "USD",
403
+ broker: "ibkr",
404
+ brokerInstanceId: "ibkr-flex",
405
+ brokerAccountId: "DU12345",
406
+ multiplier: 100,
407
+ }],
408
+ }),
409
+ ]]);
410
+ state.financials = new Map([[
411
+ optionTicker,
412
+ {
413
+ annualStatements: [],
414
+ quarterlyStatements: [],
415
+ priceHistory: [],
416
+ quote: makeQuote({
417
+ symbol: optionTicker,
418
+ price: 5,
419
+ change: 0.5,
420
+ changePercent: 11.11,
421
+ previousClose: 4.5,
422
+ name: "SPY Jun19'26 500 Call",
423
+ }),
424
+ },
425
+ ]]);
426
+ state.paneState[TEST_PANE_ID] = {
427
+ collectionId: portfolioId,
428
+ cursorSymbol: optionTicker,
429
+ cashDrawerExpanded: false,
430
+ };
431
+ }}
432
+ />,
433
+ { width: 100, height: 12 },
434
+ );
435
+
436
+ await flushFrame();
437
+
438
+ const frame = testSetup.captureCharFrame();
439
+ expect(frame).toContain("4.25");
440
+ expect(frame).toContain("5");
441
+ expect(frame).toContain("850");
442
+ expect(frame).toContain("1k");
443
+ expect(frame).toContain("+150");
444
+ expect(frame).not.toContain("$4.25");
445
+ });
446
+
447
+ test("shows flex cash summary and hides unavailable margin metrics", async () => {
448
+ const config = createPortfolioConfig("broker:ibkr-flex:DU12345", [createBrokerInstance("flex")]);
449
+ testSetup = await testRender(
450
+ <PortfolioHarness
451
+ config={config}
452
+ collectionId="broker:ibkr-flex:DU12345"
453
+ expanded
454
+ brokerAccounts={{
455
+ "ibkr-flex": [{
456
+ accountId: "DU12345",
457
+ name: "DU12345",
458
+ currency: "USD",
459
+ source: "flex",
460
+ updatedAt: new Date(2026, 2, 27).getTime(),
461
+ totalCashValue: -50000,
462
+ settledCash: -45000,
463
+ netLiquidation: 125000,
464
+ cashBalances: [
465
+ { currency: "USD", quantity: -50000, baseValue: -50000, baseCurrency: "USD" },
466
+ { currency: "JPY", quantity: 0, baseValue: 0, baseCurrency: "USD" },
467
+ ],
468
+ }],
469
+ }}
470
+ />,
471
+ { width: 100, height: 24 },
472
+ );
473
+
474
+ await flushFrame();
475
+
476
+ const frame = testSetup.captureCharFrame();
477
+ expect(frame).toContain("Cash");
478
+ expect(frame).toContain("Cash & Margin");
479
+ expect(frame).toContain("Net Liq");
480
+ expect(frame).toContain("Flex Mar 27");
481
+ expect(frame).not.toContain("Avail");
482
+ expect(frame).not.toContain("JPY");
483
+ });
484
+
485
+ test("shows a live gateway badge in the header and drawer preview", async () => {
486
+ const config = createPortfolioConfig("broker:ibkr-live:DU12345", [createBrokerInstance("gateway", "ibkr-live")]);
487
+ const service = ibkrGatewayManager.getService("ibkr-live") as any;
488
+ service.updateSnapshot({
489
+ status: { state: "connected", updatedAt: Date.now(), mode: "gateway" },
490
+ accounts: [{
491
+ accountId: "DU12345",
492
+ name: "DU12345",
493
+ currency: "USD",
494
+ source: "gateway",
495
+ updatedAt: Date.now(),
496
+ totalCashValue: -75000,
497
+ settledCash: -70000,
498
+ availableFunds: 15000,
499
+ excessLiquidity: 12000,
500
+ buyingPower: 30000,
501
+ netLiquidation: 200000,
502
+ cashBalances: [
503
+ { currency: "USD", quantity: -75000, baseValue: -75000, baseCurrency: "USD" },
504
+ { currency: "EUR", quantity: -10000, baseValue: undefined, baseCurrency: "USD" },
505
+ ],
506
+ }],
507
+ openOrders: [],
508
+ executions: [],
509
+ });
510
+
511
+ testSetup = await testRender(
512
+ <PortfolioHarness
513
+ config={config}
514
+ collectionId="broker:ibkr-live:DU12345"
515
+ brokerAccounts={{
516
+ "ibkr-live": [{
517
+ accountId: "DU12345",
518
+ name: "DU12345",
519
+ currency: "USD",
520
+ source: "gateway",
521
+ updatedAt: Date.now(),
522
+ totalCashValue: -70000,
523
+ }],
524
+ }}
525
+ />,
526
+ { width: 100, height: 24 },
527
+ );
528
+
529
+ await flushFrame();
530
+ expect(testSetup.captureCharFrame()).toContain("Live");
531
+ expect(testSetup.captureCharFrame()).toContain("▸ Cash & Margin");
532
+ });
533
+
534
+ test("toggles the cash and margin drawer when the header is clicked", async () => {
535
+ const config = createPortfolioConfig("broker:ibkr-flex:DU12345", [createBrokerInstance("flex")]);
536
+
537
+ testSetup = await testRender(
538
+ <PortfolioHarness
539
+ config={config}
540
+ collectionId="broker:ibkr-flex:DU12345"
541
+ brokerAccounts={{
542
+ "ibkr-flex": [{
543
+ accountId: "DU12345",
544
+ name: "DU12345",
545
+ currency: "USD",
546
+ source: "flex",
547
+ updatedAt: new Date(2026, 2, 27).getTime(),
548
+ totalCashValue: -50000,
549
+ settledCash: -45000,
550
+ netLiquidation: 125000,
551
+ cashBalances: [
552
+ { currency: "USD", quantity: -50000, baseValue: -50000, baseCurrency: "USD" },
553
+ ],
554
+ }],
555
+ }}
556
+ />,
557
+ { width: 100, height: 24 },
558
+ );
559
+
560
+ await flushFrame();
561
+
562
+ const pressDrawerHeader = async () => {
563
+ const lines = testSetup!.captureCharFrame().split("\n");
564
+ const row = lines.findIndex((line) => line.includes("Cash & Margin"));
565
+ const col = lines[row]?.indexOf("Cash & Margin") ?? -1;
566
+
567
+ expect(row).toBeGreaterThanOrEqual(0);
568
+ expect(col).toBeGreaterThanOrEqual(0);
569
+
570
+ await act(async () => {
571
+ await testSetup!.mockMouse.pressDown(col + 1, row);
572
+ await testSetup!.renderOnce();
573
+ await testSetup!.renderOnce();
574
+ });
575
+
576
+ return { row, col };
577
+ };
578
+
579
+ const { row, col } = await pressDrawerHeader();
580
+ expect(harnessState?.paneState[TEST_PANE_ID]).toMatchObject({
581
+ collectionId: "broker:ibkr-flex:DU12345",
582
+ cursorSymbol: "AAPL",
583
+ cashDrawerExpanded: true,
584
+ });
585
+
586
+ await act(async () => {
587
+ await testSetup!.mockMouse.release(col + 1, row);
588
+ await testSetup!.renderOnce();
589
+ await testSetup!.renderOnce();
590
+ });
591
+ expect(harnessState?.paneState[TEST_PANE_ID]?.cashDrawerExpanded).toBe(true);
592
+ });
593
+
594
+ test("renders the summary on its own row below the tabs", async () => {
595
+ const config = {
596
+ ...createPortfolioConfig("broker:ibkr-flex:DU12345", [createBrokerInstance("flex")]),
597
+ watchlists: [
598
+ { id: "watchlist-1", name: "International Compounders" },
599
+ { id: "watchlist-2", name: "Interactive Brokers Paper" },
600
+ ],
601
+ };
602
+
603
+ testSetup = await testRender(
604
+ <PortfolioHarness
605
+ config={config}
606
+ collectionId="broker:ibkr-flex:DU12345"
607
+ brokerAccounts={{
608
+ "ibkr-flex": [{
609
+ accountId: "DU12345",
610
+ name: "DU12345",
611
+ currency: "USD",
612
+ source: "flex",
613
+ updatedAt: new Date(2026, 2, 27).getTime(),
614
+ totalCashValue: -50000,
615
+ settledCash: -45000,
616
+ netLiquidation: 125000,
617
+ cashBalances: [
618
+ { currency: "USD", quantity: -50000, baseValue: -50000, baseCurrency: "USD" },
619
+ ],
620
+ }],
621
+ }}
622
+ />,
623
+ { width: 100, height: 24 },
624
+ );
625
+
626
+ await flushFrame();
627
+
628
+ const lines = testSetup.captureCharFrame().split("\n");
629
+ const tabsRow = lines.findIndex((line) => line.includes("Main Portfolio"));
630
+ const summaryRow = lines.findIndex((line) => line.includes("Net Liq 125k Val 1.3k Cash -50k"));
631
+
632
+ expect(tabsRow).toBeGreaterThanOrEqual(0);
633
+ expect(summaryRow).toBeGreaterThan(tabsRow);
634
+ expect(lines[summaryRow + 1]).toContain("TICKER");
635
+ });
636
+
637
+ test("renders bid ask and spread when those columns are enabled", async () => {
638
+ const config = createPortfolioConfigWithColumns(
639
+ "broker:ibkr-flex:DU12345",
640
+ ["ticker", "bid", "ask", "spread", "latency"],
641
+ [createBrokerInstance("flex")],
642
+ );
643
+
644
+ testSetup = await testRender(
645
+ <PortfolioHarness config={config} collectionId="broker:ibkr-flex:DU12345" />,
646
+ { width: 100, height: 12 },
647
+ );
648
+
649
+ await flushFrame();
650
+
651
+ const frame = testSetup.captureCharFrame();
652
+ expect(frame).toContain("BID");
653
+ expect(frame).toContain("ASK");
654
+ expect(frame).toContain("SPREAD");
655
+ expect(frame).toContain("124.95");
656
+ expect(frame).toContain("125.05");
657
+ expect(frame).toContain("0.10");
658
+ });
659
+
660
+ test("warms full financials for visible rows when only quote data is loaded", async () => {
661
+ const config = createPortfolioConfigWithColumns(
662
+ "broker:ibkr-flex:DU12345",
663
+ ["ticker", "market_cap", "pe", "forward_pe"],
664
+ [createBrokerInstance("flex")],
665
+ );
666
+ let calls = 0;
667
+ let resolveFinancials!: (value: {
668
+ annualStatements: [];
669
+ quarterlyStatements: [];
670
+ priceHistory: Array<{ date: Date; close: number }>;
671
+ quote: Quote;
672
+ fundamentals: { trailingPE: number; forwardPE: number };
673
+ }) => void;
674
+ const financialsPromise = new Promise<{
675
+ annualStatements: [];
676
+ quarterlyStatements: [];
677
+ priceHistory: Array<{ date: Date; close: number }>;
678
+ quote: Quote;
679
+ fundamentals: { trailingPE: number; forwardPE: number };
680
+ }>((resolve) => {
681
+ resolveFinancials = resolve;
682
+ });
683
+ const provider: DataProvider = {
684
+ id: "test-provider",
685
+ name: "Test Provider",
686
+ async getTickerFinancials(symbol) {
687
+ calls += 1;
688
+ return financialsPromise;
689
+ },
690
+ async getQuote() {
691
+ return makeQuote();
692
+ },
693
+ async getExchangeRate() {
694
+ return 1;
695
+ },
696
+ async search() {
697
+ return [];
698
+ },
699
+ async getNews() {
700
+ return [];
701
+ },
702
+ async getArticleSummary() {
703
+ return null;
704
+ },
705
+ async getPriceHistory() {
706
+ return [];
707
+ },
708
+ subscribeQuotes() {
709
+ return () => {};
710
+ },
711
+ };
712
+ sharedCoordinator = new MarketDataCoordinator(provider);
713
+ setSharedMarketDataCoordinator(sharedCoordinator);
714
+
715
+ testSetup = await testRender(
716
+ <PortfolioHarness config={config} collectionId="broker:ibkr-flex:DU12345" />,
717
+ { width: 100, height: 12 },
718
+ );
719
+
720
+ await flushFrame();
721
+ await act(async () => {
722
+ resolveFinancials({
723
+ annualStatements: [],
724
+ quarterlyStatements: [],
725
+ priceHistory: [{ date: new Date("2026-03-28T00:00:00Z"), close: 124 }],
726
+ quote: makeQuote({
727
+ symbol: "AAPL",
728
+ marketCap: 2_000_000_000,
729
+ }),
730
+ fundamentals: {
731
+ trailingPE: 25,
732
+ forwardPE: 22,
733
+ },
734
+ });
735
+ await Promise.resolve();
736
+ });
737
+ await flushFrame();
738
+
739
+ const frame = testSetup.captureCharFrame();
740
+ expect(calls).toBeGreaterThan(0);
741
+ expect(frame).toContain("2B");
742
+ expect(frame).toContain("25.0");
743
+ expect(frame).toContain("22.0");
744
+ });
745
+
746
+ test("shows cached market cap on reopen for broker-linked rows", async () => {
747
+ const dbPath = createTempDbPath("cached-reopen-market-cap");
748
+ const persistence = new AppPersistence(dbPath);
749
+ const instrument = {
750
+ brokerId: "ibkr",
751
+ brokerInstanceId: "ibkr-live",
752
+ symbol: "AAPL",
753
+ localSymbol: "AAPL",
754
+ exchange: "SMART",
755
+ primaryExchange: "NASDAQ",
756
+ conId: 265598,
757
+ };
758
+ const cloudProvider: DataProvider = {
759
+ id: "cloud",
760
+ name: "Cloud",
761
+ priority: 100,
762
+ async getTickerFinancials() {
763
+ return {
764
+ annualStatements: [],
765
+ quarterlyStatements: [],
766
+ priceHistory: [],
767
+ quote: makeQuote({
768
+ symbol: "AAPL",
769
+ price: 125,
770
+ marketCap: undefined,
771
+ }),
772
+ fundamentals: {
773
+ trailingPE: 25,
774
+ },
775
+ profile: {
776
+ sector: "Technology",
777
+ },
778
+ };
779
+ },
780
+ async getQuote() {
781
+ return makeQuote({
782
+ symbol: "AAPL",
783
+ price: 125,
784
+ });
785
+ },
786
+ async getExchangeRate() {
787
+ return 1;
788
+ },
789
+ async search() {
790
+ return [];
791
+ },
792
+ async getNews() {
793
+ return [];
794
+ },
795
+ async getArticleSummary() {
796
+ return null;
797
+ },
798
+ async getPriceHistory() {
799
+ return [];
800
+ },
801
+ };
802
+ const yahooProvider: DataProvider = {
803
+ ...cloudProvider,
804
+ id: "yahoo",
805
+ name: "Yahoo",
806
+ priority: 1000,
807
+ async getTickerFinancials() {
808
+ return {
809
+ annualStatements: [],
810
+ quarterlyStatements: [],
811
+ priceHistory: [{ date: new Date("2026-03-28T00:00:00Z"), close: 124 }],
812
+ quote: makeQuote({
813
+ symbol: "AAPL",
814
+ price: 124,
815
+ marketCap: 2_000_000_000,
816
+ }),
817
+ fundamentals: {
818
+ forwardPE: 22,
819
+ },
820
+ profile: {
821
+ sector: "Technology",
822
+ },
823
+ };
824
+ },
825
+ };
826
+
827
+ const seedRouter = new ProviderRouter(yahooProvider, [cloudProvider], persistence.resources);
828
+ await seedRouter.getTickerFinancials("AAPL", "NASDAQ", {
829
+ brokerId: "ibkr",
830
+ brokerInstanceId: "ibkr-live",
831
+ instrument,
832
+ });
833
+
834
+ let liveCalls = 0;
835
+ const cachedRouter = new ProviderRouter({
836
+ ...yahooProvider,
837
+ async getTickerFinancials() {
838
+ liveCalls += 1;
839
+ throw new Error("expected cached yahoo snapshot");
840
+ },
841
+ }, [{
842
+ ...cloudProvider,
843
+ async getTickerFinancials() {
844
+ liveCalls += 1;
845
+ throw new Error("expected cached cloud snapshot");
846
+ },
847
+ }], persistence.resources);
848
+ sharedCoordinator = new MarketDataCoordinator(cachedRouter);
849
+ setSharedMarketDataCoordinator(sharedCoordinator);
850
+ const cachedFinancials = cachedRouter.getCachedFinancialsForTargets([{
851
+ symbol: "AAPL",
852
+ exchange: "NASDAQ",
853
+ brokerId: "ibkr",
854
+ brokerInstanceId: "ibkr-live",
855
+ instrument,
856
+ }]);
857
+ sharedCoordinator.primeCachedFinancials([{
858
+ instrument: {
859
+ symbol: "AAPL",
860
+ exchange: "NASDAQ",
861
+ brokerId: "ibkr",
862
+ brokerInstanceId: "ibkr-live",
863
+ instrument,
864
+ },
865
+ financials: cachedFinancials.get("AAPL")!,
866
+ }]);
867
+
868
+ const config = createPortfolioConfigWithColumns(
869
+ "broker:ibkr-live:DU12345",
870
+ ["ticker", "market_cap", "pe", "forward_pe"],
871
+ [createBrokerInstance("gateway", "ibkr-live")],
872
+ );
873
+
874
+ testSetup = await testRender(
875
+ <PortfolioHarness
876
+ config={config}
877
+ collectionId="broker:ibkr-live:DU12345"
878
+ stateMutator={(state) => {
879
+ state.tickers = new Map([["AAPL", makeTicker({
880
+ portfolios: ["broker:ibkr-live:DU12345"],
881
+ positions: [{
882
+ portfolio: "broker:ibkr-live:DU12345",
883
+ shares: 10,
884
+ avgCost: 100,
885
+ currency: "USD",
886
+ broker: "ibkr",
887
+ brokerInstanceId: "ibkr-live",
888
+ brokerAccountId: "DU12345",
889
+ }],
890
+ broker_contracts: [instrument],
891
+ })]]);
892
+ state.financials = new Map();
893
+ state.paneState[TEST_PANE_ID] = {
894
+ collectionId: "broker:ibkr-live:DU12345",
895
+ cursorSymbol: "AAPL",
896
+ cashDrawerExpanded: false,
897
+ };
898
+ }}
899
+ />,
900
+ { width: 100, height: 12 },
901
+ );
902
+
903
+ await flushFrame();
904
+
905
+ const frame = testSetup.captureCharFrame();
906
+ expect(liveCalls).toBe(0);
907
+ expect(frame).toContain("2B");
908
+ expect(frame).toContain("25.0");
909
+ expect(frame).toContain("22.0");
910
+
911
+ persistence.close();
912
+ });
913
+
914
+ test("updates a non-selected broker-linked row from streamed quotes", async () => {
915
+ const config = createPortfolioConfigWithColumns(
916
+ "broker:ibkr-live:DU12345",
917
+ ["ticker", "price", "change_pct", "latency"],
918
+ [createBrokerInstance("gateway", "ibkr-live")],
919
+ );
920
+ let streamed: ((target: { symbol: string; exchange?: string; context?: unknown }, quote: Quote) => void) | null = null;
921
+ const provider: DataProvider = {
922
+ id: "test-provider",
923
+ name: "Test Provider",
924
+ async getTickerFinancials(symbol) {
925
+ if (symbol === "AAPL") {
926
+ return {
927
+ annualStatements: [],
928
+ quarterlyStatements: [],
929
+ priceHistory: [],
930
+ quote: makeQuote({
931
+ symbol: "AAPL",
932
+ price: 125,
933
+ change: 5,
934
+ changePercent: 4.17,
935
+ marketState: "PRE",
936
+ preMarketPrice: 125,
937
+ preMarketChange: 5,
938
+ preMarketChangePercent: 4.17,
939
+ }),
940
+ };
941
+ }
942
+ return {
943
+ annualStatements: [],
944
+ quarterlyStatements: [],
945
+ priceHistory: [],
946
+ quote: makeQuote({
947
+ symbol: "MSFT",
948
+ price: 315,
949
+ change: 2,
950
+ changePercent: 0.64,
951
+ previousClose: 313,
952
+ name: "Microsoft",
953
+ }),
954
+ };
955
+ },
956
+ async getQuote(symbol) {
957
+ return symbol === "AAPL"
958
+ ? makeQuote({
959
+ symbol: "AAPL",
960
+ price: 125,
961
+ change: 5,
962
+ changePercent: 4.17,
963
+ marketState: "PRE",
964
+ preMarketPrice: 125,
965
+ preMarketChange: 5,
966
+ preMarketChangePercent: 4.17,
967
+ })
968
+ : makeQuote({
969
+ symbol: "MSFT",
970
+ price: 315,
971
+ change: 2,
972
+ changePercent: 0.64,
973
+ previousClose: 313,
974
+ name: "Microsoft",
975
+ });
976
+ },
977
+ async getExchangeRate() {
978
+ return 1;
979
+ },
980
+ async search() {
981
+ return [];
982
+ },
983
+ async getNews() {
984
+ return [];
985
+ },
986
+ async getArticleSummary() {
987
+ return null;
988
+ },
989
+ async getPriceHistory() {
990
+ return [];
991
+ },
992
+ subscribeQuotes(_targets, onQuote) {
993
+ streamed = onQuote as typeof streamed;
994
+ return () => {};
995
+ },
996
+ };
997
+ sharedCoordinator = new MarketDataCoordinator(provider);
998
+ setSharedMarketDataCoordinator(sharedCoordinator);
999
+
1000
+ testSetup = await testRender(
1001
+ <PortfolioHarness
1002
+ config={config}
1003
+ collectionId="broker:ibkr-live:DU12345"
1004
+ stateMutator={(state) => {
1005
+ state.tickers = new Map([
1006
+ ["AAPL", makeTicker({
1007
+ ticker: "AAPL",
1008
+ name: "Apple",
1009
+ broker_contracts: [{
1010
+ brokerId: "ibkr",
1011
+ brokerInstanceId: "ibkr-live",
1012
+ symbol: "AAPL",
1013
+ localSymbol: "AAPL",
1014
+ exchange: "SMART",
1015
+ primaryExchange: "NASDAQ",
1016
+ conId: 265598,
1017
+ }],
1018
+ })],
1019
+ ["MSFT", makeTicker({
1020
+ ticker: "MSFT",
1021
+ name: "Microsoft",
1022
+ broker_contracts: [{
1023
+ brokerId: "ibkr",
1024
+ brokerInstanceId: "ibkr-live",
1025
+ symbol: "MSFT",
1026
+ localSymbol: "MSFT",
1027
+ exchange: "SMART",
1028
+ primaryExchange: "NASDAQ",
1029
+ conId: 272093,
1030
+ }],
1031
+ })],
1032
+ ]);
1033
+ state.financials = new Map();
1034
+ state.paneState[TEST_PANE_ID] = {
1035
+ collectionId: "broker:ibkr-live:DU12345",
1036
+ cursorSymbol: "MSFT",
1037
+ cashDrawerExpanded: false,
1038
+ };
1039
+ }}
1040
+ />,
1041
+ { width: 100, height: 12 },
1042
+ );
1043
+
1044
+ await flushFrame();
1045
+ await act(async () => {
1046
+ await Promise.resolve();
1047
+ });
1048
+ await flushFrame();
1049
+
1050
+ await act(async () => {
1051
+ streamed?.(
1052
+ {
1053
+ symbol: "AAPL",
1054
+ exchange: "NASDAQ",
1055
+ context: {
1056
+ brokerId: "ibkr",
1057
+ brokerInstanceId: "ibkr-live",
1058
+ instrument: {
1059
+ brokerId: "ibkr",
1060
+ brokerInstanceId: "ibkr-live",
1061
+ symbol: "AAPL",
1062
+ localSymbol: "AAPL",
1063
+ exchange: "SMART",
1064
+ primaryExchange: "NASDAQ",
1065
+ conId: 265598,
1066
+ },
1067
+ },
1068
+ },
1069
+ makeQuote({
1070
+ symbol: "AAPL",
1071
+ price: 126.5,
1072
+ change: 6.5,
1073
+ changePercent: 5.41,
1074
+ marketState: "PRE",
1075
+ preMarketPrice: 126.5,
1076
+ preMarketChange: 6.5,
1077
+ preMarketChangePercent: 5.41,
1078
+ lastUpdated: Date.now() + 1_000,
1079
+ }),
1080
+ );
1081
+ await Promise.resolve();
1082
+ });
1083
+ await flushFrame();
1084
+
1085
+ const frame = testSetup.captureCharFrame();
1086
+ expect(frame).toContain("AAPL");
1087
+ expect(frame).toContain("126.5");
1088
+ expect(frame).toContain("+5.41%");
1089
+ });
1090
+
1091
+ test("quote flash keeps the existing row background intact", async () => {
1092
+ const config = createPortfolioConfigWithColumns(
1093
+ "broker:ibkr-flex:DU12345",
1094
+ ["ticker", "price", "change", "latency"],
1095
+ [createBrokerInstance("flex")],
1096
+ );
1097
+
1098
+ testSetup = await testRender(
1099
+ <PortfolioHarness
1100
+ config={config}
1101
+ collectionId="broker:ibkr-flex:DU12345"
1102
+ stateMutator={(state) => {
1103
+ state.tickers = new Map([
1104
+ ["AAPL", makeTicker({ ticker: "AAPL", name: "Apple" })],
1105
+ ["MSFT", makeTicker({ ticker: "MSFT", name: "Microsoft" })],
1106
+ ]);
1107
+ state.financials = new Map([
1108
+ ["AAPL", { annualStatements: [], quarterlyStatements: [], priceHistory: [], quote: makeQuote() }],
1109
+ ["MSFT", {
1110
+ annualStatements: [],
1111
+ quarterlyStatements: [],
1112
+ priceHistory: [],
1113
+ quote: makeQuote({
1114
+ symbol: "MSFT",
1115
+ price: 315,
1116
+ bid: 314.95,
1117
+ ask: 315.05,
1118
+ change: 2,
1119
+ changePercent: 0.64,
1120
+ previousClose: 313,
1121
+ name: "Microsoft",
1122
+ }),
1123
+ }],
1124
+ ]);
1125
+ state.paneState[TEST_PANE_ID] = {
1126
+ collectionId: "broker:ibkr-flex:DU12345",
1127
+ cursorSymbol: "MSFT",
1128
+ cashDrawerExpanded: false,
1129
+ };
1130
+ }}
1131
+ />,
1132
+ { width: 100, height: 12 },
1133
+ );
1134
+
1135
+ await flushFrame();
1136
+ const beforeFrame = testSetup.captureSpans();
1137
+ const beforeLine = beforeFrame.lines.find((line) => line.spans.map((span) => span.text).join("").includes("AAPL"));
1138
+ expect(beforeLine).toBeDefined();
1139
+
1140
+ await act(async () => {
1141
+ harnessDispatch?.({
1142
+ type: "MERGE_QUOTE",
1143
+ symbol: "AAPL",
1144
+ quote: makeQuote({
1145
+ price: 126,
1146
+ bid: 125.95,
1147
+ ask: 126.05,
1148
+ change: 6,
1149
+ changePercent: 5,
1150
+ lastUpdated: Date.now() + 1_000,
1151
+ }),
1152
+ });
1153
+ });
1154
+ await flushFrame();
1155
+
1156
+ const frame = testSetup.captureSpans();
1157
+ const aaplLine = frame.lines.find((line) => line.spans.map((span) => span.text).join("").includes("AAPL"));
1158
+ expect(aaplLine).toBeDefined();
1159
+
1160
+ const beforeBackgrounds = beforeLine!.spans.map((span) => span.bg.toInts().join(","));
1161
+ const afterBackgrounds = aaplLine!.spans.map((span) => span.bg.toInts().join(","));
1162
+ expect(afterBackgrounds).toEqual(beforeBackgrounds);
1163
+ });
1164
+ });