gloomberb 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/app.tsx +168 -65
  4. package/src/cli/commands/ticker.ts +45 -15
  5. package/src/cli/helpers.ts +4 -2
  6. package/src/cli/registry.test.ts +42 -35
  7. package/src/cli.test.ts +4 -2
  8. package/src/components/chart/chart-control-hint.tsx +39 -0
  9. package/src/components/chart/chart-controller.test.ts +110 -0
  10. package/src/components/chart/chart-controller.ts +364 -2
  11. package/src/components/chart/chart-data.ts +177 -29
  12. package/src/components/chart/chart-indicator-selector.tsx +53 -0
  13. package/src/components/chart/chart-pane-settings.ts +20 -0
  14. package/src/components/chart/chart-renderer.ts +220 -30
  15. package/src/components/chart/chart-resolution.test.ts +67 -0
  16. package/src/components/chart/chart-resolution.ts +74 -4
  17. package/src/components/chart/chart-scroll.test.ts +69 -0
  18. package/src/components/chart/chart-scroll.ts +104 -0
  19. package/src/components/chart/chart-types.ts +12 -0
  20. package/src/components/chart/chart-viewport.test.ts +5 -1
  21. package/src/components/chart/chart-viewport.ts +1 -1
  22. package/src/components/chart/chart.test.ts +249 -10
  23. package/src/components/chart/comparison-chart-renderer.test.ts +71 -1
  24. package/src/components/chart/comparison-chart-renderer.ts +40 -3
  25. package/src/components/chart/comparison-stock-chart.tsx +218 -123
  26. package/src/components/chart/indicators/bands.test.ts +50 -0
  27. package/src/components/chart/indicators/bands.ts +29 -0
  28. package/src/components/chart/indicators/moving-averages.test.ts +54 -0
  29. package/src/components/chart/indicators/moving-averages.ts +42 -0
  30. package/src/components/chart/indicators/options.test.ts +32 -0
  31. package/src/components/chart/indicators/options.ts +107 -0
  32. package/src/components/chart/indicators/oscillators.test.ts +79 -0
  33. package/src/components/chart/indicators/oscillators.ts +92 -0
  34. package/src/components/chart/indicators/types.ts +36 -0
  35. package/src/components/chart/native/chart-rasterizer.ts +51 -0
  36. package/src/components/chart/native/kitty.test.ts +68 -0
  37. package/src/components/chart/stock-chart-renderer-switch.test.tsx +740 -9
  38. package/src/components/chart/stock-chart.test.ts +239 -0
  39. package/src/components/chart/stock-chart.tsx +2257 -372
  40. package/src/components/command-bar/command-bar.test.tsx +337 -5
  41. package/src/components/command-bar/command-bar.tsx +292 -120
  42. package/src/components/command-bar/helpers.ts +1 -1
  43. package/src/components/command-bar/root-shortcuts.ts +33 -4
  44. package/src/components/command-bar/view-model.test.ts +3 -3
  45. package/src/components/command-bar/view-model.ts +4 -4
  46. package/src/components/command-bar/workflow-ops.ts +10 -0
  47. package/src/components/layout/floating-pane.tsx +3 -2
  48. package/src/components/layout/header.tsx +2 -1
  49. package/src/components/layout/pane-sizing.ts +10 -0
  50. package/src/components/layout/pane.tsx +9 -2
  51. package/src/components/layout/shell.test.tsx +282 -28
  52. package/src/components/layout/shell.tsx +10 -7
  53. package/src/components/layout/status-bar.test.tsx +16 -7
  54. package/src/components/layout/status-bar.tsx +4 -1
  55. package/src/components/markdown-editor.tsx +1 -0
  56. package/src/components/pane-settings-dialog.tsx +13 -152
  57. package/src/components/pane-template-wizard.tsx +1 -0
  58. package/src/components/price-selector-dialog.tsx +12 -7
  59. package/src/components/ticker-badge-text.test.tsx +50 -0
  60. package/src/components/ticker-badge-text.tsx +17 -2
  61. package/src/components/ticker-badge.tsx +2 -2
  62. package/src/components/ticker-list-table.tsx +54 -1
  63. package/src/components/toggle-list.tsx +15 -2
  64. package/src/components/ui/data-table.tsx +60 -3
  65. package/src/components/ui/external-link.tsx +48 -11
  66. package/src/components/ui/fields.tsx +2 -1
  67. package/src/components/ui/index.ts +8 -1
  68. package/src/components/ui/multi-select-chips.tsx +69 -0
  69. package/src/components/ui/multi-select-dialog.tsx +267 -0
  70. package/src/components/ui/multi-select.ts +78 -0
  71. package/src/components/ui/ui.test.tsx +343 -1
  72. package/src/data/plugin-state-store.ts +30 -19
  73. package/src/data/resource-store.ts +133 -103
  74. package/src/data/session-store.ts +19 -12
  75. package/src/data/sqlite-cache.test.ts +37 -2
  76. package/src/data/sqlite-database.ts +8 -2
  77. package/src/data/sqlite-retry.test.ts +63 -0
  78. package/src/data/sqlite-retry.ts +66 -0
  79. package/src/data/ticker-store.ts +29 -18
  80. package/src/index.tsx +6 -1
  81. package/src/market-data/coordinator.test.ts +114 -5
  82. package/src/market-data/coordinator.ts +70 -1
  83. package/src/market-data/hooks.test.tsx +73 -2
  84. package/src/market-data/hooks.tsx +16 -4
  85. package/src/notifications/app-notifier.test.ts +177 -0
  86. package/src/notifications/app-notifier.ts +241 -0
  87. package/src/pane-settings.test.ts +2 -0
  88. package/src/plugins/builtin/ai/index.test.tsx +1 -1
  89. package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
  90. package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
  91. package/src/plugins/builtin/ai/ticker-context.ts +3 -2
  92. package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
  93. package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
  94. package/src/plugins/builtin/alerts/index.test.tsx +357 -0
  95. package/src/plugins/builtin/alerts/index.tsx +519 -0
  96. package/src/plugins/builtin/alerts/types.ts +14 -0
  97. package/src/plugins/builtin/analytics/index.test.tsx +300 -0
  98. package/src/plugins/builtin/analytics/index.tsx +775 -0
  99. package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
  100. package/src/plugins/builtin/analytics/metrics.ts +155 -0
  101. package/src/plugins/builtin/ask-ai.test.tsx +2 -0
  102. package/src/plugins/builtin/chat-controller.test.ts +83 -18
  103. package/src/plugins/builtin/chat-controller.ts +26 -14
  104. package/src/plugins/builtin/chat.test.tsx +715 -4
  105. package/src/plugins/builtin/chat.tsx +424 -120
  106. package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
  107. package/src/plugins/builtin/comparison-chart.tsx +1 -2
  108. package/src/plugins/builtin/correlation/compute.test.ts +147 -0
  109. package/src/plugins/builtin/correlation/compute.ts +115 -0
  110. package/src/plugins/builtin/correlation/index.tsx +385 -0
  111. package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
  112. package/src/plugins/builtin/correlation/settings.test.ts +34 -0
  113. package/src/plugins/builtin/correlation/settings.ts +95 -0
  114. package/src/plugins/builtin/debug.tsx +36 -50
  115. package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
  116. package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
  117. package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
  118. package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
  119. package/src/plugins/builtin/earnings/index.tsx +337 -0
  120. package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
  121. package/src/plugins/builtin/econ/calendar-source.ts +121 -0
  122. package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
  123. package/src/plugins/builtin/econ/fred-client.ts +79 -0
  124. package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
  125. package/src/plugins/builtin/econ/index.tsx +999 -0
  126. package/src/plugins/builtin/econ/types.ts +13 -0
  127. package/src/plugins/builtin/fred-settings.ts +10 -0
  128. package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
  129. package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
  130. package/src/plugins/builtin/help.test.tsx +1 -5
  131. package/src/plugins/builtin/help.tsx +1 -16
  132. package/src/plugins/builtin/insider/index.tsx +417 -0
  133. package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
  134. package/src/plugins/builtin/insider/insider-data.ts +49 -0
  135. package/src/plugins/builtin/layout-manager.tsx +24 -16
  136. package/src/plugins/builtin/market-movers/index.tsx +573 -0
  137. package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
  138. package/src/plugins/builtin/market-movers/screener.ts +105 -0
  139. package/src/plugins/builtin/options.tsx +4 -3
  140. package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
  141. package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
  142. package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
  143. package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
  144. package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
  145. package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
  146. package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
  147. package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
  148. package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
  149. package/src/plugins/builtin/sec.tsx +114 -7
  150. package/src/plugins/builtin/sectors/index.tsx +359 -0
  151. package/src/plugins/builtin/sectors/sector-data.ts +18 -0
  152. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
  153. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
  154. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
  155. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
  156. package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
  157. package/src/plugins/builtin/world-indices/index.tsx +410 -0
  158. package/src/plugins/builtin/world-indices/indices.ts +52 -0
  159. package/src/plugins/builtin/yield-curve/index.tsx +242 -0
  160. package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
  161. package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
  162. package/src/plugins/catalog.ts +22 -0
  163. package/src/plugins/gridlock-notification.ts +15 -0
  164. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
  165. package/src/plugins/ibkr/index.test.tsx +1 -1
  166. package/src/plugins/ibkr/index.tsx +1 -1
  167. package/src/plugins/ibkr/trade-tab.tsx +23 -5
  168. package/src/plugins/ibkr/trade-utils.test.ts +1 -1
  169. package/src/plugins/ibkr/trade-utils.ts +7 -6
  170. package/src/plugins/ibkr/trading-pane.tsx +8 -7
  171. package/src/plugins/plugin-runtime.test.tsx +2 -0
  172. package/src/plugins/plugin-runtime.tsx +10 -0
  173. package/src/plugins/prediction-markets/controller-data.ts +8 -0
  174. package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
  175. package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
  176. package/src/plugins/prediction-markets/services/fetch.ts +13 -7
  177. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  178. package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
  179. package/src/plugins/prediction-markets/test-helpers.tsx +2 -0
  180. package/src/plugins/registry.ts +22 -13
  181. package/src/sources/provider-router.test.ts +3 -2
  182. package/src/sources/sec-edgar.ts +13 -1
  183. package/src/sources/yahoo-finance.ts +75 -1
  184. package/src/state/app-context-selector.test.tsx +17 -1
  185. package/src/state/app-context.test.tsx +24 -0
  186. package/src/state/app-context.tsx +29 -6
  187. package/src/theme/colors.ts +5 -0
  188. package/src/theme/themes.ts +25 -0
  189. package/src/types/config.test.ts +107 -0
  190. package/src/types/config.ts +30 -1
  191. package/src/types/data-provider.ts +13 -0
  192. package/src/types/plugin.ts +35 -1
  193. package/src/utils/api-client.test.ts +90 -0
  194. package/src/utils/api-client.ts +56 -16
  195. package/src/utils/format.test.ts +11 -1
  196. package/src/utils/format.ts +86 -8
  197. package/src/utils/inline-content-tokenizer.test.ts +22 -0
  198. package/src/utils/inline-content-tokenizer.ts +76 -0
  199. package/src/utils/link-tokenizer.test.ts +27 -0
  200. package/src/utils/link-tokenizer.ts +93 -0
  201. package/src/utils/market-format.test.ts +109 -0
  202. package/src/utils/market-format.ts +299 -0
  203. package/src/utils/market-status.ts +3 -2
  204. package/src/utils/selection-clipboard.test.ts +43 -0
  205. package/src/utils/selection-clipboard.ts +88 -0
  206. package/src/utils/terminal-input-reset.test.ts +50 -0
  207. package/src/utils/terminal-input-reset.ts +24 -0
  208. package/src/utils/throttled-fetch.test.ts +145 -0
  209. package/src/utils/throttled-fetch.ts +212 -0
  210. package/src/utils/ticker-tokenizer.test.ts +31 -0
  211. package/src/utils/ticker-tokenizer.ts +7 -42
  212. package/src/version.ts +1 -1
@@ -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";
@@ -155,6 +155,27 @@ describe("ChatController", () => {
155
155
  });
156
156
  });
157
157
 
158
+ test("does not restore persisted websocket tokens", () => {
159
+ const persistence = new MemoryPersistence();
160
+ const controller = new ChatController();
161
+
162
+ persistence.setState("session", {
163
+ sessionToken: "token-123",
164
+ websocketToken: "stale-ws-token",
165
+ user: { id: "u1", username: "vince", emailVerified: true },
166
+ }, { schemaVersion: 1 });
167
+
168
+ controller.attachPersistence(persistence);
169
+
170
+ expect(apiClient.getSessionToken()).toBe("token-123");
171
+ expect(apiClient.getWebSocketToken()).toBeNull();
172
+ expect(apiClient.getCurrentUser()).toMatchObject({
173
+ id: "u1",
174
+ username: "vince",
175
+ emailVerified: true,
176
+ });
177
+ });
178
+
158
179
  test("reset clears persisted chat state and session token", () => {
159
180
  const persistence = new MemoryPersistence();
160
181
  const controller = new ChatController();
@@ -412,15 +433,15 @@ describe("ChatController", () => {
412
433
  test("marks a pending message as failed when sending errors", async () => {
413
434
  const persistence = new MemoryPersistence();
414
435
  const controller = new ChatController();
415
- const toasts: string[] = [];
436
+ const notifications: AppNotificationRequest[] = [];
416
437
 
417
438
  persistence.setState("session", {
418
439
  sessionToken: "token-123",
419
440
  user: { id: "u1", username: "vince", emailVerified: true },
420
441
  }, { schemaVersion: 1 });
421
442
 
422
- controller.setToastNotifier((message) => {
423
- toasts.push(message);
443
+ controller.setNotifier((notification) => {
444
+ notifications.push(notification);
424
445
  });
425
446
  controller.attachPersistence(persistence);
426
447
 
@@ -441,13 +462,13 @@ describe("ChatController", () => {
441
462
  clientStatus: "failed",
442
463
  clientError: "server offline",
443
464
  });
444
- expect(toasts).toEqual(["server offline"]);
465
+ expect(notifications).toEqual([{ body: "server offline", type: "error" }]);
445
466
  });
446
467
 
447
- test("tracks unread mentions and shows a toast while chat is closed", () => {
468
+ test("tracks unread mentions and emits an app notification while chat is closed", () => {
448
469
  const persistence = new MemoryPersistence();
449
470
  const controller = new ChatController();
450
- const toasts: string[] = [];
471
+ const notifications: AppNotificationRequest[] = [];
451
472
  const message: ChatMessage = {
452
473
  id: "m1",
453
474
  channelId: "everyone",
@@ -462,25 +483,64 @@ describe("ChatController", () => {
462
483
  user: { id: "u1", username: "vince", emailVerified: true },
463
484
  }, { schemaVersion: 1 });
464
485
 
465
- controller.setToastNotifier((toast) => {
466
- toasts.push(toast);
486
+ controller.setNotifier((notification) => {
487
+ notifications.push(notification);
467
488
  });
468
489
  controller.attachPersistence(persistence);
469
490
 
470
491
  (controller as any).mergeMessages([message]);
471
492
 
472
493
  expect(controller.getSnapshot().unreadMentionCount).toBe(1);
473
- expect(toasts).toEqual(["@bob mentioned you: hey @Vince can you take a look?"]);
494
+ expect(notifications).toEqual([{
495
+ title: "Gloomberb chat",
496
+ body: "@bob mentioned you: hey @Vince can you take a look?",
497
+ type: "info",
498
+ desktop: "when-inactive",
499
+ }]);
474
500
  expect(persistence.getState<{ lastCursor: string | null; lastViewedMessageId: string | null }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
475
501
  lastCursor: "m1",
476
502
  lastViewedMessageId: null,
477
503
  });
478
504
  });
479
505
 
506
+ test("keeps emitting mention notifications while the app is backgrounded", () => {
507
+ const persistence = new MemoryPersistence();
508
+ const controller = new ChatController();
509
+ const notifications: AppNotificationRequest[] = [];
510
+ const message: ChatMessage = {
511
+ id: "m1",
512
+ channelId: "everyone",
513
+ content: "hey @vince",
514
+ replyToId: null,
515
+ createdAt: "2026-03-28T00:00:00.000Z",
516
+ user: { id: "u2", username: "bob", displayName: "Bob" },
517
+ };
518
+
519
+ persistence.setState("session", {
520
+ sessionToken: "token-123",
521
+ user: { id: "u1", username: "vince", emailVerified: true },
522
+ }, { schemaVersion: 1 });
523
+
524
+ controller.setNotifier((notification) => {
525
+ notifications.push(notification);
526
+ });
527
+ controller.attachPersistence(persistence);
528
+ controller.setAppActive(false);
529
+
530
+ (controller as any).mergeMessages([message]);
531
+
532
+ expect(notifications).toEqual([{
533
+ title: "Gloomberb chat",
534
+ body: "@bob mentioned you: hey @vince",
535
+ type: "info",
536
+ desktop: "when-inactive",
537
+ }]);
538
+ });
539
+
480
540
  test("marks mentions viewed when a chat view opens", () => {
481
541
  const persistence = new MemoryPersistence();
482
542
  const controller = new ChatController();
483
- const toasts: string[] = [];
543
+ const notifications: AppNotificationRequest[] = [];
484
544
  const message: ChatMessage = {
485
545
  id: "m1",
486
546
  channelId: "everyone",
@@ -495,8 +555,8 @@ describe("ChatController", () => {
495
555
  user: { id: "u1", username: "vince", emailVerified: true },
496
556
  }, { schemaVersion: 1 });
497
557
 
498
- controller.setToastNotifier((toast) => {
499
- toasts.push(toast);
558
+ controller.setNotifier((notification) => {
559
+ notifications.push(notification);
500
560
  });
501
561
  controller.attachPersistence(persistence);
502
562
 
@@ -507,7 +567,12 @@ describe("ChatController", () => {
507
567
  const detachView = controller.attachView();
508
568
 
509
569
  expect(controller.getSnapshot().unreadMentionCount).toBe(0);
510
- expect(toasts).toEqual(["@bob mentioned you: hey @vince"]);
570
+ expect(notifications).toEqual([{
571
+ title: "Gloomberb chat",
572
+ body: "@bob mentioned you: hey @vince",
573
+ type: "info",
574
+ desktop: "when-inactive",
575
+ }]);
511
576
  expect(persistence.getState<{ lastViewedMessageId: string | null }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
512
577
  lastViewedMessageId: "m1",
513
578
  });
@@ -517,7 +582,7 @@ describe("ChatController", () => {
517
582
  test("does not keep mentions unread while a chat view is already open", () => {
518
583
  const persistence = new MemoryPersistence();
519
584
  const controller = new ChatController();
520
- const toasts: string[] = [];
585
+ const notifications: AppNotificationRequest[] = [];
521
586
  const message: ChatMessage = {
522
587
  id: "m1",
523
588
  channelId: "everyone",
@@ -532,8 +597,8 @@ describe("ChatController", () => {
532
597
  user: { id: "u1", username: "vince", emailVerified: true },
533
598
  }, { schemaVersion: 1 });
534
599
 
535
- controller.setToastNotifier((toast) => {
536
- toasts.push(toast);
600
+ controller.setNotifier((notification) => {
601
+ notifications.push(notification);
537
602
  });
538
603
  controller.attachPersistence(persistence);
539
604
  const detachView = controller.attachView();
@@ -541,7 +606,7 @@ describe("ChatController", () => {
541
606
  (controller as any).mergeMessages([message]);
542
607
 
543
608
  expect(controller.getSnapshot().unreadMentionCount).toBe(0);
544
- expect(toasts).toEqual([]);
609
+ expect(notifications).toEqual([]);
545
610
  expect(persistence.getState<{ lastViewedMessageId: string | null }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
546
611
  lastViewedMessageId: "m1",
547
612
  });
@@ -1,4 +1,4 @@
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
3
  import { debugLog } from "../../utils/debug-log";
4
4
  import { toTimestampMillis } from "../../utils/timestamp";
@@ -49,7 +49,6 @@ export interface ChatControllerSnapshot {
49
49
  }
50
50
 
51
51
  type ChatConnection = { send: (content: string, replyToId?: string) => Promise<ChatMessage>; close: () => void };
52
- type ChatToastOptions = { duration?: number; type?: "info" | "success" | "error" };
53
52
 
54
53
  function normalizeUsername(username: string | null | undefined): string | null {
55
54
  const trimmed = username?.trim();
@@ -82,7 +81,9 @@ function isLegacyTimestampCursor(cursor: string | null): boolean {
82
81
  }
83
82
 
84
83
  function compareMessages(a: ChatMessage, b: ChatMessage): number {
85
- 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);
86
87
  }
87
88
 
88
89
  export class ChatController {
@@ -105,7 +106,7 @@ export class ChatController {
105
106
  private verificationPollTimer: ReturnType<typeof setInterval> | null = null;
106
107
  private openViewCount = 0;
107
108
  private pendingMessageSeq = 0;
108
- private toastFn: (message: string, options?: ChatToastOptions) => void = () => {};
109
+ private notifyFn: (notification: AppNotificationRequest) => void = () => {};
109
110
  private listeners = new Set<(snapshot: ChatControllerSnapshot) => void>();
110
111
 
111
112
  attachPersistence(persistence: PluginPersistence, resume?: PluginResumeState): void {
@@ -114,8 +115,8 @@ export class ChatController {
114
115
  this.hydrate();
115
116
  }
116
117
 
117
- setToastNotifier(notify: (message: string, options?: ChatToastOptions) => void): void {
118
- this.toastFn = notify;
118
+ setNotifier(notify: (notification: AppNotificationRequest) => void): void {
119
+ this.notifyFn = notify;
119
120
  }
120
121
 
121
122
  hydrate(): void {
@@ -128,7 +129,9 @@ export class ChatController {
128
129
  schemaVersion: SESSION_SCHEMA_VERSION,
129
130
  });
130
131
  apiClient.setSessionToken(session?.sessionToken ?? null);
131
- apiClient.setWebSocketToken(session?.websocketToken ?? null);
132
+ // WebSocket tokens are short-lived connection credentials. Reusing a persisted
133
+ // one can trap reconnects on an expired token even while the session cookie is valid.
134
+ apiClient.setWebSocketToken(null);
132
135
  apiClient.restoreCachedUser(session?.user ?? null);
133
136
  this.user = session?.user
134
137
  ? {
@@ -261,6 +264,7 @@ export class ChatController {
261
264
  }
262
265
 
263
266
  setDraft(draft: string): void {
267
+ if (draft === this.draft) return;
264
268
  this.draft = draft;
265
269
  this.persistChannelState();
266
270
  this.emit();
@@ -340,7 +344,7 @@ export class ChatController {
340
344
  this.messagesLoading = false;
341
345
  this.refreshMessagesPromise = null;
342
346
  this.openViewCount = 0;
343
- this.toastFn = () => {};
347
+ this.notifyFn = () => {};
344
348
  this.listeners.clear();
345
349
  }
346
350
 
@@ -351,7 +355,7 @@ export class ChatController {
351
355
  }
352
356
  const connection = this.wsConnection;
353
357
  if (!connection) {
354
- this.toastFn("Unable to send message right now.", { type: "error" });
358
+ this.notifyFn({ body: "Unable to send message right now.", type: "error" });
355
359
  return;
356
360
  }
357
361
 
@@ -373,7 +377,7 @@ export class ChatController {
373
377
  : entry
374
378
  ));
375
379
  this.emit();
376
- this.toastFn(errorMessage, { type: "error" });
380
+ this.notifyFn({ body: errorMessage, type: "error" });
377
381
  });
378
382
  }
379
383
 
@@ -481,7 +485,6 @@ export class ChatController {
481
485
  private persistSession(): void {
482
486
  const value = {
483
487
  sessionToken: apiClient.getSessionToken(),
484
- websocketToken: apiClient.getWebSocketToken(),
485
488
  user: this.user,
486
489
  } satisfies PersistedSessionState;
487
490
  this.resume?.setState(SESSION_STATE_KEY, value, {
@@ -531,13 +534,22 @@ export class ChatController {
531
534
 
532
535
  const freshMentions = this.getMentionMessages(messages);
533
536
  if (freshMentions.length === 0) return;
534
- if (!this.appActive) return;
535
537
 
536
538
  if (freshMentions.length === 1) {
537
- this.toastFn(formatMentionToast(freshMentions[0]!), { type: "info" });
539
+ this.notifyFn({
540
+ title: "Gloomberb chat",
541
+ body: formatMentionToast(freshMentions[0]!),
542
+ type: "info",
543
+ desktop: "when-inactive",
544
+ });
538
545
  return;
539
546
  }
540
- this.toastFn(`${freshMentions.length} new mentions in #everyone.`, { type: "info" });
547
+ this.notifyFn({
548
+ title: "Gloomberb chat",
549
+ body: `${freshMentions.length} new mentions in #everyone.`,
550
+ type: "info",
551
+ desktop: "when-inactive",
552
+ });
541
553
  }
542
554
 
543
555
  private getUnreadMentionCount(): number {