Skip to main content

portfolio_analyzer/
sd_timer.rs

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}