kestrel_chartkit/engine/
balance_classifier.rs1#[cfg(feature = "serde")]
2use serde::{Deserialize, Serialize};
3
4use crate::engine::market_context::AuctionPhase;
5use crate::model::Bar;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9#[cfg_attr(
10 feature = "serde",
11 derive(Serialize, Deserialize),
12 serde(rename_all = "snake_case")
13)]
14pub enum MarketBalanceState {
15 Balance,
16 Imbalance,
17 Transition,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq)]
22#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
23pub struct BalanceClassifierOutput {
24 pub state: MarketBalanceState,
25 pub auction_phase: AuctionPhase,
26 pub compression_ratio: f64,
27 pub balance_confidence: f64,
28}
29
30pub fn classify_balance_imbalance(
32 bars: &[Bar],
33 atr_raw: f64,
34 pivot_score: f64,
35) -> BalanceClassifierOutput {
36 if bars.len() < 5 || atr_raw <= 0.0 {
37 return BalanceClassifierOutput {
38 state: MarketBalanceState::Transition,
39 auction_phase: AuctionPhase::InsideBalance,
40 compression_ratio: 1.0,
41 balance_confidence: 0.5,
42 };
43 }
44
45 let len = bars.len();
46 let recent_slice = &bars[len - 5..];
47 let range = recent_slice.iter().map(|b| b.high).fold(f64::MIN, f64::max)
48 - recent_slice.iter().map(|b| b.low).fold(f64::MAX, f64::min);
49
50 let compression_ratio = range / (atr_raw * 5.0).max(1e-8);
51
52 let (state, phase, confidence) = if compression_ratio < 0.60 && pivot_score.abs() < 0.40 {
53 (
54 MarketBalanceState::Balance,
55 AuctionPhase::InsideBalance,
56 (1.0 - compression_ratio).clamp(0.5, 1.0),
57 )
58 } else if compression_ratio > 1.20 || pivot_score.abs() >= 0.70 {
59 (
60 MarketBalanceState::Imbalance,
61 AuctionPhase::Expansion,
62 (compression_ratio / 2.0).clamp(0.6, 1.0),
63 )
64 } else {
65 (MarketBalanceState::Transition, AuctionPhase::Retest, 0.50)
66 };
67
68 BalanceClassifierOutput {
69 state,
70 auction_phase: phase,
71 compression_ratio,
72 balance_confidence: confidence,
73 }
74}