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,54 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { computeSMA, computeEMA } from "./moving-averages";
|
|
3
|
+
|
|
4
|
+
describe("computeSMA", () => {
|
|
5
|
+
const closes = [10, 11, 12, 13, 14, 15, 16, 17, 18, 19];
|
|
6
|
+
|
|
7
|
+
it("starts at index period-1 with correct value", () => {
|
|
8
|
+
const result = computeSMA(closes, 5);
|
|
9
|
+
expect(result[0].index).toBe(4);
|
|
10
|
+
expect(result[0].value).toBe(12); // (10+11+12+13+14)/5 = 60/5 = 12
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("produces correct number of points", () => {
|
|
14
|
+
const result = computeSMA(closes, 5);
|
|
15
|
+
expect(result.length).toBe(6); // 10 - 5 + 1
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("returns [] when period > data length", () => {
|
|
19
|
+
expect(computeSMA([1, 2, 3], 5)).toEqual([]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns [] for empty input", () => {
|
|
23
|
+
expect(computeSMA([], 5)).toEqual([]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("handles period equal to data length", () => {
|
|
27
|
+
const result = computeSMA(closes, 10);
|
|
28
|
+
expect(result.length).toBe(1);
|
|
29
|
+
expect(result[0].index).toBe(9);
|
|
30
|
+
expect(result[0].value).toBeCloseTo(14.5);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("computeEMA", () => {
|
|
35
|
+
it("seeds with SMA and applies multiplier correctly", () => {
|
|
36
|
+
// period 3, k = 2/(3+1) = 0.5
|
|
37
|
+
// seed = (10+11+12)/3 = 11 at index 2
|
|
38
|
+
// EMA[3] = 13*0.5 + 11*0.5 = 12
|
|
39
|
+
const closes = [10, 11, 12, 13, 14, 15];
|
|
40
|
+
const result = computeEMA(closes, 3);
|
|
41
|
+
expect(result[0].index).toBe(2);
|
|
42
|
+
expect(result[0].value).toBeCloseTo(11);
|
|
43
|
+
expect(result[1].index).toBe(3);
|
|
44
|
+
expect(result[1].value).toBeCloseTo(12);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("returns [] when period > data length", () => {
|
|
48
|
+
expect(computeEMA([1, 2], 5)).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("returns [] for empty input", () => {
|
|
52
|
+
expect(computeEMA([], 3)).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { OverlayPoint } from "./types";
|
|
2
|
+
|
|
3
|
+
export function computeSMA(closes: number[], period: number): OverlayPoint[] {
|
|
4
|
+
if (closes.length < period || period <= 0) return [];
|
|
5
|
+
|
|
6
|
+
const result: OverlayPoint[] = [];
|
|
7
|
+
let sum = 0;
|
|
8
|
+
|
|
9
|
+
for (let i = 0; i < period; i++) {
|
|
10
|
+
sum += closes[i];
|
|
11
|
+
}
|
|
12
|
+
result.push({ index: period - 1, value: sum / period });
|
|
13
|
+
|
|
14
|
+
for (let i = period; i < closes.length; i++) {
|
|
15
|
+
sum += closes[i] - closes[i - period];
|
|
16
|
+
result.push({ index: i, value: sum / period });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function computeEMA(closes: number[], period: number): OverlayPoint[] {
|
|
23
|
+
if (closes.length < period || period <= 0) return [];
|
|
24
|
+
|
|
25
|
+
const k = 2 / (period + 1);
|
|
26
|
+
const result: OverlayPoint[] = [];
|
|
27
|
+
|
|
28
|
+
// Seed with SMA of first `period` values
|
|
29
|
+
let sum = 0;
|
|
30
|
+
for (let i = 0; i < period; i++) {
|
|
31
|
+
sum += closes[i];
|
|
32
|
+
}
|
|
33
|
+
let ema = sum / period;
|
|
34
|
+
result.push({ index: period - 1, value: ema });
|
|
35
|
+
|
|
36
|
+
for (let i = period; i < closes.length; i++) {
|
|
37
|
+
ema = closes[i] * k + ema * (1 - k);
|
|
38
|
+
result.push({ index: i, value: ema });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildIndicatorConfigFromSelection,
|
|
4
|
+
CURRENT_CHART_INDICATORS_CONFIG_VERSION,
|
|
5
|
+
normalizeChartIndicatorSelection,
|
|
6
|
+
resolveChartIndicatorSelection,
|
|
7
|
+
} from "./options";
|
|
8
|
+
|
|
9
|
+
describe("chart indicator options", () => {
|
|
10
|
+
test("normalizes persisted indicator selections into supported option order", () => {
|
|
11
|
+
expect(normalizeChartIndicatorSelection(["ema20", "unknown", "volume", "sma20", "ema20"])).toEqual([
|
|
12
|
+
"volume",
|
|
13
|
+
"sma20",
|
|
14
|
+
"ema20",
|
|
15
|
+
]);
|
|
16
|
+
expect(normalizeChartIndicatorSelection("sma20")).toEqual([]);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("keeps volume enabled by default and migrates legacy selections", () => {
|
|
20
|
+
expect(resolveChartIndicatorSelection(undefined, undefined)).toEqual(["volume"]);
|
|
21
|
+
expect(resolveChartIndicatorSelection(["sma20"], undefined)).toEqual(["volume", "sma20"]);
|
|
22
|
+
expect(resolveChartIndicatorSelection(["sma20"], CURRENT_CHART_INDICATORS_CONFIG_VERSION)).toEqual(["sma20"]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("builds chart indicator config from selected overlay ids", () => {
|
|
26
|
+
expect(buildIndicatorConfigFromSelection(["volume", "sma20", "sma50", "ema20", "bollinger20"])).toEqual({
|
|
27
|
+
sma: [20, 50],
|
|
28
|
+
ema: [20],
|
|
29
|
+
bollinger: { period: 20, stdDev: 2 },
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { IndicatorConfig } from "./types";
|
|
2
|
+
|
|
3
|
+
export const CHART_INDICATORS_PLUGIN_CONFIG_KEY = "chartIndicators";
|
|
4
|
+
export const CHART_INDICATORS_PLUGIN_CONFIG_VERSION_KEY = "chartIndicatorsVersion";
|
|
5
|
+
export const CURRENT_CHART_INDICATORS_CONFIG_VERSION = 2;
|
|
6
|
+
|
|
7
|
+
export type ChartIndicatorId =
|
|
8
|
+
| "volume"
|
|
9
|
+
| "sma20"
|
|
10
|
+
| "sma50"
|
|
11
|
+
| "sma200"
|
|
12
|
+
| "ema20"
|
|
13
|
+
| "bollinger20";
|
|
14
|
+
|
|
15
|
+
export interface ChartIndicatorOption {
|
|
16
|
+
id: ChartIndicatorId;
|
|
17
|
+
label: string;
|
|
18
|
+
compactLabel: string;
|
|
19
|
+
description: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const CHART_INDICATOR_OPTIONS: ChartIndicatorOption[] = [
|
|
23
|
+
{
|
|
24
|
+
id: "volume",
|
|
25
|
+
label: "Volume",
|
|
26
|
+
compactLabel: "VOL",
|
|
27
|
+
description: "Volume bars below the price chart.",
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: "sma20",
|
|
31
|
+
label: "SMA 20",
|
|
32
|
+
compactLabel: "S20",
|
|
33
|
+
description: "20-period simple moving average.",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
id: "sma50",
|
|
37
|
+
label: "SMA 50",
|
|
38
|
+
compactLabel: "S50",
|
|
39
|
+
description: "50-period simple moving average.",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
id: "sma200",
|
|
43
|
+
label: "SMA 200",
|
|
44
|
+
compactLabel: "S200",
|
|
45
|
+
description: "200-period simple moving average.",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "ema20",
|
|
49
|
+
label: "EMA 20",
|
|
50
|
+
compactLabel: "E20",
|
|
51
|
+
description: "20-period exponential moving average.",
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: "bollinger20",
|
|
55
|
+
label: "Bollinger 20",
|
|
56
|
+
compactLabel: "BB20",
|
|
57
|
+
description: "20-period Bollinger Bands at two standard deviations.",
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const CHART_INDICATOR_IDS = new Set<string>(CHART_INDICATOR_OPTIONS.map((option) => option.id));
|
|
62
|
+
export const DEFAULT_CHART_INDICATOR_SELECTION: ChartIndicatorId[] = ["volume"];
|
|
63
|
+
|
|
64
|
+
export function isChartIndicatorId(value: unknown): value is ChartIndicatorId {
|
|
65
|
+
return typeof value === "string" && CHART_INDICATOR_IDS.has(value);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function normalizeChartIndicatorSelection(value: unknown): ChartIndicatorId[] {
|
|
69
|
+
if (!Array.isArray(value)) return [];
|
|
70
|
+
|
|
71
|
+
const selected = new Set(value.filter(isChartIndicatorId));
|
|
72
|
+
return CHART_INDICATOR_OPTIONS
|
|
73
|
+
.map((option) => option.id)
|
|
74
|
+
.filter((id) => selected.has(id));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function resolveChartIndicatorSelection(value: unknown, version: unknown): ChartIndicatorId[] {
|
|
78
|
+
if (!Array.isArray(value)) return [...DEFAULT_CHART_INDICATOR_SELECTION];
|
|
79
|
+
|
|
80
|
+
const normalized = normalizeChartIndicatorSelection(value);
|
|
81
|
+
if (version === CURRENT_CHART_INDICATORS_CONFIG_VERSION) return normalized;
|
|
82
|
+
|
|
83
|
+
return normalizeChartIndicatorSelection([
|
|
84
|
+
...DEFAULT_CHART_INDICATOR_SELECTION,
|
|
85
|
+
...normalized,
|
|
86
|
+
]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function buildIndicatorConfigFromSelection(selection: readonly ChartIndicatorId[]): IndicatorConfig {
|
|
90
|
+
const selected = new Set(selection);
|
|
91
|
+
const sma: number[] = [];
|
|
92
|
+
const ema: number[] = [];
|
|
93
|
+
const config: IndicatorConfig = {};
|
|
94
|
+
|
|
95
|
+
if (selected.has("sma20")) sma.push(20);
|
|
96
|
+
if (selected.has("sma50")) sma.push(50);
|
|
97
|
+
if (selected.has("sma200")) sma.push(200);
|
|
98
|
+
if (selected.has("ema20")) ema.push(20);
|
|
99
|
+
|
|
100
|
+
if (sma.length > 0) config.sma = sma;
|
|
101
|
+
if (ema.length > 0) config.ema = ema;
|
|
102
|
+
if (selected.has("bollinger20")) {
|
|
103
|
+
config.bollinger = { period: 20, stdDev: 2 };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return config;
|
|
107
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { computeRSI, computeMACD } from "./oscillators";
|
|
3
|
+
|
|
4
|
+
describe("computeRSI", () => {
|
|
5
|
+
it("produces at least one result with 15 data points and period 14", () => {
|
|
6
|
+
const closes = [44, 46, 44, 45, 47, 43, 44, 46, 48, 47, 45, 46, 47, 48, 50];
|
|
7
|
+
const result = computeRSI(closes, 14);
|
|
8
|
+
expect(result.length).toBeGreaterThanOrEqual(1);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("all values are between 0 and 100", () => {
|
|
12
|
+
const closes = [44, 46, 44, 45, 47, 43, 44, 46, 48, 47, 45, 46, 47, 48, 50];
|
|
13
|
+
const result = computeRSI(closes, 14);
|
|
14
|
+
for (const p of result) {
|
|
15
|
+
expect(p.value).toBeGreaterThanOrEqual(0);
|
|
16
|
+
expect(p.value).toBeLessThanOrEqual(100);
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("all-increasing data gives RSI > 99", () => {
|
|
21
|
+
const closes = Array.from({ length: 20 }, (_, i) => i + 1);
|
|
22
|
+
const result = computeRSI(closes, 14);
|
|
23
|
+
expect(result.length).toBeGreaterThan(0);
|
|
24
|
+
for (const p of result) {
|
|
25
|
+
expect(p.value).toBeGreaterThan(99);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("returns [] for insufficient data", () => {
|
|
30
|
+
expect(computeRSI([1, 2, 3], 14)).toEqual([]);
|
|
31
|
+
expect(computeRSI([], 14)).toEqual([]);
|
|
32
|
+
// Exactly period data points (need period+1)
|
|
33
|
+
const closes = Array.from({ length: 14 }, (_, i) => i + 1);
|
|
34
|
+
expect(computeRSI(closes, 14)).toEqual([]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("first result starts at index equal to period", () => {
|
|
38
|
+
const closes = [44, 46, 44, 45, 47, 43, 44, 46, 48, 47, 45, 46, 47, 48, 50];
|
|
39
|
+
const result = computeRSI(closes, 14);
|
|
40
|
+
expect(result[0].index).toBe(14);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("computeMACD", () => {
|
|
45
|
+
const closes50 = Array.from({ length: 50 }, (_, i) => 100 + Math.sin(i * 0.3) * 10);
|
|
46
|
+
|
|
47
|
+
it("produces non-empty macd, signal, histogram with 50 data points", () => {
|
|
48
|
+
const result = computeMACD(closes50, 12, 26, 9);
|
|
49
|
+
expect(result.macd.length).toBeGreaterThan(0);
|
|
50
|
+
expect(result.signal.length).toBeGreaterThan(0);
|
|
51
|
+
expect(result.histogram.length).toBeGreaterThan(0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("histogram[i] = macd[i] - signal[i] at matching indices", () => {
|
|
55
|
+
const result = computeMACD(closes50, 12, 26, 9);
|
|
56
|
+
const macdByIndex = new Map(result.macd.map((p) => [p.index, p.value]));
|
|
57
|
+
for (const h of result.histogram) {
|
|
58
|
+
const m = macdByIndex.get(h.index);
|
|
59
|
+
const s = result.signal.find((p) => p.index === h.index);
|
|
60
|
+
expect(m).toBeDefined();
|
|
61
|
+
expect(s).toBeDefined();
|
|
62
|
+
expect(h.value).toBeCloseTo(m! - s!.value, 10);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("returns empty result for insufficient data", () => {
|
|
67
|
+
const result = computeMACD([1, 2, 3], 12, 26, 9);
|
|
68
|
+
expect(result.macd.length).toBe(0);
|
|
69
|
+
expect(result.signal.length).toBe(0);
|
|
70
|
+
expect(result.histogram.length).toBe(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("returns empty result for empty input", () => {
|
|
74
|
+
const result = computeMACD([], 12, 26, 9);
|
|
75
|
+
expect(result.macd.length).toBe(0);
|
|
76
|
+
expect(result.signal.length).toBe(0);
|
|
77
|
+
expect(result.histogram.length).toBe(0);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { OscillatorPoint, MacdResult } from "./types";
|
|
2
|
+
import { computeEMA } from "./moving-averages";
|
|
3
|
+
|
|
4
|
+
export function computeRSI(closes: number[], period = 14): OscillatorPoint[] {
|
|
5
|
+
// Need at least period+1 data points (period changes)
|
|
6
|
+
if (closes.length < period + 1) return [];
|
|
7
|
+
|
|
8
|
+
const result: OscillatorPoint[] = [];
|
|
9
|
+
|
|
10
|
+
// Compute initial average gain/loss over first `period` changes
|
|
11
|
+
let avgGain = 0;
|
|
12
|
+
let avgLoss = 0;
|
|
13
|
+
for (let i = 1; i <= period; i++) {
|
|
14
|
+
const change = closes[i] - closes[i - 1];
|
|
15
|
+
if (change > 0) avgGain += change;
|
|
16
|
+
else avgLoss += -change;
|
|
17
|
+
}
|
|
18
|
+
avgGain /= period;
|
|
19
|
+
avgLoss /= period;
|
|
20
|
+
|
|
21
|
+
const rs = avgLoss === 0 ? Infinity : avgGain / avgLoss;
|
|
22
|
+
const rsi = avgLoss === 0 ? 100 : 100 - 100 / (1 + rs);
|
|
23
|
+
result.push({ index: period, value: rsi });
|
|
24
|
+
|
|
25
|
+
// Wilder's smoothing for subsequent values
|
|
26
|
+
for (let i = period + 1; i < closes.length; i++) {
|
|
27
|
+
const change = closes[i] - closes[i - 1];
|
|
28
|
+
const gain = change > 0 ? change : 0;
|
|
29
|
+
const loss = change < 0 ? -change : 0;
|
|
30
|
+
avgGain = (avgGain * (period - 1) + gain) / period;
|
|
31
|
+
avgLoss = (avgLoss * (period - 1) + loss) / period;
|
|
32
|
+
|
|
33
|
+
const rs = avgLoss === 0 ? Infinity : avgGain / avgLoss;
|
|
34
|
+
const rsi = avgLoss === 0 ? 100 : 100 - 100 / (1 + rs);
|
|
35
|
+
result.push({ index: i, value: rsi });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function computeMACD(
|
|
42
|
+
closes: number[],
|
|
43
|
+
fast = 12,
|
|
44
|
+
slow = 26,
|
|
45
|
+
signal = 9
|
|
46
|
+
): MacdResult {
|
|
47
|
+
const empty: MacdResult = { macd: [], signal: [], histogram: [] };
|
|
48
|
+
|
|
49
|
+
const fastEma = computeEMA(closes, fast);
|
|
50
|
+
const slowEma = computeEMA(closes, slow);
|
|
51
|
+
|
|
52
|
+
if (fastEma.length === 0 || slowEma.length === 0) return empty;
|
|
53
|
+
|
|
54
|
+
// Build a map of index -> fast EMA value for alignment
|
|
55
|
+
const fastByIndex = new Map<number, number>();
|
|
56
|
+
for (const p of fastEma) fastByIndex.set(p.index, p.value);
|
|
57
|
+
|
|
58
|
+
// MACD line = fast EMA - slow EMA (only where both exist)
|
|
59
|
+
const macdLine: OscillatorPoint[] = [];
|
|
60
|
+
for (const p of slowEma) {
|
|
61
|
+
const fv = fastByIndex.get(p.index);
|
|
62
|
+
if (fv !== undefined) {
|
|
63
|
+
macdLine.push({ index: p.index, value: fv - p.value });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (macdLine.length < signal) return empty;
|
|
68
|
+
|
|
69
|
+
// Signal line = EMA of MACD values
|
|
70
|
+
const macdValues = macdLine.map((p) => p.value);
|
|
71
|
+
const signalEma = computeEMA(macdValues, signal);
|
|
72
|
+
|
|
73
|
+
if (signalEma.length === 0) return empty;
|
|
74
|
+
|
|
75
|
+
// Map signal EMA positions back to original indices
|
|
76
|
+
// signalEma[i].index is an offset into macdValues; macdLine[offset].index is the original index
|
|
77
|
+
const signalLine: OscillatorPoint[] = signalEma.map((p) => ({
|
|
78
|
+
index: macdLine[p.index].index,
|
|
79
|
+
value: p.value,
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
// Histogram: MACD - Signal at matching original indices
|
|
83
|
+
const macdByIndex = new Map<number, number>();
|
|
84
|
+
for (const p of macdLine) macdByIndex.set(p.index, p.value);
|
|
85
|
+
|
|
86
|
+
const histogram: OscillatorPoint[] = signalLine.map((p) => ({
|
|
87
|
+
index: p.index,
|
|
88
|
+
value: (macdByIndex.get(p.index) ?? 0) - p.value,
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
return { macd: macdLine, signal: signalLine, histogram };
|
|
92
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** A single data point for an overlay line drawn on the main chart */
|
|
2
|
+
export interface OverlayPoint {
|
|
3
|
+
index: number;
|
|
4
|
+
value: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** A single data point for an oscillator drawn in a sub-panel */
|
|
8
|
+
export interface OscillatorPoint {
|
|
9
|
+
index: number;
|
|
10
|
+
value: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** MACD has three series: macd line, signal line, histogram */
|
|
14
|
+
export interface MacdResult {
|
|
15
|
+
macd: OscillatorPoint[];
|
|
16
|
+
signal: OscillatorPoint[];
|
|
17
|
+
histogram: OscillatorPoint[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Bollinger Bands: upper, middle (SMA), lower */
|
|
21
|
+
export interface BollingerResult {
|
|
22
|
+
upper: OverlayPoint[];
|
|
23
|
+
middle: OverlayPoint[];
|
|
24
|
+
lower: OverlayPoint[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Active indicator configuration for a chart pane */
|
|
28
|
+
export interface IndicatorConfig {
|
|
29
|
+
sma?: number[]; // e.g. [20, 50, 200]
|
|
30
|
+
ema?: number[]; // e.g. [12, 26]
|
|
31
|
+
rsi?: number | null; // e.g. 14
|
|
32
|
+
macd?: { fast: number; slow: number; signal: number } | null;
|
|
33
|
+
bollinger?: { period: number; stdDev: number } | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const DEFAULT_INDICATOR_CONFIG: IndicatorConfig = {};
|
|
@@ -256,6 +256,20 @@ function projectX(index: number, count: number, left: number, right: number): nu
|
|
|
256
256
|
return lerp(left, right, index / (count - 1));
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
function projectChartX(index: number, count: number, width: number, mode: ChartRenderMode): number {
|
|
260
|
+
if (mode !== "candles" && mode !== "ohlc") {
|
|
261
|
+
return projectX(index, count, 0, Math.max(width - 1, 0));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const spacing = width / Math.max(count, 1);
|
|
265
|
+
const bodyWidth = clamp(spacing * 0.58, 2, Math.max(spacing - 1, 2));
|
|
266
|
+
const tickLength = clamp(spacing * 0.34, 2, Math.max(spacing * 0.48, 2));
|
|
267
|
+
const horizontalPad = mode === "ohlc"
|
|
268
|
+
? Math.ceil(Math.max(tickLength - 1, 0))
|
|
269
|
+
: Math.ceil(bodyWidth / 2);
|
|
270
|
+
return projectX(index, count, horizontalPad, Math.max(width - 1 - horizontalPad, horizontalPad));
|
|
271
|
+
}
|
|
272
|
+
|
|
259
273
|
function projectY(value: number, min: number, max: number, top: number, bottom: number): number {
|
|
260
274
|
const range = max - min || 1;
|
|
261
275
|
return lerp(bottom, top, (value - min) / range);
|
|
@@ -398,6 +412,42 @@ function drawVolume(
|
|
|
398
412
|
}
|
|
399
413
|
}
|
|
400
414
|
|
|
415
|
+
function drawIndicatorOverlays(
|
|
416
|
+
data: Uint8Array,
|
|
417
|
+
width: number,
|
|
418
|
+
height: number,
|
|
419
|
+
scene: ChartScene,
|
|
420
|
+
top: number,
|
|
421
|
+
bottom: number,
|
|
422
|
+
) {
|
|
423
|
+
if (!scene.indicators) return;
|
|
424
|
+
|
|
425
|
+
const drawOverlay = (points: { index: number; value: number }[], color: string) => {
|
|
426
|
+
const lineColor = parseHex(color, 0.95);
|
|
427
|
+
for (let index = 0; index < points.length - 1; index += 1) {
|
|
428
|
+
const p0 = points[index]!;
|
|
429
|
+
const p1 = points[index + 1]!;
|
|
430
|
+
const x0 = projectChartX(p0.index, scene.points.length, width, scene.mode);
|
|
431
|
+
const y0 = projectY(p0.value, scene.min, scene.max, top, bottom);
|
|
432
|
+
const x1 = projectChartX(p1.index, scene.points.length, width, scene.mode);
|
|
433
|
+
const y1 = projectY(p1.value, scene.min, scene.max, top, bottom);
|
|
434
|
+
drawLine(data, width, height, x0, y0, x1, y1, lineColor, 1.2);
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
for (const sma of scene.indicators.smaLines) {
|
|
439
|
+
drawOverlay(sma.points, sma.color);
|
|
440
|
+
}
|
|
441
|
+
for (const ema of scene.indicators.emaLines) {
|
|
442
|
+
drawOverlay(ema.points, ema.color);
|
|
443
|
+
}
|
|
444
|
+
if (scene.indicators.bollinger) {
|
|
445
|
+
drawOverlay(scene.indicators.bollinger.upper, scene.indicators.bollinger.color);
|
|
446
|
+
drawOverlay(scene.indicators.bollinger.middle, scene.indicators.bollinger.color);
|
|
447
|
+
drawOverlay(scene.indicators.bollinger.lower, scene.indicators.bollinger.color);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
401
451
|
function drawCrosshairOverlay(
|
|
402
452
|
data: Uint8Array,
|
|
403
453
|
width: number,
|
|
@@ -447,6 +497,7 @@ export function renderNativeChartBase(scene: ChartScene, pixelWidth: number, pix
|
|
|
447
497
|
break;
|
|
448
498
|
}
|
|
449
499
|
|
|
500
|
+
drawIndicatorOverlays(pixels, pixelWidth, pixelHeight, scene, layout.plotTop, layout.plotBottom);
|
|
450
501
|
drawVolume(pixels, pixelWidth, pixelHeight, scene, layout.volumeTop, layout.volumeBottom);
|
|
451
502
|
|
|
452
503
|
return { width: pixelWidth, height: pixelHeight, pixels };
|
|
@@ -206,6 +206,74 @@ describe("renderNativeChartBase", () => {
|
|
|
206
206
|
const withCursor = renderNativeChartBase(sceneWithCursor!, 120, 60);
|
|
207
207
|
expect(withCursor.pixels).toEqual(withoutCursor.pixels);
|
|
208
208
|
});
|
|
209
|
+
|
|
210
|
+
test("renders indicator overlays into the native chart bitmap", () => {
|
|
211
|
+
const palette = resolveChartPalette({
|
|
212
|
+
bg: "#112233",
|
|
213
|
+
border: "#333333",
|
|
214
|
+
borderFocused: "#ffff00",
|
|
215
|
+
text: "#ffffff",
|
|
216
|
+
textDim: "#777777",
|
|
217
|
+
positive: "#00ff00",
|
|
218
|
+
negative: "#ff0000",
|
|
219
|
+
}, "positive");
|
|
220
|
+
const points = [
|
|
221
|
+
{ date: new Date("2024-01-02"), open: 10, high: 12, low: 9, close: 11, volume: 100 },
|
|
222
|
+
{ date: new Date("2024-01-03"), open: 11, high: 13, low: 10, close: 12, volume: 120 },
|
|
223
|
+
{ date: new Date("2024-01-04"), open: 12, high: 14, low: 11, close: 13, volume: 130 },
|
|
224
|
+
];
|
|
225
|
+
const baseScene = buildChartScene(points, {
|
|
226
|
+
width: 18,
|
|
227
|
+
height: 8,
|
|
228
|
+
showVolume: false,
|
|
229
|
+
volumeHeight: 0,
|
|
230
|
+
cursorX: null,
|
|
231
|
+
cursorY: null,
|
|
232
|
+
mode: "line",
|
|
233
|
+
colors: palette,
|
|
234
|
+
});
|
|
235
|
+
const sceneWithIndicator = buildChartScene(points, {
|
|
236
|
+
width: 18,
|
|
237
|
+
height: 8,
|
|
238
|
+
showVolume: false,
|
|
239
|
+
volumeHeight: 0,
|
|
240
|
+
cursorX: null,
|
|
241
|
+
cursorY: null,
|
|
242
|
+
mode: "line",
|
|
243
|
+
colors: palette,
|
|
244
|
+
indicators: {
|
|
245
|
+
smaLines: [{
|
|
246
|
+
period: 2,
|
|
247
|
+
color: "#ff00ff",
|
|
248
|
+
points: [
|
|
249
|
+
{ index: 0, value: 9.5 },
|
|
250
|
+
{ index: 1, value: 10.5 },
|
|
251
|
+
{ index: 2, value: 11.5 },
|
|
252
|
+
],
|
|
253
|
+
}],
|
|
254
|
+
emaLines: [],
|
|
255
|
+
bollinger: null,
|
|
256
|
+
rsi: null,
|
|
257
|
+
macd: null,
|
|
258
|
+
},
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
expect(baseScene).not.toBeNull();
|
|
262
|
+
expect(sceneWithIndicator).not.toBeNull();
|
|
263
|
+
|
|
264
|
+
const baseBitmap = renderNativeChartBase(baseScene!, 120, 60);
|
|
265
|
+
const indicatorBitmap = renderNativeChartBase(sceneWithIndicator!, 120, 60);
|
|
266
|
+
expect(indicatorBitmap.pixels).not.toEqual(baseBitmap.pixels);
|
|
267
|
+
|
|
268
|
+
let magentaPixels = 0;
|
|
269
|
+
for (let offset = 0; offset < indicatorBitmap.pixels.length; offset += 4) {
|
|
270
|
+
const red = indicatorBitmap.pixels[offset]!;
|
|
271
|
+
const green = indicatorBitmap.pixels[offset + 1]!;
|
|
272
|
+
const blue = indicatorBitmap.pixels[offset + 2]!;
|
|
273
|
+
if (red > 180 && blue > 180 && green < 120) magentaPixels += 1;
|
|
274
|
+
}
|
|
275
|
+
expect(magentaPixels).toBeGreaterThan(0);
|
|
276
|
+
});
|
|
209
277
|
});
|
|
210
278
|
|
|
211
279
|
describe("renderNativeComparisonChartBase", () => {
|