gloomberb 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/app.tsx +168 -65
  4. package/src/cli/commands/ticker.ts +45 -15
  5. package/src/cli/helpers.ts +4 -2
  6. package/src/cli/registry.test.ts +42 -35
  7. package/src/cli.test.ts +4 -2
  8. package/src/components/chart/chart-control-hint.tsx +39 -0
  9. package/src/components/chart/chart-controller.test.ts +110 -0
  10. package/src/components/chart/chart-controller.ts +364 -2
  11. package/src/components/chart/chart-data.ts +177 -29
  12. package/src/components/chart/chart-indicator-selector.tsx +53 -0
  13. package/src/components/chart/chart-pane-settings.ts +20 -0
  14. package/src/components/chart/chart-renderer.ts +220 -30
  15. package/src/components/chart/chart-resolution.test.ts +67 -0
  16. package/src/components/chart/chart-resolution.ts +74 -4
  17. package/src/components/chart/chart-scroll.test.ts +69 -0
  18. package/src/components/chart/chart-scroll.ts +104 -0
  19. package/src/components/chart/chart-types.ts +12 -0
  20. package/src/components/chart/chart-viewport.test.ts +5 -1
  21. package/src/components/chart/chart-viewport.ts +1 -1
  22. package/src/components/chart/chart.test.ts +249 -10
  23. package/src/components/chart/comparison-chart-renderer.test.ts +71 -1
  24. package/src/components/chart/comparison-chart-renderer.ts +40 -3
  25. package/src/components/chart/comparison-stock-chart.tsx +218 -123
  26. package/src/components/chart/indicators/bands.test.ts +50 -0
  27. package/src/components/chart/indicators/bands.ts +29 -0
  28. package/src/components/chart/indicators/moving-averages.test.ts +54 -0
  29. package/src/components/chart/indicators/moving-averages.ts +42 -0
  30. package/src/components/chart/indicators/options.test.ts +32 -0
  31. package/src/components/chart/indicators/options.ts +107 -0
  32. package/src/components/chart/indicators/oscillators.test.ts +79 -0
  33. package/src/components/chart/indicators/oscillators.ts +92 -0
  34. package/src/components/chart/indicators/types.ts +36 -0
  35. package/src/components/chart/native/chart-rasterizer.ts +51 -0
  36. package/src/components/chart/native/kitty.test.ts +68 -0
  37. package/src/components/chart/stock-chart-renderer-switch.test.tsx +740 -9
  38. package/src/components/chart/stock-chart.test.ts +239 -0
  39. package/src/components/chart/stock-chart.tsx +2257 -372
  40. package/src/components/command-bar/command-bar.test.tsx +337 -5
  41. package/src/components/command-bar/command-bar.tsx +292 -120
  42. package/src/components/command-bar/helpers.ts +1 -1
  43. package/src/components/command-bar/root-shortcuts.ts +33 -4
  44. package/src/components/command-bar/view-model.test.ts +3 -3
  45. package/src/components/command-bar/view-model.ts +4 -4
  46. package/src/components/command-bar/workflow-ops.ts +10 -0
  47. package/src/components/layout/header.tsx +2 -1
  48. package/src/components/layout/shell.test.tsx +186 -27
  49. package/src/components/layout/shell.tsx +5 -4
  50. package/src/components/layout/status-bar.test.tsx +16 -7
  51. package/src/components/layout/status-bar.tsx +4 -1
  52. package/src/components/markdown-editor.tsx +1 -0
  53. package/src/components/pane-settings-dialog.tsx +13 -152
  54. package/src/components/pane-template-wizard.tsx +1 -0
  55. package/src/components/price-selector-dialog.tsx +12 -7
  56. package/src/components/ticker-badge-text.test.tsx +50 -0
  57. package/src/components/ticker-badge-text.tsx +17 -2
  58. package/src/components/ticker-badge.tsx +2 -2
  59. package/src/components/ticker-list-table.tsx +54 -1
  60. package/src/components/toggle-list.tsx +15 -2
  61. package/src/components/ui/data-table.tsx +60 -3
  62. package/src/components/ui/external-link.tsx +48 -11
  63. package/src/components/ui/fields.tsx +2 -1
  64. package/src/components/ui/index.ts +8 -1
  65. package/src/components/ui/multi-select-chips.tsx +69 -0
  66. package/src/components/ui/multi-select-dialog.tsx +267 -0
  67. package/src/components/ui/multi-select.ts +78 -0
  68. package/src/components/ui/ui.test.tsx +343 -1
  69. package/src/data/plugin-state-store.ts +30 -19
  70. package/src/data/resource-store.ts +133 -103
  71. package/src/data/session-store.ts +19 -12
  72. package/src/data/sqlite-cache.test.ts +37 -2
  73. package/src/data/sqlite-database.ts +8 -2
  74. package/src/data/sqlite-retry.test.ts +63 -0
  75. package/src/data/sqlite-retry.ts +66 -0
  76. package/src/data/ticker-store.ts +29 -18
  77. package/src/index.tsx +6 -1
  78. package/src/market-data/coordinator.test.ts +114 -5
  79. package/src/market-data/coordinator.ts +70 -1
  80. package/src/market-data/hooks.test.tsx +73 -2
  81. package/src/market-data/hooks.tsx +16 -4
  82. package/src/notifications/app-notifier.test.ts +177 -0
  83. package/src/notifications/app-notifier.ts +241 -0
  84. package/src/pane-settings.test.ts +2 -0
  85. package/src/plugins/builtin/ai/index.test.tsx +1 -1
  86. package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
  87. package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
  88. package/src/plugins/builtin/ai/ticker-context.ts +3 -2
  89. package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
  90. package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
  91. package/src/plugins/builtin/alerts/index.test.tsx +357 -0
  92. package/src/plugins/builtin/alerts/index.tsx +519 -0
  93. package/src/plugins/builtin/alerts/types.ts +14 -0
  94. package/src/plugins/builtin/analytics/index.test.tsx +300 -0
  95. package/src/plugins/builtin/analytics/index.tsx +775 -0
  96. package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
  97. package/src/plugins/builtin/analytics/metrics.ts +155 -0
  98. package/src/plugins/builtin/ask-ai.test.tsx +2 -0
  99. package/src/plugins/builtin/chat-controller.test.ts +62 -18
  100. package/src/plugins/builtin/chat-controller.ts +23 -12
  101. package/src/plugins/builtin/chat.test.tsx +715 -4
  102. package/src/plugins/builtin/chat.tsx +415 -119
  103. package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
  104. package/src/plugins/builtin/comparison-chart.tsx +1 -2
  105. package/src/plugins/builtin/correlation/compute.test.ts +147 -0
  106. package/src/plugins/builtin/correlation/compute.ts +115 -0
  107. package/src/plugins/builtin/correlation/index.tsx +385 -0
  108. package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
  109. package/src/plugins/builtin/correlation/settings.test.ts +34 -0
  110. package/src/plugins/builtin/correlation/settings.ts +95 -0
  111. package/src/plugins/builtin/debug.tsx +36 -50
  112. package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
  113. package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
  114. package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
  115. package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
  116. package/src/plugins/builtin/earnings/index.tsx +337 -0
  117. package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
  118. package/src/plugins/builtin/econ/calendar-source.ts +121 -0
  119. package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
  120. package/src/plugins/builtin/econ/fred-client.ts +79 -0
  121. package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
  122. package/src/plugins/builtin/econ/index.tsx +999 -0
  123. package/src/plugins/builtin/econ/types.ts +13 -0
  124. package/src/plugins/builtin/fred-settings.ts +10 -0
  125. package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
  126. package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
  127. package/src/plugins/builtin/help.test.tsx +1 -5
  128. package/src/plugins/builtin/help.tsx +1 -16
  129. package/src/plugins/builtin/insider/index.tsx +417 -0
  130. package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
  131. package/src/plugins/builtin/insider/insider-data.ts +49 -0
  132. package/src/plugins/builtin/layout-manager.tsx +24 -16
  133. package/src/plugins/builtin/market-movers/index.tsx +573 -0
  134. package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
  135. package/src/plugins/builtin/market-movers/screener.ts +105 -0
  136. package/src/plugins/builtin/options.tsx +4 -3
  137. package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
  138. package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
  139. package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
  140. package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
  141. package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
  142. package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
  143. package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
  144. package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
  145. package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
  146. package/src/plugins/builtin/sec.tsx +114 -7
  147. package/src/plugins/builtin/sectors/index.tsx +359 -0
  148. package/src/plugins/builtin/sectors/sector-data.ts +18 -0
  149. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
  150. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
  151. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
  152. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
  153. package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
  154. package/src/plugins/builtin/world-indices/index.tsx +410 -0
  155. package/src/plugins/builtin/world-indices/indices.ts +52 -0
  156. package/src/plugins/builtin/yield-curve/index.tsx +242 -0
  157. package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
  158. package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
  159. package/src/plugins/catalog.ts +22 -0
  160. package/src/plugins/gridlock-notification.ts +15 -0
  161. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
  162. package/src/plugins/ibkr/index.test.tsx +1 -1
  163. package/src/plugins/ibkr/index.tsx +1 -1
  164. package/src/plugins/ibkr/trade-tab.tsx +23 -5
  165. package/src/plugins/ibkr/trade-utils.test.ts +1 -1
  166. package/src/plugins/ibkr/trade-utils.ts +7 -6
  167. package/src/plugins/ibkr/trading-pane.tsx +8 -7
  168. package/src/plugins/plugin-runtime.test.tsx +2 -0
  169. package/src/plugins/plugin-runtime.tsx +10 -0
  170. package/src/plugins/prediction-markets/controller-data.ts +8 -0
  171. package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
  172. package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
  173. package/src/plugins/prediction-markets/services/fetch.ts +13 -7
  174. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  175. package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
  176. package/src/plugins/prediction-markets/test-helpers.tsx +2 -0
  177. package/src/plugins/registry.ts +22 -13
  178. package/src/sources/provider-router.test.ts +3 -2
  179. package/src/sources/sec-edgar.ts +13 -1
  180. package/src/sources/yahoo-finance.ts +75 -1
  181. package/src/state/app-context-selector.test.tsx +17 -1
  182. package/src/state/app-context.test.tsx +24 -0
  183. package/src/state/app-context.tsx +29 -6
  184. package/src/theme/colors.ts +5 -0
  185. package/src/theme/themes.ts +25 -0
  186. package/src/types/config.test.ts +107 -0
  187. package/src/types/config.ts +30 -1
  188. package/src/types/data-provider.ts +13 -0
  189. package/src/types/plugin.ts +35 -1
  190. package/src/utils/format.test.ts +11 -1
  191. package/src/utils/format.ts +86 -8
  192. package/src/utils/inline-content-tokenizer.test.ts +22 -0
  193. package/src/utils/inline-content-tokenizer.ts +76 -0
  194. package/src/utils/link-tokenizer.test.ts +27 -0
  195. package/src/utils/link-tokenizer.ts +93 -0
  196. package/src/utils/market-format.test.ts +109 -0
  197. package/src/utils/market-format.ts +299 -0
  198. package/src/utils/market-status.ts +3 -2
  199. package/src/utils/selection-clipboard.test.ts +43 -0
  200. package/src/utils/selection-clipboard.ts +88 -0
  201. package/src/utils/terminal-input-reset.test.ts +50 -0
  202. package/src/utils/terminal-input-reset.ts +24 -0
  203. package/src/utils/throttled-fetch.test.ts +145 -0
  204. package/src/utils/throttled-fetch.ts +212 -0
  205. package/src/utils/ticker-tokenizer.test.ts +31 -0
  206. package/src/utils/ticker-tokenizer.ts +7 -42
  207. package/src/version.ts +1 -1
@@ -0,0 +1,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";
@@ -412,15 +412,15 @@ describe("ChatController", () => {
412
412
  test("marks a pending message as failed when sending errors", async () => {
413
413
  const persistence = new MemoryPersistence();
414
414
  const controller = new ChatController();
415
- const toasts: string[] = [];
415
+ const notifications: AppNotificationRequest[] = [];
416
416
 
417
417
  persistence.setState("session", {
418
418
  sessionToken: "token-123",
419
419
  user: { id: "u1", username: "vince", emailVerified: true },
420
420
  }, { schemaVersion: 1 });
421
421
 
422
- controller.setToastNotifier((message) => {
423
- toasts.push(message);
422
+ controller.setNotifier((notification) => {
423
+ notifications.push(notification);
424
424
  });
425
425
  controller.attachPersistence(persistence);
426
426
 
@@ -441,13 +441,13 @@ describe("ChatController", () => {
441
441
  clientStatus: "failed",
442
442
  clientError: "server offline",
443
443
  });
444
- expect(toasts).toEqual(["server offline"]);
444
+ expect(notifications).toEqual([{ body: "server offline", type: "error" }]);
445
445
  });
446
446
 
447
- 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", () => {
448
448
  const persistence = new MemoryPersistence();
449
449
  const controller = new ChatController();
450
- const toasts: string[] = [];
450
+ const notifications: AppNotificationRequest[] = [];
451
451
  const message: ChatMessage = {
452
452
  id: "m1",
453
453
  channelId: "everyone",
@@ -462,25 +462,64 @@ describe("ChatController", () => {
462
462
  user: { id: "u1", username: "vince", emailVerified: true },
463
463
  }, { schemaVersion: 1 });
464
464
 
465
- controller.setToastNotifier((toast) => {
466
- toasts.push(toast);
465
+ controller.setNotifier((notification) => {
466
+ notifications.push(notification);
467
467
  });
468
468
  controller.attachPersistence(persistence);
469
469
 
470
470
  (controller as any).mergeMessages([message]);
471
471
 
472
472
  expect(controller.getSnapshot().unreadMentionCount).toBe(1);
473
- 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
+ }]);
474
479
  expect(persistence.getState<{ lastCursor: string | null; lastViewedMessageId: string | null }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
475
480
  lastCursor: "m1",
476
481
  lastViewedMessageId: null,
477
482
  });
478
483
  });
479
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
+
480
519
  test("marks mentions viewed when a chat view opens", () => {
481
520
  const persistence = new MemoryPersistence();
482
521
  const controller = new ChatController();
483
- const toasts: string[] = [];
522
+ const notifications: AppNotificationRequest[] = [];
484
523
  const message: ChatMessage = {
485
524
  id: "m1",
486
525
  channelId: "everyone",
@@ -495,8 +534,8 @@ describe("ChatController", () => {
495
534
  user: { id: "u1", username: "vince", emailVerified: true },
496
535
  }, { schemaVersion: 1 });
497
536
 
498
- controller.setToastNotifier((toast) => {
499
- toasts.push(toast);
537
+ controller.setNotifier((notification) => {
538
+ notifications.push(notification);
500
539
  });
501
540
  controller.attachPersistence(persistence);
502
541
 
@@ -507,7 +546,12 @@ describe("ChatController", () => {
507
546
  const detachView = controller.attachView();
508
547
 
509
548
  expect(controller.getSnapshot().unreadMentionCount).toBe(0);
510
- 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
+ }]);
511
555
  expect(persistence.getState<{ lastViewedMessageId: string | null }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
512
556
  lastViewedMessageId: "m1",
513
557
  });
@@ -517,7 +561,7 @@ describe("ChatController", () => {
517
561
  test("does not keep mentions unread while a chat view is already open", () => {
518
562
  const persistence = new MemoryPersistence();
519
563
  const controller = new ChatController();
520
- const toasts: string[] = [];
564
+ const notifications: AppNotificationRequest[] = [];
521
565
  const message: ChatMessage = {
522
566
  id: "m1",
523
567
  channelId: "everyone",
@@ -532,8 +576,8 @@ describe("ChatController", () => {
532
576
  user: { id: "u1", username: "vince", emailVerified: true },
533
577
  }, { schemaVersion: 1 });
534
578
 
535
- controller.setToastNotifier((toast) => {
536
- toasts.push(toast);
579
+ controller.setNotifier((notification) => {
580
+ notifications.push(notification);
537
581
  });
538
582
  controller.attachPersistence(persistence);
539
583
  const detachView = controller.attachView();
@@ -541,7 +585,7 @@ describe("ChatController", () => {
541
585
  (controller as any).mergeMessages([message]);
542
586
 
543
587
  expect(controller.getSnapshot().unreadMentionCount).toBe(0);
544
- expect(toasts).toEqual([]);
588
+ expect(notifications).toEqual([]);
545
589
  expect(persistence.getState<{ lastViewedMessageId: string | null }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
546
590
  lastViewedMessageId: "m1",
547
591
  });
@@ -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 {
@@ -261,6 +262,7 @@ export class ChatController {
261
262
  }
262
263
 
263
264
  setDraft(draft: string): void {
265
+ if (draft === this.draft) return;
264
266
  this.draft = draft;
265
267
  this.persistChannelState();
266
268
  this.emit();
@@ -340,7 +342,7 @@ export class ChatController {
340
342
  this.messagesLoading = false;
341
343
  this.refreshMessagesPromise = null;
342
344
  this.openViewCount = 0;
343
- this.toastFn = () => {};
345
+ this.notifyFn = () => {};
344
346
  this.listeners.clear();
345
347
  }
346
348
 
@@ -351,7 +353,7 @@ export class ChatController {
351
353
  }
352
354
  const connection = this.wsConnection;
353
355
  if (!connection) {
354
- this.toastFn("Unable to send message right now.", { type: "error" });
356
+ this.notifyFn({ body: "Unable to send message right now.", type: "error" });
355
357
  return;
356
358
  }
357
359
 
@@ -373,7 +375,7 @@ export class ChatController {
373
375
  : entry
374
376
  ));
375
377
  this.emit();
376
- this.toastFn(errorMessage, { type: "error" });
378
+ this.notifyFn({ body: errorMessage, type: "error" });
377
379
  });
378
380
  }
379
381
 
@@ -531,13 +533,22 @@ export class ChatController {
531
533
 
532
534
  const freshMentions = this.getMentionMessages(messages);
533
535
  if (freshMentions.length === 0) return;
534
- if (!this.appActive) return;
535
536
 
536
537
  if (freshMentions.length === 1) {
537
- 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
+ });
538
544
  return;
539
545
  }
540
- 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
+ });
541
552
  }
542
553
 
543
554
  private getUnreadMentionCount(): number {