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,125 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ computeBeta,
4
+ computeDatedBeta,
5
+ computeDatedReturns,
6
+ computeSectorAllocation,
7
+ computeSharpeRatio,
8
+ computeWeightedPortfolioReturns,
9
+ type DatedReturn,
10
+ } from "./metrics";
11
+
12
+ function datedReturns(values: number[], startDay = 1): DatedReturn[] {
13
+ return values.map((value, index) => ({
14
+ dateKey: `2024-01-${String(startDay + index).padStart(2, "0")}`,
15
+ value,
16
+ }));
17
+ }
18
+
19
+ describe("computeSharpeRatio", () => {
20
+ test("computes positive Sharpe for good returns", () => {
21
+ const returns = Array.from({ length: 20 }, () => 0.005 + (Math.random() - 0.5) * 0.001);
22
+ const sharpe = computeSharpeRatio(returns);
23
+ expect(sharpe).not.toBeNull();
24
+ expect(sharpe!).toBeGreaterThan(0);
25
+ });
26
+
27
+ test("returns null for insufficient data", () => {
28
+ expect(computeSharpeRatio([0.01, 0.02])).toBeNull();
29
+ });
30
+
31
+ test("returns null for zero variance", () => {
32
+ expect(computeSharpeRatio(Array(20).fill(0.01))).toBeNull();
33
+ });
34
+ });
35
+
36
+ describe("computeBeta", () => {
37
+ test("beta of 1 when returns match market", () => {
38
+ const returns = Array.from({ length: 20 }, () => Math.random() * 0.02 - 0.01);
39
+ const beta = computeBeta(returns, returns);
40
+ expect(beta).toBeCloseTo(1.0, 1);
41
+ });
42
+
43
+ test("returns null for insufficient data", () => {
44
+ expect(computeBeta([0.01], [0.01])).toBeNull();
45
+ });
46
+
47
+ test("aligns dated returns before computing beta", () => {
48
+ const market = datedReturns([
49
+ -0.010, 0.015, 0.004, -0.006, 0.011,
50
+ 0.008, -0.012, 0.009, 0.013, -0.007,
51
+ 0.005, 0.010,
52
+ ], 2);
53
+ const asset = [
54
+ { dateKey: "2024-01-01", value: 0.25 },
55
+ ...market.map((point) => ({ dateKey: point.dateKey, value: point.value * 2 })),
56
+ ];
57
+
58
+ expect(computeDatedBeta(asset, market)).toBeCloseTo(2, 5);
59
+ });
60
+
61
+ test("weights portfolio returns by holding value", () => {
62
+ const market = datedReturns([
63
+ -0.010, 0.015, 0.004, -0.006, 0.011,
64
+ 0.008, -0.012, 0.009, 0.013, -0.007,
65
+ 0.005, 0.010,
66
+ ]);
67
+ const portfolio = computeWeightedPortfolioReturns([
68
+ {
69
+ weight: 80,
70
+ returns: market.map((point) => ({ dateKey: point.dateKey, value: point.value * 2 })),
71
+ },
72
+ {
73
+ weight: 20,
74
+ returns: market.map((point) => ({ dateKey: point.dateKey, value: 0 })),
75
+ },
76
+ ]);
77
+
78
+ expect(computeDatedBeta(portfolio, market)).toBeCloseTo(1.6, 5);
79
+ });
80
+
81
+ test("computes dated returns from closing prices", () => {
82
+ const returns = computeDatedReturns([
83
+ { date: new Date("2024-01-01T00:00:00Z"), close: 100 },
84
+ { date: new Date("2024-01-02T00:00:00Z"), close: 110 },
85
+ { date: new Date("2024-01-03T00:00:00Z"), close: 99 },
86
+ ]);
87
+
88
+ expect(returns).toEqual([
89
+ { dateKey: "2024-01-02", value: 0.1 },
90
+ { dateKey: "2024-01-03", value: -0.1 },
91
+ ]);
92
+ });
93
+ });
94
+
95
+ describe("computeSectorAllocation", () => {
96
+ test("computes weights from positions", () => {
97
+ const alloc = computeSectorAllocation([
98
+ { sector: "Technology", marketValue: 60000 },
99
+ { sector: "Healthcare", marketValue: 40000 },
100
+ ]);
101
+ expect(alloc).toHaveLength(2);
102
+ expect(alloc[0]!.sector).toBe("Technology");
103
+ expect(alloc[0]!.weight).toBeCloseTo(0.6, 2);
104
+ });
105
+
106
+ test("groups same sectors", () => {
107
+ const alloc = computeSectorAllocation([
108
+ { sector: "Tech", marketValue: 30000 },
109
+ { sector: "Tech", marketValue: 20000 },
110
+ { sector: "Health", marketValue: 50000 },
111
+ ]);
112
+ expect(alloc).toHaveLength(2);
113
+ expect(alloc[0]!.sector).toBe("Health");
114
+ expect(alloc[0]!.weight).toBeCloseTo(0.5, 2);
115
+ });
116
+
117
+ test("returns empty for zero total value", () => {
118
+ expect(computeSectorAllocation([])).toEqual([]);
119
+ });
120
+
121
+ test("uses Unknown for missing sector", () => {
122
+ const alloc = computeSectorAllocation([{ sector: "", marketValue: 100 }]);
123
+ expect(alloc[0]!.sector).toBe("Unknown");
124
+ });
125
+ });
@@ -0,0 +1,155 @@
1
+ import type { PricePoint } from "../../../types/financials";
2
+ import type { TickerRecord } from "../../../types/ticker";
3
+
4
+ export interface DatedReturn {
5
+ dateKey: string;
6
+ value: number;
7
+ }
8
+
9
+ export interface WeightedReturnSeries {
10
+ weight: number;
11
+ returns: DatedReturn[];
12
+ }
13
+
14
+ function getPricePointTimestamp(point: PricePoint): number {
15
+ const value = point.date as Date | string | number | null | undefined;
16
+ if (value instanceof Date) return value.getTime();
17
+ if (value == null) return Number.NaN;
18
+ return new Date(value).getTime();
19
+ }
20
+
21
+ function toDateKey(timestamp: number): string {
22
+ const date = new Date(timestamp);
23
+ const month = String(date.getMonth() + 1).padStart(2, "0");
24
+ const day = String(date.getDate()).padStart(2, "0");
25
+ return `${date.getFullYear()}-${month}-${day}`;
26
+ }
27
+
28
+ export function computeSharpeRatio(returns: number[], riskFreeRate = 0.05): number | null {
29
+ if (returns.length < 10) return null;
30
+ const n = returns.length;
31
+ const meanReturn = returns.reduce((s, r) => s + r, 0) / n;
32
+ const variance = returns.reduce((s, r) => s + (r - meanReturn) ** 2, 0) / (n - 1);
33
+ const stdDev = Math.sqrt(variance);
34
+ if (variance < Number.EPSILON) return null;
35
+ const annualizedReturn = meanReturn * 252;
36
+ const annualizedStdDev = stdDev * Math.sqrt(252);
37
+ return (annualizedReturn - riskFreeRate) / annualizedStdDev;
38
+ }
39
+
40
+ export function computeBeta(assetReturns: number[], marketReturns: number[]): number | null {
41
+ const n = Math.min(assetReturns.length, marketReturns.length);
42
+ if (n < 10) return null;
43
+ let sumMarket = 0, sumAsset = 0;
44
+ for (let i = 0; i < n; i++) {
45
+ sumMarket += marketReturns[i]!;
46
+ sumAsset += assetReturns[i]!;
47
+ }
48
+ const meanMarket = sumMarket / n;
49
+ const meanAsset = sumAsset / n;
50
+ let covariance = 0, marketVariance = 0;
51
+ for (let i = 0; i < n; i++) {
52
+ const dm = marketReturns[i]! - meanMarket;
53
+ const da = assetReturns[i]! - meanAsset;
54
+ covariance += dm * da;
55
+ marketVariance += dm * dm;
56
+ }
57
+ if (marketVariance === 0) return null;
58
+ return covariance / marketVariance;
59
+ }
60
+
61
+ export function computeDatedReturns(history: PricePoint[]): DatedReturn[] {
62
+ const points = history
63
+ .map((point) => ({ point, timestamp: getPricePointTimestamp(point) }))
64
+ .filter(({ point, timestamp }) => (
65
+ Number.isFinite(timestamp)
66
+ && Number.isFinite(point.close)
67
+ && point.close > 0
68
+ ))
69
+ .sort((left, right) => left.timestamp - right.timestamp);
70
+
71
+ const returns: DatedReturn[] = [];
72
+ for (let i = 1; i < points.length; i++) {
73
+ const previous = points[i - 1]!;
74
+ const current = points[i]!;
75
+ const value = (current.point.close - previous.point.close) / previous.point.close;
76
+ if (!Number.isFinite(value)) continue;
77
+ returns.push({
78
+ dateKey: toDateKey(current.timestamp),
79
+ value,
80
+ });
81
+ }
82
+ return returns;
83
+ }
84
+
85
+ export function computeWeightedPortfolioReturns(series: WeightedReturnSeries[]): DatedReturn[] {
86
+ const totals = new Map<string, { weightedReturn: number; weight: number }>();
87
+
88
+ for (const entry of series) {
89
+ if (!Number.isFinite(entry.weight) || entry.weight <= 0) continue;
90
+ for (const point of entry.returns) {
91
+ if (!Number.isFinite(point.value)) continue;
92
+ const current = totals.get(point.dateKey) ?? { weightedReturn: 0, weight: 0 };
93
+ current.weightedReturn += point.value * entry.weight;
94
+ current.weight += entry.weight;
95
+ totals.set(point.dateKey, current);
96
+ }
97
+ }
98
+
99
+ return [...totals.entries()]
100
+ .filter(([, total]) => total.weight > 0)
101
+ .map(([dateKey, total]) => ({
102
+ dateKey,
103
+ value: total.weightedReturn / total.weight,
104
+ }))
105
+ .sort((left, right) => left.dateKey.localeCompare(right.dateKey));
106
+ }
107
+
108
+ export function alignReturnSeries(
109
+ assetReturns: DatedReturn[],
110
+ marketReturns: DatedReturn[],
111
+ ): { asset: number[]; market: number[] } {
112
+ const marketByDate = new Map(marketReturns.map((point) => [point.dateKey, point.value]));
113
+ const asset: number[] = [];
114
+ const market: number[] = [];
115
+
116
+ for (const point of assetReturns) {
117
+ const marketValue = marketByDate.get(point.dateKey);
118
+ if (marketValue == null) continue;
119
+ asset.push(point.value);
120
+ market.push(marketValue);
121
+ }
122
+
123
+ return { asset, market };
124
+ }
125
+
126
+ export function computeDatedBeta(assetReturns: DatedReturn[], marketReturns: DatedReturn[]): number | null {
127
+ const aligned = alignReturnSeries(assetReturns, marketReturns);
128
+ return computeBeta(aligned.asset, aligned.market);
129
+ }
130
+
131
+ export interface SectorAllocation {
132
+ sector: string;
133
+ weight: number;
134
+ value: number;
135
+ }
136
+
137
+ export function computeSectorAllocation(
138
+ positions: Array<{ sector: string; marketValue: number }>,
139
+ ): SectorAllocation[] {
140
+ const sectorMap = new Map<string, number>();
141
+ let total = 0;
142
+ for (const pos of positions) {
143
+ const sector = pos.sector || "Unknown";
144
+ sectorMap.set(sector, (sectorMap.get(sector) ?? 0) + pos.marketValue);
145
+ total += pos.marketValue;
146
+ }
147
+ if (total === 0) return [];
148
+ return [...sectorMap.entries()]
149
+ .map(([sector, value]) => ({ sector, weight: value / total, value }))
150
+ .sort((a, b) => b.weight - a.weight || a.sector.localeCompare(b.sector));
151
+ }
152
+
153
+ export function hasPortfolioPosition(ticker: TickerRecord, portfolioId: string): boolean {
154
+ return ticker.metadata.positions.some((position) => position.portfolio === portfolioId);
155
+ }
@@ -17,6 +17,8 @@ function makeRuntime(): PluginRuntimeAccess {
17
17
  const listeners = new Map<string, Set<() => void>>();
18
18
 
19
19
  return {
20
+ pinTicker() {},
21
+ navigateTicker() {},
20
22
  subscribeResumeState(pluginId, key, listener) {
21
23
  const storeKey = `${pluginId}:${key}`;
22
24
  if (!listeners.has(storeKey)) listeners.set(storeKey, new Set());
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
- import type { PluginPersistence } from "../../types/plugin";
2
+ import type { AppNotificationRequest, PluginPersistence } from "../../types/plugin";
3
3
  import type { PersistedResourceValue } from "../../types/persistence";
4
4
  import { apiClient, type ChatMessage } from "../../utils/api-client";
5
5
  import { ChatController } from "./chat-controller";
@@ -84,6 +84,7 @@ class MemoryPersistence implements PluginPersistence {
84
84
  }
85
85
 
86
86
  afterEach(() => {
87
+ apiClient.dispose();
87
88
  apiClient.setSessionToken(null);
88
89
  apiClient.connectChannel = originalConnectChannel;
89
90
  apiClient.getSession = originalGetSession;
@@ -203,6 +204,35 @@ describe("ChatController", () => {
203
204
  controller.reset(true);
204
205
  });
205
206
 
207
+ test("dispose stops verification polling and closes the live connection", () => {
208
+ const controller = new ChatController();
209
+ let closed = false;
210
+
211
+ apiClient.setSessionToken("token-123");
212
+ (controller as any).user = { id: "u1", username: "vince", emailVerified: false };
213
+ (controller as any).syncVerificationPolling();
214
+ expect((controller as any).verificationPollTimer).not.toBeNull();
215
+
216
+ (controller as any).user = { id: "u1", username: "vince", emailVerified: true };
217
+ apiClient.connectChannel = () => ({
218
+ send: async () => {
219
+ throw new Error("not implemented");
220
+ },
221
+ close: () => {
222
+ closed = true;
223
+ },
224
+ });
225
+
226
+ controller.ensureConnection();
227
+ expect((controller as any).wsConnection).not.toBeNull();
228
+
229
+ controller.dispose();
230
+
231
+ expect(closed).toBe(true);
232
+ expect((controller as any).verificationPollTimer).toBeNull();
233
+ expect((controller as any).wsConnection).toBeNull();
234
+ });
235
+
206
236
  test("keeps the cached session when session refresh fails transiently", async () => {
207
237
  const persistence = new MemoryPersistence();
208
238
  const controller = new ChatController();
@@ -382,15 +412,15 @@ describe("ChatController", () => {
382
412
  test("marks a pending message as failed when sending errors", async () => {
383
413
  const persistence = new MemoryPersistence();
384
414
  const controller = new ChatController();
385
- const toasts: string[] = [];
415
+ const notifications: AppNotificationRequest[] = [];
386
416
 
387
417
  persistence.setState("session", {
388
418
  sessionToken: "token-123",
389
419
  user: { id: "u1", username: "vince", emailVerified: true },
390
420
  }, { schemaVersion: 1 });
391
421
 
392
- controller.setToastNotifier((message) => {
393
- toasts.push(message);
422
+ controller.setNotifier((notification) => {
423
+ notifications.push(notification);
394
424
  });
395
425
  controller.attachPersistence(persistence);
396
426
 
@@ -411,13 +441,13 @@ describe("ChatController", () => {
411
441
  clientStatus: "failed",
412
442
  clientError: "server offline",
413
443
  });
414
- expect(toasts).toEqual(["server offline"]);
444
+ expect(notifications).toEqual([{ body: "server offline", type: "error" }]);
415
445
  });
416
446
 
417
- test("tracks unread mentions and shows a toast while chat is closed", () => {
447
+ test("tracks unread mentions and emits an app notification while chat is closed", () => {
418
448
  const persistence = new MemoryPersistence();
419
449
  const controller = new ChatController();
420
- const toasts: string[] = [];
450
+ const notifications: AppNotificationRequest[] = [];
421
451
  const message: ChatMessage = {
422
452
  id: "m1",
423
453
  channelId: "everyone",
@@ -432,25 +462,64 @@ describe("ChatController", () => {
432
462
  user: { id: "u1", username: "vince", emailVerified: true },
433
463
  }, { schemaVersion: 1 });
434
464
 
435
- controller.setToastNotifier((toast) => {
436
- toasts.push(toast);
465
+ controller.setNotifier((notification) => {
466
+ notifications.push(notification);
437
467
  });
438
468
  controller.attachPersistence(persistence);
439
469
 
440
470
  (controller as any).mergeMessages([message]);
441
471
 
442
472
  expect(controller.getSnapshot().unreadMentionCount).toBe(1);
443
- expect(toasts).toEqual(["@bob mentioned you: hey @Vince can you take a look?"]);
473
+ expect(notifications).toEqual([{
474
+ title: "Gloomberb chat",
475
+ body: "@bob mentioned you: hey @Vince can you take a look?",
476
+ type: "info",
477
+ desktop: "when-inactive",
478
+ }]);
444
479
  expect(persistence.getState<{ lastCursor: string | null; lastViewedMessageId: string | null }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
445
480
  lastCursor: "m1",
446
481
  lastViewedMessageId: null,
447
482
  });
448
483
  });
449
484
 
485
+ test("keeps emitting mention notifications while the app is backgrounded", () => {
486
+ const persistence = new MemoryPersistence();
487
+ const controller = new ChatController();
488
+ const notifications: AppNotificationRequest[] = [];
489
+ const message: ChatMessage = {
490
+ id: "m1",
491
+ channelId: "everyone",
492
+ content: "hey @vince",
493
+ replyToId: null,
494
+ createdAt: "2026-03-28T00:00:00.000Z",
495
+ user: { id: "u2", username: "bob", displayName: "Bob" },
496
+ };
497
+
498
+ persistence.setState("session", {
499
+ sessionToken: "token-123",
500
+ user: { id: "u1", username: "vince", emailVerified: true },
501
+ }, { schemaVersion: 1 });
502
+
503
+ controller.setNotifier((notification) => {
504
+ notifications.push(notification);
505
+ });
506
+ controller.attachPersistence(persistence);
507
+ controller.setAppActive(false);
508
+
509
+ (controller as any).mergeMessages([message]);
510
+
511
+ expect(notifications).toEqual([{
512
+ title: "Gloomberb chat",
513
+ body: "@bob mentioned you: hey @vince",
514
+ type: "info",
515
+ desktop: "when-inactive",
516
+ }]);
517
+ });
518
+
450
519
  test("marks mentions viewed when a chat view opens", () => {
451
520
  const persistence = new MemoryPersistence();
452
521
  const controller = new ChatController();
453
- const toasts: string[] = [];
522
+ const notifications: AppNotificationRequest[] = [];
454
523
  const message: ChatMessage = {
455
524
  id: "m1",
456
525
  channelId: "everyone",
@@ -465,8 +534,8 @@ describe("ChatController", () => {
465
534
  user: { id: "u1", username: "vince", emailVerified: true },
466
535
  }, { schemaVersion: 1 });
467
536
 
468
- controller.setToastNotifier((toast) => {
469
- toasts.push(toast);
537
+ controller.setNotifier((notification) => {
538
+ notifications.push(notification);
470
539
  });
471
540
  controller.attachPersistence(persistence);
472
541
 
@@ -477,7 +546,12 @@ describe("ChatController", () => {
477
546
  const detachView = controller.attachView();
478
547
 
479
548
  expect(controller.getSnapshot().unreadMentionCount).toBe(0);
480
- expect(toasts).toEqual(["@bob mentioned you: hey @vince"]);
549
+ expect(notifications).toEqual([{
550
+ title: "Gloomberb chat",
551
+ body: "@bob mentioned you: hey @vince",
552
+ type: "info",
553
+ desktop: "when-inactive",
554
+ }]);
481
555
  expect(persistence.getState<{ lastViewedMessageId: string | null }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
482
556
  lastViewedMessageId: "m1",
483
557
  });
@@ -487,7 +561,7 @@ describe("ChatController", () => {
487
561
  test("does not keep mentions unread while a chat view is already open", () => {
488
562
  const persistence = new MemoryPersistence();
489
563
  const controller = new ChatController();
490
- const toasts: string[] = [];
564
+ const notifications: AppNotificationRequest[] = [];
491
565
  const message: ChatMessage = {
492
566
  id: "m1",
493
567
  channelId: "everyone",
@@ -502,8 +576,8 @@ describe("ChatController", () => {
502
576
  user: { id: "u1", username: "vince", emailVerified: true },
503
577
  }, { schemaVersion: 1 });
504
578
 
505
- controller.setToastNotifier((toast) => {
506
- toasts.push(toast);
579
+ controller.setNotifier((notification) => {
580
+ notifications.push(notification);
507
581
  });
508
582
  controller.attachPersistence(persistence);
509
583
  const detachView = controller.attachView();
@@ -511,7 +585,7 @@ describe("ChatController", () => {
511
585
  (controller as any).mergeMessages([message]);
512
586
 
513
587
  expect(controller.getSnapshot().unreadMentionCount).toBe(0);
514
- expect(toasts).toEqual([]);
588
+ expect(notifications).toEqual([]);
515
589
  expect(persistence.getState<{ lastViewedMessageId: string | null }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
516
590
  lastViewedMessageId: "m1",
517
591
  });
@@ -1,5 +1,6 @@
1
- import type { PluginPersistence, PluginResumeState } from "../../types/plugin";
1
+ import type { AppNotificationRequest, PluginPersistence, PluginResumeState } from "../../types/plugin";
2
2
  import { apiClient, type ChatMessage, type PersistedAuthUser } from "../../utils/api-client";
3
+ import { debugLog } from "../../utils/debug-log";
3
4
  import { toTimestampMillis } from "../../utils/timestamp";
4
5
 
5
6
  const SESSION_STATE_KEY = "session";
@@ -18,6 +19,7 @@ const VERIFICATION_POLL_MS = 5_000;
18
19
  const ISO_TIMESTAMP_CURSOR = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
19
20
  const USERNAME_MENTION = /(^|[^A-Za-z0-9_])@([A-Za-z][A-Za-z0-9_]{2,29})(?![A-Za-z0-9_])/g;
20
21
  const PENDING_RECONCILE_WINDOW_MS = 2 * 60_000;
22
+ const chatLog = debugLog.createLogger("chat-controller");
21
23
 
22
24
  interface PersistedSessionState {
23
25
  sessionToken: string | null;
@@ -47,7 +49,6 @@ export interface ChatControllerSnapshot {
47
49
  }
48
50
 
49
51
  type ChatConnection = { send: (content: string, replyToId?: string) => Promise<ChatMessage>; close: () => void };
50
- type ChatToastOptions = { duration?: number; type?: "info" | "success" | "error" };
51
52
 
52
53
  function normalizeUsername(username: string | null | undefined): string | null {
53
54
  const trimmed = username?.trim();
@@ -80,7 +81,9 @@ function isLegacyTimestampCursor(cursor: string | null): boolean {
80
81
  }
81
82
 
82
83
  function compareMessages(a: ChatMessage, b: ChatMessage): number {
83
- return a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id);
84
+ // Preserve server/insertion order inside same-timestamp batches so the bottom-most
85
+ // rendered message stays the one selected first from the composer.
86
+ return a.createdAt.localeCompare(b.createdAt);
84
87
  }
85
88
 
86
89
  export class ChatController {
@@ -103,7 +106,7 @@ export class ChatController {
103
106
  private verificationPollTimer: ReturnType<typeof setInterval> | null = null;
104
107
  private openViewCount = 0;
105
108
  private pendingMessageSeq = 0;
106
- private toastFn: (message: string, options?: ChatToastOptions) => void = () => {};
109
+ private notifyFn: (notification: AppNotificationRequest) => void = () => {};
107
110
  private listeners = new Set<(snapshot: ChatControllerSnapshot) => void>();
108
111
 
109
112
  attachPersistence(persistence: PluginPersistence, resume?: PluginResumeState): void {
@@ -112,8 +115,8 @@ export class ChatController {
112
115
  this.hydrate();
113
116
  }
114
117
 
115
- setToastNotifier(notify: (message: string, options?: ChatToastOptions) => void): void {
116
- this.toastFn = notify;
118
+ setNotifier(notify: (notification: AppNotificationRequest) => void): void {
119
+ this.notifyFn = notify;
117
120
  }
118
121
 
119
122
  hydrate(): void {
@@ -259,6 +262,7 @@ export class ChatController {
259
262
  }
260
263
 
261
264
  setDraft(draft: string): void {
265
+ if (draft === this.draft) return;
262
266
  this.draft = draft;
263
267
  this.persistChannelState();
264
268
  this.emit();
@@ -326,6 +330,22 @@ export class ChatController {
326
330
  this.emit();
327
331
  }
328
332
 
333
+ dispose(): void {
334
+ chatLog.info("dispose controller", {
335
+ listeners: this.listeners.size,
336
+ hasConnection: !!this.wsConnection,
337
+ });
338
+ this.stopVerificationPolling();
339
+ this.wsConnection?.close();
340
+ this.wsConnection = null;
341
+ this.wsConnected = false;
342
+ this.messagesLoading = false;
343
+ this.refreshMessagesPromise = null;
344
+ this.openViewCount = 0;
345
+ this.notifyFn = () => {};
346
+ this.listeners.clear();
347
+ }
348
+
329
349
  send(content: string, replyToId?: string): void {
330
350
  if (!this.user?.emailVerified || !apiClient.getSessionToken()) return;
331
351
  if (!this.wsConnection && this.user?.emailVerified && apiClient.getSessionToken()) {
@@ -333,7 +353,7 @@ export class ChatController {
333
353
  }
334
354
  const connection = this.wsConnection;
335
355
  if (!connection) {
336
- this.toastFn("Unable to send message right now.", { type: "error" });
356
+ this.notifyFn({ body: "Unable to send message right now.", type: "error" });
337
357
  return;
338
358
  }
339
359
 
@@ -355,7 +375,7 @@ export class ChatController {
355
375
  : entry
356
376
  ));
357
377
  this.emit();
358
- this.toastFn(errorMessage, { type: "error" });
378
+ this.notifyFn({ body: errorMessage, type: "error" });
359
379
  });
360
380
  }
361
381
 
@@ -513,13 +533,22 @@ export class ChatController {
513
533
 
514
534
  const freshMentions = this.getMentionMessages(messages);
515
535
  if (freshMentions.length === 0) return;
516
- if (!this.appActive) return;
517
536
 
518
537
  if (freshMentions.length === 1) {
519
- this.toastFn(formatMentionToast(freshMentions[0]!), { type: "info" });
538
+ this.notifyFn({
539
+ title: "Gloomberb chat",
540
+ body: formatMentionToast(freshMentions[0]!),
541
+ type: "info",
542
+ desktop: "when-inactive",
543
+ });
520
544
  return;
521
545
  }
522
- this.toastFn(`${freshMentions.length} new mentions in #everyone.`, { type: "info" });
546
+ this.notifyFn({
547
+ title: "Gloomberb chat",
548
+ body: `${freshMentions.length} new mentions in #everyone.`,
549
+ type: "info",
550
+ desktop: "when-inactive",
551
+ });
523
552
  }
524
553
 
525
554
  private getUnreadMentionCount(): number {