1use chrono::{Duration, Utc};
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Serialize, Deserialize, Default)]
5pub struct SdTimerPoint {
6 pub horizon_months: f64,
7 pub probability: f64,
8 pub score: Option<f64>,
9 pub group_a: Option<String>,
10 pub group_b: Option<String>,
11 pub group_c: Option<u8>,
12 pub source: Option<String>,
13 pub synthetic_bar_days: Option<f64>,
14 pub synthetic_bars: Option<usize>,
15 pub expected_squeeze_ticks: Option<f64>,
16 pub elapsed_squeeze_ticks: Option<f64>,
17 pub remaining_squeeze_ticks: Option<f64>,
18 pub active_squeeze: Option<bool>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize, Default)]
22pub struct SdTimerWindow {
23 pub net_force: f64,
24 pub positive_force: f64,
25 pub negative_force: f64,
26 pub positive_peak_days: Option<f64>,
27 pub negative_peak_days: Option<f64>,
28 pub most_positive_timeframe_minutes: Option<f64>,
29 pub most_positive_score: Option<f64>,
30 pub most_negative_timeframe_minutes: Option<f64>,
31 pub most_negative_score: Option<f64>,
32 pub squeeze_count: usize,
33 pub squeeze_ranges: Vec<String>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct SdTimerSignal {
38 pub symbol: String,
39 pub as_of: String,
40 pub point_count: usize,
41 pub fallback_timer_count: usize,
42 pub short: SdTimerWindow,
43 pub long: SdTimerWindow,
44 pub entry_label: String,
45 pub entry_days: Option<f64>,
46 pub entry_date: Option<String>,
47 pub entry_force: f64,
48 pub exit_label: String,
49 pub exit_days: Option<f64>,
50 pub exit_date: Option<String>,
51 pub exit_force: f64,
52 pub conflict: String,
53 pub explanation: String,
54}
55
56#[derive(Debug, Clone)]
57struct ScoredPoint {
58 horizon_months: f64,
59 score: f64,
60 direction: f64,
61 group_c: u8,
62 squeeze_code: String,
63 tick_minutes: f64,
64 expected_ticks: f64,
65 elapsed_ticks: f64,
66 remaining_ticks: f64,
67 fallback_timer: bool,
68 force: f64,
69 peak_days: f64,
70}
71
72pub fn compute_sd_timer(symbol: &str, points: &[SdTimerPoint]) -> Option<SdTimerSignal> {
73 let mut scored = points
74 .iter()
75 .filter_map(score_point)
76 .collect::<Vec<ScoredPoint>>();
77 if scored.is_empty() {
78 return None;
79 }
80 scored.sort_by(|a, b| a.horizon_months.total_cmp(&b.horizon_months));
81
82 let short_points = scored
83 .iter()
84 .filter(|p| p.horizon_months <= 1.5)
85 .cloned()
86 .collect::<Vec<_>>();
87 let long_points = scored
88 .iter()
89 .filter(|p| p.horizon_months >= 6.0)
90 .cloned()
91 .collect::<Vec<_>>();
92 let short = summarize_window(&short_points);
93 let long = summarize_window(&long_points);
94 let fallback_timer_count = scored.iter().filter(|p| p.fallback_timer).count();
95
96 let short_neg = short.negative_force;
97 let short_pos = short.positive_force;
98 let long_pos = long.positive_force;
99 let long_neg = long.negative_force;
100 let conflict = if long.net_force > 0.08 && short.net_force < -0.05 {
101 "Long-term force is bullish, but short-term force is still negative.".to_string()
102 } else if long.net_force < -0.08 && short.net_force > 0.05 {
103 "Long-term force is bearish, but short-term force is still positive.".to_string()
104 } else if long.net_force.abs() <= 0.08 {
105 "Long-term force is not decisive.".to_string()
106 } else {
107 "Short-term and long-term forces are broadly aligned.".to_string()
108 };
109
110 let (entry_label, entry_days, entry_force) = if long_pos < 0.03 || long.net_force <= 0.02 {
111 (
112 "No long entry".to_string(),
113 None,
114 long.net_force - short_neg,
115 )
116 } else if short.net_force < -0.05 {
117 (
118 "Wait for short-term squeeze to calm".to_string(),
119 short.negative_peak_days.map(|d| d.clamp(0.0, 84.0)),
120 long_pos - short_neg,
121 )
122 } else if short.net_force > 0.04 {
123 (
124 "Entry window open".to_string(),
125 Some(0.0),
126 long_pos + short_pos,
127 )
128 } else {
129 (
130 "Stage entry".to_string(),
131 long.positive_peak_days
132 .map(|d| d.clamp(0.0, 84.0))
133 .or(Some(14.0)),
134 long_pos,
135 )
136 };
137
138 let (exit_label, exit_days, exit_force) = if long_neg < 0.03 || long.net_force >= -0.02 {
139 (
140 "No strategic exit".to_string(),
141 None,
142 -long.net_force - short_pos,
143 )
144 } else if short.net_force <= -0.04 {
145 (
146 "Exit window open".to_string(),
147 Some(0.0),
148 long_neg + short_neg,
149 )
150 } else if short.net_force > 0.05 {
151 (
152 "Exit into short-term strength".to_string(),
153 short.positive_peak_days.map(|d| d.clamp(0.0, 42.0)),
154 long_neg - short_pos,
155 )
156 } else {
157 (
158 "Stage exit".to_string(),
159 long.negative_peak_days
160 .map(|d| d.clamp(0.0, 84.0))
161 .or(Some(14.0)),
162 long_neg,
163 )
164 };
165
166 let as_of = Utc::now().to_rfc3339();
167 let explanation = format!(
168 "SD Timer models each horizon as a directional force. Long force {:.4}; short force {:.4}. {} Average squeeze lengths are per synthesized timeframe when present; {} of {} points used fallback timer estimates.",
169 long.net_force,
170 short.net_force,
171 conflict,
172 fallback_timer_count,
173 scored.len()
174 );
175
176 Some(SdTimerSignal {
177 symbol: symbol.to_string(),
178 as_of,
179 point_count: scored.len(),
180 fallback_timer_count,
181 short,
182 long,
183 entry_label,
184 entry_days,
185 entry_date: entry_days.map(date_after_trading_days),
186 entry_force,
187 exit_label,
188 exit_days,
189 exit_date: exit_days.map(date_after_trading_days),
190 exit_force,
191 conflict,
192 explanation,
193 })
194}
195
196fn score_point(point: &SdTimerPoint) -> Option<ScoredPoint> {
197 let score = point.score.filter(|v| v.is_finite())?;
198 let horizon_months = finite_positive(point.horizon_months)?;
199 let synthetic_bar_days = point
200 .synthetic_bar_days
201 .filter(|v| v.is_finite() && *v > 0.0)
202 .unwrap_or(horizon_months * 21.0 / 8.0);
203 let tick_minutes = synthetic_bar_days * 390.0;
204 let group_c = point.group_c.unwrap_or(0);
205 let expected = point
206 .expected_squeeze_ticks
207 .filter(|v| v.is_finite() && *v > 0.0)
208 .unwrap_or_else(|| fallback_expected_ticks(group_c));
209 let fallback_timer = point.expected_squeeze_ticks.is_none();
210 let elapsed = point
211 .elapsed_squeeze_ticks
212 .filter(|v| v.is_finite() && *v >= 0.0)
213 .unwrap_or_else(|| fallback_elapsed_ticks(group_c, expected));
214 let remaining = point
215 .remaining_squeeze_ticks
216 .filter(|v| v.is_finite() && *v >= 0.0)
217 .unwrap_or_else(|| {
218 if group_c > 0 {
219 (expected - elapsed).max(0.0)
220 } else {
221 0.0
222 }
223 });
224 let direction = (2.0 * score - 1.0).clamp(-1.0, 1.0);
225 let intensity = squeeze_intensity(group_c);
226 let prob = point.probability.max(0.0);
227 let maturity = if expected > 0.0 {
228 (elapsed / expected).clamp(0.0, 1.25)
229 } else {
230 0.0
231 };
232 let time_decay = 1.0 / (1.0 + remaining).sqrt();
233 let maturity_weight = 0.75 + 0.35 * maturity.min(1.0);
234 let force = prob * direction.abs() * (0.30 + 0.70 * intensity) * maturity_weight * time_decay;
235 let peak_days = remaining * tick_minutes / 390.0;
236
237 Some(ScoredPoint {
238 horizon_months,
239 score,
240 direction,
241 group_c,
242 squeeze_code: squeeze_code(group_c).to_string(),
243 tick_minutes,
244 expected_ticks: expected,
245 elapsed_ticks: elapsed,
246 remaining_ticks: remaining,
247 fallback_timer,
248 force,
249 peak_days,
250 })
251}
252
253fn summarize_window(points: &[ScoredPoint]) -> SdTimerWindow {
254 if points.is_empty() {
255 return SdTimerWindow::default();
256 }
257 let mut positive_force = 0.0;
258 let mut negative_force = 0.0;
259 let mut positive_day_num = 0.0;
260 let mut negative_day_num = 0.0;
261 let mut positive_day_den = 0.0;
262 let mut negative_day_den = 0.0;
263 let mut positive_extreme: Option<&ScoredPoint> = None;
264 let mut negative_extreme: Option<&ScoredPoint> = None;
265 let mut squeeze_points = Vec::new();
266
267 for point in points {
268 if point.direction >= 0.0 {
269 positive_force += point.force;
270 positive_day_num += point.peak_days * point.force;
271 positive_day_den += point.force;
272 } else {
273 negative_force += point.force;
274 negative_day_num += point.peak_days * point.force;
275 negative_day_den += point.force;
276 }
277 if point.group_c > 0 {
278 squeeze_points.push(point);
279 }
280 if positive_extreme
281 .map(|p| point.score > p.score)
282 .unwrap_or(point.direction >= 0.0)
283 {
284 positive_extreme = Some(point);
285 }
286 if negative_extreme
287 .map(|p| point.score < p.score)
288 .unwrap_or(point.direction < 0.0)
289 {
290 negative_extreme = Some(point);
291 }
292 }
293
294 SdTimerWindow {
295 net_force: positive_force - negative_force,
296 positive_force,
297 negative_force,
298 positive_peak_days: (positive_day_den > 0.0).then_some(positive_day_num / positive_day_den),
299 negative_peak_days: (negative_day_den > 0.0).then_some(negative_day_num / negative_day_den),
300 most_positive_timeframe_minutes: positive_extreme.map(|p| p.tick_minutes),
301 most_positive_score: positive_extreme.map(|p| p.score),
302 most_negative_timeframe_minutes: negative_extreme.map(|p| p.tick_minutes),
303 most_negative_score: negative_extreme.map(|p| p.score),
304 squeeze_count: squeeze_points.len(),
305 squeeze_ranges: compress_squeeze_ranges(&squeeze_points),
306 }
307}
308
309fn compress_squeeze_ranges(points: &[&ScoredPoint]) -> Vec<String> {
310 if points.is_empty() {
311 return Vec::new();
312 }
313 let mut sorted = points.to_vec();
314 sorted.sort_by(|a, b| a.horizon_months.total_cmp(&b.horizon_months));
315 let mut ranges: Vec<Vec<&ScoredPoint>> = Vec::new();
316 for point in sorted {
317 if let Some(last) = ranges.last_mut() {
318 let prev = last.last().copied().unwrap();
319 if point.horizon_months - prev.horizon_months <= 0.130
320 && point.squeeze_code == prev.squeeze_code
321 && point.direction.signum() == prev.direction.signum()
322 {
323 last.push(point);
324 continue;
325 }
326 }
327 ranges.push(vec![point]);
328 }
329
330 ranges
331 .into_iter()
332 .map(|range| {
333 let first = range.first().copied().unwrap();
334 let last = range.last().copied().unwrap();
335 let avg_score = range.iter().map(|p| p.score).sum::<f64>() / range.len() as f64;
336 let h = if (first.horizon_months - last.horizon_months).abs() < 1e-9 {
337 format!("{:.3}m", first.horizon_months)
338 } else {
339 format!("{:.3}-{:.3}m", first.horizon_months, last.horizon_months)
340 };
341 let minutes = if (first.tick_minutes - last.tick_minutes).abs() < 1e-6 {
342 format!("{:.0} min/tick", first.tick_minutes)
343 } else {
344 format!("{:.0}-{:.0} min/tick", first.tick_minutes, last.tick_minutes)
345 };
346 let avg_expected = range.iter().map(|p| p.expected_ticks).sum::<f64>() / range.len() as f64;
347 let avg_elapsed = range.iter().map(|p| p.elapsed_ticks).sum::<f64>() / range.len() as f64;
348 let avg_remaining = range.iter().map(|p| p.remaining_ticks).sum::<f64>() / range.len() as f64;
349 format!(
350 "{h}, {minutes}, {}, avg score {:.4}, avg length {:.1} ticks, elapsed {:.1}, remaining {:.1}, {} pts",
351 first.squeeze_code,
352 avg_score,
353 avg_expected,
354 avg_elapsed,
355 avg_remaining,
356 range.len()
357 )
358 })
359 .take(8)
360 .collect()
361}
362
363fn fallback_expected_ticks(group_c: u8) -> f64 {
364 match group_c {
365 1 | 4 => 8.0,
366 2 | 3 => 7.0,
367 5 | 7 => 6.0,
368 6 | 8 | 9 => 5.0,
369 _ => 4.0,
370 }
371}
372
373fn fallback_elapsed_ticks(group_c: u8, expected: f64) -> f64 {
374 let frac = match group_c {
375 1 | 5 | 8 => 0.25,
376 2 | 6 => 0.45,
377 3 | 7 | 9 => 0.65,
378 4 => 0.85,
379 _ => 0.0,
380 };
381 expected * frac
382}
383
384fn squeeze_intensity(group_c: u8) -> f64 {
385 match group_c {
386 1 | 4 => 1.00,
387 2 | 3 | 5 | 7 => 0.78,
388 6 | 8 | 9 => 0.58,
389 _ => 0.20,
390 }
391}
392
393fn squeeze_code(group_c: u8) -> &'static str {
394 match group_c {
395 1 => "***",
396 2 => "**.",
397 3 => "*..",
398 4 => "...",
399 5 => "**",
400 6 => "*.",
401 7 => "..",
402 8 => "*",
403 9 => ".",
404 _ => "none",
405 }
406}
407
408fn finite_positive(value: f64) -> Option<f64> {
409 (value.is_finite() && value > 0.0).then_some(value)
410}
411
412fn date_after_trading_days(days: f64) -> String {
413 let calendar_days = (days.max(0.0) * 7.0 / 5.0).round() as i64;
414 (Utc::now().date_naive() + Duration::days(calendar_days)).to_string()
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420
421 #[test]
422 fn timer_waits_when_short_negative_and_long_positive() {
423 let points = vec![
424 SdTimerPoint {
425 horizon_months: 1.0,
426 probability: 0.30,
427 score: Some(0.25),
428 group_c: Some(4),
429 synthetic_bar_days: Some(2.625),
430 expected_squeeze_ticks: Some(6.0),
431 elapsed_squeeze_ticks: Some(2.0),
432 remaining_squeeze_ticks: Some(4.0),
433 ..SdTimerPoint::default()
434 },
435 SdTimerPoint {
436 horizon_months: 13.0,
437 probability: 0.70,
438 score: Some(0.80),
439 group_c: Some(1),
440 synthetic_bar_days: Some(34.125),
441 expected_squeeze_ticks: Some(8.0),
442 elapsed_squeeze_ticks: Some(1.0),
443 remaining_squeeze_ticks: Some(7.0),
444 ..SdTimerPoint::default()
445 },
446 ];
447 let signal = compute_sd_timer("TST", &points).unwrap();
448 assert_eq!(signal.entry_label, "Wait for short-term squeeze to calm");
449 assert!(signal.entry_days.unwrap() > 0.0);
450 }
451}