use std::collections::VecDeque;
use crate::error::{Error, Result};
use crate::ohlcv::Candle;
use crate::traits::Indicator;
#[derive(Debug, Clone)]
pub struct ProfileShape {
period: usize,
bins: usize,
window: VecDeque<Candle>,
last: Option<f64>,
}
impl ProfileShape {
pub fn new(period: usize, bins: usize) -> Result<Self> {
if period == 0 {
return Err(Error::PeriodZero);
}
if bins < 3 {
return Err(Error::InvalidPeriod {
message: "profile shape needs bins >= 3",
});
}
Ok(Self {
period,
bins,
window: VecDeque::with_capacity(period),
last: None,
})
}
pub const fn params(&self) -> (usize, usize) {
(self.period, self.bins)
}
pub const fn value(&self) -> Option<f64> {
self.last
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
fn poc_index(&self) -> usize {
let mut low = f64::INFINITY;
let mut high = f64::NEG_INFINITY;
for c in &self.window {
low = low.min(c.low);
high = high.max(c.high);
}
let mut hist = vec![0.0; self.bins];
let span = high - low;
if span > 0.0 {
let width = span / self.bins as f64;
for c in &self.window {
if c.volume == 0.0 {
continue;
}
let lo_idx = (((c.low - low) / width).floor() as usize).min(self.bins - 1);
let hi_idx = (((c.high - low) / width).floor() as usize).min(self.bins - 1);
let share = c.volume / (hi_idx - lo_idx + 1) as f64;
for bin in hist.iter_mut().take(hi_idx + 1).skip(lo_idx) {
*bin += share;
}
}
}
let mut poc_idx = 0;
let mut poc_vol = f64::NEG_INFINITY;
for (idx, &vol) in hist.iter().enumerate() {
if vol > poc_vol {
poc_vol = vol;
poc_idx = idx;
}
}
poc_idx
}
}
impl Indicator for ProfileShape {
type Input = Candle;
type Output = f64;
fn update(&mut self, candle: Candle) -> Option<f64> {
if self.window.len() == self.period {
self.window.pop_front();
}
self.window.push_back(candle);
if self.window.len() < self.period {
return None;
}
let poc = self.poc_index();
let lower = self.bins / 3;
let upper = self.bins - self.bins / 3;
let shape = if poc >= upper {
1.0
} else if poc < lower {
-1.0
} else {
0.0
};
self.last = Some(shape);
Some(shape)
}
fn reset(&mut self) {
self.window.clear();
self.last = None;
}
fn warmup_period(&self) -> usize {
self.period
}
fn is_ready(&self) -> bool {
self.last.is_some()
}
fn name(&self) -> &'static str {
"ProfileShape"
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::traits::BatchExt;
fn c(high: f64, low: f64, volume: f64) -> Candle {
Candle::new_unchecked(
f64::midpoint(high, low),
high,
low,
f64::midpoint(high, low),
volume,
0,
)
}
#[test]
fn rejects_invalid_params() {
assert!(matches!(ProfileShape::new(0, 24), Err(Error::PeriodZero)));
assert!(matches!(
ProfileShape::new(20, 2),
Err(Error::InvalidPeriod { .. })
));
}
#[test]
fn accessors_and_metadata() {
let p = ProfileShape::new(20, 24).unwrap();
assert_eq!(p.params(), (20, 24));
assert_eq!(p.warmup_period(), 20);
assert_eq!(p.name(), "ProfileShape");
assert!(!p.is_ready());
assert_eq!(p.value(), None);
}
#[test]
fn first_emission_at_warmup_period() {
let mut p = ProfileShape::new(4, 9).unwrap();
let candles: Vec<Candle> = (0..6).map(|_| c(110.0, 90.0, 1_000.0)).collect();
let out = p.batch(&candles);
for v in out.iter().take(3) {
assert!(v.is_none());
}
assert!(out[3].is_some());
}
#[test]
fn heavy_top_is_p_shape() {
let mut p = ProfileShape::new(6, 9).unwrap();
let mut candles: Vec<Candle> = (0..5).map(|_| c(119.0, 117.0, 5_000.0)).collect();
candles.push(c(119.0, 80.0, 50.0)); let last = p.batch(&candles).into_iter().flatten().last().unwrap();
assert_eq!(last, 1.0);
}
#[test]
fn heavy_bottom_is_b_shape() {
let mut p = ProfileShape::new(6, 9).unwrap();
let mut candles: Vec<Candle> = (0..5).map(|_| c(83.0, 81.0, 5_000.0)).collect();
candles.push(c(120.0, 81.0, 50.0)); let last = p.batch(&candles).into_iter().flatten().last().unwrap();
assert_eq!(last, -1.0);
}
#[test]
fn balanced_is_d_shape() {
let mut p = ProfileShape::new(6, 9).unwrap();
let mut candles: Vec<Candle> = (0..5).map(|_| c(101.0, 99.0, 5_000.0)).collect();
candles.push(c(120.0, 80.0, 50.0)); let last = p.batch(&candles).into_iter().flatten().last().unwrap();
assert_eq!(last, 0.0);
}
#[test]
fn reset_clears_state() {
let mut p = ProfileShape::new(4, 9).unwrap();
p.batch(&[c(110.0, 90.0, 1_000.0); 6]);
assert!(p.is_ready());
p.reset();
assert!(!p.is_ready());
assert_eq!(p.value(), None);
assert_eq!(p.update(c(110.0, 90.0, 1_000.0)), None);
}
#[test]
fn batch_equals_streaming() {
let candles: Vec<Candle> = (0..80)
.map(|i| {
c(
110.0 + (f64::from(i) * 0.25).sin() * 9.0,
90.0,
1_000.0 + f64::from(i),
)
})
.collect();
let batch = ProfileShape::new(20, 24).unwrap().batch(&candles);
let mut b = ProfileShape::new(20, 24).unwrap();
let streamed: Vec<_> = candles.iter().map(|x| b.update(*x)).collect();
assert_eq!(batch, streamed);
}
#[test]
fn flat_window_is_handled() {
let mut p = ProfileShape::new(2, 4).unwrap();
p.update(c(50.0, 50.0, 10.0));
assert!(p.update(c(50.0, 50.0, 10.0)).is_some());
}
#[test]
fn zero_volume_window_is_handled() {
let mut p = ProfileShape::new(2, 4).unwrap();
p.update(c(60.0, 40.0, 0.0));
assert!(p.update(c(60.0, 40.0, 0.0)).is_some());
}
}