Skip to main content

kestrel_chartkit/
synthetic.rs

1//! Deterministic, dependency-free synthetic price series and market pattern generators.
2//!
3//! Provides seed-based pseudo-random OHLCV bar generators (`random_walk_bars`, `trending_bars`)
4//! as well as calibrated structural presets for market analysis indicators:
5//! - Wyckoff accumulation and distribution schematic sequences (`wyckoff_schematic_bars`)
6//! - Break of Structure (BOS) / Change of Character (CHoCH) swing pivots (`bos_choch_swing_bars`)
7//!
8//! All bars are emitted as [`QualifiedBar`] with [`BarQuality::is_synthetic`] flagged as `true`.
9
10use crate::indicator::wyckoff::WyckoffBias;
11use crate::model::{Bar, BarQuality, QualifiedBar};
12
13/// Self-contained, seed-based 64-bit pseudo-random number generator (SplitMix64).
14///
15/// Designed to provide deterministic, platform-independent random number generation without
16/// pulling in external dependencies like `rand`.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct SimpleRng {
19    state: u64,
20}
21
22impl SimpleRng {
23    /// Creates a new PRNG with the specified 64-bit seed.
24    pub fn new(seed: u64) -> Self {
25        Self { state: seed }
26    }
27
28    /// Generates the next pseudo-random 64-bit unsigned integer.
29    pub fn next_u64(&mut self) -> u64 {
30        self.state = self.state.wrapping_add(0x9e3779b97f4a7c15);
31        let mut z = self.state;
32        z = (z ^ (z >> 30)).wrapping_mul(0xbf58476d1ce4e5b9);
33        z = (z ^ (z >> 27)).wrapping_mul(0x94d049bb133111eb);
34        z ^ (z >> 31)
35    }
36
37    /// Generates a pseudo-random `f64` in the half-open interval `[0.0, 1.0)`.
38    pub fn next_f64(&mut self) -> f64 {
39        (self.next_u64() >> 11) as f64 * (1.0 / (1u64 << 53) as f64)
40    }
41
42    /// Generates a pseudo-random `f64` uniformly distributed in `[min, max)`.
43    pub fn next_range(&mut self, min: f64, max: f64) -> f64 {
44        min + (max - min) * self.next_f64()
45    }
46
47    /// Generates a standard normally distributed variable (mean 0.0, std dev 1.0)
48    /// using the Box-Muller transform.
49    pub fn next_gaussian(&mut self) -> f64 {
50        let u1 = self.next_f64().max(1e-15);
51        let u2 = self.next_f64();
52        (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
53    }
54}
55
56/// Constructs a [`QualifiedBar`] marked with synthetic quality flags.
57fn synthetic_bar(
58    timestamp: i64,
59    open: f64,
60    high: f64,
61    low: f64,
62    close: f64,
63    volume: f64,
64) -> QualifiedBar {
65    QualifiedBar::new(
66        Bar::new(timestamp, open, high, low, close, volume),
67        BarQuality {
68            volume_available: true,
69            is_synthetic: true,
70            is_forward_filled: false,
71            has_gap: false,
72        },
73    )
74}
75
76/// Generates a synthetic random walk bar series with drift and volatility.
77///
78/// Returns `count` bars, starting at `start_price` and stepping at 60-second intervals.
79pub fn random_walk_bars(
80    seed: u64,
81    count: usize,
82    start_price: f64,
83    drift: f64,
84    volatility: f64,
85    volume: f64,
86) -> Vec<QualifiedBar> {
87    let mut rng = SimpleRng::new(seed);
88    let mut bars = Vec::with_capacity(count);
89    let mut current_price = start_price.max(0.01);
90
91    for i in 0..count {
92        let open = current_price;
93        let change = drift + volatility * rng.next_gaussian();
94        let close = (open + change).max(0.01);
95        let wick_upper = rng.next_f64() * volatility.abs();
96        let wick_lower = rng.next_f64() * volatility.abs();
97        let high = open.max(close) + wick_upper;
98        let low = (open.min(close) - wick_lower).max(0.001);
99        let bar_volume = (volume + rng.next_range(-0.05, 0.05) * volume).max(0.0);
100
101        bars.push(synthetic_bar(
102            i as i64 * 60,
103            open,
104            high,
105            low,
106            close,
107            bar_volume,
108        ));
109        current_price = close;
110    }
111
112    bars
113}
114
115/// Generates a directional trending bar series with superimposed noise.
116///
117/// Returns `count` bars, stepping by `trend_per_bar` per interval plus gaussian noise.
118pub fn trending_bars(
119    seed: u64,
120    count: usize,
121    start_price: f64,
122    trend_per_bar: f64,
123    noise: f64,
124    volume: f64,
125) -> Vec<QualifiedBar> {
126    let mut rng = SimpleRng::new(seed);
127    let mut bars = Vec::with_capacity(count);
128    let mut current_price = start_price.max(0.01);
129
130    for i in 0..count {
131        let open = current_price;
132        let delta = trend_per_bar + rng.next_gaussian() * noise;
133        let close = (open + delta).max(0.01);
134        let wick1 = rng.next_f64() * noise.abs() + 0.01;
135        let wick2 = rng.next_f64() * noise.abs() + 0.01;
136        let high = open.max(close) + wick1;
137        let low = (open.min(close) - wick2).max(0.001);
138        let bar_volume = (volume + rng.next_range(-0.05, 0.05) * volume).max(0.0);
139
140        bars.push(synthetic_bar(
141            i as i64 * 60,
142            open,
143            high,
144            low,
145            close,
146            bar_volume,
147        ));
148        current_price = close;
149    }
150
151    bars
152}
153
154/// Minimum effective range lookback this generator calibrates against.
155///
156/// Matches [`WyckoffStateMachine::with_defaults`](crate::indicator::wyckoff::WyckoffStateMachine::with_defaults)'s
157/// `range_lookback` and gives both `Rma::new(14)` (ATR warmup) and the robust MAD-based
158/// volume-outlier window enough samples to be statistically meaningful before the directed climax
159/// bar arrives. Smaller `WyckoffStateMachine`/`"wyckoff"` configurations are structurally
160/// supported (deque and volume-outlier window are derived from the same `range_lookback` there,
161/// see `src/indicator/wyckoff.rs`), but a climax-outlier band computed from very few samples is
162/// noisy, so this generator does not tune below the default for its forced-bias guarantee.
163const WYCKOFF_MIN_RANGE_LOOKBACK: usize = 20;
164
165/// Configuration parameters for generating Wyckoff schematic bar sequences.
166#[derive(Debug, Clone, Copy, PartialEq)]
167pub struct WyckoffGeneratorConfig {
168    /// Baseline price around which the trading range contracts.
169    pub center_price: f64,
170    /// Number of bars used for the lookback window.
171    ///
172    /// Internally clamped up to `WYCKOFF_MIN_RANGE_LOOKBACK` regardless of the value supplied
173    /// here — see that (private) constant's doc comment in this module for why.
174    pub range_lookback: usize,
175    /// Price spread / half-width of the trading range.
176    pub spread: f64,
177    /// Baseline volume during the range phase.
178    pub base_volume: f64,
179}
180
181impl Default for WyckoffGeneratorConfig {
182    fn default() -> Self {
183        Self {
184            center_price: 100.0,
185            range_lookback: 20,
186            spread: 2.0,
187            base_volume: 100.0,
188        }
189    }
190}
191
192/// Generates a calibrated Wyckoff schematic sequence completing Phases A through E.
193///
194/// The sequence consists of:
195/// 1. Warmup contraction range bars establishing baseline ATR and volume statistics.
196/// 2. Directed climax bar (high volume down-close for Accumulation, up-close for Distribution)
197///    locking the trading range with the target [`WyckoffBias`] deterministically.
198/// 3. Secondary boundary test / pause bar (Phase B).
199/// 4. Decisive Spring (Accumulation) or UTAD (Distribution) test bar (Phase C).
200/// 5. Sign of Strength (Accumulation) or Sign of Weakness (Distribution) breakout bar (Phase D).
201/// 6. Last Point of Support (Accumulation) or Last Point of Supply (Distribution) confirmation bar (Phase E).
202pub fn wyckoff_schematic_bars(
203    seed: u64,
204    bias: WyckoffBias,
205    config: WyckoffGeneratorConfig,
206) -> Vec<QualifiedBar> {
207    let mut rng = SimpleRng::new(seed);
208    let warmup_bars = config
209        .range_lookback
210        .max(WYCKOFF_MIN_RANGE_LOOKBACK)
211        .saturating_sub(1);
212    let mut bars = Vec::with_capacity(warmup_bars + 6);
213    let center = config.center_price;
214    let spread = config.spread;
215    let base_volume = config.base_volume;
216
217    // 1. Warmup oscillating range bars (contracting range, stable ATR and volume)
218    // Runs for exactly `lookback - 1` bars so range lock evaluates on the subsequent climax bar.
219    for i in 0..warmup_bars {
220        let pattern_offset = ((i % 4) as f64 - 1.5) * spread * 0.15;
221        let noise = rng.next_range(-0.02, 0.02) * spread;
222        let price = center + pattern_offset + noise;
223        let open = price - 0.05 * spread;
224        let close = price + 0.05 * spread;
225        let high = price + spread * 0.2;
226        let low = price - spread * 0.2;
227        let vol = base_volume + rng.next_range(-1.0, 1.0);
228
229        bars.push(synthetic_bar(i as i64 * 60, open, high, low, close, vol));
230    }
231
232    let mut timestamp = warmup_bars as i64 * 60;
233
234    // 2. Climax Bar: Evaluated as the `lookback`-th bar. Outlier volume + directional close
235    // locks the range and picks the bias deterministically.
236    let climax_vol = base_volume * 4.0;
237    let (c_open, c_high, c_low, c_close) = match bias {
238        WyckoffBias::Accumulation => {
239            // Down climax: close < open and close < prev_close
240            (
241                center + 0.2 * spread,
242                center + 0.3 * spread,
243                center - 0.4 * spread,
244                center - 0.3 * spread,
245            )
246        }
247        WyckoffBias::Distribution => {
248            // Up climax: close > open and close > prev_close
249            (
250                center - 0.2 * spread,
251                center + 0.4 * spread,
252                center - 0.3 * spread,
253                center + 0.3 * spread,
254            )
255        }
256    };
257    bars.push(synthetic_bar(
258        timestamp, c_open, c_high, c_low, c_close, climax_vol,
259    ));
260    timestamp += 60;
261
262    // 3. Decisive Test (Phase A/B -> Phase C): Spring (Accumulation) or UTAD (Distribution)
263    match bias {
264        WyckoffBias::Accumulation => {
265            // Spring: low < range_low && close > range_low
266            let s_open = center - 0.2 * spread;
267            let s_low = center - 1.5 * spread;
268            let s_high = center;
269            let s_close = center - 0.1 * spread;
270            bars.push(synthetic_bar(
271                timestamp,
272                s_open,
273                s_high,
274                s_low,
275                s_close,
276                base_volume,
277            ));
278        }
279        WyckoffBias::Distribution => {
280            // UTAD: high > range_high && close < range_high
281            let u_open = center + 0.2 * spread;
282            let u_high = center + 1.5 * spread;
283            let u_low = center;
284            let u_close = center + 0.1 * spread;
285            bars.push(synthetic_bar(
286                timestamp,
287                u_open,
288                u_high,
289                u_low,
290                u_close,
291                base_volume,
292            ));
293        }
294    }
295    timestamp += 60;
296
297    // 4. Breakout (Phase C -> Phase D): SOS (Accumulation) or SOW (Distribution)
298    match bias {
299        WyckoffBias::Accumulation => {
300            // SOS: close > range_high
301            let sos_open = center;
302            let sos_high = center + 1.6 * spread;
303            let sos_low = center - 0.1 * spread;
304            let sos_close = center + 1.5 * spread;
305            bars.push(synthetic_bar(
306                timestamp,
307                sos_open,
308                sos_high,
309                sos_low,
310                sos_close,
311                base_volume * 1.5,
312            ));
313        }
314        WyckoffBias::Distribution => {
315            // SOW: close < range_low
316            let sow_open = center;
317            let sow_low = center - 1.6 * spread;
318            let sow_high = center + 0.1 * spread;
319            let sow_close = center - 1.5 * spread;
320            bars.push(synthetic_bar(
321                timestamp,
322                sow_open,
323                sow_high,
324                sow_low,
325                sow_close,
326                base_volume * 1.5,
327            ));
328        }
329    }
330    timestamp += 60;
331
332    // 5. Confirmation (Phase D -> Phase E): LPS (Accumulation) or LPSY (Distribution)
333    match bias {
334        WyckoffBias::Accumulation => {
335            // LPS: low >= range_high - atr * 0.5 && close > range_high
336            let lps_open = center + 1.2 * spread;
337            let lps_low = center + 0.8 * spread;
338            let lps_high = center + 1.5 * spread;
339            let lps_close = center + 1.3 * spread;
340            bars.push(synthetic_bar(
341                timestamp,
342                lps_open,
343                lps_high,
344                lps_low,
345                lps_close,
346                base_volume,
347            ));
348        }
349        WyckoffBias::Distribution => {
350            // LPSY: high <= range_low + atr * 0.5 && close < range_low
351            let lpsy_open = center - 1.2 * spread;
352            let lpsy_high = center - 0.8 * spread;
353            let lpsy_low = center - 1.5 * spread;
354            let lpsy_close = center - 1.3 * spread;
355            bars.push(synthetic_bar(
356                timestamp,
357                lpsy_open,
358                lpsy_high,
359                lpsy_low,
360                lpsy_close,
361                base_volume,
362            ));
363        }
364    }
365
366    bars
367}
368
369/// Swing direction for BOS / CHoCH structure tests.
370#[derive(Debug, Clone, Copy, PartialEq, Eq)]
371pub enum SwingDirection {
372    Bullish,
373    Bearish,
374}
375
376/// Generates a calibrated swing pivot and subsequent structural break bar sequence.
377///
378/// Produces a symmetrical window of `2 * pivot_len + 1` bars where the bar at index
379/// `pivot_len` is guaranteed to be the pivot peak (for Bullish break) or pivot valley
380/// (for Bearish break), followed immediately by a decisive breakout bar closing beyond
381/// that confirmed pivot level.
382pub fn bos_choch_swing_bars(
383    seed: u64,
384    direction: SwingDirection,
385    pivot_len: usize,
386) -> Vec<QualifiedBar> {
387    let mut rng = SimpleRng::new(seed);
388    let len = pivot_len.max(2);
389    let window_bars = 2 * len + 1;
390    let mut bars = Vec::with_capacity(window_bars + 1);
391
392    let base_price = 100.0;
393    let step_height = 3.0;
394
395    for i in 0..window_bars {
396        let dist = i.abs_diff(len) as f64;
397        let noise = rng.next_range(0.05, 0.2);
398
399        let (open, high, low, close) = match direction {
400            SwingDirection::Bearish => {
401                // Form a pivot low at index `len`
402                let price = if i == len {
403                    base_price
404                } else {
405                    base_price + dist * step_height + noise
406                };
407                (price, price + 0.5, price - 0.5, price)
408            }
409            SwingDirection::Bullish => {
410                // Form a pivot high at index `len`
411                let price = if i == len {
412                    base_price
413                } else {
414                    base_price - dist * step_height - noise
415                };
416                (price, price + 0.5, price - 0.5, price)
417            }
418        };
419
420        bars.push(synthetic_bar(i as i64 * 60, open, high, low, close, 1000.0));
421    }
422
423    // Break bar: closes definitively beyond the pivot formed at index `len`
424    let break_timestamp = window_bars as i64 * 60;
425    let break_bar = match direction {
426        SwingDirection::Bearish => {
427            // Closes lower than pivot valley low (99.5)
428            let close = base_price - step_height * 2.0;
429            synthetic_bar(
430                break_timestamp,
431                base_price,
432                base_price + 0.2,
433                close - 0.5,
434                close,
435                1500.0,
436            )
437        }
438        SwingDirection::Bullish => {
439            // Closes higher than pivot peak high (100.5)
440            let close = base_price + step_height * 2.0;
441            synthetic_bar(
442                break_timestamp,
443                base_price,
444                close + 0.5,
445                base_price - 0.2,
446                close,
447                1500.0,
448            )
449        }
450    };
451    bars.push(break_bar);
452
453    bars
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459    use crate::indicator::bos_choch::BosChochEngine;
460    use crate::indicator::wyckoff::{WyckoffPhase, WyckoffStateMachine};
461    use crate::indicator::Indicator;
462
463    #[test]
464    fn test_rng_determinism() {
465        let mut rng1 = SimpleRng::new(42);
466        let mut rng2 = SimpleRng::new(42);
467        for _ in 0..100 {
468            assert_eq!(rng1.next_u64(), rng2.next_u64());
469            assert_eq!(rng1.next_f64(), rng2.next_f64());
470            assert_eq!(rng1.next_gaussian(), rng2.next_gaussian());
471        }
472    }
473
474    #[test]
475    fn test_random_walk_determinism_and_synthetic_flag() {
476        let bars1 = random_walk_bars(12345, 50, 100.0, 0.05, 1.0, 1000.0);
477        let bars2 = random_walk_bars(12345, 50, 100.0, 0.05, 1.0, 1000.0);
478
479        assert_eq!(bars1.len(), 50);
480        assert_eq!(bars1, bars2);
481        for qb in &bars1 {
482            assert!(qb.quality.is_synthetic);
483            assert!(qb.quality.volume_available);
484            assert!(qb.bar.validate().is_ok());
485        }
486    }
487
488    #[test]
489    fn test_trending_bars_determinism_and_synthetic_flag() {
490        let bars = trending_bars(999, 40, 50.0, 0.5, 0.2, 500.0);
491        assert_eq!(bars.len(), 40);
492        assert!(bars.last().unwrap().bar.close > 50.0);
493        for qb in &bars {
494            assert!(qb.quality.is_synthetic);
495            assert!(qb.bar.validate().is_ok());
496        }
497    }
498
499    #[test]
500    fn test_wyckoff_schematic_accumulation_reaches_phase_e() {
501        let bars = wyckoff_schematic_bars(
502            42,
503            WyckoffBias::Accumulation,
504            WyckoffGeneratorConfig::default(),
505        );
506
507        let mut machine = WyckoffStateMachine::new(20, 5.0, 3);
508        for qb in &bars {
509            machine.on_bar(&qb.bar);
510        }
511
512        assert_eq!(machine.bias(), Some(WyckoffBias::Accumulation));
513        assert_eq!(machine.phase(), WyckoffPhase::E);
514        let score = machine.score();
515        assert!(
516            score.sequence_quality >= 0.5,
517            "Sequence quality was {}",
518            score.sequence_quality
519        );
520    }
521
522    #[test]
523    fn test_wyckoff_schematic_distribution_reaches_phase_e() {
524        let bars = wyckoff_schematic_bars(
525            42,
526            WyckoffBias::Distribution,
527            WyckoffGeneratorConfig::default(),
528        );
529
530        let mut machine = WyckoffStateMachine::new(20, 5.0, 3);
531        for qb in &bars {
532            machine.on_bar(&qb.bar);
533        }
534
535        assert_eq!(machine.bias(), Some(WyckoffBias::Distribution));
536        assert_eq!(machine.phase(), WyckoffPhase::E);
537        let score = machine.score();
538        assert!(
539            score.sequence_quality >= 0.5,
540            "Sequence quality was {}",
541            score.sequence_quality
542        );
543    }
544
545    #[test]
546    fn test_bos_choch_swing_bars_bullish() {
547        let pivot_len = 3;
548        let bars = bos_choch_swing_bars(101, SwingDirection::Bullish, pivot_len);
549        let mut engine = BosChochEngine::new(pivot_len);
550
551        let mut event_codes = Vec::new();
552        for qb in &bars {
553            if let Some(out) = engine.on_bar(&qb.bar) {
554                event_codes.push(out.value);
555            }
556        }
557
558        assert!(
559            event_codes.iter().any(|&c| c > 0.0),
560            "Bullish swing bars must trigger Bullish BOS/CHoCH event (> 0)"
561        );
562    }
563
564    #[test]
565    fn test_bos_choch_swing_bars_bearish() {
566        let pivot_len = 3;
567        let bars = bos_choch_swing_bars(101, SwingDirection::Bearish, pivot_len);
568        let mut engine = BosChochEngine::new(pivot_len);
569
570        let mut event_codes = Vec::new();
571        for qb in &bars {
572            if let Some(out) = engine.on_bar(&qb.bar) {
573                event_codes.push(out.value);
574            }
575        }
576
577        assert!(
578            event_codes.iter().any(|&c| c < 0.0),
579            "Bearish swing bars must trigger Bearish BOS/CHoCH event (< 0)"
580        );
581    }
582}
583
584// ---------------------------------------------------------------------------
585// Pattern constructors
586// ---------------------------------------------------------------------------
587//
588// The generators above answer "what does a market series look like". These answer the inverse
589// question: "which series contains *this* pattern, at *this* place". Documentation and teaching
590// material needs the inverse — a pattern is constructed on purpose rather than hunted for.
591//
592// They are deliberately calculation, not drawing: a renderer that assembled its own bars would be
593// a second source of truth next to the detectors that are supposed to confirm them.
594
595/// A single candle described by scale-free ratios instead of absolute prices.
596///
597/// A twenty-point wick is large at an ATR of thirty and irrelevant at four hundred. Candle
598/// definitions are therefore stated as ratios, and this struct is that statement made explicit —
599/// which also makes it invertible: [`bar_from_shape`] turns the condition back into a bar that
600/// satisfies it.
601///
602/// The three ratios describe how the range is divided and are normalised to sum to one, so
603/// `CandleShape { body_ratio: 2.0, upper_wick_ratio: 1.0, lower_wick_ratio: 1.0, .. }` and
604/// `{ 0.5, 0.25, 0.25 }` describe the same candle.
605#[derive(Debug, Clone, Copy, PartialEq)]
606pub struct CandleShape {
607    /// `|close - open| / (high - low)`.
608    pub body_ratio: f64,
609    /// `(high - max(open, close)) / (high - low)`.
610    pub upper_wick_ratio: f64,
611    /// `(min(open, close) - low) / (high - low)`.
612    pub lower_wick_ratio: f64,
613    /// `(high - low) / atr` — how large the candle is relative to recent volatility.
614    pub relative_range: f64,
615    /// `close > open`.
616    pub bullish: bool,
617}
618
619impl CandleShape {
620    /// A candle with the given body ratio, the remaining range split evenly between the wicks.
621    pub fn with_body(body_ratio: f64, relative_range: f64, bullish: bool) -> Self {
622        let rest = (1.0 - body_ratio.clamp(0.0, 1.0)) / 2.0;
623        Self {
624            body_ratio: body_ratio.clamp(0.0, 1.0),
625            upper_wick_ratio: rest,
626            lower_wick_ratio: rest,
627            relative_range,
628            bullish,
629        }
630    }
631
632    /// The three range shares, normalised to sum to one.
633    ///
634    /// Returns an even three-way split for a degenerate all-zero input rather than dividing by
635    /// zero — a candle with no range is not expressible as OHLC anyway.
636    fn normalised(&self) -> (f64, f64, f64) {
637        let body = self.body_ratio.max(0.0);
638        let upper = self.upper_wick_ratio.max(0.0);
639        let lower = self.lower_wick_ratio.max(0.0);
640        let sum = body + upper + lower;
641        if sum <= f64::EPSILON {
642            return (1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0);
643        }
644        (body / sum, upper / sum, lower / sum)
645    }
646}
647
648/// Builds the bar that satisfies a [`CandleShape`], opening at `open_price`.
649///
650/// This is the normalisation of the candle metrics read backwards. A "hammer" stops being a bar
651/// that happens to look like one and becomes the condition itself, solved for OHLC — which is what
652/// makes a documentation figure reproducible instead of drawn.
653///
654/// The bar opens at `open_price` so it can be appended to a running series; `atr` sets its size.
655pub fn bar_from_shape(
656    timestamp: i64,
657    shape: &CandleShape,
658    open_price: f64,
659    atr: f64,
660    volume: f64,
661) -> QualifiedBar {
662    let (body_ratio, upper_ratio, lower_ratio) = shape.normalised();
663    let range = (shape.relative_range * atr).max(f64::EPSILON);
664    let body = body_ratio * range;
665    let upper = upper_ratio * range;
666    let lower = lower_ratio * range;
667
668    let (open, high, low, close) = if shape.bullish {
669        let open = open_price;
670        let close = open + body;
671        (open, close + upper, open - lower, close)
672    } else {
673        let open = open_price;
674        let close = open - body;
675        (open, open + upper, close - lower, close)
676    };
677
678    synthetic_bar(timestamp, open, high, low, close, volume.max(0.0))
679}
680
681/// A turning point of a constructed series: the bar index it falls on and its price.
682///
683/// Whether it is a peak or a trough follows from its neighbours and is not stated separately —
684/// a pivot list that disagreed with itself about direction would be the first thing to go wrong.
685pub type Pivot = (usize, f64);
686
687/// How much of the room between a bar and the pivot it heads towards may be spent on that bar.
688///
689/// Below one, a bar cannot reach past the pivot it is approaching. The remaining slack keeps the
690/// margin visible rather than hairline.
691const PIVOT_PATH_BUDGET: f64 = 0.9;
692
693/// Upper bound on a bar's excursion in units of the local per-bar step, regardless of headroom.
694///
695/// Without it a long leg would grow ever larger bars towards its middle. Two and a half steps is
696/// roughly the ratio between bar range and bar-to-bar drift that an ordinary series shows.
697const PIVOT_PATH_MAX_STEPS: f64 = 2.5;
698
699/// Builds a bar series that passes through the given pivots, in order.
700///
701/// A chart pattern is a condition on consecutive pivots, so a series containing a given pattern is
702/// a pivot list: a head and shoulders is five pivots, a double top is three, a flag is an impulse
703/// plus a narrow counter-channel. This turns the pattern definition into the series that satisfies
704/// it, rather than searching for one.
705///
706/// Two properties hold by construction, and they are what a detector needs:
707///
708/// - the extreme of each pivot bar equals the pivot price exactly — the high at a peak, the low at
709///   a trough, with the body sitting on the inside;
710/// - no other bar between two pivots reaches past either of them.
711///
712/// The second is bought by scaling each bar to **its own distance from the nearer pivot**: a bar
713/// one step away from a peak may only be small, one in the middle of a leg may be large. A single
714/// global bound would have to assume the tightest spot everywhere and would draw a ruler instead
715/// of a chart. Without the property the series would contain a different pattern than the one it
716/// was built from — the one failure mode that goes unnoticed, because the picture still looks
717/// right.
718///
719/// `liveliness` runs from 0 (a clean path) to 1 (as much movement as the bound allows) and is
720/// clamped to that range. Pivot indices must be strictly increasing; anything else yields an empty
721/// series rather than a silently wrong one.
722pub fn bars_from_pivots(
723    seed: u64,
724    pivots: &[Pivot],
725    liveliness: f64,
726    volume: f64,
727) -> Vec<QualifiedBar> {
728    if pivots.len() < 2 || pivots.windows(2).any(|w| w[0].0 >= w[1].0) {
729        return Vec::new();
730    }
731
732    let lively = liveliness.clamp(0.0, 1.0);
733    let mut rng = SimpleRng::new(seed);
734    let last = pivots[pivots.len() - 1].0;
735    let mut bars = Vec::with_capacity(last + 1);
736    let mut segment = 0usize;
737
738    for i in 0..=last {
739        while segment + 1 < pivots.len() && i > pivots[segment + 1].0 {
740            segment += 1;
741        }
742        let (from_i, from_p) = pivots[segment];
743        let (to_i, to_p) = pivots[segment + 1];
744        let span = (to_i - from_i) as f64;
745        let step = (to_p - from_p).abs() / span;
746        let t = (i - from_i) as f64 / span;
747        let path = from_p + (to_p - from_p) * t;
748        let rising = to_p > from_p;
749
750        // Distance to the nearer of the two pivots, in bars. That distance is the headroom.
751        let distance = (i - from_i).min(to_i - i) as f64;
752        let room = (PIVOT_PATH_BUDGET * distance * step).min(PIVOT_PATH_MAX_STEPS * step) * lively;
753
754        let jitter = rng.next_gaussian().clamp(-1.0, 1.0) * room * 0.35;
755        let half_body = rng.next_range(0.35, 1.0) * room * 0.30;
756        let upper_wick = rng.next_f64() * room * 0.30;
757        let lower_wick = rng.next_f64() * room * 0.30;
758        let bar_volume = (volume + rng.next_range(-0.05, 0.05) * volume).max(0.0);
759
760        // The pivot bar has no headroom but still needs a body. It gets the one a bar a single
761        // step away would have, laid entirely on the inside.
762        let pivot_body = PIVOT_PATH_BUDGET * step * 0.35;
763        let (open, high, low, close) = match pivot_is_peak(pivots, i) {
764            Some(true) => (
765                path - pivot_body,
766                path,
767                path - pivot_body - lower_wick,
768                path - pivot_body * 0.4,
769            ),
770            Some(false) => (
771                path + pivot_body,
772                path + pivot_body + upper_wick,
773                path,
774                path + pivot_body * 0.4,
775            ),
776            None => {
777                let center = path + jitter;
778                let (open, close) = if rising {
779                    (center - half_body, center + half_body)
780                } else {
781                    (center + half_body, center - half_body)
782                };
783                (
784                    open,
785                    open.max(close) + upper_wick,
786                    open.min(close) - lower_wick,
787                    close,
788                )
789            }
790        };
791
792        bars.push(synthetic_bar(
793            i as i64 * 60,
794            open,
795            high,
796            low,
797            close,
798            bar_volume,
799        ));
800    }
801
802    bars
803}
804
805/// `Some(true)` if bar `i` is a peak pivot, `Some(false)` for a trough, `None` if it is neither.
806///
807/// The first and last pivot have only one neighbour; their direction follows from that side alone.
808fn pivot_is_peak(pivots: &[Pivot], i: usize) -> Option<bool> {
809    let at = pivots.iter().position(|(index, _)| *index == i)?;
810    let price = pivots[at].1;
811    let neighbour = if at == 0 {
812        pivots[1].1
813    } else {
814        pivots[at - 1].1
815    };
816    Some(price > neighbour)
817}
818
819/// The three ratios that distinguish one harmonic pattern from another.
820///
821/// The patterns do not differ in shape — every one of them is a five-point zigzag. They differ in
822/// these numbers, which is why a figure for them is worth generating from the same table the
823/// documentation prints rather than drawing twice.
824#[derive(Debug, Clone, Copy, PartialEq)]
825pub struct HarmonicRatios {
826    /// How far B retraces the XA leg.
827    pub b: f64,
828    /// How far C retraces the AB leg.
829    pub c: f64,
830    /// Where D sits relative to XA — below one it stays inside XA, above one it extends past X.
831    pub d: f64,
832}
833
834impl HarmonicRatios {
835    /// Gartley: B at 0.618 of XA, D at 0.786 — the retracement case, D stays inside XA.
836    pub const GARTLEY: Self = Self {
837        b: 0.618,
838        c: 0.5,
839        d: 0.786,
840    };
841    /// Bat: a shallower B and a deeper D than Gartley, still a retracement.
842    pub const BAT: Self = Self {
843        b: 0.5,
844        c: 0.5,
845        d: 0.886,
846    };
847    /// Butterfly: D extends past X — the extension case.
848    pub const BUTTERFLY: Self = Self {
849        b: 0.786,
850        c: 0.5,
851        d: 1.27,
852    };
853}
854
855/// The five prices X, A, B, C, D of a harmonic structure, from its ratio table.
856///
857/// ```text
858/// B = A − b · (A − X)
859/// C = B + c · (A − B)
860/// D = A − d · (A − X)
861/// ```
862///
863/// Works in both directions: a bullish structure has `A > X`, a bearish one `A < X`, and the
864/// signs carry through unchanged.
865pub fn xabcd_prices(x: f64, a: f64, ratios: &HarmonicRatios) -> [f64; 5] {
866    let xa = a - x;
867    let b = a - ratios.b * xa;
868    let c = b + ratios.c * (a - b);
869    let d = a - ratios.d * xa;
870    [x, a, b, c, d]
871}
872
873/// The same five points as a pivot list at regular spacing, ready for [`bars_from_pivots`].
874///
875/// `spacing` is the number of bars between consecutive points. `start` shifts the whole structure
876/// so it can be placed inside a longer series.
877pub fn xabcd_pivots(
878    start: usize,
879    spacing: usize,
880    x: f64,
881    a: f64,
882    ratios: &HarmonicRatios,
883) -> Vec<Pivot> {
884    xabcd_prices(x, a, ratios)
885        .into_iter()
886        .enumerate()
887        .map(|(i, price)| (start + i * spacing.max(1), price))
888        .collect()
889}