1use crate::indicator::wyckoff::WyckoffBias;
11use crate::model::{Bar, BarQuality, QualifiedBar};
12
13#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct SimpleRng {
19 state: u64,
20}
21
22impl SimpleRng {
23 pub fn new(seed: u64) -> Self {
25 Self { state: seed }
26 }
27
28 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 pub fn next_f64(&mut self) -> f64 {
39 (self.next_u64() >> 11) as f64 * (1.0 / (1u64 << 53) as f64)
40 }
41
42 pub fn next_range(&mut self, min: f64, max: f64) -> f64 {
44 min + (max - min) * self.next_f64()
45 }
46
47 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
56fn 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
76pub 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
115pub 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
154const WYCKOFF_MIN_RANGE_LOOKBACK: usize = 20;
164
165#[derive(Debug, Clone, Copy, PartialEq)]
167pub struct WyckoffGeneratorConfig {
168 pub center_price: f64,
170 pub range_lookback: usize,
175 pub spread: f64,
177 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
192pub 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 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 let climax_vol = base_volume * 4.0;
237 let (c_open, c_high, c_low, c_close) = match bias {
238 WyckoffBias::Accumulation => {
239 (
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 (
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 match bias {
264 WyckoffBias::Accumulation => {
265 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 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 match bias {
299 WyckoffBias::Accumulation => {
300 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 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 match bias {
334 WyckoffBias::Accumulation => {
335 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
371pub enum SwingDirection {
372 Bullish,
373 Bearish,
374}
375
376pub 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 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 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 let break_timestamp = window_bars as i64 * 60;
425 let break_bar = match direction {
426 SwingDirection::Bearish => {
427 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 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#[derive(Debug, Clone, Copy, PartialEq)]
606pub struct CandleShape {
607 pub body_ratio: f64,
609 pub upper_wick_ratio: f64,
611 pub lower_wick_ratio: f64,
613 pub relative_range: f64,
615 pub bullish: bool,
617}
618
619impl CandleShape {
620 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 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
648pub 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
681pub type Pivot = (usize, f64);
686
687const PIVOT_PATH_BUDGET: f64 = 0.9;
692
693const PIVOT_PATH_MAX_STEPS: f64 = 2.5;
698
699pub 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 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 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
805fn 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#[derive(Debug, Clone, Copy, PartialEq)]
825pub struct HarmonicRatios {
826 pub b: f64,
828 pub c: f64,
830 pub d: f64,
832}
833
834impl HarmonicRatios {
835 pub const GARTLEY: Self = Self {
837 b: 0.618,
838 c: 0.5,
839 d: 0.786,
840 };
841 pub const BAT: Self = Self {
843 b: 0.5,
844 c: 0.5,
845 d: 0.886,
846 };
847 pub const BUTTERFLY: Self = Self {
849 b: 0.786,
850 c: 0.5,
851 d: 1.27,
852 };
853}
854
855pub 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
873pub 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}