gloomberb 0.9.7 → 0.9.9

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 (276) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/auth.ts +20 -0
  3. package/src/api-client/index.test.ts +3 -3
  4. package/src/api-client/index.ts +48 -6
  5. package/src/api-client/request.test.ts +63 -0
  6. package/src/api-client/request.ts +53 -5
  7. package/src/api-client/types.ts +45 -2
  8. package/src/app/pane-runtime/layout-placement.ts +0 -1
  9. package/src/app/runtime/desktop-deeplink.test.ts +87 -0
  10. package/src/app/runtime/desktop-deeplink.ts +496 -0
  11. package/src/app.tsx +34 -0
  12. package/src/brokers/account-cache.ts +2 -10
  13. package/src/brokers/broker-portfolio-sync.ts +0 -1
  14. package/src/brokers/broker-ticker-sync.ts +20 -16
  15. package/src/brokers/remote-broker-adapter.ts +3 -0
  16. package/src/capabilities/registry.test.ts +25 -0
  17. package/src/capabilities/registry.ts +22 -5
  18. package/src/cli/commands/automation.ts +1 -3
  19. package/src/cli/commands/command-utils.ts +0 -7
  20. package/src/cli/commands/overview.ts +2 -2
  21. package/src/cli/commands/plugins.ts +34 -17
  22. package/src/cli/index.ts +2 -2
  23. package/src/cli.test.ts +0 -157
  24. package/src/components/chart/bar-chart-renderer.test.ts +87 -12
  25. package/src/components/chart/bar-chart-renderer.ts +111 -55
  26. package/src/components/chart/comparison/renderer.ts +0 -1
  27. package/src/components/chart/comparison-stock-chart/render-output.ts +3 -8
  28. package/src/components/chart/comparison-stock-chart/view.tsx +0 -2
  29. package/src/components/chart/core/index.test.ts +0 -24
  30. package/src/components/chart/core/renderer.ts +0 -4
  31. package/src/components/chart/core/resolution.test.ts +0 -17
  32. package/src/components/chart/core/resolution.ts +0 -26
  33. package/src/components/chart/core/scroll.test.ts +0 -6
  34. package/src/components/chart/core/scroll.ts +0 -22
  35. package/src/components/chart/core/viewport.test.ts +2 -23
  36. package/src/components/chart/core/viewport.ts +0 -27
  37. package/src/components/chart/market-session.ts +1 -1
  38. package/src/components/chart/native/surface/hooks.ts +10 -3
  39. package/src/components/chart/static/bar-chart-surface.tsx +104 -20
  40. package/src/components/chart/stock-chart/data-runtime.ts +0 -1
  41. package/src/components/chart/stock-chart/footer.test.ts +65 -0
  42. package/src/components/chart/stock-chart/footer.ts +68 -13
  43. package/src/components/chart/stock-chart/resolved.tsx +18 -4
  44. package/src/components/chart/stock-chart/viewport/effects.ts +0 -3
  45. package/src/components/command-bar/helpers.test.ts +1 -80
  46. package/src/components/command-bar/helpers.ts +1 -36
  47. package/src/components/command-bar/pane-settings.ts +1 -1
  48. package/src/components/command-bar/pane-templates/items.ts +11 -3
  49. package/src/components/command-bar/panel/runtime.ts +0 -2
  50. package/src/components/command-bar/routes/root/results.ts +1 -1
  51. package/src/components/command-bar/routes/ticker-search/results.ts +0 -11
  52. package/src/components/command-bar/surface/index.test.tsx +52 -16
  53. package/src/components/command-bar/surface/index.tsx +0 -1
  54. package/src/components/command-bar/workflow/ops.ts +81 -20
  55. package/src/components/feed-data-table/stack-view.test.tsx +36 -130
  56. package/src/components/feed-data-table/stack-view.tsx +0 -1
  57. package/src/components/index.ts +1 -0
  58. package/src/components/layout/shell/index.test.tsx +2 -113
  59. package/src/components/layout/shell/index.tsx +0 -1
  60. package/src/components/layout/shell/menu.ts +0 -1
  61. package/src/components/layout/shell/terminal-pointer-runtime.ts +0 -1
  62. package/src/components/onboarding/onboarding-wizard.tsx +3 -3
  63. package/src/components/onboarding/wizard-keyboard.ts +2 -23
  64. package/src/components/price-selector-dialog.tsx +3 -3
  65. package/src/components/ticker/badge/text.tsx +0 -1
  66. package/src/components/toggle-list.tsx +29 -73
  67. package/src/components/ui/button.tsx +4 -1
  68. package/src/components/ui/checkbox.tsx +76 -0
  69. package/src/components/ui/choice-dialog.tsx +15 -3
  70. package/src/components/ui/frame.tsx +1 -1
  71. package/src/components/ui/index.ts +2 -0
  72. package/src/components/ui/message-composer.tsx +4 -0
  73. package/src/components/ui/native-select.tsx +4 -2
  74. package/src/components/ui/page-stack-view.tsx +1 -1
  75. package/src/components/ui/ui.test.tsx +0 -78
  76. package/src/core/state/app/layout-reducer.ts +0 -1
  77. package/src/data/config/layout.ts +3 -3
  78. package/src/data/resource-store.ts +6 -0
  79. package/src/data/sqlite/cache.test.ts +5 -0
  80. package/src/data/ticker-store.ts +0 -7
  81. package/src/market-data/coordinator/auxiliary.ts +1 -44
  82. package/src/market-data/coordinator/entries.ts +0 -1
  83. package/src/market-data/coordinator/events.test.ts +49 -0
  84. package/src/market-data/coordinator/events.ts +25 -4
  85. package/src/market-data/coordinator/index.test.ts +223 -10
  86. package/src/market-data/coordinator/index.ts +41 -21
  87. package/src/market-data/coordinator/quotes.ts +13 -11
  88. package/src/market-data/coordinator/subscriptions.test.ts +1 -0
  89. package/src/market-data/hooks.tsx +17 -13
  90. package/src/market-data/market/freshness.ts +58 -1
  91. package/src/market-data/market/status.ts +19 -1
  92. package/src/market-data/query-store.ts +0 -4
  93. package/src/market-data/quotes/freshness.test.ts +49 -1
  94. package/src/market-data/quotes/freshness.ts +8 -0
  95. package/src/market-data/quotes/resolution.test.ts +185 -0
  96. package/src/market-data/quotes/resolution.ts +96 -10
  97. package/src/market-data/request-types.test.ts +61 -1
  98. package/src/market-data/request-types.ts +42 -8
  99. package/src/market-data/result-types.ts +0 -11
  100. package/src/market-data/selectors.ts +1 -5
  101. package/src/news/aggregator.ts +5 -1
  102. package/src/pane-settings.test.ts +0 -18
  103. package/src/plugins/builtin/account-management/footer.ts +2 -5
  104. package/src/plugins/builtin/account-management/form-components.tsx +175 -59
  105. package/src/plugins/builtin/account-management/keyboard.ts +32 -2
  106. package/src/plugins/builtin/account-management/model.test.ts +99 -4
  107. package/src/plugins/builtin/account-management/model.ts +259 -23
  108. package/src/plugins/builtin/account-management/pane.tsx +784 -155
  109. package/src/plugins/builtin/broker-manager/index.test.tsx +0 -32
  110. package/src/plugins/builtin/buildout/cells.tsx +1 -1
  111. package/src/plugins/builtin/buildout/detail/satellite-images.tsx +1 -1
  112. package/src/plugins/builtin/buildout/detail/ui.tsx +0 -1
  113. package/src/plugins/builtin/buildout/format.ts +2 -11
  114. package/src/plugins/builtin/chat/chat.test.tsx +127 -1
  115. package/src/plugins/builtin/chat/content/channel-navigation.ts +1 -21
  116. package/src/plugins/builtin/chat/content/composer-cursor.ts +29 -0
  117. package/src/plugins/builtin/chat/content/composer-runtime.ts +22 -24
  118. package/src/plugins/builtin/chat/content/composer.tsx +84 -1
  119. package/src/plugins/builtin/chat/content/index.tsx +153 -16
  120. package/src/plugins/builtin/chat/content/layout-metrics.ts +10 -1
  121. package/src/plugins/builtin/chat/content/mentions.test.ts +74 -0
  122. package/src/plugins/builtin/chat/content/mentions.ts +88 -0
  123. package/src/plugins/builtin/chat/content/new-dm-dialog.tsx +264 -0
  124. package/src/plugins/builtin/chat/content/shortcuts.ts +47 -1
  125. package/src/plugins/builtin/chat/content/snapshot.ts +7 -0
  126. package/src/plugins/builtin/chat/content/transcript.tsx +0 -5
  127. package/src/plugins/builtin/chat/controller/index.ts +0 -10
  128. package/src/plugins/builtin/chat/controller.test.ts +5 -5
  129. package/src/plugins/builtin/chat/message/profile-popover.test.tsx +36 -0
  130. package/src/plugins/builtin/chat/message/profile-popover.tsx +124 -20
  131. package/src/plugins/builtin/chat/sidebar.test.tsx +83 -6
  132. package/src/plugins/builtin/chat/sidebar.tsx +26 -2
  133. package/src/plugins/builtin/chat/test-harness.tsx +22 -12
  134. package/src/plugins/builtin/cloud/plugin.tsx +8 -1
  135. package/src/plugins/builtin/cloud-tweets/pane.tsx +0 -1
  136. package/src/plugins/builtin/congress-trades/model.ts +2 -7
  137. package/src/plugins/builtin/congress-trades/pane.tsx +0 -1
  138. package/src/plugins/builtin/correlation/index.tsx +4 -1
  139. package/src/plugins/builtin/correlation/relationship/controls.tsx +8 -2
  140. package/src/plugins/builtin/correlation/relationship/model.ts +22 -2
  141. package/src/plugins/builtin/correlation/relationship/pane.tsx +9 -3
  142. package/src/plugins/builtin/earnings/index.tsx +0 -9
  143. package/src/plugins/builtin/econ/index.tsx +1 -5
  144. package/src/plugins/builtin/help/index.tsx +2 -2
  145. package/src/plugins/builtin/holders/pane.tsx +0 -5
  146. package/src/plugins/builtin/insider/index.tsx +1 -8
  147. package/src/plugins/builtin/kelly-sizer/index.test.tsx +0 -26
  148. package/src/plugins/builtin/kelly-sizer/index.tsx +0 -2
  149. package/src/plugins/builtin/market-movers/index.tsx +0 -2
  150. package/src/plugins/builtin/news/index.tsx +0 -1
  151. package/src/plugins/builtin/news/wire/news/table.tsx +0 -11
  152. package/src/plugins/builtin/options/view.test.tsx +1 -38
  153. package/src/plugins/builtin/plugin-groups.ts +17 -3
  154. package/src/plugins/builtin/portfolio-list/column-values.ts +15 -8
  155. package/src/plugins/builtin/portfolio-list/index.test.ts +52 -25
  156. package/src/plugins/builtin/portfolio-list/metrics.test.ts +44 -7
  157. package/src/plugins/builtin/portfolio-list/pane/data.test.ts +54 -0
  158. package/src/plugins/builtin/portfolio-list/pane/data.ts +50 -3
  159. package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +314 -16
  160. package/src/plugins/builtin/portfolio-list/pane/index.tsx +11 -4
  161. package/src/plugins/builtin/portfolio-list/pane/streaming.ts +83 -24
  162. package/src/plugins/builtin/sec/filing-documents.ts +0 -11
  163. package/src/plugins/builtin/sec/index.tsx +0 -2
  164. package/src/plugins/builtin/substack/article-detail.tsx +1 -1
  165. package/src/plugins/builtin/thirteenf/api.ts +0 -15
  166. package/src/plugins/builtin/thirteenf/pane.tsx +1 -3
  167. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +42 -6
  168. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +415 -64
  169. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/settings.ts +23 -2
  170. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/types.ts +8 -1
  171. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +3 -1
  172. package/src/plugins/builtin/ticker-detail/data-panes.test.ts +95 -1
  173. package/src/plugins/builtin/ticker-detail/financials/tab.tsx +0 -7
  174. package/src/plugins/builtin/ticker-detail/index.tsx +2 -0
  175. package/src/plugins/builtin/ticker-detail/quote-monitor/index.test.tsx +5 -34
  176. package/src/plugins/builtin/world-indices/index.tsx +0 -4
  177. package/src/plugins/builtin/world-indices/model.ts +0 -7
  178. package/src/plugins/ibkr/account-cache.ts +1 -1
  179. package/src/plugins/ibkr/gateway/market-data.ts +12 -5
  180. package/src/plugins/ibkr/gateway/service/index.test.ts +31 -0
  181. package/src/plugins/ibkr/portfolio-performance.ts +1 -1
  182. package/src/plugins/ibkr/trade/utils.ts +1 -7
  183. package/src/plugins/loader.ts +2 -1
  184. package/src/plugins/prediction-markets/detail.test.tsx +0 -34
  185. package/src/plugins/registry/context.ts +15 -0
  186. package/src/plugins/registry/contributions.ts +22 -11
  187. package/src/plugins/registry/index.test.ts +44 -0
  188. package/src/plugins/registry/index.ts +75 -26
  189. package/src/remote/controller.ts +1 -7
  190. package/src/remote/json-patch.ts +15 -1
  191. package/src/remote/server.ts +16 -6
  192. package/src/renderers/electrobun/bun/application-menu/index.ts +1 -1
  193. package/src/renderers/electrobun/bun/core-capabilities.ts +2 -0
  194. package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +14 -0
  195. package/src/renderers/electrobun/bun/desktop/http-fetch.ts +8 -0
  196. package/src/renderers/electrobun/bun/desktop/initialization.ts +2 -1
  197. package/src/renderers/electrobun/bun/index.ts +42 -0
  198. package/src/renderers/electrobun/shared/protocol.ts +5 -0
  199. package/src/renderers/electrobun/view/backend-rpc.ts +29 -0
  200. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -0
  201. package/src/renderers/electrobun/view/data-table/dom.ts +0 -1
  202. package/src/renderers/electrobun/view/desktop/controls.tsx +135 -4
  203. package/src/renderers/electrobun/view/desktop/list-view.tsx +38 -25
  204. package/src/renderers/electrobun/view/desktop-deeplink-bridge.ts +10 -0
  205. package/src/renderers/electrobun/view/host/box.tsx +0 -2
  206. package/src/renderers/electrobun/view/host/mouse.ts +0 -11
  207. package/src/renderers/electrobun/view/host/scroll-box.tsx +1 -2
  208. package/src/renderers/electrobun/view/host/text.tsx +1 -2
  209. package/src/renderers/electrobun/view/http-fetch.ts +15 -2
  210. package/src/renderers/electrobun/view/main.tsx +3 -0
  211. package/src/renderers/electrobun/view/remote/asset-data-client.ts +9 -18
  212. package/src/renderers/electrobun/view/remote/capability-invoker.test.ts +50 -0
  213. package/src/renderers/electrobun/view/remote/capability-invoker.ts +36 -0
  214. package/src/renderers/electrobun/view/ui-host.tsx +2 -0
  215. package/src/renderers/opentui/host.tsx +11 -3
  216. package/src/renderers/opentui/input-host.tsx +5 -1
  217. package/src/sources/gloomberb-cloud/index.test.ts +0 -72
  218. package/src/sources/gloomberb-cloud/index.ts +1 -30
  219. package/src/sources/gloomberb-cloud/news.ts +0 -12
  220. package/src/sources/provider-router/batches.ts +11 -3
  221. package/src/sources/provider-router/cached-financials.test.ts +123 -0
  222. package/src/sources/provider-router/financial-routes.ts +77 -5
  223. package/src/sources/provider-router/financials.test.ts +49 -0
  224. package/src/sources/provider-router/financials.ts +40 -3
  225. package/src/sources/provider-router/index.test.ts +314 -0
  226. package/src/sources/provider-router/primary.ts +17 -3
  227. package/src/sources/provider-router/search.ts +5 -0
  228. package/src/sources/provider-router/streaming.ts +6 -1
  229. package/src/sources/provider-router/supplemental.ts +4 -3
  230. package/src/sources/sec-edgar.test.ts +42 -0
  231. package/src/sources/sec-edgar.ts +1 -2
  232. package/src/sources/yahoo-finance/history.ts +5 -1
  233. package/src/sources/yahoo-finance/mappers.test.ts +29 -0
  234. package/src/sources/yahoo-finance/mappers.ts +2 -1
  235. package/src/sources/yahoo-finance/options.ts +59 -14
  236. package/src/sources/yahoo-finance/quote-summary.ts +4 -1
  237. package/src/sources/yahoo-finance/requests.ts +2 -1
  238. package/src/sources/yahoo-finance/snapshots.ts +13 -2
  239. package/src/sources/yahoo-finance.test.ts +40 -21
  240. package/src/sources/yahoo-finance.ts +19 -10
  241. package/src/state/app/context/index.test.tsx +0 -81
  242. package/src/state/app/context/selector.test.tsx +6 -2
  243. package/src/state/hooks/quote-streaming.test.ts +14 -1
  244. package/src/state/hooks/quote-streaming.ts +5 -4
  245. package/src/sync/controller.test.ts +31 -0
  246. package/src/sync/controller.ts +300 -0
  247. package/src/sync/core-contributors.test.ts +251 -0
  248. package/src/sync/core-contributors.ts +400 -0
  249. package/src/sync/profile-analytics.ts +39 -0
  250. package/src/sync/react.ts +53 -0
  251. package/src/sync/types.ts +77 -0
  252. package/src/test-support/data-provider.ts +0 -1
  253. package/src/types/desktop-deeplink.ts +7 -0
  254. package/src/types/plugin.ts +3 -0
  255. package/src/ui/host.tsx +15 -1
  256. package/src/ui/index.tsx +2 -0
  257. package/src/updater.test.ts +0 -21
  258. package/src/updater.ts +20 -16
  259. package/src/utils/async-deadline.ts +26 -0
  260. package/src/utils/exchanges.test.ts +11 -1
  261. package/src/utils/exchanges.ts +5 -1
  262. package/src/utils/selection-clipboard.ts +2 -2
  263. package/src/version.ts +1 -1
  264. package/src/components/layout/pane/header.test.tsx +0 -39
  265. package/src/plugins/builtin/chat/channels.test.ts +0 -28
  266. package/src/plugins/builtin/chat/index.tsx +0 -4
  267. package/src/plugins/builtin/cloud-tweets/index.ts +0 -1
  268. package/src/plugins/builtin/earnings/index.test.ts +0 -12
  269. package/src/plugins/builtin/help/index.test.tsx +0 -242
  270. package/src/plugins/builtin/substack/article-detail.test.tsx +0 -10
  271. package/src/renderers/electrobun/bun/application-menu/index.test.ts +0 -21
  272. package/src/renderers/electrobun/bun/window/focus.test.ts +0 -35
  273. package/src/renderers/electrobun/view/host/mouse.test.ts +0 -37
  274. package/src/renderers/electrobun/view/host/text.test.ts +0 -22
  275. package/src/renderers/opentui/dialog-host.test.tsx +0 -44
  276. /package/src/{plugins/ibkr → utils}/hash.ts +0 -0
@@ -68,6 +68,191 @@ describe("quote-resolution", () => {
68
68
  expect(quote?.provenance?.session?.providerId).toBe("yahoo");
69
69
  });
70
70
 
71
+ test("uses provider previous close with the live broker price for daily change", () => {
72
+ const now = Date.parse("2026-07-06T14:05:00Z");
73
+ const contributions: QuoteContributionMap = {
74
+ ibkr: {
75
+ symbol: "VICR",
76
+ providerId: "ibkr",
77
+ dataSource: "live",
78
+ price: 299.8,
79
+ currency: "USD",
80
+ change: -80.3,
81
+ changePercent: -21.12,
82
+ previousClose: 380.1,
83
+ lastUpdated: Date.parse("2026-07-06T14:04:58Z"),
84
+ receivedAt: Date.parse("2026-07-06T14:04:59Z"),
85
+ listingExchangeName: "NASDAQ",
86
+ routingExchangeName: "SMART",
87
+ sessionConfidence: "unknown",
88
+ },
89
+ yahoo: {
90
+ symbol: "VICR",
91
+ providerId: "yahoo",
92
+ dataSource: "delayed",
93
+ price: 299.74,
94
+ currency: "USD",
95
+ change: 16.79,
96
+ changePercent: 5.93,
97
+ previousClose: 282.95,
98
+ lastUpdated: Date.parse("2026-07-06T14:04:00Z"),
99
+ listingExchangeName: "NMS",
100
+ marketState: "REGULAR",
101
+ sessionConfidence: "derived",
102
+ },
103
+ };
104
+
105
+ const quote = resolveCanonicalQuote(contributions, now).quote;
106
+
107
+ expect(quote?.providerId).toBe("ibkr");
108
+ expect(quote?.price).toBe(299.8);
109
+ expect(quote?.previousClose).toBe(282.95);
110
+ expect(quote?.change).toBeCloseTo(16.85, 10);
111
+ expect(quote?.changePercent).toBeCloseTo((16.85 / 282.95) * 100, 10);
112
+ expect(quote?.provenance?.price?.providerId).toBe("ibkr");
113
+ expect(quote?.provenance?.fields?.previousClose?.providerId).toBe("yahoo");
114
+ });
115
+
116
+ test("prefers fresher/provider-ranked non-live price candidates", () => {
117
+ const now = Date.parse("2026-07-07T11:25:00Z");
118
+ const cases: Array<{
119
+ contributions: QuoteContributionMap;
120
+ expectedPrice: number;
121
+ expectedProvider: string;
122
+ expectedChangePercent?: number;
123
+ expectedRoute?: string;
124
+ }> = [
125
+ {
126
+ contributions: {
127
+ ibkr: {
128
+ symbol: "LPK",
129
+ providerId: "ibkr",
130
+ dataSource: "delayed",
131
+ price: 17.75,
132
+ currency: "EUR",
133
+ change: -1.85,
134
+ changePercent: -9.44,
135
+ previousClose: 19.6,
136
+ lastUpdated: Date.parse("2026-07-07T11:02:00Z"),
137
+ receivedAt: Date.parse("2026-07-07T11:02:01Z"),
138
+ listingExchangeName: "IBIS",
139
+ routingExchangeName: "SMART",
140
+ sessionConfidence: "unknown",
141
+ },
142
+ yahoo: {
143
+ symbol: "LPK.DE",
144
+ providerId: "yahoo",
145
+ dataSource: "delayed",
146
+ price: 17.85,
147
+ currency: "EUR",
148
+ change: -1.75,
149
+ changePercent: -8.93,
150
+ previousClose: 19.6,
151
+ lastUpdated: Date.parse("2026-07-07T11:24:00Z"),
152
+ listingExchangeName: "GER",
153
+ marketState: "REGULAR",
154
+ sessionConfidence: "derived",
155
+ },
156
+ },
157
+ expectedProvider: "yahoo",
158
+ expectedPrice: 17.85,
159
+ expectedChangePercent: ((17.85 - 19.6) / 19.6) * 100,
160
+ expectedRoute: "SMART",
161
+ },
162
+ {
163
+ contributions: {
164
+ "gloomberb-cloud": {
165
+ symbol: "3HNX",
166
+ providerId: "gloomberb-cloud",
167
+ dataSource: "delayed",
168
+ price: 5.81,
169
+ currency: "GBP",
170
+ change: -1.58,
171
+ changePercent: -21.42,
172
+ previousClose: 7.39,
173
+ lastUpdated: Date.parse("2026-07-07T11:18:00Z"),
174
+ listingExchangeName: "LSE",
175
+ marketState: "REGULAR",
176
+ sessionConfidence: "derived",
177
+ },
178
+ yahoo: {
179
+ symbol: "3HNX.L",
180
+ providerId: "yahoo",
181
+ dataSource: "delayed",
182
+ price: 5.9775,
183
+ currency: "GBP",
184
+ change: -1.4125,
185
+ changePercent: -19.09,
186
+ previousClose: 7.39,
187
+ lastUpdated: Date.parse("2026-07-07T11:24:00Z"),
188
+ listingExchangeName: "LSE",
189
+ marketState: "REGULAR",
190
+ sessionConfidence: "derived",
191
+ },
192
+ },
193
+ expectedProvider: "yahoo",
194
+ expectedPrice: 5.9775,
195
+ },
196
+ ];
197
+
198
+ for (const scenario of cases) {
199
+ const quote = resolveCanonicalQuote(scenario.contributions, now).quote;
200
+ expect(quote?.providerId).toBe(scenario.expectedProvider);
201
+ expect(quote?.price).toBe(scenario.expectedPrice);
202
+ expect(quote?.provenance?.price?.providerId).toBe(scenario.expectedProvider);
203
+ if (scenario.expectedChangePercent != null) {
204
+ expect(quote?.changePercent).toBeCloseTo(scenario.expectedChangePercent, 10);
205
+ }
206
+ if (scenario.expectedRoute) {
207
+ expect(quote?.routingExchangeName).toBe(scenario.expectedRoute);
208
+ }
209
+ }
210
+ });
211
+
212
+ test("prefers yahoo day reference fields over stale cloud previous close data", () => {
213
+ const now = Date.parse("2026-07-06T15:45:00Z");
214
+ const contributions: QuoteContributionMap = {
215
+ "gloomberb-cloud": {
216
+ symbol: "VICR",
217
+ providerId: "gloomberb-cloud",
218
+ dataSource: "delayed",
219
+ price: 299.8,
220
+ currency: "USD",
221
+ change: -80.27,
222
+ changePercent: -21.12,
223
+ previousClose: 380.07,
224
+ lastUpdated: Date.parse("2026-07-06T15:44:00Z"),
225
+ listingExchangeName: "NASDAQ",
226
+ marketState: "REGULAR",
227
+ sessionConfidence: "derived",
228
+ },
229
+ yahoo: {
230
+ symbol: "VICR",
231
+ providerId: "yahoo",
232
+ dataSource: "delayed",
233
+ price: 297.9,
234
+ currency: "USD",
235
+ change: 14.95,
236
+ changePercent: 5.28,
237
+ previousClose: 282.95,
238
+ lastUpdated: Date.parse("2026-07-06T15:41:00Z"),
239
+ listingExchangeName: "NMS",
240
+ marketState: "REGULAR",
241
+ sessionConfidence: "derived",
242
+ },
243
+ };
244
+
245
+ const quote = resolveCanonicalQuote(contributions, now).quote;
246
+
247
+ expect(quote?.providerId).toBe("gloomberb-cloud");
248
+ expect(quote?.price).toBe(299.8);
249
+ expect(quote?.previousClose).toBe(282.95);
250
+ expect(quote?.change).toBeCloseTo(16.85, 10);
251
+ expect(quote?.changePercent).toBeCloseTo((16.85 / 282.95) * 100, 10);
252
+ expect(quote?.provenance?.price?.providerId).toBe("gloomberb-cloud");
253
+ expect(quote?.provenance?.fields?.previousClose?.providerId).toBe("yahoo");
254
+ });
255
+
71
256
  test("prefers cloud session data over yahoo when confidence is tied", () => {
72
257
  const now = Date.parse("2026-04-08T11:00:00Z");
73
258
  const contributions: QuoteContributionMap = {
@@ -31,9 +31,6 @@ const PRICE_FIELD_KEYS = [
31
31
  "symbol",
32
32
  "price",
33
33
  "currency",
34
- "change",
35
- "changePercent",
36
- "previousClose",
37
34
  "bid",
38
35
  "ask",
39
36
  "bidSize",
@@ -88,12 +85,39 @@ function providerKindRank(providerId?: string): number {
88
85
  }
89
86
  }
90
87
 
88
+ function dailyReferenceRank(providerId?: string): number {
89
+ switch (providerId) {
90
+ case "yahoo":
91
+ return 0;
92
+ case "gloomberb-cloud":
93
+ return 1;
94
+ case "ibkr":
95
+ return 2;
96
+ default:
97
+ return 3;
98
+ }
99
+ }
100
+
91
101
  function priceRank(quote: QuoteContribution): number {
92
102
  if (quote.providerId === "ibkr" && quote.dataSource === "live") return 0;
93
- if (quote.providerId === "ibkr") return 1;
94
- if (quote.providerId === "gloomberb-cloud") return 2;
95
- if (quote.providerId === "yahoo") return 3;
96
- return 4;
103
+ return 1;
104
+ }
105
+
106
+ function priceProviderTieRank(providerId?: string): number {
107
+ switch (providerId) {
108
+ case "gloomberb-cloud":
109
+ return 0;
110
+ case "yahoo":
111
+ return 1;
112
+ case "ibkr":
113
+ return 2;
114
+ default:
115
+ return 3;
116
+ }
117
+ }
118
+
119
+ function quoteUpdateTime(quote: QuoteContribution): number {
120
+ return quote.lastUpdated ?? quote.receivedAt ?? 0;
97
121
  }
98
122
 
99
123
  function sessionConfidenceRank(confidence?: SessionConfidence): number {
@@ -123,7 +147,7 @@ function assignField(
123
147
  quote: QuoteContribution | undefined,
124
148
  ): void {
125
149
  if (!quote) return;
126
- const value = (quote as Record<string, unknown>)[field];
150
+ const value = (quote as unknown as Record<string, unknown>)[field];
127
151
  if (value === undefined) return;
128
152
  (target as Record<string, unknown>)[field] = value;
129
153
  provenance.fields ??= {};
@@ -137,13 +161,68 @@ function pickField(
137
161
  candidates: QuoteContribution[],
138
162
  ): QuoteContribution | undefined {
139
163
  for (const candidate of candidates) {
140
- if ((candidate as Record<string, unknown>)[field] === undefined) continue;
164
+ if ((candidate as unknown as Record<string, unknown>)[field] === undefined) continue;
141
165
  assignField(target, provenance, field, candidate);
142
166
  return candidate;
143
167
  }
144
168
  return undefined;
145
169
  }
146
170
 
171
+ function finitePositiveNumber(value: unknown): value is number {
172
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
173
+ }
174
+
175
+ function buildDailyReferenceCandidates(
176
+ candidates: QuoteContribution[],
177
+ priceProvider: QuoteContribution | undefined,
178
+ ): QuoteContribution[] {
179
+ return [...candidates]
180
+ .filter((quote) => finitePositiveNumber(quote.previousClose))
181
+ .sort((left, right) => {
182
+ if (priceProvider) {
183
+ const leftMismatch = hasLikelyQuoteUnitMismatch(priceProvider, left) ? 1 : 0;
184
+ const rightMismatch = hasLikelyQuoteUnitMismatch(priceProvider, right) ? 1 : 0;
185
+ if (leftMismatch !== rightMismatch) return leftMismatch - rightMismatch;
186
+ }
187
+ const providerDelta = dailyReferenceRank(left.providerId) - dailyReferenceRank(right.providerId);
188
+ if (providerDelta !== 0) return providerDelta;
189
+ return (right.lastUpdated ?? 0) - (left.lastUpdated ?? 0);
190
+ });
191
+ }
192
+
193
+ function assignDailyChangeFields(
194
+ target: Partial<Quote>,
195
+ provenance: QuoteProvenance,
196
+ priceProvider: QuoteContribution | undefined,
197
+ candidates: QuoteContribution[],
198
+ ): void {
199
+ const referenceProvider = buildDailyReferenceCandidates(candidates, priceProvider)[0];
200
+ if (!referenceProvider) {
201
+ pickField(target, provenance, "previousClose", candidates);
202
+ pickField(target, provenance, "change", candidates);
203
+ pickField(target, provenance, "changePercent", candidates);
204
+ return;
205
+ }
206
+
207
+ assignField(target, provenance, "previousClose", referenceProvider);
208
+ const price = target.price;
209
+ const previousClose = target.previousClose;
210
+ if (!finitePositiveNumber(price) || !finitePositiveNumber(previousClose)) {
211
+ pickField(target, provenance, "change", candidates);
212
+ pickField(target, provenance, "changePercent", candidates);
213
+ return;
214
+ }
215
+
216
+ target.change = price - previousClose;
217
+ target.changePercent = (target.change / previousClose) * 100;
218
+ provenance.fields ??= {};
219
+ const referenceProvenance = toProvenance(referenceProvider);
220
+ if (referenceProvenance) {
221
+ provenance.fields.change = referenceProvenance;
222
+ provenance.fields.changePercent = referenceProvenance;
223
+ }
224
+ }
225
+
147
226
  function buildAcceptedPriceCandidates(contributions: QuoteContribution[]): {
148
227
  accepted: QuoteContribution[];
149
228
  rejectedProviders: string[];
@@ -153,7 +232,13 @@ function buildAcceptedPriceCandidates(contributions: QuoteContribution[]): {
153
232
  .sort((left, right) => {
154
233
  const rankDelta = priceRank(left) - priceRank(right);
155
234
  if (rankDelta !== 0) return rankDelta;
156
- return (right.lastUpdated ?? 0) - (left.lastUpdated ?? 0);
235
+ if (priceRank(left) > 0) {
236
+ const updateDelta = quoteUpdateTime(right) - quoteUpdateTime(left);
237
+ if (updateDelta !== 0) return updateDelta;
238
+ }
239
+ const providerDelta = priceProviderTieRank(left.providerId) - priceProviderTieRank(right.providerId);
240
+ if (providerDelta !== 0) return providerDelta;
241
+ return quoteUpdateTime(right) - quoteUpdateTime(left);
157
242
  });
158
243
 
159
244
  const accepted: QuoteContribution[] = [];
@@ -290,6 +375,7 @@ export function resolveCanonicalQuote(
290
375
  if (field === "price") continue;
291
376
  pickField(resolved, provenance, field, acceptedPriceCandidates);
292
377
  }
378
+ assignDailyChangeFields(resolved, provenance, priceProvider, acceptedPriceCandidates);
293
379
  provenance.price = toProvenance(priceProvider);
294
380
 
295
381
  const sessionProvider = sessionCandidates[0];
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { TickerRecord } from "../types/ticker";
3
- import { quoteSubscriptionTargetFromTicker } from "./request-types";
3
+ import { instrumentFromTicker, quoteSubscriptionTargetFromTicker } from "./request-types";
4
4
 
5
5
  function makeTicker(overrides: Partial<TickerRecord["metadata"]> = {}): TickerRecord {
6
6
  return {
@@ -21,6 +21,66 @@ function makeTicker(overrides: Partial<TickerRecord["metadata"]> = {}): TickerRe
21
21
  }
22
22
 
23
23
  describe("quoteSubscriptionTargetFromTicker", () => {
24
+ test("selects the broker contract that belongs to the active portfolio", () => {
25
+ const ticker = makeTicker({
26
+ ticker: "VICR",
27
+ name: "Vicor",
28
+ portfolios: [
29
+ "broker:ibkr-live:DU111",
30
+ "broker:ibkr-coldstart:DU222",
31
+ ],
32
+ positions: [
33
+ {
34
+ portfolio: "broker:ibkr-live:DU111",
35
+ shares: 170,
36
+ avgCost: 198,
37
+ currency: "USD",
38
+ broker: "ibkr",
39
+ brokerInstanceId: "ibkr-live",
40
+ brokerContractId: 275759,
41
+ },
42
+ {
43
+ portfolio: "broker:ibkr-coldstart:DU222",
44
+ shares: 350,
45
+ avgCost: 290,
46
+ currency: "USD",
47
+ broker: "ibkr",
48
+ brokerInstanceId: "ibkr-coldstart",
49
+ brokerContractId: 275759,
50
+ },
51
+ ],
52
+ broker_contracts: [
53
+ {
54
+ brokerId: "ibkr",
55
+ brokerInstanceId: "ibkr-live",
56
+ conId: 275759,
57
+ symbol: "VICR",
58
+ localSymbol: "VICR",
59
+ exchange: "NASDAQ",
60
+ currency: "USD",
61
+ secType: "STK",
62
+ },
63
+ {
64
+ brokerId: "ibkr",
65
+ brokerInstanceId: "ibkr-coldstart",
66
+ conId: 275759,
67
+ symbol: "VICR",
68
+ localSymbol: "VICR",
69
+ exchange: "NASDAQ",
70
+ currency: "USD",
71
+ secType: "STK",
72
+ },
73
+ ],
74
+ });
75
+
76
+ expect(instrumentFromTicker(ticker, "VICR", { portfolioId: "broker:ibkr-coldstart:DU222" })).toMatchObject({
77
+ symbol: "VICR",
78
+ brokerId: "ibkr",
79
+ brokerInstanceId: "ibkr-coldstart",
80
+ instrument: ticker.metadata.broker_contracts?.[1],
81
+ });
82
+ });
83
+
24
84
  test("preserves broker contract context for streaming targets", () => {
25
85
  const ticker = makeTicker({
26
86
  broker_contracts: [{
@@ -12,6 +12,10 @@ export interface InstrumentRef {
12
12
  instrument?: BrokerContractRef | null;
13
13
  }
14
14
 
15
+ export interface TickerInstrumentOptions {
16
+ portfolioId?: string | null;
17
+ }
18
+
15
19
  type ChartGranularity = "range" | "detail" | "resolution";
16
20
 
17
21
  export interface ChartRequest {
@@ -24,11 +28,6 @@ export interface ChartRequest {
24
28
  barSize?: string;
25
29
  }
26
30
 
27
- export interface NewsRequest {
28
- instrument: InstrumentRef;
29
- count?: number;
30
- }
31
-
32
31
  export interface OptionsRequest {
33
32
  instrument: InstrumentRef;
34
33
  expirationDate?: number;
@@ -39,10 +38,44 @@ export interface SecFilingsRequest {
39
38
  count?: number;
40
39
  }
41
40
 
42
- export function instrumentFromTicker(ticker: TickerRecord | null | undefined, fallbackSymbol?: string | null): InstrumentRef | null {
41
+ function brokerContractForTicker(
42
+ ticker: TickerRecord | null | undefined,
43
+ options: TickerInstrumentOptions = {},
44
+ ): BrokerContractRef | null {
45
+ const contracts = ticker?.metadata.broker_contracts ?? [];
46
+ if (contracts.length === 0) return null;
47
+
48
+ const portfolioId = options.portfolioId;
49
+ if (portfolioId) {
50
+ const positions = ticker?.metadata.positions.filter((position) => position.portfolio === portfolioId) ?? [];
51
+ for (const position of positions) {
52
+ const matchingContract = contracts.find((contract) => (
53
+ (position.brokerContractId == null || contract.conId === position.brokerContractId)
54
+ && (!position.brokerInstanceId || contract.brokerInstanceId === position.brokerInstanceId)
55
+ && (!position.broker || contract.brokerId === position.broker)
56
+ ));
57
+ if (matchingContract) return matchingContract;
58
+ }
59
+ for (const position of positions) {
60
+ const matchingContract = contracts.find((contract) => (
61
+ (!position.brokerInstanceId || contract.brokerInstanceId === position.brokerInstanceId)
62
+ && (!position.broker || contract.brokerId === position.broker)
63
+ ));
64
+ if (matchingContract) return matchingContract;
65
+ }
66
+ }
67
+
68
+ return contracts[0] ?? null;
69
+ }
70
+
71
+ export function instrumentFromTicker(
72
+ ticker: TickerRecord | null | undefined,
73
+ fallbackSymbol?: string | null,
74
+ options: TickerInstrumentOptions = {},
75
+ ): InstrumentRef | null {
43
76
  const symbol = ticker?.metadata.ticker ?? fallbackSymbol ?? null;
44
77
  if (!symbol) return null;
45
- const instrument = ticker?.metadata.broker_contracts?.[0] ?? null;
78
+ const instrument = brokerContractForTicker(ticker, options);
46
79
  return {
47
80
  symbol,
48
81
  exchange: ticker?.metadata.exchange ?? "",
@@ -56,8 +89,9 @@ export function quoteSubscriptionTargetFromTicker(
56
89
  ticker: TickerRecord | null | undefined,
57
90
  fallbackSymbol?: string | null,
58
91
  route: QuoteSubscriptionTarget["route"] = "auto",
92
+ options: TickerInstrumentOptions = {},
59
93
  ): QuoteSubscriptionTarget | null {
60
- const instrument = instrumentFromTicker(ticker, fallbackSymbol);
94
+ const instrument = instrumentFromTicker(ticker, fallbackSymbol, options);
61
95
  return quoteSubscriptionTargetFromInstrument(instrument, route);
62
96
  }
63
97
 
@@ -19,17 +19,6 @@ export interface ProviderAttempt {
19
19
  message?: string;
20
20
  }
21
21
 
22
- interface ProviderResult<T> {
23
- status: ProviderStatus;
24
- data: T | null;
25
- providerId: string;
26
- latencyMs: number;
27
- completeness: "full" | "partial";
28
- reasonCode?: ProviderReasonCode;
29
- asOf?: number;
30
- staleAt?: number;
31
- }
32
-
33
22
  type QueryPhase = "idle" | "loading" | "ready" | "refreshing" | "error";
34
23
 
35
24
  export interface QueryEntry<T> {
@@ -1,6 +1,6 @@
1
1
  import type { MarketDataRequestContext } from "../types/data-provider";
2
2
  import type { Quote, TickerFinancials } from "../types/financials";
3
- import type { InstrumentRef, NewsRequest, OptionsRequest, SecFilingsRequest, ChartRequest } from "./request-types";
3
+ import type { InstrumentRef, OptionsRequest, SecFilingsRequest, ChartRequest } from "./request-types";
4
4
  import type { QueryEntry } from "./result-types";
5
5
  import { hasLikelyQuoteUnitMismatch } from "../utils/currency-units";
6
6
  import { normalizePriceHistory } from "../utils/price-history";
@@ -61,10 +61,6 @@ export function buildChartKey(request: ChartRequest): string {
61
61
  ].join(":");
62
62
  }
63
63
 
64
- export function buildNewsKey(request: NewsRequest): string {
65
- return `news:${buildInstrumentKey(request.instrument)}:${request.count ?? 50}`;
66
- }
67
-
68
64
  export function buildOptionsKey(request: OptionsRequest): string {
69
65
  return `options:${buildInstrumentKey(request.instrument)}:${request.expirationDate ?? "default"}`;
70
66
  }
@@ -58,7 +58,11 @@ export class NewsService {
58
58
  if (this.pollTimer !== null) {
59
59
  void this.pollActiveQueries();
60
60
  }
61
- return () => this.unregister(source.id);
61
+ return () => {
62
+ if (this.sources.get(source.id) === source) {
63
+ this.unregister(source.id);
64
+ }
65
+ };
62
66
  }
63
67
 
64
68
  unregister(sourceId: string): void {
@@ -3,24 +3,6 @@ import { cloneLayout, createDefaultConfig, createPaneInstance, DEFAULT_PORTFOLIO
3
3
  import { deletePaneSetting, getPaneSettingValue, setPaneSetting } from "./pane-settings";
4
4
 
5
5
  describe("pane settings helpers", () => {
6
- test("seeds built-in default panes with persisted settings", () => {
7
- const config = createDefaultConfig("/tmp/gloomberb-pane-settings");
8
- const portfolioPane = config.layout.instances.find((instance) => instance.instanceId === "portfolio-list:main");
9
- const detailPane = config.layout.instances.find((instance) => instance.instanceId === "ticker-detail:main");
10
-
11
- expect(portfolioPane?.settings).toMatchObject({
12
- collectionScope: "all",
13
- viewMode: "table",
14
- });
15
- expect(portfolioPane?.settings?.columnIds).toEqual(DEFAULT_PORTFOLIO_COLUMN_IDS);
16
- expect(detailPane?.settings).toEqual({
17
- chartRangePreset: "5Y",
18
- chartResolution: "auto",
19
- hideTabs: false,
20
- lockedTabId: "overview",
21
- });
22
- });
23
-
24
6
  test("cloneLayout deep-clones pane settings", () => {
25
7
  const layout = cloneLayout(createDefaultConfig("/tmp/gloomberb-pane-settings").layout);
26
8
  const cloned = cloneLayout(layout);
@@ -8,22 +8,19 @@ export function useAccountManagementFooter({
8
8
  draft,
9
9
  hasSession,
10
10
  message,
11
- openPasswordDialog,
12
11
  profile,
13
12
  saveProfile,
14
13
  }: {
15
- busy: "profile" | "password" | null;
14
+ busy: "profile" | "password" | "alerts" | "billing" | "delete" | null;
16
15
  draft: AccountDraft;
17
16
  hasSession: boolean;
18
17
  message: { tone: "info" | "success" | "error"; text: string } | null;
19
- openPasswordDialog: () => void;
20
18
  profile: AccountProfile | null;
21
19
  saveProfile: () => Promise<void>;
22
20
  }) {
23
21
  const footerHints = useMemo<PaneHint[]>(() => [
24
22
  { id: "save", key: "Ctrl+S", label: "save", onPress: () => { void saveProfile(); }, disabled: !!busy || !hasSession },
25
- { id: "password", key: "p", label: "assword", onPress: openPasswordDialog, disabled: !!busy || !hasSession },
26
- ], [busy, hasSession, openPasswordDialog, saveProfile]);
23
+ ], [busy, hasSession, saveProfile]);
27
24
 
28
25
  usePaneFooter("account-management", () => ({
29
26
  info: [