1use crate::model::{Bar, SupportResistanceZone, ZoneKind};
2
3#[cfg(feature = "serde")]
4use serde::{Deserialize, Serialize};
5
6#[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#[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 pub sources: Vec<String>,
33 pub merged_from: Vec<u64>,
36 pub reaction_strength: f64,
40}
41
42#[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 pub fn register(&mut self, zone: SupportResistanceZone, created_at: i64) -> u64 {
61 self.register_with_source(zone, created_at, "unspecified")
62 }
63
64 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 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 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 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 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 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 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 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
270fn 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 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 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 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 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 = ®.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"); reg.register_with_source(support_zone(101.0, 99.0, 0.1, 0), 0, "sr_pivot"); reg.register_with_source(support_zone(1002.0, 998.0, 0.9, 5), 0, "order_block"); 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}