gloomberb 0.4.1 → 0.5.0

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 (207) 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/header.tsx +2 -1
  48. package/src/components/layout/shell.test.tsx +186 -27
  49. package/src/components/layout/shell.tsx +5 -4
  50. package/src/components/layout/status-bar.test.tsx +16 -7
  51. package/src/components/layout/status-bar.tsx +4 -1
  52. package/src/components/markdown-editor.tsx +1 -0
  53. package/src/components/pane-settings-dialog.tsx +13 -152
  54. package/src/components/pane-template-wizard.tsx +1 -0
  55. package/src/components/price-selector-dialog.tsx +12 -7
  56. package/src/components/ticker-badge-text.test.tsx +50 -0
  57. package/src/components/ticker-badge-text.tsx +17 -2
  58. package/src/components/ticker-badge.tsx +2 -2
  59. package/src/components/ticker-list-table.tsx +54 -1
  60. package/src/components/toggle-list.tsx +15 -2
  61. package/src/components/ui/data-table.tsx +60 -3
  62. package/src/components/ui/external-link.tsx +48 -11
  63. package/src/components/ui/fields.tsx +2 -1
  64. package/src/components/ui/index.ts +8 -1
  65. package/src/components/ui/multi-select-chips.tsx +69 -0
  66. package/src/components/ui/multi-select-dialog.tsx +267 -0
  67. package/src/components/ui/multi-select.ts +78 -0
  68. package/src/components/ui/ui.test.tsx +343 -1
  69. package/src/data/plugin-state-store.ts +30 -19
  70. package/src/data/resource-store.ts +133 -103
  71. package/src/data/session-store.ts +19 -12
  72. package/src/data/sqlite-cache.test.ts +37 -2
  73. package/src/data/sqlite-database.ts +8 -2
  74. package/src/data/sqlite-retry.test.ts +63 -0
  75. package/src/data/sqlite-retry.ts +66 -0
  76. package/src/data/ticker-store.ts +29 -18
  77. package/src/index.tsx +6 -1
  78. package/src/market-data/coordinator.test.ts +114 -5
  79. package/src/market-data/coordinator.ts +70 -1
  80. package/src/market-data/hooks.test.tsx +73 -2
  81. package/src/market-data/hooks.tsx +16 -4
  82. package/src/notifications/app-notifier.test.ts +177 -0
  83. package/src/notifications/app-notifier.ts +241 -0
  84. package/src/pane-settings.test.ts +2 -0
  85. package/src/plugins/builtin/ai/index.test.tsx +1 -1
  86. package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
  87. package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
  88. package/src/plugins/builtin/ai/ticker-context.ts +3 -2
  89. package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
  90. package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
  91. package/src/plugins/builtin/alerts/index.test.tsx +357 -0
  92. package/src/plugins/builtin/alerts/index.tsx +519 -0
  93. package/src/plugins/builtin/alerts/types.ts +14 -0
  94. package/src/plugins/builtin/analytics/index.test.tsx +300 -0
  95. package/src/plugins/builtin/analytics/index.tsx +775 -0
  96. package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
  97. package/src/plugins/builtin/analytics/metrics.ts +155 -0
  98. package/src/plugins/builtin/ask-ai.test.tsx +2 -0
  99. package/src/plugins/builtin/chat-controller.test.ts +62 -18
  100. package/src/plugins/builtin/chat-controller.ts +23 -12
  101. package/src/plugins/builtin/chat.test.tsx +715 -4
  102. package/src/plugins/builtin/chat.tsx +415 -119
  103. package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
  104. package/src/plugins/builtin/comparison-chart.tsx +1 -2
  105. package/src/plugins/builtin/correlation/compute.test.ts +147 -0
  106. package/src/plugins/builtin/correlation/compute.ts +115 -0
  107. package/src/plugins/builtin/correlation/index.tsx +385 -0
  108. package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
  109. package/src/plugins/builtin/correlation/settings.test.ts +34 -0
  110. package/src/plugins/builtin/correlation/settings.ts +95 -0
  111. package/src/plugins/builtin/debug.tsx +36 -50
  112. package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
  113. package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
  114. package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
  115. package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
  116. package/src/plugins/builtin/earnings/index.tsx +337 -0
  117. package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
  118. package/src/plugins/builtin/econ/calendar-source.ts +121 -0
  119. package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
  120. package/src/plugins/builtin/econ/fred-client.ts +79 -0
  121. package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
  122. package/src/plugins/builtin/econ/index.tsx +999 -0
  123. package/src/plugins/builtin/econ/types.ts +13 -0
  124. package/src/plugins/builtin/fred-settings.ts +10 -0
  125. package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
  126. package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
  127. package/src/plugins/builtin/help.test.tsx +1 -5
  128. package/src/plugins/builtin/help.tsx +1 -16
  129. package/src/plugins/builtin/insider/index.tsx +417 -0
  130. package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
  131. package/src/plugins/builtin/insider/insider-data.ts +49 -0
  132. package/src/plugins/builtin/layout-manager.tsx +24 -16
  133. package/src/plugins/builtin/market-movers/index.tsx +573 -0
  134. package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
  135. package/src/plugins/builtin/market-movers/screener.ts +105 -0
  136. package/src/plugins/builtin/options.tsx +4 -3
  137. package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
  138. package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
  139. package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
  140. package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
  141. package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
  142. package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
  143. package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
  144. package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
  145. package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
  146. package/src/plugins/builtin/sec.tsx +114 -7
  147. package/src/plugins/builtin/sectors/index.tsx +359 -0
  148. package/src/plugins/builtin/sectors/sector-data.ts +18 -0
  149. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
  150. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
  151. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
  152. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
  153. package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
  154. package/src/plugins/builtin/world-indices/index.tsx +410 -0
  155. package/src/plugins/builtin/world-indices/indices.ts +52 -0
  156. package/src/plugins/builtin/yield-curve/index.tsx +242 -0
  157. package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
  158. package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
  159. package/src/plugins/catalog.ts +22 -0
  160. package/src/plugins/gridlock-notification.ts +15 -0
  161. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
  162. package/src/plugins/ibkr/index.test.tsx +1 -1
  163. package/src/plugins/ibkr/index.tsx +1 -1
  164. package/src/plugins/ibkr/trade-tab.tsx +23 -5
  165. package/src/plugins/ibkr/trade-utils.test.ts +1 -1
  166. package/src/plugins/ibkr/trade-utils.ts +7 -6
  167. package/src/plugins/ibkr/trading-pane.tsx +8 -7
  168. package/src/plugins/plugin-runtime.test.tsx +2 -0
  169. package/src/plugins/plugin-runtime.tsx +10 -0
  170. package/src/plugins/prediction-markets/controller-data.ts +8 -0
  171. package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
  172. package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
  173. package/src/plugins/prediction-markets/services/fetch.ts +13 -7
  174. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  175. package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
  176. package/src/plugins/prediction-markets/test-helpers.tsx +2 -0
  177. package/src/plugins/registry.ts +22 -13
  178. package/src/sources/provider-router.test.ts +3 -2
  179. package/src/sources/sec-edgar.ts +13 -1
  180. package/src/sources/yahoo-finance.ts +75 -1
  181. package/src/state/app-context-selector.test.tsx +17 -1
  182. package/src/state/app-context.test.tsx +24 -0
  183. package/src/state/app-context.tsx +29 -6
  184. package/src/theme/colors.ts +5 -0
  185. package/src/theme/themes.ts +25 -0
  186. package/src/types/config.test.ts +107 -0
  187. package/src/types/config.ts +30 -1
  188. package/src/types/data-provider.ts +13 -0
  189. package/src/types/plugin.ts +35 -1
  190. package/src/utils/format.test.ts +11 -1
  191. package/src/utils/format.ts +86 -8
  192. package/src/utils/inline-content-tokenizer.test.ts +22 -0
  193. package/src/utils/inline-content-tokenizer.ts +76 -0
  194. package/src/utils/link-tokenizer.test.ts +27 -0
  195. package/src/utils/link-tokenizer.ts +93 -0
  196. package/src/utils/market-format.test.ts +109 -0
  197. package/src/utils/market-format.ts +299 -0
  198. package/src/utils/market-status.ts +3 -2
  199. package/src/utils/selection-clipboard.test.ts +43 -0
  200. package/src/utils/selection-clipboard.ts +88 -0
  201. package/src/utils/terminal-input-reset.test.ts +50 -0
  202. package/src/utils/terminal-input-reset.ts +24 -0
  203. package/src/utils/throttled-fetch.test.ts +145 -0
  204. package/src/utils/throttled-fetch.ts +212 -0
  205. package/src/utils/ticker-tokenizer.test.ts +31 -0
  206. package/src/utils/ticker-tokenizer.ts +7 -42
  207. package/src/version.ts +1 -1
@@ -0,0 +1,93 @@
1
+ export interface InlineLinkTextToken {
2
+ kind: "text";
3
+ value: string;
4
+ }
5
+
6
+ export interface InlineLinkUrlToken {
7
+ kind: "link";
8
+ value: string;
9
+ url: string;
10
+ }
11
+
12
+ export type InlineLinkToken = InlineLinkTextToken | InlineLinkUrlToken;
13
+
14
+ const LINK_TOKEN_RE = /\b(?:https?:\/\/|www\.)[^\s<>"']+/gi;
15
+ const ALWAYS_TRIM_RE = /[.,!?;:]/;
16
+ const BRACKET_PAIRS: Record<string, string> = {
17
+ ")": "(",
18
+ "]": "[",
19
+ "}": "{",
20
+ };
21
+
22
+ function countChar(value: string, char: string): number {
23
+ let total = 0;
24
+ for (const current of value) {
25
+ if (current === char) total += 1;
26
+ }
27
+ return total;
28
+ }
29
+
30
+ function trimTrailingLinkPunctuation(rawValue: string): { value: string; trailing: string } {
31
+ let value = rawValue;
32
+
33
+ while (value.length > 0) {
34
+ const lastChar = value[value.length - 1]!;
35
+ if (ALWAYS_TRIM_RE.test(lastChar)) {
36
+ value = value.slice(0, -1);
37
+ continue;
38
+ }
39
+
40
+ const openingChar = BRACKET_PAIRS[lastChar];
41
+ if (!openingChar) break;
42
+
43
+ if (countChar(value, lastChar) > countChar(value, openingChar)) {
44
+ value = value.slice(0, -1);
45
+ continue;
46
+ }
47
+
48
+ break;
49
+ }
50
+
51
+ return { value, trailing: rawValue.slice(value.length) };
52
+ }
53
+
54
+ function normalizeLinkUrl(value: string): string {
55
+ return /^https?:\/\//i.test(value) ? value : `https://${value}`;
56
+ }
57
+
58
+ export function tokenizeInlineLinks(text: string): InlineLinkToken[] {
59
+ const tokens: InlineLinkToken[] = [];
60
+ let cursor = 0;
61
+ let match: RegExpExecArray | null;
62
+
63
+ LINK_TOKEN_RE.lastIndex = 0;
64
+ while ((match = LINK_TOKEN_RE.exec(text)) !== null) {
65
+ const rawValue = match[0];
66
+ const start = match.index;
67
+ const end = start + rawValue.length;
68
+
69
+ if (start > cursor) {
70
+ tokens.push({ kind: "text", value: text.slice(cursor, start) });
71
+ }
72
+
73
+ const trimmed = trimTrailingLinkPunctuation(rawValue);
74
+ if (trimmed.value) {
75
+ tokens.push({
76
+ kind: "link",
77
+ value: trimmed.value,
78
+ url: normalizeLinkUrl(trimmed.value),
79
+ });
80
+ }
81
+ if (trimmed.trailing) {
82
+ tokens.push({ kind: "text", value: trimmed.trailing });
83
+ }
84
+
85
+ cursor = end;
86
+ }
87
+
88
+ if (cursor < text.length) {
89
+ tokens.push({ kind: "text", value: text.slice(cursor) });
90
+ }
91
+
92
+ return tokens;
93
+ }
@@ -0,0 +1,109 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ formatMarketCost,
4
+ formatMarketCostWithCurrency,
5
+ formatCompactMarketPriceWithCurrency,
6
+ formatMarketPrice,
7
+ formatMarketPriceWithCurrency,
8
+ formatMarketQuantity,
9
+ formatSignedMarketPrice,
10
+ resolveAssetDisplayKind,
11
+ } from "./market-format";
12
+
13
+ describe("resolveAssetDisplayKind", () => {
14
+ test("prefers explicit cash balances", () => {
15
+ expect(resolveAssetDisplayKind({ isCashBalance: true, assetCategory: "STK" })).toBe("cash");
16
+ });
17
+
18
+ test("maps common broker security types", () => {
19
+ expect(resolveAssetDisplayKind({ assetCategory: "CRYPTO" })).toBe("crypto");
20
+ expect(resolveAssetDisplayKind({ assetCategory: "STK" })).toBe("equity");
21
+ expect(resolveAssetDisplayKind({ contractSecType: "OPT" })).toBe("contract");
22
+ expect(resolveAssetDisplayKind({ assetCategory: "FOREX" })).toBe("cash");
23
+ expect(resolveAssetDisplayKind({ assetCategory: "CURRENCY" })).toBe("cash");
24
+ expect(resolveAssetDisplayKind({ assetCategory: "CCY" })).toBe("cash");
25
+ });
26
+
27
+ test("falls back to contract formatting when only a multiplier is present", () => {
28
+ expect(resolveAssetDisplayKind({ multiplier: 100 })).toBe("contract");
29
+ });
30
+ });
31
+
32
+ describe("formatMarketQuantity", () => {
33
+ test("preserves additional FX precision while trimming trailing zeroes", () => {
34
+ expect(formatMarketQuantity(-351957.025, { isCashBalance: true })).toBe("-351,957.025");
35
+ expect(formatMarketQuantity(-303029.144938754, { isCashBalance: true })).toBe("-303,029.144939");
36
+ });
37
+
38
+ test("shows whole equities without decimals and fractional equities with up to four decimals", () => {
39
+ expect(formatMarketQuantity(10, { assetCategory: "STK" })).toBe("10");
40
+ expect(formatMarketQuantity(10.125, { assetCategory: "STK" })).toBe("10.125");
41
+ expect(formatMarketQuantity(10.123456, { assetCategory: "STK" })).toBe("10.1235");
42
+ });
43
+
44
+ test("uses higher precision for crypto quantities", () => {
45
+ expect(formatMarketQuantity(0.123456789, { assetCategory: "CRYPTO" })).toBe("0.12345679");
46
+ });
47
+ });
48
+
49
+ describe("formatMarketPrice", () => {
50
+ test("uses semantic precision for equities, cash, and crypto", () => {
51
+ expect(formatMarketPrice(190.2, { assetCategory: "STK" })).toBe("190.2");
52
+ expect(formatMarketPrice(259.7499, { assetCategory: "STK" })).toBe("259.75");
53
+ expect(formatMarketPrice(1.084567, { isCashBalance: true })).toBe("1.084567");
54
+ expect(formatMarketPrice(1.17364, { assetCategory: "CURRENCY" })).toBe("1.17364");
55
+ expect(formatMarketPrice(0.000123456789, { assetCategory: "CRYPTO" })).toBe("0.00012346");
56
+ });
57
+
58
+ test("can adapt chart precision to the visible price range", () => {
59
+ expect(formatMarketPrice(1.167815, { assetCategory: "CURRENCY", priceRange: 0.12 })).toBe("1.17");
60
+ expect(formatMarketPrice(1.167815, { assetCategory: "CURRENCY", priceRange: 0.0024 })).toBe("1.1678");
61
+ expect(formatMarketPrice(259.7499, {
62
+ assetCategory: "STK",
63
+ minimumFractionDigits: 2,
64
+ precisionOffset: 1,
65
+ priceRange: 80,
66
+ })).toBe("259.75");
67
+ });
68
+
69
+ test("can lock price displays to a specific number of decimals", () => {
70
+ expect(formatMarketPrice(18, { assetCategory: "STK", fixedFractionDigits: 1 })).toBe("18.0");
71
+ expect(formatMarketPriceWithCurrency(18, "USD", { assetCategory: "STK", fixedFractionDigits: 2 })).toBe("$18.00");
72
+ });
73
+
74
+ test("fits within the supplied width before falling back to truncation elsewhere", () => {
75
+ expect(formatMarketPrice(1.084567, { isCashBalance: true, maxWidth: 6 })).toBe("1.0846");
76
+ expect(formatMarketQuantity(123456.789, { assetCategory: "CRYPTO", maxWidth: 7 })).toBe("123,457");
77
+ });
78
+
79
+ test("formats signed price changes without double signs", () => {
80
+ expect(formatSignedMarketPrice(0.123456, { isCashBalance: true })).toBe("+0.123456");
81
+ expect(formatSignedMarketPrice(-0.123456, { isCashBalance: true })).toBe("-0.123456");
82
+ });
83
+ });
84
+
85
+ describe("formatMarketCost", () => {
86
+ test("keeps equity cost displays conservative while preserving FX and crypto precision", () => {
87
+ expect(formatMarketCost(119.3687, { assetCategory: "STK" })).toBe("119.37");
88
+ expect(formatMarketCost(1.084567, { isCashBalance: true })).toBe("1.084567");
89
+ expect(formatMarketCost(113905.720075, { assetCategory: "CRYPTO" })).toBe("113,905.720075");
90
+ });
91
+ });
92
+
93
+ describe("formatMarketPriceWithCurrency", () => {
94
+ test("renders price-like values with a symbol and variable decimals", () => {
95
+ expect(formatMarketPriceWithCurrency(190.25, "USD", { assetCategory: "STK" })).toBe("$190.25");
96
+ expect(formatMarketPriceWithCurrency(1.084567, "EUR", { isCashBalance: true })).toBe("€1.084567");
97
+ expect(formatMarketPriceWithCurrency(1.17364, "USD", { assetCategory: "CURRENCY" })).toBe("$1.17364");
98
+ });
99
+
100
+ test("preserves chart-style compact notation for large values", () => {
101
+ expect(formatCompactMarketPriceWithCurrency(21_970, "JPY")).toBe("¥22.0K");
102
+ expect(formatCompactMarketPriceWithCurrency(12_340, "HKD")).toBe("HK$12.3K");
103
+ });
104
+
105
+ test("formats position cost values with tighter equity precision", () => {
106
+ expect(formatMarketCostWithCurrency(119.3687, "HKD", { assetCategory: "STK" })).toBe("HK$119.37");
107
+ expect(formatMarketCostWithCurrency(50.9507, "USD", { assetCategory: "OPT", multiplier: 100 })).toBe("$50.9507");
108
+ });
109
+ });
@@ -0,0 +1,299 @@
1
+ import { formatCurrency } from "./format";
2
+
3
+ export type AssetDisplayKind = "cash" | "crypto" | "equity" | "contract" | "other";
4
+
5
+ export interface AssetDisplayContext {
6
+ isCashBalance?: boolean;
7
+ assetCategory?: string;
8
+ contractSecType?: string;
9
+ multiplier?: number;
10
+ }
11
+
12
+ export interface MarketFormatOptions extends AssetDisplayContext {
13
+ maxWidth?: number;
14
+ minimumFractionDigits?: number;
15
+ precisionOffset?: number;
16
+ priceRange?: number;
17
+ fixedFractionDigits?: number;
18
+ }
19
+
20
+ const CASH_TYPES = new Set(["CASH", "FX", "FOREX", "CCY", "CURRENCY", "CURRENCYPAIR"]);
21
+ const CRYPTO_TYPES = new Set(["CRYPTO", "CRYPTOCURRENCY", "COIN", "TOKEN"]);
22
+ const EQUITY_TYPES = new Set(["STK", "STOCK", "EQUITY", "ETF", "ETN", "ETP", "FUND", "MUTUALFUND", "CEF", "ADR"]);
23
+ const CONTRACT_TYPES = new Set(["OPT", "OPTION", "OPTIONS", "FUT", "FUTURE", "FUTURES", "FOP"]);
24
+
25
+ const currencySymbols = new Map<string, string>();
26
+ const numberFormatters = new Map<string, Intl.NumberFormat>();
27
+
28
+ function normalizeType(value?: string): string {
29
+ return (value ?? "").trim().toUpperCase().replace(/[\s_-]+/g, "");
30
+ }
31
+
32
+ function getNumberFormatter(
33
+ minimumFractionDigits: number,
34
+ maximumFractionDigits: number,
35
+ useGrouping: boolean,
36
+ ): Intl.NumberFormat {
37
+ const key = `${minimumFractionDigits}:${maximumFractionDigits}:${useGrouping ? 1 : 0}`;
38
+ let formatter = numberFormatters.get(key);
39
+ if (!formatter) {
40
+ formatter = new Intl.NumberFormat("en-US", {
41
+ minimumFractionDigits,
42
+ maximumFractionDigits,
43
+ useGrouping,
44
+ });
45
+ numberFormatters.set(key, formatter);
46
+ }
47
+ return formatter;
48
+ }
49
+
50
+ function getCurrencySymbol(currency: string): string {
51
+ const normalizedCurrency = currency.trim().toUpperCase() || "USD";
52
+ const cached = currencySymbols.get(normalizedCurrency);
53
+ if (cached) return cached;
54
+
55
+ const formatter = new Intl.NumberFormat("en-US", {
56
+ style: "currency",
57
+ currency: normalizedCurrency,
58
+ currencyDisplay: "symbol",
59
+ minimumFractionDigits: 0,
60
+ maximumFractionDigits: 0,
61
+ });
62
+ const symbol = formatter.formatToParts(0).find((part) => part.type === "currency")?.value ?? normalizedCurrency;
63
+ currencySymbols.set(normalizedCurrency, symbol);
64
+ return symbol;
65
+ }
66
+
67
+ function fitsWidth(text: string, maxWidth: number | undefined): boolean {
68
+ return maxWidth == null || text.length <= maxWidth;
69
+ }
70
+
71
+ function isEffectivelyInteger(value: number): boolean {
72
+ return Math.abs(value - Math.round(value)) < 1e-9;
73
+ }
74
+
75
+ function formatVariableNumber(
76
+ value: number,
77
+ maxFractionDigits: number,
78
+ maxWidth: number | undefined,
79
+ minimumFractionDigits = 0,
80
+ ): string {
81
+ const groupedModes = [true, false];
82
+
83
+ for (const useGrouping of groupedModes) {
84
+ for (let decimals = maxFractionDigits; decimals >= minimumFractionDigits; decimals -= 1) {
85
+ const formatter = getNumberFormatter(Math.min(minimumFractionDigits, decimals), decimals, useGrouping);
86
+ const formatted = formatter.format(value);
87
+ if (fitsWidth(formatted, maxWidth)) return formatted;
88
+ }
89
+ }
90
+
91
+ return getNumberFormatter(0, 0, false).format(value);
92
+ }
93
+
94
+ function getQuantityMaxFractionDigits(kind: AssetDisplayKind, value: number): number {
95
+ if (isEffectivelyInteger(value)) return 0;
96
+
97
+ switch (kind) {
98
+ case "cash":
99
+ return 6;
100
+ case "crypto":
101
+ return 8;
102
+ case "equity":
103
+ return 4;
104
+ case "contract":
105
+ return 4;
106
+ case "other":
107
+ default:
108
+ return 4;
109
+ }
110
+ }
111
+
112
+ function getBasePriceMaxFractionDigits(kind: AssetDisplayKind, value: number): number {
113
+ switch (kind) {
114
+ case "cash":
115
+ return 6;
116
+ case "crypto":
117
+ return 8;
118
+ case "equity":
119
+ return Math.abs(value) >= 1 ? 2 : 4;
120
+ case "contract":
121
+ return 4;
122
+ case "other":
123
+ default:
124
+ return Math.abs(value) >= 1 ? 2 : 4;
125
+ }
126
+ }
127
+
128
+ function getAdaptivePriceFractionDigits(priceRange: number | undefined, precisionOffset = 0): number | null {
129
+ if (priceRange === undefined || !Number.isFinite(priceRange) || priceRange <= 0) return null;
130
+
131
+ // The chart price axis renders four labels by default, so the visible range
132
+ // divided across three intervals is a good proxy for the current zoom step.
133
+ const visibleStep = priceRange / 3;
134
+ if (!Number.isFinite(visibleStep) || visibleStep <= 0) return null;
135
+
136
+ return Math.max(0, Math.ceil(-Math.log10(visibleStep)) + precisionOffset);
137
+ }
138
+
139
+ function getPriceMaxFractionDigits(
140
+ kind: AssetDisplayKind,
141
+ value: number,
142
+ priceRange: number | undefined,
143
+ precisionOffset: number,
144
+ ): number {
145
+ const baseMaxFractionDigits = kind === "other" && priceRange !== undefined
146
+ ? 6
147
+ : getBasePriceMaxFractionDigits(kind, value);
148
+ const adaptiveFractionDigits = getAdaptivePriceFractionDigits(priceRange, precisionOffset);
149
+ return adaptiveFractionDigits === null
150
+ ? baseMaxFractionDigits
151
+ : Math.min(baseMaxFractionDigits, adaptiveFractionDigits);
152
+ }
153
+
154
+ function getCostMaxFractionDigits(kind: AssetDisplayKind): number {
155
+ switch (kind) {
156
+ case "cash":
157
+ return 6;
158
+ case "crypto":
159
+ return 8;
160
+ case "contract":
161
+ return 4;
162
+ case "equity":
163
+ return 2;
164
+ case "other":
165
+ default:
166
+ return 2;
167
+ }
168
+ }
169
+
170
+ function compactScaledPrice(
171
+ value: number,
172
+ divisor: number,
173
+ suffix: string,
174
+ currency: string,
175
+ maxWidth: number | undefined,
176
+ ): string {
177
+ const sign = value < 0 ? "-" : "";
178
+ const symbol = getCurrencySymbol(currency);
179
+ const numericWidth = maxWidth == null
180
+ ? undefined
181
+ : Math.max(1, maxWidth - sign.length - symbol.length - suffix.length);
182
+ const formatted = formatVariableNumber(Math.abs(value) / divisor, 1, numericWidth, 1);
183
+ return `${sign}${symbol}${formatted}${suffix}`;
184
+ }
185
+
186
+ export function resolveAssetDisplayKind({
187
+ isCashBalance,
188
+ assetCategory,
189
+ contractSecType,
190
+ multiplier,
191
+ }: AssetDisplayContext): AssetDisplayKind {
192
+ if (isCashBalance) return "cash";
193
+
194
+ const normalizedType = normalizeType(contractSecType || assetCategory);
195
+ if (CASH_TYPES.has(normalizedType) || normalizedType.includes("FOREX") || normalizedType.includes("CURRENCY")) return "cash";
196
+ if (CRYPTO_TYPES.has(normalizedType) || normalizedType.includes("CRYPTO")) return "crypto";
197
+ if (EQUITY_TYPES.has(normalizedType)) return "equity";
198
+ if (CONTRACT_TYPES.has(normalizedType)) return "contract";
199
+ if ((multiplier ?? 1) > 1) return "contract";
200
+ return "other";
201
+ }
202
+
203
+ export function formatMarketQuantity(value: number | undefined, options: MarketFormatOptions = {}): string {
204
+ if (value === undefined || value === null || Number.isNaN(value)) return "—";
205
+ const kind = resolveAssetDisplayKind(options);
206
+ const maxFractionDigits = getQuantityMaxFractionDigits(kind, value);
207
+ return formatVariableNumber(value, maxFractionDigits, options.maxWidth);
208
+ }
209
+
210
+ export function formatMarketPrice(value: number | undefined, options: MarketFormatOptions = {}): string {
211
+ if (value === undefined || value === null || Number.isNaN(value)) return "—";
212
+ const kind = resolveAssetDisplayKind(options);
213
+ const fixedFractionDigits = options.fixedFractionDigits;
214
+ if (fixedFractionDigits !== undefined) {
215
+ const maxFractionDigits = kind === "other" && options.priceRange !== undefined
216
+ ? 6
217
+ : getBasePriceMaxFractionDigits(kind, value);
218
+ const clampedFixedFractionDigits = Math.max(0, Math.min(fixedFractionDigits, maxFractionDigits));
219
+ return formatVariableNumber(value, clampedFixedFractionDigits, options.maxWidth, clampedFixedFractionDigits);
220
+ }
221
+ const minimumFractionDigits = Math.max(
222
+ 0,
223
+ Math.min(options.minimumFractionDigits ?? 0, getBasePriceMaxFractionDigits(kind, value)),
224
+ );
225
+ const maxFractionDigits = Math.max(
226
+ getPriceMaxFractionDigits(kind, value, options.priceRange, options.precisionOffset ?? 0),
227
+ minimumFractionDigits,
228
+ );
229
+ return formatVariableNumber(value, maxFractionDigits, options.maxWidth, minimumFractionDigits);
230
+ }
231
+
232
+ export function formatMarketCost(value: number | undefined, options: MarketFormatOptions = {}): string {
233
+ if (value === undefined || value === null || Number.isNaN(value)) return "—";
234
+ const kind = resolveAssetDisplayKind(options);
235
+ return formatVariableNumber(value, getCostMaxFractionDigits(kind), options.maxWidth);
236
+ }
237
+
238
+ export function formatSignedMarketPrice(value: number | undefined, options: MarketFormatOptions = {}): string {
239
+ if (value === undefined || value === null || Number.isNaN(value)) return "—";
240
+ if (value > 0) {
241
+ const maxWidth = options.maxWidth == null ? undefined : Math.max(1, options.maxWidth - 1);
242
+ return `+${formatMarketPrice(value, { ...options, maxWidth })}`;
243
+ }
244
+ return formatMarketPrice(value, options);
245
+ }
246
+
247
+ export function formatMarketPriceWithCurrency(
248
+ value: number | undefined,
249
+ currency = "USD",
250
+ options: MarketFormatOptions = {},
251
+ ): string {
252
+ if (value === undefined || value === null || Number.isNaN(value)) return "—";
253
+ const normalizedCurrency = currency.trim().toUpperCase() || "USD";
254
+ const sign = value < 0 ? "-" : "";
255
+ const symbol = getCurrencySymbol(normalizedCurrency);
256
+ const numericWidth = options.maxWidth == null
257
+ ? undefined
258
+ : Math.max(1, options.maxWidth - sign.length - symbol.length);
259
+ const body = formatMarketPrice(Math.abs(value), { ...options, maxWidth: numericWidth });
260
+ return `${sign}${symbol}${body}`;
261
+ }
262
+
263
+ export function formatMarketCostWithCurrency(
264
+ value: number | undefined,
265
+ currency = "USD",
266
+ options: MarketFormatOptions = {},
267
+ ): string {
268
+ if (value === undefined || value === null || Number.isNaN(value)) return "—";
269
+ const normalizedCurrency = currency.trim().toUpperCase() || "USD";
270
+ const sign = value < 0 ? "-" : "";
271
+ const symbol = getCurrencySymbol(normalizedCurrency);
272
+ const numericWidth = options.maxWidth == null
273
+ ? undefined
274
+ : Math.max(1, options.maxWidth - sign.length - symbol.length);
275
+ const body = formatMarketCost(Math.abs(value), { ...options, maxWidth: numericWidth });
276
+ return `${sign}${symbol}${body}`;
277
+ }
278
+
279
+ export function formatCompactMarketPriceWithCurrency(
280
+ value: number | undefined,
281
+ currency = "USD",
282
+ options: MarketFormatOptions = {},
283
+ ): string {
284
+ if (value === undefined || value === null || Number.isNaN(value)) return "—";
285
+ const abs = Math.abs(value);
286
+
287
+ if (abs >= 1e6) {
288
+ return compactScaledPrice(value, 1e6, "M", currency, options.maxWidth);
289
+ }
290
+ if (abs >= 1e3 && abs < 1e5) {
291
+ return compactScaledPrice(value, 1e3, "K", currency, options.maxWidth);
292
+ }
293
+
294
+ return formatMarketPriceWithCurrency(value, currency, options);
295
+ }
296
+
297
+ export function formatMarketMoney(value: number | undefined, currency = "USD"): string {
298
+ return formatCurrency(value, currency);
299
+ }
@@ -1,6 +1,7 @@
1
1
  import type { MarketState, Quote, QuoteFieldProvenance } from "../types/financials";
2
2
  import { colors, priceColor } from "../theme/colors";
3
3
  import { formatPercentRaw } from "./format";
4
+ import { formatMarketPrice } from "./market-format";
4
5
 
5
6
  export interface ActiveQuoteDisplay {
6
7
  price: number;
@@ -107,11 +108,11 @@ export function getExtendedHoursInfo(quote: Quote | null | undefined): { text: s
107
108
  if (!quote) return null;
108
109
  if ((quote.marketState === "PRE" || quote.marketState === "PREPRE") && quote.preMarketPrice != null) {
109
110
  const chg = quote.preMarketChangePercent ?? 0;
110
- return { text: `Pre ${quote.preMarketPrice.toFixed(2)} ${formatPercentRaw(chg)}`, color: priceColor(chg) };
111
+ return { text: `Pre ${formatMarketPrice(quote.preMarketPrice)} ${formatPercentRaw(chg)}`, color: priceColor(chg) };
111
112
  }
112
113
  if ((quote.marketState === "POST" || quote.marketState === "POSTPOST") && quote.postMarketPrice != null) {
113
114
  const chg = quote.postMarketChangePercent ?? 0;
114
- return { text: `AH ${quote.postMarketPrice.toFixed(2)} ${formatPercentRaw(chg)}`, color: priceColor(chg) };
115
+ return { text: `AH ${formatMarketPrice(quote.postMarketPrice)} ${formatPercentRaw(chg)}`, color: priceColor(chg) };
115
116
  }
116
117
  return null;
117
118
  }
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { copyActiveSelection, isCopyShortcut, isPasteShortcut } from "./selection-clipboard";
3
+
4
+ describe("selection clipboard sync", () => {
5
+ test("copies the active selection when present", () => {
6
+ const renderer = {
7
+ copyToClipboardOSC52: (text: string) => text === "AAPL",
8
+ getSelection: () => ({
9
+ getSelectedText: () => "AAPL",
10
+ }),
11
+ };
12
+
13
+ expect(copyActiveSelection(renderer as any)).toBe(true);
14
+ });
15
+
16
+ test("ignores empty or missing selections", () => {
17
+ expect(copyActiveSelection({
18
+ copyToClipboardOSC52: () => true,
19
+ getSelection: () => null,
20
+ } as any)).toBe(false);
21
+
22
+ expect(copyActiveSelection({
23
+ copyToClipboardOSC52: () => true,
24
+ getSelection: () => ({
25
+ getSelectedText: () => "",
26
+ }),
27
+ } as any)).toBe(false);
28
+ });
29
+
30
+ test("matches explicit copy shortcuts only", () => {
31
+ expect(isCopyShortcut({ name: "c", super: true, meta: false, ctrl: false, shift: false } as any)).toBe(true);
32
+ expect(isCopyShortcut({ name: "c", super: false, meta: true, ctrl: false, shift: false } as any)).toBe(true);
33
+ expect(isCopyShortcut({ name: "c", super: false, meta: false, ctrl: true, shift: true } as any)).toBe(true);
34
+ expect(isCopyShortcut({ name: "c", super: false, ctrl: true, shift: false } as any)).toBe(false);
35
+ });
36
+
37
+ test("matches explicit paste shortcuts only", () => {
38
+ expect(isPasteShortcut({ name: "v", super: true, meta: false, ctrl: false, shift: false } as any)).toBe(true);
39
+ expect(isPasteShortcut({ name: "v", super: false, meta: true, ctrl: false, shift: false } as any)).toBe(true);
40
+ expect(isPasteShortcut({ name: "v", super: false, meta: false, ctrl: true, shift: true } as any)).toBe(true);
41
+ expect(isPasteShortcut({ name: "v", super: false, meta: false, ctrl: true, shift: false } as any)).toBe(false);
42
+ });
43
+ });
@@ -0,0 +1,88 @@
1
+ import type { CliRenderer, KeyEvent } from "@opentui/core";
2
+
3
+ function readTextFromSystemClipboard(): string | null {
4
+ if (process.platform !== "darwin") {
5
+ return null;
6
+ }
7
+
8
+ try {
9
+ const result = Bun.spawnSync(["pbpaste"], {
10
+ stdout: "pipe",
11
+ stderr: "ignore",
12
+ });
13
+ if (!result.success) {
14
+ return null;
15
+ }
16
+ return result.stdout.toString().replace(/\r\n/g, "\n");
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ function copyTextToSystemClipboard(text: string): boolean {
23
+ if (text.length === 0 || process.platform !== "darwin") {
24
+ return false;
25
+ }
26
+
27
+ try {
28
+ const result = Bun.spawnSync(["pbcopy"], {
29
+ stdin: new Blob([text]),
30
+ stdout: "ignore",
31
+ stderr: "ignore",
32
+ });
33
+ return result.success;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ export function copySelectionText(
40
+ renderer: Pick<CliRenderer, "copyToClipboardOSC52">,
41
+ text: string,
42
+ ): boolean {
43
+ if (text.length === 0) {
44
+ return false;
45
+ }
46
+
47
+ return renderer.copyToClipboardOSC52(text) || copyTextToSystemClipboard(text);
48
+ }
49
+
50
+ export function copyActiveSelection(
51
+ renderer: Pick<CliRenderer, "getSelection" | "copyToClipboardOSC52">,
52
+ ): boolean {
53
+ const text = renderer.getSelection()?.getSelectedText() ?? "";
54
+ return copySelectionText(renderer, text);
55
+ }
56
+
57
+ export function isCopyShortcut(
58
+ event: Pick<KeyEvent, "name" | "super" | "meta" | "ctrl" | "shift">,
59
+ ): boolean {
60
+ const keyName = event.name.toLowerCase();
61
+ return keyName === "c" && (
62
+ event.super === true
63
+ || event.meta === true
64
+ || (event.ctrl === true && event.shift === true)
65
+ );
66
+ }
67
+
68
+ export function isPasteShortcut(
69
+ event: Pick<KeyEvent, "name" | "super" | "meta" | "ctrl" | "shift">,
70
+ ): boolean {
71
+ const keyName = event.name.toLowerCase();
72
+ return keyName === "v" && (
73
+ event.super === true
74
+ || event.meta === true
75
+ || (event.ctrl === true && event.shift === true)
76
+ );
77
+ }
78
+
79
+ export function pasteSystemClipboard(
80
+ renderer: Pick<CliRenderer, "keyInput">,
81
+ ): boolean {
82
+ const text = readTextFromSystemClipboard();
83
+ if (!text) {
84
+ return false;
85
+ }
86
+ renderer.keyInput.processPaste(new TextEncoder().encode(text));
87
+ return true;
88
+ }