Skip to main content

sandbox_quant/
strategy_catalog.rs

1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone)]
4pub struct StrategyLifecycleRow {
5    pub created_at_ms: i64,
6    pub total_running_ms: u64,
7}
8
9fn now_ms() -> i64 {
10    chrono::Utc::now().timestamp_millis()
11}
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct StrategyProfile {
15    pub label: String,
16    pub source_tag: String,
17    #[serde(default)]
18    pub symbol: String,
19    #[serde(default = "now_ms")]
20    pub created_at_ms: i64,
21    #[serde(default)]
22    pub cumulative_running_ms: u64,
23    #[serde(default)]
24    pub last_started_at_ms: Option<i64>,
25    pub fast_period: usize,
26    pub slow_period: usize,
27    pub min_ticks_between_signals: u64,
28}
29
30impl StrategyProfile {
31    pub fn periods_tuple(&self) -> (usize, usize, u64) {
32        (
33            self.fast_period,
34            self.slow_period,
35            self.min_ticks_between_signals,
36        )
37    }
38}
39
40#[derive(Debug, Clone)]
41pub struct StrategyCatalog {
42    profiles: Vec<StrategyProfile>,
43    next_custom_id: u32,
44}
45
46impl StrategyCatalog {
47    pub fn is_custom_source_tag(source_tag: &str) -> bool {
48        let Some(id) = source_tag.strip_prefix('c') else {
49            return false;
50        };
51        !id.is_empty() && id.chars().all(|ch| ch.is_ascii_digit())
52    }
53
54    pub fn new(
55        default_symbol: &str,
56        config_fast: usize,
57        config_slow: usize,
58        min_ticks_between_signals: u64,
59    ) -> Self {
60        let symbol = default_symbol.trim().to_ascii_uppercase();
61        Self {
62            profiles: vec![
63                StrategyProfile {
64                    label: "MA(Config)".to_string(),
65                    source_tag: "cfg".to_string(),
66                    symbol: symbol.clone(),
67                    created_at_ms: now_ms(),
68                    cumulative_running_ms: 0,
69                    last_started_at_ms: None,
70                    fast_period: config_fast,
71                    slow_period: config_slow,
72                    min_ticks_between_signals,
73                },
74                StrategyProfile {
75                    label: "MA(Fast 5/20)".to_string(),
76                    source_tag: "fst".to_string(),
77                    symbol: symbol.clone(),
78                    created_at_ms: now_ms(),
79                    cumulative_running_ms: 0,
80                    last_started_at_ms: None,
81                    fast_period: 5,
82                    slow_period: 20,
83                    min_ticks_between_signals,
84                },
85                StrategyProfile {
86                    label: "MA(Slow 20/60)".to_string(),
87                    source_tag: "slw".to_string(),
88                    symbol,
89                    created_at_ms: now_ms(),
90                    cumulative_running_ms: 0,
91                    last_started_at_ms: None,
92                    fast_period: 20,
93                    slow_period: 60,
94                    min_ticks_between_signals,
95                },
96            ],
97            next_custom_id: 1,
98        }
99    }
100
101    pub fn labels(&self) -> Vec<String> {
102        self.profiles.iter().map(|p| p.label.clone()).collect()
103    }
104
105    pub fn symbols(&self) -> Vec<String> {
106        self.profiles.iter().map(|p| p.symbol.clone()).collect()
107    }
108
109    pub fn lifecycle_rows(&self, now_ms: i64) -> Vec<StrategyLifecycleRow> {
110        self.profiles
111            .iter()
112            .map(|profile| {
113                let running_delta = profile
114                    .last_started_at_ms
115                    .map(|started| now_ms.saturating_sub(started).max(0) as u64)
116                    .unwrap_or(0);
117                StrategyLifecycleRow {
118                    created_at_ms: profile.created_at_ms,
119                    total_running_ms: profile.cumulative_running_ms.saturating_add(running_delta),
120                }
121            })
122            .collect()
123    }
124
125    pub fn len(&self) -> usize {
126        self.profiles.len()
127    }
128
129    pub fn get(&self, index: usize) -> Option<&StrategyProfile> {
130        self.profiles.get(index)
131    }
132
133    pub fn get_by_source_tag(&self, source_tag: &str) -> Option<&StrategyProfile> {
134        self.profiles.iter().find(|p| p.source_tag == source_tag)
135    }
136
137    pub fn mark_running(&mut self, source_tag: &str, now_ms: i64) -> bool {
138        let Some(profile) = self.profiles.iter_mut().find(|p| p.source_tag == source_tag) else {
139            return false;
140        };
141        if profile.last_started_at_ms.is_none() {
142            profile.last_started_at_ms = Some(now_ms);
143        }
144        true
145    }
146
147    pub fn mark_stopped(&mut self, source_tag: &str, now_ms: i64) -> bool {
148        let Some(profile) = self.profiles.iter_mut().find(|p| p.source_tag == source_tag) else {
149            return false;
150        };
151        if let Some(started) = profile.last_started_at_ms.take() {
152            let delta = now_ms.saturating_sub(started).max(0) as u64;
153            profile.cumulative_running_ms = profile.cumulative_running_ms.saturating_add(delta);
154        }
155        true
156    }
157
158    pub fn stop_all_running(&mut self, now_ms: i64) {
159        for profile in &mut self.profiles {
160            if let Some(started) = profile.last_started_at_ms.take() {
161                let delta = now_ms.saturating_sub(started).max(0) as u64;
162                profile.cumulative_running_ms = profile.cumulative_running_ms.saturating_add(delta);
163            }
164        }
165    }
166
167    pub fn profiles(&self) -> &[StrategyProfile] {
168        &self.profiles
169    }
170
171    pub fn index_of_label(&self, label: &str) -> Option<usize> {
172        self.profiles.iter().position(|p| p.label == label)
173    }
174
175    pub fn from_profiles(
176        mut profiles: Vec<StrategyProfile>,
177        default_symbol: &str,
178        config_fast: usize,
179        config_slow: usize,
180        min_ticks_between_signals: u64,
181    ) -> Self {
182        if profiles.is_empty() {
183            return Self::new(
184                default_symbol,
185                config_fast,
186                config_slow,
187                min_ticks_between_signals,
188            );
189        }
190        for profile in &mut profiles {
191            if profile.symbol.trim().is_empty() {
192                profile.symbol = default_symbol.trim().to_ascii_uppercase();
193            }
194            if profile.created_at_ms <= 0 {
195                profile.created_at_ms = now_ms();
196            }
197        }
198        let next_custom_id = profiles
199            .iter()
200            .filter_map(|profile| {
201                let tag = profile.source_tag.strip_prefix('c')?;
202                tag.parse::<u32>().ok()
203            })
204            .max()
205            .map(|id| id + 1)
206            .unwrap_or(1);
207        Self {
208            profiles,
209            next_custom_id,
210        }
211    }
212
213    pub fn add_custom_from_index(&mut self, base_index: usize) -> StrategyProfile {
214        let base = self
215            .profiles
216            .get(base_index)
217            .cloned()
218            .unwrap_or_else(|| self.profiles[0].clone());
219        self.new_custom_profile(
220            &base.symbol,
221            base.fast_period,
222            base.slow_period,
223            base.min_ticks_between_signals,
224        )
225    }
226
227    pub fn fork_profile(
228        &mut self,
229        index: usize,
230        symbol: &str,
231        fast_period: usize,
232        slow_period: usize,
233        min_ticks_between_signals: u64,
234    ) -> Option<StrategyProfile> {
235        self.profiles.get(index)?;
236        Some(self.new_custom_profile(
237            symbol,
238            fast_period,
239            slow_period,
240            min_ticks_between_signals,
241        ))
242    }
243
244    pub fn remove_custom_profile(&mut self, index: usize) -> Option<StrategyProfile> {
245        let profile = self.profiles.get(index)?;
246        if !Self::is_custom_source_tag(&profile.source_tag) {
247            return None;
248        }
249        Some(self.profiles.remove(index))
250    }
251
252    fn new_custom_profile(
253        &mut self,
254        symbol: &str,
255        fast_period: usize,
256        slow_period: usize,
257        min_ticks_between_signals: u64,
258    ) -> StrategyProfile {
259        let tag = format!("c{:02}", self.next_custom_id);
260        self.next_custom_id += 1;
261        let fast = fast_period.max(2);
262        let slow = slow_period.max(fast + 1);
263        let profile = StrategyProfile {
264            label: format!("MA(Custom {}/{}) [{}]", fast, slow, tag),
265            source_tag: tag,
266            symbol: symbol.trim().to_ascii_uppercase(),
267            created_at_ms: now_ms(),
268            cumulative_running_ms: 0,
269            last_started_at_ms: None,
270            fast_period: fast,
271            slow_period: slow,
272            min_ticks_between_signals: min_ticks_between_signals.max(1),
273        };
274        self.profiles.push(profile.clone());
275        profile
276    }
277}