Skip to main content

kestrel_chartkit/structure/
zone_registry.rs

1use crate::model::{Bar, SupportResistanceZone, ZoneKind};
2
3#[cfg(feature = "serde")]
4use serde::{Deserialize, Serialize};
5
6/// Lifecycle state of a market structure zone.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
8#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
9pub enum ZoneState {
10    #[default]
11    Active,
12    Touched,
13    Reacted,
14    Broken,
15    Flipped,
16}
17
18/// Tracked Zone record with lifecycle metadata and confluence rating.
19#[derive(Debug, Clone, PartialEq)]
20#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
21pub struct ManagedZone {
22    pub id: u64,
23    pub zone: SupportResistanceZone,
24    pub state: ZoneState,
25    pub confluence_score: f64,
26    pub touch_count: u32,
27    pub age_bars: u32,
28    pub created_at: i64,
29    /// Which detector(s) produced this zone's evidence (e.g. `"order_block"`,
30    /// `"fair_value_gap"`, `"sr_pivot"`, `"volume_profile_hvn"`). A merged zone accumulates every
31    /// contributing source instead of losing provenance to whichever zone happened to survive.
32    pub sources: Vec<String>,
33    /// IDs of zones merged into this one (this zone's own ID is never included) — merge lineage
34    /// that survives across repeated merges, rather than being discarded.
35    pub merged_from: Vec<u64>,
36    /// Strongest observed reaction magnitude, in ATR units: the price distance moved away from
37    /// the zone edge at the moment `state` most recently transitioned to `Reacted`. `0.0` if the
38    /// zone has never reacted.
39    pub reaction_strength: f64,
40}
41
42/// Central registry managing support/resistance, order block, and FVG zones.
43#[derive(Debug, Clone, Default)]
44pub struct ZoneRegistry {
45    next_id: u64,
46    zones: Vec<ManagedZone>,
47}
48
49impl ZoneRegistry {
50    pub fn new() -> Self {
51        Self {
52            next_id: 1,
53            zones: Vec::new(),
54        }
55    }
56
57    /// Registers a new zone into the registry, assigning a unique ID and initial confluence score.
58    /// Source provenance defaults to `"unspecified"`; prefer [`ZoneRegistry::register_with_source`]
59    /// when the producing detector is known.
60    pub fn register(&mut self, zone: SupportResistanceZone, created_at: i64) -> u64 {
61        self.register_with_source(zone, created_at, "unspecified")
62    }
63
64    /// Like [`ZoneRegistry::register`], tagging the zone with which detector produced it.
65    pub fn register_with_source(
66        &mut self,
67        zone: SupportResistanceZone,
68        created_at: i64,
69        source: impl Into<String>,
70    ) -> u64 {
71        let id = self.next_id;
72        self.next_id += 1;
73
74        let touch_score = (f64::from(zone.touches) / 5.0).min(1.0);
75        let confluence_score = (touch_score * 0.3 + zone.strength * 0.7).clamp(0.0, 1.0);
76
77        self.zones.push(ManagedZone {
78            id,
79            zone,
80            state: ZoneState::Active,
81            confluence_score,
82            touch_count: 0,
83            age_bars: 0,
84            created_at,
85            sources: vec![source.into()],
86            merged_from: Vec::new(),
87            reaction_strength: 0.0,
88        });
89
90        id
91    }
92
93    /// Updates zone states based on incoming price action (touch, break, flip, aging).
94    pub fn update(&mut self, bar: &Bar, atr: f64) {
95        for mz in &mut self.zones {
96            mz.age_bars += 1;
97
98            if mz.state == ZoneState::Flipped {
99                continue;
100            }
101
102            let price_in_zone = bar.low <= mz.zone.price_top && bar.high >= mz.zone.price_bottom;
103
104            if mz.state == ZoneState::Broken {
105                let is_flip = match mz.zone.kind {
106                    ZoneKind::Support => price_in_zone && bar.close < mz.zone.price_bottom,
107                    ZoneKind::Resistance => price_in_zone && bar.close > mz.zone.price_top,
108                };
109                if is_flip {
110                    mz.state = ZoneState::Flipped;
111                    mz.touch_count += 1;
112                }
113                continue;
114            }
115
116            let reacted = match mz.zone.kind {
117                ZoneKind::Support => {
118                    mz.state == ZoneState::Touched && bar.close > mz.zone.price_top
119                }
120                ZoneKind::Resistance => {
121                    mz.state == ZoneState::Touched && bar.close < mz.zone.price_bottom
122                }
123            };
124            if reacted {
125                mz.state = ZoneState::Reacted;
126                let edge = match mz.zone.kind {
127                    ZoneKind::Support => mz.zone.price_top,
128                    ZoneKind::Resistance => mz.zone.price_bottom,
129                };
130                if atr > 0.0 {
131                    let magnitude = (bar.close - edge).abs() / atr;
132                    mz.reaction_strength = mz.reaction_strength.max(magnitude);
133                }
134            }
135
136            if price_in_zone && !reacted {
137                mz.touch_count += 1;
138                mz.state = ZoneState::Touched;
139            }
140
141            // Check for structural break
142            match mz.zone.kind {
143                ZoneKind::Support => {
144                    if bar.close < mz.zone.price_bottom - (atr * 0.2) {
145                        mz.state = ZoneState::Broken;
146                    }
147                }
148                ZoneKind::Resistance => {
149                    if bar.close > mz.zone.price_top + (atr * 0.2) {
150                        mz.state = ZoneState::Broken;
151                    }
152                }
153            }
154        }
155    }
156
157    /// Merges overlapping zones of the same kind within `atr_margin` distance.
158    pub fn merge_overlapping(&mut self, atr_margin: f64) {
159        if self.zones.len() < 2 {
160            return;
161        }
162
163        let candidates = std::mem::take(&mut self.zones);
164        self.zones = merge_zone_candidates(candidates, atr_margin, true);
165    }
166
167    /// Like [`ZoneRegistry::merge_overlapping`], but also merges same-kind zones across
168    /// *different* sources/states (not just identical-source, identical-state duplicates), and
169    /// resolves which zone "wins" as the merge target by an explicit priority order — higher
170    /// confluence score first, then more touches, then newer — instead of whichever was iterated
171    /// first silently dominating.
172    pub fn merge_overlapping_with_priority(&mut self, price_margin: f64) {
173        if self.zones.len() < 2 {
174            return;
175        }
176
177        let mut candidates = std::mem::take(&mut self.zones);
178        candidates.sort_by(|a, b| {
179            b.confluence_score
180                .total_cmp(&a.confluence_score)
181                .then(b.touch_count.cmp(&a.touch_count))
182                .then(b.created_at.cmp(&a.created_at))
183        });
184
185        self.zones = merge_zone_candidates(candidates, price_margin, false);
186    }
187
188    /// Recomputes every active zone's confluence score from its own touches/strength (as at
189    /// registration) plus a cross-source overlap bonus: how many *distinct other sources* have a
190    /// zone overlapping this one's price range within `price_tolerance`. Reflects genuine
191    /// multi-detector agreement rather than one detector's self-reported touch count — the
192    /// "belastbarer Confluence-Score" a single-source score cannot provide. Call after
193    /// registering zones from more than one detector.
194    pub fn recompute_confluence(&mut self, price_tolerance: f64) {
195        let snapshot: Vec<(u64, f64, f64, Vec<String>)> = self
196            .zones
197            .iter()
198            .map(|mz| {
199                (
200                    mz.id,
201                    mz.zone.price_bottom,
202                    mz.zone.price_top,
203                    mz.sources.clone(),
204                )
205            })
206            .collect();
207
208        for mz in &mut self.zones {
209            let touch_score = (f64::from(mz.zone.touches) / 5.0).min(1.0);
210            let base_score = (touch_score * 0.3 + mz.zone.strength * 0.7).clamp(0.0, 1.0);
211
212            let distinct_other_sources: std::collections::HashSet<&String> = snapshot
213                .iter()
214                .filter(|(id, bottom, top, _)| {
215                    *id != mz.id
216                        && *bottom <= mz.zone.price_top + price_tolerance
217                        && *top >= mz.zone.price_bottom - price_tolerance
218                })
219                .flat_map(|(_, _, _, sources)| sources.iter())
220                .filter(|s| !mz.sources.contains(s))
221                .collect();
222
223            let confluence_bonus = (distinct_other_sources.len() as f64 * 0.15).min(0.4);
224            mz.confluence_score = (base_score + confluence_bonus).clamp(0.0, 1.0);
225        }
226    }
227
228    /// Returns up to `top_n` active zones within `max_distance` of `current_price`, ranked by
229    /// confluence score descending — the zones a consumer should actually act on, rather than the
230    /// full unranked registry.
231    pub fn relevant_zones(
232        &self,
233        current_price: f64,
234        max_distance: f64,
235        top_n: usize,
236    ) -> Vec<&ManagedZone> {
237        let mut candidates: Vec<&ManagedZone> = self
238            .active_zones()
239            .filter(|mz| {
240                let distance = if current_price < mz.zone.price_bottom {
241                    mz.zone.price_bottom - current_price
242                } else if current_price > mz.zone.price_top {
243                    current_price - mz.zone.price_top
244                } else {
245                    0.0
246                };
247                distance <= max_distance
248            })
249            .collect();
250        candidates.sort_by(|a, b| b.confluence_score.total_cmp(&a.confluence_score));
251        candidates.truncate(top_n);
252        candidates
253    }
254
255    /// Prunes expired or broken zones older than `max_age_bars`.
256    pub fn prune(&mut self, max_age_bars: u32) {
257        self.zones
258            .retain(|mz| mz.age_bars <= max_age_bars && mz.state != ZoneState::Broken);
259    }
260
261    pub fn active_zones(&self) -> impl Iterator<Item = &ManagedZone> {
262        self.zones.iter().filter(|mz| mz.state != ZoneState::Broken)
263    }
264
265    pub fn zones(&self) -> &[ManagedZone] {
266        &self.zones
267    }
268}
269
270/// Shared merge routine behind [`ZoneRegistry::merge_overlapping`] and
271/// [`ZoneRegistry::merge_overlapping_with_priority`]: absorbs each candidate (in the order given
272/// by the caller, which encodes either original insertion order or an explicit priority sort)
273/// into the first already-merged zone of the same kind (and, if `require_same_state`, the same
274/// lifecycle state) whose price range overlaps within `price_margin`.
275fn merge_zone_candidates(
276    candidates: Vec<ManagedZone>,
277    price_margin: f64,
278    require_same_state: bool,
279) -> Vec<ManagedZone> {
280    let mut merged: Vec<ManagedZone> = Vec::new();
281
282    for mz in candidates {
283        let mut absorbed = false;
284        for existing in &mut merged {
285            let same_kind_and_state = existing.zone.kind == mz.zone.kind
286                && (!require_same_state || existing.state == mz.state);
287            if !same_kind_and_state {
288                continue;
289            }
290            let overlap = (mz.zone.price_bottom <= existing.zone.price_top + price_margin)
291                && (mz.zone.price_top >= existing.zone.price_bottom - price_margin);
292            if !overlap {
293                continue;
294            }
295
296            existing.zone.price_top = existing.zone.price_top.max(mz.zone.price_top);
297            existing.zone.price_bottom = existing.zone.price_bottom.min(mz.zone.price_bottom);
298            existing.zone.strength = existing.zone.strength.max(mz.zone.strength);
299            existing.zone.touches = existing.zone.touches.saturating_add(mz.zone.touches);
300            existing.confluence_score = existing.confluence_score.max(mz.confluence_score);
301            existing.touch_count = existing.touch_count.saturating_add(mz.touch_count);
302            existing.age_bars = existing.age_bars.max(mz.age_bars);
303            existing.created_at = existing.created_at.min(mz.created_at);
304            existing.reaction_strength = existing.reaction_strength.max(mz.reaction_strength);
305            for src in &mz.sources {
306                if !existing.sources.contains(src) {
307                    existing.sources.push(src.clone());
308                }
309            }
310            existing.merged_from.push(mz.id);
311            existing.merged_from.extend(mz.merged_from.iter().copied());
312            absorbed = true;
313            break;
314        }
315        if !absorbed {
316            merged.push(mz);
317        }
318    }
319
320    merged
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn test_zone_registry_lifecycle() {
329        let mut reg = ZoneRegistry::new();
330        let s_zone = SupportResistanceZone {
331            kind: ZoneKind::Support,
332            price: 100.0,
333            price_top: 102.0,
334            price_bottom: 98.0,
335            strength: 0.8,
336            distance_pct: 0.0,
337            touches: 2,
338        };
339
340        let id = reg.register(s_zone, 1000);
341        assert_eq!(id, 1);
342
343        // Bar touches support zone
344        let bar1 = Bar::new(1000, 101.0, 103.0, 99.0, 100.0, 1000.0);
345        reg.update(&bar1, 2.0);
346        assert_eq!(reg.zones()[0].state, ZoneState::Touched);
347
348        // Bar breaks support zone below 98 - 0.4 = 97.6
349        let bar2 = Bar::new(2000, 99.0, 99.0, 95.0, 96.0, 1000.0);
350        reg.update(&bar2, 2.0);
351        assert_eq!(reg.zones()[0].state, ZoneState::Broken);
352
353        // A retest from below confirms the former support as flipped resistance.
354        let bar3 = Bar::new(3000, 96.0, 99.0, 95.0, 97.0, 1000.0);
355        reg.update(&bar3, 2.0);
356        assert_eq!(reg.zones()[0].state, ZoneState::Flipped);
357    }
358
359    #[test]
360    fn touch_can_transition_to_reacted() {
361        let mut reg = ZoneRegistry::new();
362        reg.register(
363            SupportResistanceZone {
364                kind: ZoneKind::Support,
365                price: 100.0,
366                price_top: 102.0,
367                price_bottom: 98.0,
368                strength: 0.8,
369                distance_pct: 0.0,
370                touches: 1,
371            },
372            0,
373        );
374        reg.update(&Bar::new(1, 103.0, 103.0, 100.0, 101.0, 1.0), 2.0);
375        reg.update(&Bar::new(2, 101.0, 104.0, 101.0, 103.0, 1.0), 2.0);
376        assert_eq!(reg.zones()[0].state, ZoneState::Reacted);
377        // close=103, edge=price_top=102, atr=2.0 -> |103-102|/2 = 0.5 ATR.
378        assert!((reg.zones()[0].reaction_strength - 0.5).abs() < 1e-9);
379    }
380
381    fn support_zone(
382        price_top: f64,
383        price_bottom: f64,
384        strength: f64,
385        touches: u32,
386    ) -> SupportResistanceZone {
387        SupportResistanceZone {
388            kind: ZoneKind::Support,
389            price: (price_top + price_bottom) / 2.0,
390            price_top,
391            price_bottom,
392            strength,
393            distance_pct: 0.0,
394            touches,
395        }
396    }
397
398    #[test]
399    fn test_register_with_source_tags_provenance() {
400        let mut reg = ZoneRegistry::new();
401        let id = reg.register_with_source(support_zone(102.0, 98.0, 0.5, 1), 0, "order_block");
402        let zone = reg.zones().iter().find(|z| z.id == id).unwrap();
403        assert_eq!(zone.sources, vec!["order_block".to_string()]);
404
405        let default_id = reg.register(support_zone(50.0, 48.0, 0.5, 1), 0);
406        let default_zone = reg.zones().iter().find(|z| z.id == default_id).unwrap();
407        assert_eq!(default_zone.sources, vec!["unspecified".to_string()]);
408    }
409
410    #[test]
411    fn test_recompute_confluence_rewards_multi_source_overlap() {
412        let mut reg = ZoneRegistry::new();
413        let solo_id = reg.register_with_source(support_zone(60.0, 58.0, 0.3, 0), 0, "sr_pivot");
414        let a_id = reg.register_with_source(support_zone(102.0, 98.0, 0.3, 0), 0, "sr_pivot");
415        reg.register_with_source(support_zone(101.0, 99.0, 0.3, 0), 0, "order_block");
416        reg.register_with_source(support_zone(100.5, 99.5, 0.3, 0), 0, "fair_value_gap");
417
418        reg.recompute_confluence(0.5);
419
420        let solo = reg
421            .zones()
422            .iter()
423            .find(|z| z.id == solo_id)
424            .unwrap()
425            .confluence_score;
426        let confluent = reg
427            .zones()
428            .iter()
429            .find(|z| z.id == a_id)
430            .unwrap()
431            .confluence_score;
432        assert!(
433            confluent > solo,
434            "a zone corroborated by two other independent sources must score higher than an isolated one"
435        );
436    }
437
438    #[test]
439    fn test_merge_overlapping_with_priority_preserves_lineage_and_sources() {
440        let mut reg = ZoneRegistry::new();
441        let strong_id =
442            reg.register_with_source(support_zone(102.0, 98.0, 0.9, 5), 0, "order_block");
443        let weak_id =
444            reg.register_with_source(support_zone(101.0, 99.0, 0.2, 0), 5, "fair_value_gap");
445        reg.recompute_confluence(0.5);
446
447        reg.merge_overlapping_with_priority(1.0);
448
449        assert_eq!(reg.zones().len(), 1);
450        let survivor = &reg.zones()[0];
451        assert_eq!(
452            survivor.id, strong_id,
453            "the higher-confluence zone must be the merge target"
454        );
455        assert!(survivor.sources.contains(&"order_block".to_string()));
456        assert!(survivor.sources.contains(&"fair_value_gap".to_string()));
457        assert!(survivor.merged_from.contains(&weak_id));
458    }
459
460    #[test]
461    fn test_relevant_zones_ranks_by_confluence_within_distance() {
462        let mut reg = ZoneRegistry::new();
463        reg.register_with_source(support_zone(102.0, 98.0, 0.9, 5), 0, "order_block"); // near, strong
464        reg.register_with_source(support_zone(101.0, 99.0, 0.1, 0), 0, "sr_pivot"); // near, weak
465        reg.register_with_source(support_zone(1002.0, 998.0, 0.9, 5), 0, "order_block"); // far, strong
466
467        let relevant = reg.relevant_zones(100.0, 5.0, 2);
468        assert_eq!(
469            relevant.len(),
470            2,
471            "the far zone must be excluded by max_distance"
472        );
473        assert!(relevant[0].confluence_score >= relevant[1].confluence_score);
474    }
475}