gloomberb 0.2.0 → 0.3.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.
Files changed (310) hide show
  1. package/README.md +20 -19
  2. package/bin/gloomberb +3 -0
  3. package/package.json +5 -4
  4. package/patches/@opentui%2Fcore@0.1.90.patch +356 -0
  5. package/src/app.tsx +1330 -0
  6. package/src/brokers/sync-broker-instance.test.ts +100 -0
  7. package/src/brokers/sync-broker-instance.ts +341 -0
  8. package/src/cli/commands/plugins.ts +173 -0
  9. package/src/cli/commands/portfolio.ts +210 -0
  10. package/src/cli/commands/search.ts +120 -0
  11. package/src/cli/commands/ticker.ts +375 -0
  12. package/src/cli/commands/watchlist.ts +210 -0
  13. package/src/cli/context.ts +62 -0
  14. package/src/cli/errors.ts +13 -0
  15. package/src/cli/helpers.ts +85 -0
  16. package/src/cli/index.ts +143 -0
  17. package/src/cli/ticker-resolution.ts +39 -0
  18. package/src/cli/types.ts +15 -0
  19. package/src/cli.test.ts +361 -0
  20. package/src/components/chart/chart-data.ts +239 -0
  21. package/src/components/chart/chart-renderer.ts +1199 -0
  22. package/src/components/chart/chart-types.ts +82 -0
  23. package/src/components/chart/chart-viewport.ts +21 -0
  24. package/src/components/chart/chart.test.ts +439 -0
  25. package/src/components/chart/comparison-chart-data.test.ts +109 -0
  26. package/src/components/chart/comparison-chart-data.ts +316 -0
  27. package/src/components/chart/comparison-chart-renderer.test.ts +84 -0
  28. package/src/components/chart/comparison-chart-renderer.ts +354 -0
  29. package/src/components/chart/comparison-stock-chart.tsx +1486 -0
  30. package/src/components/chart/cursor-motion.ts +48 -0
  31. package/src/components/chart/native/chart-rasterizer.ts +732 -0
  32. package/src/components/chart/native/kitty-adapter.ts +12 -0
  33. package/src/components/chart/native/kitty-manager.ts +142 -0
  34. package/src/components/chart/native/kitty-protocol.ts +110 -0
  35. package/src/components/chart/native/kitty-support.ts +71 -0
  36. package/src/components/chart/native/kitty.test.ts +620 -0
  37. package/src/components/chart/native/renderer-selection.ts +24 -0
  38. package/src/components/chart/native/surface-manager.ts +225 -0
  39. package/src/components/chart/native/surface-sync.ts +34 -0
  40. package/src/components/chart/stock-chart-renderer-switch.test.tsx +165 -0
  41. package/src/components/chart/stock-chart.test.ts +107 -0
  42. package/src/components/chart/stock-chart.tsx +1556 -0
  43. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +137 -0
  44. package/src/components/command-bar/command-bar.test.tsx +1524 -0
  45. package/src/components/command-bar/command-bar.tsx +4823 -0
  46. package/src/components/command-bar/command-registry.ts +261 -0
  47. package/src/components/command-bar/helpers.test.ts +224 -0
  48. package/src/components/command-bar/helpers.ts +371 -0
  49. package/src/components/command-bar/root-shortcuts.ts +158 -0
  50. package/src/components/command-bar/view-model.test.ts +152 -0
  51. package/src/components/command-bar/view-model.ts +185 -0
  52. package/src/components/command-bar/workflow-ops.test.ts +70 -0
  53. package/src/components/command-bar/workflow-ops.ts +433 -0
  54. package/src/components/command-bar/workflow-types.ts +127 -0
  55. package/src/components/detail-feed-view.test.tsx +105 -0
  56. package/src/components/detail-feed-view.tsx +295 -0
  57. package/src/components/index.ts +32 -0
  58. package/src/components/layout/floating-pane.tsx +49 -0
  59. package/src/components/layout/header.tsx +129 -0
  60. package/src/components/layout/pane-header.tsx +44 -0
  61. package/src/components/layout/pane.tsx +49 -0
  62. package/src/components/layout/pixel-mouse-mode.test.ts +122 -0
  63. package/src/components/layout/pixel-mouse.test.ts +40 -0
  64. package/src/components/layout/shell.test.tsx +625 -0
  65. package/src/components/layout/shell.tsx +1200 -0
  66. package/src/components/layout/status-bar.test.tsx +173 -0
  67. package/src/components/layout/status-bar.tsx +138 -0
  68. package/src/components/markdown-editor.tsx +134 -0
  69. package/src/components/onboarding/onboarding-wizard.test.tsx +184 -0
  70. package/src/components/onboarding/onboarding-wizard.tsx +1365 -0
  71. package/src/components/pane-settings-dialog.tsx +468 -0
  72. package/src/components/pane-template-wizard.test.tsx +52 -0
  73. package/src/components/pane-template-wizard.tsx +199 -0
  74. package/src/components/price-selector-dialog.tsx +195 -0
  75. package/src/components/spinner.tsx +2 -0
  76. package/src/components/tab-bar.tsx +17 -0
  77. package/src/components/ticker-badge-text.test.tsx +160 -0
  78. package/src/components/ticker-badge-text.tsx +58 -0
  79. package/src/components/ticker-badge.tsx +88 -0
  80. package/src/components/ticker-list-table.tsx +188 -0
  81. package/src/components/toggle-list.tsx +74 -0
  82. package/src/components/ui/button.tsx +77 -0
  83. package/src/components/ui/data-table.tsx +210 -0
  84. package/src/components/ui/external-link.tsx +22 -0
  85. package/src/components/ui/fields.tsx +187 -0
  86. package/src/components/ui/frame.tsx +73 -0
  87. package/src/components/ui/index.ts +53 -0
  88. package/src/components/ui/list-view.tsx +166 -0
  89. package/src/components/ui/loading.tsx +70 -0
  90. package/src/components/ui/status.tsx +83 -0
  91. package/src/components/ui/tabs.tsx +47 -0
  92. package/src/components/ui/toggle.tsx +164 -0
  93. package/src/components/ui/ui.test.tsx +134 -0
  94. package/src/data/app-persistence.ts +25 -0
  95. package/src/data/config-store.test.ts +310 -0
  96. package/src/data/config-store.ts +469 -0
  97. package/src/data/plugin-state-store.ts +102 -0
  98. package/src/data/resource-store.ts +323 -0
  99. package/src/data/session-store.ts +52 -0
  100. package/src/data/sqlite-cache.test.ts +103 -0
  101. package/src/data/sqlite-database.ts +62 -0
  102. package/src/data/sqlite-json.ts +19 -0
  103. package/src/data/ticker-repository.ts +45 -0
  104. package/src/data/ticker-store.ts +46 -0
  105. package/src/index.tsx +67 -0
  106. package/src/market-data/coordinator.test.ts +369 -0
  107. package/src/market-data/coordinator.ts +580 -0
  108. package/src/market-data/hooks.tsx +234 -0
  109. package/src/market-data/query-store.ts +28 -0
  110. package/src/market-data/request-types.test.ts +64 -0
  111. package/src/market-data/request-types.ts +70 -0
  112. package/src/market-data/result-types.ts +56 -0
  113. package/src/market-data/selectors.ts +166 -0
  114. package/src/market-data/trace.ts +7 -0
  115. package/src/pane-settings.test.ts +71 -0
  116. package/src/pane-settings.ts +73 -0
  117. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +338 -0
  118. package/src/plugins/builtin/ai/gloomberb-cli.ts +70 -0
  119. package/src/plugins/builtin/ai/index.test.tsx +139 -0
  120. package/src/plugins/builtin/ai/index.tsx +86 -0
  121. package/src/plugins/builtin/ai/providers.ts +99 -0
  122. package/src/plugins/builtin/ai/runner.ts +82 -0
  123. package/src/plugins/builtin/ai/screener-contract.test.ts +117 -0
  124. package/src/plugins/builtin/ai/screener-contract.ts +174 -0
  125. package/src/plugins/builtin/ai/screener-pane.test.tsx +365 -0
  126. package/src/plugins/builtin/ai/screener-pane.tsx +1161 -0
  127. package/src/plugins/builtin/ai/settings.ts +79 -0
  128. package/src/plugins/builtin/ai/ticker-context.ts +64 -0
  129. package/src/plugins/builtin/ai/utils.ts +71 -0
  130. package/src/plugins/builtin/ask-ai.test.tsx +258 -0
  131. package/src/plugins/builtin/ask-ai.tsx +9 -0
  132. package/src/plugins/builtin/chat-controller.test.ts +217 -0
  133. package/src/plugins/builtin/chat-controller.ts +340 -0
  134. package/src/plugins/builtin/chat.test.tsx +451 -0
  135. package/src/plugins/builtin/chat.tsx +620 -0
  136. package/src/plugins/builtin/comparison-chart.test.tsx +277 -0
  137. package/src/plugins/builtin/comparison-chart.tsx +169 -0
  138. package/src/plugins/builtin/data-providers.ts +16 -0
  139. package/src/plugins/builtin/debug.tsx +351 -0
  140. package/src/plugins/builtin/help.test.tsx +110 -0
  141. package/src/plugins/builtin/help.tsx +281 -0
  142. package/src/plugins/builtin/layout-manager.tsx +264 -0
  143. package/src/plugins/builtin/manual-entry.tsx +25 -0
  144. package/src/plugins/builtin/news.tsx +143 -0
  145. package/src/plugins/builtin/notes-files.ts +58 -0
  146. package/src/plugins/builtin/notes.tsx +396 -0
  147. package/src/plugins/builtin/options-availability.test.tsx +179 -0
  148. package/src/plugins/builtin/options-availability.ts +158 -0
  149. package/src/plugins/builtin/options.tsx +364 -0
  150. package/src/plugins/builtin/portfolio-list/header.tsx +227 -0
  151. package/src/plugins/builtin/portfolio-list/index.tsx +99 -0
  152. package/src/plugins/builtin/portfolio-list/metrics.ts +436 -0
  153. package/src/plugins/builtin/portfolio-list/pane.tsx +533 -0
  154. package/src/plugins/builtin/portfolio-list/settings.ts +280 -0
  155. package/src/plugins/builtin/portfolio-list/summary.tsx +270 -0
  156. package/src/plugins/builtin/portfolio-list/table.tsx +64 -0
  157. package/src/plugins/builtin/portfolio-list.test.ts +74 -0
  158. package/src/plugins/builtin/portfolio-list.test.tsx +1164 -0
  159. package/src/plugins/builtin/portfolio-metrics.test.ts +153 -0
  160. package/src/plugins/builtin/quote-monitor.test.tsx +114 -0
  161. package/src/plugins/builtin/sec.tsx +250 -0
  162. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +43 -0
  163. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +390 -0
  164. package/src/plugins/builtin/ticker-detail/index.tsx +80 -0
  165. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +242 -0
  166. package/src/plugins/builtin/ticker-detail/pane.tsx +183 -0
  167. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +83 -0
  168. package/src/plugins/builtin/ticker-detail/settings.ts +132 -0
  169. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +193 -0
  170. package/src/plugins/builtin/ticker-detail.test.tsx +578 -0
  171. package/src/plugins/builtin/yahoo.ts +18 -0
  172. package/src/plugins/event-bus.ts +37 -0
  173. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +35 -0
  174. package/src/plugins/ibkr/account-cache.test.ts +101 -0
  175. package/src/plugins/ibkr/account-cache.ts +137 -0
  176. package/src/plugins/ibkr/broker-adapter.ts +168 -0
  177. package/src/plugins/ibkr/config.test.ts +68 -0
  178. package/src/plugins/ibkr/config.ts +262 -0
  179. package/src/plugins/ibkr/dialogs.tsx +83 -0
  180. package/src/plugins/ibkr/flex.test.ts +107 -0
  181. package/src/plugins/ibkr/flex.ts +267 -0
  182. package/src/plugins/ibkr/fundamental-parser.ts +149 -0
  183. package/src/plugins/ibkr/gateway-helpers.ts +28 -0
  184. package/src/plugins/ibkr/gateway-selection.ts +51 -0
  185. package/src/plugins/ibkr/gateway-service.test.ts +450 -0
  186. package/src/plugins/ibkr/gateway-service.ts +2228 -0
  187. package/src/plugins/ibkr/index.test.tsx +183 -0
  188. package/src/plugins/ibkr/index.tsx +153 -0
  189. package/src/plugins/ibkr/instance-selection.test.ts +121 -0
  190. package/src/plugins/ibkr/instance-selection.ts +36 -0
  191. package/src/plugins/ibkr/price-normalization.test.ts +38 -0
  192. package/src/plugins/ibkr/price-normalization.ts +59 -0
  193. package/src/plugins/ibkr/trade-badge.tsx +27 -0
  194. package/src/plugins/ibkr/trade-tab.test.tsx +225 -0
  195. package/src/plugins/ibkr/trade-tab.tsx +1050 -0
  196. package/src/plugins/ibkr/trade-utils.test.ts +175 -0
  197. package/src/plugins/ibkr/trade-utils.ts +149 -0
  198. package/src/plugins/ibkr/trading-pane.tsx +438 -0
  199. package/src/plugins/ibkr/trading-state.test.ts +109 -0
  200. package/src/plugins/ibkr/trading-state.ts +339 -0
  201. package/src/plugins/loader.ts +71 -0
  202. package/src/plugins/pane-manager.test.ts +103 -0
  203. package/src/plugins/pane-manager.ts +974 -0
  204. package/src/plugins/plugin-persistence.ts +85 -0
  205. package/src/plugins/plugin-runtime.test.tsx +185 -0
  206. package/src/plugins/plugin-runtime.tsx +175 -0
  207. package/src/plugins/prediction-markets/cache.ts +114 -0
  208. package/src/plugins/prediction-markets/categories.ts +223 -0
  209. package/src/plugins/prediction-markets/chart.tsx +512 -0
  210. package/src/plugins/prediction-markets/columns.ts +133 -0
  211. package/src/plugins/prediction-markets/controller-data.ts +614 -0
  212. package/src/plugins/prediction-markets/controller.ts +725 -0
  213. package/src/plugins/prediction-markets/detail/book.tsx +118 -0
  214. package/src/plugins/prediction-markets/detail/outcomes.tsx +102 -0
  215. package/src/plugins/prediction-markets/detail/overview.tsx +73 -0
  216. package/src/plugins/prediction-markets/detail/pane.tsx +308 -0
  217. package/src/plugins/prediction-markets/detail/rules.tsx +16 -0
  218. package/src/plugins/prediction-markets/detail/shared.tsx +29 -0
  219. package/src/plugins/prediction-markets/detail/trades.tsx +30 -0
  220. package/src/plugins/prediction-markets/detail.test.tsx +47 -0
  221. package/src/plugins/prediction-markets/index.tsx +102 -0
  222. package/src/plugins/prediction-markets/keyboard.ts +64 -0
  223. package/src/plugins/prediction-markets/launch.test.ts +126 -0
  224. package/src/plugins/prediction-markets/launch.ts +240 -0
  225. package/src/plugins/prediction-markets/metrics.ts +311 -0
  226. package/src/plugins/prediction-markets/navigation.ts +90 -0
  227. package/src/plugins/prediction-markets/outcome-order.ts +19 -0
  228. package/src/plugins/prediction-markets/pane.test.tsx +425 -0
  229. package/src/plugins/prediction-markets/pane.tsx +294 -0
  230. package/src/plugins/prediction-markets/plugin.test.tsx +570 -0
  231. package/src/plugins/prediction-markets/rows.ts +229 -0
  232. package/src/plugins/prediction-markets/services/fetch.ts +97 -0
  233. package/src/plugins/prediction-markets/services/kalshi-adapter.ts +555 -0
  234. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +660 -0
  235. package/src/plugins/prediction-markets/services/polymarket-ws.ts +108 -0
  236. package/src/plugins/prediction-markets/settings.ts +164 -0
  237. package/src/plugins/prediction-markets/status.ts +24 -0
  238. package/src/plugins/prediction-markets/table.tsx +84 -0
  239. package/src/plugins/prediction-markets/test-helpers.tsx +636 -0
  240. package/src/plugins/prediction-markets/types.ts +186 -0
  241. package/src/plugins/registry.ts +633 -0
  242. package/src/sources/exchange-rates.ts +18 -0
  243. package/src/sources/gloomberb-cloud.test.ts +199 -0
  244. package/src/sources/gloomberb-cloud.ts +326 -0
  245. package/src/sources/provider-errors.ts +19 -0
  246. package/src/sources/provider-router.test.ts +1476 -0
  247. package/src/sources/provider-router.ts +1405 -0
  248. package/src/sources/sec-edgar.test.ts +286 -0
  249. package/src/sources/sec-edgar.ts +526 -0
  250. package/src/sources/yahoo-finance.test.ts +14 -0
  251. package/src/sources/yahoo-finance.ts +1017 -0
  252. package/src/state/app-activity.test.ts +41 -0
  253. package/src/state/app-activity.ts +106 -0
  254. package/src/state/app-bootstrap.test.ts +471 -0
  255. package/src/state/app-bootstrap.ts +308 -0
  256. package/src/state/app-context.test.ts +406 -0
  257. package/src/state/app-context.test.tsx +132 -0
  258. package/src/state/app-context.tsx +969 -0
  259. package/src/state/selectors.ts +67 -0
  260. package/src/state/session-persistence.test.ts +128 -0
  261. package/src/state/session-persistence.ts +161 -0
  262. package/src/state/ticker-refresh-queue.test.ts +46 -0
  263. package/src/state/ticker-refresh-queue.ts +40 -0
  264. package/src/state/use-inline-tickers.ts +174 -0
  265. package/src/state/use-quote-streaming.test.ts +37 -0
  266. package/src/state/use-quote-streaming.test.tsx +70 -0
  267. package/src/state/use-quote-streaming.ts +80 -0
  268. package/src/state/use-session-snapshot.ts +84 -0
  269. package/src/test-support/data-provider.ts +22 -0
  270. package/src/theme/color-utils.ts +67 -0
  271. package/src/theme/colors.ts +128 -0
  272. package/src/theme/themes.test.ts +45 -0
  273. package/src/theme/themes.ts +491 -0
  274. package/src/types/broker.ts +105 -0
  275. package/src/types/config.ts +434 -0
  276. package/src/types/data-provider.ts +81 -0
  277. package/src/types/financials.ts +142 -0
  278. package/src/types/instrument.ts +29 -0
  279. package/src/types/persistence.ts +21 -0
  280. package/src/types/plugin.ts +354 -0
  281. package/src/types/ticker.ts +58 -0
  282. package/src/types/trading.ts +85 -0
  283. package/src/updater.test.ts +162 -0
  284. package/src/updater.ts +247 -0
  285. package/src/utils/api-client.ts +666 -0
  286. package/src/utils/ascii-chart.ts +172 -0
  287. package/src/utils/broker-instances.ts +47 -0
  288. package/src/utils/cli-output.ts +83 -0
  289. package/src/utils/currency-units.test.ts +49 -0
  290. package/src/utils/currency-units.ts +82 -0
  291. package/src/utils/debug-log.ts +133 -0
  292. package/src/utils/exchange-rate-map.ts +8 -0
  293. package/src/utils/format.ts +158 -0
  294. package/src/utils/fuzzy-search.ts +34 -0
  295. package/src/utils/market-status.ts +95 -0
  296. package/src/utils/options.ts +86 -0
  297. package/src/utils/price-history.ts +39 -0
  298. package/src/utils/quote-time.test.ts +22 -0
  299. package/src/utils/quote-time.ts +50 -0
  300. package/src/utils/sec.test.ts +55 -0
  301. package/src/utils/sec.ts +45 -0
  302. package/src/utils/ticker-list.test.ts +22 -0
  303. package/src/utils/ticker-list.ts +35 -0
  304. package/src/utils/ticker-metadata.ts +51 -0
  305. package/src/utils/ticker-search.test.ts +227 -0
  306. package/src/utils/ticker-search.ts +764 -0
  307. package/src/utils/ticker-tokenizer.test.ts +48 -0
  308. package/src/utils/ticker-tokenizer.ts +72 -0
  309. package/src/version.ts +1 -0
  310. package/install.mjs +0 -68
@@ -0,0 +1,82 @@
1
+ import type { PricePoint } from "../../types/financials";
2
+
3
+ export type TimeRange = "1W" | "1M" | "3M" | "6M" | "1Y" | "5Y" | "ALL";
4
+ export type ChartRenderMode = "area" | "line" | "candles" | "ohlc";
5
+ export type ChartAxisMode = "price" | "percent";
6
+ export type ChartRendererPreference = "auto" | "kitty" | "braille";
7
+ export type ResolvedChartRenderer = "kitty" | "braille";
8
+ export type ComparisonChartRenderMode = "area" | "line";
9
+
10
+ export const TIME_RANGES: TimeRange[] = ["1W", "1M", "3M", "6M", "1Y", "5Y", "ALL"];
11
+ export const CHART_RENDER_MODES: ChartRenderMode[] = ["area", "line", "candles", "ohlc"];
12
+ export const CHART_AXIS_MODES: ChartAxisMode[] = ["price", "percent"];
13
+ export const CHART_RENDERER_PREFERENCES: ChartRendererPreference[] = ["auto", "kitty", "braille"];
14
+ export const COMPARISON_RENDER_MODES: ComparisonChartRenderMode[] = ["area", "line"];
15
+
16
+ /** Number of trading days for each time range */
17
+ export const RANGE_DAYS: Record<TimeRange, number> = {
18
+ "1W": 5,
19
+ "1M": 21,
20
+ "3M": 63,
21
+ "6M": 126,
22
+ "1Y": 252,
23
+ "5Y": 1260,
24
+ "ALL": Infinity,
25
+ };
26
+
27
+ export interface ChartViewState {
28
+ timeRange: TimeRange;
29
+ panOffset: number; // data points shifted left from right edge (0 = most recent)
30
+ zoomLevel: number; // 1.0 = full selected range, 2.0 = zoomed in 2x
31
+ cursorX: number | null; // local plot x in cell units; may be fractional with pixel mouse
32
+ cursorY: number | null; // local plot y in cell units; may be fractional with pixel mouse
33
+ renderMode?: ChartRenderMode;
34
+ }
35
+
36
+ export interface Pixel {
37
+ color: string;
38
+ layer: number;
39
+ }
40
+
41
+ export interface PixelBuffer {
42
+ width: number;
43
+ height: number; // virtual pixel rows (4x terminal rows for braille rendering)
44
+ pixels: (Pixel | null)[][]; // [y][x] = pixel with color and layer, or null
45
+ }
46
+
47
+ export interface ChartColors {
48
+ lineColor: string;
49
+ fillColor: string;
50
+ volumeUp: string;
51
+ volumeDown: string;
52
+ gridColor: string;
53
+ crosshairColor: string;
54
+ bgColor: string;
55
+ axisColor: string;
56
+ activeRangeColor: string;
57
+ inactiveRangeColor: string;
58
+ }
59
+
60
+ export interface VisibleWindow {
61
+ points: PricePoint[];
62
+ startIdx: number;
63
+ endIdx: number;
64
+ }
65
+
66
+ export interface ComparisonChartSeries {
67
+ symbol: string;
68
+ color: string;
69
+ fillColor: string;
70
+ currency?: string;
71
+ points: PricePoint[];
72
+ }
73
+
74
+ export interface ComparisonChartViewState {
75
+ timeRange: TimeRange;
76
+ panOffset: number;
77
+ zoomLevel: number; // 1.0 = full selected range, 2.0 = zoomed in 2x
78
+ cursorX: number | null;
79
+ cursorY: number | null;
80
+ renderMode?: ComparisonChartRenderMode;
81
+ selectedSymbol: string | null;
82
+ }
@@ -0,0 +1,21 @@
1
+ const MIN_VISIBLE_POINTS = 10;
2
+
3
+ function clamp(value: number, min: number, max: number): number {
4
+ return Math.max(min, Math.min(max, value));
5
+ }
6
+
7
+ export function getMaxChartZoom(totalPoints: number): number {
8
+ if (totalPoints <= 0) return 1;
9
+ return Math.max(1, totalPoints / MIN_VISIBLE_POINTS);
10
+ }
11
+
12
+ export function clampChartZoom(totalPoints: number, zoomLevel: number): number {
13
+ return clamp(zoomLevel, 1, getMaxChartZoom(totalPoints));
14
+ }
15
+
16
+ export function getVisiblePointCount(totalPoints: number, zoomLevel: number): number {
17
+ if (totalPoints <= 0) return 0;
18
+
19
+ const clampedZoom = clampChartZoom(totalPoints, zoomLevel);
20
+ return Math.min(totalPoints, Math.max(Math.floor(totalPoints / clampedZoom), MIN_VISIBLE_POINTS));
21
+ }
@@ -0,0 +1,439 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { bucketOhlcSeries, getVisibleWindow, projectChartData, resolveRenderMode } from "./chart-data";
3
+ import { stepCursorTowards } from "./cursor-motion";
4
+ import { buildTimeAxis, formatAxisValue, renderChart, resolveChartPalette } from "./chart-renderer";
5
+ import type { PricePoint } from "../../types/financials";
6
+ import type { ChartRenderMode, ChartViewState } from "./chart-types";
7
+
8
+ const aggregationFixture: PricePoint[] = [
9
+ { date: new Date("2024-01-02"), close: 10, volume: 100 },
10
+ { date: new Date("2024-01-03"), open: 11, high: 14, low: 9, close: 13, volume: 150 },
11
+ { date: new Date("2024-01-04"), open: 13, high: 15, low: 12, close: 14, volume: 200 },
12
+ { date: new Date("2024-01-05"), open: 14, high: 16, low: 10, close: 11, volume: 250 },
13
+ ];
14
+
15
+ const chartFixture: PricePoint[] = [
16
+ { date: new Date("2024-01-02"), open: 10, high: 12, low: 9, close: 11, volume: 100 },
17
+ { date: new Date("2024-01-03"), open: 11, high: 13, low: 10, close: 12, volume: 120 },
18
+ { date: new Date("2024-01-04"), open: 12, high: 12.5, low: 8, close: 9, volume: 140 },
19
+ { date: new Date("2024-01-05"), open: 9, high: 11, low: 8.5, close: 10.5, volume: 160 },
20
+ ];
21
+
22
+ function buildDenseHistory(length: number): PricePoint[] {
23
+ return Array.from({ length }, (_, index) => ({
24
+ date: new Date(Date.UTC(2024, 0, index + 1)),
25
+ close: 100 + index,
26
+ volume: 1_000 + index,
27
+ }));
28
+ }
29
+
30
+ const palette = resolveChartPalette({
31
+ bg: "#000000",
32
+ border: "#333333",
33
+ borderFocused: "#ffff00",
34
+ text: "#ffffff",
35
+ textDim: "#777777",
36
+ positive: "#00ff00",
37
+ negative: "#ff0000",
38
+ }, "positive");
39
+
40
+ function textLines(result: ReturnType<typeof renderChart>): string[] {
41
+ return result.lines.map((line) => line.chunks.map((chunk) => chunk.text).join(""));
42
+ }
43
+
44
+ describe("bucketOhlcSeries", () => {
45
+ test("preserves bucket open/close/high/low/volume with missing OHLC fields", () => {
46
+ const buckets = bucketOhlcSeries(aggregationFixture, 2);
47
+
48
+ expect(buckets).toHaveLength(2);
49
+ expect(buckets[0]).toEqual({
50
+ date: new Date("2024-01-03"),
51
+ open: 10,
52
+ high: 14,
53
+ low: 9,
54
+ close: 13,
55
+ volume: 250,
56
+ });
57
+ expect(buckets[1]).toEqual({
58
+ date: new Date("2024-01-05"),
59
+ open: 13,
60
+ high: 16,
61
+ low: 10,
62
+ close: 11,
63
+ volume: 450,
64
+ });
65
+ });
66
+ });
67
+
68
+ describe("resolveRenderMode", () => {
69
+ test("applies candle fallback thresholds", () => {
70
+ expect(resolveRenderMode("candles", 27)).toMatchObject({ effectiveMode: "line", fallbackMode: "line" });
71
+ expect(resolveRenderMode("candles", 28)).toMatchObject({ effectiveMode: "ohlc", fallbackMode: "ohlc" });
72
+ expect(resolveRenderMode("candles", 39)).toMatchObject({ effectiveMode: "ohlc", fallbackMode: "ohlc" });
73
+ expect(resolveRenderMode("candles", 40)).toMatchObject({ effectiveMode: "candles", fallbackMode: null });
74
+ });
75
+
76
+ test("forces compact charts back to area mode", () => {
77
+ const projection = projectChartData(chartFixture, 80, "candles", true);
78
+ expect(projection.requestedMode).toBe("area");
79
+ expect(projection.effectiveMode).toBe("area");
80
+ expect(projection.fallbackMode).toBeNull();
81
+ });
82
+
83
+ test("uses fewer projected buckets for candles and ohlc to preserve spacing", () => {
84
+ const denseSeries = Array.from({ length: 100 }, (_, i) => ({
85
+ date: new Date(2024, 0, i + 1),
86
+ open: 100 + i,
87
+ high: 101 + i,
88
+ low: 99 + i,
89
+ close: 100.5 + i,
90
+ volume: 1_000 + i,
91
+ })) as PricePoint[];
92
+
93
+ expect(projectChartData(denseSeries, 80, "candles", false).points).toHaveLength(40);
94
+ expect(projectChartData(denseSeries, 80, "ohlc", false).points).toHaveLength(40);
95
+ expect(projectChartData(denseSeries, 80, "line", false).points).toHaveLength(80);
96
+ });
97
+ });
98
+
99
+ describe("getVisibleWindow", () => {
100
+ test("shows the full selected range at default zoom and lets the renderer downsample it", () => {
101
+ const history = buildDenseHistory(252);
102
+ const viewState: ChartViewState = {
103
+ timeRange: "1Y",
104
+ panOffset: 0,
105
+ zoomLevel: 1,
106
+ cursorX: null,
107
+ cursorY: null,
108
+ };
109
+
110
+ const window = getVisibleWindow(history, viewState, 80);
111
+
112
+ expect(window.points).toHaveLength(history.length);
113
+ expect(window.points[0]?.date.toISOString()).toBe(history[0]?.date.toISOString());
114
+ expect(window.points.at(-1)?.date.toISOString()).toBe(history.at(-1)?.date.toISOString());
115
+ });
116
+
117
+ test("zooms into the selected range instead of pinning the default view to chart width", () => {
118
+ const history = buildDenseHistory(252);
119
+ const viewState: ChartViewState = {
120
+ timeRange: "1Y",
121
+ panOffset: 0,
122
+ zoomLevel: 2,
123
+ cursorX: null,
124
+ cursorY: null,
125
+ };
126
+
127
+ const window = getVisibleWindow(history, viewState, 80);
128
+
129
+ expect(window.points).toHaveLength(126);
130
+ expect(window.points[0]?.date.toISOString()).toBe(history[126]?.date.toISOString());
131
+ expect(window.points.at(-1)?.date.toISOString()).toBe(history.at(-1)?.date.toISOString());
132
+ });
133
+ });
134
+
135
+ describe("renderChart", () => {
136
+ test("formats price axes with the instrument currency", () => {
137
+ expect(formatAxisValue(21_970, "price", 0, "JPY")).toBe("¥22.0K");
138
+ expect(formatAxisValue(12_340, "price", 0, "HKD")).toBe("HK$12.3K");
139
+ });
140
+
141
+ test("eases coarse cursor motion quickly and settles without overshooting", () => {
142
+ let current: { x: number | null; y: number | null } = { x: 2, y: 1 };
143
+ const target = { x: 6, y: 4 };
144
+
145
+ const firstStep = stepCursorTowards(current, target);
146
+ expect(firstStep.settled).toBe(false);
147
+ expect(firstStep.next.x!).toBeGreaterThan(current.x!);
148
+ expect(firstStep.next.x!).toBeLessThan(target.x);
149
+ expect(firstStep.next.y!).toBeGreaterThan(current.y!);
150
+ expect(firstStep.next.y!).toBeLessThan(target.y);
151
+
152
+ current = firstStep.next;
153
+ for (let iteration = 0; iteration < 8; iteration += 1) {
154
+ const nextStep = stepCursorTowards(current, target);
155
+ current = nextStep.next;
156
+ if (nextStep.settled) break;
157
+ }
158
+
159
+ expect(current).toEqual(target);
160
+ });
161
+
162
+ test("snaps directly when the coarse cursor has no active position", () => {
163
+ expect(stepCursorTowards({ x: null, y: null }, { x: 8, y: 3 })).toEqual({
164
+ next: { x: 8, y: 3 },
165
+ settled: true,
166
+ });
167
+ });
168
+
169
+ test("compresses dense short-range x-axis labels without repeating the month on every tick", () => {
170
+ const dates = Array.from({ length: 31 }, (_, index) => new Date(2026, 0, index + 1));
171
+
172
+ expect(buildTimeAxis(dates, 72)).toBe(
173
+ "Jan 1 4 8 11 14 18 21 24 28 Jan 31",
174
+ );
175
+ });
176
+
177
+ test("uses times instead of repeating the same calendar date for intraday ranges", () => {
178
+ const dates = Array.from({ length: 13 }, (_, index) => new Date(2026, 0, 5, 9, 30 + index * 30));
179
+
180
+ expect(buildTimeAxis(dates, 72)).toBe(
181
+ "09:30 10:30 11:00 12:00 13:00 14:00 14:30 15:30",
182
+ );
183
+ });
184
+
185
+ test("shows second precision when the visible window reaches second-level data", () => {
186
+ const dates = Array.from({ length: 6 }, (_, index) => new Date(2026, 0, 5, 9, 30, index * 10));
187
+
188
+ expect(buildTimeAxis(dates, 60)).toBe(
189
+ "09:30:00 09:30:20 09:30:30 09:30:50",
190
+ );
191
+ });
192
+
193
+ test("collapses identical timestamps into a single centered label", () => {
194
+ const dates = Array.from({ length: 20 }, () => new Date(2026, 2, 29, 9, 0, 0, 0));
195
+
196
+ expect(buildTimeAxis(dates, 96)).toBe(
197
+ " 09:00:00.000 ",
198
+ );
199
+ });
200
+
201
+ test("coarsens to month and year labels for longer spans", () => {
202
+ const monthlyDates = Array.from({ length: 12 }, (_, index) => new Date(2025, index, 1));
203
+ const yearlyDates = Array.from({ length: 6 }, (_, index) => new Date(2020 + index, 0, 1));
204
+
205
+ expect(buildTimeAxis(monthlyDates, 48)).toBe("Jan 2025 May Aug Dec 2025");
206
+ expect(buildTimeAxis(yearlyDates, 48)).toBe("2020 2021 2022 2023 2024 2025");
207
+ });
208
+
209
+ test("maps active bucket metadata across all render modes", () => {
210
+ const cases: Array<{ mode: ChartRenderMode; width: number; cursorX: number }> = [
211
+ { mode: "area", width: 12, cursorX: 4 },
212
+ { mode: "line", width: 12, cursorX: 4 },
213
+ { mode: "candles", width: 40, cursorX: 13 },
214
+ { mode: "ohlc", width: 28, cursorX: 9 },
215
+ ];
216
+
217
+ for (const testCase of cases) {
218
+ const projection = projectChartData(chartFixture, testCase.width, testCase.mode, false);
219
+ const result = renderChart(projection.points, {
220
+ width: testCase.width,
221
+ height: 6,
222
+ showVolume: false,
223
+ volumeHeight: 0,
224
+ cursorX: testCase.cursorX,
225
+ cursorY: null,
226
+ mode: projection.effectiveMode,
227
+ colors: palette,
228
+ });
229
+
230
+ expect(result.activePoint?.date.toISOString()).toBe("2024-01-03T00:00:00.000Z");
231
+ expect(result.activePoint?.close).toBe(12);
232
+ }
233
+ });
234
+
235
+ test("keeps the visual cursor free-running on both axes", () => {
236
+ const cases: Array<{ mode: ChartRenderMode; width: number; height: number; cursorX: number; cursorY: number }> = [
237
+ { mode: "area", width: 12, height: 6, cursorX: 5, cursorY: 4 },
238
+ { mode: "candles", width: 40, height: 6, cursorX: 13, cursorY: 3 },
239
+ { mode: "ohlc", width: 28, height: 6, cursorX: 9, cursorY: 1 },
240
+ ];
241
+
242
+ for (const testCase of cases) {
243
+ const projection = projectChartData(chartFixture, testCase.width, testCase.mode, false);
244
+ const result = renderChart(projection.points, {
245
+ width: testCase.width,
246
+ height: testCase.height,
247
+ showVolume: false,
248
+ volumeHeight: 0,
249
+ cursorX: testCase.cursorX,
250
+ cursorY: testCase.cursorY,
251
+ mode: projection.effectiveMode,
252
+ colors: palette,
253
+ });
254
+
255
+ expect(result.cursorColumn).toBe(testCase.cursorX);
256
+ expect(result.cursorRow).toBe(testCase.cursorY);
257
+ }
258
+ });
259
+
260
+ test("uses fractional cursor rows for crosshair price readout", () => {
261
+ const projection = projectChartData(chartFixture, 12, "line", false);
262
+ const result = renderChart(projection.points, {
263
+ width: 12,
264
+ height: 6,
265
+ showVolume: false,
266
+ volumeHeight: 0,
267
+ cursorX: 5.25,
268
+ cursorY: 2.5,
269
+ mode: projection.effectiveMode,
270
+ colors: palette,
271
+ });
272
+
273
+ expect(result.cursorColumn).toBe(5);
274
+ expect(result.cursorRow).toBe(3);
275
+ expect(result.crosshairPrice).toBeCloseTo(10.5, 5);
276
+ });
277
+
278
+ test("renders stable terminal shapes for each chart mode", () => {
279
+ const cases: Array<{ mode: ChartRenderMode; width: number; height: number; lines: string[]; timeLabels: string }> = [
280
+ {
281
+ mode: "area",
282
+ width: 12,
283
+ height: 5,
284
+ lines: [
285
+ "⠁ ⡠⠔⢣ ⠁ ⠁ ",
286
+ "⠔⠊⣿⣿⠈⢆⠄ ⠄ ",
287
+ "⣿⣿⣿⣿⣿⠘⡄ ⡠",
288
+ "⣿⣿⣿⣿⣿⣿⠱⡀ ⢠⠊⣿",
289
+ "⣿⣿⣿⣿⣿⣿⣿⢣⠔⠁⣿⣿",
290
+ ],
291
+ timeLabels: "Jan 2 Jan 5",
292
+ },
293
+ {
294
+ mode: "line",
295
+ width: 12,
296
+ height: 5,
297
+ lines: [
298
+ "⠁ ⡠⠔⢣ ⠁ ⠁ ",
299
+ "⠔⠊ ⠄⠈⢆⠄ ⠄ ",
300
+ " ⠘⡄ ⡠",
301
+ "⠂ ⠂ ⠱⡀ ⢠⠊ ",
302
+ "⡀ ⡀ ⡀⢣⠔⠁ ",
303
+ ],
304
+ timeLabels: "Jan 2 Jan 5",
305
+ },
306
+ {
307
+ mode: "candles",
308
+ width: 40,
309
+ height: 6,
310
+ lines: [
311
+ "⠁ ⠁ ⠁ ⠁ ⠁ ⡇⠁ ⠁ ⠁ ⠁⢠ ⠁ ⠁ ⠁ ⠁ ⠁",
312
+ " ⢰ ⢰⣶⣶⣷⣶⣶ ⣶⣶⣾⣶⣶⡆ ",
313
+ "⣶⣶⣾⣶⣶⡆⠁ ⠁ ⠘⠛⠛⡟⠛⠛ ⠁ ⠁ ⣿⣿⣿⣿⣿⡇ ⠁ ⠁ ⠁⡆ ⠁",
314
+ "⠿⠿⢿⠿⠿⠇⡀ ⡀ ⡀ ⠇⡀ ⡀ ⡀ ⣿⣿⣿⣿⣿⡇ ⡀ ⡀⢸⣿⣿⣿⣿⣿",
315
+ " ⠸ ⠿⠿⢿⠿⠿⠇ ⠸⠿⠿⡿⠿⠿",
316
+ "⡀ ⡀ ⡀ ⡀ ⡀ ⡀ ⡀ ⡀ ⡀⢸ ⡀ ⡀ ⡀ ⡀⠃ ⡀",
317
+ ],
318
+ timeLabels: "Jan 2 3 4 Jan 5",
319
+ },
320
+ {
321
+ mode: "ohlc",
322
+ width: 28,
323
+ height: 6,
324
+ lines: [
325
+ "⠁ ⠁ ⠁ ⢸ ⠁ ⠁ ⡄ ⠁ ⠁ ⠁",
326
+ " ⢰ ⢸⠒⠂ ⠐⠒⡇ ",
327
+ "⠁⢸⠒⠂ ⠁ ⠒⢺ ⠁ ⠁ ⡇ ⠁ ⠁ ⡆⠁",
328
+ "⠤⢼ ⡀ ⡀ ⠸ ⡀ ⡀ ⡇ ⡀ ⡀ ⡏⠉",
329
+ " ⠸ ⡧⠤ ⠠⠤⡇ ",
330
+ "⡀ ⡀ ⡀ ⡀ ⡀ ⡀ ⡇ ⡀ ⡀ ⠃⡀",
331
+ ],
332
+ timeLabels: "Jan 2 3 4 Jan 5",
333
+ },
334
+ ];
335
+
336
+ for (const testCase of cases) {
337
+ const projection = projectChartData(chartFixture, testCase.width, testCase.mode, false);
338
+ const result = renderChart(projection.points, {
339
+ width: testCase.width,
340
+ height: testCase.height,
341
+ showVolume: false,
342
+ volumeHeight: 0,
343
+ cursorX: null,
344
+ cursorY: null,
345
+ mode: projection.effectiveMode,
346
+ colors: palette,
347
+ });
348
+
349
+ expect(textLines(result)).toEqual(testCase.lines);
350
+ expect(result.timeLabels).toBe(testCase.timeLabels);
351
+ }
352
+ });
353
+
354
+ test("keeps the default area renderer stable", () => {
355
+ const projection = projectChartData(chartFixture, 12, undefined, false);
356
+ const result = renderChart(projection.points, {
357
+ width: 12,
358
+ height: 5,
359
+ showVolume: false,
360
+ volumeHeight: 0,
361
+ cursorX: null,
362
+ cursorY: null,
363
+ mode: projection.effectiveMode,
364
+ colors: palette,
365
+ });
366
+
367
+ expect(projection.requestedMode).toBe("area");
368
+ expect(projection.effectiveMode).toBe("area");
369
+ expect(textLines(result)).toEqual([
370
+ "⠁ ⡠⠔⢣ ⠁ ⠁ ",
371
+ "⠔⠊⣿⣿⠈⢆⠄ ⠄ ",
372
+ "⣿⣿⣿⣿⣿⠘⡄ ⡠",
373
+ "⣿⣿⣿⣿⣿⣿⠱⡀ ⢠⠊⣿",
374
+ "⣿⣿⣿⣿⣿⣿⣿⢣⠔⠁⣿⣿",
375
+ ]);
376
+ });
377
+
378
+ test("formats y-axis labels as percent change when requested", () => {
379
+ const projection = projectChartData(chartFixture, 12, "line", false);
380
+ const result = renderChart(projection.points, {
381
+ width: 12,
382
+ height: 5,
383
+ showVolume: false,
384
+ volumeHeight: 0,
385
+ cursorX: null,
386
+ mode: projection.effectiveMode,
387
+ axisMode: "percent",
388
+ colors: palette,
389
+ });
390
+
391
+ expect(result.axisLabels).toEqual([
392
+ { row: 0, label: "+9.09%" },
393
+ { row: 1, label: "0.00%" },
394
+ { row: 3, label: "-9.09%" },
395
+ { row: 4, label: "-18.2%" },
396
+ ]);
397
+ });
398
+
399
+ test("uses the supplied currency for rendered price-axis labels", () => {
400
+ const projection = projectChartData(chartFixture, 12, "line", false);
401
+ const result = renderChart(projection.points, {
402
+ width: 12,
403
+ height: 5,
404
+ showVolume: false,
405
+ volumeHeight: 0,
406
+ cursorX: null,
407
+ cursorY: null,
408
+ mode: projection.effectiveMode,
409
+ axisMode: "price",
410
+ currency: "JPY",
411
+ colors: palette,
412
+ });
413
+
414
+ expect(result.axisLabels.some((entry) => entry.label.includes("¥"))).toBe(true);
415
+ expect(result.axisLabels.every((entry) => !entry.label.includes("$"))).toBe(true);
416
+ });
417
+
418
+ test("accepts serialized string dates from cached chart data", () => {
419
+ const serialized = chartFixture.map((point) => ({
420
+ ...point,
421
+ date: point.date.toISOString(),
422
+ })) as unknown as PricePoint[];
423
+
424
+ const projection = projectChartData(serialized, 12, "area", false);
425
+ const result = renderChart(projection.points, {
426
+ width: 12,
427
+ height: 5,
428
+ showVolume: false,
429
+ volumeHeight: 0,
430
+ cursorX: null,
431
+ cursorY: null,
432
+ mode: projection.effectiveMode,
433
+ colors: palette,
434
+ });
435
+
436
+ expect(projection.points[0]?.date instanceof Date).toBe(true);
437
+ expect(result.timeLabels).toBe("Jan 2 Jan 5");
438
+ });
439
+ });
@@ -0,0 +1,109 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { getVisibleComparisonWindow, projectComparisonChartData } from "./comparison-chart-data";
3
+ import type { ComparisonChartSeries } from "./chart-types";
4
+
5
+ function makeSeries(symbol: string, currency: string, closes: Array<[string, number]>): ComparisonChartSeries {
6
+ return {
7
+ symbol,
8
+ color: "#ffffff",
9
+ fillColor: "#444444",
10
+ currency,
11
+ points: closes.map(([date, close]) => ({
12
+ date: new Date(date),
13
+ close,
14
+ })),
15
+ };
16
+ }
17
+
18
+ describe("projectComparisonChartData", () => {
19
+ test("shows the full selected date range at default zoom", () => {
20
+ const closes = Array.from({ length: 120 }, (_, index) => [
21
+ new Date(Date.UTC(2024, 0, index + 1)).toISOString().slice(0, 10),
22
+ 100 + index,
23
+ ] as [string, number]);
24
+
25
+ const window = getVisibleComparisonWindow([
26
+ makeSeries("AAPL", "USD", closes),
27
+ ], {
28
+ timeRange: "ALL",
29
+ panOffset: 0,
30
+ zoomLevel: 1,
31
+ }, 40);
32
+
33
+ expect(window.dates).toHaveLength(120);
34
+ expect(window.dates[0]?.toISOString()).toBe("2024-01-01T00:00:00.000Z");
35
+ expect(window.dates.at(-1)?.toISOString()).toBe("2024-04-29T00:00:00.000Z");
36
+ });
37
+
38
+ test("aligns multiple series on a shared date axis and preserves gaps", () => {
39
+ const projection = projectComparisonChartData([
40
+ makeSeries("AAPL", "USD", [
41
+ ["2024-01-02", 10],
42
+ ["2024-01-03", 11],
43
+ ["2024-01-04", 12],
44
+ ["2024-01-05", 13],
45
+ ]),
46
+ makeSeries("MSFT", "USD", [
47
+ ["2024-01-04", 20],
48
+ ["2024-01-05", 22],
49
+ ]),
50
+ ], 8, {
51
+ timeRange: "ALL",
52
+ panOffset: 0,
53
+ zoomLevel: 1,
54
+ renderMode: "line",
55
+ }, "percent");
56
+
57
+ expect(projection.dates).toHaveLength(4);
58
+ expect(projection.series[0]?.points.map((point) => point.rawValue)).toEqual([10, 11, 12, 13]);
59
+ expect(projection.series[1]?.points.map((point) => point.rawValue)).toEqual([null, null, 20, 22]);
60
+ });
61
+
62
+ test("normalizes percent mode from each series first visible point", () => {
63
+ const projection = projectComparisonChartData([
64
+ makeSeries("AAPL", "USD", [
65
+ ["2024-01-02", 100],
66
+ ["2024-01-03", 110],
67
+ ["2024-01-04", 120],
68
+ ]),
69
+ makeSeries("NVDA", "USD", [
70
+ ["2024-01-02", 50],
71
+ ["2024-01-03", 55],
72
+ ["2024-01-04", 60],
73
+ ]),
74
+ ], 8, {
75
+ timeRange: "ALL",
76
+ panOffset: 0,
77
+ zoomLevel: 1,
78
+ renderMode: "line",
79
+ }, "percent");
80
+
81
+ expect(projection.effectiveAxisMode).toBe("percent");
82
+ expect(projection.series[0]?.points.map((point) => point.value)).toEqual([0, 10, 20]);
83
+ expect(projection.series[1]?.points.map((point) => point.value)).toEqual([0, 10, 20]);
84
+ });
85
+
86
+ test("forces percent mode when price overlays mix currencies", () => {
87
+ const projection = projectComparisonChartData([
88
+ makeSeries("AAPL", "USD", [
89
+ ["2024-01-02", 100],
90
+ ["2024-01-03", 110],
91
+ ]),
92
+ makeSeries("7203", "JPY", [
93
+ ["2024-01-02", 2000],
94
+ ["2024-01-03", 2100],
95
+ ]),
96
+ ], 8, {
97
+ timeRange: "ALL",
98
+ panOffset: 0,
99
+ zoomLevel: 1,
100
+ renderMode: "line",
101
+ }, "price");
102
+
103
+ expect(projection.requestedAxisMode).toBe("price");
104
+ expect(projection.effectiveAxisMode).toBe("percent");
105
+ expect(projection.warning).toContain("Mixed currencies");
106
+ expect(projection.series[0]?.points[0]?.value).toBe(0);
107
+ expect(projection.series[1]?.points[0]?.value).toBe(0);
108
+ });
109
+ });