#![allow(clippy::doc_markdown)]
use crate::ohlcv::Candle;
use crate::traits::Indicator;
fn body_top(candle: Candle) -> f64 {
candle.open.max(candle.close)
}
fn body_bottom(candle: Candle) -> f64 {
candle.open.min(candle.close)
}
fn is_doji(candle: Candle) -> bool {
let body = (candle.close - candle.open).abs();
let range = candle.high - candle.low;
range > 0.0 && body <= 0.1 * range
}
#[derive(Debug, Clone, Default)]
pub struct Tristar {
c1: Option<Candle>,
c2: Option<Candle>,
last_value: Option<f64>,
}
impl Tristar {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub const fn value(&self) -> Option<f64> {
self.last_value
}
}
impl Indicator for Tristar {
type Input = Candle;
type Output = f64;
#[inline]
fn update(&mut self, candle: Candle) -> Option<f64> {
let (Some(first), Some(middle)) = (self.c1, self.c2) else {
self.c1 = self.c2;
self.c2 = Some(candle);
self.last_value = None;
return None;
};
let v = if is_doji(first) && is_doji(middle) && is_doji(candle) {
if body_bottom(middle) > body_top(first) && body_top(candle) < body_top(middle) {
-1.0
} else if body_top(middle) < body_bottom(first)
&& body_bottom(candle) > body_bottom(middle)
{
1.0
} else {
0.0
}
} else {
0.0
};
self.c1 = self.c2;
self.c2 = Some(candle);
self.last_value = Some(v);
Some(v)
}
fn reset(&mut self) {
self.c1 = None;
self.c2 = None;
self.last_value = None;
}
#[inline]
fn warmup_period(&self) -> usize {
3
}
#[inline]
fn is_ready(&self) -> bool {
self.last_value.is_some()
}
#[inline]
fn name(&self) -> &'static str {
"Tristar"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn doji(mid: f64) -> Candle {
Candle::new_unchecked(mid, mid + 1.0, mid - 1.0, mid + 0.02, 0.0, 0)
}
fn solid(open: f64, close: f64) -> Candle {
Candle::new_unchecked(
open,
open.max(close) + 0.1,
open.min(close) - 0.1,
close,
0.0,
0,
)
}
#[test]
fn accessors_and_metadata() {
let t = Tristar::new();
assert_eq!(t.warmup_period(), 3);
assert_eq!(t.name(), "Tristar");
assert!(!t.is_ready());
assert_eq!(t.value(), None);
}
#[test]
fn first_two_bars_seed_without_signal() {
let mut t = Tristar::new();
assert_eq!(t.update(doji(100.0)), None);
assert_eq!(t.update(doji(100.0)), None);
assert!(t.update(doji(100.0)).is_some());
}
#[test]
fn bearish_tristar_top() {
let mut t = Tristar::new();
t.update(doji(100.0));
t.update(doji(105.0)); assert_eq!(t.update(doji(100.0)), Some(-1.0));
}
#[test]
fn bullish_tristar_bottom() {
let mut t = Tristar::new();
t.update(doji(100.0));
t.update(doji(95.0)); assert_eq!(t.update(doji(100.0)), Some(1.0));
}
#[test]
fn non_doji_is_zero() {
let mut t = Tristar::new();
t.update(doji(100.0));
t.update(solid(100.0, 110.0)); assert_eq!(t.update(doji(100.0)), Some(0.0));
}
#[test]
fn reset_clears_state() {
let mut t = Tristar::new();
t.update(doji(100.0));
t.update(doji(105.0));
t.update(doji(100.0));
assert!(t.is_ready());
t.reset();
assert!(!t.is_ready());
assert_eq!(t.update(doji(100.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..40)
.map(|i| doji(100.0 + (f64::from(i) * 0.4).sin() * 5.0))
.collect();
let batch = Tristar::new().batch(&candles);
let mut b = Tristar::new();
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
fn doji_body(open: f64, close: f64) -> Candle {
let mid = f64::midpoint(open, close);
Candle::new_unchecked(open, mid + 1.0, mid - 1.0, close, 0.0, 0)
}
fn run(bars: [Candle; 3]) -> Option<f64> {
let mut t = Tristar::new();
bars.iter().map(|b| t.update(*b)).last().unwrap()
}
#[test]
fn hand_computed_body_gap_with_overlapping_shadows() {
assert_eq!(run([doji(100.0), doji(100.5), doji(100.1)]), Some(-1.0));
assert_eq!(run([doji(100.0), doji(99.5), doji(99.9)]), Some(1.0));
}
#[test]
fn bearish_gap_rules() {
let first = doji_body(100.0, 100.02);
assert_eq!(
run([first, doji_body(100.02, 100.04), doji(100.0)]),
Some(0.0)
);
assert_eq!(run([doji(100.0), doji(105.0), doji(105.5)]), Some(0.0));
assert_eq!(run([doji(100.0), doji(105.0), doji(105.0)]), Some(0.0));
}
#[test]
fn bullish_gap_rules() {
let first = doji_body(100.0, 100.02);
assert_eq!(
run([first, doji_body(99.98, 100.0), doji(100.0)]),
Some(0.0)
);
assert_eq!(run([doji(100.0), doji(95.0), doji(94.5)]), Some(0.0));
assert_eq!(run([doji(100.0), doji(95.0), doji(95.0)]), Some(0.0));
}
#[test]
fn every_bar_must_be_a_doji() {
assert_eq!(
run([solid(100.0, 101.0), doji(105.0), doji(100.0)]),
Some(0.0)
);
assert_eq!(
run([doji(100.0), doji(105.0), solid(100.0, 99.0)]),
Some(0.0)
);
assert_eq!(
run([doji(100.0), doji(95.0), solid(100.0, 101.0)]),
Some(0.0)
);
let flat = Candle::new_unchecked(105.0, 105.0, 105.0, 105.0, 0.0, 0);
assert_eq!(run([doji(100.0), flat, doji(100.0)]), Some(0.0));
}
#[test]
fn doji_threshold_is_ten_percent_of_range() {
assert!(is_doji(Candle::new_unchecked(
100.0, 101.25, 98.75, 100.25, 0.0, 0
)));
assert!(!is_doji(Candle::new_unchecked(
100.0, 101.25, 98.75, 100.5, 0.0, 0
)));
assert!(!is_doji(Candle::new_unchecked(
100.0, 100.0, 100.0, 100.0, 0.0, 0
)));
}
#[test]
fn first_value_lands_exactly_at_warmup_index() {
let mut t = Tristar::new();
let out = t.batch(&[doji(100.0), doji(105.0), doji(100.0), doji(100.0)]);
let warm = t.warmup_period();
assert!(out[..warm - 1].iter().all(Option::is_none));
assert_eq!(out[warm - 1], Some(-1.0));
assert_eq!(t.value(), Some(0.0));
}
fn mixed_series() -> Vec<Candle> {
let bars = [
doji(100.0),
doji(105.0),
doji(100.0),
doji(95.0),
doji(100.0),
solid(100.0, 104.0),
];
bars.iter().cycle().take(30).copied().collect()
}
#[test]
fn reset_replays_identically() {
let candles = mixed_series();
let fresh = Tristar::new().batch(&candles);
let mut t = Tristar::new();
let _ = t.batch(&candles);
t.reset();
assert_eq!(t.value(), None);
assert_eq!(t.batch(&candles), fresh);
}
#[test]
fn batch_nan_into_matches_streaming_bits() {
let candles = mixed_series();
let mut t = Tristar::new();
let streamed: Vec<f64> = candles
.iter()
.map(|x| t.update(*x).unwrap_or(f64::NAN))
.collect();
let mut out = vec![0.0; candles.len()];
Tristar::new().batch_nan_into(&candles, &mut out);
assert!(streamed
.iter()
.zip(&out)
.all(|(a, b)| a.to_bits() == b.to_bits()));
assert!(streamed.contains(&1.0) && streamed.contains(&-1.0));
}
}