gloomberb 0.4.0 → 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 (218) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/app.tsx +184 -69
  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 +296 -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 -1
  48. package/src/components/layout/header.tsx +5 -4
  49. package/src/components/layout/pane-sizing.ts +6 -0
  50. package/src/components/layout/pane.tsx +5 -1
  51. package/src/components/layout/shell.test.tsx +263 -32
  52. package/src/components/layout/shell.tsx +10 -6
  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 +62 -2
  63. package/src/components/toggle-list.tsx +15 -2
  64. package/src/components/ui/data-table.tsx +82 -5
  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 +92 -18
  103. package/src/plugins/builtin/chat-controller.ts +40 -11
  104. package/src/plugins/builtin/chat.test.tsx +715 -4
  105. package/src/plugins/builtin/chat.tsx +420 -119
  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 +33 -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/gateway-service.test.ts +22 -0
  166. package/src/plugins/ibkr/gateway-service.ts +6 -0
  167. package/src/plugins/ibkr/index.test.tsx +1 -1
  168. package/src/plugins/ibkr/index.tsx +8 -2
  169. package/src/plugins/ibkr/trade-tab.tsx +23 -5
  170. package/src/plugins/ibkr/trade-utils.test.ts +1 -1
  171. package/src/plugins/ibkr/trade-utils.ts +7 -6
  172. package/src/plugins/ibkr/trading-pane.tsx +8 -7
  173. package/src/plugins/plugin-runtime.test.tsx +2 -0
  174. package/src/plugins/plugin-runtime.tsx +10 -0
  175. package/src/plugins/prediction-markets/controller-data.ts +129 -14
  176. package/src/plugins/prediction-markets/controller.ts +1 -1
  177. package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
  178. package/src/plugins/prediction-markets/index.tsx +5 -1
  179. package/src/plugins/prediction-markets/pane.test.tsx +78 -0
  180. package/src/plugins/prediction-markets/pane.tsx +7 -3
  181. package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
  182. package/src/plugins/prediction-markets/services/fetch.ts +17 -7
  183. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  184. package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
  185. package/src/plugins/prediction-markets/test-helpers.tsx +22 -5
  186. package/src/plugins/registry.ts +45 -13
  187. package/src/sources/provider-router.test.ts +3 -2
  188. package/src/sources/sec-edgar.ts +13 -1
  189. package/src/sources/yahoo-finance.ts +75 -1
  190. package/src/state/app-context-selector.test.tsx +17 -1
  191. package/src/state/app-context.test.tsx +24 -0
  192. package/src/state/app-context.tsx +29 -6
  193. package/src/theme/colors.ts +5 -0
  194. package/src/theme/themes.ts +25 -0
  195. package/src/types/config.test.ts +107 -0
  196. package/src/types/config.ts +30 -1
  197. package/src/types/data-provider.ts +13 -0
  198. package/src/types/plugin.ts +35 -1
  199. package/src/updater.test.ts +2 -0
  200. package/src/utils/api-client.ts +12 -0
  201. package/src/utils/format.test.ts +11 -1
  202. package/src/utils/format.ts +86 -8
  203. package/src/utils/inline-content-tokenizer.test.ts +22 -0
  204. package/src/utils/inline-content-tokenizer.ts +76 -0
  205. package/src/utils/link-tokenizer.test.ts +27 -0
  206. package/src/utils/link-tokenizer.ts +93 -0
  207. package/src/utils/market-format.test.ts +109 -0
  208. package/src/utils/market-format.ts +299 -0
  209. package/src/utils/market-status.ts +3 -2
  210. package/src/utils/selection-clipboard.test.ts +43 -0
  211. package/src/utils/selection-clipboard.ts +88 -0
  212. package/src/utils/terminal-input-reset.test.ts +50 -0
  213. package/src/utils/terminal-input-reset.ts +24 -0
  214. package/src/utils/throttled-fetch.test.ts +145 -0
  215. package/src/utils/throttled-fetch.ts +212 -0
  216. package/src/utils/ticker-tokenizer.test.ts +31 -0
  217. package/src/utils/ticker-tokenizer.ts +7 -42
  218. package/src/version.ts +1 -1
@@ -0,0 +1,143 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { parseScreenerResponse, parseTrendingResponse } from "./screener";
3
+
4
+ const SAMPLE_SCREENER_RESPONSE = {
5
+ finance: {
6
+ result: [
7
+ {
8
+ quotes: [
9
+ {
10
+ symbol: "AAPL",
11
+ shortName: "Apple Inc.",
12
+ regularMarketPrice: 185.5,
13
+ regularMarketChange: 3.25,
14
+ regularMarketChangePercent: 1.78,
15
+ regularMarketVolume: 52_000_000,
16
+ averageDailyVolume3Month: 20_000_000,
17
+ marketCap: 2_900_000_000_000,
18
+ currency: "USD",
19
+ fiftyTwoWeekHigh: 199.62,
20
+ fiftyTwoWeekLow: 140.0,
21
+ regularMarketDayHigh: 186.0,
22
+ regularMarketDayLow: 182.0,
23
+ fullExchangeName: "NasdaqGS",
24
+ },
25
+ {
26
+ symbol: "MSFT",
27
+ shortName: "Microsoft Corporation",
28
+ regularMarketPrice: 415.0,
29
+ regularMarketChange: -2.1,
30
+ regularMarketChangePercent: -0.5,
31
+ regularMarketVolume: 18_000_000,
32
+ averageDailyVolume3Month: 25_000_000,
33
+ marketCap: 3_100_000_000_000,
34
+ currency: "USD",
35
+ fullExchangeName: "NasdaqGS",
36
+ },
37
+ ],
38
+ },
39
+ ],
40
+ error: null,
41
+ },
42
+ };
43
+
44
+ const SAMPLE_TRENDING_RESPONSE = {
45
+ finance: {
46
+ result: [
47
+ {
48
+ quotes: [
49
+ { symbol: "NVDA" },
50
+ { symbol: "TSLA" },
51
+ ],
52
+ },
53
+ ],
54
+ error: null,
55
+ },
56
+ };
57
+
58
+ describe("parseScreenerResponse", () => {
59
+ test("maps Yahoo Finance fields to ScreenerQuote", () => {
60
+ const results = parseScreenerResponse(SAMPLE_SCREENER_RESPONSE);
61
+ expect(results).toHaveLength(2);
62
+
63
+ const apple = results[0]!;
64
+ expect(apple.symbol).toBe("AAPL");
65
+ expect(apple.name).toBe("Apple Inc.");
66
+ expect(apple.price).toBe(185.5);
67
+ expect(apple.change).toBe(3.25);
68
+ expect(apple.changePercent).toBe(1.78);
69
+ expect(apple.volume).toBe(52_000_000);
70
+ expect(apple.avgVolume).toBe(20_000_000);
71
+ expect(apple.volumeRatio).toBeCloseTo(2.6, 1);
72
+ expect(apple.marketCap).toBe(2_900_000_000_000);
73
+ expect(apple.currency).toBe("USD");
74
+ expect(apple.fiftyTwoWeekHigh).toBe(199.62);
75
+ expect(apple.fiftyTwoWeekLow).toBe(140.0);
76
+ expect(apple.exchange).toBe("NasdaqGS");
77
+
78
+ const msft = results[1]!;
79
+ expect(msft.symbol).toBe("MSFT");
80
+ expect(msft.change).toBe(-2.1);
81
+ expect(msft.changePercent).toBe(-0.5);
82
+ expect(msft.volumeRatio).toBeCloseTo(0.72, 1);
83
+ });
84
+
85
+ test("returns [] for missing finance.result", () => {
86
+ expect(parseScreenerResponse({})).toEqual([]);
87
+ expect(parseScreenerResponse(null)).toEqual([]);
88
+ expect(parseScreenerResponse({ finance: {} })).toEqual([]);
89
+ expect(parseScreenerResponse({ finance: { result: [] } })).toEqual([]);
90
+ });
91
+
92
+ test("returns [] for non-array quotes", () => {
93
+ expect(parseScreenerResponse({ finance: { result: [{ quotes: null }] } })).toEqual([]);
94
+ expect(parseScreenerResponse({ finance: { result: [{}] } })).toEqual([]);
95
+ });
96
+
97
+ test("skips entries without a string symbol", () => {
98
+ const data = {
99
+ finance: {
100
+ result: [{ quotes: [{ symbol: 123 }, { symbol: "AMD", regularMarketPrice: 100 }] }],
101
+ },
102
+ };
103
+ const results = parseScreenerResponse(data);
104
+ expect(results).toHaveLength(1);
105
+ expect(results[0]!.symbol).toBe("AMD");
106
+ });
107
+
108
+ test("falls back to symbol when shortName and longName are absent", () => {
109
+ const data = {
110
+ finance: {
111
+ result: [{ quotes: [{ symbol: "XYZ", regularMarketPrice: 10 }] }],
112
+ },
113
+ };
114
+ const results = parseScreenerResponse(data);
115
+ expect(results[0]!.name).toBe("XYZ");
116
+ });
117
+ });
118
+
119
+ describe("parseTrendingResponse", () => {
120
+ test("extracts symbols from trending response", () => {
121
+ const results = parseTrendingResponse(SAMPLE_TRENDING_RESPONSE);
122
+ expect(results).toHaveLength(2);
123
+ expect(results[0]!.symbol).toBe("NVDA");
124
+ expect(results[1]!.symbol).toBe("TSLA");
125
+ });
126
+
127
+ test("returns [] for malformed input", () => {
128
+ expect(parseTrendingResponse({})).toEqual([]);
129
+ expect(parseTrendingResponse(null)).toEqual([]);
130
+ expect(parseTrendingResponse({ finance: { result: [{ quotes: "bad" }] } })).toEqual([]);
131
+ });
132
+
133
+ test("skips entries without a string symbol", () => {
134
+ const data = {
135
+ finance: {
136
+ result: [{ quotes: [{ symbol: null }, { symbol: "SPY" }] }],
137
+ },
138
+ };
139
+ const results = parseTrendingResponse(data);
140
+ expect(results).toHaveLength(1);
141
+ expect(results[0]!.symbol).toBe("SPY");
142
+ });
143
+ });
@@ -0,0 +1,105 @@
1
+ import { createThrottledFetch } from "../../../utils/throttled-fetch";
2
+
3
+ const screenerClient = createThrottledFetch({
4
+ requestsPerMinute: 15,
5
+ maxRetries: 2,
6
+ timeoutMs: 10_000,
7
+ defaultHeaders: {
8
+ "User-Agent": "Gloomberb/0.4.1",
9
+ Accept: "application/json",
10
+ },
11
+ });
12
+
13
+ export type ScreenerCategory = "day_gainers" | "day_losers" | "most_actives";
14
+
15
+ export interface ScreenerQuote {
16
+ symbol: string;
17
+ name: string;
18
+ price: number;
19
+ change: number;
20
+ changePercent: number;
21
+ volume: number;
22
+ avgVolume: number;
23
+ volumeRatio: number; // volume / avgVolume
24
+ marketCap: number | undefined;
25
+ currency: string;
26
+ fiftyTwoWeekHigh: number | undefined;
27
+ fiftyTwoWeekLow: number | undefined;
28
+ dayHigh: number | undefined;
29
+ dayLow: number | undefined;
30
+ exchange: string;
31
+ }
32
+
33
+ export interface TrendingSymbol {
34
+ symbol: string;
35
+ }
36
+
37
+ export interface MarketSummaryQuote {
38
+ symbol: string;
39
+ name: string;
40
+ price: number;
41
+ change: number;
42
+ changePercent: number;
43
+ }
44
+
45
+ export function parseScreenerResponse(data: any): ScreenerQuote[] {
46
+ try {
47
+ const quotes = data?.finance?.result?.[0]?.quotes;
48
+ if (!Array.isArray(quotes)) return [];
49
+ const result: ScreenerQuote[] = [];
50
+ for (const q of quotes) {
51
+ if (!q || typeof q.symbol !== "string") continue;
52
+ const volume = q.regularMarketVolume ?? 0;
53
+ const avgVolume = q.averageDailyVolume3Month ?? q.averageDailyVolume10Day ?? 0;
54
+ result.push({
55
+ symbol: q.symbol,
56
+ name: q.shortName ?? q.longName ?? q.symbol,
57
+ price: q.regularMarketPrice ?? 0,
58
+ change: q.regularMarketChange ?? 0,
59
+ changePercent: q.regularMarketChangePercent ?? 0,
60
+ volume,
61
+ avgVolume,
62
+ volumeRatio: avgVolume > 0 ? volume / avgVolume : 0,
63
+ marketCap: q.marketCap ?? undefined,
64
+ currency: q.currency ?? "USD",
65
+ fiftyTwoWeekHigh: q.fiftyTwoWeekHigh ?? undefined,
66
+ fiftyTwoWeekLow: q.fiftyTwoWeekLow ?? undefined,
67
+ dayHigh: q.regularMarketDayHigh ?? undefined,
68
+ dayLow: q.regularMarketDayLow ?? undefined,
69
+ exchange: q.fullExchangeName ?? q.exchange ?? "",
70
+ });
71
+ }
72
+ return result;
73
+ } catch {
74
+ return [];
75
+ }
76
+ }
77
+
78
+ export async function fetchScreener(category: ScreenerCategory, count = 25): Promise<ScreenerQuote[]> {
79
+ const url = `https://query2.finance.yahoo.com/v1/finance/screener/predefined/saved?scrIds=${category}&count=${count}`;
80
+ const data = await screenerClient.fetchJson(url);
81
+ return parseScreenerResponse(data);
82
+ }
83
+
84
+ export function parseTrendingResponse(data: any): TrendingSymbol[] {
85
+ try {
86
+ const quotes = data?.finance?.result?.[0]?.quotes;
87
+ if (!Array.isArray(quotes)) return [];
88
+ const result: TrendingSymbol[] = [];
89
+ for (const q of quotes) {
90
+ if (!q || typeof q.symbol !== "string") continue;
91
+ result.push({ symbol: q.symbol });
92
+ }
93
+ return result;
94
+ } catch {
95
+ return [];
96
+ }
97
+ }
98
+
99
+ export async function fetchTrending(count = 25): Promise<TrendingSymbol[]> {
100
+ const url = `https://query2.finance.yahoo.com/v1/finance/trending/US?count=${count}`;
101
+ const data = await screenerClient.fetchJson(url);
102
+ return parseTrendingResponse(data);
103
+ }
104
+
105
+ export const MARKET_SUMMARY_SYMBOLS = ["^GSPC", "^DJI", "^IXIC", "^RUT"] as const;
@@ -7,6 +7,7 @@ import { usePaneTicker } from "../../state/app-context";
7
7
  import { ListView, type ListViewItem } from "../../components/ui";
8
8
  import { colors, hoverBg } from "../../theme/colors";
9
9
  import { padTo, formatCompact, formatNumber } from "../../utils/format";
10
+ import { formatMarketPrice } from "../../utils/market-format";
10
11
  import { formatExpDate, resolveOptionsTarget } from "../../utils/options";
11
12
  import { useOptionsQuery, useResolvedEntryValue } from "../../market-data/hooks";
12
13
  import { Spinner } from "../../components/spinner";
@@ -338,9 +339,9 @@ function formatContractRow(contract: { lastPrice: number; bid: number; ask: numb
338
339
  return padTo("—", w.last) + " " + padTo("—", w.bid) + " " + padTo("—", w.ask) + " " + padTo("—", w.vol) + " " + padTo("—", w.oi);
339
340
  }
340
341
  return (
341
- padTo(contract.lastPrice.toFixed(2), w.last) + " " +
342
- padTo(contract.bid.toFixed(2), w.bid) + " " +
343
- padTo(contract.ask.toFixed(2), w.ask) + " " +
342
+ padTo(formatMarketPrice(contract.lastPrice, { assetCategory: "OPT", maxWidth: w.last }), w.last) + " " +
343
+ padTo(formatMarketPrice(contract.bid, { assetCategory: "OPT", maxWidth: w.bid }), w.bid) + " " +
344
+ padTo(formatMarketPrice(contract.ask, { assetCategory: "OPT", maxWidth: w.ask }), w.ask) + " " +
344
345
  padTo(formatCompact(contract.volume), w.vol) + " " +
345
346
  padTo(formatCompact(contract.openInterest), w.oi)
346
347
  );
@@ -17,6 +17,7 @@ import {
17
17
  } from "../../../../utils/cli-output";
18
18
  import { exchangeShortName } from "../../../../utils/market-status";
19
19
  import { formatCompact, formatCurrency } from "../../../../utils/format";
20
+ import { formatMarketCostWithCurrency, formatMarketPriceWithCurrency, formatMarketQuantity } from "../../../../utils/market-format";
20
21
  import type { AppConfig } from "../../../../types/config";
21
22
  import type { CliCommandContext, CliCommandDef } from "../../../../types/plugin";
22
23
  import type { TickerRecord } from "../../../../types/ticker";
@@ -134,7 +135,9 @@ async function showCollection(name: string, ctx: CliCommandContext) {
134
135
  for (const ticker of filtered) {
135
136
  const quote = quotes.get(ticker.metadata.ticker);
136
137
  const positions = ticker.metadata.positions.filter((position) => position.portfolio === id);
137
- const priceText = quote ? colorBySign(formatCurrency(quote.price, quote.currency), quote.change) : "—";
138
+ const priceText = quote
139
+ ? colorBySign(formatMarketPriceWithCurrency(quote.price, quote.currency, { assetCategory: ticker.metadata.assetCategory }), quote.change)
140
+ : "—";
138
141
  const changeText = quote ? colorBySign(formatSignedPercentRaw(quote.changePercent), quote.change) : "—";
139
142
 
140
143
  if (positions.length === 0) {
@@ -157,8 +160,8 @@ async function showCollection(name: string, ctx: CliCommandContext) {
157
160
  ticker.metadata.ticker,
158
161
  priceText,
159
162
  changeText,
160
- String(position.shares),
161
- formatCurrency(position.avgCost, positionCurrency),
163
+ formatMarketQuantity(position.shares, { assetCategory: ticker.metadata.assetCategory, multiplier: position.multiplier }),
164
+ formatMarketCostWithCurrency(position.avgCost, positionCurrency, { assetCategory: ticker.metadata.assetCategory, multiplier: position.multiplier }),
162
165
  pnl == null ? "—" : colorBySign(formatSignedCurrency(pnl, baseCurrency), pnl),
163
166
  ]);
164
167
  }
@@ -181,7 +184,9 @@ async function showCollection(name: string, ctx: CliCommandContext) {
181
184
  const rows: string[][] = [];
182
185
  for (const ticker of filtered) {
183
186
  const quote = quotes.get(ticker.metadata.ticker);
184
- const priceText = quote ? colorBySign(formatCurrency(quote.price, quote.currency), quote.change) : "—";
187
+ const priceText = quote
188
+ ? colorBySign(formatMarketPriceWithCurrency(quote.price, quote.currency, { assetCategory: ticker.metadata.assetCategory }), quote.change)
189
+ : "—";
185
190
  const changeText = quote ? colorBySign(formatSignedPercentRaw(quote.changePercent), quote.change) : "—";
186
191
  const marketCapText = quote?.marketCap != null
187
192
  ? `${formatCompact(await toBase(quote.marketCap, quote.currency || ticker.metadata.currency || baseCurrency))} ${baseCurrency}`
@@ -343,8 +348,8 @@ async function setPositionCommand(
343
348
  });
344
349
  await store.saveTicker(result.ticker);
345
350
  console.log(cliStyles.success(`Set position for ${result.ticker.metadata.ticker} in "${portfolio.name}".`));
346
- console.log(renderStat("Shares", String(shares)));
347
- console.log(renderStat("Average Cost", formatCurrency(avgCost, currency)));
351
+ console.log(renderStat("Shares", formatMarketQuantity(shares, { assetCategory: result.ticker.metadata.assetCategory })));
352
+ console.log(renderStat("Average Cost", formatMarketCostWithCurrency(avgCost, currency, { assetCategory: result.ticker.metadata.assetCategory })));
348
353
  console.log(renderStat("Currency", currency));
349
354
  } catch (error: any) {
350
355
  ctx.closeAndFail(persistence, error?.message || `Failed to set position for ${symbol} in "${portfolioName}".`);
@@ -15,6 +15,7 @@ interface ManualPortfolioPositionWorkflowOptions {
15
15
  pendingLabel: string;
16
16
  positionOptional?: boolean;
17
17
  defaultAvgCost?: number | null;
18
+ hidePortfolioFieldWhenSingleOption?: boolean;
18
19
  }
19
20
 
20
21
  function buildManualPortfolioPositionWorkflow(
@@ -28,19 +29,20 @@ function buildManualPortfolioPositionWorkflow(
28
29
  const preferredPosition = options.ticker
29
30
  ? getManualPortfolioPosition(options.ticker, preferredPortfolio.id)
30
31
  : null;
32
+ const showPortfolioField = !options.hidePortfolioFieldWhenSingleOption || manualPortfolios.length > 1;
31
33
 
32
34
  const fields: CommandBarWorkflowField[] = [
33
- {
35
+ ...(showPortfolioField ? [{
34
36
  id: "portfolioId",
35
37
  label: "Portfolio",
36
- type: "select",
38
+ type: "select" as const,
37
39
  options: manualPortfolios.map((portfolio) => ({
38
40
  label: portfolio.name,
39
41
  value: portfolio.id,
40
42
  description: portfolio.currency,
41
43
  })),
42
44
  required: true,
43
- },
45
+ }] : []),
44
46
  {
45
47
  id: "ticker",
46
48
  label: "Ticker",
@@ -118,5 +120,6 @@ export function buildAddToPortfolioWorkflow(
118
120
  pendingLabel: "Adding to portfolio…",
119
121
  positionOptional: true,
120
122
  defaultAvgCost: options.defaultAvgCost,
123
+ hidePortfolioFieldWhenSingleOption: true,
121
124
  });
122
125
  }
@@ -4,7 +4,8 @@ import type { AppState } from "../../../state/app-context";
4
4
  import { colors, priceColor } from "../../../theme/colors";
5
5
  import type { TickerFinancials } from "../../../types/financials";
6
6
  import type { Portfolio, TickerRecord } from "../../../types/ticker";
7
- import { formatCompact, formatNumber, formatPercentRaw, padTo } from "../../../utils/format";
7
+ import { formatCompact, formatPercentRaw, padTo } from "../../../utils/format";
8
+ import { formatMarketQuantity } from "../../../utils/market-format";
8
9
  import { getMostRecentQuoteUpdate } from "../../../utils/quote-time";
9
10
  import { ibkrGatewayManager } from "../../ibkr/gateway-service";
10
11
  import { calculatePortfolioSummaryTotals } from "./metrics";
@@ -110,7 +111,7 @@ export function PortfolioCashMarginDrawer({
110
111
  <box key={balance.currency} height={1} flexDirection="row">
111
112
  <text fg={colors.textBright}>{padTo(balance.currency, 4)}</text>
112
113
  <text fg={colors.textDim}>{" qty "}</text>
113
- <text fg={colors.text}>{padTo(formatNumber(balance.quantity, 2), 14, "right")}</text>
114
+ <text fg={colors.text}>{padTo(formatMarketQuantity(balance.quantity, { isCashBalance: true, maxWidth: 14 }), 14, "right")}</text>
114
115
  <text fg={colors.textDim}>{" value "}</text>
115
116
  <text fg={colors.text}>{padTo(balance.baseValue != null ? formatCompact(balance.baseValue) : "—", 10, "right")}</text>
116
117
  </box>
@@ -4,7 +4,8 @@ import type { TickerFinancials } from "../../../types/financials";
4
4
  import type { TickerRecord } from "../../../types/ticker";
5
5
  import { priceColor } from "../../../theme/colors";
6
6
  import { clampQuoteTimestamp, formatQuoteAgeWithSource } from "../../../utils/quote-time";
7
- import { convertCurrency, formatCompact, formatCurrency, formatNumber, formatPercentRaw, formatPrice } from "../../../utils/format";
7
+ import { convertCurrency, formatCompact, formatNumber, formatPercentRaw } from "../../../utils/format";
8
+ import { formatMarketCost, formatMarketPrice, formatMarketQuantity, formatSignedMarketPrice, type MarketFormatOptions } from "../../../utils/market-format";
8
9
  import { getActiveQuoteDisplay, marketStateDot, type ActiveQuoteDisplay } from "../../../utils/market-status";
9
10
  import { formatOptionTicker } from "../../../utils/options";
10
11
 
@@ -21,6 +22,7 @@ interface PortfolioPositionMetrics {
21
22
  totalCost: number;
22
23
  totalCostUnits: number;
23
24
  totalPriceUnits: number;
25
+ multiplierHint: number;
24
26
  brokerMktValue: number;
25
27
  hasBrokerMktValue: boolean;
26
28
  brokerPnl: number;
@@ -95,6 +97,7 @@ function getPortfolioPositionMetrics(
95
97
  let totalCost = 0;
96
98
  let totalCostUnits = 0;
97
99
  let totalPriceUnits = 0;
100
+ let multiplierHint = 1;
98
101
  let brokerMktValue = 0;
99
102
  let hasBrokerMktValue = false;
100
103
  let brokerPnl = 0;
@@ -103,6 +106,7 @@ function getPortfolioPositionMetrics(
103
106
  const direction = position.side === "short" ? -1 : 1;
104
107
  const priceMultiplier = normalizePositionMultiplier(position.multiplier);
105
108
  const costMultiplier = resolvePositionCostMultiplier(position);
109
+ multiplierHint = Math.max(multiplierHint, priceMultiplier, costMultiplier);
106
110
 
107
111
  totalShares += position.shares * direction;
108
112
  totalCost += position.shares * position.avgCost * costMultiplier;
@@ -125,6 +129,7 @@ function getPortfolioPositionMetrics(
125
129
  totalCost,
126
130
  totalCostUnits,
127
131
  totalPriceUnits,
132
+ multiplierHint,
128
133
  brokerMktValue,
129
134
  hasBrokerMktValue,
130
135
  brokerPnl,
@@ -155,15 +160,17 @@ function resolveBrokerFallbackPnl(metrics: PortfolioPositionMetrics, brokerMarke
155
160
  export function resolvePortfolioPriceValue(
156
161
  activeQuote: ActiveQuoteDisplay | null,
157
162
  brokerMarkPrice: number | undefined,
163
+ formatOptions: MarketFormatOptions,
164
+ maxWidth?: number,
158
165
  ): { text: string; color?: string } {
159
166
  if (activeQuote) {
160
167
  return {
161
- text: formatPrice(activeQuote.price),
168
+ text: formatMarketPrice(activeQuote.price, { ...formatOptions, maxWidth }),
162
169
  color: priceColor(activeQuote.change),
163
170
  };
164
171
  }
165
172
  if (brokerMarkPrice != null) {
166
- return { text: formatPrice(brokerMarkPrice) };
173
+ return { text: formatMarketPrice(brokerMarkPrice, { ...formatOptions, maxWidth }) };
167
174
  }
168
175
  return { text: "—" };
169
176
  }
@@ -180,13 +187,17 @@ export function getColumnValue(
180
187
  const quoteCurrency = quote?.currency || ticker.metadata.currency || "USD";
181
188
 
182
189
  const positionMetrics = getPortfolioPositionMetrics(ticker, ctx.activeTab, quoteCurrency);
183
- const { positionCurrency, totalShares, totalCost, totalCostUnits, totalPriceUnits, brokerMarkPrice } = positionMetrics;
190
+ const { positionCurrency, totalShares, totalCost, totalCostUnits, totalPriceUnits, multiplierHint, brokerMarkPrice } = positionMetrics;
184
191
  const brokerFallbackMktValue = resolveBrokerFallbackMarketValue(positionMetrics);
185
192
  const brokerFallbackPnl = resolveBrokerFallbackPnl(positionMetrics, brokerFallbackMktValue);
186
193
  const toBaseQuote = (value: number) =>
187
194
  convertCurrency(value, quoteCurrency, ctx.baseCurrency, ctx.exchangeRates);
188
195
  const toBasePosition = (value: number) =>
189
196
  convertCurrency(value, positionCurrency, ctx.baseCurrency, ctx.exchangeRates);
197
+ const formatOptions: MarketFormatOptions = {
198
+ assetCategory: ticker.metadata.assetCategory,
199
+ multiplier: multiplierHint,
200
+ };
190
201
 
191
202
  switch (col.id) {
192
203
  case "ticker": {
@@ -198,21 +209,21 @@ export function getColumnValue(
198
209
  return { text: `${statusDot} ${displayName}` };
199
210
  }
200
211
  case "price":
201
- return resolvePortfolioPriceValue(activeQuote, brokerMarkPrice);
212
+ return resolvePortfolioPriceValue(activeQuote, brokerMarkPrice, formatOptions, col.width);
202
213
  case "change":
203
214
  if (!activeQuote) return { text: "—" };
204
215
  return {
205
- text: (activeQuote.change >= 0 ? "+" : "") + activeQuote.change.toFixed(2),
216
+ text: formatSignedMarketPrice(activeQuote.change, { ...formatOptions, maxWidth: col.width }),
206
217
  color: priceColor(activeQuote.change),
207
218
  };
208
219
  case "bid":
209
- return { text: quote?.bid != null ? formatPrice(quote.bid) : "—" };
220
+ return { text: quote?.bid != null ? formatMarketPrice(quote.bid, { ...formatOptions, maxWidth: col.width }) : "—" };
210
221
  case "ask":
211
- return { text: quote?.ask != null ? formatPrice(quote.ask) : "—" };
222
+ return { text: quote?.ask != null ? formatMarketPrice(quote.ask, { ...formatOptions, maxWidth: col.width }) : "—" };
212
223
  case "spread":
213
224
  return {
214
225
  text: quote?.bid != null && quote?.ask != null
215
- ? formatCurrency(quote.ask - quote.bid, quoteCurrency)
226
+ ? formatMarketPrice(quote.ask - quote.bid, { ...formatOptions, maxWidth: col.width })
216
227
  : "—",
217
228
  };
218
229
  case "change_pct":
@@ -241,10 +252,10 @@ export function getColumnValue(
241
252
  }
242
253
  return { text: "—" };
243
254
  case "shares":
244
- return { text: totalShares !== 0 ? formatCompact(totalShares) : "—" };
255
+ return { text: totalShares !== 0 ? formatMarketQuantity(totalShares, { ...formatOptions, maxWidth: col.width }) : "—" };
245
256
  case "avg_cost":
246
257
  if (totalCostUnits === 0) return { text: "—" };
247
- return { text: formatPrice(totalCost / Math.abs(totalCostUnits)) };
258
+ return { text: formatMarketCost(totalCost / Math.abs(totalCostUnits), { ...formatOptions, maxWidth: col.width }) };
248
259
  case "cost_basis":
249
260
  if (totalCost === 0) return { text: "—" };
250
261
  return { text: formatCompact(toBasePosition(totalCost)) };
@@ -323,16 +323,10 @@ export function PortfolioListPane({ focused, width, height }: PaneProps) {
323
323
 
324
324
  flushCursorSymbol(cursorSymbol);
325
325
 
326
- const follower = state.config.layout.instances.find((instance) =>
327
- instance.paneId === "ticker-detail"
328
- && instance.binding?.kind === "follow"
329
- && instance.binding.sourceInstanceId === paneId,
330
- );
331
- if (follower) {
332
- registry?.focusPaneFn(follower.instanceId);
333
- return;
326
+ const ticker = sortedTickers[safeSelectedIdx];
327
+ if (ticker) {
328
+ registry?.navigateTickerFn(ticker.metadata.ticker);
334
329
  }
335
- registry?.showPaneFn("ticker-detail");
336
330
  }, [
337
331
  cashDrawerExpanded,
338
332
  currentTabIdx,
@@ -11,6 +11,7 @@ export interface PortfolioPaneSettings {
11
11
  hideHeader: boolean;
12
12
  hideCash: boolean;
13
13
  lockedCollectionId: string;
14
+ showSparklines?: boolean;
14
15
  }
15
16
 
16
17
  export interface CollectionEntry {
@@ -117,6 +118,7 @@ export function getPortfolioPaneSettings(settings: Record<string, unknown> | und
117
118
  hideHeader: settings?.hideHeader === true,
118
119
  hideCash: settings?.hideCash === true,
119
120
  lockedCollectionId: typeof settings?.lockedCollectionId === "string" ? settings.lockedCollectionId : "",
121
+ showSparklines: settings?.showSparklines === true,
120
122
  };
121
123
  }
122
124
 
@@ -129,6 +131,7 @@ export function createPortfolioPaneSettings(overrides: Partial<PortfolioPaneSett
129
131
  hideHeader: overrides.hideHeader ?? false,
130
132
  hideCash: overrides.hideCash ?? false,
131
133
  lockedCollectionId: overrides.lockedCollectionId ?? "",
134
+ showSparklines: overrides.showSparklines ?? false,
132
135
  };
133
136
  }
134
137
 
@@ -240,6 +243,12 @@ export function buildPortfolioPaneSettingsDef(config: AppConfig, settings: Portf
240
243
  description: "Hide the cash & margin drawer at the bottom of the pane.",
241
244
  type: "toggle",
242
245
  });
246
+ fields.push({
247
+ key: "showSparklines",
248
+ label: "Sparklines",
249
+ description: "Show a tiny price history chart in each row.",
250
+ type: "toggle",
251
+ });
243
252
 
244
253
  if (settings.hideTabs && lockedCollectionOptions.length > 0) {
245
254
  fields.push({
@@ -25,6 +25,7 @@ export function PortfolioTickerTable({
25
25
  financialsMap,
26
26
  columnContext,
27
27
  flashSymbols,
28
+ showSparklines,
28
29
  }: {
29
30
  columns: ColumnConfig[];
30
31
  sortColumnId: string | null;
@@ -42,6 +43,7 @@ export function PortfolioTickerTable({
42
43
  financialsMap: Map<string, TickerFinancials>;
43
44
  columnContext: ColumnContext;
44
45
  flashSymbols: Map<string, QuoteFlashDirection>;
46
+ showSparklines?: boolean;
45
47
  }) {
46
48
  const resolveCell = useCallback(
47
49
  (column: ColumnConfig, ticker: TickerRecord, financials: TickerFinancials | undefined) => (
@@ -68,6 +70,7 @@ export function PortfolioTickerTable({
68
70
  sortColumnId={sortColumnId}
69
71
  sortDirection={sortDirection}
70
72
  onHeaderClick={onHeaderClick}
73
+ showSparklines={showSparklines}
71
74
  />
72
75
  );
73
76
  }
@@ -463,6 +463,7 @@ describe("PortfolioListPane cash and margin UI", () => {
463
463
  netLiquidation: 125000,
464
464
  cashBalances: [
465
465
  { currency: "USD", quantity: -50000, baseValue: -50000, baseCurrency: "USD" },
466
+ { currency: "EUR", quantity: -351957.025, baseValue: -381000, baseCurrency: "USD" },
466
467
  { currency: "JPY", quantity: 0, baseValue: 0, baseCurrency: "USD" },
467
468
  ],
468
469
  }],
@@ -478,6 +479,7 @@ describe("PortfolioListPane cash and margin UI", () => {
478
479
  expect(frame).toContain("Cash & Margin");
479
480
  expect(frame).toContain("Net Liq");
480
481
  expect(frame).toContain("Flex Mar 27");
482
+ expect(frame).toContain("-351,957.025");
481
483
  expect(frame).not.toContain("Avail");
482
484
  expect(frame).not.toContain("JPY");
483
485
  });
@@ -654,7 +656,7 @@ describe("PortfolioListPane cash and margin UI", () => {
654
656
  expect(frame).toContain("SPREAD");
655
657
  expect(frame).toContain("124.95");
656
658
  expect(frame).toContain("125.05");
657
- expect(frame).toContain("0.10");
659
+ expect(frame).toContain("0.1");
658
660
  });
659
661
 
660
662
  test("warms full financials for visible rows when only quote data is loaded", async () => {
@@ -196,7 +196,7 @@ describe("portfolio-metrics", () => {
196
196
  const pnlColumn: ColumnConfig = { id: "pnl", label: "P&L", width: 10, align: "right", format: "compact" };
197
197
 
198
198
  expect(getColumnValue(avgCostColumn, ticker, financials, defaultColumnContext)).toEqual({
199
- text: "5,095.07",
199
+ text: "5,095.073",
200
200
  });
201
201
  expect(getColumnValue(pnlColumn, ticker, financials, defaultColumnContext)).toEqual({
202
202
  text: "+7.9k",
@@ -204,6 +204,18 @@ describe("portfolio-metrics", () => {
204
204
  });
205
205
  });
206
206
 
207
+ test("formats equity average cost with tighter precision than quote prices", () => {
208
+ const ticker = createTicker({
209
+ assetCategory: "STK",
210
+ positions: [{ portfolio: "main", shares: 10, avgCost: 119.3687, broker: "manual", currency: "HKD" }],
211
+ });
212
+ const avgCostColumn: ColumnConfig = { id: "avg_cost", label: "AVG COST", width: 10, align: "right", format: "currency" };
213
+
214
+ expect(getColumnValue(avgCostColumn, ticker, undefined, defaultColumnContext)).toEqual({
215
+ text: "119.37",
216
+ });
217
+ });
218
+
207
219
  test("formats portfolio-only column values and sort keys consistently", () => {
208
220
  const ticker = createTicker({
209
221
  positions: [{ portfolio: "main", shares: 10, avgCost: 100, broker: "manual" }],