kestrel_chartkit/scoring/
reason_builder.rs1use 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 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 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 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 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 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}