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.
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/app.tsx +168 -65
- package/src/cli/commands/ticker.ts +45 -15
- package/src/cli/helpers.ts +4 -2
- package/src/cli/registry.test.ts +42 -35
- package/src/cli.test.ts +4 -2
- package/src/components/chart/chart-control-hint.tsx +39 -0
- package/src/components/chart/chart-controller.test.ts +110 -0
- package/src/components/chart/chart-controller.ts +364 -2
- package/src/components/chart/chart-data.ts +177 -29
- package/src/components/chart/chart-indicator-selector.tsx +53 -0
- package/src/components/chart/chart-pane-settings.ts +20 -0
- package/src/components/chart/chart-renderer.ts +220 -30
- package/src/components/chart/chart-resolution.test.ts +67 -0
- package/src/components/chart/chart-resolution.ts +74 -4
- package/src/components/chart/chart-scroll.test.ts +69 -0
- package/src/components/chart/chart-scroll.ts +104 -0
- package/src/components/chart/chart-types.ts +12 -0
- package/src/components/chart/chart-viewport.test.ts +5 -1
- package/src/components/chart/chart-viewport.ts +1 -1
- package/src/components/chart/chart.test.ts +249 -10
- package/src/components/chart/comparison-chart-renderer.test.ts +71 -1
- package/src/components/chart/comparison-chart-renderer.ts +40 -3
- package/src/components/chart/comparison-stock-chart.tsx +218 -123
- package/src/components/chart/indicators/bands.test.ts +50 -0
- package/src/components/chart/indicators/bands.ts +29 -0
- package/src/components/chart/indicators/moving-averages.test.ts +54 -0
- package/src/components/chart/indicators/moving-averages.ts +42 -0
- package/src/components/chart/indicators/options.test.ts +32 -0
- package/src/components/chart/indicators/options.ts +107 -0
- package/src/components/chart/indicators/oscillators.test.ts +79 -0
- package/src/components/chart/indicators/oscillators.ts +92 -0
- package/src/components/chart/indicators/types.ts +36 -0
- package/src/components/chart/native/chart-rasterizer.ts +51 -0
- package/src/components/chart/native/kitty.test.ts +68 -0
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +740 -9
- package/src/components/chart/stock-chart.test.ts +239 -0
- package/src/components/chart/stock-chart.tsx +2257 -372
- package/src/components/command-bar/command-bar.test.tsx +337 -5
- package/src/components/command-bar/command-bar.tsx +292 -120
- package/src/components/command-bar/helpers.ts +1 -1
- package/src/components/command-bar/root-shortcuts.ts +33 -4
- package/src/components/command-bar/view-model.test.ts +3 -3
- package/src/components/command-bar/view-model.ts +4 -4
- package/src/components/command-bar/workflow-ops.ts +10 -0
- package/src/components/layout/floating-pane.tsx +3 -2
- package/src/components/layout/header.tsx +2 -1
- package/src/components/layout/pane-sizing.ts +10 -0
- package/src/components/layout/pane.tsx +9 -2
- package/src/components/layout/shell.test.tsx +282 -28
- package/src/components/layout/shell.tsx +10 -7
- package/src/components/layout/status-bar.test.tsx +16 -7
- package/src/components/layout/status-bar.tsx +4 -1
- package/src/components/markdown-editor.tsx +1 -0
- package/src/components/pane-settings-dialog.tsx +13 -152
- package/src/components/pane-template-wizard.tsx +1 -0
- package/src/components/price-selector-dialog.tsx +12 -7
- package/src/components/ticker-badge-text.test.tsx +50 -0
- package/src/components/ticker-badge-text.tsx +17 -2
- package/src/components/ticker-badge.tsx +2 -2
- package/src/components/ticker-list-table.tsx +54 -1
- package/src/components/toggle-list.tsx +15 -2
- package/src/components/ui/data-table.tsx +60 -3
- package/src/components/ui/external-link.tsx +48 -11
- package/src/components/ui/fields.tsx +2 -1
- package/src/components/ui/index.ts +8 -1
- package/src/components/ui/multi-select-chips.tsx +69 -0
- package/src/components/ui/multi-select-dialog.tsx +267 -0
- package/src/components/ui/multi-select.ts +78 -0
- package/src/components/ui/ui.test.tsx +343 -1
- package/src/data/plugin-state-store.ts +30 -19
- package/src/data/resource-store.ts +133 -103
- package/src/data/session-store.ts +19 -12
- package/src/data/sqlite-cache.test.ts +37 -2
- package/src/data/sqlite-database.ts +8 -2
- package/src/data/sqlite-retry.test.ts +63 -0
- package/src/data/sqlite-retry.ts +66 -0
- package/src/data/ticker-store.ts +29 -18
- package/src/index.tsx +6 -1
- package/src/market-data/coordinator.test.ts +114 -5
- package/src/market-data/coordinator.ts +70 -1
- package/src/market-data/hooks.test.tsx +73 -2
- package/src/market-data/hooks.tsx +16 -4
- package/src/notifications/app-notifier.test.ts +177 -0
- package/src/notifications/app-notifier.ts +241 -0
- package/src/pane-settings.test.ts +2 -0
- package/src/plugins/builtin/ai/index.test.tsx +1 -1
- package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
- package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
- package/src/plugins/builtin/ai/ticker-context.ts +3 -2
- package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
- package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
- package/src/plugins/builtin/alerts/index.test.tsx +357 -0
- package/src/plugins/builtin/alerts/index.tsx +519 -0
- package/src/plugins/builtin/alerts/types.ts +14 -0
- package/src/plugins/builtin/analytics/index.test.tsx +300 -0
- package/src/plugins/builtin/analytics/index.tsx +775 -0
- package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
- package/src/plugins/builtin/analytics/metrics.ts +155 -0
- package/src/plugins/builtin/ask-ai.test.tsx +2 -0
- package/src/plugins/builtin/chat-controller.test.ts +83 -18
- package/src/plugins/builtin/chat-controller.ts +26 -14
- package/src/plugins/builtin/chat.test.tsx +715 -4
- package/src/plugins/builtin/chat.tsx +424 -120
- package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
- package/src/plugins/builtin/comparison-chart.tsx +1 -2
- package/src/plugins/builtin/correlation/compute.test.ts +147 -0
- package/src/plugins/builtin/correlation/compute.ts +115 -0
- package/src/plugins/builtin/correlation/index.tsx +385 -0
- package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
- package/src/plugins/builtin/correlation/settings.test.ts +34 -0
- package/src/plugins/builtin/correlation/settings.ts +95 -0
- package/src/plugins/builtin/debug.tsx +36 -50
- package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
- package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
- package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
- package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
- package/src/plugins/builtin/earnings/index.tsx +337 -0
- package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
- package/src/plugins/builtin/econ/calendar-source.ts +121 -0
- package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
- package/src/plugins/builtin/econ/fred-client.ts +79 -0
- package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
- package/src/plugins/builtin/econ/index.tsx +999 -0
- package/src/plugins/builtin/econ/types.ts +13 -0
- package/src/plugins/builtin/fred-settings.ts +10 -0
- package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
- package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
- package/src/plugins/builtin/help.test.tsx +1 -5
- package/src/plugins/builtin/help.tsx +1 -16
- package/src/plugins/builtin/insider/index.tsx +417 -0
- package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
- package/src/plugins/builtin/insider/insider-data.ts +49 -0
- package/src/plugins/builtin/layout-manager.tsx +24 -16
- package/src/plugins/builtin/market-movers/index.tsx +573 -0
- package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
- package/src/plugins/builtin/market-movers/screener.ts +105 -0
- package/src/plugins/builtin/options.tsx +4 -3
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
- package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
- package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
- package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
- package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
- package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
- package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
- package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
- package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
- package/src/plugins/builtin/sec.tsx +114 -7
- package/src/plugins/builtin/sectors/index.tsx +359 -0
- package/src/plugins/builtin/sectors/sector-data.ts +18 -0
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
- package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
- package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
- package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
- package/src/plugins/builtin/world-indices/index.tsx +410 -0
- package/src/plugins/builtin/world-indices/indices.ts +52 -0
- package/src/plugins/builtin/yield-curve/index.tsx +242 -0
- package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
- package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
- package/src/plugins/catalog.ts +22 -0
- package/src/plugins/gridlock-notification.ts +15 -0
- package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
- package/src/plugins/ibkr/index.test.tsx +1 -1
- package/src/plugins/ibkr/index.tsx +1 -1
- package/src/plugins/ibkr/trade-tab.tsx +23 -5
- package/src/plugins/ibkr/trade-utils.test.ts +1 -1
- package/src/plugins/ibkr/trade-utils.ts +7 -6
- package/src/plugins/ibkr/trading-pane.tsx +8 -7
- package/src/plugins/plugin-runtime.test.tsx +2 -0
- package/src/plugins/plugin-runtime.tsx +10 -0
- package/src/plugins/prediction-markets/controller-data.ts +8 -0
- package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
- package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
- package/src/plugins/prediction-markets/services/fetch.ts +13 -7
- package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
- package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
- package/src/plugins/prediction-markets/test-helpers.tsx +2 -0
- package/src/plugins/registry.ts +22 -13
- package/src/sources/provider-router.test.ts +3 -2
- package/src/sources/sec-edgar.ts +13 -1
- package/src/sources/yahoo-finance.ts +75 -1
- package/src/state/app-context-selector.test.tsx +17 -1
- package/src/state/app-context.test.tsx +24 -0
- package/src/state/app-context.tsx +29 -6
- package/src/theme/colors.ts +5 -0
- package/src/theme/themes.ts +25 -0
- package/src/types/config.test.ts +107 -0
- package/src/types/config.ts +30 -1
- package/src/types/data-provider.ts +13 -0
- package/src/types/plugin.ts +35 -1
- package/src/utils/api-client.test.ts +90 -0
- package/src/utils/api-client.ts +56 -16
- package/src/utils/format.test.ts +11 -1
- package/src/utils/format.ts +86 -8
- package/src/utils/inline-content-tokenizer.test.ts +22 -0
- package/src/utils/inline-content-tokenizer.ts +76 -0
- package/src/utils/link-tokenizer.test.ts +27 -0
- package/src/utils/link-tokenizer.ts +93 -0
- package/src/utils/market-format.test.ts +109 -0
- package/src/utils/market-format.ts +299 -0
- package/src/utils/market-status.ts +3 -2
- package/src/utils/selection-clipboard.test.ts +43 -0
- package/src/utils/selection-clipboard.ts +88 -0
- package/src/utils/terminal-input-reset.test.ts +50 -0
- package/src/utils/terminal-input-reset.ts +24 -0
- package/src/utils/throttled-fetch.test.ts +145 -0
- package/src/utils/throttled-fetch.ts +212 -0
- package/src/utils/ticker-tokenizer.test.ts +31 -0
- package/src/utils/ticker-tokenizer.ts +7 -42
- 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
|
|
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.
|
|
423
|
-
|
|
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(
|
|
465
|
+
expect(notifications).toEqual([{ body: "server offline", type: "error" }]);
|
|
445
466
|
});
|
|
446
467
|
|
|
447
|
-
test("tracks unread mentions and
|
|
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
|
|
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.
|
|
466
|
-
|
|
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(
|
|
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
|
|
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.
|
|
499
|
-
|
|
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(
|
|
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
|
|
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.
|
|
536
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
118
|
-
this.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 {
|