kestrel_chartkit/indicator/bbtrend.rs
1use std::collections::HashMap;
2
3use crate::model::Bar;
4
5use super::bollinger::{BollingerBands, VarianceConvention};
6use super::{Indicator, IndicatorOutput};
7
8/// BBTrend: how far a short Bollinger set has moved out of a long one.
9///
10/// Two band sets over the same prices, one short and one long, with the same multiplier and the
11/// same variance convention. The published value compares how far each edge of the short set sits
12/// from the matching edge of the long set:
13///
14/// ```text
15/// BBTrend = 100 * (|lower_short - lower_long| - |upper_short - upper_long|) / basis_short
16/// ```
17///
18/// Positive means the short set has pushed further out at the top than at the bottom — the recent
19/// window is trending up relative to the longer one — and negative the reverse. Around zero the
20/// two sets sit concentrically, which is what a range looks like.
21///
22/// Both sets come from the same [`BollingerBands`] engine this crate already has, so there is one
23/// band calculation, not a second one written for this indicator. The variance convention applies
24/// to both sets: comparing a population-based set against a sample-based one would measure the
25/// convention rather than the market.
26///
27/// Unit: percent of the short basis. Division by a zero basis cannot arise from valid bars, whose
28/// prices are positive; should it, the value is `0`.
29///
30/// Per-bar outputs:
31/// - `value`: the BBTrend line.
32/// - `extra["upper_gap"]` / `extra["lower_gap"]`: the two absolute edge distances it is built
33/// from, so a reader can see which side moved.
34///
35/// First output: with the `long_len`-th bar, when both sets exist. [`Indicator::reset`] clears
36/// both.
37#[derive(Debug, Clone)]
38pub struct BbTrend {
39 short: BollingerBands,
40 long: BollingerBands,
41 long_len: usize,
42}
43
44impl BbTrend {
45 pub fn new(short_len: usize, long_len: usize, mult: f64, variance: VarianceConvention) -> Self {
46 Self {
47 short: BollingerBands::new(short_len, mult).with_variance(variance),
48 long: BollingerBands::new(long_len, mult).with_variance(variance),
49 long_len,
50 }
51 }
52
53 pub fn with_defaults() -> Self {
54 Self::new(20, 50, 2.0, VarianceConvention::Population)
55 }
56}
57
58impl Indicator for BbTrend {
59 fn name(&self) -> &str {
60 "bbtrend"
61 }
62
63 fn warmup_period(&self) -> usize {
64 self.long_len
65 }
66
67 fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
68 let short = self.short.on_bar(bar);
69 let long = self.long.on_bar(bar);
70 let (short, long) = (short?, long?);
71
72 let upper_gap = (short.extra["upper"] - long.extra["upper"]).abs();
73 let lower_gap = (short.extra["lower"] - long.extra["lower"]).abs();
74 let basis = short.value;
75
76 let value = if basis != 0.0 {
77 100.0 * (lower_gap - upper_gap) / basis
78 } else {
79 0.0
80 };
81
82 let extra = HashMap::from([
83 ("upper_gap".to_string(), upper_gap),
84 ("lower_gap".to_string(), lower_gap),
85 ]);
86 Some(IndicatorOutput::with_extra(value, extra))
87 }
88
89 fn reset(&mut self) {
90 self.short.reset();
91 self.long.reset();
92 }
93}