gloomberb 0.1.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 (311) hide show
  1. package/LICENSE +25 -0
  2. package/README.md +68 -121
  3. package/bin/gloomberb +3 -0
  4. package/package.json +13 -4
  5. package/patches/@opentui%2Fcore@0.1.90.patch +356 -0
  6. package/src/app.tsx +1330 -0
  7. package/src/brokers/sync-broker-instance.test.ts +100 -0
  8. package/src/brokers/sync-broker-instance.ts +341 -0
  9. package/src/cli/commands/plugins.ts +173 -0
  10. package/src/cli/commands/portfolio.ts +210 -0
  11. package/src/cli/commands/search.ts +120 -0
  12. package/src/cli/commands/ticker.ts +375 -0
  13. package/src/cli/commands/watchlist.ts +210 -0
  14. package/src/cli/context.ts +62 -0
  15. package/src/cli/errors.ts +13 -0
  16. package/src/cli/helpers.ts +85 -0
  17. package/src/cli/index.ts +143 -0
  18. package/src/cli/ticker-resolution.ts +39 -0
  19. package/src/cli/types.ts +15 -0
  20. package/src/cli.test.ts +361 -0
  21. package/src/components/chart/chart-data.ts +239 -0
  22. package/src/components/chart/chart-renderer.ts +1199 -0
  23. package/src/components/chart/chart-types.ts +82 -0
  24. package/src/components/chart/chart-viewport.ts +21 -0
  25. package/src/components/chart/chart.test.ts +439 -0
  26. package/src/components/chart/comparison-chart-data.test.ts +109 -0
  27. package/src/components/chart/comparison-chart-data.ts +316 -0
  28. package/src/components/chart/comparison-chart-renderer.test.ts +84 -0
  29. package/src/components/chart/comparison-chart-renderer.ts +354 -0
  30. package/src/components/chart/comparison-stock-chart.tsx +1486 -0
  31. package/src/components/chart/cursor-motion.ts +48 -0
  32. package/src/components/chart/native/chart-rasterizer.ts +732 -0
  33. package/src/components/chart/native/kitty-adapter.ts +12 -0
  34. package/src/components/chart/native/kitty-manager.ts +142 -0
  35. package/src/components/chart/native/kitty-protocol.ts +110 -0
  36. package/src/components/chart/native/kitty-support.ts +71 -0
  37. package/src/components/chart/native/kitty.test.ts +620 -0
  38. package/src/components/chart/native/renderer-selection.ts +24 -0
  39. package/src/components/chart/native/surface-manager.ts +225 -0
  40. package/src/components/chart/native/surface-sync.ts +34 -0
  41. package/src/components/chart/stock-chart-renderer-switch.test.tsx +165 -0
  42. package/src/components/chart/stock-chart.test.ts +107 -0
  43. package/src/components/chart/stock-chart.tsx +1556 -0
  44. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +137 -0
  45. package/src/components/command-bar/command-bar.test.tsx +1524 -0
  46. package/src/components/command-bar/command-bar.tsx +4823 -0
  47. package/src/components/command-bar/command-registry.ts +261 -0
  48. package/src/components/command-bar/helpers.test.ts +224 -0
  49. package/src/components/command-bar/helpers.ts +371 -0
  50. package/src/components/command-bar/root-shortcuts.ts +158 -0
  51. package/src/components/command-bar/view-model.test.ts +152 -0
  52. package/src/components/command-bar/view-model.ts +185 -0
  53. package/src/components/command-bar/workflow-ops.test.ts +70 -0
  54. package/src/components/command-bar/workflow-ops.ts +433 -0
  55. package/src/components/command-bar/workflow-types.ts +127 -0
  56. package/src/components/detail-feed-view.test.tsx +105 -0
  57. package/src/components/detail-feed-view.tsx +295 -0
  58. package/src/components/index.ts +32 -0
  59. package/src/components/layout/floating-pane.tsx +49 -0
  60. package/src/components/layout/header.tsx +129 -0
  61. package/src/components/layout/pane-header.tsx +44 -0
  62. package/src/components/layout/pane.tsx +49 -0
  63. package/src/components/layout/pixel-mouse-mode.test.ts +122 -0
  64. package/src/components/layout/pixel-mouse.test.ts +40 -0
  65. package/src/components/layout/shell.test.tsx +625 -0
  66. package/src/components/layout/shell.tsx +1200 -0
  67. package/src/components/layout/status-bar.test.tsx +173 -0
  68. package/src/components/layout/status-bar.tsx +138 -0
  69. package/src/components/markdown-editor.tsx +134 -0
  70. package/src/components/onboarding/onboarding-wizard.test.tsx +184 -0
  71. package/src/components/onboarding/onboarding-wizard.tsx +1365 -0
  72. package/src/components/pane-settings-dialog.tsx +468 -0
  73. package/src/components/pane-template-wizard.test.tsx +52 -0
  74. package/src/components/pane-template-wizard.tsx +199 -0
  75. package/src/components/price-selector-dialog.tsx +195 -0
  76. package/src/components/spinner.tsx +2 -0
  77. package/src/components/tab-bar.tsx +17 -0
  78. package/src/components/ticker-badge-text.test.tsx +160 -0
  79. package/src/components/ticker-badge-text.tsx +58 -0
  80. package/src/components/ticker-badge.tsx +88 -0
  81. package/src/components/ticker-list-table.tsx +188 -0
  82. package/src/components/toggle-list.tsx +74 -0
  83. package/src/components/ui/button.tsx +77 -0
  84. package/src/components/ui/data-table.tsx +210 -0
  85. package/src/components/ui/external-link.tsx +22 -0
  86. package/src/components/ui/fields.tsx +187 -0
  87. package/src/components/ui/frame.tsx +73 -0
  88. package/src/components/ui/index.ts +53 -0
  89. package/src/components/ui/list-view.tsx +166 -0
  90. package/src/components/ui/loading.tsx +70 -0
  91. package/src/components/ui/status.tsx +83 -0
  92. package/src/components/ui/tabs.tsx +47 -0
  93. package/src/components/ui/toggle.tsx +164 -0
  94. package/src/components/ui/ui.test.tsx +134 -0
  95. package/src/data/app-persistence.ts +25 -0
  96. package/src/data/config-store.test.ts +310 -0
  97. package/src/data/config-store.ts +469 -0
  98. package/src/data/plugin-state-store.ts +102 -0
  99. package/src/data/resource-store.ts +323 -0
  100. package/src/data/session-store.ts +52 -0
  101. package/src/data/sqlite-cache.test.ts +103 -0
  102. package/src/data/sqlite-database.ts +62 -0
  103. package/src/data/sqlite-json.ts +19 -0
  104. package/src/data/ticker-repository.ts +45 -0
  105. package/src/data/ticker-store.ts +46 -0
  106. package/src/index.tsx +67 -0
  107. package/src/market-data/coordinator.test.ts +369 -0
  108. package/src/market-data/coordinator.ts +580 -0
  109. package/src/market-data/hooks.tsx +234 -0
  110. package/src/market-data/query-store.ts +28 -0
  111. package/src/market-data/request-types.test.ts +64 -0
  112. package/src/market-data/request-types.ts +70 -0
  113. package/src/market-data/result-types.ts +56 -0
  114. package/src/market-data/selectors.ts +166 -0
  115. package/src/market-data/trace.ts +7 -0
  116. package/src/pane-settings.test.ts +71 -0
  117. package/src/pane-settings.ts +73 -0
  118. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +338 -0
  119. package/src/plugins/builtin/ai/gloomberb-cli.ts +70 -0
  120. package/src/plugins/builtin/ai/index.test.tsx +139 -0
  121. package/src/plugins/builtin/ai/index.tsx +86 -0
  122. package/src/plugins/builtin/ai/providers.ts +99 -0
  123. package/src/plugins/builtin/ai/runner.ts +82 -0
  124. package/src/plugins/builtin/ai/screener-contract.test.ts +117 -0
  125. package/src/plugins/builtin/ai/screener-contract.ts +174 -0
  126. package/src/plugins/builtin/ai/screener-pane.test.tsx +365 -0
  127. package/src/plugins/builtin/ai/screener-pane.tsx +1161 -0
  128. package/src/plugins/builtin/ai/settings.ts +79 -0
  129. package/src/plugins/builtin/ai/ticker-context.ts +64 -0
  130. package/src/plugins/builtin/ai/utils.ts +71 -0
  131. package/src/plugins/builtin/ask-ai.test.tsx +258 -0
  132. package/src/plugins/builtin/ask-ai.tsx +9 -0
  133. package/src/plugins/builtin/chat-controller.test.ts +217 -0
  134. package/src/plugins/builtin/chat-controller.ts +340 -0
  135. package/src/plugins/builtin/chat.test.tsx +451 -0
  136. package/src/plugins/builtin/chat.tsx +620 -0
  137. package/src/plugins/builtin/comparison-chart.test.tsx +277 -0
  138. package/src/plugins/builtin/comparison-chart.tsx +169 -0
  139. package/src/plugins/builtin/data-providers.ts +16 -0
  140. package/src/plugins/builtin/debug.tsx +351 -0
  141. package/src/plugins/builtin/help.test.tsx +110 -0
  142. package/src/plugins/builtin/help.tsx +281 -0
  143. package/src/plugins/builtin/layout-manager.tsx +264 -0
  144. package/src/plugins/builtin/manual-entry.tsx +25 -0
  145. package/src/plugins/builtin/news.tsx +143 -0
  146. package/src/plugins/builtin/notes-files.ts +58 -0
  147. package/src/plugins/builtin/notes.tsx +396 -0
  148. package/src/plugins/builtin/options-availability.test.tsx +179 -0
  149. package/src/plugins/builtin/options-availability.ts +158 -0
  150. package/src/plugins/builtin/options.tsx +364 -0
  151. package/src/plugins/builtin/portfolio-list/header.tsx +227 -0
  152. package/src/plugins/builtin/portfolio-list/index.tsx +99 -0
  153. package/src/plugins/builtin/portfolio-list/metrics.ts +436 -0
  154. package/src/plugins/builtin/portfolio-list/pane.tsx +533 -0
  155. package/src/plugins/builtin/portfolio-list/settings.ts +280 -0
  156. package/src/plugins/builtin/portfolio-list/summary.tsx +270 -0
  157. package/src/plugins/builtin/portfolio-list/table.tsx +64 -0
  158. package/src/plugins/builtin/portfolio-list.test.ts +74 -0
  159. package/src/plugins/builtin/portfolio-list.test.tsx +1164 -0
  160. package/src/plugins/builtin/portfolio-metrics.test.ts +153 -0
  161. package/src/plugins/builtin/quote-monitor.test.tsx +114 -0
  162. package/src/plugins/builtin/sec.tsx +250 -0
  163. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +43 -0
  164. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +390 -0
  165. package/src/plugins/builtin/ticker-detail/index.tsx +80 -0
  166. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +242 -0
  167. package/src/plugins/builtin/ticker-detail/pane.tsx +183 -0
  168. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +83 -0
  169. package/src/plugins/builtin/ticker-detail/settings.ts +132 -0
  170. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +193 -0
  171. package/src/plugins/builtin/ticker-detail.test.tsx +578 -0
  172. package/src/plugins/builtin/yahoo.ts +18 -0
  173. package/src/plugins/event-bus.ts +37 -0
  174. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +35 -0
  175. package/src/plugins/ibkr/account-cache.test.ts +101 -0
  176. package/src/plugins/ibkr/account-cache.ts +137 -0
  177. package/src/plugins/ibkr/broker-adapter.ts +168 -0
  178. package/src/plugins/ibkr/config.test.ts +68 -0
  179. package/src/plugins/ibkr/config.ts +262 -0
  180. package/src/plugins/ibkr/dialogs.tsx +83 -0
  181. package/src/plugins/ibkr/flex.test.ts +107 -0
  182. package/src/plugins/ibkr/flex.ts +267 -0
  183. package/src/plugins/ibkr/fundamental-parser.ts +149 -0
  184. package/src/plugins/ibkr/gateway-helpers.ts +28 -0
  185. package/src/plugins/ibkr/gateway-selection.ts +51 -0
  186. package/src/plugins/ibkr/gateway-service.test.ts +450 -0
  187. package/src/plugins/ibkr/gateway-service.ts +2228 -0
  188. package/src/plugins/ibkr/index.test.tsx +183 -0
  189. package/src/plugins/ibkr/index.tsx +153 -0
  190. package/src/plugins/ibkr/instance-selection.test.ts +121 -0
  191. package/src/plugins/ibkr/instance-selection.ts +36 -0
  192. package/src/plugins/ibkr/price-normalization.test.ts +38 -0
  193. package/src/plugins/ibkr/price-normalization.ts +59 -0
  194. package/src/plugins/ibkr/trade-badge.tsx +27 -0
  195. package/src/plugins/ibkr/trade-tab.test.tsx +225 -0
  196. package/src/plugins/ibkr/trade-tab.tsx +1050 -0
  197. package/src/plugins/ibkr/trade-utils.test.ts +175 -0
  198. package/src/plugins/ibkr/trade-utils.ts +149 -0
  199. package/src/plugins/ibkr/trading-pane.tsx +438 -0
  200. package/src/plugins/ibkr/trading-state.test.ts +109 -0
  201. package/src/plugins/ibkr/trading-state.ts +339 -0
  202. package/src/plugins/loader.ts +71 -0
  203. package/src/plugins/pane-manager.test.ts +103 -0
  204. package/src/plugins/pane-manager.ts +974 -0
  205. package/src/plugins/plugin-persistence.ts +85 -0
  206. package/src/plugins/plugin-runtime.test.tsx +185 -0
  207. package/src/plugins/plugin-runtime.tsx +175 -0
  208. package/src/plugins/prediction-markets/cache.ts +114 -0
  209. package/src/plugins/prediction-markets/categories.ts +223 -0
  210. package/src/plugins/prediction-markets/chart.tsx +512 -0
  211. package/src/plugins/prediction-markets/columns.ts +133 -0
  212. package/src/plugins/prediction-markets/controller-data.ts +614 -0
  213. package/src/plugins/prediction-markets/controller.ts +725 -0
  214. package/src/plugins/prediction-markets/detail/book.tsx +118 -0
  215. package/src/plugins/prediction-markets/detail/outcomes.tsx +102 -0
  216. package/src/plugins/prediction-markets/detail/overview.tsx +73 -0
  217. package/src/plugins/prediction-markets/detail/pane.tsx +308 -0
  218. package/src/plugins/prediction-markets/detail/rules.tsx +16 -0
  219. package/src/plugins/prediction-markets/detail/shared.tsx +29 -0
  220. package/src/plugins/prediction-markets/detail/trades.tsx +30 -0
  221. package/src/plugins/prediction-markets/detail.test.tsx +47 -0
  222. package/src/plugins/prediction-markets/index.tsx +102 -0
  223. package/src/plugins/prediction-markets/keyboard.ts +64 -0
  224. package/src/plugins/prediction-markets/launch.test.ts +126 -0
  225. package/src/plugins/prediction-markets/launch.ts +240 -0
  226. package/src/plugins/prediction-markets/metrics.ts +311 -0
  227. package/src/plugins/prediction-markets/navigation.ts +90 -0
  228. package/src/plugins/prediction-markets/outcome-order.ts +19 -0
  229. package/src/plugins/prediction-markets/pane.test.tsx +425 -0
  230. package/src/plugins/prediction-markets/pane.tsx +294 -0
  231. package/src/plugins/prediction-markets/plugin.test.tsx +570 -0
  232. package/src/plugins/prediction-markets/rows.ts +229 -0
  233. package/src/plugins/prediction-markets/services/fetch.ts +97 -0
  234. package/src/plugins/prediction-markets/services/kalshi-adapter.ts +555 -0
  235. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +660 -0
  236. package/src/plugins/prediction-markets/services/polymarket-ws.ts +108 -0
  237. package/src/plugins/prediction-markets/settings.ts +164 -0
  238. package/src/plugins/prediction-markets/status.ts +24 -0
  239. package/src/plugins/prediction-markets/table.tsx +84 -0
  240. package/src/plugins/prediction-markets/test-helpers.tsx +636 -0
  241. package/src/plugins/prediction-markets/types.ts +186 -0
  242. package/src/plugins/registry.ts +633 -0
  243. package/src/sources/exchange-rates.ts +18 -0
  244. package/src/sources/gloomberb-cloud.test.ts +199 -0
  245. package/src/sources/gloomberb-cloud.ts +326 -0
  246. package/src/sources/provider-errors.ts +19 -0
  247. package/src/sources/provider-router.test.ts +1476 -0
  248. package/src/sources/provider-router.ts +1405 -0
  249. package/src/sources/sec-edgar.test.ts +286 -0
  250. package/src/sources/sec-edgar.ts +526 -0
  251. package/src/sources/yahoo-finance.test.ts +14 -0
  252. package/src/sources/yahoo-finance.ts +1017 -0
  253. package/src/state/app-activity.test.ts +41 -0
  254. package/src/state/app-activity.ts +106 -0
  255. package/src/state/app-bootstrap.test.ts +471 -0
  256. package/src/state/app-bootstrap.ts +308 -0
  257. package/src/state/app-context.test.ts +406 -0
  258. package/src/state/app-context.test.tsx +132 -0
  259. package/src/state/app-context.tsx +969 -0
  260. package/src/state/selectors.ts +67 -0
  261. package/src/state/session-persistence.test.ts +128 -0
  262. package/src/state/session-persistence.ts +161 -0
  263. package/src/state/ticker-refresh-queue.test.ts +46 -0
  264. package/src/state/ticker-refresh-queue.ts +40 -0
  265. package/src/state/use-inline-tickers.ts +174 -0
  266. package/src/state/use-quote-streaming.test.ts +37 -0
  267. package/src/state/use-quote-streaming.test.tsx +70 -0
  268. package/src/state/use-quote-streaming.ts +80 -0
  269. package/src/state/use-session-snapshot.ts +84 -0
  270. package/src/test-support/data-provider.ts +22 -0
  271. package/src/theme/color-utils.ts +67 -0
  272. package/src/theme/colors.ts +128 -0
  273. package/src/theme/themes.test.ts +45 -0
  274. package/src/theme/themes.ts +491 -0
  275. package/src/types/broker.ts +105 -0
  276. package/src/types/config.ts +434 -0
  277. package/src/types/data-provider.ts +81 -0
  278. package/src/types/financials.ts +142 -0
  279. package/src/types/instrument.ts +29 -0
  280. package/src/types/persistence.ts +21 -0
  281. package/src/types/plugin.ts +354 -0
  282. package/src/types/ticker.ts +58 -0
  283. package/src/types/trading.ts +85 -0
  284. package/src/updater.test.ts +162 -0
  285. package/src/updater.ts +247 -0
  286. package/src/utils/api-client.ts +666 -0
  287. package/src/utils/ascii-chart.ts +172 -0
  288. package/src/utils/broker-instances.ts +47 -0
  289. package/src/utils/cli-output.ts +83 -0
  290. package/src/utils/currency-units.test.ts +49 -0
  291. package/src/utils/currency-units.ts +82 -0
  292. package/src/utils/debug-log.ts +133 -0
  293. package/src/utils/exchange-rate-map.ts +8 -0
  294. package/src/utils/format.ts +158 -0
  295. package/src/utils/fuzzy-search.ts +34 -0
  296. package/src/utils/market-status.ts +95 -0
  297. package/src/utils/options.ts +86 -0
  298. package/src/utils/price-history.ts +39 -0
  299. package/src/utils/quote-time.test.ts +22 -0
  300. package/src/utils/quote-time.ts +50 -0
  301. package/src/utils/sec.test.ts +55 -0
  302. package/src/utils/sec.ts +45 -0
  303. package/src/utils/ticker-list.test.ts +22 -0
  304. package/src/utils/ticker-list.ts +35 -0
  305. package/src/utils/ticker-metadata.ts +51 -0
  306. package/src/utils/ticker-search.test.ts +227 -0
  307. package/src/utils/ticker-search.ts +764 -0
  308. package/src/utils/ticker-tokenizer.test.ts +48 -0
  309. package/src/utils/ticker-tokenizer.ts +72 -0
  310. package/src/version.ts +1 -0
  311. package/install.mjs +0 -69
@@ -0,0 +1,280 @@
1
+ import type { PaneSettingOption, PaneSettingsDef, PaneTemplateContext } from "../../../types/plugin";
2
+ import { DEFAULT_COLUMNS, type AppConfig, type ColumnConfig } from "../../../types/config";
3
+
4
+ export type CollectionScope = "all" | "portfolios" | "watchlists" | "custom";
5
+
6
+ export interface PortfolioPaneSettings {
7
+ columnIds: string[];
8
+ collectionScope: CollectionScope;
9
+ visibleCollectionIds: string[];
10
+ hideTabs: boolean;
11
+ hideHeader: boolean;
12
+ hideCash: boolean;
13
+ lockedCollectionId: string;
14
+ }
15
+
16
+ export interface CollectionEntry {
17
+ id: string;
18
+ name: string;
19
+ kind: "portfolio" | "watchlist";
20
+ }
21
+
22
+ export const PORTFOLIO_COLUMN_DEFS: ColumnConfig[] = [
23
+ ...DEFAULT_COLUMNS,
24
+ { id: "bid", label: "BID", width: 10, align: "right", format: "currency" },
25
+ { id: "ask", label: "ASK", width: 10, align: "right", format: "currency" },
26
+ { id: "spread", label: "SPREAD", width: 10, align: "right", format: "currency" },
27
+ { id: "change", label: "CHG", width: 9, align: "right", format: "currency" },
28
+ { id: "ext_hours", label: "EXT%", width: 8, align: "right", format: "percent" },
29
+ { id: "dividend_yield", label: "DIV%", width: 7, align: "right", format: "percent" },
30
+ { id: "shares", label: "SHARES", width: 9, align: "right", format: "number" },
31
+ { id: "avg_cost", label: "AVG COST", width: 10, align: "right", format: "currency" },
32
+ { id: "cost_basis", label: "COST", width: 10, align: "right", format: "compact" },
33
+ { id: "mkt_value", label: "MKT VAL", width: 10, align: "right", format: "compact" },
34
+ { id: "pnl", label: "P&L", width: 10, align: "right", format: "compact" },
35
+ { id: "pnl_pct", label: "P&L%", width: 8, align: "right", format: "percent" },
36
+ ];
37
+
38
+ export const DEFAULT_PORTFOLIO_COLUMN_IDS = [
39
+ ...DEFAULT_COLUMNS.map((column) => column.id),
40
+ "shares",
41
+ "avg_cost",
42
+ "cost_basis",
43
+ "mkt_value",
44
+ "pnl",
45
+ "pnl_pct",
46
+ ];
47
+
48
+ const PORTFOLIO_COLUMNS_BY_ID = new Map(PORTFOLIO_COLUMN_DEFS.map((column) => [column.id, column]));
49
+ const PORTFOLIO_ONLY_COLUMN_IDS = new Set([
50
+ "shares",
51
+ "avg_cost",
52
+ "cost_basis",
53
+ "mkt_value",
54
+ "pnl",
55
+ "pnl_pct",
56
+ ]);
57
+ const COLLECTION_SCOPE_OPTIONS: PaneSettingOption[] = [
58
+ {
59
+ value: "all",
60
+ label: "All Collections",
61
+ description: "Show portfolios and watchlists in this pane.",
62
+ },
63
+ {
64
+ value: "portfolios",
65
+ label: "Portfolios Only",
66
+ description: "Limit the pane to portfolios.",
67
+ },
68
+ {
69
+ value: "watchlists",
70
+ label: "Watchlists Only",
71
+ description: "Limit the pane to watchlists.",
72
+ },
73
+ {
74
+ value: "custom",
75
+ label: "Custom Selection",
76
+ description: "Choose exactly which collections this pane should show.",
77
+ },
78
+ ];
79
+
80
+ function isCollectionScope(value: unknown): value is CollectionScope {
81
+ return value === "all" || value === "portfolios" || value === "watchlists" || value === "custom";
82
+ }
83
+
84
+ function filterCollectionEntries(entries: CollectionEntry[], settings: PortfolioPaneSettings): CollectionEntry[] {
85
+ switch (settings.collectionScope) {
86
+ case "portfolios":
87
+ return entries.filter((entry) => entry.kind === "portfolio");
88
+ case "watchlists":
89
+ return entries.filter((entry) => entry.kind === "watchlist");
90
+ case "custom": {
91
+ const selectedIds = new Set(settings.visibleCollectionIds);
92
+ return entries.filter((entry) => selectedIds.has(entry.id));
93
+ }
94
+ default:
95
+ return entries;
96
+ }
97
+ }
98
+
99
+ function resolveCollectionOptions(entries: CollectionEntry[]): PaneSettingOption[] {
100
+ return entries.map((entry) => ({
101
+ value: entry.id,
102
+ label: entry.name,
103
+ description: entry.kind === "portfolio" ? "Portfolio" : "Watchlist",
104
+ }));
105
+ }
106
+
107
+ function resolveLockedCollectionId(settings: PortfolioPaneSettings, visibleCollections: CollectionEntry[]): string {
108
+ if (visibleCollections.some((entry) => entry.id === settings.lockedCollectionId)) {
109
+ return settings.lockedCollectionId;
110
+ }
111
+ return visibleCollections[0]?.id ?? "";
112
+ }
113
+
114
+ export function getPortfolioPaneSettings(settings: Record<string, unknown> | undefined): PortfolioPaneSettings {
115
+ const columnIds = Array.isArray(settings?.columnIds)
116
+ ? settings.columnIds.filter((value): value is string => typeof value === "string")
117
+ : DEFAULT_PORTFOLIO_COLUMN_IDS;
118
+ const visibleCollectionIds = Array.isArray(settings?.visibleCollectionIds)
119
+ ? settings.visibleCollectionIds.filter((value): value is string => typeof value === "string")
120
+ : [];
121
+
122
+ return {
123
+ columnIds: columnIds.length > 0 ? columnIds : DEFAULT_PORTFOLIO_COLUMN_IDS,
124
+ collectionScope: isCollectionScope(settings?.collectionScope) ? settings.collectionScope : "all",
125
+ visibleCollectionIds,
126
+ hideTabs: settings?.hideTabs === true,
127
+ hideHeader: settings?.hideHeader === true,
128
+ hideCash: settings?.hideCash === true,
129
+ lockedCollectionId: typeof settings?.lockedCollectionId === "string" ? settings.lockedCollectionId : "",
130
+ };
131
+ }
132
+
133
+ export function createPortfolioPaneSettings(overrides: Partial<PortfolioPaneSettings> = {}): PortfolioPaneSettings {
134
+ return {
135
+ columnIds: [...(overrides.columnIds ?? DEFAULT_PORTFOLIO_COLUMN_IDS)],
136
+ collectionScope: overrides.collectionScope ?? "all",
137
+ visibleCollectionIds: [...(overrides.visibleCollectionIds ?? [])],
138
+ hideTabs: overrides.hideTabs ?? false,
139
+ hideHeader: overrides.hideHeader ?? false,
140
+ hideCash: overrides.hideCash ?? false,
141
+ lockedCollectionId: overrides.lockedCollectionId ?? "",
142
+ };
143
+ }
144
+
145
+ export function getCollectionEntries(config: AppConfig): CollectionEntry[] {
146
+ return [
147
+ ...config.portfolios.map((portfolio) => ({
148
+ id: portfolio.id,
149
+ name: portfolio.name,
150
+ kind: "portfolio" as const,
151
+ })),
152
+ ...config.watchlists.map((watchlist) => ({
153
+ id: watchlist.id,
154
+ name: watchlist.name,
155
+ kind: "watchlist" as const,
156
+ })),
157
+ ];
158
+ }
159
+
160
+ export function resolveScopedCollectionEntries(entries: CollectionEntry[], settings: PortfolioPaneSettings): CollectionEntry[] {
161
+ const filtered = filterCollectionEntries(entries, settings);
162
+ if (settings.collectionScope === "custom" && filtered.length === 0 && entries[0]) {
163
+ return [entries[0]];
164
+ }
165
+ return filtered;
166
+ }
167
+
168
+ export function resolveActiveCollectionId(
169
+ currentCollectionId: string,
170
+ visibleCollections: CollectionEntry[],
171
+ settings: PortfolioPaneSettings,
172
+ ): string {
173
+ if (visibleCollections.length === 0) return "";
174
+ if (settings.hideTabs) {
175
+ return resolveLockedCollectionId(settings, visibleCollections);
176
+ }
177
+ if (visibleCollections.some((entry) => entry.id === currentCollectionId)) {
178
+ return currentCollectionId;
179
+ }
180
+ return resolveLockedCollectionId(settings, visibleCollections);
181
+ }
182
+
183
+ export function resolveVisibleColumns(columnIds: string[], isPortfolioTab: boolean): ColumnConfig[] {
184
+ const resolved = columnIds
185
+ .map((columnId) => PORTFOLIO_COLUMNS_BY_ID.get(columnId))
186
+ .filter((column): column is ColumnConfig => column != null)
187
+ .filter((column) => isPortfolioTab || !PORTFOLIO_ONLY_COLUMN_IDS.has(column.id));
188
+
189
+ if (resolved.length > 0) {
190
+ return resolved;
191
+ }
192
+
193
+ return DEFAULT_COLUMNS.filter((column) => isPortfolioTab || !PORTFOLIO_ONLY_COLUMN_IDS.has(column.id));
194
+ }
195
+
196
+ export function buildPortfolioPaneSettingsDef(config: AppConfig, settings: PortfolioPaneSettings): PaneSettingsDef {
197
+ const collectionEntries = getCollectionEntries(config);
198
+ const scopedEntries = resolveScopedCollectionEntries(collectionEntries, settings);
199
+ const allCollectionOptions = resolveCollectionOptions(collectionEntries);
200
+ const lockedCollectionOptions = resolveCollectionOptions(scopedEntries.length > 0 ? scopedEntries : collectionEntries);
201
+
202
+ const fields: PaneSettingsDef["fields"] = [
203
+ {
204
+ key: "columnIds",
205
+ label: "Columns",
206
+ description: "Choose which columns this pane shows and in what order.",
207
+ type: "ordered-multi-select",
208
+ options: PORTFOLIO_COLUMN_DEFS.map((column) => ({
209
+ value: column.id,
210
+ label: column.label,
211
+ description: PORTFOLIO_ONLY_COLUMN_IDS.has(column.id)
212
+ ? "Visible only when this pane is showing a portfolio."
213
+ : "Visible for watchlists and portfolios.",
214
+ })),
215
+ },
216
+ {
217
+ key: "collectionScope",
218
+ label: "Collections",
219
+ description: "Control which portfolios or watchlists appear in this pane.",
220
+ type: "select",
221
+ options: COLLECTION_SCOPE_OPTIONS,
222
+ },
223
+ ];
224
+
225
+ if (settings.collectionScope === "custom") {
226
+ fields.push({
227
+ key: "visibleCollectionIds",
228
+ label: "Visible Collections",
229
+ description: "Pick the exact collections that should appear in this pane.",
230
+ type: "multi-select",
231
+ options: allCollectionOptions,
232
+ });
233
+ }
234
+
235
+ fields.push({
236
+ key: "hideTabs",
237
+ label: "Hide Tabs",
238
+ description: "Hide the collection tab bar and lock this pane to one collection.",
239
+ type: "toggle",
240
+ });
241
+ fields.push({
242
+ key: "hideHeader",
243
+ label: "Hide Header Bar",
244
+ description: "Hide the summary bar showing portfolio value, P&L, and account metrics.",
245
+ type: "toggle",
246
+ });
247
+ fields.push({
248
+ key: "hideCash",
249
+ label: "Hide Cash Positions",
250
+ description: "Hide the cash & margin drawer at the bottom of the pane.",
251
+ type: "toggle",
252
+ });
253
+
254
+ if (settings.hideTabs && lockedCollectionOptions.length > 0) {
255
+ fields.push({
256
+ key: "lockedCollectionId",
257
+ label: "Locked Collection",
258
+ description: "Choose which collection this pane should stay pinned to.",
259
+ type: "select",
260
+ options: lockedCollectionOptions,
261
+ });
262
+ }
263
+
264
+ return {
265
+ title: "Portfolio Pane Settings",
266
+ fields,
267
+ };
268
+ }
269
+
270
+ export function resolveCollectionPaneId(context: PaneTemplateContext): string | null {
271
+ if (context.activeCollectionId) {
272
+ const isPortfolio = context.config.portfolios.some((portfolio) => portfolio.id === context.activeCollectionId);
273
+ const isWatchlist = context.config.watchlists.some((watchlist) => watchlist.id === context.activeCollectionId);
274
+ if (isPortfolio || isWatchlist) {
275
+ return context.activeCollectionId;
276
+ }
277
+ }
278
+
279
+ return context.config.portfolios[0]?.id ?? context.config.watchlists[0]?.id ?? null;
280
+ }
@@ -0,0 +1,270 @@
1
+ import { TextAttributes } from "@opentui/core";
2
+ import { colors, priceColor } from "../../../theme/colors";
3
+ import type { AppState } from "../../../state/app-context";
4
+ import type { Portfolio } from "../../../types/ticker";
5
+ import type { BrokerAccount, BrokerCashBalance } from "../../../types/trading";
6
+ import { formatCompact, formatPercentRaw } from "../../../utils/format";
7
+ import { getBrokerInstance } from "../../../utils/broker-instances";
8
+ import type { IbkrSnapshot } from "../../ibkr/gateway-service";
9
+ import type { PortfolioSummaryTotals } from "./metrics";
10
+
11
+ export interface PortfolioSummarySegment {
12
+ id: string;
13
+ parts: Array<{
14
+ text: string;
15
+ tone: "label" | "value" | "muted";
16
+ color?: string;
17
+ bold?: boolean;
18
+ }>;
19
+ length: number;
20
+ }
21
+
22
+ export interface PortfolioSummaryAccountState {
23
+ account: BrokerAccount;
24
+ sourceLabel: string;
25
+ }
26
+
27
+ export interface ResolvedPortfolioAccountState extends PortfolioSummaryAccountState {
28
+ sourceKind: "live" | "cached" | "flex";
29
+ visibleCashBalances: BrokerCashBalance[];
30
+ }
31
+
32
+ function createSummarySegment(
33
+ id: string,
34
+ parts: PortfolioSummarySegment["parts"],
35
+ ): PortfolioSummarySegment {
36
+ return {
37
+ id,
38
+ parts,
39
+ length: parts.reduce((sum, part) => sum + part.text.length, 0) + Math.max(0, parts.length - 1),
40
+ };
41
+ }
42
+
43
+ function fitSummarySegments(candidates: PortfolioSummarySegment[], widthBudget: number): PortfolioSummarySegment[] {
44
+ const fitted: PortfolioSummarySegment[] = [];
45
+ let used = 0;
46
+ for (const segment of candidates) {
47
+ const nextUsed = used + (fitted.length > 0 ? 2 : 0) + segment.length;
48
+ if (fitted.length > 0 && nextUsed > widthBudget) break;
49
+ fitted.push(segment);
50
+ used = nextUsed;
51
+ }
52
+ return fitted;
53
+ }
54
+
55
+ function formatSourceBadge(account: BrokerAccount, liveGateway: boolean): { label: string; kind: "live" | "cached" | "flex" } {
56
+ if (liveGateway) {
57
+ return { label: "Live", kind: "live" };
58
+ }
59
+ if (account.source === "flex") {
60
+ return {
61
+ label: account.updatedAt
62
+ ? `Flex ${new Date(account.updatedAt).toLocaleDateString("en-US", { month: "short", day: "numeric" })}`
63
+ : "Flex",
64
+ kind: "flex",
65
+ };
66
+ }
67
+ return { label: "Cached", kind: "cached" };
68
+ }
69
+
70
+ function getVisibleCashBalances(cashBalances: BrokerCashBalance[] | undefined): BrokerCashBalance[] {
71
+ if (!cashBalances) return [];
72
+ return cashBalances
73
+ .filter((balance) => {
74
+ const quantity = Math.abs(balance.quantity);
75
+ const baseValue = Math.abs(balance.baseValue ?? 0);
76
+ return quantity > 1e-9 || baseValue > 1e-9;
77
+ })
78
+ .sort((left, right) => {
79
+ const leftValue = Math.abs(left.baseValue ?? left.quantity);
80
+ const rightValue = Math.abs(right.baseValue ?? right.quantity);
81
+ return rightValue - leftValue;
82
+ });
83
+ }
84
+
85
+ function findPortfolioAccount(
86
+ accounts: BrokerAccount[],
87
+ portfolio: Portfolio,
88
+ ): BrokerAccount | undefined {
89
+ if (accounts.length === 0) return undefined;
90
+
91
+ const accountId = portfolio.brokerAccountId?.trim();
92
+ const portfolioName = portfolio.name.trim();
93
+ const collectionAccountId = portfolio.id.split(":").pop()?.trim();
94
+
95
+ return accounts.find((account) => account.accountId === accountId)
96
+ ?? accounts.find((account) => account.accountId === portfolioName || account.name === portfolioName)
97
+ ?? accounts.find((account) => account.accountId === collectionAccountId || account.name === collectionAccountId)
98
+ ?? (accounts.length === 1 ? accounts[0] : undefined);
99
+ }
100
+
101
+ export function resolvePortfolioAccountState(
102
+ portfolio: Portfolio | null,
103
+ state: Pick<AppState, "config" | "brokerAccounts">,
104
+ liveSnapshot: IbkrSnapshot,
105
+ ): ResolvedPortfolioAccountState | null {
106
+ if (!portfolio?.brokerInstanceId) return null;
107
+
108
+ const brokerInstance = getBrokerInstance(state.config.brokerInstances, portfolio.brokerInstanceId);
109
+ const cachedAccounts = state.brokerAccounts[portfolio.brokerInstanceId] ?? [];
110
+ const cachedAccount = findPortfolioAccount(cachedAccounts, portfolio);
111
+
112
+ const liveAccount = brokerInstance?.brokerType === "ibkr"
113
+ && brokerInstance.connectionMode === "gateway"
114
+ && liveSnapshot.status.state === "connected"
115
+ ? findPortfolioAccount(liveSnapshot.accounts, portfolio)
116
+ : undefined;
117
+
118
+ const account = liveAccount ?? cachedAccount;
119
+ if (!account) return null;
120
+
121
+ const source = formatSourceBadge(account, !!liveAccount);
122
+ return {
123
+ account,
124
+ sourceLabel: source.label,
125
+ sourceKind: source.kind,
126
+ visibleCashBalances: getVisibleCashBalances(account.cashBalances),
127
+ };
128
+ }
129
+
130
+ export function buildPortfolioSummarySegments({
131
+ totals,
132
+ accountState,
133
+ widthBudget,
134
+ refreshText,
135
+ }: {
136
+ totals: PortfolioSummaryTotals;
137
+ accountState: PortfolioSummaryAccountState | null;
138
+ widthBudget: number;
139
+ refreshText?: string;
140
+ }): PortfolioSummarySegment[] {
141
+ const candidates: PortfolioSummarySegment[] = [];
142
+
143
+ if (accountState?.account.netLiquidation != null) {
144
+ candidates.push(createSummarySegment("netliq", [
145
+ { text: "Net Liq", tone: "label" },
146
+ { text: formatCompact(accountState.account.netLiquidation), tone: "value", bold: true },
147
+ ]));
148
+ }
149
+
150
+ candidates.push(createSummarySegment("val", [
151
+ { text: "Val", tone: "label" },
152
+ { text: formatCompact(totals.totalMktValue), tone: "value", bold: true },
153
+ ]));
154
+
155
+ if (accountState?.account.totalCashValue != null) {
156
+ candidates.push(createSummarySegment("cash", [
157
+ { text: "Cash", tone: "label" },
158
+ { text: formatCompact(accountState.account.totalCashValue), tone: "value", bold: true },
159
+ ]));
160
+ }
161
+
162
+ candidates.push(createSummarySegment("day", [
163
+ { text: "Day", tone: "label" },
164
+ { text: `${totals.dailyPnl >= 0 ? "+" : ""}${formatCompact(totals.dailyPnl)}`, tone: "value", color: priceColor(totals.dailyPnl), bold: true },
165
+ { text: `(${formatPercentRaw(totals.dailyPnlPct)})`, tone: "muted", color: priceColor(totals.dailyPnlPct) },
166
+ ]));
167
+ candidates.push(createSummarySegment("pnl", [
168
+ { text: "P&L", tone: "label" },
169
+ { text: `${totals.unrealizedPnl >= 0 ? "+" : ""}${formatCompact(totals.unrealizedPnl)}`, tone: "value", color: priceColor(totals.unrealizedPnl), bold: true },
170
+ { text: `(${formatPercentRaw(totals.unrealizedPnlPct)})`, tone: "muted", color: priceColor(totals.unrealizedPnlPct) },
171
+ ]));
172
+
173
+ if (accountState) {
174
+ const { account, sourceLabel } = accountState;
175
+ const brokerSegments = [
176
+ account.settledCash != null
177
+ ? createSummarySegment("settled", [
178
+ { text: "Settled", tone: "label" },
179
+ { text: formatCompact(account.settledCash), tone: "value", bold: true },
180
+ ])
181
+ : null,
182
+ account.availableFunds != null
183
+ ? createSummarySegment("avail", [
184
+ { text: "Avail", tone: "label" },
185
+ { text: formatCompact(account.availableFunds), tone: "value", bold: true },
186
+ ])
187
+ : null,
188
+ account.excessLiquidity != null
189
+ ? createSummarySegment("excess", [
190
+ { text: "Excess", tone: "label" },
191
+ { text: formatCompact(account.excessLiquidity), tone: "value", bold: true },
192
+ ])
193
+ : null,
194
+ account.buyingPower != null
195
+ ? createSummarySegment("bp", [
196
+ { text: "BP", tone: "label" },
197
+ { text: formatCompact(account.buyingPower), tone: "value", bold: true },
198
+ ])
199
+ : null,
200
+ createSummarySegment("source", [
201
+ { text: sourceLabel, tone: "muted" },
202
+ ]),
203
+ ].filter((segment): segment is PortfolioSummarySegment => segment != null);
204
+
205
+ return fitSummarySegments([...candidates, ...brokerSegments], widthBudget);
206
+ }
207
+
208
+ if (refreshText) {
209
+ candidates.push(createSummarySegment("refresh", [
210
+ { text: refreshText, tone: "muted" },
211
+ ]));
212
+ }
213
+
214
+ return fitSummarySegments(candidates, widthBudget);
215
+ }
216
+
217
+ export function renderSummarySegments(segments: PortfolioSummarySegment[], width: number) {
218
+ if (segments.length === 0) return null;
219
+ return (
220
+ <box flexDirection="row" width={width} justifyContent="flex-start" overflow="hidden">
221
+ {segments.map((segment, segmentIndex) => (
222
+ <box key={segment.id} flexDirection="row">
223
+ {segmentIndex > 0 && <text fg={colors.textDim}>{" "}</text>}
224
+ {segment.parts.map((part, partIndex) => (
225
+ <box key={`${segment.id}:${partIndex}`} flexDirection="row">
226
+ {partIndex > 0 && <text fg={colors.textDim}>{" "}</text>}
227
+ <text
228
+ fg={part.color ?? (part.tone === "label" || part.tone === "muted" ? colors.textDim : colors.text)}
229
+ attributes={part.bold ? TextAttributes.BOLD : 0}
230
+ >
231
+ {part.text}
232
+ </text>
233
+ </box>
234
+ ))}
235
+ </box>
236
+ ))}
237
+ </box>
238
+ );
239
+ }
240
+
241
+ export function buildDrawerMetricSegments(account: BrokerAccount, widthBudget: number): PortfolioSummarySegment[] {
242
+ const candidates = [
243
+ account.totalCashValue != null
244
+ ? createSummarySegment("cash", [{ text: "Cash", tone: "label" }, { text: formatCompact(account.totalCashValue), tone: "value", bold: true }])
245
+ : null,
246
+ account.settledCash != null
247
+ ? createSummarySegment("settled", [{ text: "Settled", tone: "label" }, { text: formatCompact(account.settledCash), tone: "value", bold: true }])
248
+ : null,
249
+ account.netLiquidation != null
250
+ ? createSummarySegment("netliq", [{ text: "Net Liq", tone: "label" }, { text: formatCompact(account.netLiquidation), tone: "value", bold: true }])
251
+ : null,
252
+ account.availableFunds != null
253
+ ? createSummarySegment("avail", [{ text: "Avail", tone: "label" }, { text: formatCompact(account.availableFunds), tone: "value", bold: true }])
254
+ : null,
255
+ account.excessLiquidity != null
256
+ ? createSummarySegment("excess", [{ text: "Excess", tone: "label" }, { text: formatCompact(account.excessLiquidity), tone: "value", bold: true }])
257
+ : null,
258
+ account.buyingPower != null
259
+ ? createSummarySegment("bp", [{ text: "BP", tone: "label" }, { text: formatCompact(account.buyingPower), tone: "value", bold: true }])
260
+ : null,
261
+ account.initMarginReq != null
262
+ ? createSummarySegment("init", [{ text: "Init", tone: "label" }, { text: formatCompact(account.initMarginReq), tone: "value", bold: true }])
263
+ : null,
264
+ account.maintMarginReq != null
265
+ ? createSummarySegment("maint", [{ text: "Maint", tone: "label" }, { text: formatCompact(account.maintMarginReq), tone: "value", bold: true }])
266
+ : null,
267
+ ].filter((segment): segment is PortfolioSummarySegment => segment != null);
268
+
269
+ return fitSummarySegments(candidates, widthBudget);
270
+ }
@@ -0,0 +1,64 @@
1
+ import type { RefObject } from "react";
2
+ import type { ScrollBoxRenderable } from "@opentui/core";
3
+ import { TickerListTable, type QuoteFlashDirection } from "../../../components/ticker-list-table";
4
+ import type { ColumnConfig } from "../../../types/config";
5
+ import type { TickerFinancials } from "../../../types/financials";
6
+ import type { TickerRecord } from "../../../types/ticker";
7
+ import { getColumnValue, type ColumnContext } from "./metrics";
8
+
9
+ export function PortfolioTickerTable({
10
+ columns,
11
+ sortColumnId,
12
+ sortDirection,
13
+ onHeaderClick,
14
+ headerScrollRef,
15
+ scrollRef,
16
+ syncHeaderScroll,
17
+ onBodyScrollActivity,
18
+ sortedTickers,
19
+ cursorSymbol,
20
+ hoveredIdx,
21
+ setHoveredIdx,
22
+ setCursorSymbol,
23
+ financialsMap,
24
+ columnContext,
25
+ flashSymbols,
26
+ }: {
27
+ columns: ColumnConfig[];
28
+ sortColumnId: string | null;
29
+ sortDirection: "asc" | "desc";
30
+ onHeaderClick: (columnId: string) => void;
31
+ headerScrollRef: RefObject<ScrollBoxRenderable | null>;
32
+ scrollRef: RefObject<ScrollBoxRenderable | null>;
33
+ syncHeaderScroll: () => void;
34
+ onBodyScrollActivity: () => void;
35
+ sortedTickers: TickerRecord[];
36
+ cursorSymbol: string | null;
37
+ hoveredIdx: number | null;
38
+ setHoveredIdx: (index: number | null) => void;
39
+ setCursorSymbol: (symbol: string) => void;
40
+ financialsMap: Map<string, TickerFinancials>;
41
+ columnContext: ColumnContext;
42
+ flashSymbols: Map<string, QuoteFlashDirection>;
43
+ }) {
44
+ return (
45
+ <TickerListTable
46
+ columns={columns}
47
+ tickers={sortedTickers}
48
+ cursorSymbol={cursorSymbol}
49
+ hoveredIdx={hoveredIdx}
50
+ setHoveredIdx={setHoveredIdx}
51
+ setCursorSymbol={setCursorSymbol}
52
+ resolveCell={(column, ticker, financials) => getColumnValue(column, ticker, financials, columnContext)}
53
+ financialsMap={financialsMap}
54
+ headerScrollRef={headerScrollRef}
55
+ scrollRef={scrollRef}
56
+ syncHeaderScroll={syncHeaderScroll}
57
+ onBodyScrollActivity={onBodyScrollActivity}
58
+ flashSymbols={flashSymbols}
59
+ sortColumnId={sortColumnId}
60
+ sortDirection={sortDirection}
61
+ onHeaderClick={onHeaderClick}
62
+ />
63
+ );
64
+ }
@@ -0,0 +1,74 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { BrokerAccount } from "../../types/trading";
3
+ import type { PortfolioSummaryTotals } from "./portfolio-list/metrics";
4
+ import { buildPortfolioSummarySegments } from "./portfolio-list/summary";
5
+ import { shouldToggleCashMarginDrawer } from "./portfolio-list";
6
+
7
+ describe("buildPortfolioSummarySegments", () => {
8
+ const totals: PortfolioSummaryTotals = {
9
+ totalMktValue: 125000,
10
+ dailyPnl: 5000,
11
+ dailyPnlPct: 4.17,
12
+ totalCostBasis: 100000,
13
+ hasPositions: true,
14
+ unrealizedPnl: 25000,
15
+ unrealizedPnlPct: 25,
16
+ avgWatchlistChange: 0,
17
+ watchlistCount: 0,
18
+ };
19
+
20
+ const account: BrokerAccount = {
21
+ accountId: "DU12345",
22
+ name: "DU12345",
23
+ netLiquidation: 125000,
24
+ totalCashValue: -50000,
25
+ settledCash: -45000,
26
+ availableFunds: 12000,
27
+ excessLiquidity: 10000,
28
+ buyingPower: 24000,
29
+ };
30
+
31
+ test("prioritizes net liquidation at narrow widths for broker portfolios", () => {
32
+ const segments = buildPortfolioSummarySegments({
33
+ totals,
34
+ accountState: { account, sourceLabel: "Live" },
35
+ widthBudget: 24,
36
+ });
37
+
38
+ expect(segments.map((segment) => segment.id)).toEqual(["netliq", "val"]);
39
+ });
40
+
41
+ test("drops low-priority broker segments before required ones", () => {
42
+ const segments = buildPortfolioSummarySegments({
43
+ totals,
44
+ accountState: { account, sourceLabel: "Live" },
45
+ widthBudget: 100,
46
+ });
47
+
48
+ expect(segments.map((segment) => segment.id)).toEqual([
49
+ "netliq",
50
+ "val",
51
+ "cash",
52
+ "day",
53
+ "pnl",
54
+ "settled",
55
+ "avail",
56
+ ]);
57
+ });
58
+
59
+ test("includes the source badge only when width permits", () => {
60
+ const segments = buildPortfolioSummarySegments({
61
+ totals,
62
+ accountState: { account, sourceLabel: "Live" },
63
+ widthBudget: 130,
64
+ });
65
+
66
+ expect(segments.map((segment) => segment.id)).toContain("source");
67
+ });
68
+
69
+ test("treats c as the cash drawer shortcut only when the drawer is available", () => {
70
+ expect(shouldToggleCashMarginDrawer("c", true)).toBe(true);
71
+ expect(shouldToggleCashMarginDrawer("c", false)).toBe(false);
72
+ expect(shouldToggleCashMarginDrawer("j", true)).toBe(false);
73
+ });
74
+ });