@forgecharts/sdk 1.1.23

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 (101) hide show
  1. package/package.json +50 -0
  2. package/src/__tests__/backwardCompatibility.test.ts +191 -0
  3. package/src/__tests__/candleInvariant.test.ts +500 -0
  4. package/src/__tests__/public-api-surface.ts +76 -0
  5. package/src/__tests__/timeframeBoundary.test.ts +583 -0
  6. package/src/api/DrawingManager.ts +188 -0
  7. package/src/api/EventBus.ts +53 -0
  8. package/src/api/IndicatorDAG.ts +389 -0
  9. package/src/api/IndicatorRegistry.ts +47 -0
  10. package/src/api/LayoutManager.ts +72 -0
  11. package/src/api/PaneManager.ts +129 -0
  12. package/src/api/ReferenceAPI.ts +195 -0
  13. package/src/api/TChart.ts +881 -0
  14. package/src/api/createChart.ts +43 -0
  15. package/src/api/drawing tools/fib gann menu/fibRetracement.ts +27 -0
  16. package/src/api/drawing tools/lines menu/crossLine.ts +21 -0
  17. package/src/api/drawing tools/lines menu/disjointChannel.ts +74 -0
  18. package/src/api/drawing tools/lines menu/extendedLine.ts +22 -0
  19. package/src/api/drawing tools/lines menu/flatTopBottom.ts +45 -0
  20. package/src/api/drawing tools/lines menu/horizontal.ts +24 -0
  21. package/src/api/drawing tools/lines menu/horizontalRay.ts +25 -0
  22. package/src/api/drawing tools/lines menu/infoLine.ts +127 -0
  23. package/src/api/drawing tools/lines menu/insidePitchfork.ts +21 -0
  24. package/src/api/drawing tools/lines menu/modifiedSchiffPitchfork.ts +18 -0
  25. package/src/api/drawing tools/lines menu/parallelChannel.ts +47 -0
  26. package/src/api/drawing tools/lines menu/pitchfork.ts +15 -0
  27. package/src/api/drawing tools/lines menu/ray.ts +28 -0
  28. package/src/api/drawing tools/lines menu/regressionTrend.ts +157 -0
  29. package/src/api/drawing tools/lines menu/schiffPitchfork.ts +18 -0
  30. package/src/api/drawing tools/lines menu/trendAngle.ts +64 -0
  31. package/src/api/drawing tools/lines menu/trendline.ts +16 -0
  32. package/src/api/drawing tools/lines menu/vertical.ts +16 -0
  33. package/src/api/drawing tools/pointers menu/crosshair.ts +17 -0
  34. package/src/api/drawing tools/pointers menu/cursor.ts +16 -0
  35. package/src/api/drawing tools/pointers menu/demonstration.ts +35 -0
  36. package/src/api/drawing tools/pointers menu/dot.ts +26 -0
  37. package/src/api/drawing tools/shapes menu/rectangle.ts +24 -0
  38. package/src/api/drawing tools/shapes menu/text.ts +30 -0
  39. package/src/api/drawingUtils.ts +82 -0
  40. package/src/core/CanvasLayer.ts +77 -0
  41. package/src/core/Chart.ts +917 -0
  42. package/src/core/CoordTransform.ts +282 -0
  43. package/src/core/Crosshair.ts +207 -0
  44. package/src/core/IndicatorEngine.ts +216 -0
  45. package/src/core/InteractionManager.ts +899 -0
  46. package/src/core/PriceScale.ts +133 -0
  47. package/src/core/Series.ts +132 -0
  48. package/src/core/TimeScale.ts +175 -0
  49. package/src/datafeed/DatafeedConnector.ts +300 -0
  50. package/src/engine/CandleEngine.ts +458 -0
  51. package/src/engine/__tests__/CandleEngine.test.ts +402 -0
  52. package/src/engine/candleInvariants.ts +172 -0
  53. package/src/engine/mergeUtils.ts +93 -0
  54. package/src/engine/timeframeUtils.ts +118 -0
  55. package/src/index.ts +190 -0
  56. package/src/internal.ts +41 -0
  57. package/src/licensing/ChartRuntimeResolver.ts +380 -0
  58. package/src/licensing/LicenseManager.ts +131 -0
  59. package/src/licensing/__tests__/ChartRuntimeResolver.test.ts +207 -0
  60. package/src/licensing/__tests__/LicenseManager.test.ts +180 -0
  61. package/src/licensing/licenseTypes.ts +19 -0
  62. package/src/pine/PineCompiler.ts +68 -0
  63. package/src/pine/diagnostics.ts +30 -0
  64. package/src/pine/index.ts +7 -0
  65. package/src/pine/pine-ast.ts +163 -0
  66. package/src/pine/pine-lexer.ts +265 -0
  67. package/src/pine/pine-parser.ts +439 -0
  68. package/src/pine/pine-transpiler.ts +301 -0
  69. package/src/pixi/LayerName.ts +35 -0
  70. package/src/pixi/PixiCandlestickRenderer.ts +125 -0
  71. package/src/pixi/PixiChart.ts +425 -0
  72. package/src/pixi/PixiCrosshairRenderer.ts +134 -0
  73. package/src/pixi/PixiDrawingRenderer.ts +121 -0
  74. package/src/pixi/PixiGridRenderer.ts +136 -0
  75. package/src/pixi/PixiLayerManager.ts +102 -0
  76. package/src/renderers/CandlestickRenderer.ts +130 -0
  77. package/src/renderers/HistogramRenderer.ts +63 -0
  78. package/src/renderers/LineRenderer.ts +77 -0
  79. package/src/theme/colors.ts +21 -0
  80. package/src/tools/barDivergenceCheck.ts +305 -0
  81. package/src/trading/TradingOverlayStore.ts +161 -0
  82. package/src/trading/UnmanagedIngestion.ts +156 -0
  83. package/src/trading/__tests__/ManagedTradingController.test.ts +338 -0
  84. package/src/trading/__tests__/TradingOverlayStore.test.ts +323 -0
  85. package/src/trading/__tests__/UnmanagedIngestion.test.ts +205 -0
  86. package/src/trading/managed/ManagedTradingController.ts +292 -0
  87. package/src/trading/managed/managedCapabilities.ts +98 -0
  88. package/src/trading/managed/managedTypes.ts +151 -0
  89. package/src/trading/tradingTypes.ts +135 -0
  90. package/src/tscript/TScriptIndicator.ts +54 -0
  91. package/src/tscript/ast.ts +105 -0
  92. package/src/tscript/lexer.ts +190 -0
  93. package/src/tscript/parser.ts +334 -0
  94. package/src/tscript/runtime.ts +525 -0
  95. package/src/tscript/series.ts +84 -0
  96. package/src/types/IChart.ts +56 -0
  97. package/src/types/IRenderer.ts +16 -0
  98. package/src/types/ISeries.ts +30 -0
  99. package/tsconfig.json +22 -0
  100. package/tsup.config.ts +15 -0
  101. package/vitest.config.ts +25 -0
@@ -0,0 +1,282 @@
1
+ import type { TimeRange, PriceRange, Point, DrawingPoint } from '@forgecharts/types';
2
+ import type { TimeScale } from './TimeScale';
3
+ import type { PriceScale } from './PriceScale';
4
+
5
+ /**
6
+ * Immutable snapshot of the plot area geometry (excluding axes).
7
+ * All coordinates are in logical canvas pixels (pre-DPR).
8
+ */
9
+ export type PlotArea = {
10
+ /** Left edge — always 0. */
11
+ readonly left: number;
12
+ /** Top edge — always 0. */
13
+ readonly top: number;
14
+ /** Right edge = canvasWidth − priceScaleWidth. */
15
+ readonly right: number;
16
+ /** Bottom edge = canvasHeight − timeScaleHeight. */
17
+ readonly bottom: number;
18
+ /** Drawable width (canvasWidth − priceScaleWidth). */
19
+ readonly width: number;
20
+ /** Drawable height (canvasHeight − timeScaleHeight). */
21
+ readonly height: number;
22
+ };
23
+
24
+ /**
25
+ * CoordTransform — the single source of truth for all coordinate conversions.
26
+ *
27
+ * Converts between chart data-space coordinates (Unix timestamp in seconds,
28
+ * price) and canvas pixel coordinates (x, y). Every chart subsystem that
29
+ * needs coordinate maths — the Crosshair, series renderers, drawing tools,
30
+ * plugins — should use this class rather than reading scale internals directly.
31
+ *
32
+ * A `CoordTransform` instance is owned by `Chart` and can be retrieved via
33
+ * `chart.transform()`. It is kept up-to-date with every resize; callers
34
+ * hold a stable reference and always see current values.
35
+ *
36
+ * @example
37
+ * ```ts
38
+ * const t = chart.transform();
39
+ *
40
+ * // Data → pixel
41
+ * const x = t.timeToX(bar.time);
42
+ * const y = t.priceToY(bar.close);
43
+ * const { x, y } = t.toPixel(bar.time, bar.close);
44
+ *
45
+ * // Pixel → data (e.g. read pointer position)
46
+ * const { time, price } = t.fromPixel(event.offsetX, event.offsetY);
47
+ *
48
+ * // Zoom on wheel
49
+ * t.zoomTime(0.9, event.offsetX); // zoom in
50
+ *
51
+ * // Pan on drag
52
+ * t.pan(dx, dy);
53
+ * ```
54
+ */
55
+ export class CoordTransform {
56
+ private _canvasWidth = 0;
57
+ private _canvasHeight = 0;
58
+
59
+ private readonly _timeScale: TimeScale;
60
+ private readonly _priceScale: PriceScale;
61
+
62
+ /** Width of the right-side price axis in logical pixels. */
63
+ readonly priceScaleWidth: number;
64
+
65
+ /** Height of the bottom time axis in logical pixels. */
66
+ readonly timeScaleHeight: number;
67
+
68
+ constructor(
69
+ timeScale: TimeScale,
70
+ priceScale: PriceScale,
71
+ priceScaleWidth: number,
72
+ timeScaleHeight: number,
73
+ ) {
74
+ this._timeScale = timeScale;
75
+ this._priceScale = priceScale;
76
+ this.priceScaleWidth = priceScaleWidth;
77
+ this.timeScaleHeight = timeScaleHeight;
78
+ }
79
+
80
+ // ─── Canvas update ────────────────────────────────────────────────────────
81
+
82
+ /**
83
+ * Stores the current canvas size.
84
+ * **Must be called** by `Chart` once at construction and after every resize.
85
+ */
86
+ update(canvasWidth: number, canvasHeight: number): void {
87
+ this._canvasWidth = canvasWidth;
88
+ this._canvasHeight = canvasHeight;
89
+ }
90
+
91
+ // ─── Plot area geometry ───────────────────────────────────────────────────
92
+
93
+ /**
94
+ * The drawable region excluding both axes.
95
+ * Use this to clip drawing operations or check mouse bounds.
96
+ */
97
+ get plotArea(): PlotArea {
98
+ const w = this._canvasWidth - this.priceScaleWidth;
99
+ const h = this._canvasHeight - this.timeScaleHeight;
100
+ return { left: 0, top: 0, right: w, bottom: h, width: w, height: h };
101
+ }
102
+
103
+ /** Full logical canvas width (plot area + price axis). */
104
+ get canvasWidth(): number {
105
+ return this._canvasWidth;
106
+ }
107
+
108
+ /** Full logical canvas height (plot area + time axis). */
109
+ get canvasHeight(): number {
110
+ return this._canvasHeight;
111
+ }
112
+
113
+ /** Drawable width (canvas width minus the price axis). */
114
+ get plotWidth(): number {
115
+ return this._canvasWidth - this.priceScaleWidth;
116
+ }
117
+
118
+ /** Drawable height (canvas height minus the time axis). */
119
+ get plotHeight(): number {
120
+ return this._canvasHeight - this.timeScaleHeight;
121
+ }
122
+
123
+ // ─── Time axis ────────────────────────────────────────────────────────────
124
+
125
+ /**
126
+ * Maps a Unix timestamp (seconds) to a canvas X pixel.
127
+ *
128
+ * Returns a value in `[0, plotWidth]` for visible times; may be negative
129
+ * or exceed `plotWidth` for off-screen bars — useful for clipping checks.
130
+ */
131
+ timeToX(time: number): number {
132
+ return this._timeScale.timeToX(time, this._canvasWidth);
133
+ }
134
+
135
+ /**
136
+ * Maps a canvas X pixel coordinate back to a Unix timestamp (seconds).
137
+ */
138
+ xToTime(x: number): number {
139
+ return this._timeScale.xToTime(x, this._canvasWidth);
140
+ }
141
+
142
+ // ─── Price axis ───────────────────────────────────────────────────────────
143
+
144
+ /**
145
+ * Maps a price value to a canvas Y pixel.
146
+ *
147
+ * Y = 0 is the top of the canvas. Higher prices map to smaller Y values
148
+ * in normal (non-inverted) scale mode.
149
+ */
150
+ priceToY(price: number): number {
151
+ return this._priceScale.priceToY(price, this._canvasHeight);
152
+ }
153
+
154
+ /**
155
+ * Maps a canvas Y pixel coordinate back to a price value.
156
+ */
157
+ yToPrice(y: number): number {
158
+ return this._priceScale.yToPrice(y, this._canvasHeight);
159
+ }
160
+
161
+ // ─── Combined helpers ─────────────────────────────────────────────────────
162
+
163
+ /**
164
+ * Converts a data-space point to a canvas pixel.
165
+ *
166
+ * @example
167
+ * ```ts
168
+ * const { x, y } = transform.toPixel(bar.time, bar.high);
169
+ * ctx.arc(x, y, 3, 0, Math.PI * 2);
170
+ * ```
171
+ */
172
+ toPixel(time: number, price: number): Point {
173
+ return { x: this.timeToX(time), y: this.priceToY(price) };
174
+ }
175
+
176
+ /**
177
+ * Converts a canvas pixel to a data-space point (time, price).
178
+ *
179
+ * Returned `time` is in Unix seconds; `price` is in the same units as the
180
+ * price scale (e.g. USD for BTCUSDT charts).
181
+ *
182
+ * @example
183
+ * ```ts
184
+ * canvas.addEventListener('click', e => {
185
+ * const { time, price } = transform.fromPixel(e.offsetX, e.offsetY);
186
+ * });
187
+ * ```
188
+ */
189
+ fromPixel(x: number, y: number): DrawingPoint {
190
+ return { time: this.xToTime(x), price: this.yToPrice(y) };
191
+ }
192
+
193
+ /**
194
+ * Returns `true` when pixel `(x, y)` falls inside the plot area —
195
+ * i.e. not over the axis gutter regions.
196
+ */
197
+ inPlotArea(x: number, y: number): boolean {
198
+ return x >= 0 && x <= this.plotWidth && y >= 0 && y <= this.plotHeight;
199
+ }
200
+
201
+ // ─── Zoom ─────────────────────────────────────────────────────────────────
202
+
203
+ /**
204
+ * Zooms the time axis around a canvas-space pivot X pixel.
205
+ *
206
+ * @param factor Multiplier applied to the visible time span.
207
+ * `< 1` = zoom in (fewer bars visible),
208
+ * `> 1` = zoom out (more bars visible).
209
+ * @param pivotX Canvas X pixel that remains fixed during the zoom.
210
+ * Pass `event.offsetX` from a wheel event.
211
+ */
212
+ zoomTime(factor: number, pivotX: number): void {
213
+ this._timeScale.zoom(factor, pivotX, this._canvasWidth);
214
+ }
215
+
216
+ /**
217
+ * Zooms the price axis around a canvas-space pivot Y pixel.
218
+ *
219
+ * @param factor Multiplier for the visible price span.
220
+ * `> 1` = zoom out (wider range), `< 1` = zoom in (narrower range).
221
+ * @param pivotY Canvas Y pixel that stays fixed during the zoom.
222
+ */
223
+ zoomPrice(factor: number, pivotY: number): void {
224
+ const pivotPrice = this.yToPrice(pivotY);
225
+ const { min, max } = this._priceScale.visibleRange;
226
+ const span = max - min;
227
+ const newSpan = span * factor;
228
+ // Keep the pivot price anchored at the same Y position
229
+ const ratio = (pivotPrice - min) / span;
230
+ const newMin = pivotPrice - ratio * newSpan;
231
+ this._priceScale.setVisibleRange({ min: newMin, max: newMin + newSpan });
232
+ }
233
+
234
+ // ─── Pan ──────────────────────────────────────────────────────────────────
235
+
236
+ /**
237
+ * Pans the chart by a pixel delta — typically called from a drag handler.
238
+ *
239
+ * - `deltaX > 0` (right drag) shifts the visible time range backward
240
+ * (revealing older bars).
241
+ * - `deltaX < 0` (left drag) shifts forward (revealing newer bars).
242
+ * - `deltaY > 0` (down drag) shifts the visible price range downward.
243
+ * - `deltaY < 0` (up drag) shifts the price range upward.
244
+ *
245
+ * @param deltaX Horizontal pixel displacement since last event.
246
+ * @param deltaY Vertical pixel displacement since last event.
247
+ */
248
+ pan(deltaX: number, deltaY: number): void {
249
+ // ── Time axis ────────────────────────────────────────────────────────────
250
+ if (deltaX !== 0 && this._canvasWidth > 0) {
251
+ const { from, to } = this._timeScale.visibleRange;
252
+ const timeSpan = to - from;
253
+ // Right drag (deltaX > 0) → move range backward → negate
254
+ const deltaSeconds = (deltaX / this.plotWidth) * timeSpan;
255
+ this._timeScale.scroll(-deltaSeconds);
256
+ }
257
+
258
+ // ── Price axis ────────────────────────────────────────────────────────────
259
+ if (deltaY !== 0 && this._canvasHeight > 0) {
260
+ const { min, max } = this._priceScale.visibleRange;
261
+ const priceSpan = max - min;
262
+ // Down drag (deltaY > 0) → chart content moves down → reveal higher prices → add
263
+ const deltaPrice = (deltaY / this.plotHeight) * priceSpan;
264
+ this._priceScale.setVisibleRange({
265
+ min: min + deltaPrice,
266
+ max: max + deltaPrice,
267
+ });
268
+ }
269
+ }
270
+
271
+ // ─── Range accessors ──────────────────────────────────────────────────────
272
+
273
+ /** The currently visible time range (Unix seconds). */
274
+ get timeRange(): TimeRange {
275
+ return this._timeScale.visibleRange;
276
+ }
277
+
278
+ /** The currently visible price range. */
279
+ get priceRange(): PriceRange {
280
+ return this._priceScale.visibleRange;
281
+ }
282
+ }
@@ -0,0 +1,207 @@
1
+ import type { ChartColors, ChartOptions, CrosshairOptions } from '@forgecharts/types';
2
+ import type { CanvasLayer } from './CanvasLayer';
3
+ import type { CoordTransform } from './CoordTransform';
4
+ import { hexToRgba } from '@forgecharts/utils';
5
+
6
+ /**
7
+ * Crosshair — renders the interactive cross-line overlay and price/time labels.
8
+ */
9
+ export class Crosshair {
10
+ private _x: number | null = null;
11
+ private _snapTime: number | null = null;
12
+ private _y: number | null = null;
13
+ private readonly _layer: CanvasLayer;
14
+ private readonly _colors: ChartColors;
15
+ private readonly _options: Required<CrosshairOptions>;
16
+ private readonly _transform: CoordTransform;
17
+ /** IANA timezone for the time label on the crosshair. Falls back to UTC. */
18
+ timezone: string | undefined = undefined;
19
+ /** Active timeframe — controls whether time is shown alongside the date. */
20
+ interval: string = '1h';
21
+
22
+ constructor(
23
+ layer: CanvasLayer,
24
+ colors: ChartColors,
25
+ chartOptions: ChartOptions,
26
+ transform: CoordTransform,
27
+ ) {
28
+ this._layer = layer;
29
+ this._colors = colors;
30
+ this._transform = transform;
31
+ const co = chartOptions.crosshair as CrosshairOptions | undefined;
32
+ this._options = {
33
+ mode: co?.mode ?? 'normal',
34
+ vertLine: {
35
+ ...(co?.vertLine?.color !== undefined ? { color: co.vertLine.color } : {}),
36
+ width: co?.vertLine?.width ?? 1,
37
+ style: co?.vertLine?.style ?? 'dashed',
38
+ visible: co?.vertLine?.visible ?? true,
39
+ labelVisible: co?.vertLine?.labelVisible ?? true,
40
+ },
41
+ horzLine: {
42
+ ...(co?.horzLine?.color !== undefined ? { color: co.horzLine.color } : {}),
43
+ width: co?.horzLine?.width ?? 1,
44
+ style: co?.horzLine?.style ?? 'dashed',
45
+ visible: co?.horzLine?.visible ?? true,
46
+ labelVisible: co?.horzLine?.labelVisible ?? true,
47
+ },
48
+ };
49
+ }
50
+
51
+ update(x: number, y: number, snapTime?: number): void {
52
+ this._x = x;
53
+ this._y = y;
54
+ this._snapTime = snapTime ?? null;
55
+ }
56
+
57
+ hide(): void {
58
+ this._x = null;
59
+ this._y = null;
60
+ this._snapTime = null;
61
+ }
62
+
63
+ destroy(): void {
64
+ this.hide();
65
+ }
66
+
67
+ render(ctx: CanvasRenderingContext2D, width: number, height: number): void {
68
+ if (this._x === null || this._y === null) return;
69
+ if (this._options.mode === 'hidden') return;
70
+
71
+ const { x, y } = { x: this._x, y: this._y };
72
+ const priceScaleWidth = this._transform.priceScaleWidth;
73
+ const timeScaleHeight = this._transform.timeScaleHeight;
74
+ const plotWidth = this._transform.plotWidth;
75
+ const plotHeight = this._transform.plotHeight;
76
+
77
+ // Suppress unused-variable warnings — width/height kept for API compatibility
78
+ void width;
79
+ void height;
80
+
81
+ ctx.save();
82
+
83
+ // Vertical line
84
+ if (this._options.vertLine.visible === true && x >= 0 && x <= plotWidth) {
85
+ const vl = this._options.vertLine;
86
+ ctx.beginPath();
87
+ ctx.strokeStyle = vl.color ?? this._colors.crosshair;
88
+ ctx.lineWidth = vl.width ?? 1;
89
+ this._applyLineStyle(ctx, vl.style ?? 'dashed');
90
+ ctx.moveTo(x, 0);
91
+ ctx.lineTo(x, plotHeight);
92
+ ctx.stroke();
93
+ ctx.setLineDash([]);
94
+ }
95
+
96
+ // Horizontal line
97
+ if (this._options.horzLine.visible === true && y >= 0 && y <= plotHeight) {
98
+ const hl = this._options.horzLine;
99
+ ctx.beginPath();
100
+ ctx.strokeStyle = hl.color ?? this._colors.crosshair;
101
+ ctx.lineWidth = hl.width ?? 1;
102
+ this._applyLineStyle(ctx, hl.style ?? 'dashed');
103
+ ctx.moveTo(0, y);
104
+ ctx.lineTo(plotWidth, y);
105
+ ctx.stroke();
106
+ ctx.setLineDash([]);
107
+
108
+ // Price label on right axis
109
+ if (this._options.horzLine.labelVisible === true) {
110
+ const price = this._transform.yToPrice(y);
111
+ const label = this._formatPrice(price);
112
+ const labelBg = hexToRgba(this._colors.crosshair, 0.9);
113
+ ctx.fillStyle = labelBg;
114
+ const labelW = priceScaleWidth;
115
+ const labelH = 18;
116
+ const labelY = y - labelH / 2;
117
+ ctx.fillRect(plotWidth, labelY, labelW, labelH);
118
+ ctx.fillStyle = this._colors.background;
119
+ ctx.font = '11px system-ui, sans-serif';
120
+ ctx.textAlign = 'left';
121
+ ctx.textBaseline = 'middle';
122
+ ctx.fillText(label, plotWidth + 6, y);
123
+ }
124
+ }
125
+
126
+ // Center crosshair indicator — 4 short arms with a gap, matching the toolbar icon
127
+ if (
128
+ this._options.vertLine.visible === true &&
129
+ this._options.horzLine.visible === true &&
130
+ x >= 0 && x <= plotWidth &&
131
+ y >= 0 && y <= plotHeight
132
+ ) {
133
+ const GAP = 4; // half-gap from center
134
+ const ARM = 8; // arm length (tip to gap)
135
+ ctx.beginPath();
136
+ ctx.strokeStyle = this._colors.crosshair;
137
+ ctx.lineWidth = 1.5;
138
+ ctx.setLineDash([]);
139
+ // top arm
140
+ ctx.moveTo(x, y - GAP); ctx.lineTo(x, y - GAP - ARM);
141
+ // bottom arm
142
+ ctx.moveTo(x, y + GAP); ctx.lineTo(x, y + GAP + ARM);
143
+ // left arm
144
+ ctx.moveTo(x - GAP, y); ctx.lineTo(x - GAP - ARM, y);
145
+ // right arm
146
+ ctx.moveTo(x + GAP, y); ctx.lineTo(x + GAP + ARM, y);
147
+ ctx.stroke();
148
+ ctx.setLineDash([]);
149
+ }
150
+
151
+ // Time label on bottom axis (below vertical line)
152
+ if (
153
+ this._options.vertLine.visible === true &&
154
+ this._options.vertLine.labelVisible === true &&
155
+ x >= 0 && x <= plotWidth
156
+ ) {
157
+ const time = this._snapTime ?? this._transform.xToTime(x);
158
+ const label = this._formatTime(time);
159
+ const dailyIntervals = new Set(['1d', '2d', '3d', '1w', '2w', '1M', '3M', '6M', '12M']);
160
+ const labelW = dailyIntervals.has(this.interval) ? 110 : 160;
161
+ const labelH = timeScaleHeight - 4;
162
+ const labelX = Math.max(labelW / 2, Math.min(x, plotWidth - labelW / 2));
163
+ const labelBg = hexToRgba(this._colors.crosshair, 0.9);
164
+ ctx.fillStyle = labelBg;
165
+ ctx.fillRect(labelX - labelW / 2, plotHeight + 2, labelW, labelH);
166
+ ctx.fillStyle = this._colors.background;
167
+ ctx.font = '11px system-ui, sans-serif';
168
+ ctx.textAlign = 'center';
169
+ ctx.textBaseline = 'middle';
170
+ ctx.fillText(label, labelX, plotHeight + 2 + labelH / 2);
171
+ }
172
+
173
+ ctx.restore();
174
+ }
175
+
176
+ private _applyLineStyle(ctx: CanvasRenderingContext2D, style: string): void {
177
+ if (style === 'dashed') ctx.setLineDash([4, 4]);
178
+ else if (style === 'dotted') ctx.setLineDash([1, 3]);
179
+ else ctx.setLineDash([]);
180
+ }
181
+
182
+ /** Formats a price as a fixed-decimal string. */
183
+ private _formatPrice(price: number): string {
184
+ // Show more decimals for low-value instruments (e.g. forex, small-cap)
185
+ const decimals = price < 1 ? 6 : price < 1000 ? 4 : 2;
186
+ return price.toFixed(decimals);
187
+ }
188
+
189
+ /** Formats a Unix timestamp (seconds) as a human-readable date/time string. */
190
+ private _formatTime(time: number): string {
191
+ const timeZone: string = (!this.timezone || this.timezone === 'exchange') ? 'UTC' : this.timezone;
192
+ const d = new Date(time * 1000);
193
+ const weekday = d.toLocaleDateString('en-US', { weekday: 'short', timeZone });
194
+ const day = d.toLocaleDateString('en-US', { day: '2-digit', timeZone });
195
+ const month = d.toLocaleDateString('en-US', { month: 'short', timeZone });
196
+ const year = d.toLocaleDateString('en-US', { year: '2-digit', timeZone });
197
+ const datePart = `${weekday} ${day} ${month} '${year}`;
198
+
199
+ // Show time only for sub-day intervals
200
+ const dailyIntervals = new Set(['1d', '2d', '3d', '1w', '2w', '1M', '3M', '6M', '12M']);
201
+ if (dailyIntervals.has(this.interval)) return datePart;
202
+
203
+ const hh = d.toLocaleString('en-US', { hour: '2-digit', hour12: false, timeZone });
204
+ const mm = d.toLocaleString('en-US', { minute: '2-digit', timeZone }).padStart(2, '0');
205
+ return `${datePart} ${hh}:${mm}`;
206
+ }
207
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * IndicatorEngine — pure, stateless computation functions.
3
+ *
4
+ * All functions accept a readonly OHLCV array and operation-specific
5
+ * parameters, and return plain value arrays. No side effects, no
6
+ * class instances. Safe to call from any renderer or React component.
7
+ */
8
+
9
+ import type { OHLCV, PriceField } from '@forgecharts/types';
10
+
11
+ // ─── Output point types ───────────────────────────────────────────────────────
12
+
13
+ export type IndicatorPoint = {
14
+ readonly time: number;
15
+ readonly value: number;
16
+ };
17
+
18
+ export type MACDPoint = {
19
+ readonly time: number;
20
+ readonly macd: number;
21
+ readonly signal: number;
22
+ readonly histogram: number;
23
+ };
24
+
25
+ // ─── Internals ────────────────────────────────────────────────────────────────
26
+
27
+ /**
28
+ * Exponential moving average over an arbitrary number array.
29
+ * Uses the standard multiplier k = 2 / (period + 1).
30
+ * The very first value seeds the EMA (warm-start).
31
+ */
32
+ function _ema(values: readonly number[], period: number): number[] {
33
+ if (values.length === 0) return [];
34
+ const k = 2 / (period + 1);
35
+ const result: number[] = [values[0]!];
36
+ for (let i = 1; i < values.length; i++) {
37
+ result.push(values[i]! * k + result[i - 1]! * (1 - k));
38
+ }
39
+ return result;
40
+ }
41
+
42
+ // ─── Public API ───────────────────────────────────────────────────────────────
43
+
44
+ /** Simple Moving Average over bar close prices. */
45
+ export function computeSMA(bars: readonly OHLCV[], period: number): IndicatorPoint[] {
46
+ if (period < 1 || bars.length < period) return [];
47
+ const result: IndicatorPoint[] = [];
48
+ let windowSum = 0;
49
+ for (let i = 0; i < period; i++) windowSum += bars[i]!.close;
50
+ result.push({ time: bars[period - 1]!.time, value: windowSum / period });
51
+ for (let i = period; i < bars.length; i++) {
52
+ windowSum += bars[i]!.close - bars[i - period]!.close;
53
+ result.push({ time: bars[i]!.time, value: windowSum / period });
54
+ }
55
+ return result;
56
+ }
57
+
58
+ /** Exponential Moving Average over bar close prices. */
59
+ export function computeEMA(bars: readonly OHLCV[], period: number): IndicatorPoint[] {
60
+ if (period < 1 || bars.length < period) return [];
61
+ const closes = bars.map((b) => b.close);
62
+ return _ema(closes, period).map((value, i) => ({ time: bars[i]!.time, value }));
63
+ }
64
+
65
+ /**
66
+ * Relative Strength Index using Wilder's smoothing.
67
+ * Returns values in [0, 100].
68
+ * Requires at least `period + 1` bars; returns [] otherwise.
69
+ */
70
+ export function computeRSI(bars: readonly OHLCV[], period = 14): IndicatorPoint[] {
71
+ if (bars.length < period + 1) return [];
72
+
73
+ // Seed: simple average of first `period` up/down moves
74
+ let avgGain = 0;
75
+ let avgLoss = 0;
76
+ for (let i = 1; i <= period; i++) {
77
+ const d = bars[i]!.close - bars[i - 1]!.close;
78
+ if (d > 0) avgGain += d;
79
+ else avgLoss -= d;
80
+ }
81
+ avgGain /= period;
82
+ avgLoss /= period;
83
+
84
+ const result: IndicatorPoint[] = [];
85
+ result.push({
86
+ time: bars[period]!.time,
87
+ value: avgLoss === 0 ? 100 : 100 - 100 / (1 + avgGain / avgLoss),
88
+ });
89
+
90
+ // Wilder's smoothing for remaining bars
91
+ for (let i = period + 1; i < bars.length; i++) {
92
+ const d = bars[i]!.close - bars[i - 1]!.close;
93
+ const gain = d > 0 ? d : 0;
94
+ const loss = d < 0 ? -d : 0;
95
+ avgGain = (avgGain * (period - 1) + gain) / period;
96
+ avgLoss = (avgLoss * (period - 1) + loss) / period;
97
+ result.push({
98
+ time: bars[i]!.time,
99
+ value: avgLoss === 0 ? 100 : 100 - 100 / (1 + avgGain / avgLoss),
100
+ });
101
+ }
102
+ return result;
103
+ }
104
+
105
+ /**
106
+ * MACD — Moving Average Convergence/Divergence.
107
+ * Returns MACD line, Signal line, and Histogram.
108
+ * Standard defaults: fast=12, slow=26, signal=9.
109
+ */
110
+ export function computeMACD(
111
+ bars: readonly OHLCV[],
112
+ fastPeriod = 12,
113
+ slowPeriod = 26,
114
+ signalPeriod = 9,
115
+ ): MACDPoint[] {
116
+ if (bars.length < slowPeriod) return [];
117
+
118
+ const closes = bars.map((b) => b.close);
119
+ const fastEMAs = _ema(closes, fastPeriod);
120
+ const slowEMAs = _ema(closes, slowPeriod);
121
+
122
+ // MACD line starts at index slowPeriod - 1 (first bar where slow EMA is meaningful)
123
+ const macdLine: number[] = [];
124
+ const macdTimes: number[] = [];
125
+ for (let i = slowPeriod - 1; i < bars.length; i++) {
126
+ macdLine.push(fastEMAs[i]! - slowEMAs[i]!);
127
+ macdTimes.push(bars[i]!.time);
128
+ }
129
+
130
+ if (macdLine.length < signalPeriod) return [];
131
+
132
+ const signalLine = _ema(macdLine, signalPeriod);
133
+ return signalLine.map((sig, i) => ({
134
+ time: macdTimes[i]!,
135
+ macd: macdLine[i]!,
136
+ signal: sig,
137
+ histogram: macdLine[i]! - sig,
138
+ }));
139
+ }
140
+
141
+ /** Volume bars — raw volume values keyed by bar time. */
142
+ export function computeVolume(bars: readonly OHLCV[]): IndicatorPoint[] {
143
+ return bars.map((b) => ({ time: b.time, value: b.volume }));
144
+ }
145
+
146
+ // ─── Field extraction & series-based variants (for DAG chaining) ─────────────
147
+
148
+ /** Extracts a single numeric value from an OHLCV bar by named price field. */
149
+ export function extractField(bar: OHLCV, field: PriceField): number {
150
+ switch (field) {
151
+ case 'open': return bar.open;
152
+ case 'high': return bar.high;
153
+ case 'low': return bar.low;
154
+ case 'close': return bar.close;
155
+ case 'volume': return bar.volume;
156
+ case 'hl2': return (bar.high + bar.low) / 2;
157
+ case 'hlc3': return (bar.high + bar.low + bar.close) / 3;
158
+ case 'ohlc4': return (bar.open + bar.high + bar.low + bar.close) / 4;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * SMA over an arbitrary `IndicatorPoint[]` series (not just OHLCV close).
164
+ * Enables chains such as EMA(SMA(close, 20), 10).
165
+ */
166
+ export function computeSMAFromSeries(
167
+ input: readonly IndicatorPoint[],
168
+ period: number,
169
+ ): IndicatorPoint[] {
170
+ if (period < 1 || input.length < period) return [];
171
+ const result: IndicatorPoint[] = [];
172
+ let sum = 0;
173
+ for (let i = 0; i < period; i++) sum += input[i]!.value;
174
+ result.push({ time: input[period - 1]!.time, value: sum / period });
175
+ for (let i = period; i < input.length; i++) {
176
+ sum += input[i]!.value - input[i - period]!.value;
177
+ result.push({ time: input[i]!.time, value: sum / period });
178
+ }
179
+ return result;
180
+ }
181
+
182
+ /**
183
+ * EMA over an arbitrary `IndicatorPoint[]` series.
184
+ * Seeds the EMA from the first input value.
185
+ */
186
+ export function computeEMAFromSeries(
187
+ input: readonly IndicatorPoint[],
188
+ period: number,
189
+ ): IndicatorPoint[] {
190
+ if (period < 1 || input.length === 0) return [];
191
+ const k = 2 / (period + 1);
192
+ const result: IndicatorPoint[] = [{ time: input[0]!.time, value: input[0]!.value }];
193
+ for (let i = 1; i < input.length; i++) {
194
+ result.push({
195
+ time: input[i]!.time,
196
+ value: input[i]!.value * k + result[i - 1]!.value * (1 - k),
197
+ });
198
+ }
199
+ return result;
200
+ }
201
+
202
+ /** WMA (Weighted Moving Average) over an arbitrary `IndicatorPoint[]` series. */
203
+ export function computeWMAFromSeries(
204
+ input: readonly IndicatorPoint[],
205
+ period: number,
206
+ ): IndicatorPoint[] {
207
+ if (period < 1 || input.length < period) return [];
208
+ const denom = (period * (period + 1)) / 2;
209
+ const result: IndicatorPoint[] = [];
210
+ for (let i = period - 1; i < input.length; i++) {
211
+ let sum = 0;
212
+ for (let j = 0; j < period; j++) sum += input[i - j]!.value * (period - j);
213
+ result.push({ time: input[i]!.time, value: sum / denom });
214
+ }
215
+ return result;
216
+ }