gloomberb 0.1.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 (311) hide show
  1. package/LICENSE +25 -0
  2. package/README.md +68 -121
  3. package/bin/gloomberb +3 -0
  4. package/package.json +13 -4
  5. package/patches/@opentui%2Fcore@0.1.90.patch +356 -0
  6. package/src/app.tsx +1330 -0
  7. package/src/brokers/sync-broker-instance.test.ts +100 -0
  8. package/src/brokers/sync-broker-instance.ts +341 -0
  9. package/src/cli/commands/plugins.ts +173 -0
  10. package/src/cli/commands/portfolio.ts +210 -0
  11. package/src/cli/commands/search.ts +120 -0
  12. package/src/cli/commands/ticker.ts +375 -0
  13. package/src/cli/commands/watchlist.ts +210 -0
  14. package/src/cli/context.ts +62 -0
  15. package/src/cli/errors.ts +13 -0
  16. package/src/cli/helpers.ts +85 -0
  17. package/src/cli/index.ts +143 -0
  18. package/src/cli/ticker-resolution.ts +39 -0
  19. package/src/cli/types.ts +15 -0
  20. package/src/cli.test.ts +361 -0
  21. package/src/components/chart/chart-data.ts +239 -0
  22. package/src/components/chart/chart-renderer.ts +1199 -0
  23. package/src/components/chart/chart-types.ts +82 -0
  24. package/src/components/chart/chart-viewport.ts +21 -0
  25. package/src/components/chart/chart.test.ts +439 -0
  26. package/src/components/chart/comparison-chart-data.test.ts +109 -0
  27. package/src/components/chart/comparison-chart-data.ts +316 -0
  28. package/src/components/chart/comparison-chart-renderer.test.ts +84 -0
  29. package/src/components/chart/comparison-chart-renderer.ts +354 -0
  30. package/src/components/chart/comparison-stock-chart.tsx +1486 -0
  31. package/src/components/chart/cursor-motion.ts +48 -0
  32. package/src/components/chart/native/chart-rasterizer.ts +732 -0
  33. package/src/components/chart/native/kitty-adapter.ts +12 -0
  34. package/src/components/chart/native/kitty-manager.ts +142 -0
  35. package/src/components/chart/native/kitty-protocol.ts +110 -0
  36. package/src/components/chart/native/kitty-support.ts +71 -0
  37. package/src/components/chart/native/kitty.test.ts +620 -0
  38. package/src/components/chart/native/renderer-selection.ts +24 -0
  39. package/src/components/chart/native/surface-manager.ts +225 -0
  40. package/src/components/chart/native/surface-sync.ts +34 -0
  41. package/src/components/chart/stock-chart-renderer-switch.test.tsx +165 -0
  42. package/src/components/chart/stock-chart.test.ts +107 -0
  43. package/src/components/chart/stock-chart.tsx +1556 -0
  44. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +137 -0
  45. package/src/components/command-bar/command-bar.test.tsx +1524 -0
  46. package/src/components/command-bar/command-bar.tsx +4823 -0
  47. package/src/components/command-bar/command-registry.ts +261 -0
  48. package/src/components/command-bar/helpers.test.ts +224 -0
  49. package/src/components/command-bar/helpers.ts +371 -0
  50. package/src/components/command-bar/root-shortcuts.ts +158 -0
  51. package/src/components/command-bar/view-model.test.ts +152 -0
  52. package/src/components/command-bar/view-model.ts +185 -0
  53. package/src/components/command-bar/workflow-ops.test.ts +70 -0
  54. package/src/components/command-bar/workflow-ops.ts +433 -0
  55. package/src/components/command-bar/workflow-types.ts +127 -0
  56. package/src/components/detail-feed-view.test.tsx +105 -0
  57. package/src/components/detail-feed-view.tsx +295 -0
  58. package/src/components/index.ts +32 -0
  59. package/src/components/layout/floating-pane.tsx +49 -0
  60. package/src/components/layout/header.tsx +129 -0
  61. package/src/components/layout/pane-header.tsx +44 -0
  62. package/src/components/layout/pane.tsx +49 -0
  63. package/src/components/layout/pixel-mouse-mode.test.ts +122 -0
  64. package/src/components/layout/pixel-mouse.test.ts +40 -0
  65. package/src/components/layout/shell.test.tsx +625 -0
  66. package/src/components/layout/shell.tsx +1200 -0
  67. package/src/components/layout/status-bar.test.tsx +173 -0
  68. package/src/components/layout/status-bar.tsx +138 -0
  69. package/src/components/markdown-editor.tsx +134 -0
  70. package/src/components/onboarding/onboarding-wizard.test.tsx +184 -0
  71. package/src/components/onboarding/onboarding-wizard.tsx +1365 -0
  72. package/src/components/pane-settings-dialog.tsx +468 -0
  73. package/src/components/pane-template-wizard.test.tsx +52 -0
  74. package/src/components/pane-template-wizard.tsx +199 -0
  75. package/src/components/price-selector-dialog.tsx +195 -0
  76. package/src/components/spinner.tsx +2 -0
  77. package/src/components/tab-bar.tsx +17 -0
  78. package/src/components/ticker-badge-text.test.tsx +160 -0
  79. package/src/components/ticker-badge-text.tsx +58 -0
  80. package/src/components/ticker-badge.tsx +88 -0
  81. package/src/components/ticker-list-table.tsx +188 -0
  82. package/src/components/toggle-list.tsx +74 -0
  83. package/src/components/ui/button.tsx +77 -0
  84. package/src/components/ui/data-table.tsx +210 -0
  85. package/src/components/ui/external-link.tsx +22 -0
  86. package/src/components/ui/fields.tsx +187 -0
  87. package/src/components/ui/frame.tsx +73 -0
  88. package/src/components/ui/index.ts +53 -0
  89. package/src/components/ui/list-view.tsx +166 -0
  90. package/src/components/ui/loading.tsx +70 -0
  91. package/src/components/ui/status.tsx +83 -0
  92. package/src/components/ui/tabs.tsx +47 -0
  93. package/src/components/ui/toggle.tsx +164 -0
  94. package/src/components/ui/ui.test.tsx +134 -0
  95. package/src/data/app-persistence.ts +25 -0
  96. package/src/data/config-store.test.ts +310 -0
  97. package/src/data/config-store.ts +469 -0
  98. package/src/data/plugin-state-store.ts +102 -0
  99. package/src/data/resource-store.ts +323 -0
  100. package/src/data/session-store.ts +52 -0
  101. package/src/data/sqlite-cache.test.ts +103 -0
  102. package/src/data/sqlite-database.ts +62 -0
  103. package/src/data/sqlite-json.ts +19 -0
  104. package/src/data/ticker-repository.ts +45 -0
  105. package/src/data/ticker-store.ts +46 -0
  106. package/src/index.tsx +67 -0
  107. package/src/market-data/coordinator.test.ts +369 -0
  108. package/src/market-data/coordinator.ts +580 -0
  109. package/src/market-data/hooks.tsx +234 -0
  110. package/src/market-data/query-store.ts +28 -0
  111. package/src/market-data/request-types.test.ts +64 -0
  112. package/src/market-data/request-types.ts +70 -0
  113. package/src/market-data/result-types.ts +56 -0
  114. package/src/market-data/selectors.ts +166 -0
  115. package/src/market-data/trace.ts +7 -0
  116. package/src/pane-settings.test.ts +71 -0
  117. package/src/pane-settings.ts +73 -0
  118. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +338 -0
  119. package/src/plugins/builtin/ai/gloomberb-cli.ts +70 -0
  120. package/src/plugins/builtin/ai/index.test.tsx +139 -0
  121. package/src/plugins/builtin/ai/index.tsx +86 -0
  122. package/src/plugins/builtin/ai/providers.ts +99 -0
  123. package/src/plugins/builtin/ai/runner.ts +82 -0
  124. package/src/plugins/builtin/ai/screener-contract.test.ts +117 -0
  125. package/src/plugins/builtin/ai/screener-contract.ts +174 -0
  126. package/src/plugins/builtin/ai/screener-pane.test.tsx +365 -0
  127. package/src/plugins/builtin/ai/screener-pane.tsx +1161 -0
  128. package/src/plugins/builtin/ai/settings.ts +79 -0
  129. package/src/plugins/builtin/ai/ticker-context.ts +64 -0
  130. package/src/plugins/builtin/ai/utils.ts +71 -0
  131. package/src/plugins/builtin/ask-ai.test.tsx +258 -0
  132. package/src/plugins/builtin/ask-ai.tsx +9 -0
  133. package/src/plugins/builtin/chat-controller.test.ts +217 -0
  134. package/src/plugins/builtin/chat-controller.ts +340 -0
  135. package/src/plugins/builtin/chat.test.tsx +451 -0
  136. package/src/plugins/builtin/chat.tsx +620 -0
  137. package/src/plugins/builtin/comparison-chart.test.tsx +277 -0
  138. package/src/plugins/builtin/comparison-chart.tsx +169 -0
  139. package/src/plugins/builtin/data-providers.ts +16 -0
  140. package/src/plugins/builtin/debug.tsx +351 -0
  141. package/src/plugins/builtin/help.test.tsx +110 -0
  142. package/src/plugins/builtin/help.tsx +281 -0
  143. package/src/plugins/builtin/layout-manager.tsx +264 -0
  144. package/src/plugins/builtin/manual-entry.tsx +25 -0
  145. package/src/plugins/builtin/news.tsx +143 -0
  146. package/src/plugins/builtin/notes-files.ts +58 -0
  147. package/src/plugins/builtin/notes.tsx +396 -0
  148. package/src/plugins/builtin/options-availability.test.tsx +179 -0
  149. package/src/plugins/builtin/options-availability.ts +158 -0
  150. package/src/plugins/builtin/options.tsx +364 -0
  151. package/src/plugins/builtin/portfolio-list/header.tsx +227 -0
  152. package/src/plugins/builtin/portfolio-list/index.tsx +99 -0
  153. package/src/plugins/builtin/portfolio-list/metrics.ts +436 -0
  154. package/src/plugins/builtin/portfolio-list/pane.tsx +533 -0
  155. package/src/plugins/builtin/portfolio-list/settings.ts +280 -0
  156. package/src/plugins/builtin/portfolio-list/summary.tsx +270 -0
  157. package/src/plugins/builtin/portfolio-list/table.tsx +64 -0
  158. package/src/plugins/builtin/portfolio-list.test.ts +74 -0
  159. package/src/plugins/builtin/portfolio-list.test.tsx +1164 -0
  160. package/src/plugins/builtin/portfolio-metrics.test.ts +153 -0
  161. package/src/plugins/builtin/quote-monitor.test.tsx +114 -0
  162. package/src/plugins/builtin/sec.tsx +250 -0
  163. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +43 -0
  164. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +390 -0
  165. package/src/plugins/builtin/ticker-detail/index.tsx +80 -0
  166. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +242 -0
  167. package/src/plugins/builtin/ticker-detail/pane.tsx +183 -0
  168. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +83 -0
  169. package/src/plugins/builtin/ticker-detail/settings.ts +132 -0
  170. package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +193 -0
  171. package/src/plugins/builtin/ticker-detail.test.tsx +578 -0
  172. package/src/plugins/builtin/yahoo.ts +18 -0
  173. package/src/plugins/event-bus.ts +37 -0
  174. package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +35 -0
  175. package/src/plugins/ibkr/account-cache.test.ts +101 -0
  176. package/src/plugins/ibkr/account-cache.ts +137 -0
  177. package/src/plugins/ibkr/broker-adapter.ts +168 -0
  178. package/src/plugins/ibkr/config.test.ts +68 -0
  179. package/src/plugins/ibkr/config.ts +262 -0
  180. package/src/plugins/ibkr/dialogs.tsx +83 -0
  181. package/src/plugins/ibkr/flex.test.ts +107 -0
  182. package/src/plugins/ibkr/flex.ts +267 -0
  183. package/src/plugins/ibkr/fundamental-parser.ts +149 -0
  184. package/src/plugins/ibkr/gateway-helpers.ts +28 -0
  185. package/src/plugins/ibkr/gateway-selection.ts +51 -0
  186. package/src/plugins/ibkr/gateway-service.test.ts +450 -0
  187. package/src/plugins/ibkr/gateway-service.ts +2228 -0
  188. package/src/plugins/ibkr/index.test.tsx +183 -0
  189. package/src/plugins/ibkr/index.tsx +153 -0
  190. package/src/plugins/ibkr/instance-selection.test.ts +121 -0
  191. package/src/plugins/ibkr/instance-selection.ts +36 -0
  192. package/src/plugins/ibkr/price-normalization.test.ts +38 -0
  193. package/src/plugins/ibkr/price-normalization.ts +59 -0
  194. package/src/plugins/ibkr/trade-badge.tsx +27 -0
  195. package/src/plugins/ibkr/trade-tab.test.tsx +225 -0
  196. package/src/plugins/ibkr/trade-tab.tsx +1050 -0
  197. package/src/plugins/ibkr/trade-utils.test.ts +175 -0
  198. package/src/plugins/ibkr/trade-utils.ts +149 -0
  199. package/src/plugins/ibkr/trading-pane.tsx +438 -0
  200. package/src/plugins/ibkr/trading-state.test.ts +109 -0
  201. package/src/plugins/ibkr/trading-state.ts +339 -0
  202. package/src/plugins/loader.ts +71 -0
  203. package/src/plugins/pane-manager.test.ts +103 -0
  204. package/src/plugins/pane-manager.ts +974 -0
  205. package/src/plugins/plugin-persistence.ts +85 -0
  206. package/src/plugins/plugin-runtime.test.tsx +185 -0
  207. package/src/plugins/plugin-runtime.tsx +175 -0
  208. package/src/plugins/prediction-markets/cache.ts +114 -0
  209. package/src/plugins/prediction-markets/categories.ts +223 -0
  210. package/src/plugins/prediction-markets/chart.tsx +512 -0
  211. package/src/plugins/prediction-markets/columns.ts +133 -0
  212. package/src/plugins/prediction-markets/controller-data.ts +614 -0
  213. package/src/plugins/prediction-markets/controller.ts +725 -0
  214. package/src/plugins/prediction-markets/detail/book.tsx +118 -0
  215. package/src/plugins/prediction-markets/detail/outcomes.tsx +102 -0
  216. package/src/plugins/prediction-markets/detail/overview.tsx +73 -0
  217. package/src/plugins/prediction-markets/detail/pane.tsx +308 -0
  218. package/src/plugins/prediction-markets/detail/rules.tsx +16 -0
  219. package/src/plugins/prediction-markets/detail/shared.tsx +29 -0
  220. package/src/plugins/prediction-markets/detail/trades.tsx +30 -0
  221. package/src/plugins/prediction-markets/detail.test.tsx +47 -0
  222. package/src/plugins/prediction-markets/index.tsx +102 -0
  223. package/src/plugins/prediction-markets/keyboard.ts +64 -0
  224. package/src/plugins/prediction-markets/launch.test.ts +126 -0
  225. package/src/plugins/prediction-markets/launch.ts +240 -0
  226. package/src/plugins/prediction-markets/metrics.ts +311 -0
  227. package/src/plugins/prediction-markets/navigation.ts +90 -0
  228. package/src/plugins/prediction-markets/outcome-order.ts +19 -0
  229. package/src/plugins/prediction-markets/pane.test.tsx +425 -0
  230. package/src/plugins/prediction-markets/pane.tsx +294 -0
  231. package/src/plugins/prediction-markets/plugin.test.tsx +570 -0
  232. package/src/plugins/prediction-markets/rows.ts +229 -0
  233. package/src/plugins/prediction-markets/services/fetch.ts +97 -0
  234. package/src/plugins/prediction-markets/services/kalshi-adapter.ts +555 -0
  235. package/src/plugins/prediction-markets/services/polymarket-adapter.ts +660 -0
  236. package/src/plugins/prediction-markets/services/polymarket-ws.ts +108 -0
  237. package/src/plugins/prediction-markets/settings.ts +164 -0
  238. package/src/plugins/prediction-markets/status.ts +24 -0
  239. package/src/plugins/prediction-markets/table.tsx +84 -0
  240. package/src/plugins/prediction-markets/test-helpers.tsx +636 -0
  241. package/src/plugins/prediction-markets/types.ts +186 -0
  242. package/src/plugins/registry.ts +633 -0
  243. package/src/sources/exchange-rates.ts +18 -0
  244. package/src/sources/gloomberb-cloud.test.ts +199 -0
  245. package/src/sources/gloomberb-cloud.ts +326 -0
  246. package/src/sources/provider-errors.ts +19 -0
  247. package/src/sources/provider-router.test.ts +1476 -0
  248. package/src/sources/provider-router.ts +1405 -0
  249. package/src/sources/sec-edgar.test.ts +286 -0
  250. package/src/sources/sec-edgar.ts +526 -0
  251. package/src/sources/yahoo-finance.test.ts +14 -0
  252. package/src/sources/yahoo-finance.ts +1017 -0
  253. package/src/state/app-activity.test.ts +41 -0
  254. package/src/state/app-activity.ts +106 -0
  255. package/src/state/app-bootstrap.test.ts +471 -0
  256. package/src/state/app-bootstrap.ts +308 -0
  257. package/src/state/app-context.test.ts +406 -0
  258. package/src/state/app-context.test.tsx +132 -0
  259. package/src/state/app-context.tsx +969 -0
  260. package/src/state/selectors.ts +67 -0
  261. package/src/state/session-persistence.test.ts +128 -0
  262. package/src/state/session-persistence.ts +161 -0
  263. package/src/state/ticker-refresh-queue.test.ts +46 -0
  264. package/src/state/ticker-refresh-queue.ts +40 -0
  265. package/src/state/use-inline-tickers.ts +174 -0
  266. package/src/state/use-quote-streaming.test.ts +37 -0
  267. package/src/state/use-quote-streaming.test.tsx +70 -0
  268. package/src/state/use-quote-streaming.ts +80 -0
  269. package/src/state/use-session-snapshot.ts +84 -0
  270. package/src/test-support/data-provider.ts +22 -0
  271. package/src/theme/color-utils.ts +67 -0
  272. package/src/theme/colors.ts +128 -0
  273. package/src/theme/themes.test.ts +45 -0
  274. package/src/theme/themes.ts +491 -0
  275. package/src/types/broker.ts +105 -0
  276. package/src/types/config.ts +434 -0
  277. package/src/types/data-provider.ts +81 -0
  278. package/src/types/financials.ts +142 -0
  279. package/src/types/instrument.ts +29 -0
  280. package/src/types/persistence.ts +21 -0
  281. package/src/types/plugin.ts +354 -0
  282. package/src/types/ticker.ts +58 -0
  283. package/src/types/trading.ts +85 -0
  284. package/src/updater.test.ts +162 -0
  285. package/src/updater.ts +247 -0
  286. package/src/utils/api-client.ts +666 -0
  287. package/src/utils/ascii-chart.ts +172 -0
  288. package/src/utils/broker-instances.ts +47 -0
  289. package/src/utils/cli-output.ts +83 -0
  290. package/src/utils/currency-units.test.ts +49 -0
  291. package/src/utils/currency-units.ts +82 -0
  292. package/src/utils/debug-log.ts +133 -0
  293. package/src/utils/exchange-rate-map.ts +8 -0
  294. package/src/utils/format.ts +158 -0
  295. package/src/utils/fuzzy-search.ts +34 -0
  296. package/src/utils/market-status.ts +95 -0
  297. package/src/utils/options.ts +86 -0
  298. package/src/utils/price-history.ts +39 -0
  299. package/src/utils/quote-time.test.ts +22 -0
  300. package/src/utils/quote-time.ts +50 -0
  301. package/src/utils/sec.test.ts +55 -0
  302. package/src/utils/sec.ts +45 -0
  303. package/src/utils/ticker-list.test.ts +22 -0
  304. package/src/utils/ticker-list.ts +35 -0
  305. package/src/utils/ticker-metadata.ts +51 -0
  306. package/src/utils/ticker-search.test.ts +227 -0
  307. package/src/utils/ticker-search.ts +764 -0
  308. package/src/utils/ticker-tokenizer.test.ts +48 -0
  309. package/src/utils/ticker-tokenizer.ts +72 -0
  310. package/src/version.ts +1 -0
  311. package/install.mjs +0 -69
@@ -0,0 +1,1199 @@
1
+ import { RGBA } from "@opentui/core";
2
+ import type { ChartAxisMode, ChartColors, Pixel, PixelBuffer, ChartRenderMode } from "./chart-types";
3
+ import type { ProjectedChartPoint } from "./chart-data";
4
+ import { formatCurrency } from "../../utils/format";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Styled output types (unchanged public API)
8
+ // ---------------------------------------------------------------------------
9
+
10
+ interface StyledChunk {
11
+ __isChunk: true;
12
+ text: string;
13
+ fg?: RGBA;
14
+ bg?: RGBA;
15
+ attributes: number;
16
+ }
17
+
18
+ export interface StyledContent {
19
+ chunks: StyledChunk[];
20
+ }
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Layer constants — higher values win when pixels overlap
24
+ // ---------------------------------------------------------------------------
25
+
26
+ const LAYER_GRID = 0;
27
+ const LAYER_FILL = 1;
28
+ const LAYER_DATA = 2;
29
+ const LAYER_CROSSHAIR = 3;
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Braille character mapping
33
+ // Each terminal cell maps to a 2×4 dot grid. Unicode braille = U+2800 + bits.
34
+ // Col 0 Col 1
35
+ // bit 0 bit 3 (row 0, top)
36
+ // bit 1 bit 4 (row 1)
37
+ // bit 2 bit 5 (row 2)
38
+ // bit 6 bit 7 (row 3, bottom)
39
+ // ---------------------------------------------------------------------------
40
+
41
+ const BRAILLE_BASE = 0x2800;
42
+ const BRAILLE_DOT: number[][] = [
43
+ [0x01, 0x08], // row 0
44
+ [0x02, 0x10], // row 1
45
+ [0x04, 0x20], // row 2
46
+ [0x40, 0x80], // row 3
47
+ ];
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Palette resolution
51
+ // ---------------------------------------------------------------------------
52
+
53
+ interface ChartPaletteInput {
54
+ bg: string;
55
+ border: string;
56
+ borderFocused: string;
57
+ text: string;
58
+ textDim: string;
59
+ positive: string;
60
+ negative: string;
61
+ }
62
+
63
+ export interface ResolvedChartPalette extends ChartColors {
64
+ candleUp: string;
65
+ candleDown: string;
66
+ wickUp: string;
67
+ wickDown: string;
68
+ }
69
+
70
+ type VolumeTrendMode = "previousClose" | "openClose";
71
+
72
+ function makeChunk(text: string, fgColor: string, bgColor: string): StyledChunk {
73
+ return {
74
+ __isChunk: true,
75
+ text,
76
+ fg: RGBA.fromHex(fgColor),
77
+ bg: RGBA.fromHex(bgColor),
78
+ attributes: 0,
79
+ };
80
+ }
81
+
82
+ function blendHex(a: string, b: string, ratio: number): string {
83
+ const parse = (hex: string) => {
84
+ const h = hex.replace("#", "");
85
+ return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)] as const;
86
+ };
87
+
88
+ const [ar, ag, ab] = parse(a);
89
+ const [br, bg, bb] = parse(b);
90
+ const mix = (x: number, y: number) => Math.round(x + (y - x) * ratio).toString(16).padStart(2, "0");
91
+ return `#${mix(ar, br)}${mix(ag, bg)}${mix(ab, bb)}`;
92
+ }
93
+
94
+ export function resolveChartPalette(
95
+ baseColors: ChartPaletteInput,
96
+ trend: "positive" | "negative" | "neutral" = "positive",
97
+ ): ResolvedChartPalette {
98
+ const lineColor = trend === "negative"
99
+ ? baseColors.negative
100
+ : trend === "neutral"
101
+ ? baseColors.text
102
+ : baseColors.positive;
103
+
104
+ return {
105
+ lineColor,
106
+ fillColor: blendHex(baseColors.bg, lineColor, 0.22),
107
+ volumeUp: blendHex(baseColors.bg, baseColors.positive, 0.35),
108
+ volumeDown: blendHex(baseColors.bg, baseColors.negative, 0.35),
109
+ gridColor: blendHex(baseColors.bg, baseColors.border, 0.55),
110
+ crosshairColor: baseColors.borderFocused,
111
+ bgColor: baseColors.bg,
112
+ axisColor: baseColors.textDim,
113
+ activeRangeColor: baseColors.text,
114
+ inactiveRangeColor: blendHex(baseColors.bg, baseColors.textDim, 0.75),
115
+ candleUp: baseColors.positive,
116
+ candleDown: baseColors.negative,
117
+ wickUp: blendHex(baseColors.positive, baseColors.text, 0.35),
118
+ wickDown: blendHex(baseColors.negative, baseColors.text, 0.35),
119
+ };
120
+ }
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Pixel buffer — now at braille resolution (2× wide, 4× tall)
124
+ // ---------------------------------------------------------------------------
125
+
126
+ export function createPixelBuffer(width: number, heightPixels: number): PixelBuffer {
127
+ const pixels: (Pixel | null)[][] = [];
128
+ for (let y = 0; y < heightPixels; y++) {
129
+ pixels.push(new Array(width).fill(null));
130
+ }
131
+ return { width, height: heightPixels, pixels };
132
+ }
133
+
134
+ function setPixel(buf: PixelBuffer, x: number, y: number, color: string, layer: number) {
135
+ if (x >= 0 && x < buf.width && y >= 0 && y < buf.height) {
136
+ const existing = buf.pixels[y]![x];
137
+ if (!existing || layer >= existing.layer) {
138
+ buf.pixels[y]![x] = { color, layer };
139
+ }
140
+ }
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Drawing primitives
145
+ // ---------------------------------------------------------------------------
146
+
147
+ export function drawLine(
148
+ buf: PixelBuffer,
149
+ x0: number,
150
+ y0: number,
151
+ x1: number,
152
+ y1: number,
153
+ color: string,
154
+ layer: number = LAYER_DATA,
155
+ ) {
156
+ let dx = Math.abs(x1 - x0);
157
+ let dy = Math.abs(y1 - y0);
158
+ const sx = x0 < x1 ? 1 : -1;
159
+ const sy = y0 < y1 ? 1 : -1;
160
+ let err = dx - dy;
161
+ let x = x0;
162
+ let y = y0;
163
+
164
+ while (true) {
165
+ setPixel(buf, x, y, color, layer);
166
+ if (x === x1 && y === y1) break;
167
+ const e2 = 2 * err;
168
+ if (e2 > -dy) {
169
+ err -= dy;
170
+ x += sx;
171
+ }
172
+ if (e2 < dx) {
173
+ err += dx;
174
+ y += sy;
175
+ }
176
+ }
177
+ }
178
+
179
+ function fillColumn(buf: PixelBuffer, x: number, y0: number, y1: number, color: string, layer: number) {
180
+ const start = Math.min(y0, y1);
181
+ const end = Math.max(y0, y1);
182
+ for (let y = start; y <= end; y++) {
183
+ setPixel(buf, x, y, color, layer);
184
+ }
185
+ }
186
+
187
+ function fillRect(buf: PixelBuffer, x0: number, y0: number, x1: number, y1: number, color: string, layer: number) {
188
+ for (let x = Math.min(x0, x1); x <= Math.max(x0, x1); x++) {
189
+ fillColumn(buf, x, y0, y1, color, layer);
190
+ }
191
+ }
192
+
193
+ function drawWickOutsideBody(
194
+ buf: PixelBuffer,
195
+ x: number,
196
+ highY: number,
197
+ lowY: number,
198
+ bodyTop: number,
199
+ bodyBottom: number,
200
+ color: string,
201
+ ) {
202
+ if (highY < bodyTop) {
203
+ drawLine(buf, x, highY, x, bodyTop, color, LAYER_DATA);
204
+ }
205
+ if (bodyBottom < lowY) {
206
+ drawLine(buf, x, bodyBottom, x, lowY, color, LAYER_DATA);
207
+ }
208
+ }
209
+
210
+ function getSeriesPosition(
211
+ index: number,
212
+ pointCount: number,
213
+ width: number,
214
+ startPadding = 0,
215
+ endPadding = startPadding,
216
+ ): number {
217
+ const start = Math.max(startPadding, 0);
218
+ const end = Math.max(start, width - 1 - Math.max(endPadding, 0));
219
+ if (pointCount <= 1) return Math.round((start + end) / 2);
220
+ return Math.round(start + (index / (pointCount - 1)) * (end - start));
221
+ }
222
+
223
+ function getScaledY(value: number, min: number, max: number, chartTop: number, chartBottom: number): number {
224
+ const range = max - min || 1;
225
+ const chartH = chartBottom - chartTop;
226
+ return chartTop + Math.round((1 - (value - min) / range) * chartH);
227
+ }
228
+
229
+ // ---------------------------------------------------------------------------
230
+ // Proportional element widths for braille resolution
231
+ // ---------------------------------------------------------------------------
232
+
233
+ function getCandleHalfWidth(pointCount: number, bufWidth: number): number {
234
+ if (pointCount <= 1) return 2;
235
+ const spacing = bufWidth / pointCount;
236
+ const bodyW = Math.min(Math.max(Math.round(spacing * 0.55), 2), 10);
237
+ return Math.floor(bodyW / 2);
238
+ }
239
+
240
+ function getBarHalfWidth(pointCount: number, bufWidth: number): number {
241
+ if (pointCount <= 1) return 2;
242
+ const spacing = bufWidth / pointCount;
243
+ const barW = Math.min(Math.max(Math.round(spacing * 0.45), 1), 10);
244
+ return Math.floor(barW / 2);
245
+ }
246
+
247
+ function getDotX(index: number, pointCount: number, width: number, mode: ChartRenderMode): number {
248
+ switch (mode) {
249
+ case "candles": {
250
+ const pad = getCandleHalfWidth(pointCount, width);
251
+ return getSeriesPosition(index, pointCount, width, pad, pad);
252
+ }
253
+ case "ohlc": {
254
+ const tickLen = Math.max(getCandleHalfWidth(pointCount, width), 2);
255
+ const pad = Math.max(tickLen - 1, 0);
256
+ return getSeriesPosition(index, pointCount, width, pad, pad);
257
+ }
258
+ default:
259
+ return getSeriesPosition(index, pointCount, width);
260
+ }
261
+ }
262
+
263
+ function getPointDotX(index: number, pointCount: number, width: number, mode: ChartRenderMode): number {
264
+ return getDotX(index, pointCount, Math.max(width * 2, 1), mode);
265
+ }
266
+
267
+ export function getPointTerminalColumn(index: number, pointCount: number, width: number, mode: ChartRenderMode): number {
268
+ if (width <= 1) return 0;
269
+ return Math.min(Math.max(Math.floor(getPointDotX(index, pointCount, width, mode) / 2), 0), width - 1);
270
+ }
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // Chart mode renderers
274
+ // ---------------------------------------------------------------------------
275
+
276
+ function drawLineSeries(
277
+ buf: PixelBuffer,
278
+ points: ProjectedChartPoint[],
279
+ chartTop: number,
280
+ chartBottom: number,
281
+ lineColor: string,
282
+ min: number,
283
+ max: number,
284
+ ) {
285
+ if (points.length === 0) return;
286
+
287
+ for (let i = 0; i < points.length; i++) {
288
+ const x = getDotX(i, points.length, buf.width, "line");
289
+ const y = getScaledY(points[i]!.close, min, max, chartTop, chartBottom);
290
+ if (i < points.length - 1) {
291
+ const x1 = getDotX(i + 1, points.length, buf.width, "line");
292
+ const y1 = getScaledY(points[i + 1]!.close, min, max, chartTop, chartBottom);
293
+ drawLine(buf, x, y, x1, y1, lineColor, LAYER_DATA);
294
+ } else {
295
+ setPixel(buf, x, y, lineColor, LAYER_DATA);
296
+ }
297
+ }
298
+ }
299
+
300
+ function drawAreaChart(
301
+ buf: PixelBuffer,
302
+ points: ProjectedChartPoint[],
303
+ chartTop: number,
304
+ chartBottom: number,
305
+ lineColor: string,
306
+ fillColor: string,
307
+ min: number,
308
+ max: number,
309
+ ) {
310
+ if (points.length === 0) return;
311
+
312
+ // Draw fill first (lower layer), then line on top
313
+ for (let i = 0; i < points.length; i++) {
314
+ const x = getDotX(i, points.length, buf.width, "area");
315
+ const y = getScaledY(points[i]!.close, min, max, chartTop, chartBottom);
316
+
317
+ fillColumn(buf, x, y + 1, chartBottom, fillColor, LAYER_FILL);
318
+
319
+ if (i < points.length - 1) {
320
+ const x1 = getDotX(i + 1, points.length, buf.width, "area");
321
+ const y1 = getScaledY(points[i + 1]!.close, min, max, chartTop, chartBottom);
322
+
323
+ // Interpolate fill between consecutive points
324
+ for (let cx = Math.min(x, x1); cx <= Math.max(x, x1); cx++) {
325
+ if (cx === x || cx === x1) continue;
326
+ const t = (cx - x) / Math.max(Math.abs(x1 - x), 1);
327
+ const iy = Math.round(y + t * (y1 - y));
328
+ fillColumn(buf, cx, iy + 1, chartBottom, fillColor, LAYER_FILL);
329
+ }
330
+
331
+ // Line on top
332
+ drawLine(buf, x, y, x1, y1, lineColor, LAYER_DATA);
333
+ } else {
334
+ setPixel(buf, x, y, lineColor, LAYER_DATA);
335
+ }
336
+ }
337
+ }
338
+
339
+ function drawCandlestickChart(
340
+ buf: PixelBuffer,
341
+ points: ProjectedChartPoint[],
342
+ chartTop: number,
343
+ chartBottom: number,
344
+ palette: ResolvedChartPalette,
345
+ min: number,
346
+ max: number,
347
+ ) {
348
+ const halfW = getCandleHalfWidth(points.length, buf.width);
349
+
350
+ for (let i = 0; i < points.length; i++) {
351
+ const point = points[i]!;
352
+ const x = getDotX(i, points.length, buf.width, "candles");
353
+ const highY = getScaledY(point.high, min, max, chartTop, chartBottom);
354
+ const lowY = getScaledY(point.low, min, max, chartTop, chartBottom);
355
+ const openY = getScaledY(point.open, min, max, chartTop, chartBottom);
356
+ const closeY = getScaledY(point.close, min, max, chartTop, chartBottom);
357
+ const isUp = point.close >= point.open;
358
+ const wickColor = isUp ? palette.wickUp : palette.wickDown;
359
+ const bodyColor = isUp ? palette.candleUp : palette.candleDown;
360
+
361
+ // Body: proportional width
362
+ const bodyTop = Math.min(openY, closeY);
363
+ const bodyBottom = Math.max(openY, closeY);
364
+ if (bodyTop === bodyBottom) {
365
+ drawLine(buf, x, highY, x, lowY, wickColor, LAYER_DATA);
366
+ const dojiBottom = Math.min(bodyBottom + 1, chartBottom);
367
+ const dojiTop = Math.max(chartTop, dojiBottom - 1);
368
+ fillRect(buf, x - halfW, dojiTop, x + halfW, dojiBottom, bodyColor, LAYER_DATA);
369
+ } else {
370
+ drawWickOutsideBody(buf, x, highY, lowY, bodyTop, bodyBottom, wickColor);
371
+ fillRect(buf, x - halfW, bodyTop, x + halfW, bodyBottom, bodyColor, LAYER_DATA);
372
+ }
373
+ }
374
+ }
375
+
376
+ function drawOhlcChart(
377
+ buf: PixelBuffer,
378
+ points: ProjectedChartPoint[],
379
+ chartTop: number,
380
+ chartBottom: number,
381
+ palette: ResolvedChartPalette,
382
+ min: number,
383
+ max: number,
384
+ ) {
385
+ const tickLen = Math.max(getCandleHalfWidth(points.length, buf.width), 2);
386
+
387
+ for (let i = 0; i < points.length; i++) {
388
+ const point = points[i]!;
389
+ const x = getDotX(i, points.length, buf.width, "ohlc");
390
+ const highY = getScaledY(point.high, min, max, chartTop, chartBottom);
391
+ const lowY = getScaledY(point.low, min, max, chartTop, chartBottom);
392
+ const openY = getScaledY(point.open, min, max, chartTop, chartBottom);
393
+ const closeY = getScaledY(point.close, min, max, chartTop, chartBottom);
394
+ const isUp = point.close >= point.open;
395
+ const color = isUp ? palette.candleUp : palette.candleDown;
396
+
397
+ // Vertical line
398
+ drawLine(buf, x, highY, x, lowY, color, LAYER_DATA);
399
+
400
+ // Open tick extends left
401
+ for (let dx = 0; dx < tickLen; dx++) {
402
+ setPixel(buf, x - dx, openY, color, LAYER_DATA);
403
+ }
404
+ // Close tick extends right
405
+ for (let dx = 0; dx < tickLen; dx++) {
406
+ setPixel(buf, x + dx, closeY, color, LAYER_DATA);
407
+ }
408
+ }
409
+ }
410
+
411
+ // ---------------------------------------------------------------------------
412
+ // Volume bars
413
+ // ---------------------------------------------------------------------------
414
+
415
+ export function drawVolumeBars(
416
+ buf: PixelBuffer,
417
+ points: ProjectedChartPoint[],
418
+ yTop: number,
419
+ yBottom: number,
420
+ upColor: string,
421
+ downColor: string,
422
+ trendMode: VolumeTrendMode,
423
+ mode: ChartRenderMode,
424
+ ) {
425
+ const volumes = points.map((point) => point.volume);
426
+ const maxVol = Math.max(...volumes, 1);
427
+ const volH = yBottom - yTop;
428
+ const halfW = getBarHalfWidth(points.length, buf.width);
429
+
430
+ for (let i = 0; i < points.length; i++) {
431
+ const point = points[i]!;
432
+ const x = getDotX(i, points.length, buf.width, mode);
433
+ const barH = Math.round((point.volume / maxVol) * volH);
434
+ if (barH === 0) continue;
435
+
436
+ const isUp = trendMode === "openClose"
437
+ ? point.close >= point.open
438
+ : i === 0 || point.close >= points[i - 1]!.close;
439
+ const color = isUp ? upColor : downColor;
440
+
441
+ fillRect(buf, x - halfW, yBottom - barH, x + halfW, yBottom, color, LAYER_FILL);
442
+ }
443
+ }
444
+
445
+ // ---------------------------------------------------------------------------
446
+ // Overlays
447
+ // ---------------------------------------------------------------------------
448
+
449
+ export function drawCrosshair(
450
+ buf: PixelBuffer,
451
+ x: number,
452
+ yTop: number,
453
+ yBottom: number,
454
+ color: string,
455
+ ) {
456
+ if (x < 0 || x >= buf.width) return;
457
+ // Dashed pattern: 2 dots on, 2 dots off
458
+ for (let y = yTop; y <= yBottom; y++) {
459
+ if (y % 4 < 2) {
460
+ setPixel(buf, x, y, color, LAYER_CROSSHAIR);
461
+ }
462
+ }
463
+ }
464
+
465
+ export function drawGridLines(
466
+ buf: PixelBuffer,
467
+ yPositions: number[],
468
+ color: string,
469
+ ) {
470
+ for (const y of yPositions) {
471
+ if (y < 0 || y >= buf.height) continue;
472
+ // Dotted horizontal line — dot every 6th column (≈ every 3 terminal columns)
473
+ for (let x = 0; x < buf.width; x++) {
474
+ if (x % 6 === 0 && !buf.pixels[y]![x]) {
475
+ setPixel(buf, x, y, color, LAYER_GRID);
476
+ }
477
+ }
478
+ }
479
+ }
480
+
481
+ // ---------------------------------------------------------------------------
482
+ // Braille buffer → styled terminal output
483
+ // ---------------------------------------------------------------------------
484
+
485
+ export function bufferToBrailleLines(buf: PixelBuffer, bgColor: string): StyledContent[] {
486
+ const lines: StyledContent[] = [];
487
+ const termCols = Math.ceil(buf.width / 2);
488
+ const termRows = Math.ceil(buf.height / 4);
489
+
490
+ for (let row = 0; row < termRows; row++) {
491
+ const chunks: StyledChunk[] = [];
492
+ let runChar = "";
493
+ let runFg = "";
494
+ let runBg = "";
495
+ let runLen = 0;
496
+
497
+ const flushRun = () => {
498
+ if (runLen > 0) {
499
+ chunks.push(makeChunk(runChar.repeat(runLen), runFg, runBg));
500
+ runLen = 0;
501
+ }
502
+ };
503
+
504
+ for (let col = 0; col < termCols; col++) {
505
+ let topLayer = -1;
506
+
507
+ // Track dots per layer so we can render only the top layer's dots
508
+ const dotsByLayer: Map<number, number> = new Map(); // layer → braille bits
509
+ const colorByLayer: Map<number, Map<string, number>> = new Map();
510
+
511
+ for (let dy = 0; dy < 4; dy++) {
512
+ for (let dx = 0; dx < 2; dx++) {
513
+ const px = col * 2 + dx;
514
+ const py = row * 4 + dy;
515
+ if (px >= buf.width || py >= buf.height) continue;
516
+ const pixel = buf.pixels[py]?.[px] ?? null;
517
+ if (pixel) {
518
+ const bit = BRAILLE_DOT[dy]![dx]!;
519
+ dotsByLayer.set(pixel.layer, (dotsByLayer.get(pixel.layer) || 0) | bit);
520
+ if (!colorByLayer.has(pixel.layer)) colorByLayer.set(pixel.layer, new Map());
521
+ const counts = colorByLayer.get(pixel.layer)!;
522
+ counts.set(pixel.color, (counts.get(pixel.color) || 0) + 1);
523
+ if (pixel.layer > topLayer) {
524
+ topLayer = pixel.layer;
525
+ }
526
+ }
527
+ }
528
+ }
529
+
530
+ let char: string;
531
+ let cellFg: string;
532
+ let cellBg: string;
533
+
534
+ if (topLayer < 0) {
535
+ char = " ";
536
+ cellFg = bgColor;
537
+ cellBg = bgColor;
538
+ } else {
539
+ // Only show dots from the highest layer — keeps lines thin in mixed
540
+ // cells (e.g. area chart where line and fill overlap).
541
+ const pattern = dotsByLayer.get(topLayer) || 0;
542
+ char = String.fromCharCode(BRAILLE_BASE + pattern);
543
+
544
+ const topCounts: Map<string, number> = colorByLayer.get(topLayer) ?? new Map();
545
+ let topColor = "";
546
+ let bestCount = 0;
547
+ for (const [c, n] of topCounts) {
548
+ if (n > bestCount) { bestCount = n; topColor = c; }
549
+ }
550
+
551
+ cellFg = topColor;
552
+ cellBg = bgColor;
553
+ }
554
+
555
+ if (char === runChar && cellFg === runFg && cellBg === runBg) {
556
+ runLen++;
557
+ } else {
558
+ flushRun();
559
+ runChar = char;
560
+ runFg = cellFg;
561
+ runBg = cellBg;
562
+ runLen = 1;
563
+ }
564
+ }
565
+
566
+ flushRun();
567
+ lines.push({ chunks });
568
+ }
569
+
570
+ return lines;
571
+ }
572
+
573
+ // ---------------------------------------------------------------------------
574
+ // Formatting helpers
575
+ // ---------------------------------------------------------------------------
576
+
577
+ export function formatPrice(value: number): string {
578
+ return formatPriceWithCurrency(value);
579
+ }
580
+
581
+ function formatPercentAxisValue(value: number): string {
582
+ const abs = Math.abs(value);
583
+ const decimals = abs >= 100 ? 0 : abs >= 10 ? 1 : 2;
584
+ const prefix = value > 0 ? "+" : "";
585
+ return `${prefix}${value.toFixed(decimals)}%`;
586
+ }
587
+
588
+ const currencySymbols = new Map<string, string>();
589
+
590
+ function getCurrencySymbol(currency: string): string {
591
+ const cached = currencySymbols.get(currency);
592
+ if (cached) return cached;
593
+
594
+ const formatter = new Intl.NumberFormat("en-US", {
595
+ style: "currency",
596
+ currency,
597
+ currencyDisplay: "symbol",
598
+ minimumFractionDigits: 0,
599
+ maximumFractionDigits: 0,
600
+ });
601
+ const symbol = formatter.formatToParts(0).find((part) => part.type === "currency")?.value ?? currency;
602
+ currencySymbols.set(currency, symbol);
603
+ return symbol;
604
+ }
605
+
606
+ export function formatPriceWithCurrency(value: number, currency = "USD"): string {
607
+ const abs = Math.abs(value);
608
+ const sign = value < 0 ? "-" : "";
609
+ const symbol = getCurrencySymbol(currency);
610
+
611
+ if (abs >= 1e6) return `${sign}${symbol}${(abs / 1e6).toFixed(1)}M`;
612
+ if (abs >= 1e3 && abs < 1e5) return `${sign}${symbol}${(abs / 1e3).toFixed(1)}K`;
613
+ return formatCurrency(value, currency);
614
+ }
615
+
616
+ export function formatAxisValue(value: number, axisMode: ChartAxisMode, basePrice: number, currency = "USD"): string {
617
+ if (axisMode === "percent" && basePrice !== 0) {
618
+ return formatPercentAxisValue(((value - basePrice) / basePrice) * 100);
619
+ }
620
+ return formatPriceWithCurrency(value, currency);
621
+ }
622
+
623
+ const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
624
+
625
+ export function formatDate(date: Date | string | number): string {
626
+ const d = date instanceof Date ? date : new Date(date);
627
+ if (isNaN(d.getTime())) return "—";
628
+ return `${MONTHS[d.getMonth()]} ${d.getFullYear()}`;
629
+ }
630
+
631
+ export function formatDateShort(date: Date | string | number): string {
632
+ const d = date instanceof Date ? date : new Date(date);
633
+ if (isNaN(d.getTime())) return "—";
634
+ return `${MONTHS[d.getMonth()]} ${d.getDate()}`;
635
+ }
636
+
637
+ const AXIS_MS_SECOND = 1_000;
638
+ const AXIS_MS_MINUTE = 60 * AXIS_MS_SECOND;
639
+ const AXIS_MS_HOUR = 60 * AXIS_MS_MINUTE;
640
+ const AXIS_MS_DAY = 24 * AXIS_MS_HOUR;
641
+ const AXIS_MS_MONTH = 30 * AXIS_MS_DAY;
642
+ const AXIS_MS_YEAR = 365 * AXIS_MS_DAY;
643
+
644
+ type AxisLabelUnit = "year" | "month" | "day" | "hour" | "minute" | "second" | "millisecond";
645
+
646
+ function formatClockTime(date: Date, unit: AxisLabelUnit): string {
647
+ const hours = String(date.getHours()).padStart(2, "0");
648
+ const minutes = String(date.getMinutes()).padStart(2, "0");
649
+ const seconds = String(date.getSeconds()).padStart(2, "0");
650
+ const milliseconds = String(date.getMilliseconds()).padStart(3, "0");
651
+
652
+ switch (unit) {
653
+ case "hour":
654
+ case "minute":
655
+ return `${hours}:${minutes}`;
656
+ case "second":
657
+ return `${hours}:${minutes}:${seconds}`;
658
+ case "millisecond":
659
+ return `${hours}:${minutes}:${seconds}.${milliseconds}`;
660
+ default:
661
+ return `${hours}:${minutes}`;
662
+ }
663
+ }
664
+
665
+ function isSameCalendarDay(a: Date, b: Date): boolean {
666
+ return a.getFullYear() === b.getFullYear()
667
+ && a.getMonth() === b.getMonth()
668
+ && a.getDate() === b.getDate();
669
+ }
670
+
671
+ function isSameCalendarMonth(a: Date, b: Date): boolean {
672
+ return a.getFullYear() === b.getFullYear()
673
+ && a.getMonth() === b.getMonth();
674
+ }
675
+
676
+ function getMinPositiveGapMs(dates: Date[]): number {
677
+ let minGapMs = Number.POSITIVE_INFINITY;
678
+
679
+ for (let index = 1; index < dates.length; index += 1) {
680
+ const current = dates[index]!;
681
+ const previous = dates[index - 1]!;
682
+ const gapMs = Math.abs(current.getTime() - previous.getTime());
683
+ if (gapMs > 0 && gapMs < minGapMs) {
684
+ minGapMs = gapMs;
685
+ }
686
+ }
687
+
688
+ return Number.isFinite(minGapMs) ? minGapMs : 0;
689
+ }
690
+
691
+ function resolveAxisLabelUnit(stepMs: number): AxisLabelUnit {
692
+ if (stepMs >= AXIS_MS_YEAR) return "year";
693
+ if (stepMs >= AXIS_MS_MONTH) return "month";
694
+ if (stepMs >= AXIS_MS_DAY) return "day";
695
+ if (stepMs >= AXIS_MS_HOUR) return "hour";
696
+ if (stepMs >= AXIS_MS_MINUTE) return "minute";
697
+ if (stepMs >= AXIS_MS_SECOND) return "second";
698
+ return "millisecond";
699
+ }
700
+
701
+ function estimateAxisLabelWidth(unit: AxisLabelUnit, first: Date, last: Date): number {
702
+ const spansMultipleDays = !isSameCalendarDay(first, last);
703
+ const spansMultipleYears = first.getFullYear() !== last.getFullYear();
704
+
705
+ switch (unit) {
706
+ case "year":
707
+ return 4;
708
+ case "month":
709
+ return spansMultipleYears ? 8 : 5;
710
+ case "day":
711
+ return spansMultipleYears ? 10 : 6;
712
+ case "hour":
713
+ case "minute":
714
+ return spansMultipleDays ? 12 : 8;
715
+ case "second":
716
+ return spansMultipleDays ? 17 : 12;
717
+ case "millisecond":
718
+ return spansMultipleDays ? 21 : 16;
719
+ }
720
+ }
721
+
722
+ function formatTimeAxisLabel(
723
+ date: Date,
724
+ previousDate: Date | null,
725
+ unit: AxisLabelUnit,
726
+ ): string {
727
+ if (isNaN(date.getTime())) return "—";
728
+
729
+ switch (unit) {
730
+ case "year":
731
+ return `${date.getFullYear()}`;
732
+ case "month":
733
+ if (previousDate && previousDate.getFullYear() === date.getFullYear()) {
734
+ return MONTHS[date.getMonth()]!;
735
+ }
736
+ return `${MONTHS[date.getMonth()]} ${date.getFullYear()}`;
737
+ case "day":
738
+ if (previousDate && isSameCalendarMonth(previousDate, date)) {
739
+ return `${date.getDate()}`;
740
+ }
741
+ if (previousDate && previousDate.getFullYear() === date.getFullYear()) {
742
+ return `${MONTHS[date.getMonth()]} ${date.getDate()}`;
743
+ }
744
+ return `${MONTHS[date.getMonth()]} ${date.getDate()} ${date.getFullYear()}`;
745
+ case "hour":
746
+ case "minute":
747
+ case "second":
748
+ case "millisecond": {
749
+ const timeLabel = formatClockTime(date, unit);
750
+ if (previousDate && isSameCalendarDay(previousDate, date)) {
751
+ return timeLabel;
752
+ }
753
+ if (previousDate && previousDate.getFullYear() === date.getFullYear()) {
754
+ return `${MONTHS[date.getMonth()]} ${date.getDate()} ${timeLabel}`;
755
+ }
756
+ return `${MONTHS[date.getMonth()]} ${date.getDate()} ${date.getFullYear()} ${timeLabel}`;
757
+ }
758
+ }
759
+ }
760
+
761
+ function formatTimeAxisBoundaryLabel(date: Date, unit: AxisLabelUnit, counterpart: Date): string {
762
+ if (isNaN(date.getTime())) return "—";
763
+
764
+ switch (unit) {
765
+ case "year":
766
+ return `${date.getFullYear()}`;
767
+ case "month":
768
+ return `${MONTHS[date.getMonth()]} ${date.getFullYear()}`;
769
+ case "day":
770
+ return counterpart.getFullYear() === date.getFullYear()
771
+ ? `${MONTHS[date.getMonth()]} ${date.getDate()}`
772
+ : `${MONTHS[date.getMonth()]} ${date.getDate()} ${date.getFullYear()}`;
773
+ case "hour":
774
+ case "minute":
775
+ case "second":
776
+ case "millisecond": {
777
+ const timeLabel = formatClockTime(date, unit);
778
+ if (isSameCalendarDay(date, counterpart)) {
779
+ return timeLabel;
780
+ }
781
+ if (counterpart.getFullYear() === date.getFullYear()) {
782
+ return `${MONTHS[date.getMonth()]} ${date.getDate()} ${timeLabel}`;
783
+ }
784
+ return `${MONTHS[date.getMonth()]} ${date.getDate()} ${date.getFullYear()} ${timeLabel}`;
785
+ }
786
+ }
787
+ }
788
+
789
+ function resolveAxisLabelStart(pos: number, label: string, width: number): number {
790
+ return Math.max(Math.min(pos - Math.floor(label.length / 2), width - label.length), 0);
791
+ }
792
+
793
+ function resolveCenteredAxisLabelStart(label: string, width: number): number {
794
+ return Math.max(Math.floor((width - label.length) / 2), 0);
795
+ }
796
+
797
+ function writeAxisLabel(axis: string[], start: number, label: string) {
798
+ for (let index = 0; index < label.length && start + index < axis.length; index += 1) {
799
+ axis[start + index] = label[index]!;
800
+ }
801
+ }
802
+
803
+ export function buildTimeAxis(dates: Array<Date | string | number>, width: number): string {
804
+ if (dates.length === 0 || width <= 0) return "";
805
+
806
+ const normalizedDates = dates.map((value) => (value instanceof Date ? value : new Date(value)));
807
+ const first = normalizedDates[0]!;
808
+ const last = normalizedDates[normalizedDates.length - 1]!;
809
+ const rawSpanMs = last.getTime() - first.getTime();
810
+ const spanMs = Math.max(rawSpanMs, 1);
811
+ const roughLabelCount = Math.max(Math.floor(width / 10), 2);
812
+ const minGapMs = getMinPositiveGapMs(normalizedDates);
813
+ const effectiveStepMs = Math.max(spanMs / Math.max(roughLabelCount - 1, 1), minGapMs || 0);
814
+ const unit = resolveAxisLabelUnit(effectiveStepMs);
815
+ const allSameTimestamp = rawSpanMs === 0 && minGapMs === 0;
816
+ const axis = new Array(width).fill(" ");
817
+ const minGap = unit === "year" || unit === "month" ? 2 : 1;
818
+ const idealLabelWidth = estimateAxisLabelWidth(unit, first, last);
819
+ const targetLabelCount = Math.min(
820
+ normalizedDates.length,
821
+ Math.max(Math.floor(width / (idealLabelWidth + minGap)), 2),
822
+ );
823
+ const candidateIndices = [...new Set(
824
+ Array.from({ length: targetLabelCount }, (_, index) => (
825
+ targetLabelCount === 1
826
+ ? 0
827
+ : Math.round((index / (targetLabelCount - 1)) * (normalizedDates.length - 1))
828
+ )),
829
+ )];
830
+
831
+ const firstLabel = formatTimeAxisBoundaryLabel(first, unit, last);
832
+ if (allSameTimestamp) {
833
+ const centeredStart = resolveCenteredAxisLabelStart(firstLabel, width);
834
+ writeAxisLabel(axis, centeredStart, firstLabel);
835
+ return axis.join("");
836
+ }
837
+
838
+ const firstStart = resolveAxisLabelStart(0, firstLabel, width);
839
+ writeAxisLabel(axis, firstStart, firstLabel);
840
+ const placedLabels = new Set<string>([firstLabel]);
841
+
842
+ let lastPlacedDate = first;
843
+ let lastEnd = firstStart + firstLabel.length - 1;
844
+
845
+ const lastPos = width - 1;
846
+ const lastLabel = normalizedDates.length === 1
847
+ ? firstLabel
848
+ : formatTimeAxisBoundaryLabel(last, unit, first);
849
+ const lastStart = resolveAxisLabelStart(lastPos, lastLabel, width);
850
+ const lastFits = normalizedDates.length === 1 || lastStart > lastEnd + minGap;
851
+
852
+ for (const index of candidateIndices.slice(1, -1)) {
853
+ const date = normalizedDates[index]!;
854
+ if (isNaN(date.getTime())) continue;
855
+
856
+ const pos = Math.round((index / (normalizedDates.length - 1)) * (width - 1));
857
+ const label = formatTimeAxisLabel(date, lastPlacedDate, unit);
858
+ if (placedLabels.has(label)) continue;
859
+ const start = resolveAxisLabelStart(pos, label, width);
860
+ const end = start + label.length - 1;
861
+
862
+ if (label === axis.slice(start, end + 1).join("")) continue;
863
+ if (start <= lastEnd + minGap) continue;
864
+ if (lastFits && end >= lastStart - minGap) continue;
865
+
866
+ writeAxisLabel(axis, start, label);
867
+ placedLabels.add(label);
868
+ lastPlacedDate = date;
869
+ lastEnd = end;
870
+ }
871
+
872
+ if (normalizedDates.length > 1 && lastFits && !placedLabels.has(lastLabel)) {
873
+ writeAxisLabel(axis, lastStart, lastLabel);
874
+ }
875
+
876
+ return axis.join("");
877
+ }
878
+
879
+ // ---------------------------------------------------------------------------
880
+ // Grid lines & price axis
881
+ // ---------------------------------------------------------------------------
882
+
883
+ export function computeGridLines(
884
+ min: number,
885
+ max: number,
886
+ chartTop: number,
887
+ chartBottom: number,
888
+ numLines: number,
889
+ ): { y: number; price: number }[] {
890
+ const range = max - min || 1;
891
+ const chartH = chartBottom - chartTop;
892
+ const result: { y: number; price: number }[] = [];
893
+
894
+ for (let i = 0; i <= numLines; i++) {
895
+ const frac = i / numLines;
896
+ result.push({
897
+ y: chartTop + Math.round(frac * chartH),
898
+ price: max - frac * range,
899
+ });
900
+ }
901
+
902
+ return result;
903
+ }
904
+
905
+ // ---------------------------------------------------------------------------
906
+ // Main render entry point
907
+ // ---------------------------------------------------------------------------
908
+
909
+ export interface RenderChartOptions {
910
+ width: number;
911
+ height: number;
912
+ showVolume: boolean;
913
+ volumeHeight: number;
914
+ cursorX: number | null;
915
+ cursorY: number | null;
916
+ mode: ChartRenderMode;
917
+ axisMode?: ChartAxisMode;
918
+ currency?: string;
919
+ colors: ResolvedChartPalette;
920
+ }
921
+
922
+ export interface ChartScene {
923
+ points: ProjectedChartPoint[];
924
+ width: number;
925
+ height: number;
926
+ showVolume: boolean;
927
+ volumeHeight: number;
928
+ chartRows: number;
929
+ mode: ChartRenderMode;
930
+ colors: ResolvedChartPalette;
931
+ min: number;
932
+ max: number;
933
+ activeIdx: number;
934
+ activePoint: ProjectedChartPoint;
935
+ priceAtCursor: number;
936
+ crosshairPrice: number | null;
937
+ dateAtCursor: Date;
938
+ changeAtCursor: number;
939
+ changePctAtCursor: number;
940
+ timeLabels: string;
941
+ cursorX: number | null;
942
+ cursorY: number | null;
943
+ cursorColumn: number | null;
944
+ cursorRow: number | null;
945
+ cursorDotX: number | null;
946
+ }
947
+
948
+ export interface RenderChartResult {
949
+ lines: StyledContent[];
950
+ axisLabels: { row: number; label: string }[];
951
+ timeLabels: string;
952
+ activePoint: ProjectedChartPoint | null;
953
+ priceAtCursor: number | null;
954
+ crosshairPrice: number | null;
955
+ dateAtCursor: Date | null;
956
+ changeAtCursor: number | null;
957
+ changePctAtCursor: number | null;
958
+ cursorColumn: number | null;
959
+ cursorRow: number | null;
960
+ /** Raw pixel buffer for GPU/Kitty rendering path */
961
+ pixelBuffer: PixelBuffer | null;
962
+ }
963
+
964
+ export function getActivePointIndex(
965
+ pointCount: number,
966
+ width: number,
967
+ cursorX: number | null,
968
+ mode: ChartRenderMode,
969
+ ): number {
970
+ if (pointCount <= 0) return 0;
971
+ if (cursorX === null || cursorX < 0 || cursorX >= width) {
972
+ return pointCount - 1;
973
+ }
974
+
975
+ let bestIndex = pointCount - 1;
976
+ let bestDistance = Number.POSITIVE_INFINITY;
977
+
978
+ for (let index = 0; index < pointCount; index += 1) {
979
+ const pointColumn = getPointTerminalColumn(index, pointCount, width, mode);
980
+ const distance = Math.abs(pointColumn - cursorX);
981
+ if (distance <= bestDistance) {
982
+ bestDistance = distance;
983
+ bestIndex = index;
984
+ }
985
+ }
986
+
987
+ return bestIndex;
988
+ }
989
+
990
+ export function buildChartScene(
991
+ points: ProjectedChartPoint[],
992
+ opts: RenderChartOptions,
993
+ ): ChartScene | null {
994
+ if (points.length === 0) return null;
995
+
996
+ const min = opts.mode === "candles" || opts.mode === "ohlc"
997
+ ? Math.min(...points.map((point) => point.low))
998
+ : Math.min(...points.map((point) => point.close));
999
+ const max = opts.mode === "candles" || opts.mode === "ohlc"
1000
+ ? Math.max(...points.map((point) => point.high))
1001
+ : Math.max(...points.map((point) => point.close));
1002
+ const activeIdx = getActivePointIndex(points.length, opts.width, opts.cursorX, opts.mode);
1003
+ const activePoint = points[activeIdx]!;
1004
+ const range = max - min || 1;
1005
+ const chartRows = opts.height - (opts.showVolume ? opts.volumeHeight : 0);
1006
+ const cursorX = opts.cursorX === null
1007
+ ? null
1008
+ : Math.min(Math.max(opts.cursorX, 0), Math.max(opts.width - 1, 0));
1009
+ const cursorColumn = cursorX === null
1010
+ ? null
1011
+ : Math.round(cursorX);
1012
+ const cursorDotX = cursorX === null
1013
+ ? null
1014
+ : Math.round((cursorX / Math.max(opts.width - 1, 1)) * Math.max(opts.width * 2 - 1, 0));
1015
+ const cursorY = cursorX === null
1016
+ ? null
1017
+ : opts.cursorY !== null
1018
+ ? Math.min(Math.max(opts.cursorY, 0), Math.max(chartRows - 1, 0))
1019
+ : Math.min(
1020
+ Math.max(Math.round((1 - (activePoint.close - min) / range) * Math.max(chartRows - 1, 0)), 0),
1021
+ Math.max(chartRows - 1, 0),
1022
+ );
1023
+ const cursorRow = cursorY === null
1024
+ ? null
1025
+ : Math.round(cursorY);
1026
+ const crosshairPrice = cursorY === null
1027
+ ? null
1028
+ : max - (cursorY / Math.max(chartRows - 1, 1)) * range;
1029
+
1030
+ return {
1031
+ points,
1032
+ width: opts.width,
1033
+ height: opts.height,
1034
+ showVolume: opts.showVolume,
1035
+ volumeHeight: opts.volumeHeight,
1036
+ chartRows,
1037
+ mode: opts.mode,
1038
+ colors: opts.colors,
1039
+ min,
1040
+ max,
1041
+ activeIdx,
1042
+ activePoint,
1043
+ priceAtCursor: activePoint.close,
1044
+ crosshairPrice,
1045
+ dateAtCursor: activePoint.date,
1046
+ changeAtCursor: activePoint.close - points[0]!.close,
1047
+ changePctAtCursor: points[0]!.close ? ((activePoint.close - points[0]!.close) / points[0]!.close) * 100 : 0,
1048
+ timeLabels: buildTimeAxis(points.map((point) => point.date), opts.width),
1049
+ cursorX,
1050
+ cursorY,
1051
+ cursorColumn,
1052
+ cursorRow,
1053
+ cursorDotX,
1054
+ };
1055
+ }
1056
+
1057
+ export function renderChart(
1058
+ points: ProjectedChartPoint[],
1059
+ opts: RenderChartOptions,
1060
+ ): RenderChartResult {
1061
+ const scene = buildChartScene(points, opts);
1062
+ if (!scene) {
1063
+ return {
1064
+ lines: [],
1065
+ axisLabels: [],
1066
+ timeLabels: "",
1067
+ activePoint: null,
1068
+ priceAtCursor: null,
1069
+ crosshairPrice: null,
1070
+ dateAtCursor: null,
1071
+ changeAtCursor: null,
1072
+ changePctAtCursor: null,
1073
+ cursorColumn: null,
1074
+ cursorRow: null,
1075
+ pixelBuffer: null,
1076
+ };
1077
+ }
1078
+
1079
+ const { width, colors: palette, mode } = opts;
1080
+ const axisMode = opts.axisMode ?? "price";
1081
+ const currency = opts.currency ?? "USD";
1082
+
1083
+ // Braille resolution: 2 dot-columns per terminal column, 4 dot-rows per terminal row
1084
+ const dotWidth = width * 2;
1085
+ const volTermRows = opts.showVolume ? opts.volumeHeight : 0;
1086
+ const chartTermRows = opts.height - volTermRows;
1087
+ const totalDotH = opts.height * 4;
1088
+ const chartDotBottom = chartTermRows * 4 - 1;
1089
+ const volDotTop = chartTermRows * 4;
1090
+ const volDotBottom = totalDotH - 1;
1091
+
1092
+ const buf = createPixelBuffer(dotWidth, totalDotH);
1093
+ const { min, max } = scene;
1094
+
1095
+ const gridLines = computeGridLines(min, max, 0, chartDotBottom, 3);
1096
+ drawGridLines(buf, gridLines.map((line) => line.y), palette.gridColor);
1097
+
1098
+ switch (mode) {
1099
+ case "area":
1100
+ drawAreaChart(buf, points, 0, chartDotBottom, palette.lineColor, palette.fillColor, min, max);
1101
+ break;
1102
+ case "line":
1103
+ drawLineSeries(buf, points, 0, chartDotBottom, palette.lineColor, min, max);
1104
+ break;
1105
+ case "candles":
1106
+ drawCandlestickChart(buf, points, 0, chartDotBottom, palette, min, max);
1107
+ break;
1108
+ case "ohlc":
1109
+ drawOhlcChart(buf, points, 0, chartDotBottom, palette, min, max);
1110
+ break;
1111
+ }
1112
+
1113
+ if (opts.showVolume && volTermRows > 0) {
1114
+ drawVolumeBars(
1115
+ buf,
1116
+ points,
1117
+ volDotTop,
1118
+ volDotBottom,
1119
+ palette.volumeUp,
1120
+ palette.volumeDown,
1121
+ mode === "candles" || mode === "ohlc" ? "openClose" : "previousClose",
1122
+ mode,
1123
+ );
1124
+ }
1125
+
1126
+ // Cursor mapping stays in terminal-column space
1127
+ const { activePoint, priceAtCursor, crosshairPrice, dateAtCursor, changeAtCursor, changePctAtCursor } = scene;
1128
+
1129
+ if (scene.cursorDotX !== null) {
1130
+ drawCrosshair(buf, scene.cursorDotX, 0, opts.showVolume ? volDotBottom : chartDotBottom, palette.crosshairColor);
1131
+ }
1132
+
1133
+ const axisLabelsByRow = new Map<number, string>();
1134
+ for (const line of gridLines) {
1135
+ axisLabelsByRow.set(
1136
+ Math.min(Math.floor(line.y / 4), Math.max(chartTermRows - 1, 0)),
1137
+ formatAxisValue(line.price, axisMode, points[0]!.close, currency),
1138
+ );
1139
+ }
1140
+
1141
+ return {
1142
+ lines: bufferToBrailleLines(buf, palette.bgColor),
1143
+ axisLabels: [...axisLabelsByRow.entries()].map(([row, label]) => ({ row, label })),
1144
+ timeLabels: buildTimeAxis(points.map((point) => point.date), width),
1145
+ activePoint,
1146
+ priceAtCursor,
1147
+ crosshairPrice,
1148
+ dateAtCursor,
1149
+ changeAtCursor,
1150
+ changePctAtCursor,
1151
+ cursorColumn: scene.cursorColumn,
1152
+ cursorRow: scene.cursorRow,
1153
+ pixelBuffer: buf,
1154
+ };
1155
+ }
1156
+
1157
+ // ---------------------------------------------------------------------------
1158
+ // Pixel buffer → RGBA conversion for Kitty graphics protocol
1159
+ // ---------------------------------------------------------------------------
1160
+
1161
+ function parseHex(hex: string): [number, number, number] {
1162
+ const h = hex.replace("#", "");
1163
+ return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
1164
+ }
1165
+
1166
+ /**
1167
+ * Convert a PixelBuffer to RGBA pixel data for use with drawSuperSampleBuffer.
1168
+ * Each pixel in the buffer maps to one RGBA pixel in the output.
1169
+ */
1170
+ export function pixelBufferToRGBA(buf: PixelBuffer, bgColor: string): Uint8Array {
1171
+ const [bgR, bgG, bgB] = parseHex(bgColor);
1172
+ const data = new Uint8Array(buf.width * buf.height * 4);
1173
+ const colorCache = new Map<string, [number, number, number]>();
1174
+
1175
+ for (let y = 0; y < buf.height; y++) {
1176
+ for (let x = 0; x < buf.width; x++) {
1177
+ const offset = (y * buf.width + x) * 4;
1178
+ const pixel = buf.pixels[y]?.[x] ?? null;
1179
+ if (pixel) {
1180
+ let rgb = colorCache.get(pixel.color);
1181
+ if (!rgb) {
1182
+ rgb = parseHex(pixel.color);
1183
+ colorCache.set(pixel.color, rgb);
1184
+ }
1185
+ data[offset] = rgb[0];
1186
+ data[offset + 1] = rgb[1];
1187
+ data[offset + 2] = rgb[2];
1188
+ data[offset + 3] = 255;
1189
+ } else {
1190
+ data[offset] = bgR;
1191
+ data[offset + 1] = bgG;
1192
+ data[offset + 2] = bgB;
1193
+ data[offset + 3] = 255;
1194
+ }
1195
+ }
1196
+ }
1197
+
1198
+ return data;
1199
+ }