volas-compute 3.0.3

Numeric kernels and technical indicators for volas (pure functions over slices)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
// ---------------------------------------------------------------------------
// Candlestick patterns (TA-Lib CDL*)
// ---------------------------------------------------------------------------
//
// A cohesive sub-domain of `indicators`: candlestick-pattern recognition. This module
// owns the shared candle-settings framework (TA-Lib's `TA_CANDLE*`); the patterns
// themselves live in bar-count submodules (`one_bar`, `two_bar`, …) and are re-exported.
//
// Each pattern tests a candle's geometry against adaptive thresholds derived from a
// rolling average of a chosen "range" (real body / high-low / shadows) over the prior
// `avg_period` bars, scaled by a `factor`. Output is f64: `+100` bullish, `-100`
// bearish, `0` no pattern; the warm-up is NaN (volas's uniform convention — TA-Lib
// fills its integer outputs with 0, which is indistinguishable from "no pattern").
// Parity holds on the valid region.

// Each pattern's warm-up is `max()` over the avg_periods of the settings it uses,
// expressing "the longest required lookback". That one constant currently dominates
// is incidental — keep the `max` so a future settings change stays correct.
#![allow(clippy::unnecessary_min_or_max)]

mod multi_bar;
mod one_bar;
mod three_bar;
mod two_bar;
pub use multi_bar::*;
pub use one_bar::*;
pub use three_bar::*;
pub use two_bar::*;

/// An OHLC candle recogniser: `(open, high, low, close) -> signal`.
type PlainFn = fn(&[f64], &[f64], &[f64], &[f64]) -> Vec<f64>;
/// An OHLC recogniser that also takes TA-Lib's `penetration` ratio.
type PenetrationFn = fn(&[f64], &[f64], &[f64], &[f64], f64) -> Vec<f64>;

/// A candlestick-pattern recogniser. Most take only `(open, high, low, close)`; a few
/// also take TA-Lib's `penetration` ratio (with a pattern-specific default).
pub enum CandlePattern {
    /// `(open, high, low, close)`.
    Plain(PlainFn),
    /// `(open, high, low, close, penetration)`, plus the TA-Lib default penetration.
    Penetration {
        f: PenetrationFn,
        default: f64,
    },
}

/// Resolve a pattern by its (post-`CDL`, lower-case) name to its recogniser and
/// lookback. The single source of truth for which `style.<pattern>` / `cdl.<pattern>`
/// names exist — the directive layer (spec/exec/lookback) queries this, so adding a
/// pattern is one entry here plus its function.
pub fn candle_pattern(name: &str) -> Option<(CandlePattern, usize)> {
    use CandlePattern::{Penetration, Plain};
    // Penetration patterns: darkcloudcover defaults 0.5, the star family 0.3.
    let pen = |f, default| Penetration { f, default };
    let entry = match name {
        // one-bar
        "doji" => (Plain(cdl_doji as _), 10),
        "marubozu" => (Plain(cdl_marubozu as _), 10),
        "closingmarubozu" => (Plain(cdl_closingmarubozu as _), 10),
        "longline" => (Plain(cdl_longline as _), 10),
        "shortline" => (Plain(cdl_shortline as _), 10),
        "highwave" => (Plain(cdl_highwave as _), 10),
        "spinningtop" => (Plain(cdl_spinningtop as _), 10),
        "dragonflydoji" => (Plain(cdl_dragonflydoji as _), 10),
        "gravestonedoji" => (Plain(cdl_gravestonedoji as _), 10),
        "longleggeddoji" => (Plain(cdl_longleggeddoji as _), 10),
        "rickshawman" => (Plain(cdl_rickshawman as _), 10),
        "belthold" => (Plain(cdl_belthold as _), 10),
        "hammer" => (Plain(cdl_hammer as _), 11),
        "hangingman" => (Plain(cdl_hangingman as _), 11),
        "invertedhammer" => (Plain(cdl_invertedhammer as _), 11),
        "shootingstar" => (Plain(cdl_shootingstar as _), 11),
        "takuri" => (Plain(cdl_takuri as _), 10),
        // two-bar
        "engulfing" => (Plain(cdl_engulfing as _), 2),
        "harami" => (Plain(cdl_harami as _), 11),
        "haramicross" => (Plain(cdl_haramicross as _), 11),
        "piercing" => (Plain(cdl_piercing as _), 11),
        "darkcloudcover" => (pen(cdl_darkcloudcover, 0.5), 11),
        "dojistar" => (Plain(cdl_dojistar as _), 11),
        "homingpigeon" => (Plain(cdl_homingpigeon as _), 11),
        "matchinglow" => (Plain(cdl_matchinglow as _), 6),
        "inneck" => (Plain(cdl_inneck as _), 11),
        "onneck" => (Plain(cdl_onneck as _), 11),
        "thrusting" => (Plain(cdl_thrusting as _), 11),
        "kicking" => (Plain(cdl_kicking as _), 11),
        "kickingbylength" => (Plain(cdl_kickingbylength as _), 11),
        "separatinglines" => (Plain(cdl_separatinglines as _), 11),
        "counterattack" => (Plain(cdl_counterattack as _), 11),
        // three-bar
        "morningstar" => (pen(cdl_morningstar, 0.3), 12),
        "eveningstar" => (pen(cdl_eveningstar, 0.3), 12),
        "3inside" => (Plain(cdl_3inside as _), 12),
        "3outside" => (Plain(cdl_3outside as _), 3),
        "3whitesoldiers" => (Plain(cdl_3whitesoldiers as _), 12),
        "3blackcrows" => (Plain(cdl_3blackcrows as _), 13),
        "morningdojistar" => (pen(cdl_morningdojistar, 0.3), 12),
        "eveningdojistar" => (pen(cdl_eveningdojistar, 0.3), 12),
        "abandonedbaby" => (pen(cdl_abandonedbaby, 0.3), 12),
        "2crows" => (Plain(cdl_2crows as _), 12),
        "upsidegap2crows" => (Plain(cdl_upsidegap2crows as _), 12),
        "advanceblock" => (Plain(cdl_advanceblock as _), 12),
        "stalledpattern" => (Plain(cdl_stalledpattern as _), 12),
        "identical3crows" => (Plain(cdl_identical3crows as _), 12),
        "sticksandwich" => (Plain(cdl_sticksandwich as _), 7),
        "tristar" => (Plain(cdl_tristar as _), 12),
        "unique3river" => (Plain(cdl_unique3river as _), 12),
        "gapsidesidewhite" => (Plain(cdl_gapsidesidewhite as _), 7),
        "tasukigap" => (Plain(cdl_tasukigap as _), 7),
        "3starsinsouth" => (Plain(cdl_3starsinsouth as _), 12),
        // four / five-bar
        "3linestrike" => (Plain(cdl_3linestrike as _), 8),
        "breakaway" => (Plain(cdl_breakaway as _), 14),
        "ladderbottom" => (Plain(cdl_ladderbottom as _), 14),
        "concealbabyswall" => (Plain(cdl_concealbabyswall as _), 13),
        "mathold" => (pen(cdl_mathold, 0.5), 14),
        "risefall3methods" => (Plain(cdl_risefall3methods as _), 14),
        "xsidegap3methods" => (Plain(cdl_xsidegap3methods as _), 2),
        "hikkake" => (Plain(cdl_hikkake as _), 5),
        "hikkakemod" => (Plain(cdl_hikkakemod as _), 10),
        _ => return None,
    };
    Some(entry)
}

/// Every candlestick-pattern name (the `style.<pattern>` / `cdl.<pattern>`
/// vocabulary), as an enumerable list — the single source the indicator-count
/// script derives the pattern total from. A test keeps it in lock-step with
/// `candle_pattern`, so a pattern can never be in one and not the other.
pub const CANDLE_PATTERNS: &[&str] = &[
    "doji", "marubozu", "closingmarubozu", "longline", "shortline", "highwave",
    "spinningtop", "dragonflydoji", "gravestonedoji", "longleggeddoji", "rickshawman",
    "belthold", "hammer", "hangingman", "invertedhammer", "shootingstar", "takuri",
    "engulfing", "harami", "haramicross", "piercing", "darkcloudcover", "dojistar",
    "homingpigeon", "matchinglow", "inneck", "onneck", "thrusting", "kicking",
    "kickingbylength", "separatinglines", "counterattack", "morningstar", "eveningstar",
    "3inside", "3outside", "3whitesoldiers", "3blackcrows", "morningdojistar",
    "eveningdojistar", "abandonedbaby", "2crows", "upsidegap2crows", "advanceblock",
    "stalledpattern", "identical3crows", "sticksandwich", "tristar", "unique3river",
    "gapsidesidewhite", "tasukigap", "3starsinsouth", "3linestrike", "breakaway",
    "ladderbottom", "concealbabyswall", "mathold", "risefall3methods", "xsidegap3methods",
    "hikkake", "hikkakemod",
];

/// The range a candle-setting measures. The full set mirrors TA-Lib's settings table;
/// a variant is only *constructed* once a pattern needing it lands, so allow the lint.
#[derive(Clone, Copy)]
#[allow(dead_code)]
enum RangeType {
    RealBody,
    HighLow,
    Shadows,
}

/// One candle-setting: which range, the averaging period (0 = the bar itself), and the
/// scaling factor. Values are TA-Lib's defaults (ta_global.c). Visible to the pattern
/// submodules (descendants) without `pub`.
#[derive(Clone, Copy)]
struct Setting {
    range: RangeType,
    avg_period: usize,
    factor: f64,
}

use RangeType::{HighLow, RealBody, Shadows};
const BODY_LONG: Setting = Setting {
    range: RealBody,
    avg_period: 10,
    factor: 1.0,
};
const BODY_VERY_LONG: Setting = Setting {
    range: RealBody,
    avg_period: 10,
    factor: 3.0,
};
const BODY_SHORT: Setting = Setting {
    range: RealBody,
    avg_period: 10,
    factor: 1.0,
};
const BODY_DOJI: Setting = Setting {
    range: HighLow,
    avg_period: 10,
    factor: 0.1,
};
const SHADOW_LONG: Setting = Setting {
    range: RealBody,
    avg_period: 0,
    factor: 1.0,
};
const SHADOW_VERY_LONG: Setting = Setting {
    range: RealBody,
    avg_period: 0,
    factor: 2.0,
};
const SHADOW_SHORT: Setting = Setting {
    range: Shadows,
    avg_period: 10,
    factor: 1.0,
};
const SHADOW_VERY_SHORT: Setting = Setting {
    range: HighLow,
    avg_period: 10,
    factor: 0.1,
};
const NEAR: Setting = Setting {
    range: HighLow,
    avg_period: 5,
    factor: 0.2,
};
const FAR: Setting = Setting {
    range: HighLow,
    avg_period: 5,
    factor: 0.6,
};
const EQUAL: Setting = Setting {
    range: HighLow,
    avg_period: 5,
    factor: 0.05,
};

// Settings not yet consumed by a landed pattern (shrinks to nothing as patterns arrive).
#[allow(dead_code)]
const _UNUSED_SETTINGS: [Setting; 1] = [BODY_VERY_LONG];

#[inline]
fn realbody(o: &[f64], c: &[f64], i: usize) -> f64 {
    (c[i] - o[i]).abs()
}
#[inline]
fn uppershadow(o: &[f64], h: &[f64], c: &[f64], i: usize) -> f64 {
    h[i] - o[i].max(c[i])
}
#[inline]
fn lowershadow(o: &[f64], l: &[f64], c: &[f64], i: usize) -> f64 {
    o[i].min(c[i]) - l[i]
}
/// `+1.0` white (close ≥ open), `-1.0` black.
#[inline]
fn color(o: &[f64], c: &[f64], i: usize) -> f64 {
    if c[i] >= o[i] {
        1.0
    } else {
        -1.0
    }
}

/// Real-body gap up between bar `i` and an earlier bar `j` (TA-Lib `TA_REALBODYGAPUP`):
/// the whole real body of `i` sits above that of `j`.
#[inline]
fn realbody_gap_up(o: &[f64], c: &[f64], i: usize, j: usize) -> bool {
    o[i].min(c[i]) > o[j].max(c[j])
}
/// Real-body gap down (TA-Lib `TA_REALBODYGAPDOWN`).
#[inline]
fn realbody_gap_down(o: &[f64], c: &[f64], i: usize, j: usize) -> bool {
    o[i].max(c[i]) < o[j].min(c[j])
}
/// Candle (high-low) gap up between bar `i` and an earlier bar `j` (TA-Lib
/// `TA_CANDLEGAPUP`): bar `i`'s low is above bar `j`'s high.
#[inline]
fn candle_gap_up(h: &[f64], l: &[f64], i: usize, j: usize) -> bool {
    l[i] > h[j]
}
/// Candle gap down (TA-Lib `TA_CANDLEGAPDOWN`).
#[inline]
fn candle_gap_down(h: &[f64], l: &[f64], i: usize, j: usize) -> bool {
    h[i] < l[j]
}

/// The setting's range at bar `i`.
#[inline]
fn range(s: Setting, o: &[f64], h: &[f64], l: &[f64], c: &[f64], i: usize) -> f64 {
    match s.range {
        RangeType::RealBody => realbody(o, c, i),
        RangeType::HighLow => h[i] - l[i],
        RangeType::Shadows => uppershadow(o, h, c, i) + lowershadow(o, l, c, i),
    }
}

/// TA-Lib's `TA_CANDLEAVERAGE`: `factor · (avg of range over the prior avg_period bars,
/// or the bar's own range when avg_period == 0) / (2 if Shadows else 1)`. The average
/// window is `[i-avg_period, i-1]` — the bars *before* `i` (so callers start at `i >=
/// avg_period`).
fn candle_average(s: Setting, o: &[f64], h: &[f64], l: &[f64], c: &[f64], i: usize) -> f64 {
    let base = if s.avg_period != 0 {
        let mut sum = 0.0;
        for j in (i - s.avg_period)..i {
            sum += range(s, o, h, l, c, j);
        }
        sum / s.avg_period as f64
    } else {
        range(s, o, h, l, c, i)
    };
    let div = if matches!(s.range, RangeType::Shadows) {
        2.0
    } else {
        1.0
    };
    s.factor * base / div
}

#[inline]
fn candle_average_from_total(s: Setting, total: f64) -> f64 {
    let div = if matches!(s.range, RangeType::Shadows) {
        2.0
    } else {
        1.0
    };
    s.factor * (total / s.avg_period as f64) / div
}

/// TA-Lib's `TA_CANDLEAVERAGE` precomputed for **every** bar in O(n): a running window
/// sum of the setting's range over `[i-avg_period, i-1]` replaces [`candle_average`]'s
/// per-bar O(avg_period) rescan (the dominant cost of most CDL patterns — TA-Lib itself
/// slides this total). `out[i]` is valid for `i >= avg_period`; callers read only
/// `i >= lookback >= avg_period`. For `avg_period == 0` it is the bar's own range. The
/// final scaling keeps TA-Lib's exact `factor·(total/period)/div` order.
fn candle_average_series(s: Setting, o: &[f64], h: &[f64], l: &[f64], c: &[f64]) -> Vec<f64> {
    let n = c.len();
    let mut out = vec![0.0; n];
    let div = if matches!(s.range, RangeType::Shadows) {
        2.0
    } else {
        1.0
    };
    if s.avg_period == 0 {
        for (i, slot) in out.iter_mut().enumerate() {
            *slot = s.factor * range(s, o, h, l, c, i) / div;
        }
        return out;
    }
    let p = s.avg_period;
    if p > n {
        return out;
    }
    let pf = p as f64;
    // Seed the window [0, p-1] (so the first valid average lands at i = p), then slide
    // add-new / subtract-trailing — one range evaluation per bar instead of `p`.
    let mut total = 0.0;
    for j in 0..p {
        total += range(s, o, h, l, c, j);
    }
    #[allow(clippy::needless_range_loop)] // numeric kernel: index-loop kept for hot-path codegen stability
    for i in p..n {
        out[i] = s.factor * (total / pf) / div;
        total += range(s, o, h, l, c, i) - range(s, o, h, l, c, i - p);
    }
    out
}

/// Per-bar pattern builder that carries a **scalar** running window-sum for each of the
/// `K` settings (TA-Lib's own approach), so a pattern reads its candle averages in O(1)
/// per bar with no per-bar rescan and no materialised average arrays. Each total is
/// seeded over `[lookback - avg_period, lookback - 1]` (TA-Lib's trailing-index start)
/// and slid add-new / subtract-trailing; `avgs[k]` handed to `f` is `settings[k]`'s
/// average at bar `i` (window `[i-avg_period, i-1]`; the bar's own range when
/// `avg_period == 0`). NaN before `lookback`.
#[inline]
fn each_bar_avg<const K: usize>(
    settings: [Setting; K],
    lookback: usize,
    o: &[f64],
    h: &[f64],
    l: &[f64],
    c: &[f64],
    f: impl Fn(usize, &[f64; K]) -> f64,
) -> Vec<f64> {
    let n = c.len();
    if lookback >= n {
        return vec![f64::NAN; n];
    }
    // Single-write (D2): NaN warm-up, then each valid bar written once via `build_f64`.
    crate::buf::build_f64(n, |out| {
        for slot in &mut out[..lookback] {
            slot.write(f64::NAN);
        }
        let mut total = [0.0f64; K];
        for k in 0..K {
            let s = settings[k];
            if s.avg_period != 0 {
                for j in (lookback - s.avg_period)..lookback {
                    total[k] += range(s, o, h, l, c, j);
                }
            }
        }
        let mut avgs = [0.0f64; K];
        #[allow(clippy::needless_range_loop)] // numeric kernel: index-loop kept for hot-path codegen stability
        for i in lookback..n {
            for k in 0..K {
                let s = settings[k];
                let div = if matches!(s.range, RangeType::Shadows) {
                    2.0
                } else {
                    1.0
                };
                avgs[k] = if s.avg_period != 0 {
                    s.factor * (total[k] / s.avg_period as f64) / div
                } else {
                    s.factor * range(s, o, h, l, c, i) / div
                };
            }
            out[i].write(f(i, &avgs));
            for k in 0..K {
                let s = settings[k];
                if s.avg_period != 0 {
                    total[k] += range(s, o, h, l, c, i) - range(s, o, h, l, c, i - s.avg_period);
                }
            }
        }
    })
}

/// Like [`each_bar_avg`] but for two-bar patterns: the closure also receives the prior
/// bar's averages (`prev`), so `candle_average(s, i-1)` reads `prev[k]` and
/// `candle_average(s, i)` reads `cur[k]` — both from the same scalar running totals (no
/// arrays). Seeds at bar `lookback-1`, carries `prev` forward each step.
#[inline]
fn each_bar_avg2<const K: usize>(
    settings: [Setting; K],
    lookback: usize,
    o: &[f64],
    h: &[f64],
    l: &[f64],
    c: &[f64],
    f: impl Fn(usize, &[f64; K], &[f64; K]) -> f64,
) -> Vec<f64> {
    let n = c.len();
    if lookback == 0 || lookback >= n {
        return vec![f64::NAN; n];
    }
    // Single-write (D2): NaN warm-up, then each valid bar written once via `build_f64`.
    crate::buf::build_f64(n, |out| {
        for slot in &mut out[..lookback] {
            slot.write(f64::NAN);
        }
        // Seed each total over bar (lookback-1)'s window `[(lookback-1)-avg_period, lookback-2]`.
        let mut total = [0.0f64; K];
        for k in 0..K {
            let s = settings[k];
            if s.avg_period != 0 {
                for j in ((lookback - 1) - s.avg_period)..(lookback - 1) {
                    total[k] += range(s, o, h, l, c, j);
                }
            }
        }
        // Averages at bar (lookback-1) — the first `prev`. (Everything inlined, no inner
        // closures: the closure form here failed to inline and ran ~4x slower for K >= 2.)
        let mut prev = [0.0f64; K];
        for k in 0..K {
            let s = settings[k];
            let div = if matches!(s.range, RangeType::Shadows) {
                2.0
            } else {
                1.0
            };
            prev[k] = if s.avg_period != 0 {
                s.factor * (total[k] / s.avg_period as f64) / div
            } else {
                s.factor * range(s, o, h, l, c, lookback - 1) / div
            };
            if s.avg_period != 0 {
                total[k] += range(s, o, h, l, c, lookback - 1)
                    - range(s, o, h, l, c, (lookback - 1) - s.avg_period);
            }
        }
        let mut cur = [0.0f64; K];
        #[allow(clippy::needless_range_loop)] // numeric kernel: index-loop kept for hot-path codegen stability
        for i in lookback..n {
            for k in 0..K {
                let s = settings[k];
                let div = if matches!(s.range, RangeType::Shadows) {
                    2.0
                } else {
                    1.0
                };
                cur[k] = if s.avg_period != 0 {
                    s.factor * (total[k] / s.avg_period as f64) / div
                } else {
                    s.factor * range(s, o, h, l, c, i) / div
                };
            }
            out[i].write(f(i, &cur, &prev));
            prev = cur;
            for k in 0..K {
                let s = settings[k];
                if s.avg_period != 0 {
                    total[k] += range(s, o, h, l, c, i) - range(s, o, h, l, c, i - s.avg_period);
                }
            }
        }
    })
}

/// Multi-bar generalisation of [`each_bar_avg`]: carries the last `L` bars' averages as
/// scalar running sums, handing the closure `hist` where `hist[lag][k]` is `settings[k]`'s
/// average at bar `i-lag` (lag `0` = current). For patterns that read a candle average a
/// fixed number of bars back (e.g. tristar at `i-2`, breakaway at `i-4`). No arrays — a
/// short `[[f64; K]; L]` history is shifted each bar.
///
/// Maintains all `K` running sums *unconditionally* on every bar, so it wins when a
/// pattern reads its averages on most bars (weak early gating) or has several settings
/// whose per-bar rescans add up. For *strongly-gated* patterns — those that reject most
/// bars on cheap geometry first (three same-colour candles, a required gap) — plain
/// [`each_bar`] + lazy [`candle_average`] is faster, since the average is only computed on
/// the few bars that pass the gate; several three-/multi-bar patterns deliberately keep
/// `each_bar` for that reason (measured against this helper — do not blindly migrate them).
#[inline]
fn each_bar_avg_n<const K: usize, const L: usize>(
    settings: [Setting; K],
    lookback: usize,
    o: &[f64],
    h: &[f64],
    l: &[f64],
    c: &[f64],
    f: impl Fn(usize, &[[f64; K]; L]) -> f64,
) -> Vec<f64> {
    let n = c.len();
    if lookback + 1 < L || lookback >= n {
        return vec![f64::NAN; n];
    }
    // Single-write (D2): NaN warm-up, then each valid bar written once via `build_f64`.
    // The loop visits `[first, lookback)` only to warm up `hist`/`total` (no output).
    crate::buf::build_f64(n, |out| {
        for slot in &mut out[..lookback] {
            slot.write(f64::NAN);
        }
        let first = lookback + 1 - L; // first bar whose averages enter the history
        let mut total = [0.0f64; K];
        for k in 0..K {
            let s = settings[k];
            if s.avg_period != 0 {
                for j in (first - s.avg_period)..first {
                    total[k] += range(s, o, h, l, c, j);
                }
            }
        }
        let mut hist = [[0.0f64; K]; L];
        #[allow(clippy::needless_range_loop)] // numeric kernel: index-loop kept for hot-path codegen stability
        for bar in first..n {
            let mut a = [0.0f64; K];
            for k in 0..K {
                let s = settings[k];
                let div = if matches!(s.range, RangeType::Shadows) {
                    2.0
                } else {
                    1.0
                };
                a[k] = if s.avg_period != 0 {
                    s.factor * (total[k] / s.avg_period as f64) / div
                } else {
                    s.factor * range(s, o, h, l, c, bar) / div
                };
            }
            for j in (1..L).rev() {
                hist[j] = hist[j - 1];
            }
            hist[0] = a;
            if bar >= lookback {
                out[bar].write(f(bar, &hist));
            }
            for k in 0..K {
                let s = settings[k];
                if s.avg_period != 0 {
                    total[k] += range(s, o, h, l, c, bar) - range(s, o, h, l, c, bar - s.avg_period);
                }
            }
        }
    })
}

/// Build a per-bar pattern column: NaN before `lookback`, then `f(i)` (0 / ±100 / ±80)
/// per bar. Shared by all pattern submodules.
#[inline]
fn each_bar(n: usize, lookback: usize, f: impl Fn(usize) -> f64) -> Vec<f64> {
    let warm = lookback.min(n);
    // Single-write (D2): NaN warm-up then one write per valid bar, no prefill memset
    // of the valid region. `build_f64` keeps it safe (debug poison + write-all assert).
    crate::buf::build_f64(n, |out| {
        for slot in &mut out[..warm] {
            slot.write(f64::NAN);
        }
        #[allow(clippy::needless_range_loop)] // numeric kernel: index-loop kept for hot-path codegen stability
        for i in warm..n {
            out[i].write(f(i));
        }
    })
}

/// Single-write output buffer for specialised CDL loops: NaN warm-up `[0, lookback)`,
/// then the caller writes every valid row once via `out.set(i, ..)` and yields it with
/// `out.finish()`. No prefill memset (D2); `OutBuf` poisons + asserts in debug.
#[inline]
fn candle_output(n: usize, lookback: usize) -> Option<crate::buf::OutBuf> {
    if lookback >= n {
        return None;
    }
    Some(crate::buf::OutBuf::warmup(n, lookback))
}

#[cfg(test)]
mod tests {
    use super::*;

    /// `CANDLE_PATTERNS` must stay in lock-step with `candle_pattern`: every listed
    /// name resolves (so the indicator count never lists a phantom pattern), and the
    /// list is duplicate-free. The reverse (every `candle_pattern` arm is listed) is
    /// guarded by the count being pinned in the test suite.
    #[test]
    fn candle_patterns_list_matches_registry() {
        for name in CANDLE_PATTERNS {
            assert!(candle_pattern(name).is_some(), "CANDLE_PATTERNS has a phantom {name:?}");
        }
        let mut sorted = CANDLE_PATTERNS.to_vec();
        sorted.sort_unstable();
        sorted.dedup();
        assert_eq!(sorted.len(), CANDLE_PATTERNS.len(), "CANDLE_PATTERNS has duplicates");
    }

    /// The candle-settings averagers have branches (Shadows range → ÷2, `avg_period == 0`
    /// → the bar's own range, and the short-series guards) that the 61 landed patterns
    /// never select for these specific helpers. Exercise them directly so the framework
    /// is fully covered regardless of which settings the current patterns happen to use.
    #[test]
    fn candle_average_helper_branches() {
        let o = vec![1.0; 20];
        let h = vec![3.0; 20];
        let l = vec![0.0; 20];
        let c = vec![2.0; 20];
        let s3 = vec![1.0; 3];

        // candle_average_series: Shadows range (÷2), avg_period 0, and period > n.
        candle_average_series(SHADOW_SHORT, &o, &h, &l, &c);
        candle_average_series(SHADOW_LONG, &o, &h, &l, &c);
        candle_average_series(NEAR, &s3, &s3, &s3, &s3);

        // candle_average (per-bar): Shadows range and avg_period 0.
        let _ = candle_average(SHADOW_SHORT, &o, &h, &l, &c, 12);
        let _ = candle_average(SHADOW_LONG, &o, &h, &l, &c, 12);
        let _ = candle_average_from_total(SHADOW_SHORT, 6.0);

        // each_bar_avg2: a zero lookback trips its guard; a valid call with an
        // avg_period-0 (SHADOW_LONG) and a Shadows (SHADOW_SHORT) setting covers the
        // body's ÷2 and own-range branches in both the seed and per-bar loops.
        each_bar_avg2([BODY_LONG, NEAR], 0, &o, &h, &l, &c, |_, a, _| a[0]);
        each_bar_avg2(
            [SHADOW_LONG, SHADOW_SHORT],
            12,
            &o,
            &h,
            &l,
            &c,
            |_, a, b| a[0] + b[0],
        );

        // each_bar_avg_n: the lookback guard, a Shadows setting (÷2), and an
        // avg_period-0 setting (the bar's own range).
        each_bar_avg_n::<1, 2>([NEAR], 0, &o, &h, &l, &c, |_, hist| hist[0][0]);
        each_bar_avg_n::<1, 2>([SHADOW_SHORT], 12, &o, &h, &l, &c, |_, hist| hist[0][0]);
        each_bar_avg_n::<1, 2>([SHADOW_LONG], 6, &o, &h, &l, &c, |_, hist| hist[0][0]);
    }
}