use std::{collections::VecDeque, fmt::Debug};
use chrono::{DateTime, Duration, Utc};
use crate::{
data::{
domain::Price,
event::{IndexedOhlcv, MarketEvent, Ohlcv},
},
math::StreamingIndicator,
};
const LHS: usize = 0;
const RHS: usize = 2;
const PATTERN_LENGTH: usize = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TtlPolicy {
Bars(usize),
Time(Duration),
#[default]
Filled,
}
pub trait FairValueGapState: Debug + Clone + Send + Sync + 'static {}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct OpenState {
max_fill_percentage: f64,
touch_count: u32,
}
impl OpenState {
pub fn max_fill_percentage(&self) -> f64 {
self.max_fill_percentage
}
pub fn touch_count(&self) -> u32 {
self.touch_count
}
}
impl FairValueGapState for OpenState {}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ClosedState {
closed_time: DateTime<Utc>,
touch_count: u32,
}
impl ClosedState {
pub fn closed_time(&self) -> DateTime<Utc> {
self.closed_time
}
pub const fn max_fill_percentage(&self) -> f64 {
1.0
}
pub fn touch_count(&self) -> u32 {
self.touch_count
}
}
impl FairValueGapState for ClosedState {}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ExpiredState {
expired_time: DateTime<Utc>,
touch_count: u32,
final_fill_percentage: f64,
}
impl ExpiredState {
pub fn expired_time(&self) -> DateTime<Utc> {
self.expired_time
}
pub fn final_fill_percentage(&self) -> f64 {
self.final_fill_percentage
}
pub fn touch_count(&self) -> u32 {
self.touch_count
}
}
impl FairValueGapState for ExpiredState {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FairValueGapDirection {
Bullish,
Bearish,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GapInteraction {
Miss,
Touch,
Fill,
}
impl GapInteraction {
pub fn is_touch(&self) -> bool {
matches!(self, Self::Touch | Self::Fill)
}
pub fn is_fill(&self) -> bool {
matches!(self, Self::Fill)
}
}
#[derive(Debug, Clone, Copy)]
pub struct FairValueGap<S: FairValueGapState> {
direction: FairValueGapDirection,
creation_time: DateTime<Utc>,
creation_index: usize,
top: Price,
bottom: Price,
state: S,
}
#[derive(Debug, Clone, Copy)]
pub enum FairValueGapStatus {
Open(FairValueGap<OpenState>),
Closed(FairValueGap<ClosedState>),
Expired(FairValueGap<ExpiredState>),
}
impl MarketEvent for FairValueGapStatus {
fn point_in_time(&self) -> DateTime<Utc> {
match self {
FairValueGapStatus::Open(gap) => gap.point_in_time(),
FairValueGapStatus::Closed(gap) => gap.point_in_time(),
FairValueGapStatus::Expired(gap) => gap.point_in_time(),
}
}
}
impl<S: FairValueGapState> MarketEvent for FairValueGap<S> {
fn point_in_time(&self) -> DateTime<Utc> {
self.creation_time
}
}
impl<S: FairValueGapState> FairValueGap<S> {
pub fn direction(&self) -> FairValueGapDirection {
self.direction
}
pub fn creation_time(&self) -> DateTime<Utc> {
self.creation_time
}
pub fn creation_index(&self) -> usize {
self.creation_index
}
pub fn top(&self) -> Price {
self.top
}
pub fn bottom(&self) -> Price {
self.bottom
}
pub fn state(&self) -> &S {
&self.state
}
pub fn gap_size(&self) -> f64 {
(self.top.0 - self.bottom.0).abs()
}
pub fn map<NewState: FairValueGapState, F>(self, f: F) -> FairValueGap<NewState>
where
F: FnOnce(S) -> NewState,
{
FairValueGap {
direction: self.direction,
creation_time: self.creation_time,
creation_index: self.creation_index,
top: self.top,
bottom: self.bottom,
state: f(self.state),
}
}
pub fn evaluate_interaction(&self, candle: &Ohlcv) -> GapInteraction {
let overlaps = candle.low < self.top && candle.high > self.bottom;
if !overlaps {
return GapInteraction::Miss;
}
let is_filled = match self.direction {
FairValueGapDirection::Bullish => candle.low <= self.bottom,
FairValueGapDirection::Bearish => candle.high >= self.top,
};
if is_filled {
GapInteraction::Fill
} else {
GapInteraction::Touch
}
}
}
impl FairValueGap<OpenState> {
fn process_candle(self, indexed_candle: &IndexedOhlcv, ttl: TtlPolicy) -> FairValueGapStatus {
let candle = &indexed_candle.candle;
let updated_gap = match self.evaluate_interaction(candle) {
GapInteraction::Fill => {
return FairValueGapStatus::Closed(self.into_closed(candle.point_in_time()));
}
GapInteraction::Touch => {
let gap_size = self.gap_size();
let current_fill_pct = match self.direction {
FairValueGapDirection::Bullish => (self.top.0 - candle.low.0) / gap_size,
FairValueGapDirection::Bearish => (candle.high.0 - self.bottom.0) / gap_size,
};
self.with_partial_fill(current_fill_pct)
}
GapInteraction::Miss => self, };
let is_expired = match ttl {
TtlPolicy::Bars(limit) => {
indexed_candle
.index
.saturating_sub(updated_gap.creation_index())
>= limit
}
TtlPolicy::Time(limit) => {
candle
.close_timestamp
.signed_duration_since(updated_gap.creation_time())
>= limit
}
TtlPolicy::Filled => false,
};
if is_expired {
FairValueGapStatus::Expired(updated_gap.into_expired(candle.close_timestamp))
} else {
FairValueGapStatus::Open(updated_gap)
}
}
fn with_partial_fill(self, fill_pct: f64) -> Self {
let max_fill_percentage = self.state.max_fill_percentage.max(fill_pct.clamp(0.0, 1.0));
self.map(|s| OpenState {
max_fill_percentage,
touch_count: s.touch_count + 1,
})
}
fn into_closed(self, closed_time: DateTime<Utc>) -> FairValueGap<ClosedState> {
self.map(|s| ClosedState {
closed_time,
touch_count: s.touch_count + 1,
})
}
fn into_expired(self, expired_time: DateTime<Utc>) -> FairValueGap<ExpiredState> {
self.map(|s| ExpiredState {
expired_time,
touch_count: s.touch_count,
final_fill_percentage: s.max_fill_percentage,
})
}
}
#[derive(Debug, Clone)]
pub struct StreamingFairValueGap {
min_gap_size: f64,
ttl_policy: TtlPolicy,
buffer: VecDeque<IndexedOhlcv>,
active_gaps: Vec<FairValueGap<OpenState>>,
closed_gaps: Vec<FairValueGap<ClosedState>>,
expired_gaps: Vec<FairValueGap<ExpiredState>>,
}
impl Default for StreamingFairValueGap {
fn default() -> Self {
Self {
min_gap_size: f64::EPSILON,
ttl_policy: TtlPolicy::default(),
buffer: VecDeque::with_capacity(PATTERN_LENGTH),
active_gaps: Vec::new(),
closed_gaps: Vec::new(),
expired_gaps: Vec::new(),
}
}
}
impl StreamingFairValueGap {
pub fn with_min_gap_size(self, min_gap_size: f64) -> Self {
assert!(
min_gap_size > 0.0,
"min_gap_size must be strictly positive (got {min_gap_size} which is <= 0.0)"
);
Self {
min_gap_size,
..self
}
}
pub fn with_ttl_policy(self, ttl_policy: TtlPolicy) -> Self {
Self { ttl_policy, ..self }
}
pub fn active_gaps(&self) -> &[FairValueGap<OpenState>] {
&self.active_gaps
}
pub fn closed_gaps(&self) -> &[FairValueGap<ClosedState>] {
&self.closed_gaps
}
pub fn expired_gaps(&self) -> &[FairValueGap<ExpiredState>] {
&self.expired_gaps
}
fn detect_gap(&self) -> Option<FairValueGap<OpenState>> {
if self.buffer.len() < PATTERN_LENGTH {
return None;
}
let lhs = &self.buffer[LHS].candle;
let rhs = &self.buffer[RHS].candle;
let rhs_index = self.buffer[RHS].index;
let gap_up = rhs.low.0 - lhs.high.0;
let gap_down = lhs.low.0 - rhs.high.0;
debug_assert!(
!(gap_up >= self.min_gap_size && gap_down >= self.min_gap_size),
"detected bullish and bearish gap simultaneously (gap_up={gap_up}, gap_down={gap_down})"
);
let (direction, top, bottom) = if gap_up >= self.min_gap_size {
(FairValueGapDirection::Bullish, rhs.low, lhs.high)
} else if gap_down >= self.min_gap_size {
(FairValueGapDirection::Bearish, lhs.low, rhs.high)
} else {
return None;
};
Some(FairValueGap {
direction,
creation_time: rhs.close_timestamp,
creation_index: rhs_index,
top,
bottom,
state: OpenState::default(),
})
}
}
impl StreamingIndicator for StreamingFairValueGap {
type Input = IndexedOhlcv;
type Output<'a> = &'a [FairValueGap<OpenState>];
fn update(&mut self, indexed_candle: Self::Input) -> Self::Output<'_> {
let ttl = self.ttl_policy;
let closed_gaps = &mut self.closed_gaps;
let expired_gaps = &mut self.expired_gaps;
self.active_gaps.retain_mut(|gap_ref| {
match gap_ref.process_candle(&indexed_candle, ttl) {
FairValueGapStatus::Open(updated_gap) => {
*gap_ref = updated_gap;
true }
FairValueGapStatus::Closed(closed_gap) => {
closed_gaps.push(closed_gap);
false }
FairValueGapStatus::Expired(expired_gap) => {
expired_gaps.push(expired_gap);
false }
}
});
if self.buffer.len() >= PATTERN_LENGTH {
self.buffer.pop_front();
}
self.buffer.push_back(indexed_candle);
if let Some(new_gap) = self.detect_gap() {
self.active_gaps.push(new_gap);
}
self.active_gaps.as_slice()
}
fn reset(&mut self) {
self.buffer.clear();
self.active_gaps.clear();
self.closed_gaps.clear();
self.expired_gaps.clear();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::{domain::Quantity, event::Ohlcv};
fn ts(s: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
}
fn candle(
index: usize,
time: &str,
open: f64,
high: f64,
low: f64,
close: f64,
) -> IndexedOhlcv {
assert!(high >= low, "Invalid mock candle: high {high} < low {low}");
IndexedOhlcv {
index,
candle: Ohlcv {
open_timestamp: ts(time),
close_timestamp: ts(time),
open: Price(open),
high: Price(high),
low: Price(low),
close: Price(close),
volume: Quantity(100.0), quote_asset_volume: None,
number_of_trades: None,
taker_buy_base_asset_volume: None,
taker_buy_quote_asset_volume: None,
},
}
}
fn assert_f64_eq(a: f64, b: f64) {
assert!(
(a - b).abs() < f64::EPSILON,
"Expected {} to equal {}",
a,
b
);
}
#[test]
fn simultaneous_bullish_and_bearish_gap_is_impossible() {
let mut fvg = StreamingFairValueGap::default().with_min_gap_size(0.1);
let trajectory = vec![
candle(1, "2026-05-24T10:00:00Z", 50., 100., 10., 50.), candle(2, "2026-05-24T10:01:00Z", 50., 50., 50., 50.), candle(3, "2026-05-24T10:02:00Z", 10., 10., 10., 10.), ];
for c in trajectory {
let _ = fvg.update(c);
}
assert_eq!(fvg.active_gaps.len(), 0);
}
#[test]
fn filters_noise_below_min_gap_size() {
let mut indicator = StreamingFairValueGap::default().with_min_gap_size(2.0);
indicator.update(candle(1, "2026-05-24T10:00:00Z", 10., 10., 5., 8.)); indicator.update(candle(2, "2026-05-24T10:01:00Z", 10., 12., 8., 11.)); indicator.update(candle(3, "2026-05-24T10:02:00Z", 12., 15., 11., 14.));
assert!(indicator.active_gaps.is_empty());
}
#[test]
fn detects_bullish_and_bearish_fvgs() {
let mut indicator = StreamingFairValueGap::default().with_min_gap_size(1.0);
indicator.update(candle(1, "2026-05-24T10:00:00Z", 10., 10., 5., 8.)); indicator.update(candle(2, "2026-05-24T10:01:00Z", 10., 12., 8., 11.)); indicator.update(candle(3, "2026-05-24T10:02:00Z", 15., 20., 15., 18.));
assert_eq!(indicator.active_gaps.len(), 1);
let gap = indicator.active_gaps[0];
assert_eq!(gap.direction(), FairValueGapDirection::Bullish);
assert_eq!(gap.bottom().0, 10.0);
assert_eq!(gap.top().0, 15.0);
assert_f64_eq(gap.gap_size(), 5.0);
indicator.reset();
indicator.update(candle(4, "2026-05-24T10:00:00Z", 20., 25., 20., 22.)); indicator.update(candle(5, "2026-05-24T10:01:00Z", 18., 22., 15., 16.)); indicator.update(candle(6, "2026-05-24T10:02:00Z", 12., 15., 10., 11.));
assert_eq!(indicator.active_gaps.len(), 1);
let gap = indicator.active_gaps[0];
assert_eq!(gap.direction(), FairValueGapDirection::Bearish);
assert_eq!(gap.top().0, 20.0);
assert_eq!(gap.bottom().0, 15.0);
assert_f64_eq(gap.gap_size(), 5.0);
}
#[test]
fn partial_fill_updates_active_state_and_clamps() {
let mut indicator = StreamingFairValueGap::default().with_min_gap_size(1.0);
indicator.update(candle(1, "2026-05-24T10:00:00Z", 10., 10., 5., 8.));
indicator.update(candle(2, "2026-05-24T10:01:00Z", 10., 12., 8., 11.));
indicator.update(candle(3, "2026-05-24T10:02:00Z", 15., 20., 15., 18.));
assert_eq!(
indicator.active_gaps().len(),
1,
"Assumption failed: Bullish gap was not created"
);
let initial_gap = indicator.active_gaps()[0];
assert_eq!(initial_gap.direction(), FairValueGapDirection::Bullish);
assert_eq!(initial_gap.top().0, 15.0);
assert_eq!(initial_gap.bottom().0, 10.0);
assert_f64_eq(initial_gap.gap_size(), 5.0);
indicator.update(candle(4, "2026-05-24T10:03:00Z", 18., 18., 12.5, 17.));
assert_eq!(indicator.active_gaps().len(), 1);
assert_eq!(indicator.closed_gaps().len(), 0);
let gap = indicator.active_gaps()[0];
assert_eq!(gap.state().touch_count(), 1);
assert_f64_eq(gap.state().max_fill_percentage(), 0.5);
indicator.update(candle(5, "2026-05-24T10:04:00Z", 18., 18., 14.0, 17.));
let gap = indicator.active_gaps()[0];
assert_eq!(gap.state().touch_count(), 2);
assert_f64_eq(gap.state().max_fill_percentage(), 0.5); }
#[test]
fn full_fill_migrates_gap_to_closed() {
let mut indicator = StreamingFairValueGap::default().with_min_gap_size(1.0);
indicator.update(candle(1, "2026-05-24T10:00:00Z", 20., 25., 20., 22.)); indicator.update(candle(2, "2026-05-24T10:01:00Z", 18., 22., 12., 16.)); indicator.update(candle(3, "2026-05-24T10:02:00Z", 12., 15., 10., 11.));
assert_eq!(
indicator.active_gaps().len(),
1,
"Assumption failed: Bearish gap was not created"
);
let initial_gap = indicator.active_gaps()[0];
assert_eq!(initial_gap.direction(), FairValueGapDirection::Bearish);
assert_eq!(initial_gap.top().0, 20.0);
assert_eq!(initial_gap.bottom().0, 15.0);
assert_eq!(indicator.closed_gaps().len(), 0);
indicator.update(candle(4, "2026-05-24T10:03:00Z", 10., 12., 5., 8.));
assert_eq!(indicator.active_gaps()[0].state().touch_count(), 0);
indicator.update(candle(5, "2026-05-24T10:04:00Z", 12., 21., 12., 21.));
assert_eq!(
indicator.active_gaps().len(),
0,
"Gap should be removed from active pool"
);
assert_eq!(
indicator.closed_gaps().len(),
1,
"Gap should be migrated to history"
);
let closed = indicator.closed_gaps()[0];
assert_eq!(closed.direction(), FairValueGapDirection::Bearish);
assert_f64_eq(closed.state().max_fill_percentage(), 1.0); assert_eq!(closed.state().touch_count(), 1); assert_eq!(closed.state().closed_time(), ts("2026-05-24T10:04:00Z")); }
#[test]
fn boundary_exact_tick_is_a_miss() {
let mut indicator = StreamingFairValueGap::default().with_min_gap_size(1.0);
indicator.update(candle(1, "2026-05-26T10:01:00Z", 10., 10., 5., 8.));
indicator.update(candle(2, "2026-05-26T10:02:00Z", 10., 12., 8., 11.));
indicator.update(candle(3, "2026-05-26T10:03:00Z", 15., 20., 15., 18.));
let initial_gap = indicator.active_gaps()[0];
assert_eq!(
indicator.active_gaps().len(),
1,
"Assumption failed: Bullish gap was not created"
);
assert_eq!(initial_gap.direction(), FairValueGapDirection::Bullish);
assert_eq!(initial_gap.top().0, 15.0);
assert_eq!(initial_gap.bottom().0, 10.0);
indicator.update(candle(4, "2026-05-26T10:04:00Z", 20., 20., 15.0, 20.));
let gap = indicator.active_gaps()[0];
assert_eq!(
gap.state().touch_count(),
0,
"Exact tick overlap should not increment touches"
);
assert_f64_eq(gap.state().max_fill_percentage(), 0.0);
}
#[test]
fn multiple_gaps_tracked_and_filled_independently() {
let mut indicator = StreamingFairValueGap::default().with_min_gap_size(1.0);
indicator.update(candle(1, "2026-05-26T10:01:00Z", 10., 10., 5., 8.));
indicator.update(candle(2, "2026-05-26T10:02:00Z", 10., 20., 8., 11.)); indicator.update(candle(3, "2026-05-26T10:03:00Z", 15., 22., 15., 18.));
assert_eq!(
indicator.active_gaps().len(),
1,
"Assumption failed: Gap A not created"
);
assert_eq!(
indicator.active_gaps()[0].direction(),
FairValueGapDirection::Bullish
);
assert_eq!(indicator.active_gaps()[0].top().0, 15.0);
assert_eq!(indicator.active_gaps()[0].bottom().0, 10.0);
indicator.update(candle(4, "2026-05-26T10:04:00Z", 25., 25., 20., 22.));
indicator.update(candle(5, "2026-05-26T10:05:00Z", 25., 28., 22., 26.));
indicator.update(candle(6, "2026-05-26T10:06:00Z", 30., 35., 30., 32.));
assert_eq!(
indicator.active_gaps().len(),
2,
"Assumption failed: Gap B not created"
);
assert_eq!(
indicator.active_gaps()[1].direction(),
FairValueGapDirection::Bullish
);
assert_eq!(indicator.active_gaps()[1].top().0, 30.0);
assert_eq!(indicator.active_gaps()[1].bottom().0, 25.0);
indicator.update(candle(7, "2026-05-26T10:07:00Z", 30., 30., 20., 25.));
assert_eq!(indicator.active_gaps().len(), 1, "Gap B should be closed");
assert_eq!(
indicator.closed_gaps().len(),
1,
"Gap B should be in history"
);
let active_gap = indicator.active_gaps()[0];
assert_eq!(active_gap.bottom().0, 10.0);
assert_eq!(active_gap.top().0, 15.0);
assert_eq!(active_gap.state().touch_count(), 0);
let closed_gap = indicator.closed_gaps()[0];
assert_eq!(closed_gap.bottom().0, 25.0);
assert_eq!(closed_gap.top().0, 30.0);
assert_eq!(closed_gap.state().touch_count(), 1);
assert_f64_eq(closed_gap.state().max_fill_percentage(), 1.0);
}
#[test]
fn ttl_expires_after_n_bars() {
let mut indicator = StreamingFairValueGap::default()
.with_min_gap_size(1.0)
.with_ttl_policy(TtlPolicy::Bars(2));
indicator.update(candle(1, "2026-05-26T10:01:00Z", 10., 10., 5., 8.));
indicator.update(candle(2, "2026-05-26T10:02:00Z", 10., 20., 8., 11.)); indicator.update(candle(3, "2026-05-26T10:03:00Z", 15., 20., 15., 18.));
assert_eq!(
indicator.active_gaps().len(),
1,
"Assumption failed: Gap not created"
);
assert_eq!(
indicator.active_gaps()[0].direction(),
FairValueGapDirection::Bullish
);
assert_eq!(indicator.active_gaps()[0].creation_index(), 3);
assert_eq!(indicator.expired_gaps().len(), 0);
assert_eq!(indicator.closed_gaps().len(), 0, "No closed gaps at setup");
indicator.update(candle(4, "2026-05-26T10:04:00Z", 20., 25., 20., 22.));
assert_eq!(indicator.active_gaps().len(), 1);
assert_eq!(indicator.expired_gaps().len(), 0);
assert_eq!(
indicator.closed_gaps().len(),
0,
"No closed gaps mid-flight"
);
indicator.update(candle(5, "2026-05-26T10:05:00Z", 20., 25., 20., 22.));
assert_eq!(
indicator.active_gaps().len(),
0,
"Gap should be removed from active"
);
assert_eq!(
indicator.expired_gaps().len(),
1,
"Gap should be migrated to expired"
);
assert_eq!(
indicator.closed_gaps().len(),
0,
"No closed gaps after expiration"
);
let expired = indicator.expired_gaps()[0];
assert_eq!(expired.creation_index(), 3);
assert_eq!(expired.state().expired_time(), ts("2026-05-26T10:05:00Z"));
}
#[test]
fn ttl_expires_after_time_duration() {
let mut indicator = StreamingFairValueGap::default()
.with_min_gap_size(1.0)
.with_ttl_policy(TtlPolicy::Time(Duration::minutes(5)));
indicator.update(candle(1, "2026-05-26T10:01:00Z", 10., 10., 5., 8.));
indicator.update(candle(2, "2026-05-26T10:02:00Z", 10., 20., 8., 11.)); indicator.update(candle(3, "2026-05-26T10:03:00Z", 15., 20., 15., 18.));
assert_eq!(
indicator.active_gaps().len(),
1,
"Assumption failed: Gap not created"
);
assert_eq!(
indicator.active_gaps()[0].direction(),
FairValueGapDirection::Bullish
);
assert_eq!(
indicator.active_gaps()[0].creation_time(),
ts("2026-05-26T10:03:00Z")
);
assert_eq!(indicator.closed_gaps().len(), 0, "No closed gaps at setup");
indicator.update(candle(4, "2026-05-26T10:07:00Z", 20., 25., 20., 22.));
assert_eq!(indicator.active_gaps().len(), 1);
assert_eq!(
indicator.closed_gaps().len(),
0,
"No closed gaps mid-flight"
);
indicator.update(candle(5, "2026-05-26T10:08:00Z", 20., 25., 20., 22.));
assert_eq!(indicator.active_gaps().len(), 0);
assert_eq!(indicator.expired_gaps().len(), 1);
assert_eq!(
indicator.closed_gaps().len(),
0,
"No closed gaps after expiration"
);
}
#[test]
fn expired_state_preserves_partial_fill_history() {
let mut indicator = StreamingFairValueGap::default()
.with_min_gap_size(1.0)
.with_ttl_policy(TtlPolicy::Bars(2));
indicator.update(candle(1, "2026-05-26T10:01:00Z", 10., 10., 5., 8.));
indicator.update(candle(2, "2026-05-26T10:02:00Z", 10., 12., 8., 11.));
indicator.update(candle(3, "2026-05-26T10:03:00Z", 15., 20., 15., 18.));
assert_eq!(
indicator.active_gaps().len(),
1,
"Assumption failed: Gap not created"
);
assert_eq!(
indicator.active_gaps()[0].direction(),
FairValueGapDirection::Bullish
);
assert_eq!(indicator.closed_gaps().len(), 0, "No closed gaps at setup");
assert_eq!(
indicator.expired_gaps().len(),
0,
"No expired gaps at setup"
);
indicator.update(candle(4, "2026-05-26T10:04:00Z", 18., 18., 12.5, 17.));
assert_eq!(indicator.active_gaps().len(), 1);
assert_eq!(indicator.active_gaps()[0].state().touch_count(), 1);
indicator.update(candle(5, "2026-05-26T10:05:00Z", 20., 25., 20., 22.));
assert_eq!(indicator.active_gaps().len(), 0);
assert_eq!(indicator.closed_gaps().len(), 0);
assert_eq!(indicator.expired_gaps().len(), 1);
let expired = indicator.expired_gaps()[0];
assert_eq!(
expired.state().touch_count(),
1,
"Should preserve the touch count before expiration"
);
assert_f64_eq(expired.state().final_fill_percentage(), 0.5);
}
#[test]
fn ttl_policy_filled_never_expires() {
let mut indicator = StreamingFairValueGap::default()
.with_min_gap_size(1.0)
.with_ttl_policy(TtlPolicy::Filled);
indicator.update(candle(1, "2026-05-26T10:01:00Z", 10., 10., 5., 8.));
indicator.update(candle(2, "2026-05-26T10:02:00Z", 10., 20., 8., 11.)); indicator.update(candle(3, "2026-05-26T10:03:00Z", 15., 20., 15., 18.));
assert_eq!(
indicator.active_gaps().len(),
1,
"Assumption failed: Gap not created"
);
assert_eq!(
indicator.active_gaps()[0].direction(),
FairValueGapDirection::Bullish
);
assert_eq!(indicator.active_gaps()[0].top().0, 15.0);
indicator.update(candle(1000, "2026-05-26T20:00:00Z", 20., 25., 20., 22.));
assert_eq!(
indicator.active_gaps().len(),
1,
"Gap with TtlPolicy::Filled must remain active indefinitely"
);
assert_eq!(indicator.expired_gaps().len(), 0);
}
#[test]
fn simultaneous_full_fill_and_expiration_results_in_closed_gap() {
let mut indicator = StreamingFairValueGap::default()
.with_min_gap_size(1.0)
.with_ttl_policy(TtlPolicy::Bars(2));
indicator.update(candle(1, "2026-05-26T10:01:00Z", 10., 10., 5., 8.));
indicator.update(candle(2, "2026-05-26T10:02:00Z", 10., 20., 8., 11.)); indicator.update(candle(3, "2026-05-26T10:03:00Z", 15., 20., 15., 18.));
assert_eq!(
indicator.active_gaps().len(),
1,
"Assumption failed: Gap not created"
);
assert_eq!(
indicator.active_gaps()[0].direction(),
FairValueGapDirection::Bullish
);
assert_eq!(indicator.active_gaps()[0].top().0, 15.0);
assert_eq!(indicator.active_gaps()[0].bottom().0, 10.0);
assert_eq!(
indicator.closed_gaps().len(),
0,
"Assumption failed: closed_gaps should be empty"
);
assert_eq!(
indicator.expired_gaps().len(),
0,
"Assumption failed: expired_gaps should be empty"
);
indicator.update(candle(4, "2026-05-26T10:04:00Z", 20., 25., 20., 22.));
indicator.update(candle(5, "2026-05-26T10:05:00Z", 20., 20., 5.0, 10.));
assert_eq!(indicator.active_gaps().len(), 0);
assert_eq!(
indicator.expired_gaps().len(),
0,
"Gap must NOT be expired. It was fully filled during the candle lifespan."
);
assert_eq!(
indicator.closed_gaps().len(),
1,
"Gap MUST be closed because the fill happened before the candle closed."
);
let closed = indicator.closed_gaps()[0];
assert_f64_eq(closed.state().max_fill_percentage(), 1.0);
}
#[test]
fn simultaneous_partial_fill_and_expiration_preserves_final_action() {
let mut indicator = StreamingFairValueGap::default()
.with_min_gap_size(1.0)
.with_ttl_policy(TtlPolicy::Bars(2));
indicator.update(candle(1, "2026-05-26T10:01:00Z", 10., 10., 5., 8.));
indicator.update(candle(2, "2026-05-26T10:02:00Z", 10., 20., 8., 11.)); indicator.update(candle(3, "2026-05-26T10:03:00Z", 15., 20., 15., 18.));
assert_eq!(
indicator.active_gaps().len(),
1,
"Assumption failed: Gap not created"
);
assert_eq!(
indicator.active_gaps()[0].direction(),
FairValueGapDirection::Bullish
);
assert_eq!(indicator.active_gaps()[0].top().0, 15.0);
assert_eq!(indicator.active_gaps()[0].bottom().0, 10.0);
assert_eq!(
indicator.closed_gaps().len(),
0,
"Assumption failed: closed_gaps should be empty"
);
assert_eq!(
indicator.expired_gaps().len(),
0,
"Assumption failed: expired_gaps should be empty"
);
indicator.update(candle(4, "2026-05-26T10:04:00Z", 20., 25., 20., 22.));
indicator.update(candle(5, "2026-05-26T10:05:00Z", 20., 20., 12.5, 18.));
assert_eq!(indicator.active_gaps().len(), 0);
assert_eq!(indicator.closed_gaps().len(), 0);
assert_eq!(indicator.expired_gaps().len(), 1);
let expired = indicator.expired_gaps()[0];
assert_eq!(
expired.state().touch_count(),
1,
"Must register the touch from the expiring candle"
);
assert_f64_eq(expired.state().final_fill_percentage(), 0.5); }
#[test]
fn ttl_policy_filled_migrates_to_closed_on_full_fill() {
let mut indicator = StreamingFairValueGap::default()
.with_min_gap_size(1.0)
.with_ttl_policy(TtlPolicy::Filled);
indicator.update(candle(1, "2026-05-26T10:01:00Z", 10., 10., 5., 8.));
indicator.update(candle(2, "2026-05-26T10:02:00Z", 10., 20., 8., 11.)); indicator.update(candle(3, "2026-05-26T10:03:00Z", 15., 20., 15., 18.));
assert_eq!(
indicator.active_gaps().len(),
1,
"Assumption failed: Gap not created"
);
assert_eq!(
indicator.active_gaps()[0].direction(),
FairValueGapDirection::Bullish
);
assert_eq!(indicator.active_gaps()[0].top().0, 15.0);
assert_eq!(indicator.active_gaps()[0].bottom().0, 10.0);
assert_eq!(
indicator.closed_gaps().len(),
0,
"Assumption failed: closed_gaps should be empty"
);
assert_eq!(
indicator.expired_gaps().len(),
0,
"Assumption failed: expired_gaps should be empty"
);
indicator.update(candle(1000, "2026-05-26T20:00:00Z", 20., 25., 20., 22.));
assert_eq!(indicator.active_gaps().len(), 1);
assert_eq!(indicator.closed_gaps().len(), 0);
assert_eq!(indicator.expired_gaps().len(), 0);
indicator.update(candle(1001, "2026-05-26T20:01:00Z", 20., 20., 8.0, 10.));
assert_eq!(
indicator.active_gaps().len(),
0,
"Gap should be removed from active pool"
);
assert_eq!(
indicator.expired_gaps().len(),
0,
"Gap with TtlPolicy::Filled must NEVER enter Expired state"
);
assert_eq!(
indicator.closed_gaps().len(),
1,
"Gap MUST be correctly migrated to Closed state upon fill"
);
let closed = indicator.closed_gaps()[0];
assert_f64_eq(closed.state().max_fill_percentage(), 1.0);
}
#[test]
fn breakaway_gaps_do_not_touch_or_fill_fvg() {
let mut indicator = StreamingFairValueGap::default().with_min_gap_size(1.0);
indicator.update(candle(1, "2026-05-26T10:01:00Z", 10., 10., 5., 8.));
indicator.update(candle(2, "2026-05-26T10:02:00Z", 10., 12., 8., 11.));
indicator.update(candle(3, "2026-05-26T10:03:00Z", 15., 20., 15., 18.));
assert_eq!(indicator.active_gaps().len(), 1, "Bullish gap created");
indicator.update(candle(4, "2026-05-26T10:04:00Z", 8., 8., 5., 6.));
assert_eq!(
indicator.active_gaps().len(),
1,
"Bullish gap must remain active because it was leaped over"
);
let bullish_gap = indicator.active_gaps()[0];
assert_eq!(
bullish_gap.state().touch_count(),
0,
"The market never traded inside the Bullish gap"
);
assert_f64_eq(bullish_gap.state().max_fill_percentage(), 0.0);
indicator.reset();
indicator.update(candle(1, "2026-05-26T10:01:00Z", 20., 25., 20., 22.));
indicator.update(candle(2, "2026-05-26T10:02:00Z", 18., 25., 15., 16.)); indicator.update(candle(3, "2026-05-26T10:03:00Z", 12., 15., 10., 11.));
assert_eq!(indicator.active_gaps().len(), 1, "Bearish gap created");
indicator.update(candle(4, "2026-05-26T10:04:00Z", 25., 30., 25., 28.));
assert_eq!(
indicator.active_gaps().len(),
1,
"Bearish gap must remain active because it was leaped over"
);
let bearish_gap = indicator.active_gaps()[0];
assert_eq!(
bearish_gap.state().touch_count(),
0,
"The market never traded inside the Bearish gap"
);
assert_f64_eq(bearish_gap.state().max_fill_percentage(), 0.0);
}
#[test]
fn gap_interaction_evaluates_overlap_and_fills_correctly() {
let bullish_gap = FairValueGap {
direction: FairValueGapDirection::Bullish,
creation_time: ts("2026-05-24T10:00:00Z"),
creation_index: 0,
top: Price(15.0),
bottom: Price(10.0),
state: OpenState::default(),
};
let miss_above = candle(1, "2026-05-24T10:01:00Z", 20., 25., 15.0, 22.).candle;
let interaction = bullish_gap.evaluate_interaction(&miss_above);
assert_eq!(interaction, GapInteraction::Miss);
assert!(!interaction.is_touch());
let breakaway_below = candle(2, "2026-05-24T10:02:00Z", 5., 8., 2., 6.).candle;
let interaction = bullish_gap.evaluate_interaction(&breakaway_below);
assert_eq!(interaction, GapInteraction::Miss);
let touch_candle = candle(3, "2026-05-24T10:03:00Z", 18., 18., 12., 15.).candle;
let interaction = bullish_gap.evaluate_interaction(&touch_candle);
assert_eq!(interaction, GapInteraction::Touch);
assert!(interaction.is_touch());
assert!(!interaction.is_fill());
let fill_candle = candle(4, "2026-05-24T10:04:00Z", 18., 18., 9., 15.).candle;
let interaction = bullish_gap.evaluate_interaction(&fill_candle);
assert_eq!(interaction, GapInteraction::Fill);
assert!(interaction.is_touch()); assert!(interaction.is_fill());
let bearish_gap = FairValueGap {
direction: FairValueGapDirection::Bearish,
creation_time: ts("2026-05-24T10:00:00Z"),
creation_index: 0,
top: Price(20.0),
bottom: Price(15.0),
state: OpenState::default(),
};
let miss_below = candle(5, "2026-05-24T10:01:00Z", 10., 15.0, 5., 12.).candle;
assert_eq!(
bearish_gap.evaluate_interaction(&miss_below),
GapInteraction::Miss
);
let breakaway_above = candle(6, "2026-05-24T10:02:00Z", 25., 30., 22., 28.).candle;
assert_eq!(
bearish_gap.evaluate_interaction(&breakaway_above),
GapInteraction::Miss
);
let touch_bear = candle(7, "2026-05-24T10:03:00Z", 10., 18., 10., 12.).candle;
assert_eq!(
bearish_gap.evaluate_interaction(&touch_bear),
GapInteraction::Touch
);
let fill_bear = candle(8, "2026-05-24T10:04:00Z", 10., 21., 10., 12.).candle;
let interaction = bearish_gap.evaluate_interaction(&fill_bear);
assert_eq!(interaction, GapInteraction::Fill);
assert!(interaction.is_touch());
assert!(interaction.is_fill());
}
}