gloomberb 0.6.1 → 0.7.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 (139) hide show
  1. package/package.json +1 -1
  2. package/patches/@opentui%2Fcore@0.1.90.patch +48 -24
  3. package/src/app.tsx +9 -6
  4. package/src/cli/registry.test.ts +0 -1
  5. package/src/components/chart/chart-controller.ts +0 -1
  6. package/src/components/chart/chart-data.ts +1 -31
  7. package/src/components/chart/chart-renderer.ts +70 -0
  8. package/src/components/chart/chart-resolution.test.ts +0 -36
  9. package/src/components/chart/chart-resolution.ts +0 -27
  10. package/src/components/chart/chart.test.ts +17 -45
  11. package/src/components/chart/comparison-chart-data.test.ts +1 -5
  12. package/src/components/chart/comparison-chart-data.ts +1 -4
  13. package/src/components/chart/comparison-stock-chart.tsx +48 -27
  14. package/src/components/chart/static-chart-surface.tsx +28 -4
  15. package/src/components/chart/stock-chart.test.ts +0 -16
  16. package/src/components/chart/stock-chart.tsx +38 -21
  17. package/src/components/chart/time-axis-label.tsx +43 -0
  18. package/src/components/command-bar/command-bar.test.tsx +71 -8
  19. package/src/components/command-bar/command-bar.tsx +388 -713
  20. package/src/components/command-bar/confirm-body.tsx +51 -0
  21. package/src/components/command-bar/list-model.ts +77 -0
  22. package/src/components/command-bar/view-model.ts +0 -19
  23. package/src/components/command-bar/workflow-ops.ts +4 -0
  24. package/src/components/data-table-stack-view.test.tsx +39 -4
  25. package/src/components/data-table-stack-view.tsx +11 -104
  26. package/src/components/data-table-view.tsx +43 -79
  27. package/src/components/feed-data-table-stack-view.tsx +10 -17
  28. package/src/components/feed-stack-controller.ts +39 -0
  29. package/src/components/index.ts +5 -1
  30. package/src/components/layout/floating-pane.tsx +30 -2
  31. package/src/components/layout/header.tsx +10 -7
  32. package/src/components/layout/pane-cycle-order.test.ts +30 -0
  33. package/src/components/layout/pane-cycle-order.ts +32 -0
  34. package/src/components/layout/pane-footer.tsx +11 -4
  35. package/src/components/layout/pane-header.tsx +5 -5
  36. package/src/components/layout/shell.test.tsx +66 -22
  37. package/src/components/layout/shell.tsx +13 -6
  38. package/src/components/onboarding/onboarding-wizard.tsx +1 -2
  39. package/src/components/pane-settings-dialog.tsx +12 -4
  40. package/src/components/pane-template-wizard.test.tsx +3 -4
  41. package/src/components/speedometer-gauge.test.tsx +45 -0
  42. package/src/components/speedometer-gauge.tsx +558 -0
  43. package/src/components/table-view-shared.tsx +158 -0
  44. package/src/components/ticker-list-table-view.tsx +42 -72
  45. package/src/components/ui/confirm-dialog.tsx +66 -0
  46. package/src/components/ui/data-table.tsx +18 -10
  47. package/src/components/ui/index.ts +0 -1
  48. package/src/components/ui/page-stack-view.tsx +39 -10
  49. package/src/components/ui/table-layout.test.ts +16 -0
  50. package/src/components/ui/table-layout.ts +10 -3
  51. package/src/components/ui/ui.test.tsx +100 -20
  52. package/src/core/state/app-state.ts +6 -3
  53. package/src/market-data/coordinator.test.ts +1 -1
  54. package/src/market-data/coordinator.ts +0 -1
  55. package/src/news/aggregator.ts +0 -3
  56. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +46 -16
  57. package/src/plugins/builtin/ai/providers.ts +0 -17
  58. package/src/plugins/builtin/ai/screener-pane.test.tsx +3 -4
  59. package/src/plugins/builtin/ai/screener-pane.tsx +0 -3
  60. package/src/plugins/builtin/ask-ai.test.tsx +89 -2
  61. package/src/plugins/builtin/broker-manager/index.test.tsx +2 -2
  62. package/src/plugins/builtin/broker-manager/index.tsx +19 -36
  63. package/src/plugins/builtin/chat-controller.test.ts +105 -0
  64. package/src/plugins/builtin/chat-controller.ts +75 -6
  65. package/src/plugins/builtin/chat.test.tsx +6 -6
  66. package/src/plugins/builtin/chat.tsx +29 -20
  67. package/src/plugins/builtin/cloud-tweets.tsx +0 -1
  68. package/src/plugins/builtin/comparison-chart.test.tsx +53 -2
  69. package/src/plugins/builtin/comparison-chart.tsx +11 -5
  70. package/src/plugins/builtin/correlation/index.tsx +1 -6
  71. package/src/plugins/builtin/debug.tsx +1 -3
  72. package/src/plugins/builtin/econ/index.tsx +1 -2
  73. package/src/plugins/builtin/fear-greed/fear-greed-data.test.ts +72 -0
  74. package/src/plugins/builtin/fear-greed/fear-greed-data.ts +366 -0
  75. package/src/plugins/builtin/fear-greed/index.tsx +478 -0
  76. package/src/plugins/builtin/fx-matrix/index.tsx +2 -2
  77. package/src/plugins/builtin/help.test.tsx +2 -2
  78. package/src/plugins/builtin/help.tsx +2 -2
  79. package/src/plugins/builtin/holders/index.tsx +10 -15
  80. package/src/plugins/builtin/insider/index.tsx +22 -89
  81. package/src/plugins/builtin/market-movers/index.tsx +1 -1
  82. package/src/plugins/builtin/news-wire/index.tsx +41 -2
  83. package/src/plugins/builtin/news-wire/news-table.tsx +11 -11
  84. package/src/plugins/builtin/news.tsx +4 -16
  85. package/src/plugins/builtin/notes.tsx +18 -55
  86. package/src/plugins/builtin/options.test.tsx +58 -2
  87. package/src/plugins/builtin/options.tsx +108 -49
  88. package/src/plugins/builtin/plugin-groups.ts +2 -0
  89. package/src/plugins/builtin/portfolio-list/cli/commands.ts +1 -1
  90. package/src/plugins/builtin/research/index.tsx +5 -21
  91. package/src/plugins/builtin/sec-filing-display.tsx +84 -0
  92. package/src/plugins/builtin/sec.tsx +13 -66
  93. package/src/plugins/builtin/sectors/index.tsx +1 -1
  94. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +0 -1
  95. package/src/plugins/builtin/ticker-detail/index.tsx +0 -1
  96. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +0 -1
  97. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +3 -4
  98. package/src/plugins/builtin/world-indices/index.tsx +1 -1
  99. package/src/plugins/builtin/yield-curve/index.tsx +1 -1
  100. package/src/plugins/ibkr/broker-adapter.ts +1 -3
  101. package/src/plugins/ibkr/gateway-service-native.ts +1 -5
  102. package/src/plugins/ibkr/gateway-service.ts +1 -1
  103. package/src/plugins/ibkr/index.test.tsx +0 -2
  104. package/src/plugins/ibkr/trade-tab.test.tsx +3 -4
  105. package/src/plugins/pane-manager.ts +0 -13
  106. package/src/plugins/plugin-runtime.test.tsx +6 -6
  107. package/src/plugins/plugin-runtime.tsx +13 -8
  108. package/src/plugins/prediction-markets/cli.ts +0 -1
  109. package/src/plugins/prediction-markets/controller-data.ts +0 -2
  110. package/src/plugins/prediction-markets/pane.test.tsx +3 -11
  111. package/src/plugins/prediction-markets/test-helpers.tsx +1 -1
  112. package/src/plugins/registry.ts +10 -39
  113. package/src/react/input.ts +1 -0
  114. package/src/renderers/electrobun/view/data-table.tsx +28 -6
  115. package/src/renderers/electrobun/view/desktop-controls.tsx +34 -20
  116. package/src/renderers/electrobun/view/dialog-host.tsx +11 -1
  117. package/src/renderers/electrobun/view/input-host.tsx +74 -10
  118. package/src/renderers/electrobun/view/key-event.ts +1 -1
  119. package/src/renderers/electrobun/view/styles.css +331 -0
  120. package/src/renderers/opentui/chart-surface.tsx +179 -0
  121. package/src/renderers/opentui/start.tsx +48 -30
  122. package/src/renderers/opentui/test-utils.tsx +17 -0
  123. package/src/renderers/opentui/ui-host.tsx +5 -2
  124. package/src/sources/provider-router.ts +0 -4
  125. package/src/state/app-context.test.ts +45 -0
  126. package/src/state/use-inline-tickers.test.tsx +73 -0
  127. package/src/state/use-inline-tickers.ts +7 -1
  128. package/src/test-support/plugin-runtime.ts +3 -2
  129. package/src/theme/colors.ts +12 -3
  130. package/src/theme/themes.test.ts +23 -1
  131. package/src/theme/themes.ts +115 -0
  132. package/src/types/broker.ts +1 -1
  133. package/src/types/data-provider.ts +0 -1
  134. package/src/types/plugin.ts +0 -2
  135. package/src/ui/host.tsx +1 -0
  136. package/src/utils/back-navigation.ts +24 -1
  137. package/src/version.ts +1 -1
  138. package/src/plugins/builtin/news-wire/feed-pane.tsx +0 -21
  139. package/src/plugins/builtin/news-wire/top-pane.tsx +0 -21
@@ -34,11 +34,6 @@ const MIN_CORRELATION_OBSERVATIONS = 5;
34
34
 
35
35
  type SeriesStatus = "loading" | "ready" | "insufficient" | "empty" | "error";
36
36
 
37
- interface CorrelationInstrument {
38
- symbol: string;
39
- exchange: string;
40
- }
41
-
42
37
  interface CorrelationSeries {
43
38
  symbol: string;
44
39
  returns: DatedReturn[];
@@ -147,7 +142,7 @@ function buildCorrelationPaneTitle(symbols: string[], rangePreset: CorrelationRa
147
142
  return `${symbols.slice(0, 2).join(" · ")} +${symbols.length - 2} ${rangePreset}`;
148
143
  }
149
144
 
150
- export function CorrelationMatrixPane({ width, height }: PaneProps) {
145
+ function CorrelationMatrixPane({ width, height }: PaneProps) {
151
146
  const pane = usePaneInstance();
152
147
  const { navigateTicker } = usePluginTickerActions();
153
148
  const tickers = useAppSelector((state) => state.tickers);
@@ -62,7 +62,6 @@ function DebugPane({ focused, width, height }: PaneProps) {
62
62
  const [filterLevel, setFilterLevel] = useState<LogLevel | null>(null);
63
63
  const [filterSource, setFilterSource] = useState<string | null>(null);
64
64
  const [autoScroll, setAutoScroll] = useState(true);
65
- const [scrollOffset, setScrollOffset] = useState(0);
66
65
  const [selectedIdx, setSelectedIdx] = useState(-1);
67
66
  const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
68
67
  const [showDetail, setShowDetail] = useState(false);
@@ -77,7 +76,6 @@ function DebugPane({ focused, width, height }: PaneProps) {
77
76
  );
78
77
  setEntries(filtered);
79
78
  sourcesRef.current = debugLog.getSources();
80
- if (autoScroll) setScrollOffset(0);
81
79
  });
82
80
  return unsub;
83
81
  }, [filterLevel, filterSource, autoScroll]);
@@ -342,7 +340,7 @@ export const debugPlugin: GloomPlugin = {
342
340
  keywords: ["debug", "log", "logs", "console", "errors"],
343
341
  category: "navigation",
344
342
  execute: () => {
345
- ctx.showWidget("debug");
343
+ ctx.showPane("debug");
346
344
  },
347
345
  });
348
346
 
@@ -303,7 +303,6 @@ function EconDetailView({ event, width, height, focused }: EconDetailViewProps)
303
303
  return { date: obs.date, display: obs.value.toLocaleString("en-US", { maximumFractionDigits: 1 }) };
304
304
  });
305
305
  const units = info?.units ?? "";
306
- const source = info?.source || "";
307
306
  const title = info?.title ?? event.event;
308
307
 
309
308
  // Value color for table rows
@@ -472,7 +471,7 @@ function formatCompactAxisValue(value: number, units: string): string {
472
471
  // Main Calendar Pane
473
472
  // ---------------------------------------------------------------------------
474
473
 
475
- export function EconCalendarPane({ focused, width, height }: PaneProps) {
474
+ function EconCalendarPane({ focused, width, height }: PaneProps) {
476
475
  const [events, setEvents] = useState<EconEvent[]>(sharedCache?.data ?? []);
477
476
  const [loading, setLoading] = useState(false);
478
477
  const [error, setError] = useState<string | null>(null);
@@ -0,0 +1,72 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { normalizeFearGreedData, type CnnFearGreedGraphData } from "./fear-greed-data";
3
+
4
+ describe("fear-greed data normalization", () => {
5
+ test("combines latest score with chart history and aligned overlay series", () => {
6
+ const charts: CnnFearGreedGraphData = {
7
+ fear_and_greed: {
8
+ score: 65,
9
+ rating: "greed",
10
+ timestamp: "2026-05-08T23:59:55+00:00",
11
+ },
12
+ fear_and_greed_historical: {
13
+ score: 65,
14
+ rating: "greed",
15
+ data: [
16
+ { x: 2000, y: 62, rating: "greed" },
17
+ { x: 1000, y: 58, rating: "greed" },
18
+ ],
19
+ },
20
+ market_momentum_sp500: {
21
+ score: 99.6,
22
+ rating: "extreme greed",
23
+ timestamp: 2000,
24
+ data: [
25
+ { x: 1000, y: 100 },
26
+ { x: 2000, y: 110 },
27
+ ],
28
+ },
29
+ market_momentum_sp125: {
30
+ timestamp: 2000,
31
+ data: [
32
+ { x: 1000, y: 92 },
33
+ { x: 2000, y: 96 },
34
+ ],
35
+ },
36
+ stock_price_strength: {
37
+ score: 61.4,
38
+ rating: "greed",
39
+ data: [{ x: 1000, y: 3.8 }],
40
+ },
41
+ };
42
+ const latest: CnnFearGreedGraphData = {
43
+ fear_and_greed: {
44
+ score: 70.4,
45
+ rating: "greed",
46
+ timestamp: "2026-05-09T12:00:00+00:00",
47
+ previous_close: 67.5,
48
+ previous_1_week: 71.1,
49
+ previous_1_month: 29.1,
50
+ previous_1_year: 57.6,
51
+ },
52
+ };
53
+
54
+ const data = normalizeFearGreedData(charts, latest);
55
+
56
+ expect(data.overall.score).toBe(70.4);
57
+ expect(data.overall.previousClose).toBe(67.5);
58
+ expect(data.overall.history.map((point) => point.close)).toEqual([58, 62]);
59
+
60
+ const momentum = data.indicators.find((indicator) => indicator.definition.id === "market-momentum");
61
+ expect(momentum?.rating).toBe("extreme greed");
62
+ expect(momentum?.points.map((point) => point.close)).toEqual([100, 110]);
63
+ expect(momentum?.secondaryPoints).toEqual([
64
+ { index: 0, value: 92 },
65
+ { index: 1, value: 96 },
66
+ ]);
67
+ });
68
+
69
+ test("rejects responses without an index score", () => {
70
+ expect(() => normalizeFearGreedData({})).toThrow("index score");
71
+ });
72
+ });
@@ -0,0 +1,366 @@
1
+ import type { ProjectedChartPoint } from "../../../components/chart/chart-data";
2
+ import type { OverlayPoint } from "../../../components/chart/indicators/types";
3
+
4
+ const CNN_FEAR_GREED_GRAPH_URL = "https://production.dataviz.cnn.io/index/fearandgreed/graphdata";
5
+ const CNN_REFERER = "https://www.cnn.com/markets/fear-and-greed";
6
+ const CNN_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
7
+
8
+ export type FearGreedRating = "extreme fear" | "fear" | "neutral" | "greed" | "extreme greed";
9
+
10
+ type CnnGraphKey =
11
+ | "fear_and_greed"
12
+ | "fear_and_greed_historical"
13
+ | "market_momentum_sp500"
14
+ | "market_momentum_sp125"
15
+ | "stock_price_strength"
16
+ | "stock_price_breadth"
17
+ | "put_call_options"
18
+ | "market_volatility_vix"
19
+ | "market_volatility_vix_50"
20
+ | "safe_haven_demand"
21
+ | "junk_bond_demand";
22
+
23
+ interface CnnPoint {
24
+ x?: unknown;
25
+ y?: unknown;
26
+ rating?: unknown;
27
+ }
28
+
29
+ interface CnnSeries {
30
+ timestamp?: unknown;
31
+ score?: unknown;
32
+ rating?: unknown;
33
+ previous_close?: unknown;
34
+ previous_1_week?: unknown;
35
+ previous_1_month?: unknown;
36
+ previous_1_year?: unknown;
37
+ data?: unknown;
38
+ }
39
+
40
+ export type CnnFearGreedGraphData = Partial<Record<CnnGraphKey, CnnSeries>>;
41
+
42
+ interface NormalizedRawPoint {
43
+ x: number;
44
+ y: number;
45
+ rating: FearGreedRating | null;
46
+ }
47
+
48
+ export interface FearGreedOverall {
49
+ score: number;
50
+ rating: FearGreedRating;
51
+ updatedAt: Date | null;
52
+ previousClose: number | null;
53
+ previousWeek: number | null;
54
+ previousMonth: number | null;
55
+ previousYear: number | null;
56
+ history: ProjectedChartPoint[];
57
+ }
58
+
59
+ export type FearGreedValueFormat = "score" | "number" | "percent" | "ratio";
60
+
61
+ export interface FearGreedIndicatorDefinition {
62
+ id: string;
63
+ title: string;
64
+ subtitle: string;
65
+ primaryKey: CnnGraphKey;
66
+ primaryLabel: string;
67
+ secondaryKey?: CnnGraphKey;
68
+ secondaryLabel?: string;
69
+ valueFormat: FearGreedValueFormat;
70
+ }
71
+
72
+ export interface FearGreedIndicator {
73
+ definition: FearGreedIndicatorDefinition;
74
+ score: number | null;
75
+ rating: FearGreedRating;
76
+ updatedAt: Date | null;
77
+ points: ProjectedChartPoint[];
78
+ secondaryPoints: OverlayPoint[];
79
+ latestValue: number | null;
80
+ latestSecondaryValue: number | null;
81
+ }
82
+
83
+ export interface FearGreedData {
84
+ overall: FearGreedOverall;
85
+ indicators: FearGreedIndicator[];
86
+ }
87
+
88
+ export const FEAR_GREED_INDICATORS: FearGreedIndicatorDefinition[] = [
89
+ {
90
+ id: "market-momentum",
91
+ title: "Market Momentum",
92
+ subtitle: "S&P 500 and its 125-day moving average",
93
+ primaryKey: "market_momentum_sp500",
94
+ primaryLabel: "S&P 500",
95
+ secondaryKey: "market_momentum_sp125",
96
+ secondaryLabel: "125-day moving average",
97
+ valueFormat: "number",
98
+ },
99
+ {
100
+ id: "stock-price-strength",
101
+ title: "Stock Price Strength",
102
+ subtitle: "Net new 52-week highs and lows on the NYSE",
103
+ primaryKey: "stock_price_strength",
104
+ primaryLabel: "Net highs/lows",
105
+ valueFormat: "percent",
106
+ },
107
+ {
108
+ id: "stock-price-breadth",
109
+ title: "Stock Price Breadth",
110
+ subtitle: "McClellan Volume Summation Index",
111
+ primaryKey: "stock_price_breadth",
112
+ primaryLabel: "McClellan index",
113
+ valueFormat: "number",
114
+ },
115
+ {
116
+ id: "put-call-options",
117
+ title: "Put and Call Options",
118
+ subtitle: "5-day average put/call ratio",
119
+ primaryKey: "put_call_options",
120
+ primaryLabel: "Put/call ratio",
121
+ valueFormat: "ratio",
122
+ },
123
+ {
124
+ id: "market-volatility",
125
+ title: "Market Volatility",
126
+ subtitle: "VIX and its 50-day moving average",
127
+ primaryKey: "market_volatility_vix",
128
+ primaryLabel: "VIX",
129
+ secondaryKey: "market_volatility_vix_50",
130
+ secondaryLabel: "50-day moving average",
131
+ valueFormat: "number",
132
+ },
133
+ {
134
+ id: "safe-haven-demand",
135
+ title: "Safe Haven Demand",
136
+ subtitle: "Difference in 20-day stock and bond returns",
137
+ primaryKey: "safe_haven_demand",
138
+ primaryLabel: "Stocks vs. bonds",
139
+ valueFormat: "percent",
140
+ },
141
+ {
142
+ id: "junk-bond-demand",
143
+ title: "Junk Bond Demand",
144
+ subtitle: "Yield spread: junk bonds vs. investment grade",
145
+ primaryKey: "junk_bond_demand",
146
+ primaryLabel: "Yield spread",
147
+ valueFormat: "percent",
148
+ },
149
+ ];
150
+
151
+ function isObject(value: unknown): value is Record<string, unknown> {
152
+ return value !== null && typeof value === "object" && !Array.isArray(value);
153
+ }
154
+
155
+ function finiteNumber(value: unknown): number | null {
156
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
157
+ return value;
158
+ }
159
+
160
+ function normalizeRating(value: unknown): FearGreedRating | null {
161
+ if (typeof value !== "string") return null;
162
+ const normalized = value.trim().toLowerCase();
163
+ switch (normalized) {
164
+ case "extreme fear":
165
+ case "fear":
166
+ case "neutral":
167
+ case "greed":
168
+ case "extreme greed":
169
+ return normalized;
170
+ default:
171
+ return null;
172
+ }
173
+ }
174
+
175
+ function fallbackRatingForScore(score: number | null): FearGreedRating {
176
+ if (score == null) return "neutral";
177
+ if (score < 25) return "extreme fear";
178
+ if (score < 45) return "fear";
179
+ if (score <= 55) return "neutral";
180
+ if (score <= 75) return "greed";
181
+ return "extreme greed";
182
+ }
183
+
184
+ function parseTimestamp(value: unknown): Date | null {
185
+ if (typeof value === "string") {
186
+ const parsed = new Date(value);
187
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
188
+ }
189
+ const millis = finiteNumber(value);
190
+ if (millis == null) return null;
191
+ const parsed = new Date(millis);
192
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
193
+ }
194
+
195
+ function normalizeRawPoints(series: CnnSeries | undefined): NormalizedRawPoint[] {
196
+ if (!series || !Array.isArray(series.data)) return [];
197
+ const points: NormalizedRawPoint[] = [];
198
+ for (const item of series.data) {
199
+ if (!isObject(item)) continue;
200
+ const point = item as CnnPoint;
201
+ const x = finiteNumber(point.x);
202
+ const y = finiteNumber(point.y);
203
+ if (x == null || y == null) continue;
204
+ points.push({ x, y, rating: normalizeRating(point.rating) });
205
+ }
206
+ return points.sort((left, right) => left.x - right.x);
207
+ }
208
+
209
+ function toProjectedPoints(points: NormalizedRawPoint[]): ProjectedChartPoint[] {
210
+ return points.map((point) => ({
211
+ date: new Date(point.x),
212
+ open: point.y,
213
+ high: point.y,
214
+ low: point.y,
215
+ close: point.y,
216
+ volume: 0,
217
+ }));
218
+ }
219
+
220
+ function toSecondaryOverlay(primaryPoints: NormalizedRawPoint[], secondaryPoints: NormalizedRawPoint[]): OverlayPoint[] {
221
+ const indexByTimestamp = new Map(primaryPoints.map((point, index) => [point.x, index] as const));
222
+ const overlay: OverlayPoint[] = [];
223
+ for (let index = 0; index < secondaryPoints.length; index += 1) {
224
+ const point = secondaryPoints[index]!;
225
+ const matchedIndex = indexByTimestamp.get(point.x) ?? (index < primaryPoints.length ? index : null);
226
+ if (matchedIndex == null) continue;
227
+ overlay.push({ index: matchedIndex, value: point.y });
228
+ }
229
+ return overlay;
230
+ }
231
+
232
+ function latestValue(points: NormalizedRawPoint[]): number | null {
233
+ return points.length > 0 ? points[points.length - 1]!.y : null;
234
+ }
235
+
236
+ function latestRating(series: CnnSeries | undefined, points: NormalizedRawPoint[], score: number | null): FearGreedRating {
237
+ return normalizeRating(series?.rating)
238
+ ?? (points.length > 0 ? points[points.length - 1]!.rating : null)
239
+ ?? fallbackRatingForScore(score);
240
+ }
241
+
242
+ function latestTimestamp(series: CnnSeries | undefined, points: NormalizedRawPoint[]): Date | null {
243
+ return parseTimestamp(series?.timestamp)
244
+ ?? (points.length > 0 ? new Date(points[points.length - 1]!.x) : null);
245
+ }
246
+
247
+ export function normalizeFearGreedData(
248
+ charts: CnnFearGreedGraphData,
249
+ latest: CnnFearGreedGraphData = charts,
250
+ ): FearGreedData {
251
+ const latestOverallSeries = latest.fear_and_greed ?? charts.fear_and_greed;
252
+ const historySeries = charts.fear_and_greed_historical ?? latest.fear_and_greed_historical;
253
+ const historyRawPoints = normalizeRawPoints(historySeries);
254
+ const latestScore = finiteNumber(latestOverallSeries?.score)
255
+ ?? finiteNumber(historySeries?.score)
256
+ ?? latestValue(historyRawPoints);
257
+
258
+ if (latestScore == null) {
259
+ throw new Error("CNN Fear & Greed response did not include an index score");
260
+ }
261
+
262
+ const overall: FearGreedOverall = {
263
+ score: latestScore,
264
+ rating: normalizeRating(latestOverallSeries?.rating) ?? fallbackRatingForScore(latestScore),
265
+ updatedAt: parseTimestamp(latestOverallSeries?.timestamp) ?? latestTimestamp(historySeries, historyRawPoints),
266
+ previousClose: finiteNumber(latestOverallSeries?.previous_close),
267
+ previousWeek: finiteNumber(latestOverallSeries?.previous_1_week),
268
+ previousMonth: finiteNumber(latestOverallSeries?.previous_1_month),
269
+ previousYear: finiteNumber(latestOverallSeries?.previous_1_year),
270
+ history: toProjectedPoints(historyRawPoints),
271
+ };
272
+
273
+ const indicators = FEAR_GREED_INDICATORS.flatMap((definition): FearGreedIndicator[] => {
274
+ const series = charts[definition.primaryKey] ?? latest[definition.primaryKey];
275
+ const primaryPoints = normalizeRawPoints(series);
276
+ if (primaryPoints.length === 0) return [];
277
+
278
+ const secondarySeries = definition.secondaryKey
279
+ ? (charts[definition.secondaryKey] ?? latest[definition.secondaryKey])
280
+ : undefined;
281
+ const secondaryRawPoints = normalizeRawPoints(secondarySeries);
282
+ const score = finiteNumber(series?.score);
283
+
284
+ return [{
285
+ definition,
286
+ score,
287
+ rating: latestRating(series, primaryPoints, score),
288
+ updatedAt: latestTimestamp(series, primaryPoints),
289
+ points: toProjectedPoints(primaryPoints),
290
+ secondaryPoints: toSecondaryOverlay(primaryPoints, secondaryRawPoints),
291
+ latestValue: latestValue(primaryPoints),
292
+ latestSecondaryValue: latestValue(secondaryRawPoints),
293
+ }];
294
+ });
295
+
296
+ return { overall, indicators };
297
+ }
298
+
299
+ function formatLocalDate(date: Date): string {
300
+ const year = date.getFullYear();
301
+ const month = String(date.getMonth() + 1).padStart(2, "0");
302
+ const day = String(date.getDate()).padStart(2, "0");
303
+ return `${year}-${month}-${day}`;
304
+ }
305
+
306
+ function isDomRuntime(): boolean {
307
+ return typeof (globalThis as { document?: unknown }).document !== "undefined";
308
+ }
309
+
310
+ function cnnFetchHeaders(): HeadersInit {
311
+ const headers: Record<string, string> = {
312
+ Accept: "application/json,text/plain,*/*",
313
+ };
314
+
315
+ if (!isDomRuntime()) {
316
+ headers["User-Agent"] = CNN_USER_AGENT;
317
+ headers.Referer = CNN_REFERER;
318
+ }
319
+
320
+ return headers;
321
+ }
322
+
323
+ async function fetchCnnGraphData(url: string, fetcher: typeof fetch): Promise<CnnFearGreedGraphData> {
324
+ const response = await fetcher(url, { headers: cnnFetchHeaders() });
325
+ const body = await response.text();
326
+ if (!response.ok) {
327
+ throw new Error(`CNN Fear & Greed request failed (${response.status}): ${body.slice(0, 120)}`);
328
+ }
329
+
330
+ let parsed: unknown;
331
+ try {
332
+ parsed = JSON.parse(body);
333
+ } catch {
334
+ throw new Error(`CNN Fear & Greed returned non-JSON data: ${body.slice(0, 120)}`);
335
+ }
336
+
337
+ if (!isObject(parsed)) {
338
+ throw new Error("CNN Fear & Greed response was not an object");
339
+ }
340
+
341
+ return parsed as CnnFearGreedGraphData;
342
+ }
343
+
344
+ export async function fetchFearGreedData(options: {
345
+ date?: Date;
346
+ fetcher?: typeof fetch;
347
+ } = {}): Promise<FearGreedData> {
348
+ const fetcher = options.fetcher ?? fetch;
349
+ const date = options.date ?? new Date();
350
+ const latestUrl = `${CNN_FEAR_GREED_GRAPH_URL}/${formatLocalDate(date)}`;
351
+
352
+ const [chartsResult, latestResult] = await Promise.allSettled([
353
+ fetchCnnGraphData(CNN_FEAR_GREED_GRAPH_URL, fetcher),
354
+ fetchCnnGraphData(latestUrl, fetcher),
355
+ ]);
356
+
357
+ const charts = chartsResult.status === "fulfilled" ? chartsResult.value : null;
358
+ const latest = latestResult.status === "fulfilled" ? latestResult.value : null;
359
+
360
+ if (!charts && !latest) {
361
+ const reason = chartsResult.status === "rejected" ? chartsResult.reason : latestResult.reason;
362
+ throw reason instanceof Error ? reason : new Error(String(reason));
363
+ }
364
+
365
+ return normalizeFearGreedData(charts ?? latest!, latest ?? charts ?? undefined);
366
+ }