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.
- 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/header.tsx +2 -1
- package/src/components/layout/shell.test.tsx +186 -27
- package/src/components/layout/shell.tsx +5 -4
- 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 +62 -18
- package/src/plugins/builtin/chat-controller.ts +23 -12
- package/src/plugins/builtin/chat.test.tsx +715 -4
- package/src/plugins/builtin/chat.tsx +415 -119
- 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/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";
|
|
@@ -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
|
|
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.
|
|
423
|
-
|
|
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(
|
|
444
|
+
expect(notifications).toEqual([{ body: "server offline", type: "error" }]);
|
|
445
445
|
});
|
|
446
446
|
|
|
447
|
-
test("tracks unread mentions and
|
|
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
|
|
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.
|
|
466
|
-
|
|
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(
|
|
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
|
|
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.
|
|
499
|
-
|
|
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(
|
|
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
|
|
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.
|
|
536
|
-
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 {
|