react-native-vroom-chart 0.15.0 → 0.17.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 (53) hide show
  1. package/cpp/VroomChartHostObject.cpp +252 -1
  2. package/cpp/_core_include/vroom/vroom_chart.h +178 -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/candles.cpp +15 -14
  8. package/cpp/_core_src/chart.cpp +758 -200
  9. package/cpp/_core_src/chart.h +221 -3
  10. package/cpp/_core_src/chart_facade.cpp +236 -23
  11. package/cpp/_core_src/color_lerp.h +28 -0
  12. package/cpp/_core_src/fair_value_gaps.cpp +76 -0
  13. package/cpp/_core_src/fair_value_gaps.h +54 -0
  14. package/cpp/_core_src/fvg_overlay.cpp +262 -0
  15. package/cpp/_core_src/fvg_overlay.h +43 -0
  16. package/cpp/_core_src/ichimoku.cpp +65 -0
  17. package/cpp/_core_src/ichimoku.h +45 -0
  18. package/cpp/_core_src/labels.cpp +3 -0
  19. package/cpp/_core_src/line_morph.h +159 -0
  20. package/cpp/_core_src/loading_line.cpp +183 -0
  21. package/cpp/_core_src/loading_line.h +38 -0
  22. package/cpp/_core_src/loading_wave.h +140 -0
  23. package/cpp/_core_src/ma_overlay.cpp +273 -45
  24. package/cpp/_core_src/ma_overlay.h +64 -2
  25. package/cpp/_core_src/macd.cpp +24 -1
  26. package/cpp/_core_src/macd.h +16 -0
  27. package/cpp/_core_src/macd_pane.cpp +71 -62
  28. package/cpp/_core_src/macd_pane.h +13 -1
  29. package/cpp/_core_src/pane_series.h +169 -0
  30. package/cpp/_core_src/price_indicator.cpp +21 -7
  31. package/cpp/_core_src/price_indicator.h +11 -1
  32. package/cpp/_core_src/price_indicator_anim.h +81 -0
  33. package/cpp/_core_src/rsi.cpp +4 -0
  34. package/cpp/_core_src/rsi.h +7 -0
  35. package/cpp/_core_src/rsi_pane.cpp +85 -29
  36. package/cpp/_core_src/rsi_pane.h +11 -1
  37. package/cpp/_core_src/theme.cpp +5 -0
  38. package/cpp/_core_src/tip_geometry.h +55 -0
  39. package/cpp/_core_src/viewport.h +68 -0
  40. package/lib/index.d.mts +281 -3
  41. package/lib/index.d.ts +281 -3
  42. package/lib/index.js +292 -14
  43. package/lib/index.js.map +1 -1
  44. package/lib/index.mjs +292 -14
  45. package/lib/index.mjs.map +1 -1
  46. package/package.json +1 -1
  47. package/src/VroomChart.tsx +33 -3
  48. package/src/dataTransitions.ts +47 -0
  49. package/src/index.ts +5 -0
  50. package/src/jsi.d.ts +139 -1
  51. package/src/theme.ts +1 -0
  52. package/src/types.ts +5 -0
  53. package/src/useChartCore.ts +402 -8
@@ -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,77 @@ 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
+
500
+ extern "C" void vroom_chart_set_loading(VroomChart* chart, int32_t on,
501
+ int32_t animate) {
502
+ if (!chart) return;
503
+ chart->set_loading(on != 0, animate != 0);
504
+ }
505
+
506
+ extern "C" void vroom_chart_begin_loading_morph(VroomChart* chart) {
507
+ if (!chart) return;
508
+ chart->begin_loading_morph();
509
+ }
510
+
511
+ extern "C" void vroom_chart_set_loading_morph(VroomChart* chart, float t) {
512
+ if (!chart) return;
513
+ chart->loading_line_t = std::clamp(t, 0.f, 1.f);
514
+ chart->mark_dirty();
515
+ }
516
+
517
+ extern "C" void vroom_chart_begin_loading_reveal(VroomChart* chart) {
518
+ if (!chart) return;
519
+ chart->begin_loading_reveal();
520
+ }
521
+
413
522
  extern "C" void vroom_chart_set_interval_morph(VroomChart* chart, float t) {
414
523
  if (!chart) return;
415
524
  chart->interval_morph_t = std::clamp(t, 0.f, 1.f);
416
525
  if (chart->interval_morph_t >= 1.f) {
417
526
  chart->morph_from.clear();
418
527
  chart->morph_from.shrink_to_fit();
528
+ chart->morph_lines.clear();
529
+ chart->morph_lines.shrink_to_fit();
419
530
  chart->interval_morph_fade = false;
531
+ chart->morph_is_stream = false;
532
+ // The loading line rides this same clock out (see draw_chart 5.9), so
533
+ // it has to be released here too or it would hang over a settled chart.
534
+ chart->loading_line_revealing = false;
535
+ chart->loading_line.clear();
536
+ chart->loading_line.shrink_to_fit();
420
537
  }
421
538
  chart->mark_dirty();
422
539
  }
@@ -631,7 +748,9 @@ extern "C" void vroom_chart_scale_time_axis(VroomChart* chart, float dx_px) {
631
748
  extern "C" void vroom_chart_resize_indicator_pane(VroomChart* chart, float dy_px) {
632
749
  if (!chart || dy_px == 0.f) return;
633
750
 
634
- const int pane_count = (chart->rsi.enabled ? 1 : 0) + (chart->macd.enabled ? 1 : 0);
751
+ const int pane_count = (chart->rsi.enabled ? 1 : 0) +
752
+ (chart->macd.enabled ? 1 : 0) +
753
+ (chart->atr.enabled ? 1 : 0);
635
754
  if (pane_count == 0) return; // nothing below the chart to resize
636
755
 
637
756
  const auto lay = chart->layout();
@@ -680,42 +799,41 @@ extern "C" void vroom_chart_scale_indicator_axis(VroomChart* chart, float y_px,
680
799
  const auto lay = chart->layout();
681
800
  if (lay.indicator_area_h <= 0.f) return;
682
801
 
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};
802
+ // The same ordered pane stack the draw pass uses, so the pane under y_px is
803
+ // the one the user sees there.
804
+ vroom::IndicatorPane panes[vroom::kMaxPanes];
805
+ const int count = chart->indicator_panes(panes);
690
806
  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
807
 
697
808
  const float pane_h =
698
809
  chart->height_px * chart->theme.floats[VROOM_FLOAT_INDICATOR_HEIGHT_FRAC];
699
810
  if (pane_h <= 0.f) return;
700
811
 
701
- int target = -1; // 0 = RSI, 1 = MACD
812
+ const vroom::IndicatorPane* target = nullptr;
702
813
  float pane_top = vroom::price_pane_bottom(lay);
703
814
  for (int i = 0; i < count; ++i) {
704
815
  const float pane_bottom = pane_top + pane_h;
705
816
  if (y_px >= pane_top && y_px < pane_bottom) {
706
- target = panes[i].type;
817
+ target = &panes[i];
707
818
  break;
708
819
  }
709
820
  pane_top = pane_bottom;
710
821
  }
711
- if (target < 0) return; // not over a pane
822
+ if (!target) return; // not over a pane
712
823
 
713
824
  // Drag down (dy > 0) widens the visible value range (zoom out), matching
714
825
  // scale_price_axis's sign. The zoom is the inverse of the range scale.
715
826
  double range_scale = 1.0 + static_cast<double>(dy_px) / kAxisDragSensitivity;
716
827
  if (range_scale < 0.05) range_scale = 0.05; // never collapse or flip
717
828
 
718
- double& zoom = target == 0 ? chart->rsi_y_scale : chart->macd_y_scale;
829
+ double* zoom_ptr = nullptr;
830
+ switch (target->kind) {
831
+ case vroom::PaneKind::Rsi: zoom_ptr = &chart->rsi_y_scale; break;
832
+ case vroom::PaneKind::Macd: zoom_ptr = &chart->macd_y_scale; break;
833
+ case vroom::PaneKind::Atr: zoom_ptr = &chart->atr_y_scale; break;
834
+ }
835
+ if (!zoom_ptr) return;
836
+ double& zoom = *zoom_ptr;
719
837
  double next = zoom / range_scale;
720
838
  next = std::clamp(next, 0.1, 10.0);
721
839
  if (next == zoom) return;
@@ -1382,6 +1500,7 @@ extern "C" void vroom_chart_set_rsi(VroomChart* chart, const VroomRSI* cfg) {
1382
1500
  next.ma_visible = next.ma_visible ? 1 : 0;
1383
1501
  next.line_visible = next.line_visible ? 1 : 0;
1384
1502
  next.bands_visible = next.bands_visible ? 1 : 0;
1503
+ next.extreme_fill = next.extreme_fill ? 1 : 0;
1385
1504
  if (next.period < 2) next.period = 2;
1386
1505
  if (next.ma_period < 1) next.ma_period = 1;
1387
1506
  next.upper_band = std::clamp(next.upper_band, 0.0, 100.0);
@@ -1438,6 +1557,27 @@ extern "C" void vroom_chart_set_macd(VroomChart* chart, const VroomMACD* cfg) {
1438
1557
  chart->mark_dirty();
1439
1558
  }
1440
1559
 
1560
+ extern "C" void vroom_chart_set_atr(VroomChart* chart, const VroomATR* cfg) {
1561
+ if (!chart || !cfg) return;
1562
+ VroomATR next = *cfg;
1563
+ next.enabled = next.enabled ? 1 : 0;
1564
+ if (next.period < 1) next.period = 1;
1565
+
1566
+ // Only the series-affecting fields force a recompute; color and width
1567
+ // changes are render-only.
1568
+ const VroomATR& cur = chart->atr;
1569
+ const bool recompute = cur.enabled != next.enabled ||
1570
+ cur.period != next.period ||
1571
+ cur.smoothing != next.smoothing;
1572
+
1573
+ if (next.enabled && !cur.enabled) chart->atr_order = chart->pane_seq++;
1574
+ else if (!next.enabled) chart->atr_order = -1;
1575
+
1576
+ chart->atr = next;
1577
+ if (recompute) chart->atr_dirty = true;
1578
+ chart->mark_dirty();
1579
+ }
1580
+
1441
1581
  extern "C" void vroom_chart_set_overlays(VroomChart* chart,
1442
1582
  const VroomOverlay* overlays,
1443
1583
  size_t count) {
@@ -1489,6 +1629,79 @@ extern "C" void vroom_chart_set_bollinger(VroomChart* chart,
1489
1629
  chart->mark_dirty();
1490
1630
  }
1491
1631
 
1632
+ extern "C" void vroom_chart_set_ichimoku(VroomChart* chart,
1633
+ const VroomIchimoku* cfg) {
1634
+ if (!chart || !cfg) return;
1635
+ VroomIchimoku next = *cfg;
1636
+ next.enabled = next.enabled ? 1 : 0;
1637
+ next.tenkan_enabled = next.tenkan_enabled ? 1 : 0;
1638
+ next.kijun_enabled = next.kijun_enabled ? 1 : 0;
1639
+ next.senkou_a_enabled = next.senkou_a_enabled ? 1 : 0;
1640
+ next.senkou_b_enabled = next.senkou_b_enabled ? 1 : 0;
1641
+ next.chikou_enabled = next.chikou_enabled ? 1 : 0;
1642
+ next.cloud_enabled = next.cloud_enabled ? 1 : 0;
1643
+ if (next.tenkan_period < 1) next.tenkan_period = 1;
1644
+ if (next.kijun_period < 1) next.kijun_period = 1;
1645
+ if (next.senkou_b_period < 1) next.senkou_b_period = 1;
1646
+ if (next.displacement < 0) next.displacement = 0;
1647
+ next.cloud_opacity = std::clamp(next.cloud_opacity, 0.f, 1.f);
1648
+
1649
+ // Only the series-affecting fields force a recompute. Displacement is not
1650
+ // one of them — it shifts where the caches are drawn, not what's in them.
1651
+ const VroomIchimoku& cur = chart->ichimoku;
1652
+ const bool recompute = cur.enabled != next.enabled ||
1653
+ cur.tenkan_period != next.tenkan_period ||
1654
+ cur.kijun_period != next.kijun_period ||
1655
+ cur.senkou_b_period != next.senkou_b_period;
1656
+ const bool turned_on = !cur.enabled && next.enabled;
1657
+ chart->ichimoku = next;
1658
+ if (recompute) chart->ichimoku_dirty = true;
1659
+ // Turning it on mid-session misses the reserve the default framing makes,
1660
+ // and the leading spans would sit off the right edge until the user panned.
1661
+ if (turned_on) reserve_ichimoku_future(chart);
1662
+ chart->mark_dirty();
1663
+ }
1664
+
1665
+ extern "C" void vroom_chart_set_fair_value_gaps(VroomChart* chart,
1666
+ const VroomFairValueGaps* cfg) {
1667
+ if (!chart || !cfg) return;
1668
+ VroomFairValueGaps next = *cfg;
1669
+ next.enabled = next.enabled ? 1 : 0;
1670
+ next.wait_for_close = next.wait_for_close ? 1 : 0;
1671
+ next.delete_after_fill = next.delete_after_fill ? 1 : 0;
1672
+ next.extend_boxes = next.extend_boxes ? 1 : 0;
1673
+ next.border_enabled = next.border_enabled ? 1 : 0;
1674
+ next.labels_enabled = next.labels_enabled ? 1 : 0;
1675
+ next.show_inverse = next.show_inverse ? 1 : 0;
1676
+ next.fill_type = next.fill_type == vroom::fvg::kFillWick
1677
+ ? vroom::fvg::kFillWick
1678
+ : vroom::fvg::kFillClose;
1679
+ next.border_style = std::clamp(next.border_style, 0, 2);
1680
+ if (next.max_bars_back < 0) next.max_bars_back = 0;
1681
+ if (next.box_length < 1) next.box_length = 1;
1682
+ if (next.label_distance < 0) next.label_distance = 0;
1683
+ next.opacity = std::clamp(next.opacity, 0.f, 1.f);
1684
+
1685
+ // The labels are owned here; the struct's pointers are not retained, so the
1686
+ // caller may free theirs as soon as this returns.
1687
+ if (next.label) chart->fvg_label = next.label;
1688
+ next.label = nullptr;
1689
+ if (next.inverse_label) chart->fvg_inverse_label = next.inverse_label;
1690
+ next.inverse_label = nullptr;
1691
+
1692
+ // Only the detection inputs force a rescan. Box geometry, colors, borders,
1693
+ // labels and the inverse pass all read the same cache at draw time —
1694
+ // invalidation is detected unconditionally alongside the fill.
1695
+ const VroomFairValueGaps& cur = chart->fvg;
1696
+ const bool recompute = cur.enabled != next.enabled ||
1697
+ cur.max_bars_back != next.max_bars_back ||
1698
+ cur.wait_for_close != next.wait_for_close ||
1699
+ cur.fill_type != next.fill_type;
1700
+ chart->fvg = next;
1701
+ if (recompute) chart->fvg_dirty = true;
1702
+ chart->mark_dirty();
1703
+ }
1704
+
1492
1705
  extern "C" void vroom_chart_set_volume(VroomChart* chart,
1493
1706
  const VroomVolume* cfg) {
1494
1707
  if (!chart || !cfg) return;
@@ -0,0 +1,28 @@
1
+ // Blending two packed ARGB colors.
2
+ //
3
+ // Shared because anything anchored to the newest candle has to cross-fade when
4
+ // that candle changes direction mid-morph: the body itself (candles.cpp) and
5
+ // the current-price indicator (price_indicator.cpp) both flip between the bull
6
+ // and bear accents, and a snap in either while the other eases is visible.
7
+ //
8
+ // Skia-free so the pure translation units can use it.
9
+
10
+ #pragma once
11
+
12
+ #include <cstdint>
13
+
14
+ namespace vroom {
15
+
16
+ // Channel-wise, including alpha. `t` is expected in [0,1].
17
+ inline uint32_t lerp_argb(uint32_t a, uint32_t b, float t) {
18
+ uint32_t out = 0;
19
+ for (int shift = 0; shift < 32; shift += 8) {
20
+ const float ca = static_cast<float>((a >> shift) & 0xFFu);
21
+ const float cb = static_cast<float>((b >> shift) & 0xFFu);
22
+ const auto v = static_cast<uint32_t>(ca + (cb - ca) * t + 0.5f);
23
+ out |= v << shift;
24
+ }
25
+ return out;
26
+ }
27
+
28
+ } // namespace vroom
@@ -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
@@ -0,0 +1,54 @@
1
+ // Fair Value Gaps over a candle series — pure, no Skia, so it builds into the
2
+ // unit-test target. Drawn as shaded price-pane boxes over three-candle
3
+ // imbalances.
4
+
5
+ #pragma once
6
+
7
+ #include <cstddef>
8
+ #include <cstdint>
9
+ #include <vector>
10
+
11
+ #include "vroom/vroom_chart.h" // ::VroomCandle
12
+
13
+ namespace vroom::fvg {
14
+
15
+ // One detected imbalance, anchored in absolute time and price so the renderer
16
+ // can place it without knowing which bar it came from.
17
+ struct Gap {
18
+ int64_t time_ms = 0; // open time of the middle bar
19
+ double top = 0.0; // upper price edge
20
+ double bottom = 0.0; // lower price edge
21
+ bool bullish = false;
22
+ int64_t filled_ms = 0; // open time of the bar that filled it; 0 = unfilled
23
+ // Open time of the bar that reclaimed the zone after it inverted; 0 while
24
+ // the inversion still stands. Only meaningful once filled_ms is set.
25
+ int64_t invalidated_ms = 0;
26
+ };
27
+
28
+ // Which price settles that a gap has been traded back through.
29
+ enum FillType : int {
30
+ kFillClose = 0, // a candle must close past the far edge
31
+ kFillWick = 1, // a high or low reaching through is enough
32
+ };
33
+
34
+ // Detects the gaps in the last `max_bars_back` bars of [candles, candles+n),
35
+ // oldest first, and resolves each one's fill.
36
+ //
37
+ // A gap sits around the middle bar of a three-candle run whose outer wicks miss
38
+ // each other: bullish when candles[i-1].high < candles[i+1].low (spanning that
39
+ // range), bearish when candles[i-1].low > candles[i+1].high. `wait_for_close`
40
+ // withholds a gap whose third candle is still the newest, live bar.
41
+ //
42
+ // The fill scan walks forward from i+2 and records the first bar to reach the
43
+ // far edge — the bottom of a bullish gap, the top of a bearish one.
44
+ //
45
+ // Closing through a gap inverts it: the band price just rejected becomes a
46
+ // zone of the opposite polarity, resistance overhead where a bullish gap was
47
+ // and support underfoot where a bearish one was. A second scan resumes after
48
+ // the fill and records the bar that reclaims the band the other way, ending
49
+ // the inversion. Both scans always run, so whether a filled or inverted box is
50
+ // drawn at all stays a drawing concern.
51
+ void compute(const ::VroomCandle* candles, std::size_t n, int max_bars_back,
52
+ bool wait_for_close, int fill_type, std::vector<Gap>& out);
53
+
54
+ } // namespace vroom::fvg