Skip to main content

kestrel_chartkit/scoring/
reason_builder.rs

1use crate::indicator::{IndicatorAlert, IndicatorOutput};
2use crate::signal::SubScore;
3
4pub fn score_indicator(
5    name: &str,
6    output: &IndicatorOutput,
7    alerts: &[IndicatorAlert],
8) -> SubScore {
9    let val = output.value;
10    let mut score = 0.0f64;
11    let mut reasons = Vec::new();
12
13    for alert in alerts {
14        match alert.kind.as_str() {
15            "bull_extreme" | "bull_cross" | "bull_mid_cross" | "bull_di_cross"
16            | "bull_zero_cross" | "wt_bull_cross" => {
17                let s = 0.5 + 0.5 * alert.strength.clamp(0.0, 1.0);
18                score += s;
19                reasons.push(alert.note.clone());
20            }
21            "bear_extreme" | "bear_cross" | "bear_mid_cross" | "bear_di_cross"
22            | "bear_zero_cross" | "wt_bear_cross" => {
23                let s = 0.5 + 0.5 * alert.strength.clamp(0.0, 1.0);
24                score -= s;
25                reasons.push(alert.note.clone());
26            }
27            "bull_divergence"
28            | "bullish_kangaroo_tail"
29            | "bullish_engulfing"
30            | "bullish_tweezer"
31            | "bullish_marubozu"
32            | "bullish_fvg"
33            | "bullish_liquidity_sweep"
34            | "high_leg_efficiency"
35            | "structure_bullish_bias"
36            | "bullish_order_block"
37            | "ob_retest_bullish"
38            | "bullish_bos"
39            | "bullish_choch"
40            | "price_below_val" => {
41                let s = 0.6 + 0.4 * alert.strength.clamp(0.0, 1.0);
42                score += s;
43                reasons.push(alert.note.clone());
44            }
45            "bear_divergence"
46            | "bearish_kangaroo_tail"
47            | "bearish_engulfing"
48            | "bearish_tweezer"
49            | "bearish_marubozu"
50            | "bearish_fvg"
51            | "bearish_liquidity_sweep"
52            | "structure_bearish_bias"
53            | "bearish_order_block"
54            | "ob_retest_bearish"
55            | "bearish_bos"
56            | "bearish_choch"
57            | "price_above_vah" => {
58                let s = 0.6 + 0.4 * alert.strength.clamp(0.0, 1.0);
59                score -= s;
60                reasons.push(alert.note.clone());
61            }
62            "panic_bottom" => {
63                score += 0.9;
64                reasons.push(alert.note.clone());
65            }
66            "expansion" => {
67                reasons.push(alert.note.clone());
68            }
69            "contraction" | "low_leg_efficiency" | "volatility" => {
70                // Volatility-state alerts (squeeze/expansion) describe market *condition*, not
71                // direction — context for the explanation, deliberately no score contribution.
72                reasons.push(alert.note.clone());
73            }
74            _ => {
75                if !alert.note.is_empty() {
76                    reasons.push(alert.note.clone());
77                }
78            }
79        }
80    }
81
82    // Secondary level checks if no explicit alerts fired
83    if alerts.is_empty() {
84        match name.to_lowercase().as_str() {
85            "rsi" | "stoch_rsi" | "mfi" | "williams_r" | "connors_rsi" => {
86                if val > 70.0 {
87                    score -= 0.4;
88                    reasons.push(format!(
89                        "{}: Im überkauften Bereich ({:.1})",
90                        name.to_uppercase(),
91                        val
92                    ));
93                } else if val < 30.0 {
94                    score += 0.4;
95                    reasons.push(format!(
96                        "{}: Im überverkauften Bereich ({:.1})",
97                        name.to_uppercase(),
98                        val
99                    ));
100                } else if val > 55.0 {
101                    score += 0.2;
102                } else if val < 45.0 {
103                    score -= 0.2;
104                }
105            }
106            "macd" => {
107                let hist = output.extra.get("hist").copied().unwrap_or(0.0);
108                if hist > 0.0 {
109                    score += 0.3;
110                } else if hist < 0.0 {
111                    score -= 0.3;
112                }
113            }
114            "bollinger" => {
115                let pct_b = output.extra.get("percent_b").copied().unwrap_or(0.5);
116                if pct_b > 1.0 {
117                    score -= 0.5;
118                    reasons.push("BOLLINGER: Preis über oberem Band".to_string());
119                } else if pct_b < 0.0 {
120                    score += 0.5;
121                    reasons.push("BOLLINGER: Preis unter unterem Band".to_string());
122                }
123            }
124            "candle_story" => {
125                if val > 30.0 {
126                    score += 0.3;
127                } else if val < -30.0 {
128                    score -= 0.3;
129                }
130            }
131            "efficiency" => {
132                if val >= 0.50 {
133                    score += 0.3;
134                } else if val <= 0.25 {
135                    score -= 0.2;
136                }
137            }
138            "choppiness" => {
139                // Direction-agnostic trend-strength proxy, same convention as `efficiency`
140                // above: low Choppiness Index = clean trend (conviction bump), high =
141                // range-bound chop (conviction penalty) — not itself a bullish/bearish call.
142                if val <= 38.2 {
143                    score += 0.3;
144                    reasons.push(format!("CHOPPINESS: Klarer Trend ({val:.1})"));
145                } else if val >= 61.8 {
146                    score -= 0.2;
147                    reasons.push(format!(
148                        "CHOPPINESS: Ausgeprägte Seitwärtsbewegung ({val:.1})"
149                    ));
150                }
151            }
152            "vortex" => {
153                // `val` is VI+ (see indicator/vortex.rs); VI- lives in `extra`. Standard
154                // Vortex reading: VI+ above VI- is bullish trend dominance, and vice versa.
155                let vi_minus = output.extra.get("vi_minus").copied().unwrap_or(val);
156                let diff = val - vi_minus;
157                if diff > 0.05 {
158                    score += (diff * 2.0).clamp(0.0, 1.0);
159                    reasons.push(format!("VORTEX: +VI über -VI ({val:.2} vs {vi_minus:.2})"));
160                } else if diff < -0.05 {
161                    score += (diff * 2.0).clamp(-1.0, 0.0);
162                    reasons.push(format!("VORTEX: -VI über +VI ({vi_minus:.2} vs {val:.2})"));
163                }
164            }
165            "trend_quality" => {
166                // Already a signed, -100..100-scaled composite (direction × efficiency ×
167                // ADX-strength × RVOL-participation, see indicator/trend_quality.rs) — rescale
168                // into the -1..1 SubScore range directly instead of re-deriving a direction.
169                score += (val / 100.0).clamp(-1.0, 1.0);
170                if val.abs() >= 20.0 {
171                    let label = if val > 0.0 { "Bullische" } else { "Bärische" };
172                    reasons.push(format!("TREND_QUALITY: {label} Trendqualität ({val:.1})"));
173                }
174            }
175            _ => {}
176        }
177    }
178
179    let score_clamped = score.clamp(-1.0, 1.0);
180    let reason_str = if !reasons.is_empty() {
181        Some(reasons.join("; "))
182    } else {
183        None
184    };
185
186    SubScore {
187        indicator: name.to_string(),
188        score: score_clamped,
189        raw_value: val,
190        reason: reason_str,
191    }
192}