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}