gloomberb 0.4.1 → 0.5.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 (212) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/app.tsx +168 -65
  4. package/src/cli/commands/ticker.ts +45 -15
  5. package/src/cli/helpers.ts +4 -2
  6. package/src/cli/registry.test.ts +42 -35
  7. package/src/cli.test.ts +4 -2
  8. package/src/components/chart/chart-control-hint.tsx +39 -0
  9. package/src/components/chart/chart-controller.test.ts +110 -0
  10. package/src/components/chart/chart-controller.ts +364 -2
  11. package/src/components/chart/chart-data.ts +177 -29
  12. package/src/components/chart/chart-indicator-selector.tsx +53 -0
  13. package/src/components/chart/chart-pane-settings.ts +20 -0
  14. package/src/components/chart/chart-renderer.ts +220 -30
  15. package/src/components/chart/chart-resolution.test.ts +67 -0
  16. package/src/components/chart/chart-resolution.ts +74 -4
  17. package/src/components/chart/chart-scroll.test.ts +69 -0
  18. package/src/components/chart/chart-scroll.ts +104 -0
  19. package/src/components/chart/chart-types.ts +12 -0
  20. package/src/components/chart/chart-viewport.test.ts +5 -1
  21. package/src/components/chart/chart-viewport.ts +1 -1
  22. package/src/components/chart/chart.test.ts +249 -10
  23. package/src/components/chart/comparison-chart-renderer.test.ts +71 -1
  24. package/src/components/chart/comparison-chart-renderer.ts +40 -3
  25. package/src/components/chart/comparison-stock-chart.tsx +218 -123
  26. package/src/components/chart/indicators/bands.test.ts +50 -0
  27. package/src/components/chart/indicators/bands.ts +29 -0
  28. package/src/components/chart/indicators/moving-averages.test.ts +54 -0
  29. package/src/components/chart/indicators/moving-averages.ts +42 -0
  30. package/src/components/chart/indicators/options.test.ts +32 -0
  31. package/src/components/chart/indicators/options.ts +107 -0
  32. package/src/components/chart/indicators/oscillators.test.ts +79 -0
  33. package/src/components/chart/indicators/oscillators.ts +92 -0
  34. package/src/components/chart/indicators/types.ts +36 -0
  35. package/src/components/chart/native/chart-rasterizer.ts +51 -0
  36. package/src/components/chart/native/kitty.test.ts +68 -0
  37. package/src/components/chart/stock-chart-renderer-switch.test.tsx +740 -9
  38. package/src/components/chart/stock-chart.test.ts +239 -0
  39. package/src/components/chart/stock-chart.tsx +2257 -372
  40. package/src/components/command-bar/command-bar.test.tsx +337 -5
  41. package/src/components/command-bar/command-bar.tsx +292 -120
  42. package/src/components/command-bar/helpers.ts +1 -1
  43. package/src/components/command-bar/root-shortcuts.ts +33 -4
  44. package/src/components/command-bar/view-model.test.ts +3 -3
  45. package/src/components/command-bar/view-model.ts +4 -4
  46. package/src/components/command-bar/workflow-ops.ts +10 -0
  47. package/src/components/layout/floating-pane.tsx +3 -2
  48. package/src/components/layout/header.tsx +2 -1
  49. package/src/components/layout/pane-sizing.ts +10 -0
  50. package/src/components/layout/pane.tsx +9 -2
  51. package/src/components/layout/shell.test.tsx +282 -28
  52. package/src/components/layout/shell.tsx +10 -7
  53. package/src/components/layout/status-bar.test.tsx +16 -7
  54. package/src/components/layout/status-bar.tsx +4 -1
  55. package/src/components/markdown-editor.tsx +1 -0
  56. package/src/components/pane-settings-dialog.tsx +13 -152
  57. package/src/components/pane-template-wizard.tsx +1 -0
  58. package/src/components/price-selector-dialog.tsx +12 -7
  59. package/src/components/ticker-badge-text.test.tsx +50 -0
  60. package/src/components/ticker-badge-text.tsx +17 -2
  61. package/src/components/ticker-badge.tsx +2 -2
  62. package/src/components/ticker-list-table.tsx +54 -1
  63. package/src/components/toggle-list.tsx +15 -2
  64. package/src/components/ui/data-table.tsx +60 -3
  65. package/src/components/ui/external-link.tsx +48 -11
  66. package/src/components/ui/fields.tsx +2 -1
  67. package/src/components/ui/index.ts +8 -1
  68. package/src/components/ui/multi-select-chips.tsx +69 -0
  69. package/src/components/ui/multi-select-dialog.tsx +267 -0
  70. package/src/components/ui/multi-select.ts +78 -0
  71. package/src/components/ui/ui.test.tsx +343 -1
  72. package/src/data/plugin-state-store.ts +30 -19
  73. package/src/data/resource-store.ts +133 -103
  74. package/src/data/session-store.ts +19 -12
  75. package/src/data/sqlite-cache.test.ts +37 -2
  76. package/src/data/sqlite-database.ts +8 -2
  77. package/src/data/sqlite-retry.test.ts +63 -0
  78. package/src/data/sqlite-retry.ts +66 -0
  79. package/src/data/ticker-store.ts +29 -18
  80. package/src/index.tsx +6 -1
  81. package/src/market-data/coordinator.test.ts +114 -5
  82. package/src/market-data/coordinator.ts +70 -1
  83. package/src/market-data/hooks.test.tsx +73 -2
  84. package/src/market-data/hooks.tsx +16 -4
  85. package/src/notifications/app-notifier.test.ts +177 -0
  86. package/src/notifications/app-notifier.ts +241 -0
  87. package/src/pane-settings.test.ts +2 -0
  88. package/src/plugins/builtin/ai/index.test.tsx +1 -1
  89. package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
  90. package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
  91. package/src/plugins/builtin/ai/ticker-context.ts +3 -2
  92. package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
  93. package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
  94. package/src/plugins/builtin/alerts/index.test.tsx +357 -0
  95. package/src/plugins/builtin/alerts/index.tsx +519 -0
  96. package/src/plugins/builtin/alerts/types.ts +14 -0
  97. package/src/plugins/builtin/analytics/index.test.tsx +300 -0
  98. package/src/plugins/builtin/analytics/index.tsx +775 -0
  99. package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
  100. package/src/plugins/builtin/analytics/metrics.ts +155 -0
  101. package/src/plugins/builtin/ask-ai.test.tsx +2 -0
  102. package/src/plugins/builtin/chat-controller.test.ts +83 -18
  103. package/src/plugins/builtin/chat-controller.ts +26 -14
  104. package/src/plugins/builtin/chat.test.tsx +715 -4
  105. package/src/plugins/builtin/chat.tsx +424 -120
  106. package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
  107. package/src/plugins/builtin/comparison-chart.tsx +1 -2
  108. package/src/plugins/builtin/correlation/compute.test.ts +147 -0
  109. package/src/plugins/builtin/correlation/compute.ts +115 -0
  110. package/src/plugins/builtin/correlation/index.tsx +385 -0
  111. package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
  112. package/src/plugins/builtin/correlation/settings.test.ts +34 -0
  113. package/src/plugins/builtin/correlation/settings.ts +95 -0
  114. package/src/plugins/builtin/debug.tsx +36 -50
  115. package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
  116. package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
  117. package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
  118. package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
  119. package/src/plugins/builtin/earnings/index.tsx +337 -0
  120. package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
  121. package/src/plugins/builtin/econ/calendar-source.ts +121 -0
  122. package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
  123. package/src/plugins/builtin/econ/fred-client.ts +79 -0
  124. package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
  125. package/src/plugins/builtin/econ/index.tsx +999 -0
  126. package/src/plugins/builtin/econ/types.ts +13 -0
  127. package/src/plugins/builtin/fred-settings.ts +10 -0
  128. package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
  129. package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
  130. package/src/plugins/builtin/help.test.tsx +1 -5
  131. package/src/plugins/builtin/help.tsx +1 -16
  132. package/src/plugins/builtin/insider/index.tsx +417 -0
  133. package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
  134. package/src/plugins/builtin/insider/insider-data.ts +49 -0
  135. package/src/plugins/builtin/layout-manager.tsx +24 -16
  136. package/src/plugins/builtin/market-movers/index.tsx +573 -0
  137. package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
  138. package/src/plugins/builtin/market-movers/screener.ts +105 -0
  139. package/src/plugins/builtin/options.tsx +4 -3
  140. package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
  141. package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
  142. package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
  143. package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
  144. package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
  145. package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
  146. package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
  147. package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
  148. package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
  149. package/src/plugins/builtin/sec.tsx +114 -7
  150. package/src/plugins/builtin/sectors/index.tsx +359 -0
  151. package/src/plugins/builtin/sectors/sector-data.ts +18 -0
  152. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
  153. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
  154. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
  155. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
  156. package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
  157. package/src/plugins/builtin/world-indices/index.tsx +410 -0
  158. package/src/plugins/builtin/world-indices/indices.ts +52 -0
  159. package/src/plugins/builtin/yield-curve/index.tsx +242 -0
  160. package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
  161. package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
  162. package/src/plugins/catalog.ts +22 -0
  163. package/src/plugins/gridlock-notification.ts +15 -0
  164. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
  165. package/src/plugins/ibkr/index.test.tsx +1 -1
  166. package/src/plugins/ibkr/index.tsx +1 -1
  167. package/src/plugins/ibkr/trade-tab.tsx +23 -5
  168. package/src/plugins/ibkr/trade-utils.test.ts +1 -1
  169. package/src/plugins/ibkr/trade-utils.ts +7 -6
  170. package/src/plugins/ibkr/trading-pane.tsx +8 -7
  171. package/src/plugins/plugin-runtime.test.tsx +2 -0
  172. package/src/plugins/plugin-runtime.tsx +10 -0
  173. package/src/plugins/prediction-markets/controller-data.ts +8 -0
  174. package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
  175. package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
  176. package/src/plugins/prediction-markets/services/fetch.ts +13 -7
  177. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  178. package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
  179. package/src/plugins/prediction-markets/test-helpers.tsx +2 -0
  180. package/src/plugins/registry.ts +22 -13
  181. package/src/sources/provider-router.test.ts +3 -2
  182. package/src/sources/sec-edgar.ts +13 -1
  183. package/src/sources/yahoo-finance.ts +75 -1
  184. package/src/state/app-context-selector.test.tsx +17 -1
  185. package/src/state/app-context.test.tsx +24 -0
  186. package/src/state/app-context.tsx +29 -6
  187. package/src/theme/colors.ts +5 -0
  188. package/src/theme/themes.ts +25 -0
  189. package/src/types/config.test.ts +107 -0
  190. package/src/types/config.ts +30 -1
  191. package/src/types/data-provider.ts +13 -0
  192. package/src/types/plugin.ts +35 -1
  193. package/src/utils/api-client.test.ts +90 -0
  194. package/src/utils/api-client.ts +56 -16
  195. package/src/utils/format.test.ts +11 -1
  196. package/src/utils/format.ts +86 -8
  197. package/src/utils/inline-content-tokenizer.test.ts +22 -0
  198. package/src/utils/inline-content-tokenizer.ts +76 -0
  199. package/src/utils/link-tokenizer.test.ts +27 -0
  200. package/src/utils/link-tokenizer.ts +93 -0
  201. package/src/utils/market-format.test.ts +109 -0
  202. package/src/utils/market-format.ts +299 -0
  203. package/src/utils/market-status.ts +3 -2
  204. package/src/utils/selection-clipboard.test.ts +43 -0
  205. package/src/utils/selection-clipboard.ts +88 -0
  206. package/src/utils/terminal-input-reset.test.ts +50 -0
  207. package/src/utils/terminal-input-reset.ts +24 -0
  208. package/src/utils/throttled-fetch.test.ts +145 -0
  209. package/src/utils/throttled-fetch.ts +212 -0
  210. package/src/utils/ticker-tokenizer.test.ts +31 -0
  211. package/src/utils/ticker-tokenizer.ts +7 -42
  212. package/src/version.ts +1 -1
@@ -2,7 +2,7 @@ import { TextAttributes } from "@opentui/core";
2
2
  import { useTerminalDimensions } from "@opentui/react";
3
3
  import { useFxRatesMap } from "../../../market-data/hooks";
4
4
  import { useAppSelector } from "../../../state/app-context";
5
- import { colors, priceColor } from "../../../theme/colors";
5
+ import { colors, priceColor, blendHex } from "../../../theme/colors";
6
6
  import {
7
7
  convertCurrency,
8
8
  formatCompact,
@@ -12,6 +12,7 @@ import {
12
12
  formatPercent,
13
13
  formatPercentRaw,
14
14
  } from "../../../utils/format";
15
+ import { formatMarketCostWithCurrency, formatMarketPriceWithCurrency, formatMarketQuantity, formatSignedMarketPrice } from "../../../utils/market-format";
15
16
  import {
16
17
  exchangeShortName,
17
18
  marketStateColor,
@@ -19,11 +20,140 @@ import {
19
20
  quoteSourceLabel,
20
21
  } from "../../../utils/market-status";
21
22
  import { selectEffectiveExchangeRates } from "../../../utils/exchange-rate-map";
22
- import { EmptyState, FieldRow } from "../../../components";
23
+ import { EmptyState } from "../../../components";
23
24
  import { ResolvedStockChart } from "../../../components/chart/stock-chart";
24
25
  import type { TickerFinancials } from "../../../types/financials";
25
26
  import type { TickerRecord } from "../../../types/ticker";
26
27
 
28
+ // ─── Visual range bar ────────────────────────────────────────────────────────
29
+
30
+ function RangeBar({
31
+ current,
32
+ low,
33
+ high,
34
+ label,
35
+ barWidth,
36
+ currency,
37
+ assetCategory,
38
+ }: {
39
+ current: number;
40
+ low: number;
41
+ high: number;
42
+ label: string;
43
+ barWidth: number;
44
+ currency: string;
45
+ assetCategory?: string;
46
+ }) {
47
+ const range = high - low;
48
+ if (range <= 0) return null;
49
+ const position = Math.max(0, Math.min(1, (current - low) / range));
50
+ const filledWidth = Math.max(1, Math.round(position * barWidth));
51
+ const emptyWidth = Math.max(0, barWidth - filledWidth);
52
+ const pctLabel = `${Math.round(position * 100)}%`;
53
+ const fillColor = blendHex(colors.negative, colors.positive, position);
54
+ const trackChar = "─";
55
+ const fillChar = "━";
56
+
57
+ return (
58
+ <box flexDirection="column">
59
+ <box flexDirection="row" height={1}>
60
+ <box width={12}>
61
+ <text fg={colors.textDim}>{label}</text>
62
+ </box>
63
+ <text fg={colors.textDim}>
64
+ {formatMarketPriceWithCurrency(low, currency, { assetCategory })}
65
+ </text>
66
+ <box marginLeft={1} marginRight={1} flexDirection="row">
67
+ <text fg={fillColor}>{fillChar.repeat(filledWidth)}</text>
68
+ <text fg={colors.border}>{trackChar.repeat(emptyWidth)}</text>
69
+ </box>
70
+ <text fg={colors.textDim}>
71
+ {formatMarketPriceWithCurrency(high, currency, { assetCategory })}
72
+ </text>
73
+ <box marginLeft={1}>
74
+ <text fg={fillColor}>{pctLabel}</text>
75
+ </box>
76
+ </box>
77
+ </box>
78
+ );
79
+ }
80
+
81
+ // ─── Volume bar ──────────────────────────────────────────────────────────────
82
+
83
+ function VolumeBar({ volume, avgVolume, barWidth }: { volume: number; avgVolume: number; barWidth: number }) {
84
+ if (!volume || !avgVolume) return null;
85
+ const ratio = volume / avgVolume;
86
+ const maxRatio = Math.max(ratio, 1);
87
+ const volWidth = Math.max(1, Math.round((Math.min(ratio, maxRatio) / maxRatio) * barWidth));
88
+ const avgWidth = Math.max(1, Math.round((1 / maxRatio) * barWidth));
89
+ const ratioColor = ratio >= 2 ? colors.textBright : ratio >= 1 ? colors.text : colors.textDim;
90
+
91
+ return (
92
+ <box flexDirection="row" height={1}>
93
+ <box width={12}>
94
+ <text fg={colors.textDim}>Volume</text>
95
+ </box>
96
+ <text fg={colors.textDim}>{formatCompact(volume)}</text>
97
+ <box marginLeft={1} marginRight={1} flexDirection="row">
98
+ <text fg={ratioColor}>{"━".repeat(volWidth)}</text>
99
+ <text fg={colors.border}>{"─".repeat(Math.max(0, barWidth - volWidth))}</text>
100
+ </box>
101
+ <text fg={colors.textDim}>avg {formatCompact(avgVolume)}</text>
102
+ <box marginLeft={1}>
103
+ <text fg={ratioColor}>{ratio.toFixed(1)}x</text>
104
+ </box>
105
+ </box>
106
+ );
107
+ }
108
+
109
+ // ─── Two-column stat grid ────────────────────────────────────────────────────
110
+
111
+ interface StatField {
112
+ label: string;
113
+ value: string;
114
+ valueColor?: string;
115
+ }
116
+
117
+ function StatGrid({ fields, width }: { fields: StatField[]; width: number }) {
118
+ const colWidth = Math.floor(width / 2);
119
+ const rows: Array<[StatField | null, StatField | null]> = [];
120
+ for (let i = 0; i < fields.length; i += 2) {
121
+ rows.push([fields[i] ?? null, fields[i + 1] ?? null]);
122
+ }
123
+
124
+ return (
125
+ <box flexDirection="column">
126
+ {rows.map((row, i) => (
127
+ <box key={i} flexDirection="row" height={1}>
128
+ {row.map((field, j) => {
129
+ if (!field) return <box key={j} width={colWidth} />;
130
+ return (
131
+ <box key={j} width={colWidth} flexDirection="row">
132
+ <box width={14}>
133
+ <text fg={colors.textDim}>{field.label}</text>
134
+ </box>
135
+ <text fg={field.valueColor ?? colors.text}>{field.value}</text>
136
+ </box>
137
+ );
138
+ })}
139
+ </box>
140
+ ))}
141
+ </box>
142
+ );
143
+ }
144
+
145
+ // ─── Section header ──────────────────────────────────────────────────────────
146
+
147
+ function SectionHeader({ title }: { title: string }) {
148
+ return (
149
+ <box height={1}>
150
+ <text attributes={TextAttributes.BOLD} fg={colors.textBright}>{title}</text>
151
+ </box>
152
+ );
153
+ }
154
+
155
+ // ─── Main component ──────────────────────────────────────────────────────────
156
+
27
157
  export function OverviewTab({
28
158
  width,
29
159
  symbol,
@@ -70,12 +200,82 @@ export function OverviewTab({
70
200
  routingVenue && routingVenue !== listingVenue ? `Route: ${routingVenue}` : "",
71
201
  ].filter((part) => part.length > 0);
72
202
 
73
- const chartWidth = Math.max((width || Math.floor(termWidth * 0.5)) - 4, 20);
203
+ const contentWidth = Math.max((width || Math.floor(termWidth * 0.5)) - 4, 20);
204
+ const chartWidth = contentWidth;
205
+ const barWidth = Math.max(10, contentWidth - 50);
74
206
  const hasHistory = (financials?.priceHistory?.length ?? 0) > 2;
75
207
 
208
+ // Build the two-column stat grid
209
+ const stats: StatField[] = [];
210
+
211
+ if (quote?.marketCap) {
212
+ stats.push({ label: "Market Cap", value: formatCompactCurrency(toBase(quote.marketCap, quoteCurrency), baseCurrency) });
213
+ }
214
+ if (fundamentals?.sharesOutstanding) {
215
+ stats.push({ label: "Shares Out", value: formatCompact(fundamentals.sharesOutstanding) });
216
+ }
217
+ if (fundamentals?.trailingPE) {
218
+ stats.push({ label: "P/E (TTM)", value: formatNumber(fundamentals.trailingPE, 1) });
219
+ }
220
+ if (fundamentals?.forwardPE) {
221
+ stats.push({ label: "Fwd P/E", value: formatNumber(fundamentals.forwardPE, 1) });
222
+ }
223
+ if (fundamentals?.eps) {
224
+ stats.push({ label: "EPS", value: formatCurrency(fundamentals.eps, quoteCurrency) });
225
+ }
226
+ if (fundamentals?.pegRatio) {
227
+ stats.push({ label: "PEG", value: formatNumber(fundamentals.pegRatio, 2) });
228
+ }
229
+ if (fundamentals?.dividendYield != null) {
230
+ stats.push({ label: "Div Yield", value: formatPercent(fundamentals.dividendYield) });
231
+ }
232
+ if (fundamentals?.revenue) {
233
+ stats.push({ label: "Revenue", value: formatCompact(fundamentals.revenue) });
234
+ }
235
+ if (fundamentals?.netIncome) {
236
+ stats.push({ label: "Net Income", value: formatCompact(fundamentals.netIncome) });
237
+ }
238
+ if (fundamentals?.freeCashFlow) {
239
+ stats.push({ label: "FCF", value: formatCompact(fundamentals.freeCashFlow) });
240
+ }
241
+ if (fundamentals?.operatingMargin != null) {
242
+ stats.push({ label: "Op Margin", value: formatPercent(fundamentals.operatingMargin) });
243
+ }
244
+ if (fundamentals?.profitMargin != null) {
245
+ stats.push({ label: "Profit Marg", value: formatPercent(fundamentals.profitMargin) });
246
+ }
247
+ if (fundamentals?.revenueGrowth != null) {
248
+ stats.push({
249
+ label: "Rev Growth",
250
+ value: formatPercent(fundamentals.revenueGrowth),
251
+ valueColor: priceColor(fundamentals.revenueGrowth),
252
+ });
253
+ }
254
+ if (fundamentals?.return1Y != null) {
255
+ stats.push({
256
+ label: "1Y Return",
257
+ value: formatPercent(fundamentals.return1Y),
258
+ valueColor: priceColor(fundamentals.return1Y),
259
+ });
260
+ }
261
+ if (fundamentals?.return3Y != null) {
262
+ stats.push({
263
+ label: "3Y Return",
264
+ value: formatPercent(fundamentals.return3Y),
265
+ valueColor: priceColor(fundamentals.return3Y),
266
+ });
267
+ }
268
+ if (fundamentals?.enterpriseValue) {
269
+ stats.push({ label: "EV", value: formatCompact(fundamentals.enterpriseValue) });
270
+ }
271
+
272
+ // Bid/ask
273
+ const hasBidAsk = quote?.bid != null || quote?.ask != null;
274
+
76
275
  return (
77
276
  <scrollbox flexGrow={1} scrollY>
78
277
  <box flexDirection="column" paddingX={1} paddingBottom={1} gap={1}>
278
+ {/* Ticker header */}
79
279
  <box flexDirection="row">
80
280
  <text attributes={TextAttributes.BOLD} fg={colors.textBright}>
81
281
  {ticker.metadata.ticker}
@@ -86,9 +286,7 @@ export function OverviewTab({
86
286
  </text>
87
287
  )}
88
288
  {listingVenue && (
89
- <text fg={colors.textDim}>
90
- {" "}({listingVenue})
91
- </text>
289
+ <text fg={colors.textDim}>{" "}({listingVenue})</text>
92
290
  )}
93
291
  {quote?.marketState && (
94
292
  <text fg={marketStateColor(quote.marketState)}>
@@ -97,26 +295,25 @@ export function OverviewTab({
97
295
  )}
98
296
  </box>
99
297
 
298
+ {/* Price block */}
100
299
  {quote && (
101
300
  <box flexDirection="column" gap={0}>
102
301
  <box flexDirection="row" gap={2}>
103
302
  <text attributes={TextAttributes.BOLD} fg={priceColor(quote.change)}>
104
- {formatCurrency(quote.price, quote.currency)}
303
+ {formatMarketPriceWithCurrency(quote.price, quote.currency, { assetCategory: ticker.metadata.assetCategory })}
105
304
  </text>
106
305
  <text fg={priceColor(quote.change)}>
107
- {quote.change >= 0 ? "+" : ""}
108
- {quote.change.toFixed(2)} ({formatPercentRaw(quote.changePercent)})
306
+ {formatSignedMarketPrice(quote.change, { assetCategory: ticker.metadata.assetCategory })} ({formatPercentRaw(quote.changePercent)})
109
307
  </text>
110
308
  </box>
111
309
  {(quote.marketState === "PRE" || quote.marketState === "PREPRE") && quote.preMarketPrice != null && (
112
310
  <box flexDirection="row" gap={2}>
113
311
  <text fg={colors.textDim}>Pre-Market:</text>
114
312
  <text fg={priceColor(quote.preMarketChange ?? 0)}>
115
- {formatCurrency(quote.preMarketPrice, quote.currency)}
313
+ {formatMarketPriceWithCurrency(quote.preMarketPrice, quote.currency, { assetCategory: ticker.metadata.assetCategory })}
116
314
  </text>
117
315
  <text fg={priceColor(quote.preMarketChange ?? 0)}>
118
- {(quote.preMarketChange ?? 0) >= 0 ? "+" : ""}
119
- {(quote.preMarketChange ?? 0).toFixed(2)} ({formatPercentRaw(quote.preMarketChangePercent ?? 0)})
316
+ {formatSignedMarketPrice(quote.preMarketChange ?? 0, { assetCategory: ticker.metadata.assetCategory })} ({formatPercentRaw(quote.preMarketChangePercent ?? 0)})
120
317
  </text>
121
318
  </box>
122
319
  )}
@@ -124,11 +321,10 @@ export function OverviewTab({
124
321
  <box flexDirection="row" gap={2}>
125
322
  <text fg={colors.textDim}>After-Hours:</text>
126
323
  <text fg={priceColor(quote.postMarketChange ?? 0)}>
127
- {formatCurrency(quote.postMarketPrice, quote.currency)}
324
+ {formatMarketPriceWithCurrency(quote.postMarketPrice, quote.currency, { assetCategory: ticker.metadata.assetCategory })}
128
325
  </text>
129
326
  <text fg={priceColor(quote.postMarketChange ?? 0)}>
130
- {(quote.postMarketChange ?? 0) >= 0 ? "+" : ""}
131
- {(quote.postMarketChange ?? 0).toFixed(2)} ({formatPercentRaw(quote.postMarketChangePercent ?? 0)})
327
+ {formatSignedMarketPrice(quote.postMarketChange ?? 0, { assetCategory: ticker.metadata.assetCategory })} ({formatPercentRaw(quote.postMarketChangePercent ?? 0)})
132
328
  </text>
133
329
  </box>
134
330
  )}
@@ -138,10 +334,42 @@ export function OverviewTab({
138
334
  </box>
139
335
  )}
140
336
 
337
+ {/* Range bars */}
338
+ {quote?.low != null && quote?.high != null && quote.high > quote.low && (
339
+ <RangeBar
340
+ current={quote.price}
341
+ low={quote.low}
342
+ high={quote.high}
343
+ label="Day Range"
344
+ barWidth={barWidth}
345
+ currency={quoteCurrency}
346
+ assetCategory={ticker.metadata.assetCategory}
347
+ />
348
+ )}
349
+ {quote?.low52w != null && quote?.high52w != null && quote.high52w > quote.low52w && (
350
+ <RangeBar
351
+ current={quote.price}
352
+ low={quote.low52w}
353
+ high={quote.high52w}
354
+ label="52W Range"
355
+ barWidth={barWidth}
356
+ currency={quoteCurrency}
357
+ assetCategory={ticker.metadata.assetCategory}
358
+ />
359
+ )}
360
+ {quote?.volume != null && fundamentals?.sharesOutstanding && (
361
+ <VolumeBar
362
+ volume={quote.volume}
363
+ avgVolume={Math.round((fundamentals.sharesOutstanding * 0.01) || quote.volume)}
364
+ barWidth={barWidth}
365
+ />
366
+ )}
367
+
368
+ {/* Chart */}
141
369
  {hasHistory && (
142
370
  <ResolvedStockChart
143
371
  width={chartWidth}
144
- height={8}
372
+ height={10}
145
373
  focused={false}
146
374
  compact
147
375
  symbol={symbol}
@@ -150,78 +378,46 @@ export function OverviewTab({
150
378
  />
151
379
  )}
152
380
 
153
- <box flexDirection="column">
154
- <FieldRow
155
- label="Market Cap"
156
- value={quote?.marketCap ? formatCompactCurrency(toBase(quote.marketCap, quoteCurrency), baseCurrency) : "—"}
157
- />
158
- <FieldRow label="P/E (TTM)" value={fundamentals?.trailingPE ? formatNumber(fundamentals.trailingPE, 1) : "—"} />
159
- <FieldRow label="Forward P/E" value={fundamentals?.forwardPE ? formatNumber(fundamentals.forwardPE, 1) : "—"} />
160
- <FieldRow label="PEG Ratio" value={fundamentals?.pegRatio ? formatNumber(fundamentals.pegRatio, 2) : "—"} />
161
- <FieldRow label="EPS" value={fundamentals?.eps ? formatCurrency(fundamentals.eps, quoteCurrency) : "—"} />
162
- <FieldRow label="Div Yield" value={fundamentals?.dividendYield != null ? formatPercent(fundamentals.dividendYield) : "—"} />
163
- <FieldRow label="Revenue" value={fundamentals?.revenue ? formatCompact(fundamentals.revenue) : "—"} />
164
- <FieldRow label="Net Income" value={fundamentals?.netIncome ? formatCompact(fundamentals.netIncome) : "—"} />
165
- <FieldRow label="FCF" value={fundamentals?.freeCashFlow ? formatCompact(fundamentals.freeCashFlow) : "—"} />
166
- <FieldRow label="Op. Margin" value={fundamentals?.operatingMargin != null ? formatPercent(fundamentals.operatingMargin) : "—"} />
167
- <FieldRow label="Profit Margin" value={fundamentals?.profitMargin != null ? formatPercent(fundamentals.profitMargin) : "—"} />
168
- {(quote?.bid != null || quote?.ask != null) && (
169
- <>
170
- <FieldRow label="Bid" value={quote?.bid != null ? formatCurrency(quote.bid, quote.currency) : "—"} />
171
- <FieldRow label="Ask" value={quote?.ask != null ? formatCurrency(quote.ask, quote.currency) : "—"} />
172
- <FieldRow
173
- label="Spread"
174
- value={quote?.bid != null && quote?.ask != null ? formatCurrency(quote.ask - quote.bid, quote.currency) : "—"}
175
- />
176
- </>
177
- )}
178
- <FieldRow
179
- label="52W Range"
180
- value={quote?.low52w && quote?.high52w ? `${formatCurrency(quote.low52w, quoteCurrency)} - ${formatCurrency(quote.high52w, quoteCurrency)}` : "—"}
181
- />
182
- <FieldRow
183
- label="1Y Return"
184
- value={fundamentals?.return1Y != null ? formatPercent(fundamentals.return1Y) : "—"}
185
- valueColor={fundamentals?.return1Y != null ? priceColor(fundamentals.return1Y) : undefined}
186
- />
187
- <FieldRow
188
- label="3Y Return"
189
- value={fundamentals?.return3Y != null ? formatPercent(fundamentals.return3Y) : "—"}
190
- valueColor={fundamentals?.return3Y != null ? priceColor(fundamentals.return3Y) : undefined}
191
- />
192
- </box>
193
-
194
- {description && (
195
- <box flexDirection="column" paddingTop={1}>
196
- <box height={1}>
197
- <text attributes={TextAttributes.BOLD} fg={colors.textBright}>Description</text>
381
+ {/* Bid/Ask */}
382
+ {hasBidAsk && quote && (
383
+ <box flexDirection="row" height={1} gap={3}>
384
+ <box flexDirection="row">
385
+ <text fg={colors.textDim}>Bid: </text>
386
+ <text fg={colors.text}>
387
+ {quote.bid != null ? formatMarketPriceWithCurrency(quote.bid, quote.currency, { assetCategory: ticker.metadata.assetCategory }) : "—"}
388
+ {quote.bidSize != null ? ` ×${quote.bidSize}` : ""}
389
+ </text>
198
390
  </box>
199
- <text fg={colors.text}>{description}</text>
391
+ <box flexDirection="row">
392
+ <text fg={colors.textDim}>Ask: </text>
393
+ <text fg={colors.text}>
394
+ {quote.ask != null ? formatMarketPriceWithCurrency(quote.ask, quote.currency, { assetCategory: ticker.metadata.assetCategory }) : "—"}
395
+ {quote.askSize != null ? ` ×${quote.askSize}` : ""}
396
+ </text>
397
+ </box>
398
+ {quote.bid != null && quote.ask != null && (
399
+ <box flexDirection="row">
400
+ <text fg={colors.textDim}>Spread: </text>
401
+ <text fg={colors.text}>
402
+ {formatMarketPriceWithCurrency(quote.ask - quote.bid, quote.currency, { assetCategory: ticker.metadata.assetCategory })}
403
+ </text>
404
+ </box>
405
+ )}
200
406
  </box>
201
407
  )}
202
408
 
203
- {(sector || industry || ticker.metadata.assetCategory || ticker.metadata.isin) && (
409
+ {/* Two-column stats grid */}
410
+ {stats.length > 0 && (
204
411
  <box flexDirection="column">
205
- {ticker.metadata.assetCategory && (
206
- <FieldRow label="Type" value={ticker.metadata.assetCategory} />
207
- )}
208
- {sector && (
209
- <FieldRow label="Sector" value={sector} />
210
- )}
211
- {industry && (
212
- <FieldRow label="Industry" value={industry} />
213
- )}
214
- {ticker.metadata.isin && (
215
- <FieldRow label="ISIN" value={ticker.metadata.isin} />
216
- )}
412
+ <SectionHeader title="Fundamentals" />
413
+ <StatGrid fields={stats} width={contentWidth} />
217
414
  </box>
218
415
  )}
219
416
 
417
+ {/* Positions */}
220
418
  {ticker.metadata.positions.length > 0 && (
221
419
  <box flexDirection="column">
222
- <box height={1}>
223
- <text attributes={TextAttributes.BOLD} fg={colors.textBright}>Positions</text>
224
- </box>
420
+ <SectionHeader title="Positions" />
225
421
  {ticker.metadata.positions.map((position, index) => {
226
422
  const costBasis = position.shares * position.avgCost * (position.multiplier || 1);
227
423
  const positionCurrency = position.currency || quoteCurrency;
@@ -251,7 +447,7 @@ export function OverviewTab({
251
447
  </box>
252
448
  <box flexDirection="row" height={1}>
253
449
  <text fg={colors.text}>
254
- {position.shares} {position.multiplier && position.multiplier > 1 ? "contracts" : "shares"} @ {formatCurrency(position.avgCost, positionCurrency)}
450
+ {formatMarketQuantity(position.shares, { assetCategory: ticker.metadata.assetCategory, multiplier: position.multiplier })} {position.multiplier && position.multiplier > 1 ? "contracts" : "shares"} @ {formatMarketCostWithCurrency(position.avgCost, positionCurrency, { assetCategory: ticker.metadata.assetCategory, multiplier: position.multiplier })}
255
451
  {" = "}{formatCurrency(costBasisBase, baseCurrency)}
256
452
  </text>
257
453
  {pnlText && (
@@ -260,7 +456,7 @@ export function OverviewTab({
260
456
  </box>
261
457
  {position.markPrice != null && (
262
458
  <box flexDirection="row" height={1}>
263
- <text fg={colors.textDim}>Mark: {formatCurrency(position.markPrice, positionCurrency)}</text>
459
+ <text fg={colors.textDim}>Mark: {formatMarketPriceWithCurrency(position.markPrice, positionCurrency, { assetCategory: ticker.metadata.assetCategory, multiplier: position.multiplier })}</text>
264
460
  {marketValueBase != null && (
265
461
  <text fg={colors.textDim}>{" "}Mkt Value: {formatCurrency(marketValueBase, baseCurrency)}</text>
266
462
  )}
@@ -271,6 +467,46 @@ export function OverviewTab({
271
467
  })}
272
468
  </box>
273
469
  )}
470
+
471
+ {/* Sector / Industry / Type */}
472
+ {(sector || industry || ticker.metadata.assetCategory) && (
473
+ <box flexDirection="row" height={1} gap={3}>
474
+ {ticker.metadata.assetCategory && (
475
+ <box flexDirection="row">
476
+ <text fg={colors.textDim}>Type: </text>
477
+ <text fg={colors.text}>{ticker.metadata.assetCategory}</text>
478
+ </box>
479
+ )}
480
+ {sector && (
481
+ <box flexDirection="row">
482
+ <text fg={colors.textDim}>Sector: </text>
483
+ <text fg={colors.text}>{sector}</text>
484
+ </box>
485
+ )}
486
+ {industry && (
487
+ <box flexDirection="row">
488
+ <text fg={colors.textDim}>Industry: </text>
489
+ <text fg={colors.text}>{industry}</text>
490
+ </box>
491
+ )}
492
+ </box>
493
+ )}
494
+
495
+ {/* ISIN */}
496
+ {ticker.metadata.isin && (
497
+ <box flexDirection="row" height={1}>
498
+ <text fg={colors.textDim}>ISIN: </text>
499
+ <text fg={colors.text}>{ticker.metadata.isin}</text>
500
+ </box>
501
+ )}
502
+
503
+ {/* Description — last, collapsed */}
504
+ {description && (
505
+ <box flexDirection="column">
506
+ <SectionHeader title="Description" />
507
+ <text fg={colors.text}>{description}</text>
508
+ </box>
509
+ )}
274
510
  </box>
275
511
  </scrollbox>
276
512
  );
@@ -5,7 +5,8 @@ import { quoteSubscriptionTargetFromTicker } from "../../../market-data/request-
5
5
  import { usePaneTicker } from "../../../state/app-context";
6
6
  import { useQuoteStreaming } from "../../../state/use-quote-streaming";
7
7
  import { colors, priceColor } from "../../../theme/colors";
8
- import { formatCurrency, formatPercentRaw } from "../../../utils/format";
8
+ import { formatPercentRaw } from "../../../utils/format";
9
+ import { formatMarketPriceWithCurrency, formatSignedMarketPrice } from "../../../utils/market-format";
9
10
  import { getActiveQuoteDisplay } from "../../../utils/market-status";
10
11
  import { EmptyState } from "../../../components";
11
12
 
@@ -65,14 +66,11 @@ export function QuoteMonitorPane({ focused, width }: PaneProps) {
65
66
 
66
67
  <box flexDirection="column" alignItems={compact ? "flex-start" : "flex-end"}>
67
68
  <text attributes={TextAttributes.BOLD} fg={changeColor}>
68
- {formatCurrency(display.price, currency)}
69
+ {formatMarketPriceWithCurrency(display.price, currency, { assetCategory: ticker.metadata.assetCategory })}
69
70
  </text>
70
71
  <box flexDirection="row" gap={1}>
71
72
  <text fg={changeColor}>{formatPercentRaw(display.changePercent)}</text>
72
- <text fg={changeColor}>
73
- {display.change > 0 ? "+" : ""}
74
- {display.change.toFixed(2)}
75
- </text>
73
+ <text fg={changeColor}>{formatSignedMarketPrice(display.change, { assetCategory: ticker.metadata.assetCategory })}</text>
76
74
  </box>
77
75
  </box>
78
76
  </box>
@@ -1,6 +1,7 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
2
  import { createTestRenderer } from "@opentui/core/testing";
3
3
  import { createRoot } from "@opentui/react";
4
+ import { DialogProvider } from "@opentui-ui/dialog/react";
4
5
  import { act, useReducer, type ReactElement } from "react";
5
6
  import {
6
7
  AppContext,
@@ -97,17 +98,19 @@ function DetailHarness({
97
98
  harnessDispatch = dispatch;
98
99
 
99
100
  return (
100
- <AppContext value={{ state, dispatch }}>
101
- <PaneInstanceProvider paneId={TEST_PANE_ID}>
102
- <DetailPane
103
- paneId={TEST_PANE_ID}
104
- paneType="ticker-detail"
105
- focused
106
- width={90}
107
- height={28}
108
- />
109
- </PaneInstanceProvider>
110
- </AppContext>
101
+ <DialogProvider dialogOptions={{ style: { backgroundColor: "#000000", borderColor: "#ffffff", borderStyle: "single" } }}>
102
+ <AppContext value={{ state, dispatch }}>
103
+ <PaneInstanceProvider paneId={TEST_PANE_ID}>
104
+ <DetailPane
105
+ paneId={TEST_PANE_ID}
106
+ paneType="ticker-detail"
107
+ focused
108
+ width={90}
109
+ height={28}
110
+ />
111
+ </PaneInstanceProvider>
112
+ </AppContext>
113
+ </DialogProvider>
111
114
  );
112
115
  }
113
116
 
@@ -174,7 +177,9 @@ describe("Ticker detail chart tab switching", () => {
174
177
  });
175
178
 
176
179
  await flushFrames();
177
- expect(testSetup.captureCharFrame()).toContain("AAPL");
180
+ const chartTabFrame = testSetup.captureCharFrame();
181
+ expect(chartTabFrame).toContain("AAPL");
182
+ expect(chartTabFrame).toContain("[i]ndicators");
178
183
  expect(manager.surfaces.has("chart-surface:ticker-detail:test:full:base")).toBe(true);
179
184
 
180
185
  act(() => {
@@ -586,12 +586,12 @@ describe("TickerDetailPane", () => {
586
586
  await flushFrame();
587
587
 
588
588
  const frame = testSetup.captureCharFrame();
589
- expect(frame).toContain("€125.00");
589
+ expect(frame).toContain("€125");
590
590
  expect(frame).toContain("2.2B USD");
591
- expect(frame).toContain("@ €100.00");
591
+ expect(frame).toContain("@ €100");
592
592
  expect(frame).toContain("= $1,100.00");
593
593
  expect(frame).toContain("P&L: +$275.00");
594
- expect(frame).toContain("Mark: €125.00");
594
+ expect(frame).toContain("Mark: €125");
595
595
  expect(frame).toContain("Mkt Value: $1,375.00");
596
596
  });
597
597