gloomberb 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/app.tsx +184 -69
- 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 +296 -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 -1
- package/src/components/layout/header.tsx +5 -4
- package/src/components/layout/pane-sizing.ts +6 -0
- package/src/components/layout/pane.tsx +5 -1
- package/src/components/layout/shell.test.tsx +263 -32
- package/src/components/layout/shell.tsx +10 -6
- 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 +62 -2
- package/src/components/toggle-list.tsx +15 -2
- package/src/components/ui/data-table.tsx +82 -5
- 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 +92 -18
- package/src/plugins/builtin/chat-controller.ts +40 -11
- package/src/plugins/builtin/chat.test.tsx +715 -4
- package/src/plugins/builtin/chat.tsx +420 -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 +33 -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/gateway-service.test.ts +22 -0
- package/src/plugins/ibkr/gateway-service.ts +6 -0
- package/src/plugins/ibkr/index.test.tsx +1 -1
- package/src/plugins/ibkr/index.tsx +8 -2
- 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 +129 -14
- package/src/plugins/prediction-markets/controller.ts +1 -1
- package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
- package/src/plugins/prediction-markets/index.tsx +5 -1
- package/src/plugins/prediction-markets/pane.test.tsx +78 -0
- package/src/plugins/prediction-markets/pane.tsx +7 -3
- package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
- package/src/plugins/prediction-markets/services/fetch.ts +17 -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 +22 -5
- package/src/plugins/registry.ts +45 -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/updater.test.ts +2 -0
- package/src/utils/api-client.ts +12 -0
- 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";
|
|
@@ -84,6 +84,7 @@ class MemoryPersistence implements PluginPersistence {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
afterEach(() => {
|
|
87
|
+
apiClient.dispose();
|
|
87
88
|
apiClient.setSessionToken(null);
|
|
88
89
|
apiClient.connectChannel = originalConnectChannel;
|
|
89
90
|
apiClient.getSession = originalGetSession;
|
|
@@ -203,6 +204,35 @@ describe("ChatController", () => {
|
|
|
203
204
|
controller.reset(true);
|
|
204
205
|
});
|
|
205
206
|
|
|
207
|
+
test("dispose stops verification polling and closes the live connection", () => {
|
|
208
|
+
const controller = new ChatController();
|
|
209
|
+
let closed = false;
|
|
210
|
+
|
|
211
|
+
apiClient.setSessionToken("token-123");
|
|
212
|
+
(controller as any).user = { id: "u1", username: "vince", emailVerified: false };
|
|
213
|
+
(controller as any).syncVerificationPolling();
|
|
214
|
+
expect((controller as any).verificationPollTimer).not.toBeNull();
|
|
215
|
+
|
|
216
|
+
(controller as any).user = { id: "u1", username: "vince", emailVerified: true };
|
|
217
|
+
apiClient.connectChannel = () => ({
|
|
218
|
+
send: async () => {
|
|
219
|
+
throw new Error("not implemented");
|
|
220
|
+
},
|
|
221
|
+
close: () => {
|
|
222
|
+
closed = true;
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
controller.ensureConnection();
|
|
227
|
+
expect((controller as any).wsConnection).not.toBeNull();
|
|
228
|
+
|
|
229
|
+
controller.dispose();
|
|
230
|
+
|
|
231
|
+
expect(closed).toBe(true);
|
|
232
|
+
expect((controller as any).verificationPollTimer).toBeNull();
|
|
233
|
+
expect((controller as any).wsConnection).toBeNull();
|
|
234
|
+
});
|
|
235
|
+
|
|
206
236
|
test("keeps the cached session when session refresh fails transiently", async () => {
|
|
207
237
|
const persistence = new MemoryPersistence();
|
|
208
238
|
const controller = new ChatController();
|
|
@@ -382,15 +412,15 @@ describe("ChatController", () => {
|
|
|
382
412
|
test("marks a pending message as failed when sending errors", async () => {
|
|
383
413
|
const persistence = new MemoryPersistence();
|
|
384
414
|
const controller = new ChatController();
|
|
385
|
-
const
|
|
415
|
+
const notifications: AppNotificationRequest[] = [];
|
|
386
416
|
|
|
387
417
|
persistence.setState("session", {
|
|
388
418
|
sessionToken: "token-123",
|
|
389
419
|
user: { id: "u1", username: "vince", emailVerified: true },
|
|
390
420
|
}, { schemaVersion: 1 });
|
|
391
421
|
|
|
392
|
-
controller.
|
|
393
|
-
|
|
422
|
+
controller.setNotifier((notification) => {
|
|
423
|
+
notifications.push(notification);
|
|
394
424
|
});
|
|
395
425
|
controller.attachPersistence(persistence);
|
|
396
426
|
|
|
@@ -411,13 +441,13 @@ describe("ChatController", () => {
|
|
|
411
441
|
clientStatus: "failed",
|
|
412
442
|
clientError: "server offline",
|
|
413
443
|
});
|
|
414
|
-
expect(
|
|
444
|
+
expect(notifications).toEqual([{ body: "server offline", type: "error" }]);
|
|
415
445
|
});
|
|
416
446
|
|
|
417
|
-
test("tracks unread mentions and
|
|
447
|
+
test("tracks unread mentions and emits an app notification while chat is closed", () => {
|
|
418
448
|
const persistence = new MemoryPersistence();
|
|
419
449
|
const controller = new ChatController();
|
|
420
|
-
const
|
|
450
|
+
const notifications: AppNotificationRequest[] = [];
|
|
421
451
|
const message: ChatMessage = {
|
|
422
452
|
id: "m1",
|
|
423
453
|
channelId: "everyone",
|
|
@@ -432,25 +462,64 @@ describe("ChatController", () => {
|
|
|
432
462
|
user: { id: "u1", username: "vince", emailVerified: true },
|
|
433
463
|
}, { schemaVersion: 1 });
|
|
434
464
|
|
|
435
|
-
controller.
|
|
436
|
-
|
|
465
|
+
controller.setNotifier((notification) => {
|
|
466
|
+
notifications.push(notification);
|
|
437
467
|
});
|
|
438
468
|
controller.attachPersistence(persistence);
|
|
439
469
|
|
|
440
470
|
(controller as any).mergeMessages([message]);
|
|
441
471
|
|
|
442
472
|
expect(controller.getSnapshot().unreadMentionCount).toBe(1);
|
|
443
|
-
expect(
|
|
473
|
+
expect(notifications).toEqual([{
|
|
474
|
+
title: "Gloomberb chat",
|
|
475
|
+
body: "@bob mentioned you: hey @Vince can you take a look?",
|
|
476
|
+
type: "info",
|
|
477
|
+
desktop: "when-inactive",
|
|
478
|
+
}]);
|
|
444
479
|
expect(persistence.getState<{ lastCursor: string | null; lastViewedMessageId: string | null }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
|
|
445
480
|
lastCursor: "m1",
|
|
446
481
|
lastViewedMessageId: null,
|
|
447
482
|
});
|
|
448
483
|
});
|
|
449
484
|
|
|
485
|
+
test("keeps emitting mention notifications while the app is backgrounded", () => {
|
|
486
|
+
const persistence = new MemoryPersistence();
|
|
487
|
+
const controller = new ChatController();
|
|
488
|
+
const notifications: AppNotificationRequest[] = [];
|
|
489
|
+
const message: ChatMessage = {
|
|
490
|
+
id: "m1",
|
|
491
|
+
channelId: "everyone",
|
|
492
|
+
content: "hey @vince",
|
|
493
|
+
replyToId: null,
|
|
494
|
+
createdAt: "2026-03-28T00:00:00.000Z",
|
|
495
|
+
user: { id: "u2", username: "bob", displayName: "Bob" },
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
persistence.setState("session", {
|
|
499
|
+
sessionToken: "token-123",
|
|
500
|
+
user: { id: "u1", username: "vince", emailVerified: true },
|
|
501
|
+
}, { schemaVersion: 1 });
|
|
502
|
+
|
|
503
|
+
controller.setNotifier((notification) => {
|
|
504
|
+
notifications.push(notification);
|
|
505
|
+
});
|
|
506
|
+
controller.attachPersistence(persistence);
|
|
507
|
+
controller.setAppActive(false);
|
|
508
|
+
|
|
509
|
+
(controller as any).mergeMessages([message]);
|
|
510
|
+
|
|
511
|
+
expect(notifications).toEqual([{
|
|
512
|
+
title: "Gloomberb chat",
|
|
513
|
+
body: "@bob mentioned you: hey @vince",
|
|
514
|
+
type: "info",
|
|
515
|
+
desktop: "when-inactive",
|
|
516
|
+
}]);
|
|
517
|
+
});
|
|
518
|
+
|
|
450
519
|
test("marks mentions viewed when a chat view opens", () => {
|
|
451
520
|
const persistence = new MemoryPersistence();
|
|
452
521
|
const controller = new ChatController();
|
|
453
|
-
const
|
|
522
|
+
const notifications: AppNotificationRequest[] = [];
|
|
454
523
|
const message: ChatMessage = {
|
|
455
524
|
id: "m1",
|
|
456
525
|
channelId: "everyone",
|
|
@@ -465,8 +534,8 @@ describe("ChatController", () => {
|
|
|
465
534
|
user: { id: "u1", username: "vince", emailVerified: true },
|
|
466
535
|
}, { schemaVersion: 1 });
|
|
467
536
|
|
|
468
|
-
controller.
|
|
469
|
-
|
|
537
|
+
controller.setNotifier((notification) => {
|
|
538
|
+
notifications.push(notification);
|
|
470
539
|
});
|
|
471
540
|
controller.attachPersistence(persistence);
|
|
472
541
|
|
|
@@ -477,7 +546,12 @@ describe("ChatController", () => {
|
|
|
477
546
|
const detachView = controller.attachView();
|
|
478
547
|
|
|
479
548
|
expect(controller.getSnapshot().unreadMentionCount).toBe(0);
|
|
480
|
-
expect(
|
|
549
|
+
expect(notifications).toEqual([{
|
|
550
|
+
title: "Gloomberb chat",
|
|
551
|
+
body: "@bob mentioned you: hey @vince",
|
|
552
|
+
type: "info",
|
|
553
|
+
desktop: "when-inactive",
|
|
554
|
+
}]);
|
|
481
555
|
expect(persistence.getState<{ lastViewedMessageId: string | null }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
|
|
482
556
|
lastViewedMessageId: "m1",
|
|
483
557
|
});
|
|
@@ -487,7 +561,7 @@ describe("ChatController", () => {
|
|
|
487
561
|
test("does not keep mentions unread while a chat view is already open", () => {
|
|
488
562
|
const persistence = new MemoryPersistence();
|
|
489
563
|
const controller = new ChatController();
|
|
490
|
-
const
|
|
564
|
+
const notifications: AppNotificationRequest[] = [];
|
|
491
565
|
const message: ChatMessage = {
|
|
492
566
|
id: "m1",
|
|
493
567
|
channelId: "everyone",
|
|
@@ -502,8 +576,8 @@ describe("ChatController", () => {
|
|
|
502
576
|
user: { id: "u1", username: "vince", emailVerified: true },
|
|
503
577
|
}, { schemaVersion: 1 });
|
|
504
578
|
|
|
505
|
-
controller.
|
|
506
|
-
|
|
579
|
+
controller.setNotifier((notification) => {
|
|
580
|
+
notifications.push(notification);
|
|
507
581
|
});
|
|
508
582
|
controller.attachPersistence(persistence);
|
|
509
583
|
const detachView = controller.attachView();
|
|
@@ -511,7 +585,7 @@ describe("ChatController", () => {
|
|
|
511
585
|
(controller as any).mergeMessages([message]);
|
|
512
586
|
|
|
513
587
|
expect(controller.getSnapshot().unreadMentionCount).toBe(0);
|
|
514
|
-
expect(
|
|
588
|
+
expect(notifications).toEqual([]);
|
|
515
589
|
expect(persistence.getState<{ lastViewedMessageId: string | null }>("channel:everyone", { schemaVersion: 1 })).toMatchObject({
|
|
516
590
|
lastViewedMessageId: "m1",
|
|
517
591
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { PluginPersistence, PluginResumeState } from "../../types/plugin";
|
|
1
|
+
import type { AppNotificationRequest, PluginPersistence, PluginResumeState } from "../../types/plugin";
|
|
2
2
|
import { apiClient, type ChatMessage, type PersistedAuthUser } from "../../utils/api-client";
|
|
3
|
+
import { debugLog } from "../../utils/debug-log";
|
|
3
4
|
import { toTimestampMillis } from "../../utils/timestamp";
|
|
4
5
|
|
|
5
6
|
const SESSION_STATE_KEY = "session";
|
|
@@ -18,6 +19,7 @@ const VERIFICATION_POLL_MS = 5_000;
|
|
|
18
19
|
const ISO_TIMESTAMP_CURSOR = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
|
|
19
20
|
const USERNAME_MENTION = /(^|[^A-Za-z0-9_])@([A-Za-z][A-Za-z0-9_]{2,29})(?![A-Za-z0-9_])/g;
|
|
20
21
|
const PENDING_RECONCILE_WINDOW_MS = 2 * 60_000;
|
|
22
|
+
const chatLog = debugLog.createLogger("chat-controller");
|
|
21
23
|
|
|
22
24
|
interface PersistedSessionState {
|
|
23
25
|
sessionToken: string | null;
|
|
@@ -47,7 +49,6 @@ export interface ChatControllerSnapshot {
|
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
type ChatConnection = { send: (content: string, replyToId?: string) => Promise<ChatMessage>; close: () => void };
|
|
50
|
-
type ChatToastOptions = { duration?: number; type?: "info" | "success" | "error" };
|
|
51
52
|
|
|
52
53
|
function normalizeUsername(username: string | null | undefined): string | null {
|
|
53
54
|
const trimmed = username?.trim();
|
|
@@ -80,7 +81,9 @@ function isLegacyTimestampCursor(cursor: string | null): boolean {
|
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
function compareMessages(a: ChatMessage, b: ChatMessage): number {
|
|
83
|
-
|
|
84
|
+
// Preserve server/insertion order inside same-timestamp batches so the bottom-most
|
|
85
|
+
// rendered message stays the one selected first from the composer.
|
|
86
|
+
return a.createdAt.localeCompare(b.createdAt);
|
|
84
87
|
}
|
|
85
88
|
|
|
86
89
|
export class ChatController {
|
|
@@ -103,7 +106,7 @@ export class ChatController {
|
|
|
103
106
|
private verificationPollTimer: ReturnType<typeof setInterval> | null = null;
|
|
104
107
|
private openViewCount = 0;
|
|
105
108
|
private pendingMessageSeq = 0;
|
|
106
|
-
private
|
|
109
|
+
private notifyFn: (notification: AppNotificationRequest) => void = () => {};
|
|
107
110
|
private listeners = new Set<(snapshot: ChatControllerSnapshot) => void>();
|
|
108
111
|
|
|
109
112
|
attachPersistence(persistence: PluginPersistence, resume?: PluginResumeState): void {
|
|
@@ -112,8 +115,8 @@ export class ChatController {
|
|
|
112
115
|
this.hydrate();
|
|
113
116
|
}
|
|
114
117
|
|
|
115
|
-
|
|
116
|
-
this.
|
|
118
|
+
setNotifier(notify: (notification: AppNotificationRequest) => void): void {
|
|
119
|
+
this.notifyFn = notify;
|
|
117
120
|
}
|
|
118
121
|
|
|
119
122
|
hydrate(): void {
|
|
@@ -259,6 +262,7 @@ export class ChatController {
|
|
|
259
262
|
}
|
|
260
263
|
|
|
261
264
|
setDraft(draft: string): void {
|
|
265
|
+
if (draft === this.draft) return;
|
|
262
266
|
this.draft = draft;
|
|
263
267
|
this.persistChannelState();
|
|
264
268
|
this.emit();
|
|
@@ -326,6 +330,22 @@ export class ChatController {
|
|
|
326
330
|
this.emit();
|
|
327
331
|
}
|
|
328
332
|
|
|
333
|
+
dispose(): void {
|
|
334
|
+
chatLog.info("dispose controller", {
|
|
335
|
+
listeners: this.listeners.size,
|
|
336
|
+
hasConnection: !!this.wsConnection,
|
|
337
|
+
});
|
|
338
|
+
this.stopVerificationPolling();
|
|
339
|
+
this.wsConnection?.close();
|
|
340
|
+
this.wsConnection = null;
|
|
341
|
+
this.wsConnected = false;
|
|
342
|
+
this.messagesLoading = false;
|
|
343
|
+
this.refreshMessagesPromise = null;
|
|
344
|
+
this.openViewCount = 0;
|
|
345
|
+
this.notifyFn = () => {};
|
|
346
|
+
this.listeners.clear();
|
|
347
|
+
}
|
|
348
|
+
|
|
329
349
|
send(content: string, replyToId?: string): void {
|
|
330
350
|
if (!this.user?.emailVerified || !apiClient.getSessionToken()) return;
|
|
331
351
|
if (!this.wsConnection && this.user?.emailVerified && apiClient.getSessionToken()) {
|
|
@@ -333,7 +353,7 @@ export class ChatController {
|
|
|
333
353
|
}
|
|
334
354
|
const connection = this.wsConnection;
|
|
335
355
|
if (!connection) {
|
|
336
|
-
this.
|
|
356
|
+
this.notifyFn({ body: "Unable to send message right now.", type: "error" });
|
|
337
357
|
return;
|
|
338
358
|
}
|
|
339
359
|
|
|
@@ -355,7 +375,7 @@ export class ChatController {
|
|
|
355
375
|
: entry
|
|
356
376
|
));
|
|
357
377
|
this.emit();
|
|
358
|
-
this.
|
|
378
|
+
this.notifyFn({ body: errorMessage, type: "error" });
|
|
359
379
|
});
|
|
360
380
|
}
|
|
361
381
|
|
|
@@ -513,13 +533,22 @@ export class ChatController {
|
|
|
513
533
|
|
|
514
534
|
const freshMentions = this.getMentionMessages(messages);
|
|
515
535
|
if (freshMentions.length === 0) return;
|
|
516
|
-
if (!this.appActive) return;
|
|
517
536
|
|
|
518
537
|
if (freshMentions.length === 1) {
|
|
519
|
-
this.
|
|
538
|
+
this.notifyFn({
|
|
539
|
+
title: "Gloomberb chat",
|
|
540
|
+
body: formatMentionToast(freshMentions[0]!),
|
|
541
|
+
type: "info",
|
|
542
|
+
desktop: "when-inactive",
|
|
543
|
+
});
|
|
520
544
|
return;
|
|
521
545
|
}
|
|
522
|
-
this.
|
|
546
|
+
this.notifyFn({
|
|
547
|
+
title: "Gloomberb chat",
|
|
548
|
+
body: `${freshMentions.length} new mentions in #everyone.`,
|
|
549
|
+
type: "info",
|
|
550
|
+
desktop: "when-inactive",
|
|
551
|
+
});
|
|
523
552
|
}
|
|
524
553
|
|
|
525
554
|
private getUnreadMentionCount(): number {
|