gloomberb 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/app.tsx +168 -65
  4. package/src/cli/commands/ticker.ts +45 -15
  5. package/src/cli/helpers.ts +4 -2
  6. package/src/cli/registry.test.ts +42 -35
  7. package/src/cli.test.ts +4 -2
  8. package/src/components/chart/chart-control-hint.tsx +39 -0
  9. package/src/components/chart/chart-controller.test.ts +110 -0
  10. package/src/components/chart/chart-controller.ts +364 -2
  11. package/src/components/chart/chart-data.ts +177 -29
  12. package/src/components/chart/chart-indicator-selector.tsx +53 -0
  13. package/src/components/chart/chart-pane-settings.ts +20 -0
  14. package/src/components/chart/chart-renderer.ts +220 -30
  15. package/src/components/chart/chart-resolution.test.ts +67 -0
  16. package/src/components/chart/chart-resolution.ts +74 -4
  17. package/src/components/chart/chart-scroll.test.ts +69 -0
  18. package/src/components/chart/chart-scroll.ts +104 -0
  19. package/src/components/chart/chart-types.ts +12 -0
  20. package/src/components/chart/chart-viewport.test.ts +5 -1
  21. package/src/components/chart/chart-viewport.ts +1 -1
  22. package/src/components/chart/chart.test.ts +249 -10
  23. package/src/components/chart/comparison-chart-renderer.test.ts +71 -1
  24. package/src/components/chart/comparison-chart-renderer.ts +40 -3
  25. package/src/components/chart/comparison-stock-chart.tsx +218 -123
  26. package/src/components/chart/indicators/bands.test.ts +50 -0
  27. package/src/components/chart/indicators/bands.ts +29 -0
  28. package/src/components/chart/indicators/moving-averages.test.ts +54 -0
  29. package/src/components/chart/indicators/moving-averages.ts +42 -0
  30. package/src/components/chart/indicators/options.test.ts +32 -0
  31. package/src/components/chart/indicators/options.ts +107 -0
  32. package/src/components/chart/indicators/oscillators.test.ts +79 -0
  33. package/src/components/chart/indicators/oscillators.ts +92 -0
  34. package/src/components/chart/indicators/types.ts +36 -0
  35. package/src/components/chart/native/chart-rasterizer.ts +51 -0
  36. package/src/components/chart/native/kitty.test.ts +68 -0
  37. package/src/components/chart/stock-chart-renderer-switch.test.tsx +740 -9
  38. package/src/components/chart/stock-chart.test.ts +239 -0
  39. package/src/components/chart/stock-chart.tsx +2257 -372
  40. package/src/components/command-bar/command-bar.test.tsx +337 -5
  41. package/src/components/command-bar/command-bar.tsx +292 -120
  42. package/src/components/command-bar/helpers.ts +1 -1
  43. package/src/components/command-bar/root-shortcuts.ts +33 -4
  44. package/src/components/command-bar/view-model.test.ts +3 -3
  45. package/src/components/command-bar/view-model.ts +4 -4
  46. package/src/components/command-bar/workflow-ops.ts +10 -0
  47. package/src/components/layout/header.tsx +2 -1
  48. package/src/components/layout/shell.test.tsx +186 -27
  49. package/src/components/layout/shell.tsx +5 -4
  50. package/src/components/layout/status-bar.test.tsx +16 -7
  51. package/src/components/layout/status-bar.tsx +4 -1
  52. package/src/components/markdown-editor.tsx +1 -0
  53. package/src/components/pane-settings-dialog.tsx +13 -152
  54. package/src/components/pane-template-wizard.tsx +1 -0
  55. package/src/components/price-selector-dialog.tsx +12 -7
  56. package/src/components/ticker-badge-text.test.tsx +50 -0
  57. package/src/components/ticker-badge-text.tsx +17 -2
  58. package/src/components/ticker-badge.tsx +2 -2
  59. package/src/components/ticker-list-table.tsx +54 -1
  60. package/src/components/toggle-list.tsx +15 -2
  61. package/src/components/ui/data-table.tsx +60 -3
  62. package/src/components/ui/external-link.tsx +48 -11
  63. package/src/components/ui/fields.tsx +2 -1
  64. package/src/components/ui/index.ts +8 -1
  65. package/src/components/ui/multi-select-chips.tsx +69 -0
  66. package/src/components/ui/multi-select-dialog.tsx +267 -0
  67. package/src/components/ui/multi-select.ts +78 -0
  68. package/src/components/ui/ui.test.tsx +343 -1
  69. package/src/data/plugin-state-store.ts +30 -19
  70. package/src/data/resource-store.ts +133 -103
  71. package/src/data/session-store.ts +19 -12
  72. package/src/data/sqlite-cache.test.ts +37 -2
  73. package/src/data/sqlite-database.ts +8 -2
  74. package/src/data/sqlite-retry.test.ts +63 -0
  75. package/src/data/sqlite-retry.ts +66 -0
  76. package/src/data/ticker-store.ts +29 -18
  77. package/src/index.tsx +6 -1
  78. package/src/market-data/coordinator.test.ts +114 -5
  79. package/src/market-data/coordinator.ts +70 -1
  80. package/src/market-data/hooks.test.tsx +73 -2
  81. package/src/market-data/hooks.tsx +16 -4
  82. package/src/notifications/app-notifier.test.ts +177 -0
  83. package/src/notifications/app-notifier.ts +241 -0
  84. package/src/pane-settings.test.ts +2 -0
  85. package/src/plugins/builtin/ai/index.test.tsx +1 -1
  86. package/src/plugins/builtin/ai/screener-pane.test.tsx +2 -0
  87. package/src/plugins/builtin/ai/screener-pane.tsx +1 -0
  88. package/src/plugins/builtin/ai/ticker-context.ts +3 -2
  89. package/src/plugins/builtin/alerts/alert-engine.test.ts +90 -0
  90. package/src/plugins/builtin/alerts/alert-engine.ts +55 -0
  91. package/src/plugins/builtin/alerts/index.test.tsx +357 -0
  92. package/src/plugins/builtin/alerts/index.tsx +519 -0
  93. package/src/plugins/builtin/alerts/types.ts +14 -0
  94. package/src/plugins/builtin/analytics/index.test.tsx +300 -0
  95. package/src/plugins/builtin/analytics/index.tsx +775 -0
  96. package/src/plugins/builtin/analytics/metrics.test.ts +125 -0
  97. package/src/plugins/builtin/analytics/metrics.ts +155 -0
  98. package/src/plugins/builtin/ask-ai.test.tsx +2 -0
  99. package/src/plugins/builtin/chat-controller.test.ts +62 -18
  100. package/src/plugins/builtin/chat-controller.ts +23 -12
  101. package/src/plugins/builtin/chat.test.tsx +715 -4
  102. package/src/plugins/builtin/chat.tsx +415 -119
  103. package/src/plugins/builtin/comparison-chart.test.tsx +97 -57
  104. package/src/plugins/builtin/comparison-chart.tsx +1 -2
  105. package/src/plugins/builtin/correlation/compute.test.ts +147 -0
  106. package/src/plugins/builtin/correlation/compute.ts +115 -0
  107. package/src/plugins/builtin/correlation/index.tsx +385 -0
  108. package/src/plugins/builtin/correlation/plugin.test.ts +44 -0
  109. package/src/plugins/builtin/correlation/settings.test.ts +34 -0
  110. package/src/plugins/builtin/correlation/settings.ts +95 -0
  111. package/src/plugins/builtin/debug.tsx +36 -50
  112. package/src/plugins/builtin/earnings/earnings-cache.test.ts +181 -0
  113. package/src/plugins/builtin/earnings/earnings-cache.ts +136 -0
  114. package/src/plugins/builtin/earnings/earnings-data.test.ts +59 -0
  115. package/src/plugins/builtin/earnings/earnings-data.ts +50 -0
  116. package/src/plugins/builtin/earnings/index.tsx +337 -0
  117. package/src/plugins/builtin/econ/calendar-source.test.ts +219 -0
  118. package/src/plugins/builtin/econ/calendar-source.ts +121 -0
  119. package/src/plugins/builtin/econ/fred-client.test.ts +63 -0
  120. package/src/plugins/builtin/econ/fred-client.ts +79 -0
  121. package/src/plugins/builtin/econ/fred-series-map.ts +88 -0
  122. package/src/plugins/builtin/econ/index.tsx +999 -0
  123. package/src/plugins/builtin/econ/types.ts +13 -0
  124. package/src/plugins/builtin/fred-settings.ts +10 -0
  125. package/src/plugins/builtin/fx-matrix/index.tsx +178 -0
  126. package/src/plugins/builtin/fx-matrix/pairs.ts +30 -0
  127. package/src/plugins/builtin/help.test.tsx +1 -5
  128. package/src/plugins/builtin/help.tsx +1 -16
  129. package/src/plugins/builtin/insider/index.tsx +417 -0
  130. package/src/plugins/builtin/insider/insider-data.test.ts +55 -0
  131. package/src/plugins/builtin/insider/insider-data.ts +49 -0
  132. package/src/plugins/builtin/layout-manager.tsx +24 -16
  133. package/src/plugins/builtin/market-movers/index.tsx +573 -0
  134. package/src/plugins/builtin/market-movers/screener.test.ts +143 -0
  135. package/src/plugins/builtin/market-movers/screener.ts +105 -0
  136. package/src/plugins/builtin/options.tsx +4 -3
  137. package/src/plugins/builtin/portfolio-list/cli/commands.ts +11 -6
  138. package/src/plugins/builtin/portfolio-list/command-bar.ts +6 -3
  139. package/src/plugins/builtin/portfolio-list/header.tsx +3 -2
  140. package/src/plugins/builtin/portfolio-list/metrics.ts +22 -11
  141. package/src/plugins/builtin/portfolio-list/pane.tsx +3 -9
  142. package/src/plugins/builtin/portfolio-list/settings.ts +9 -0
  143. package/src/plugins/builtin/portfolio-list/table.tsx +3 -0
  144. package/src/plugins/builtin/portfolio-list.test.tsx +3 -1
  145. package/src/plugins/builtin/portfolio-metrics.test.ts +13 -1
  146. package/src/plugins/builtin/sec.tsx +114 -7
  147. package/src/plugins/builtin/sectors/index.tsx +359 -0
  148. package/src/plugins/builtin/sectors/sector-data.ts +18 -0
  149. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +75 -13
  150. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +316 -80
  151. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -6
  152. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +17 -12
  153. package/src/plugins/builtin/ticker-detail.test.tsx +3 -3
  154. package/src/plugins/builtin/world-indices/index.tsx +410 -0
  155. package/src/plugins/builtin/world-indices/indices.ts +52 -0
  156. package/src/plugins/builtin/yield-curve/index.tsx +242 -0
  157. package/src/plugins/builtin/yield-curve/treasury-data.test.ts +212 -0
  158. package/src/plugins/builtin/yield-curve/treasury-data.ts +107 -0
  159. package/src/plugins/catalog.ts +22 -0
  160. package/src/plugins/gridlock-notification.ts +15 -0
  161. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +5 -5
  162. package/src/plugins/ibkr/index.test.tsx +1 -1
  163. package/src/plugins/ibkr/index.tsx +1 -1
  164. package/src/plugins/ibkr/trade-tab.tsx +23 -5
  165. package/src/plugins/ibkr/trade-utils.test.ts +1 -1
  166. package/src/plugins/ibkr/trade-utils.ts +7 -6
  167. package/src/plugins/ibkr/trading-pane.tsx +8 -7
  168. package/src/plugins/plugin-runtime.test.tsx +2 -0
  169. package/src/plugins/plugin-runtime.tsx +10 -0
  170. package/src/plugins/prediction-markets/controller-data.ts +8 -0
  171. package/src/plugins/prediction-markets/detail/shared.tsx +7 -6
  172. package/src/plugins/prediction-markets/plugin.test.tsx +48 -1
  173. package/src/plugins/prediction-markets/services/fetch.ts +13 -7
  174. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +29 -11
  175. package/src/plugins/prediction-markets/services/polymarket-ws.ts +20 -0
  176. package/src/plugins/prediction-markets/test-helpers.tsx +2 -0
  177. package/src/plugins/registry.ts +22 -13
  178. package/src/sources/provider-router.test.ts +3 -2
  179. package/src/sources/sec-edgar.ts +13 -1
  180. package/src/sources/yahoo-finance.ts +75 -1
  181. package/src/state/app-context-selector.test.tsx +17 -1
  182. package/src/state/app-context.test.tsx +24 -0
  183. package/src/state/app-context.tsx +29 -6
  184. package/src/theme/colors.ts +5 -0
  185. package/src/theme/themes.ts +25 -0
  186. package/src/types/config.test.ts +107 -0
  187. package/src/types/config.ts +30 -1
  188. package/src/types/data-provider.ts +13 -0
  189. package/src/types/plugin.ts +35 -1
  190. package/src/utils/format.test.ts +11 -1
  191. package/src/utils/format.ts +86 -8
  192. package/src/utils/inline-content-tokenizer.test.ts +22 -0
  193. package/src/utils/inline-content-tokenizer.ts +76 -0
  194. package/src/utils/link-tokenizer.test.ts +27 -0
  195. package/src/utils/link-tokenizer.ts +93 -0
  196. package/src/utils/market-format.test.ts +109 -0
  197. package/src/utils/market-format.ts +299 -0
  198. package/src/utils/market-status.ts +3 -2
  199. package/src/utils/selection-clipboard.test.ts +43 -0
  200. package/src/utils/selection-clipboard.ts +88 -0
  201. package/src/utils/terminal-input-reset.test.ts +50 -0
  202. package/src/utils/terminal-input-reset.ts +24 -0
  203. package/src/utils/throttled-fetch.test.ts +145 -0
  204. package/src/utils/throttled-fetch.ts +212 -0
  205. package/src/utils/ticker-tokenizer.test.ts +31 -0
  206. package/src/utils/ticker-tokenizer.ts +7 -42
  207. package/src/version.ts +1 -1
@@ -0,0 +1,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", () => {