react-native-vroom-chart 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/cpp/VroomChartHostObject.cpp +171 -1
  2. package/cpp/_core_include/vroom/vroom_chart.h +137 -4
  3. package/cpp/_core_src/atr.cpp +67 -0
  4. package/cpp/_core_src/atr.h +44 -0
  5. package/cpp/_core_src/atr_pane.cpp +162 -0
  6. package/cpp/_core_src/atr_pane.h +48 -0
  7. package/cpp/_core_src/chart.cpp +461 -172
  8. package/cpp/_core_src/chart.h +150 -1
  9. package/cpp/_core_src/chart_facade.cpp +209 -23
  10. package/cpp/_core_src/fair_value_gaps.cpp +76 -0
  11. package/cpp/_core_src/fair_value_gaps.h +54 -0
  12. package/cpp/_core_src/fvg_overlay.cpp +262 -0
  13. package/cpp/_core_src/fvg_overlay.h +43 -0
  14. package/cpp/_core_src/ichimoku.cpp +65 -0
  15. package/cpp/_core_src/ichimoku.h +45 -0
  16. package/cpp/_core_src/labels.cpp +3 -0
  17. package/cpp/_core_src/line_morph.h +159 -0
  18. package/cpp/_core_src/ma_overlay.cpp +259 -36
  19. package/cpp/_core_src/ma_overlay.h +57 -2
  20. package/cpp/_core_src/macd.cpp +24 -1
  21. package/cpp/_core_src/macd.h +16 -0
  22. package/cpp/_core_src/macd_pane.cpp +71 -62
  23. package/cpp/_core_src/macd_pane.h +13 -1
  24. package/cpp/_core_src/pane_series.h +169 -0
  25. package/cpp/_core_src/rsi.cpp +4 -0
  26. package/cpp/_core_src/rsi.h +7 -0
  27. package/cpp/_core_src/rsi_pane.cpp +85 -29
  28. package/cpp/_core_src/rsi_pane.h +11 -1
  29. package/cpp/_core_src/viewport.h +35 -0
  30. package/lib/index.d.mts +251 -3
  31. package/lib/index.d.ts +251 -3
  32. package/lib/index.js +224 -6
  33. package/lib/index.js.map +1 -1
  34. package/lib/index.mjs +224 -6
  35. package/lib/index.mjs.map +1 -1
  36. package/package.json +1 -1
  37. package/src/VroomChart.tsx +21 -3
  38. package/src/dataTransitions.ts +47 -0
  39. package/src/index.ts +5 -0
  40. package/src/jsi.d.ts +97 -1
  41. package/src/types.ts +5 -0
  42. package/src/useChartCore.ts +284 -5
@@ -9,6 +9,7 @@
9
9
  #pragma once
10
10
 
11
11
  #include <chrono>
12
+ #include <cstddef>
12
13
  #include <string>
13
14
  #include <vector>
14
15
 
@@ -22,14 +23,33 @@
22
23
  #include "include/core/SkRefCnt.h"
23
24
  #pragma clang diagnostic pop
24
25
 
26
+ #include "fair_value_gaps.h"
25
27
  #include "footprints_layout.h"
26
28
  #include "labels.h"
29
+ #include "line_morph.h"
27
30
  #include "price_format.h"
28
31
  #include "theme.h"
29
32
  #include "viewport.h"
30
33
 
31
34
  class SkCanvas;
32
35
 
36
+ namespace vroom {
37
+
38
+ // Which indicator owns a below-chart pane. Panes are otherwise
39
+ // interchangeable: they are all the same height and stack in enable order.
40
+ enum class PaneKind { Rsi, Macd, Atr };
41
+
42
+ // Count of PaneKind values — the most panes that can be stacked at once.
43
+ constexpr int kMaxPanes = 3;
44
+
45
+ // One enabled pane and the sequence number it claimed when it was turned on.
46
+ struct IndicatorPane {
47
+ int order;
48
+ PaneKind kind;
49
+ };
50
+
51
+ } // namespace vroom
52
+
33
53
  struct VroomChart {
34
54
  // --- bridge / lifecycle -------------------------------------------------
35
55
  VroomCallbacks cb{};
@@ -82,6 +102,19 @@ struct VroomChart {
82
102
  std::vector<vroom::CandleSnapshot> morph_from;
83
103
  float interval_morph_t = 1.f; // 1 = not morphing
84
104
  bool interval_morph_fade = false;
105
+ // Set when the capture came from a live update rather than a timeframe
106
+ // switch. Same slot machinery, but the axis holds still: a stream morph
107
+ // only reshapes the last bar, and the ticks it sits between don't move, so
108
+ // running them through the interval envelope would flash the whole axis on
109
+ // every tick (see labels::interval_phase).
110
+ bool morph_is_stream = false;
111
+ // The outgoing shape of every indicator series that was on screen, captured
112
+ // alongside the candles and keyed by identity so a toggle landing in the
113
+ // same commit as the switch can't pair two different lines (see
114
+ // line_morph.h). A timeframe switch invalidates every indicator cache at
115
+ // once, so without this the lines would pop to their new values while the
116
+ // candles under them reshape.
117
+ std::vector<vroom::LineMorph> morph_lines;
85
118
  // The pre-switch price scale and time window. The candle capture above is
86
119
  // normalized against these, and the axes keep rendering their old ticks from
87
120
  // them while fading out (see labels::interval_phase), so a label is never
@@ -123,7 +156,8 @@ struct VroomChart {
123
156
  VroomRSI rsi{0, 14, 70.0, 30.0, 14, 0, 1,
124
157
  0u, -1.f, 1, // RSI line
125
158
  0u, -1.f, // trendline
126
- 0u, 1}; // band rules
159
+ 0u, 1, // band rules
160
+ 1}; // extreme shading
127
161
  std::vector<double> rsi_cache; // RSI per candle (NaN where undefined)
128
162
  std::vector<double> rsi_ma_cache; // MA of rsi_cache (empty if MA off)
129
163
  bool rsi_dirty = true;
@@ -147,11 +181,23 @@ struct VroomChart {
147
181
  // default auto-fit amplitude; >1 zooms in, <1 zooms out. Anchored at zero.
148
182
  double macd_y_scale = 1.0;
149
183
 
184
+ // ATR, drawn in its own pane. atr_cache is aligned to `candles` (NaN
185
+ // warmup), recomputed lazily by ensure_atr() when atr_dirty.
186
+ // Defaults: 14-period true range under Wilder smoothing, style fields on
187
+ // their inherit sentinel.
188
+ VroomATR atr{0, 14, 0, 0u, -1.f};
189
+ std::vector<double> atr_cache;
190
+ bool atr_dirty = true;
191
+ // User y-axis zoom for the ATR pane (drag on its y-axis strip). 1.0 = the
192
+ // default 0..peak fit; >1 zooms in, <1 zooms out. Anchored at the baseline.
193
+ double atr_y_scale = 1.0;
194
+
150
195
  // Stacking order for the indicator panes: each indicator gets the next
151
196
  // sequence number on its off->on transition, so the most recently enabled
152
197
  // pane sorts last (bottom). -1 = not currently enabled.
153
198
  int rsi_order = -1;
154
199
  int macd_order = -1;
200
+ int atr_order = -1;
155
201
  int pane_seq = 0;
156
202
 
157
203
  // Moving-average overlay lines (SMA/EMA) drawn on the price pane. Not panes
@@ -183,6 +229,46 @@ struct VroomChart {
183
229
  std::vector<double> bb_lower_cache;
184
230
  bool bollinger_dirty = true;
185
231
 
232
+ // Ichimoku overlay (price pane; no pane reserved). Caches aligned to
233
+ // `candles` (NaN warmup) and indexed by the bar each value was computed
234
+ // from — displacement is applied when drawing, so the caches survive a
235
+ // change to it. Recomputed lazily by ensure_ichimoku() when ichimoku_dirty.
236
+ // Defaults: the standard 9/26/52/26, blue tenkan / red kijun, green span A
237
+ // over orange span B, teal chikou, 15% cloud.
238
+ VroomIchimoku ichimoku{0, 9, 26, 52, 26,
239
+ 0xff2962ff, 1.f, 1, // tenkan: blue
240
+ 0xffef5350, 1.f, 1, // kijun: red
241
+ 0xff26a69a, 1.f, 1, // senkou A: green
242
+ 0xffff6d00, 1.f, 1, // senkou B: orange
243
+ 0xff00bcd4, 1.f, 1, // chikou: teal
244
+ 1, 0xff26a69a, 0xffef5350, 0.15f};
245
+ std::vector<double> ich_tenkan_cache;
246
+ std::vector<double> ich_kijun_cache;
247
+ std::vector<double> ich_senkou_a_cache;
248
+ std::vector<double> ich_senkou_b_cache;
249
+ std::vector<double> ich_chikou_cache;
250
+ bool ichimoku_dirty = true;
251
+
252
+ // Fair Value Gap overlay (price pane; no pane reserved). The cache holds the
253
+ // detected imbalances in absolute time/price, oldest first, so box geometry
254
+ // and style are free to change without re-scanning — only the detection
255
+ // inputs below dirty it. Recomputed lazily by ensure_fvg() when fvg_dirty.
256
+ //
257
+ // Defaults: 300 bars back, close-settled fills, deleted once filled, 20-slot
258
+ // boxes in green/red at 15%, solid 1px border, labels on.
259
+ VroomFairValueGaps fvg{0, 300, 0, 0, 1, 0, 20,
260
+ 0xff26a69a, 0xffef5350, 0.15f,
261
+ 1, 0, 1.f, 0xff26a69a, 0xffef5350,
262
+ 1, nullptr, 10, 0u, 0.f,
263
+ 0, 0xff26a69a, 0xffef5350, nullptr};
264
+ // Owns the label texts, so the caller may free theirs as soon as the setter
265
+ // returns. `fvg.label` and `fvg.inverse_label` are left null and these are
266
+ // what the renderer reads.
267
+ std::string fvg_label = "FVG";
268
+ std::string fvg_inverse_label = "iFVG";
269
+ std::vector<vroom::fvg::Gap> fvg_cache;
270
+ bool fvg_dirty = true;
271
+
186
272
  // Volume bars (price pane, under the candles). On by default with every
187
273
  // style field left on its inherit sentinel, so an untouched chart looks
188
274
  // exactly as it did before the config existed. No cache — bar heights come
@@ -348,6 +434,69 @@ struct VroomChart {
348
434
  // indicator is enabled.
349
435
  void ensure_bollinger();
350
436
 
437
+ // Recomputes the Ichimoku caches when ichimoku_dirty and the indicator is
438
+ // enabled.
439
+ void ensure_ichimoku();
440
+
441
+ // Redetects the Fair Value Gaps when fvg_dirty and the indicator is enabled.
442
+ void ensure_fvg();
443
+
444
+ // Recomputes the ATR cache when atr_dirty and the indicator is enabled.
445
+ void ensure_atr();
446
+
447
+ // Fills morph_lines with the outgoing shape of every enabled indicator,
448
+ // normalized the way morph_from is. Called by begin_interval_morph while
449
+ // the pre-switch candles and caches are still resident — after the swap
450
+ // there is nothing left to capture. `range` is the visible slice and
451
+ // `bounds` the price band the capture is taken against, both shared with
452
+ // the candle capture so the two land on the same pixels.
453
+ void capture_morph_lines(const vroom::Layout& lay,
454
+ const vroom::PriceBounds& bounds,
455
+ vroom::IndexRange range, int64_t window_ms);
456
+
457
+ // Source indices whose *displaced* plot time lands in the visible window,
458
+ // for an Ichimoku span drawn at `time_ms + shift_ms`. A bar of padding
459
+ // either side keeps a run reaching the pane edge instead of stopping at the
460
+ // last bar strictly inside it. Shared by the cloud, the lines and the
461
+ // interval-morph capture so all three slice the same bars.
462
+ vroom::IndexRange ichimoku_source_range(int64_t shift_ms) const;
463
+
464
+ // The enabled panes written to `out` top to bottom, returning how many.
465
+ // Both the draw pass and the y-axis hit test read this, so the stack they
466
+ // see can't drift apart.
467
+ int indicator_panes(vroom::IndicatorPane out[vroom::kMaxPanes]) const;
468
+
469
+ // Draws the pane stack into the band below the candles, starting at
470
+ // `pane_top`. Shared by the settled draw and the interval-morph fade, which
471
+ // paint the panes at different points in the z-order.
472
+ void draw_indicator_panes(SkCanvas* canvas, const vroom::Layout& lay,
473
+ const VroomCandle* visible, std::size_t n,
474
+ std::size_t first, int64_t window_ms,
475
+ float candle_right, float pane_top,
476
+ bool use_capture, float morph_t);
477
+
478
+ // The price-pane indicator overlays, split where the candles sit in the
479
+ // z-order: the Bollinger band and Ichimoku cloud go behind them, the moving
480
+ // averages, VWAP, Bollinger and Ichimoku lines over them.
481
+ //
482
+ // `use_capture` and `morph_t` carry the interval morph, on the same terms
483
+ // the candles take it. A transform pairs each series with its capture (see
484
+ // morph_lines) and eases toward the new shape. A fade's outgoing half
485
+ // passes n = 0 with morph_t = 0, replaying the capture alone. A settled
486
+ // frame passes no capture at all.
487
+ void draw_overlay_fills(SkCanvas* canvas, const vroom::Layout& lay,
488
+ const vroom::PriceBounds& bounds,
489
+ const VroomCandle* visible, std::size_t n,
490
+ std::size_t first, int64_t window_ms,
491
+ float candle_right, float candle_area_h,
492
+ bool use_capture, float morph_t);
493
+ void draw_overlay_lines(SkCanvas* canvas, const vroom::Layout& lay,
494
+ const vroom::PriceBounds& bounds,
495
+ const VroomCandle* visible, std::size_t n,
496
+ std::size_t first, int64_t window_ms,
497
+ float candle_right, float candle_area_h,
498
+ bool use_capture, float morph_t);
499
+
351
500
  // The main drawing pass. Calls into the labels and candles modules, and owns
352
501
  // the per-frame animation clock (see begin_frame) so every host gets it —
353
502
  // the web build draws straight through vroom_chart_draw rather than the
@@ -83,6 +83,32 @@ int64_t damp_future_delta(int64_t delta_ms, int64_t cur_future,
83
83
  return static_cast<int64_t>(static_cast<double>(delta_ms) * resist);
84
84
  }
85
85
 
86
+ // Empty time the Ichimoku leading spans need past the newest candle. Zero
87
+ // unless the indicator is on — the reserve is Ichimoku's alone, since it is the
88
+ // only thing that draws where there are no candles.
89
+ int64_t ichimoku_future_ms(const VroomChart* chart) {
90
+ if (!chart->ichimoku.enabled || chart->ichimoku.displacement <= 0) return 0;
91
+ return static_cast<int64_t>(chart->ichimoku.displacement) *
92
+ chart->candle_duration_ms;
93
+ }
94
+
95
+ // Pull the view forward far enough to show the leading spans. Only ever grows
96
+ // the future gap, and only as far as a pan gesture could already take it, so a
97
+ // user who has scrolled ahead keeps their position. A no-op before the view is
98
+ // framed — set_candles reserves the space itself in that case.
99
+ void reserve_ichimoku_future(VroomChart* chart) {
100
+ if (chart->candles.empty()) return;
101
+ const int64_t want = ichimoku_future_ms(chart);
102
+ if (want <= 0) return;
103
+ const int64_t window = chart->visible_end_ms - chart->visible_start_ms;
104
+ if (window <= 0) return;
105
+ const int64_t last_time = chart->candles.back().time_ms;
106
+ const int64_t target = std::min(want, max_future_gap(window, 0));
107
+ if (chart->visible_end_ms - last_time >= target) return;
108
+ chart->visible_end_ms = last_time + target;
109
+ chart->visible_start_ms = chart->visible_end_ms - window;
110
+ }
111
+
86
112
  // Frame the default view. When the consumer has set a target candle body width
87
113
  // (default_candle_px), size the window so each candle renders ~that wide with the
88
114
  // right edge pinned to the latest candle; otherwise fall back to the legacy
@@ -102,16 +128,17 @@ void apply_default_framing(VroomChart* chart) {
102
128
  lay.width_px - lay.y_axis_width_px - lay.right_padding_px;
103
129
 
104
130
  // Places a window of the given width with kRightGapPx of air past the newest
105
- // candle. Shifts the window forward rather than widening it, so the framing
106
- // keeps whatever candle width it just computed. Falls back to a flush right
107
- // edge when the layout isn't measured yet and px can't be converted to time.
131
+ // candle, plus whatever empty time Ichimoku's leading spans need. Shifts the
132
+ // window forward rather than widening it, so the framing keeps whatever
133
+ // candle width it just computed. Falls back to a flush right edge when the
134
+ // layout isn't measured yet and px can't be converted to time.
108
135
  const auto frame = [&](int64_t window_ms) {
109
136
  const int64_t gap_ms =
110
137
  usable > 0.0 && window_ms > 0
111
138
  ? static_cast<int64_t>((kRightGapPx * static_cast<double>(window_ms)) /
112
139
  usable)
113
140
  : 0;
114
- chart->visible_end_ms = right_edge_ms + gap_ms;
141
+ chart->visible_end_ms = right_edge_ms + gap_ms + ichimoku_future_ms(chart);
115
142
  chart->visible_start_ms = chart->visible_end_ms - window_ms;
116
143
  };
117
144
 
@@ -184,9 +211,12 @@ extern "C" void vroom_chart_set_candles(VroomChart* chart, const VroomCandle* da
184
211
  chart->candles.assign(data, data + count);
185
212
  chart->rsi_dirty = true;
186
213
  chart->macd_dirty = true;
214
+ chart->atr_dirty = true;
187
215
  chart->overlays_dirty = true;
188
216
  chart->vwap_dirty = true;
189
217
  chart->bollinger_dirty = true;
218
+ chart->ichimoku_dirty = true;
219
+ chart->fvg_dirty = true;
190
220
  // New bars (or a whole new interval) mean the footprint grouping no longer
191
221
  // matches the data — regroup on next use.
192
222
  chart->footprint_buckets_dirty = true;
@@ -215,9 +245,12 @@ extern "C" void vroom_chart_append_candle(VroomChart* chart, const VroomCandle*
215
245
  chart->candles.push_back(*c);
216
246
  chart->rsi_dirty = true;
217
247
  chart->macd_dirty = true;
248
+ chart->atr_dirty = true;
218
249
  chart->overlays_dirty = true;
219
250
  chart->vwap_dirty = true;
220
251
  chart->bollinger_dirty = true;
252
+ chart->ichimoku_dirty = true;
253
+ chart->fvg_dirty = true;
221
254
  chart->mark_dirty();
222
255
  }
223
256
 
@@ -226,9 +259,12 @@ extern "C" void vroom_chart_update_last(VroomChart* chart, const VroomCandle* c)
226
259
  chart->candles.back() = *c;
227
260
  chart->rsi_dirty = true;
228
261
  chart->macd_dirty = true;
262
+ chart->atr_dirty = true;
229
263
  chart->overlays_dirty = true;
230
264
  chart->vwap_dirty = true;
231
265
  chart->bollinger_dirty = true;
266
+ chart->ichimoku_dirty = true;
267
+ chart->fvg_dirty = true;
232
268
  chart->mark_dirty();
233
269
  }
234
270
 
@@ -302,6 +338,7 @@ extern "C" void vroom_chart_reset_view(VroomChart* chart) {
302
338
  // An asset switch reframes wholesale, so a slot-paired morph is meaningless
303
339
  // here — drop any capture rather than leaving it to reshape the wrong data.
304
340
  chart->morph_from.clear();
341
+ chart->morph_lines.clear();
305
342
  chart->interval_morph_t = 1.f;
306
343
  chart->interval_morph_fade = false;
307
344
  apply_default_framing(chart);
@@ -357,11 +394,17 @@ extern "C" void vroom_chart_preserve_price_envelope(VroomChart* chart,
357
394
  chart->mark_dirty();
358
395
  }
359
396
 
360
- extern "C" void vroom_chart_begin_interval_morph(VroomChart* chart, int32_t mode) {
361
- if (!chart) return;
397
+ // Captures the visible candles and every indicator on screen into the chart's
398
+ // morph slots, normalized so the result survives the bounds change and any
399
+ // resize that lands mid-morph. Leaves `morph_from` empty (and the morph closed)
400
+ // when there is nothing to capture. Shared by the timeframe switch and the live
401
+ // stream update, which differ only in what they do with the capture afterwards.
402
+ static void capture_morph(VroomChart* chart) {
362
403
  chart->morph_from.clear();
404
+ chart->morph_lines.clear();
363
405
  chart->interval_morph_t = 1.f;
364
406
  chart->interval_morph_fade = false;
407
+ chart->morph_is_stream = false;
365
408
 
366
409
  const auto lay = chart->layout();
367
410
  const float area_w = vroom::candle_area_width(lay);
@@ -397,11 +440,21 @@ extern "C" void vroom_chart_begin_interval_morph(VroomChart* chart, int32_t mode
397
440
  };
398
441
  }
399
442
 
443
+ // Every indicator on screen, captured against the same slice and band so
444
+ // its line keeps its position relative to the candles it reshapes with.
445
+ chart->capture_morph_lines(lay, bounds, idx, window_ms);
446
+
400
447
  // The scale the capture was taken against — the axes render their outgoing
401
448
  // ticks from it, and it's about to be replaced on the chart itself.
402
449
  chart->morph_from_bounds = bounds;
403
450
  chart->morph_from_start_ms = chart->visible_start_ms;
404
451
  chart->morph_from_end_ms = chart->visible_end_ms;
452
+ }
453
+
454
+ extern "C" void vroom_chart_begin_interval_morph(VroomChart* chart, int32_t mode) {
455
+ if (!chart) return;
456
+ capture_morph(chart);
457
+ if (chart->morph_from.empty()) return;
405
458
 
406
459
  // Open the morph at 0 rather than leaving it at 1: the caller still has to
407
460
  // push the new candles, and any frame painted in between should show the
@@ -410,13 +463,50 @@ extern "C" void vroom_chart_begin_interval_morph(VroomChart* chart, int32_t mode
410
463
  chart->interval_morph_t = 0.f;
411
464
  }
412
465
 
466
+ extern "C" void vroom_chart_begin_stream_morph(VroomChart* chart) {
467
+ if (!chart) return;
468
+
469
+ // Ticks arrive faster than the morph lands, so nearly every one interrupts
470
+ // the last. Keep what was on screen and fold it into the fresh capture
471
+ // below; dropping it would snap the bar back to its un-morphed shape on
472
+ // every tick. Only a stream capture is worth continuing from — a timeframe
473
+ // switch in flight is a different, larger motion, and a tick landing in the
474
+ // middle of one should let it finish rather than inherit its geometry.
475
+ std::vector<vroom::CandleSnapshot> interrupted;
476
+ std::vector<vroom::LineMorph> interrupted_lines;
477
+ float interrupted_t = 1.f;
478
+ if (chart->morph_is_stream && chart->interval_morph_t < 1.f) {
479
+ interrupted.swap(chart->morph_from);
480
+ interrupted_lines.swap(chart->morph_lines);
481
+ interrupted_t = chart->interval_morph_t;
482
+ }
483
+
484
+ capture_morph(chart);
485
+ if (chart->morph_from.empty()) return;
486
+
487
+ if (!interrupted.empty()) {
488
+ vroom::blend_candle_snapshots(chart->morph_from.data(),
489
+ chart->morph_from.size(),
490
+ interrupted.data(), interrupted.size(),
491
+ interrupted_t);
492
+ vroom::blend_line_morphs(chart->morph_lines, interrupted_lines,
493
+ interrupted_t);
494
+ }
495
+
496
+ chart->morph_is_stream = true;
497
+ chart->interval_morph_t = 0.f;
498
+ }
499
+
413
500
  extern "C" void vroom_chart_set_interval_morph(VroomChart* chart, float t) {
414
501
  if (!chart) return;
415
502
  chart->interval_morph_t = std::clamp(t, 0.f, 1.f);
416
503
  if (chart->interval_morph_t >= 1.f) {
417
504
  chart->morph_from.clear();
418
505
  chart->morph_from.shrink_to_fit();
506
+ chart->morph_lines.clear();
507
+ chart->morph_lines.shrink_to_fit();
419
508
  chart->interval_morph_fade = false;
509
+ chart->morph_is_stream = false;
420
510
  }
421
511
  chart->mark_dirty();
422
512
  }
@@ -631,7 +721,9 @@ extern "C" void vroom_chart_scale_time_axis(VroomChart* chart, float dx_px) {
631
721
  extern "C" void vroom_chart_resize_indicator_pane(VroomChart* chart, float dy_px) {
632
722
  if (!chart || dy_px == 0.f) return;
633
723
 
634
- const int pane_count = (chart->rsi.enabled ? 1 : 0) + (chart->macd.enabled ? 1 : 0);
724
+ const int pane_count = (chart->rsi.enabled ? 1 : 0) +
725
+ (chart->macd.enabled ? 1 : 0) +
726
+ (chart->atr.enabled ? 1 : 0);
635
727
  if (pane_count == 0) return; // nothing below the chart to resize
636
728
 
637
729
  const auto lay = chart->layout();
@@ -680,42 +772,41 @@ extern "C" void vroom_chart_scale_indicator_axis(VroomChart* chart, float y_px,
680
772
  const auto lay = chart->layout();
681
773
  if (lay.indicator_area_h <= 0.f) return;
682
774
 
683
- // Rebuild the same ordered pane stack draw_chart uses (most recently
684
- // enabled pane sorts to the bottom) to find which pane y_px falls in.
685
- struct ActivePane { int order; int type; }; // type: 0 = RSI, 1 = MACD
686
- ActivePane panes[2];
687
- int count = 0;
688
- if (chart->rsi.enabled) panes[count++] = {chart->rsi_order, 0};
689
- if (chart->macd.enabled) panes[count++] = {chart->macd_order, 1};
775
+ // The same ordered pane stack the draw pass uses, so the pane under y_px is
776
+ // the one the user sees there.
777
+ vroom::IndicatorPane panes[vroom::kMaxPanes];
778
+ const int count = chart->indicator_panes(panes);
690
779
  if (count == 0) return;
691
- if (count == 2 && panes[0].order > panes[1].order) {
692
- const ActivePane tmp = panes[0];
693
- panes[0] = panes[1];
694
- panes[1] = tmp;
695
- }
696
780
 
697
781
  const float pane_h =
698
782
  chart->height_px * chart->theme.floats[VROOM_FLOAT_INDICATOR_HEIGHT_FRAC];
699
783
  if (pane_h <= 0.f) return;
700
784
 
701
- int target = -1; // 0 = RSI, 1 = MACD
785
+ const vroom::IndicatorPane* target = nullptr;
702
786
  float pane_top = vroom::price_pane_bottom(lay);
703
787
  for (int i = 0; i < count; ++i) {
704
788
  const float pane_bottom = pane_top + pane_h;
705
789
  if (y_px >= pane_top && y_px < pane_bottom) {
706
- target = panes[i].type;
790
+ target = &panes[i];
707
791
  break;
708
792
  }
709
793
  pane_top = pane_bottom;
710
794
  }
711
- if (target < 0) return; // not over a pane
795
+ if (!target) return; // not over a pane
712
796
 
713
797
  // Drag down (dy > 0) widens the visible value range (zoom out), matching
714
798
  // scale_price_axis's sign. The zoom is the inverse of the range scale.
715
799
  double range_scale = 1.0 + static_cast<double>(dy_px) / kAxisDragSensitivity;
716
800
  if (range_scale < 0.05) range_scale = 0.05; // never collapse or flip
717
801
 
718
- double& zoom = target == 0 ? chart->rsi_y_scale : chart->macd_y_scale;
802
+ double* zoom_ptr = nullptr;
803
+ switch (target->kind) {
804
+ case vroom::PaneKind::Rsi: zoom_ptr = &chart->rsi_y_scale; break;
805
+ case vroom::PaneKind::Macd: zoom_ptr = &chart->macd_y_scale; break;
806
+ case vroom::PaneKind::Atr: zoom_ptr = &chart->atr_y_scale; break;
807
+ }
808
+ if (!zoom_ptr) return;
809
+ double& zoom = *zoom_ptr;
719
810
  double next = zoom / range_scale;
720
811
  next = std::clamp(next, 0.1, 10.0);
721
812
  if (next == zoom) return;
@@ -1382,6 +1473,7 @@ extern "C" void vroom_chart_set_rsi(VroomChart* chart, const VroomRSI* cfg) {
1382
1473
  next.ma_visible = next.ma_visible ? 1 : 0;
1383
1474
  next.line_visible = next.line_visible ? 1 : 0;
1384
1475
  next.bands_visible = next.bands_visible ? 1 : 0;
1476
+ next.extreme_fill = next.extreme_fill ? 1 : 0;
1385
1477
  if (next.period < 2) next.period = 2;
1386
1478
  if (next.ma_period < 1) next.ma_period = 1;
1387
1479
  next.upper_band = std::clamp(next.upper_band, 0.0, 100.0);
@@ -1438,6 +1530,27 @@ extern "C" void vroom_chart_set_macd(VroomChart* chart, const VroomMACD* cfg) {
1438
1530
  chart->mark_dirty();
1439
1531
  }
1440
1532
 
1533
+ extern "C" void vroom_chart_set_atr(VroomChart* chart, const VroomATR* cfg) {
1534
+ if (!chart || !cfg) return;
1535
+ VroomATR next = *cfg;
1536
+ next.enabled = next.enabled ? 1 : 0;
1537
+ if (next.period < 1) next.period = 1;
1538
+
1539
+ // Only the series-affecting fields force a recompute; color and width
1540
+ // changes are render-only.
1541
+ const VroomATR& cur = chart->atr;
1542
+ const bool recompute = cur.enabled != next.enabled ||
1543
+ cur.period != next.period ||
1544
+ cur.smoothing != next.smoothing;
1545
+
1546
+ if (next.enabled && !cur.enabled) chart->atr_order = chart->pane_seq++;
1547
+ else if (!next.enabled) chart->atr_order = -1;
1548
+
1549
+ chart->atr = next;
1550
+ if (recompute) chart->atr_dirty = true;
1551
+ chart->mark_dirty();
1552
+ }
1553
+
1441
1554
  extern "C" void vroom_chart_set_overlays(VroomChart* chart,
1442
1555
  const VroomOverlay* overlays,
1443
1556
  size_t count) {
@@ -1489,6 +1602,79 @@ extern "C" void vroom_chart_set_bollinger(VroomChart* chart,
1489
1602
  chart->mark_dirty();
1490
1603
  }
1491
1604
 
1605
+ extern "C" void vroom_chart_set_ichimoku(VroomChart* chart,
1606
+ const VroomIchimoku* cfg) {
1607
+ if (!chart || !cfg) return;
1608
+ VroomIchimoku next = *cfg;
1609
+ next.enabled = next.enabled ? 1 : 0;
1610
+ next.tenkan_enabled = next.tenkan_enabled ? 1 : 0;
1611
+ next.kijun_enabled = next.kijun_enabled ? 1 : 0;
1612
+ next.senkou_a_enabled = next.senkou_a_enabled ? 1 : 0;
1613
+ next.senkou_b_enabled = next.senkou_b_enabled ? 1 : 0;
1614
+ next.chikou_enabled = next.chikou_enabled ? 1 : 0;
1615
+ next.cloud_enabled = next.cloud_enabled ? 1 : 0;
1616
+ if (next.tenkan_period < 1) next.tenkan_period = 1;
1617
+ if (next.kijun_period < 1) next.kijun_period = 1;
1618
+ if (next.senkou_b_period < 1) next.senkou_b_period = 1;
1619
+ if (next.displacement < 0) next.displacement = 0;
1620
+ next.cloud_opacity = std::clamp(next.cloud_opacity, 0.f, 1.f);
1621
+
1622
+ // Only the series-affecting fields force a recompute. Displacement is not
1623
+ // one of them — it shifts where the caches are drawn, not what's in them.
1624
+ const VroomIchimoku& cur = chart->ichimoku;
1625
+ const bool recompute = cur.enabled != next.enabled ||
1626
+ cur.tenkan_period != next.tenkan_period ||
1627
+ cur.kijun_period != next.kijun_period ||
1628
+ cur.senkou_b_period != next.senkou_b_period;
1629
+ const bool turned_on = !cur.enabled && next.enabled;
1630
+ chart->ichimoku = next;
1631
+ if (recompute) chart->ichimoku_dirty = true;
1632
+ // Turning it on mid-session misses the reserve the default framing makes,
1633
+ // and the leading spans would sit off the right edge until the user panned.
1634
+ if (turned_on) reserve_ichimoku_future(chart);
1635
+ chart->mark_dirty();
1636
+ }
1637
+
1638
+ extern "C" void vroom_chart_set_fair_value_gaps(VroomChart* chart,
1639
+ const VroomFairValueGaps* cfg) {
1640
+ if (!chart || !cfg) return;
1641
+ VroomFairValueGaps next = *cfg;
1642
+ next.enabled = next.enabled ? 1 : 0;
1643
+ next.wait_for_close = next.wait_for_close ? 1 : 0;
1644
+ next.delete_after_fill = next.delete_after_fill ? 1 : 0;
1645
+ next.extend_boxes = next.extend_boxes ? 1 : 0;
1646
+ next.border_enabled = next.border_enabled ? 1 : 0;
1647
+ next.labels_enabled = next.labels_enabled ? 1 : 0;
1648
+ next.show_inverse = next.show_inverse ? 1 : 0;
1649
+ next.fill_type = next.fill_type == vroom::fvg::kFillWick
1650
+ ? vroom::fvg::kFillWick
1651
+ : vroom::fvg::kFillClose;
1652
+ next.border_style = std::clamp(next.border_style, 0, 2);
1653
+ if (next.max_bars_back < 0) next.max_bars_back = 0;
1654
+ if (next.box_length < 1) next.box_length = 1;
1655
+ if (next.label_distance < 0) next.label_distance = 0;
1656
+ next.opacity = std::clamp(next.opacity, 0.f, 1.f);
1657
+
1658
+ // The labels are owned here; the struct's pointers are not retained, so the
1659
+ // caller may free theirs as soon as this returns.
1660
+ if (next.label) chart->fvg_label = next.label;
1661
+ next.label = nullptr;
1662
+ if (next.inverse_label) chart->fvg_inverse_label = next.inverse_label;
1663
+ next.inverse_label = nullptr;
1664
+
1665
+ // Only the detection inputs force a rescan. Box geometry, colors, borders,
1666
+ // labels and the inverse pass all read the same cache at draw time —
1667
+ // invalidation is detected unconditionally alongside the fill.
1668
+ const VroomFairValueGaps& cur = chart->fvg;
1669
+ const bool recompute = cur.enabled != next.enabled ||
1670
+ cur.max_bars_back != next.max_bars_back ||
1671
+ cur.wait_for_close != next.wait_for_close ||
1672
+ cur.fill_type != next.fill_type;
1673
+ chart->fvg = next;
1674
+ if (recompute) chart->fvg_dirty = true;
1675
+ chart->mark_dirty();
1676
+ }
1677
+
1492
1678
  extern "C" void vroom_chart_set_volume(VroomChart* chart,
1493
1679
  const VroomVolume* cfg) {
1494
1680
  if (!chart || !cfg) return;
@@ -0,0 +1,76 @@
1
+ #include "fair_value_gaps.h"
2
+
3
+ #include <algorithm>
4
+
5
+ namespace vroom::fvg {
6
+
7
+ namespace {
8
+
9
+ // True once `c` has traded through the far edge of the band as seen from
10
+ // `bullish` — the bottom when bullish, the top when bearish. Under kFillClose
11
+ // the candle has to close past it; under kFillWick the wick reaching it is
12
+ // enough.
13
+ //
14
+ // Polarity is a parameter rather than `g.bullish` because the same test settles
15
+ // both events: a gap is filled when price crosses it in its own direction, and
16
+ // the zone it inverts into is reclaimed when price crosses back the other way.
17
+ bool crossed(const ::VroomCandle& c, const Gap& g, bool bullish, int fill_type) {
18
+ if (fill_type == kFillWick) {
19
+ return bullish ? c.low <= g.bottom : c.high >= g.top;
20
+ }
21
+ return bullish ? c.close <= g.bottom : c.close >= g.top;
22
+ }
23
+
24
+ } // namespace
25
+
26
+ void compute(const ::VroomCandle* candles, std::size_t n, int max_bars_back,
27
+ bool wait_for_close, int fill_type, std::vector<Gap>& out) {
28
+ out.clear();
29
+ if (!candles || n < 3 || max_bars_back <= 0) return;
30
+
31
+ // The middle bar of the three. It needs a bar on each side, and under
32
+ // wait_for_close the third one must not be the still-forming newest bar.
33
+ const std::size_t last = wait_for_close ? n - 3 : n - 2;
34
+ const std::size_t window = static_cast<std::size_t>(max_bars_back);
35
+ const std::size_t first = std::max<std::size_t>(1, n > window ? n - window : 0);
36
+
37
+ for (std::size_t i = first; i <= last; ++i) {
38
+ const ::VroomCandle& prev = candles[i - 1];
39
+ const ::VroomCandle& next = candles[i + 1];
40
+
41
+ Gap g;
42
+ if (prev.high < next.low) {
43
+ g.bullish = true;
44
+ g.bottom = prev.high;
45
+ g.top = next.low;
46
+ } else if (prev.low > next.high) {
47
+ g.bullish = false;
48
+ g.bottom = next.high;
49
+ g.top = prev.low;
50
+ } else {
51
+ continue; // the wicks overlap — price left nothing behind
52
+ }
53
+ g.time_ms = candles[i].time_ms;
54
+
55
+ std::size_t j = i + 2;
56
+ for (; j < n; ++j) {
57
+ if (crossed(candles[j], g, g.bullish, fill_type)) {
58
+ g.filled_ms = candles[j].time_ms;
59
+ break;
60
+ }
61
+ }
62
+
63
+ // Resume after the filling bar with the polarity flipped. Starting at
64
+ // j + 1 is what keeps an earlier bar that happened to sit past the
65
+ // opposite edge from counting — the inversion does not exist yet.
66
+ for (++j; g.filled_ms != 0 && j < n; ++j) {
67
+ if (crossed(candles[j], g, !g.bullish, fill_type)) {
68
+ g.invalidated_ms = candles[j].time_ms;
69
+ break;
70
+ }
71
+ }
72
+ out.push_back(g);
73
+ }
74
+ }
75
+
76
+ } // namespace vroom::fvg